@frontera-sdk/workflow 1.50.62-next.0

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,355 @@
1
+ import type { FronteraClient } from '@frontera-sdk/core/client'
2
+ import { FronteraError } from '@frontera-sdk/core/errors'
3
+ import type { RequestOptions } from '@frontera-sdk/core/transport'
4
+
5
+ import type { WorkflowDefinition } from './definition'
6
+ import {
7
+ isTerminalRunStatus,
8
+ type AgentBinding,
9
+ type BindAgentInput,
10
+ type CancelResult,
11
+ type CreateWorkflowInput,
12
+ type DeletedResult,
13
+ type EnabledResult,
14
+ type LayoutResult,
15
+ type NodeOutput,
16
+ type NodeTestResult,
17
+ type PromoteResult,
18
+ type PushResult,
19
+ type RenameResult,
20
+ type RunListFilters,
21
+ type RunMetrics,
22
+ type RunMetricsQuery,
23
+ type RunRequest,
24
+ type RunResult,
25
+ type RunSummary,
26
+ type RunTree,
27
+ type StepTest,
28
+ type VersionLineage,
29
+ type VersionSummary,
30
+ type WaitForRunOptions,
31
+ type WebhookTestWindow,
32
+ type Workflow,
33
+ type WorkflowBinding,
34
+ type WorkflowDeleteResult,
35
+ type WorkflowDraft,
36
+ type WorkflowLayout,
37
+ type WorkflowSummary,
38
+ type WorkflowVersion,
39
+ type WorkspaceRunListFilters,
40
+ } from './types'
41
+
42
+ const ORG_KEY_MESSAGE =
43
+ 'Workflows are closed to organization keys. Use a workspace key (sk-ws-…) for @frontera-sdk/workflow; no capability on an organization key opens this route.'
44
+
45
+ /**
46
+ * Typed client for `/v1/workflows`.
47
+ *
48
+ * Every method is workspace-scoped by the credential on the underlying
49
+ * `FronteraClient`. Organization keys are refused by the service for this
50
+ * whole route family, and that refusal is reworded here because the generic
51
+ * "Insufficient permissions" sends a caller to look for a grant that does not
52
+ * exist.
53
+ */
54
+ export class WorkflowClient {
55
+ constructor(private readonly client: FronteraClient) {}
56
+
57
+ private async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
58
+ try {
59
+ return await this.client.request<T>(path, options)
60
+ } catch (err) {
61
+ if (
62
+ err instanceof FronteraError &&
63
+ (err.details as { detail?: string } | undefined)?.detail === 'org-key-route-closed'
64
+ ) {
65
+ throw new FronteraError(ORG_KEY_MESSAGE, { code: 'FORBIDDEN', status: 403, details: err.details })
66
+ }
67
+ throw err
68
+ }
69
+ }
70
+
71
+ list(): Promise<WorkflowSummary[]> {
72
+ return this.request<WorkflowSummary[]>('/v1/workflows')
73
+ }
74
+
75
+ get(slug: string): Promise<WorkflowSummary> {
76
+ return this.request<WorkflowSummary>(`/v1/workflows/${encodeURIComponent(slug)}`)
77
+ }
78
+
79
+ /** An empty workflow with the slug you choose and no version yet. */
80
+ create(input: CreateWorkflowInput): Promise<Workflow> {
81
+ return this.request<Workflow>('/v1/workflows', { method: 'POST', body: input })
82
+ }
83
+
84
+ /**
85
+ * A workflow named the way a person would name it, with an empty draft
86
+ * already on it. The slug is derived from the name and stays put afterwards.
87
+ */
88
+ createDraft(displayName: string): Promise<WorkflowDraft> {
89
+ return this.request<WorkflowDraft>('/v1/workflows/drafts', { method: 'POST', body: { displayName } })
90
+ }
91
+
92
+ /**
93
+ * The kill switch. It stops cron and the production webhook URL; manual,
94
+ * agent and test runs go on ignoring it.
95
+ */
96
+ setEnabled(slug: string, enabled: boolean): Promise<EnabledResult> {
97
+ return this.request<EnabledResult>(`/v1/workflows/${encodeURIComponent(slug)}`, {
98
+ method: 'PATCH',
99
+ body: { enabled },
100
+ })
101
+ }
102
+
103
+ rename(slug: string, displayName: string): Promise<RenameResult> {
104
+ return this.request<RenameResult>(`/v1/workflows/${encodeURIComponent(slug)}/name`, {
105
+ method: 'PATCH',
106
+ body: { displayName },
107
+ })
108
+ }
109
+
110
+ /** Refused while any agent is bound or any run is still going. */
111
+ remove(slug: string): Promise<WorkflowDeleteResult> {
112
+ return this.request<WorkflowDeleteResult>(`/v1/workflows/${encodeURIComponent(slug)}`, { method: 'DELETE' })
113
+ }
114
+
115
+ /**
116
+ * The version list, narrowed to listing fields. The route answers with whole
117
+ * version rows, definition included, for every version; `version(slug, n)`
118
+ * reads one in full.
119
+ */
120
+ async versions(slug: string): Promise<VersionSummary[]> {
121
+ const rows = await this.request<WorkflowVersion[]>(`/v1/workflows/${encodeURIComponent(slug)}/versions`)
122
+ return rows.map((v) => ({
123
+ id: v.id,
124
+ number: v.number,
125
+ status: v.status,
126
+ createdAt: v.createdAt,
127
+ createdVia: v.createdVia ?? null,
128
+ }))
129
+ }
130
+
131
+ version(slug: string, number: number): Promise<WorkflowVersion> {
132
+ return this.request<WorkflowVersion>(
133
+ `/v1/workflows/${encodeURIComponent(slug)}/versions/${number}`,
134
+ )
135
+ }
136
+
137
+ /**
138
+ * Push a definition as a new draft, or with `dryRun` validate it and write
139
+ * nothing. The definition is the raw body; there is no `{ definition }`
140
+ * wrapper.
141
+ */
142
+ push(
143
+ slug: string,
144
+ definition: WorkflowDefinition,
145
+ opts: { dryRun?: boolean } = {},
146
+ ): Promise<PushResult> {
147
+ return this.request<PushResult>(`/v1/workflows/${encodeURIComponent(slug)}/versions`, {
148
+ method: 'PUT',
149
+ body: definition,
150
+ query: opts.dryRun ? { dryRun: true } : undefined,
151
+ })
152
+ }
153
+
154
+ /**
155
+ * Rewrite a draft in place rather than adding a version. Same body as
156
+ * `push`, and the same refusal: only a draft can be overwritten.
157
+ */
158
+ overwriteDraft(
159
+ slug: string,
160
+ number: number,
161
+ definition: WorkflowDefinition,
162
+ ): Promise<PushResult> {
163
+ return this.request<PushResult>(`/v1/workflows/${encodeURIComponent(slug)}/versions/${number}`, {
164
+ method: 'PUT',
165
+ body: definition,
166
+ })
167
+ }
168
+
169
+ deleteVersion(slug: string, number: number): Promise<DeletedResult> {
170
+ return this.request<DeletedResult>(`/v1/workflows/${encodeURIComponent(slug)}/versions/${number}`, {
171
+ method: 'DELETE',
172
+ })
173
+ }
174
+
175
+ /** A new draft carrying that version's definition and its arrangement. */
176
+ copyVersion(slug: string, number: number): Promise<PushResult> {
177
+ return this.request<PushResult>(`/v1/workflows/${encodeURIComponent(slug)}/versions/${number}/copy`, {
178
+ method: 'POST',
179
+ })
180
+ }
181
+
182
+ /** Where the cards sit, and only that. No version is written. */
183
+ saveLayout(slug: string, number: number, layout: WorkflowLayout): Promise<LayoutResult> {
184
+ return this.request<LayoutResult>(`/v1/workflows/${encodeURIComponent(slug)}/versions/${number}/layout`, {
185
+ method: 'PATCH',
186
+ body: { layout },
187
+ })
188
+ }
189
+
190
+ /** Run ONE step of a draft against its newest test data, synchronously. */
191
+ testNode(slug: string, number: number, nodeId: string): Promise<NodeTestResult> {
192
+ return this.request<NodeTestResult>(
193
+ `/v1/workflows/${encodeURIComponent(slug)}/versions/${number}/nodes/${encodeURIComponent(nodeId)}/test`,
194
+ { method: 'POST' },
195
+ )
196
+ }
197
+
198
+ stepTests(slug: string, number: number): Promise<StepTest[]> {
199
+ return this.request<StepTest[]>(`/v1/workflows/${encodeURIComponent(slug)}/versions/${number}/step-tests`)
200
+ }
201
+
202
+ /** Arm the draft's test webhook URL for one delivery. Calling again re-arms it. */
203
+ listenForWebhookTest(slug: string, number: number): Promise<WebhookTestWindow> {
204
+ return this.request<WebhookTestWindow>(
205
+ `/v1/workflows/${encodeURIComponent(slug)}/versions/${number}/listen`,
206
+ { method: 'POST' },
207
+ )
208
+ }
209
+
210
+ promote(slug: string, number: number): Promise<PromoteResult> {
211
+ return this.request<PromoteResult>(
212
+ `/v1/workflows/${encodeURIComponent(slug)}/versions/${number}/promote`,
213
+ { method: 'POST' },
214
+ )
215
+ }
216
+
217
+ lineage(slug: string, number: number): Promise<VersionLineage> {
218
+ return this.request<VersionLineage>(
219
+ `/v1/workflows/${encodeURIComponent(slug)}/versions/${number}/lineage`,
220
+ )
221
+ }
222
+
223
+ /** A `File` in `request.input` makes this multipart; see `runBody`. */
224
+ run(slug: string, request: RunRequest = {}): Promise<RunResult> {
225
+ return this.request<RunResult>(`/v1/workflows/${encodeURIComponent(slug)}/run`, {
226
+ method: 'POST',
227
+ body: runBody(request),
228
+ })
229
+ }
230
+
231
+ runs(slug: string, filters: RunListFilters = {}): Promise<RunSummary[]> {
232
+ return this.request<RunSummary[]>(`/v1/workflows/${encodeURIComponent(slug)}/runs`, {
233
+ query: filters,
234
+ })
235
+ }
236
+
237
+ /** Every run in the workspace, or one workflow's with `filters.workflow`. */
238
+ workspaceRuns(filters: WorkspaceRunListFilters = {}): Promise<RunSummary[]> {
239
+ return this.request<RunSummary[]>('/v1/workflows/runs', { query: filters })
240
+ }
241
+
242
+ runMetrics(query: RunMetricsQuery = {}): Promise<RunMetrics> {
243
+ return this.request<RunMetrics>('/v1/workflows/runs/metrics', { query })
244
+ }
245
+
246
+ /** A step output the interpreter stored by reference, rather than inline. */
247
+ nodeOutput(nodeCallId: string): Promise<NodeOutput> {
248
+ return this.request<NodeOutput>(`/v1/workflows/runs/nodes/${encodeURIComponent(nodeCallId)}/output`)
249
+ }
250
+
251
+ /** Every agent that may see or run this workflow. */
252
+ bindings(slug: string): Promise<WorkflowBinding[]> {
253
+ return this.request<WorkflowBinding[]>(`/v1/workflows/${encodeURIComponent(slug)}/bindings`)
254
+ }
255
+
256
+ /** Create or update one binding. Omitted fields keep what the row holds. */
257
+ bindAgent(slug: string, agentSlug: string, input: BindAgentInput = {}): Promise<WorkflowBinding> {
258
+ return this.request<WorkflowBinding>(
259
+ `/v1/workflows/${encodeURIComponent(slug)}/bindings/${encodeURIComponent(agentSlug)}`,
260
+ { method: 'PUT', body: input },
261
+ )
262
+ }
263
+
264
+ unbindAgent(slug: string, agentSlug: string): Promise<DeletedResult> {
265
+ return this.request<DeletedResult>(
266
+ `/v1/workflows/${encodeURIComponent(slug)}/bindings/${encodeURIComponent(agentSlug)}`,
267
+ { method: 'DELETE' },
268
+ )
269
+ }
270
+
271
+ /** The other direction: every workflow one agent may see or run. */
272
+ agentBindings(agentSlug: string): Promise<AgentBinding[]> {
273
+ return this.request<AgentBinding[]>(`/v1/workflows/agent-bindings/${encodeURIComponent(agentSlug)}`)
274
+ }
275
+
276
+ /** One run and, with `depth: 'all'`, the child runs and effects beneath it. */
277
+ runById(id: string, depth: 'run' | 'all' = 'run'): Promise<RunTree> {
278
+ return this.request<RunTree>(`/v1/workflows/runs/${encodeURIComponent(id)}`, { query: { depth } })
279
+ }
280
+
281
+ cancel(id: string): Promise<CancelResult> {
282
+ return this.request<CancelResult>(`/v1/workflows/runs/${encodeURIComponent(id)}/cancel`, {
283
+ method: 'POST',
284
+ })
285
+ }
286
+
287
+ /**
288
+ * Poll until the run reaches a terminal status and return that tree. On
289
+ * timeout the run is left as it is; cancelling is the caller's decision.
290
+ */
291
+ async waitForRun(id: string, options: WaitForRunOptions = {}): Promise<RunTree> {
292
+ const { intervalMs = 1500, timeoutMs = 10 * 60_000, signal, depth = 'run', onPoll } = options
293
+ const deadline = Date.now() + timeoutMs
294
+ for (;;) {
295
+ throwIfAborted(signal)
296
+ const run = await this.runById(id, depth)
297
+ onPoll?.(run)
298
+ if (isTerminalRunStatus(run.status)) return run
299
+ if (Date.now() >= deadline) {
300
+ throw new FronteraError(
301
+ `Workflow run ${id} did not settle within ${timeoutMs} ms (last status: ${run.status})`,
302
+ { code: 'RUN_WAIT_TIMEOUT', details: { runId: id, lastStatus: run.status } },
303
+ )
304
+ }
305
+ await sleep(Math.min(intervalMs, deadline - Date.now()), signal)
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * The run body, JSON unless a `file` input carries an actual `File`.
312
+ *
313
+ * A `file` input cannot travel as JSON, so one turns the whole request
314
+ * multipart: the non-file fields go in an `input` part as JSON and each file
315
+ * gets a part named after its field, which is the shape the service's
316
+ * `readMultipartRun` expects. `subject` and `version` are serialised because a
317
+ * multipart part is text — the route parses both back. The content type is
318
+ * left alone deliberately: only `FormData` can generate the boundary, and
319
+ * naming the type here would lose it.
320
+ */
321
+ function runBody(request: RunRequest): RunRequest | FormData {
322
+ const input = request.input ?? {}
323
+ const files = Object.entries(input).filter((entry): entry is [string, File] => entry[1] instanceof File)
324
+ if (files.length === 0) return request
325
+
326
+ const form = new FormData()
327
+ const rest = Object.fromEntries(Object.entries(input).filter(([, value]) => !(value instanceof File)))
328
+ form.append('input', JSON.stringify(rest))
329
+ for (const [field, file] of files) form.append(field, file)
330
+ if (request.subject) form.append('subject', JSON.stringify(request.subject))
331
+ if (request.version !== undefined) form.append('version', String(request.version))
332
+ if (request.source) form.append('source', request.source)
333
+ return form
334
+ }
335
+
336
+ function throwIfAborted(signal: AbortSignal | undefined): void {
337
+ if (signal?.aborted) {
338
+ throw new FronteraError('Workflow run wait was aborted', { code: 'ABORTED', details: { reason: signal.reason } })
339
+ }
340
+ }
341
+
342
+ /** Resolves after `ms`, or as soon as `signal` aborts. */
343
+ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
344
+ return new Promise((resolve) => {
345
+ const timer = setTimeout(() => {
346
+ signal?.removeEventListener('abort', onAbort)
347
+ resolve()
348
+ }, ms)
349
+ function onAbort() {
350
+ clearTimeout(timer)
351
+ resolve()
352
+ }
353
+ signal?.addEventListener('abort', onAbort, { once: true })
354
+ })
355
+ }