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.
@@ -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
+ })
@@ -0,0 +1,182 @@
1
+ /**
2
+ * conductorClient.mjs — a minimal, dependency-free HTTP client for the
3
+ * AgentSpan / Netflix-Conductor REST API.
4
+ *
5
+ * AgentSpan server runs on Conductor (default http://localhost:6767) and
6
+ * exposes both its own `/api/agent/*` lifecycle surface and Conductor's
7
+ * `/api/workflow/*` engine surface. This client is pure + injectable (a
8
+ * `fetchImpl` is passed in) so it is fully unit-testable offline and has no
9
+ * runtime network dependency baked in — the plugin wires the real `fetch`.
10
+ *
11
+ * Auth: AgentSpan standalone auth uses X-Auth-Key / X-Auth-Secret headers
12
+ * (AGENTSPAN_AUTH_KEY / AGENTSPAN_AUTH_SECRET). Conductor OSS uses no auth by
13
+ * default. The client sends the headers only when both are provided.
14
+ */
15
+
16
+ export const DEFAULT_BASE_URL = 'http://localhost:6767'
17
+
18
+ /** Build the auth headers for a request (only when both key+secret are set). */
19
+ export function authHeaders(auth) {
20
+ const h = { 'content-type': 'application/json', accept: 'application/json' }
21
+ if (auth && auth.key && auth.secret) {
22
+ h['X-Auth-Key'] = auth.key
23
+ h['X-Auth-Secret'] = auth.secret
24
+ }
25
+ return h
26
+ }
27
+
28
+ /** Join a base URL + path safely (single slash). */
29
+ export function joinUrl(base, path) {
30
+ const b = String(base || DEFAULT_BASE_URL).replace(/\/+$/, '')
31
+ const p = String(path || '').startsWith('/') ? String(path) : `/${path}`
32
+ return `${b}${p}`
33
+ }
34
+
35
+ /** Parse a response body as JSON when possible, else return raw text. */
36
+ async function parseBody(res) {
37
+ const text = await res.text()
38
+ if (!text) return null
39
+ try { return JSON.parse(text) } catch { return text }
40
+ }
41
+
42
+ export class ConductorApiError extends Error {
43
+ constructor(status, path, body) {
44
+ super(`conductor ${status} ${path}: ${typeof body === 'string' ? body.slice(0, 300) : JSON.stringify(body)?.slice(0, 300)}`)
45
+ this.status = status
46
+ this.path = path
47
+ this.body = body
48
+ }
49
+ }
50
+
51
+ export class ConductorClient {
52
+ /**
53
+ * @param {{ baseUrl?: string, auth?: {key?:string, secret?:string}, fetchImpl: Function }} opts
54
+ * fetchImpl(url, {method, headers, body}) -> Promise<{ok,status,text:()=>Promise<string>}>
55
+ */
56
+ constructor(opts = {}) {
57
+ if (typeof opts.fetchImpl !== 'function') throw new Error('ConductorClient needs a fetchImpl')
58
+ this.baseUrl = opts.baseUrl || DEFAULT_BASE_URL
59
+ this.auth = opts.auth
60
+ this.fetchImpl = opts.fetchImpl
61
+ }
62
+
63
+ async request(method, path, body) {
64
+ const url = joinUrl(this.baseUrl, path)
65
+ const res = await this.fetchImpl(url, {
66
+ method,
67
+ headers: authHeaders(this.auth),
68
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
69
+ })
70
+ const parsed = await parseBody(res)
71
+ if (!res.ok) throw new ConductorApiError(res.status, `${method} ${path}`, parsed)
72
+ return parsed
73
+ }
74
+
75
+ // ── Health ────────────────────────────────────────────────────────────────
76
+ /** AgentSpan/Conductor health (Spring Actuator). */
77
+ async health() {
78
+ try {
79
+ const r = await this.request('GET', '/actuator/health')
80
+ return { ok: true, status: r?.status ?? 'UP', raw: r }
81
+ } catch (e) {
82
+ return { ok: false, status: 'DOWN', error: e.message }
83
+ }
84
+ }
85
+
86
+ // ── Agent lifecycle (AgentSpan /api/agent) ───────────────────────────────
87
+ /** Compile + register + start an agent from an AgentConfig; returns { executionId }. */
88
+ async runAgent(agentConfig, prompt) {
89
+ const body = prompt !== undefined ? { agent: agentConfig, input: prompt } : agentConfig
90
+ const r = await this.request('POST', '/api/agent/start', body)
91
+ return { executionId: r?.executionId ?? r?.workflowId ?? r?.id ?? null, raw: r }
92
+ }
93
+
94
+ /** Compile only (returns the WorkflowDef without executing). */
95
+ async compileAgent(agentConfig) {
96
+ return this.request('POST', '/api/agent/compile', agentConfig)
97
+ }
98
+
99
+ /** Detailed status of a running/finished execution. */
100
+ async agentStatus(executionId) {
101
+ if (!executionId) throw new Error('agentStatus needs an executionId')
102
+ return this.request('GET', `/api/agent/${encodeURIComponent(executionId)}`)
103
+ }
104
+
105
+ /** Human-in-the-loop respond: complete a paused HUMAN task + resume. */
106
+ async agentRespond(executionId, output) {
107
+ if (!executionId) throw new Error('agentRespond needs an executionId')
108
+ return this.request('POST', `/api/agent/${encodeURIComponent(executionId)}/respond`, output ?? {})
109
+ }
110
+
111
+ /** Stop (cancel) an execution. */
112
+ async agentStop(executionId) {
113
+ if (!executionId) throw new Error('agentStop needs an executionId')
114
+ return this.request('POST', `/api/agent/${encodeURIComponent(executionId)}/stop`, {})
115
+ }
116
+
117
+ /** Server-sent event log for an execution. */
118
+ async agentEvents(executionId) {
119
+ if (!executionId) throw new Error('agentEvents needs an executionId')
120
+ return this.request('GET', `/api/agent/events/${encodeURIComponent(executionId)}`)
121
+ }
122
+
123
+ /** List agent definitions registered on the server. */
124
+ async listAgentDefinitions() {
125
+ return this.request('GET', '/api/agent/definitions')
126
+ }
127
+
128
+ // ── Workflow engine (Conductor /api/workflow) ────────────────────────────
129
+ /** Start a named Conductor workflow; returns the workflowId string. */
130
+ async startWorkflow(name, input, opts = {}) {
131
+ if (!name) throw new Error('startWorkflow needs a workflow name')
132
+ const q = new URLSearchParams()
133
+ if (opts.version !== undefined) q.set('version', String(opts.version))
134
+ if (opts.correlationId) q.set('correlationId', String(opts.correlationId))
135
+ const path = `/api/workflow/${encodeURIComponent(name)}${q.size ? `?${q}` : ''}`
136
+ const r = await this.request('POST', path, input ?? {})
137
+ return typeof r === 'string' ? r : (r?.workflowId ?? r?.id ?? null)
138
+ }
139
+
140
+ /** Get a workflow execution (status + tasks). */
141
+ async getWorkflow(workflowId, includeTasks = true) {
142
+ if (!workflowId) throw new Error('getWorkflow needs a workflowId')
143
+ return this.request('GET', `/api/workflow/${encodeURIComponent(workflowId)}?includeTasks=${includeTasks}`)
144
+ }
145
+
146
+ /** Terminate a workflow execution. */
147
+ async terminateWorkflow(workflowId, reason) {
148
+ if (!workflowId) throw new Error('terminateWorkflow needs a workflowId')
149
+ const q = reason ? `?reason=${encodeURIComponent(reason)}` : ''
150
+ return this.request('DELETE', `/api/workflow/${encodeURIComponent(workflowId)}${q}`)
151
+ }
152
+
153
+ /** Retry a failed workflow from the last failed task (durable resume). */
154
+ async retryWorkflow(workflowId) {
155
+ if (!workflowId) throw new Error('retryWorkflow needs a workflowId')
156
+ return this.request('POST', `/api/workflow/${encodeURIComponent(workflowId)}/retry`, {})
157
+ }
158
+
159
+ /** Search workflow executions (freeText query, e.g. status:FAILED). */
160
+ async searchWorkflows(query = '*', size = 20) {
161
+ const q = new URLSearchParams({ freeText: query, size: String(size), sort: 'startTime:DESC' })
162
+ return this.request('GET', `/api/workflow/search?${q}`)
163
+ }
164
+
165
+ // ── Metadata (WorkflowDef registration) ──────────────────────────────────
166
+ /** Register (create/update) a WorkflowDef on the server (POST /api/metadata/workflow).
167
+ * Accepts a single def or an array (Conductor accepts a JSON array of defs). */
168
+ async registerWorkflowDef(defOrDefs) {
169
+ if (!defOrDefs) throw new Error('registerWorkflowDef needs a WorkflowDef')
170
+ const body = Array.isArray(defOrDefs) ? defOrDefs : [defOrDefs]
171
+ return this.request('POST', '/api/metadata/workflow', body)
172
+ }
173
+
174
+ /** Get a registered WorkflowDef by name (+ optional version). */
175
+ async getWorkflowDef(name, version) {
176
+ if (!name) throw new Error('getWorkflowDef needs a name')
177
+ const q = version !== undefined ? `?version=${version}` : ''
178
+ return this.request('GET', `/api/metadata/workflow/${encodeURIComponent(name)}${q}`)
179
+ }
180
+ }
181
+
182
+ export default ConductorClient