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 +16 -0
- package/package.json +6 -2
- 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/agentspan-bridge/conductorClient.mjs +182 -0
- package/plugins/agentspan-bridge/index.mjs +389 -0
- package/plugins/agentspan-bridge/playbookToWorkflowDef.mjs +209 -0
- package/plugins/agentspan-bridge/plugin.json +27 -0
|
@@ -0,0 +1,389 @@
|
|
|
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
|
+
import { playbookToWorkflowDef } from './playbookToWorkflowDef.mjs'
|
|
25
|
+
|
|
26
|
+
// ─── config resolution ──────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** Read the agentspan config block from RTerm settings (ctx.settings) or env. */
|
|
29
|
+
export function resolveConfig(ctx = {}, env = process.env) {
|
|
30
|
+
const s = (typeof ctx.getSettings === 'function' ? ctx.getSettings() : ctx.settings) || {}
|
|
31
|
+
const block = s.agentspan || {}
|
|
32
|
+
return {
|
|
33
|
+
serverUrl: (block.serverUrl || env.AGENTSPAN_SERVER_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''),
|
|
34
|
+
authSecretRef: block.authSecretRef || env.AGENTSPAN_AUTH_SECRET_REF || undefined,
|
|
35
|
+
enabled: block.enabled !== false,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Parse a vault "KEY=VAL" blob into an {key, secret} auth object. */
|
|
40
|
+
export function parseAuthBlob(blob) {
|
|
41
|
+
if (!blob || typeof blob !== 'string') return undefined
|
|
42
|
+
const out = {}
|
|
43
|
+
for (const line of blob.split(/\r?\n/)) {
|
|
44
|
+
const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
|
|
45
|
+
if (m) out[m[1]] = m[2].trim()
|
|
46
|
+
}
|
|
47
|
+
const key = out.AGENTSPAN_AUTH_KEY || out.AUTH_KEY || out.KEY
|
|
48
|
+
const secret = out.AGENTSPAN_AUTH_SECRET || out.AUTH_SECRET || out.SECRET
|
|
49
|
+
return key && secret ? { key, secret } : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Real fetch adapter (matches conductorClient's expected {ok,status,text}). */
|
|
53
|
+
async function realFetch(url, init) {
|
|
54
|
+
const res = await fetch(url, { method: init.method, headers: init.headers, body: init.body })
|
|
55
|
+
return { ok: res.ok, status: res.status, text: () => res.text() }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build a configured ConductorClient from ctx (settings + vault). */
|
|
59
|
+
export function buildClient(ctx = {}, fetchImpl = realFetch) {
|
|
60
|
+
const cfg = resolveConfig(ctx)
|
|
61
|
+
let auth
|
|
62
|
+
if (cfg.authSecretRef && typeof ctx.getSecret === 'function') {
|
|
63
|
+
try {
|
|
64
|
+
const blob = ctx.getSecret(cfg.authSecretRef)
|
|
65
|
+
auth = parseAuthBlob(blob)
|
|
66
|
+
} catch { auth = undefined }
|
|
67
|
+
}
|
|
68
|
+
return { client: new ConductorClient({ baseUrl: cfg.serverUrl, auth, fetchImpl }), config: cfg }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── formatting helpers (pure) ─────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** Normalize an execution/workflow status payload into a compact summary. */
|
|
74
|
+
export function summarizeStatus(payload) {
|
|
75
|
+
if (!payload || typeof payload !== 'object') return { status: 'UNKNOWN' }
|
|
76
|
+
const status = payload.status ?? payload.workflowStatus ?? payload.state ?? 'UNKNOWN'
|
|
77
|
+
const name = payload.workflowName ?? payload.name ?? payload.agentName ?? payload.workflowType
|
|
78
|
+
const id = payload.workflowId ?? payload.executionId ?? payload.id
|
|
79
|
+
const tasks = Array.isArray(payload.tasks) ? payload.tasks : []
|
|
80
|
+
const failed = tasks.filter((t) => String(t.status).toUpperCase().includes('FAIL')).length
|
|
81
|
+
const done = tasks.filter((t) => String(t.status).toUpperCase() === 'COMPLETED').length
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
name,
|
|
85
|
+
status: String(status).toUpperCase(),
|
|
86
|
+
taskCount: tasks.length,
|
|
87
|
+
completedTasks: done,
|
|
88
|
+
failedTasks: failed,
|
|
89
|
+
startTime: payload.startTime ?? payload.createTime,
|
|
90
|
+
endTime: payload.endTime ?? payload.updateTime,
|
|
91
|
+
reason: payload.reasonForIncompletion ?? payload.failedReason,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Normalize a search/list result into rows for the panel + agentspan_list. */
|
|
96
|
+
export function toExecutionRows(payload) {
|
|
97
|
+
const results = payload?.results ?? payload?.workflows ?? (Array.isArray(payload) ? payload : [])
|
|
98
|
+
if (!Array.isArray(results)) return []
|
|
99
|
+
return results.map((w) => ({
|
|
100
|
+
id: w.workflowId ?? w.executionId ?? w.id,
|
|
101
|
+
name: w.workflowName ?? w.name ?? w.workflowType,
|
|
102
|
+
status: String(w.status ?? w.workflowStatus ?? '').toUpperCase(),
|
|
103
|
+
startTime: w.startTime ?? w.createTime,
|
|
104
|
+
endTime: w.endTime ?? w.updateTime,
|
|
105
|
+
}))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Map an execution status to a trigger event (fires on FAILED). */
|
|
109
|
+
export function isFailedExecution(status) {
|
|
110
|
+
const s = String(status || '').toUpperCase()
|
|
111
|
+
return s === 'FAILED' || s === 'TERMINATED' || s === 'TIMED_OUT'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── unreachable-server helper ─────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async function guarded(fn, log) {
|
|
117
|
+
try {
|
|
118
|
+
return await fn()
|
|
119
|
+
} catch (e) {
|
|
120
|
+
const msg = e?.message ?? String(e)
|
|
121
|
+
log?.(`[agentspan] ${msg}`)
|
|
122
|
+
return { error: msg, hint: 'Is the AgentSpan server running? (agentspan server start, default http://localhost:6767). Configure the URL in Settings → agentspan.' }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── playbook resolution (from RTerm AutomationManager / settings) ─────────
|
|
127
|
+
|
|
128
|
+
/** Find a named RTerm playbook from the plugin context (AutomationManager or
|
|
129
|
+
* settings.automation.playbooks), by id or name. */
|
|
130
|
+
export function findPlaybook(ctx = {}, nameOrId) {
|
|
131
|
+
if (!nameOrId) return undefined
|
|
132
|
+
const want = String(nameOrId)
|
|
133
|
+
// 1) AutomationManager (runtime handle)
|
|
134
|
+
try {
|
|
135
|
+
const am = ctx.automationManager
|
|
136
|
+
if (am) {
|
|
137
|
+
if (typeof am.getPlaybook === 'function') {
|
|
138
|
+
const p = am.getPlaybook(want)
|
|
139
|
+
if (p) return p
|
|
140
|
+
}
|
|
141
|
+
if (typeof am.listPlaybooks === 'function') {
|
|
142
|
+
const found = (am.listPlaybooks() || []).find((x) => x.id === want || x.name === want)
|
|
143
|
+
if (found) return found
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch { /* fall through to settings */ }
|
|
147
|
+
// 2) settings.automation.playbooks
|
|
148
|
+
try {
|
|
149
|
+
const s = (typeof ctx.getSettings === 'function' ? ctx.getSettings() : ctx.settings) || {}
|
|
150
|
+
const list = s.automation?.playbooks || []
|
|
151
|
+
return list.find((x) => x.id === want || x.name === want)
|
|
152
|
+
} catch {
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Build a default durable AgentConfig for `agentspan_delegate` (a simple
|
|
158
|
+
* tool-using ReAct agent compiled + started on the server). */
|
|
159
|
+
export function buildDelegateAgentConfig(name, prompt, opts = {}) {
|
|
160
|
+
return {
|
|
161
|
+
name: name || 'rterm_delegate',
|
|
162
|
+
model: opts.model || 'openai/gpt-4o',
|
|
163
|
+
instructions: opts.instructions || 'You are a durable RTerm delegate agent. Complete the task, calling tools as needed.',
|
|
164
|
+
input: prompt,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── plugin entry ───────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
export function register(ctx) {
|
|
171
|
+
const { registerTool, registerTrigger, registerPanel, log } = ctx
|
|
172
|
+
const { client, config } = buildClient(ctx)
|
|
173
|
+
|
|
174
|
+
// Tool: agentspan_health — check the server is reachable + its status.
|
|
175
|
+
registerTool({
|
|
176
|
+
name: 'agentspan_health',
|
|
177
|
+
description: 'Check whether the AgentSpan/Conductor server is reachable and healthy. Returns the server URL, health status, and whether auth is configured.',
|
|
178
|
+
params: {},
|
|
179
|
+
handler: async () => guarded(async () => {
|
|
180
|
+
const h = await client.health()
|
|
181
|
+
if (!h.ok) {
|
|
182
|
+
return {
|
|
183
|
+
serverUrl: config.serverUrl,
|
|
184
|
+
authConfigured: Boolean(config.authSecretRef),
|
|
185
|
+
...h,
|
|
186
|
+
hint: 'Is the AgentSpan server running? (agentspan server start, default http://localhost:6767). Configure the URL in Settings → agentspan.',
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { serverUrl: config.serverUrl, authConfigured: Boolean(config.authSecretRef), ...h }
|
|
190
|
+
}, log),
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Tool: agentspan_run — run a durable agent (AgentConfig) or named workflow.
|
|
194
|
+
registerTool({
|
|
195
|
+
name: 'agentspan_run',
|
|
196
|
+
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.',
|
|
197
|
+
params: {
|
|
198
|
+
agentConfig: { type: 'object', description: 'An AgentConfig JSON (agent tree) to compile+run durably', optional: true },
|
|
199
|
+
workflow: { type: 'string', description: 'Name of a registered Conductor workflow to start', optional: true },
|
|
200
|
+
input: { type: 'object', description: 'Input payload for the agent/workflow', optional: true },
|
|
201
|
+
prompt: { type: 'string', description: 'Natural-language prompt for the agent', optional: true },
|
|
202
|
+
},
|
|
203
|
+
handler: async (p) => guarded(async () => {
|
|
204
|
+
if (p?.agentConfig) {
|
|
205
|
+
const r = await client.runAgent(p.agentConfig, p.prompt ?? p.input)
|
|
206
|
+
return { kind: 'agent', executionId: r.executionId, serverUrl: config.serverUrl, uiUrl: joinUrl(config.serverUrl, `/execution/${r.executionId}`), raw: r.raw }
|
|
207
|
+
}
|
|
208
|
+
if (p?.workflow) {
|
|
209
|
+
const id = await client.startWorkflow(p.workflow, p.input ?? {})
|
|
210
|
+
return { kind: 'workflow', workflowId: id, serverUrl: config.serverUrl, uiUrl: joinUrl(config.serverUrl, `/execution/${id}`) }
|
|
211
|
+
}
|
|
212
|
+
return { error: 'agentspan_run needs either agentConfig or workflow' }
|
|
213
|
+
}, log),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Tool: agentspan_status — detailed status of an execution/workflow.
|
|
217
|
+
registerTool({
|
|
218
|
+
name: 'agentspan_status',
|
|
219
|
+
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.',
|
|
220
|
+
params: { executionId: { type: 'string', description: 'The executionId or workflowId' } },
|
|
221
|
+
handler: async (p) => guarded(async () => {
|
|
222
|
+
if (!p?.executionId) return { error: 'agentspan_status needs executionId' }
|
|
223
|
+
// Try the agent lifecycle surface first, fall back to the workflow engine.
|
|
224
|
+
try {
|
|
225
|
+
const a = await client.agentStatus(p.executionId)
|
|
226
|
+
return { kind: 'agent', ...summarizeStatus(a), raw: a }
|
|
227
|
+
} catch {
|
|
228
|
+
const w = await client.getWorkflow(p.executionId)
|
|
229
|
+
return { kind: 'workflow', ...summarizeStatus(w) }
|
|
230
|
+
}
|
|
231
|
+
}, log),
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// Tool: agentspan_approve — human-in-the-loop respond to a paused HUMAN task.
|
|
235
|
+
registerTool({
|
|
236
|
+
name: 'agentspan_approve',
|
|
237
|
+
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:"..."}).',
|
|
238
|
+
params: {
|
|
239
|
+
executionId: { type: 'string', description: 'The executionId with a paused HUMAN task' },
|
|
240
|
+
output: { type: 'object', description: 'The response output map (e.g. {approved:true})' },
|
|
241
|
+
},
|
|
242
|
+
handler: async (p) => guarded(async () => {
|
|
243
|
+
if (!p?.executionId) return { error: 'agentspan_approve needs executionId' }
|
|
244
|
+
await client.agentRespond(p.executionId, p.output ?? {})
|
|
245
|
+
const s = await client.agentStatus(p.executionId).catch(() => null)
|
|
246
|
+
return { responded: true, executionId: p.executionId, ...(s ? summarizeStatus(s) : {}) }
|
|
247
|
+
}, log),
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// Tool: agentspan_list — list recent executions (optionally filtered).
|
|
251
|
+
registerTool({
|
|
252
|
+
name: 'agentspan_list',
|
|
253
|
+
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.',
|
|
254
|
+
params: {
|
|
255
|
+
query: { type: 'string', description: 'Conductor freeText query (default *)', optional: true },
|
|
256
|
+
size: { type: 'number', description: 'Max results (default 20)', optional: true },
|
|
257
|
+
},
|
|
258
|
+
handler: async (p) => guarded(async () => {
|
|
259
|
+
const r = await client.searchWorkflows(p?.query ?? '*', p?.size ?? 20)
|
|
260
|
+
const rows = toExecutionRows(r)
|
|
261
|
+
return { count: rows.length, executions: rows }
|
|
262
|
+
}, log),
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// Tool: agentspan_stop — terminate a running execution.
|
|
266
|
+
registerTool({
|
|
267
|
+
name: 'agentspan_stop',
|
|
268
|
+
description: 'Stop (terminate) a running durable execution by id. Optionally pass a `reason`.',
|
|
269
|
+
params: {
|
|
270
|
+
executionId: { type: 'string', description: 'The executionId/workflowId to stop' },
|
|
271
|
+
reason: { type: 'string', description: 'Optional termination reason', optional: true },
|
|
272
|
+
},
|
|
273
|
+
handler: async (p) => guarded(async () => {
|
|
274
|
+
if (!p?.executionId) return { error: 'agentspan_stop needs executionId' }
|
|
275
|
+
try {
|
|
276
|
+
await client.agentStop(p.executionId)
|
|
277
|
+
} catch {
|
|
278
|
+
await client.terminateWorkflow(p.executionId, p.reason)
|
|
279
|
+
}
|
|
280
|
+
return { stopped: true, executionId: p.executionId }
|
|
281
|
+
}, log),
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// Tool: agentspan_export_playbook — map a named RTerm playbook to a Conductor
|
|
285
|
+
// WorkflowDef (returns the def; does NOT register it). Pure/dry-run preview.
|
|
286
|
+
registerTool({
|
|
287
|
+
name: 'agentspan_export_playbook',
|
|
288
|
+
description: 'Map an RTerm playbook (by id or name) to a Conductor WorkflowDef and return it — a dry-run preview of what agentspan_register_playbook would register. command steps → HTTP run_command tasks, script steps → script-reference tasks, wait steps → WAIT tasks; sequential + dependsOn DAG + retries + rollback compensating tasks.',
|
|
289
|
+
params: {
|
|
290
|
+
playbook: { type: 'string', description: 'RTerm playbook id or name' },
|
|
291
|
+
execUri: { type: 'string', description: 'Override the RTerm exec URI tasks call (default from settings)', optional: true },
|
|
292
|
+
},
|
|
293
|
+
handler: async (p) => guarded(async () => {
|
|
294
|
+
const pb = findPlaybook(ctx, p?.playbook)
|
|
295
|
+
if (!pb) return { error: `playbook not found: ${p?.playbook ?? '(none given)'}` }
|
|
296
|
+
const execUri = p?.execUri || `${config.gatewayUrl ?? 'http://localhost:17888'}/rpc/exec`
|
|
297
|
+
const def = playbookToWorkflowDef(pb, { execUri })
|
|
298
|
+
return { name: def.name, taskCount: def.tasks.length, def }
|
|
299
|
+
}, log),
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// Tool: agentspan_register_playbook — register an RTerm playbook on the
|
|
303
|
+
// AgentSpan server as a Conductor WorkflowDef (so agentspan_run {workflow:<name>}
|
|
304
|
+
// and AgentSpan SUB_WORKFLOW steps can invoke it).
|
|
305
|
+
registerTool({
|
|
306
|
+
name: 'agentspan_register_playbook',
|
|
307
|
+
description: 'Export an RTerm playbook to a Conductor WorkflowDef AND register it on the AgentSpan server (POST /api/metadata/workflow), so it can be run durably via agentspan_run {workflow:<name>} or invoked by AgentSpan agents as a SUB_WORKFLOW step.',
|
|
308
|
+
params: {
|
|
309
|
+
playbook: { type: 'string', description: 'RTerm playbook id or name' },
|
|
310
|
+
execUri: { type: 'string', description: 'Override the RTerm exec URI tasks call (default from settings)', optional: true },
|
|
311
|
+
},
|
|
312
|
+
handler: async (p) => guarded(async () => {
|
|
313
|
+
const pb = findPlaybook(ctx, p?.playbook)
|
|
314
|
+
if (!pb) return { error: `playbook not found: ${p?.playbook ?? '(none given)'}` }
|
|
315
|
+
const execUri = p?.execUri || `${config.gatewayUrl ?? 'http://localhost:17888'}/rpc/exec`
|
|
316
|
+
const def = playbookToWorkflowDef(pb, { execUri })
|
|
317
|
+
await client.registerWorkflowDef(def)
|
|
318
|
+
return {
|
|
319
|
+
registered: true,
|
|
320
|
+
name: def.name,
|
|
321
|
+
taskCount: def.tasks.length,
|
|
322
|
+
runWith: { tool: 'agentspan_run', args: { workflow: def.name } },
|
|
323
|
+
uiUrl: joinUrl(config.serverUrl, `/workflowDef/${def.name}`),
|
|
324
|
+
}
|
|
325
|
+
}, log),
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Tool: agentspan_delegate — run a prompt/task as a durable AgentSpan agent
|
|
329
|
+
// (AgentConfig), returning an executionId that survives RTerm/host restart.
|
|
330
|
+
registerTool({
|
|
331
|
+
name: 'agentspan_delegate',
|
|
332
|
+
description: 'Delegate a long-running task to AgentSpan: runs the prompt as a durable agent on the Conductor server (compiles + starts an AgentConfig) and returns an executionId that survives restarts. Follow up with agentspan_status / agentspan_approve / agentspan_stop.',
|
|
333
|
+
params: {
|
|
334
|
+
prompt: { type: 'string', description: 'The task/prompt for the durable agent' },
|
|
335
|
+
name: { type: 'string', description: 'Agent name (default rterm_delegate)', optional: true },
|
|
336
|
+
model: { type: 'string', description: 'Model id (default openai/gpt-4o)', optional: true },
|
|
337
|
+
instructions: { type: 'string', description: 'Override system instructions', optional: true },
|
|
338
|
+
},
|
|
339
|
+
handler: async (p) => guarded(async () => {
|
|
340
|
+
if (!p?.prompt) return { error: 'agentspan_delegate needs a prompt' }
|
|
341
|
+
const cfg = buildDelegateAgentConfig(p.name, p.prompt, { model: p.model, instructions: p.instructions })
|
|
342
|
+
const r = await client.runAgent(cfg)
|
|
343
|
+
return {
|
|
344
|
+
delegated: true,
|
|
345
|
+
executionId: r.executionId,
|
|
346
|
+
serverUrl: config.serverUrl,
|
|
347
|
+
uiUrl: joinUrl(config.serverUrl, `/execution/${r.executionId}`),
|
|
348
|
+
followUp: { status: 'agentspan_status', approve: 'agentspan_approve', stop: 'agentspan_stop' },
|
|
349
|
+
}
|
|
350
|
+
}, log),
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// Trigger: agentspan_execution_failed — fires when a durable execution fails.
|
|
354
|
+
registerTrigger({
|
|
355
|
+
name: 'agentspan_execution_failed',
|
|
356
|
+
description: 'Fires when an AgentSpan/Conductor durable execution transitions to FAILED/TERMINATED/TIMED_OUT. Use for auto-remediation or re-run playbooks.',
|
|
357
|
+
match: (event) => {
|
|
358
|
+
if (event?.source !== 'agentspan') return false
|
|
359
|
+
return isFailedExecution(event.status)
|
|
360
|
+
},
|
|
361
|
+
action: 'propose-change',
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
// Panel: agentspan-executions — live feed of durable executions.
|
|
365
|
+
registerPanel({
|
|
366
|
+
name: 'agentspan-executions',
|
|
367
|
+
title: 'AgentSpan Executions',
|
|
368
|
+
render: (data) => {
|
|
369
|
+
const rows = (Array.isArray(data) ? data : []).map((e) =>
|
|
370
|
+
`<tr><td>${e.name ?? ''}</td><td>${e.id ?? ''}</td><td>${e.status ?? ''}</td><td>${e.startTime ?? ''}</td></tr>`
|
|
371
|
+
).join('')
|
|
372
|
+
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>`
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
log(`[agentspan] agentspan-bridge registered: 6 tools, 1 trigger, 1 panel (server=${config.serverUrl})`)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export default {
|
|
380
|
+
register,
|
|
381
|
+
resolveConfig,
|
|
382
|
+
parseAuthBlob,
|
|
383
|
+
buildClient,
|
|
384
|
+
summarizeStatus,
|
|
385
|
+
toExecutionRows,
|
|
386
|
+
isFailedExecution,
|
|
387
|
+
findPlaybook,
|
|
388
|
+
buildDelegateAgentConfig,
|
|
389
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* playbookToWorkflowDef.mjs — map an RTerm playbook (PlaybookEntry) to a
|
|
3
|
+
* Netflix-Conductor WorkflowDef so AgentSpan agents can invoke it as a step
|
|
4
|
+
* (SUB_WORKFLOW) or RTerm can register + run it durably on the Conductor server.
|
|
5
|
+
*
|
|
6
|
+
* Pure mapping (no I/O):
|
|
7
|
+
* - command step → a run_command-style HTTP task carrying the command + target
|
|
8
|
+
* - script step → an HTTP task that references the saved script id (the
|
|
9
|
+
* Conductor worker resolves the script body by id)
|
|
10
|
+
* - wait step → a Conductor WAIT task (durationSeconds)
|
|
11
|
+
* - sequential + dependsOn DAG ordering → Conductor task `taskReferenceName`
|
|
12
|
+
* graph with JOIN on multiple dependencies
|
|
13
|
+
* - retries (onError=continue → retry, else fail-fast) → Conductor `retryCount`
|
|
14
|
+
* - rollback steps → compensating tasks appended after the main flow
|
|
15
|
+
*
|
|
16
|
+
* The emitted WorkflowDef is valid for `POST /api/metadata/workflow` on any
|
|
17
|
+
* Conductor OSS / AgentSpan server.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Sanitize a string into a Conductor-safe taskReferenceName ([A-Za-z0-9_]). */
|
|
21
|
+
export function taskRef(id, fallback) {
|
|
22
|
+
const s = String(id ?? fallback ?? 'step').replace(/[^A-Za-z0-9_]/g, '_')
|
|
23
|
+
return s.length > 0 ? s : 'step'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Map a single RTerm playbook step to a Conductor task (no edges yet). */
|
|
27
|
+
export function stepToTask(step, index, opts = {}) {
|
|
28
|
+
const ref = taskRef(step.id ?? `step_${index}`)
|
|
29
|
+
const name = step.name || ref
|
|
30
|
+
const base = {
|
|
31
|
+
name,
|
|
32
|
+
taskReferenceName: ref,
|
|
33
|
+
retryCount: step.onError === 'continue' ? (opts.continueRetryCount ?? 0) : 0,
|
|
34
|
+
startDelay: 0,
|
|
35
|
+
optional: false,
|
|
36
|
+
asyncComplete: false,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (step.kind === 'wait') {
|
|
40
|
+
return {
|
|
41
|
+
...base,
|
|
42
|
+
type: 'WAIT',
|
|
43
|
+
inputParameters: { duration: step.waitSeconds ?? 0 },
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (step.kind === 'script') {
|
|
48
|
+
// Reference the saved script by id; the Conductor-side worker resolves the
|
|
49
|
+
// script body (kept out of the def so it stays compact + secret-free).
|
|
50
|
+
return {
|
|
51
|
+
...base,
|
|
52
|
+
type: 'SIMPLE',
|
|
53
|
+
inputParameters: {
|
|
54
|
+
kind: 'rterm_script',
|
|
55
|
+
scriptId: step.scriptId,
|
|
56
|
+
name,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// kind === 'command' (default): an HTTP task that invokes RTerm's command
|
|
62
|
+
// execution surface with the command + target scope.
|
|
63
|
+
return {
|
|
64
|
+
...base,
|
|
65
|
+
type: 'HTTP',
|
|
66
|
+
inputParameters: {
|
|
67
|
+
http_request: {
|
|
68
|
+
uri: opts.execUri ?? '${workflow.input.rtermExecUri}',
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'content-type': 'application/json' },
|
|
71
|
+
body: {
|
|
72
|
+
kind: 'run_command',
|
|
73
|
+
command: step.command ?? '',
|
|
74
|
+
...(step.validate ? { validate: step.validate } : {}),
|
|
75
|
+
},
|
|
76
|
+
connectionTimeOut: opts.connectionTimeOut ?? 5000,
|
|
77
|
+
readTimeOut: opts.readTimeOut ?? 300000,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Map a rollback action to a compensating Conductor task. */
|
|
84
|
+
export function rollbackToTask(rollback, stepRef, index) {
|
|
85
|
+
const ref = taskRef(`rollback_${stepRef}_${index}`)
|
|
86
|
+
if (rollback.kind === 'script') {
|
|
87
|
+
return {
|
|
88
|
+
name: ref,
|
|
89
|
+
taskReferenceName: ref,
|
|
90
|
+
type: 'SIMPLE',
|
|
91
|
+
inputParameters: { kind: 'rterm_script', scriptId: rollback.scriptId, compensating: true },
|
|
92
|
+
retryCount: 0,
|
|
93
|
+
optional: true, // a failing rollback never blocks the workflow result
|
|
94
|
+
asyncComplete: false,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
name: ref,
|
|
99
|
+
taskReferenceName: ref,
|
|
100
|
+
type: 'HTTP',
|
|
101
|
+
inputParameters: {
|
|
102
|
+
http_request: {
|
|
103
|
+
uri: '${workflow.input.rtermExecUri}',
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
body: { kind: 'run_command', command: rollback.command ?? '', compensating: true },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
retryCount: 0,
|
|
110
|
+
optional: true,
|
|
111
|
+
asyncComplete: false,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the ordered task list with DAG edges from dependsOn.
|
|
117
|
+
* Returns { tasks, order } where order is the topological order of step refs.
|
|
118
|
+
* Steps with no dependsOn depend on the previous step (linear chain). Steps
|
|
119
|
+
* with multiple dependsOn get a JOIN task.
|
|
120
|
+
*/
|
|
121
|
+
export function buildDag(steps) {
|
|
122
|
+
const refs = steps.map((s, i) => taskRef(s.id ?? `step_${i}`))
|
|
123
|
+
const tasks = []
|
|
124
|
+
const joins = []
|
|
125
|
+
steps.forEach((s, i) => {
|
|
126
|
+
const ref = refs[i]
|
|
127
|
+
const deps = Array.isArray(s.dependsOn) && s.dependsOn.length > 0
|
|
128
|
+
? s.dependsOn.map((d) => taskRef(d))
|
|
129
|
+
: (i === 0 ? [] : [refs[i - 1]])
|
|
130
|
+
if (deps.length > 1) {
|
|
131
|
+
const joinRef = taskRef(`join_${ref}`)
|
|
132
|
+
joins.push({ ref: joinRef, type: 'JOIN', joinOn: deps, taskReferenceName: joinRef, name: joinRef })
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
return { refs, joins }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Map a full RTerm playbook to a Conductor WorkflowDef.
|
|
140
|
+
* @param {object} playbook PlaybookEntry
|
|
141
|
+
* @param {object} [opts] { execUri, connectionTimeOut, readTimeOut, continueRetryCount }
|
|
142
|
+
* @returns {object} a Conductor WorkflowDef (POST /api/metadata/workflow)
|
|
143
|
+
*/
|
|
144
|
+
export function playbookToWorkflowDef(playbook, opts = {}) {
|
|
145
|
+
if (!playbook || !Array.isArray(playbook.steps)) {
|
|
146
|
+
throw new Error('playbookToWorkflowDef needs a playbook with a steps array')
|
|
147
|
+
}
|
|
148
|
+
const name = String(playbook.name || playbook.id || 'rterm_playbook').replace(/\s+/g, '_')
|
|
149
|
+
const steps = playbook.steps
|
|
150
|
+
const tasks = []
|
|
151
|
+
const rollbackTasks = []
|
|
152
|
+
|
|
153
|
+
// Main flow: map each step, then wire sequential/DAG ordering by emitting
|
|
154
|
+
// tasks in dependency order (Conductor executes top-down; dependsOn edges are
|
|
155
|
+
// expressed via JOINs for fan-in).
|
|
156
|
+
const refs = steps.map((s, i) => taskRef(s.id ?? `step_${i}`))
|
|
157
|
+
const emittedJoins = new Set()
|
|
158
|
+
|
|
159
|
+
steps.forEach((step, i) => {
|
|
160
|
+
const ref = refs[i]
|
|
161
|
+
const task = stepToTask(step, i, opts)
|
|
162
|
+
// Wire dependency: Conductor's default is sequential task order, but we
|
|
163
|
+
// explicitly model dependsOn fan-in as a JOIN before this task when the
|
|
164
|
+
// step has multiple dependencies.
|
|
165
|
+
const deps = Array.isArray(step.dependsOn) && step.dependsOn.length > 0
|
|
166
|
+
? step.dependsOn.map((d) => taskRef(d))
|
|
167
|
+
: []
|
|
168
|
+
if (deps.length > 1 && !emittedJoins.has(ref)) {
|
|
169
|
+
const joinRef = taskRef(`join_${ref}`)
|
|
170
|
+
tasks.push({
|
|
171
|
+
name: joinRef,
|
|
172
|
+
taskReferenceName: joinRef,
|
|
173
|
+
type: 'JOIN',
|
|
174
|
+
joinOn: deps,
|
|
175
|
+
})
|
|
176
|
+
emittedJoins.add(ref)
|
|
177
|
+
}
|
|
178
|
+
tasks.push(task)
|
|
179
|
+
if (step.rollback) {
|
|
180
|
+
rollbackTasks.push(rollbackToTask(step.rollback, ref, rollbackTasks.length))
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Compensating (rollback) tasks run after the main flow, in reverse step
|
|
185
|
+
// order (undo the most recent change first) — marked optional so they never
|
|
186
|
+
// mask the workflow's real result.
|
|
187
|
+
const orderedRollback = [...rollbackTasks].reverse()
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
name,
|
|
191
|
+
description: playbook.description || `RTerm playbook: ${playbook.name ?? name}`,
|
|
192
|
+
version: 1,
|
|
193
|
+
tasks: [...tasks, ...orderedRollback],
|
|
194
|
+
inputParameters: [],
|
|
195
|
+
outputParameters: {},
|
|
196
|
+
schemaVersion: 2,
|
|
197
|
+
restartable: true,
|
|
198
|
+
workflowStatusListenerEnabled: false,
|
|
199
|
+
ownerEmail: 'rterm@hyperspace.ng',
|
|
200
|
+
timeoutPolicy: 'ALERT_ONLY',
|
|
201
|
+
timeoutSeconds: 0,
|
|
202
|
+
variables: {},
|
|
203
|
+
inputTemplate: {
|
|
204
|
+
rtermExecUri: opts.execUri ?? 'http://localhost:17888/rpc/exec',
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export default { playbookToWorkflowDef, stepToTask, rollbackToTask, taskRef, buildDag }
|
|
@@ -0,0 +1,27 @@
|
|
|
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_health",
|
|
8
|
+
"agentspan_run",
|
|
9
|
+
"agentspan_status",
|
|
10
|
+
"agentspan_approve",
|
|
11
|
+
"agentspan_list",
|
|
12
|
+
"agentspan_stop",
|
|
13
|
+
"agentspan_export_playbook",
|
|
14
|
+
"agentspan_register_playbook",
|
|
15
|
+
"agentspan_delegate"
|
|
16
|
+
],
|
|
17
|
+
"triggers": [
|
|
18
|
+
"agentspan_execution_failed"
|
|
19
|
+
],
|
|
20
|
+
"panels": [
|
|
21
|
+
"agentspan-executions"
|
|
22
|
+
],
|
|
23
|
+
"permissions": [
|
|
24
|
+
"exec",
|
|
25
|
+
"readLedger:metrics"
|
|
26
|
+
]
|
|
27
|
+
}
|