@frontera-sdk/cli 1.50.19 → 1.50.20
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 +4 -4
- package/src/api/workflow-api.ts +272 -0
- package/src/commands/registry.ts +3 -0
- package/src/commands/workflow/cancel.ts +37 -0
- package/src/commands/workflow/get.ts +57 -0
- package/src/commands/workflow/index-commands.ts +41 -0
- package/src/commands/workflow/lineage.ts +58 -0
- package/src/commands/workflow/list.ts +58 -0
- package/src/commands/workflow/promote.ts +78 -0
- package/src/commands/workflow/push.ts +172 -0
- package/src/commands/workflow/run.ts +247 -0
- package/src/commands/workflow/runs.ts +84 -0
- package/src/flag-help.ts +14 -3
- package/src/vendor/sdk-sources.json +14 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.20",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
|
-
"@frontera-sdk/functions": "1.50.
|
|
43
|
-
"@frontera-sdk/core": "1.50.
|
|
42
|
+
"@frontera-sdk/functions": "1.50.20",
|
|
43
|
+
"@frontera-sdk/core": "1.50.20",
|
|
44
44
|
"ai": "^6.0.116",
|
|
45
45
|
"gray-matter": "^4.0.3",
|
|
46
46
|
"yaml": "^2.9.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@frontera-sdk/forge-contracts": "1.50.
|
|
49
|
+
"@frontera-sdk/forge-contracts": "1.50.20",
|
|
50
50
|
"@types/bun": "^1.3.14",
|
|
51
51
|
"typescript": "^5.9.3"
|
|
52
52
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
import { credentialFailure } from './credential-failure'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `/v1/workflows`.
|
|
6
|
+
*
|
|
7
|
+
* The routes were built for this client and shipped without it: the router's
|
|
8
|
+
* own header says "a workspace key can push from the CLI", and
|
|
9
|
+
* `pushWorkflowVersion` defaults `createdVia` to `'cli'` — a value nothing
|
|
10
|
+
* could produce. Everything here is workspace-grain; organization keys are
|
|
11
|
+
* closed at the service by decision, so there is no org lane to carry.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface Envelope<T> { error?: boolean; data?: T; message?: string }
|
|
15
|
+
|
|
16
|
+
/** A row of `GET /v1/workflows`. */
|
|
17
|
+
export interface WorkflowSummary {
|
|
18
|
+
id: string
|
|
19
|
+
slug: string
|
|
20
|
+
enabled: boolean
|
|
21
|
+
description: string | null
|
|
22
|
+
liveVersion: number | null
|
|
23
|
+
latestDraftNumber: number | null
|
|
24
|
+
versionCount: number
|
|
25
|
+
runCount: number
|
|
26
|
+
lastRunAt: string | null
|
|
27
|
+
lastRunStatus: string | null
|
|
28
|
+
triggerKinds: string[]
|
|
29
|
+
cron: string | null
|
|
30
|
+
subjectApiName: string | null
|
|
31
|
+
subjectLabel: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A row of `GET /v1/workflows/:slug/versions`. */
|
|
35
|
+
export interface VersionSummary {
|
|
36
|
+
id: string
|
|
37
|
+
number: number
|
|
38
|
+
status: string
|
|
39
|
+
createdAt: string
|
|
40
|
+
createdVia?: string | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a push answers with.
|
|
45
|
+
*
|
|
46
|
+
* `ok: false` is a RESULT, not a transport failure — but only on a dry run.
|
|
47
|
+
* A real push that fails validation comes back as a 400, so `push` and
|
|
48
|
+
* `validate` read the same errors from two different places. Modelling both
|
|
49
|
+
* here keeps that asymmetry in one file instead of in every caller.
|
|
50
|
+
*/
|
|
51
|
+
export interface PushResult {
|
|
52
|
+
ok: boolean
|
|
53
|
+
errors?: string[]
|
|
54
|
+
workflowId?: string
|
|
55
|
+
version?: { id: string; number: number; status: string }
|
|
56
|
+
grants?: string[]
|
|
57
|
+
lineage?: LineageEdge[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LineageEdge {
|
|
61
|
+
kind: string
|
|
62
|
+
resourceKind: string
|
|
63
|
+
resourceId: string
|
|
64
|
+
label?: string | null
|
|
65
|
+
property?: string | null
|
|
66
|
+
nodeId?: string | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface VersionLineage {
|
|
70
|
+
version: number
|
|
71
|
+
status: string
|
|
72
|
+
grants: string[]
|
|
73
|
+
lineage: LineageEdge[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A row of `GET /v1/workflows/:slug/runs`. */
|
|
77
|
+
export interface RunSummary {
|
|
78
|
+
id: string
|
|
79
|
+
status: string
|
|
80
|
+
versionNumber: number
|
|
81
|
+
workflowSlug: string
|
|
82
|
+
triggerSource: string
|
|
83
|
+
triggeredByName: string | null
|
|
84
|
+
subjectPk: string | null
|
|
85
|
+
subjectLabel: string | null
|
|
86
|
+
nodeCalls: number
|
|
87
|
+
startedAt: string
|
|
88
|
+
endedAt: string | null
|
|
89
|
+
error: unknown
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface RunTreeNode {
|
|
93
|
+
nodeId: string
|
|
94
|
+
nodePath: string
|
|
95
|
+
kind: string
|
|
96
|
+
status: string
|
|
97
|
+
attempt: number
|
|
98
|
+
output: unknown
|
|
99
|
+
error: unknown
|
|
100
|
+
startedAt: string
|
|
101
|
+
endedAt: string | null
|
|
102
|
+
childRun?: RunTree | null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RunTree {
|
|
106
|
+
id: string
|
|
107
|
+
slug: string
|
|
108
|
+
status: string
|
|
109
|
+
versionNumber: number | null
|
|
110
|
+
triggerSource: string
|
|
111
|
+
subject: { pk: string; apiName: string | null; label: string | null } | null
|
|
112
|
+
input: Record<string, unknown>
|
|
113
|
+
output: unknown
|
|
114
|
+
error: unknown
|
|
115
|
+
nodeCalls: number
|
|
116
|
+
startedAt: string
|
|
117
|
+
endedAt: string | null
|
|
118
|
+
nodes: RunTreeNode[]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface RunRequest {
|
|
122
|
+
input?: Record<string, unknown>
|
|
123
|
+
subject?: { pk: string }
|
|
124
|
+
version?: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class WorkflowApi {
|
|
128
|
+
constructor(
|
|
129
|
+
private readonly apiUrl: string,
|
|
130
|
+
private readonly token: string,
|
|
131
|
+
private readonly workspaceId?: string,
|
|
132
|
+
) {}
|
|
133
|
+
|
|
134
|
+
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
135
|
+
return {
|
|
136
|
+
authorization: `Bearer ${this.token}`,
|
|
137
|
+
...(this.workspaceId ? { 'x-workspace-id': this.workspaceId } : {}),
|
|
138
|
+
...extra,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async call<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
|
|
143
|
+
const response = await fetch(`${this.apiUrl}${path}`, {
|
|
144
|
+
method: init.method ?? 'GET',
|
|
145
|
+
headers: this.headers(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
146
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
147
|
+
})
|
|
148
|
+
const text = await response.text()
|
|
149
|
+
let payload: unknown
|
|
150
|
+
try { payload = text ? JSON.parse(text) : null } catch { payload = null }
|
|
151
|
+
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
const body = payload as { message?: string; code?: string; details?: unknown; detail?: string } | null
|
|
154
|
+
// The lane refusal, worded before the generic credential one gets it.
|
|
155
|
+
// `/v1/workflows` is closed to organization keys by decision, and the
|
|
156
|
+
// shared `credentialFailure` renders that 403 as "check the key carries
|
|
157
|
+
// the capability … its workspace list" — advice that cannot work, since
|
|
158
|
+
// no grant reopens a closed lane. The service names the case in
|
|
159
|
+
// `detail`; nothing read it until now.
|
|
160
|
+
if (body?.detail === 'org-key-route-closed') {
|
|
161
|
+
throw new CliError('workflows are closed to organization keys', {
|
|
162
|
+
code: 'FORBIDDEN',
|
|
163
|
+
hint: 'use a workspace key (sk-ws-) — `frontera auth list` shows which kind each profile holds. '
|
|
164
|
+
+ 'A manual run executes the workflow\'s derived grants, and which principal a machine '
|
|
165
|
+
+ 'credential runs as is still open, so no capability on an organization key opens this.',
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
const credential = credentialFailure(response.status, body?.message, { token: this.token })
|
|
169
|
+
if (credential) throw credential
|
|
170
|
+
// A rejected definition carries its reasons in `details.errors`, and they
|
|
171
|
+
// are the whole answer — a caller told only "Invalid workflow definition"
|
|
172
|
+
// has to re-push with `validate` to find out what was wrong.
|
|
173
|
+
const errors = readDefinitionErrors(body?.details)
|
|
174
|
+
if (errors) throw new CliError(body?.message ?? 'Invalid workflow definition', {
|
|
175
|
+
code: 'INVALID_DEFINITION',
|
|
176
|
+
hint: errors.join('\n '),
|
|
177
|
+
})
|
|
178
|
+
throw new CliError(body?.message ?? `${response.status} from ${path}`, {
|
|
179
|
+
code: body?.code ?? 'FAILURE',
|
|
180
|
+
...(body?.details ? { hint: JSON.stringify(body.details) } : {}),
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
const envelope = payload as Envelope<T>
|
|
184
|
+
return (envelope && envelope.error === false && 'data' in envelope
|
|
185
|
+
? envelope.data
|
|
186
|
+
: payload) as T
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
list(): Promise<WorkflowSummary[]> {
|
|
190
|
+
return this.call<WorkflowSummary[]>('/v1/workflows')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get(slug: string): Promise<WorkflowSummary> {
|
|
194
|
+
return this.call<WorkflowSummary>(`/v1/workflows/${encodeURIComponent(slug)}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The version list, narrowed to the fields the CLI shows.
|
|
199
|
+
*
|
|
200
|
+
* The route answers with whole `workflow_versions` rows — `definition`,
|
|
201
|
+
* `grants`, `lineage` and `inputSchema` for EVERY version — so returning
|
|
202
|
+
* them unchanged would make `workflow get <slug> --json` dump every
|
|
203
|
+
* definition the workflow has ever had. Projected here rather than at the
|
|
204
|
+
* render site, so the `--json` contract is the same shape the table is.
|
|
205
|
+
* `workflow lineage <slug> <n>` is the verb for reading one version's body.
|
|
206
|
+
*/
|
|
207
|
+
async versions(slug: string): Promise<VersionSummary[]> {
|
|
208
|
+
const rows = await this.call<VersionSummary[]>(`/v1/workflows/${encodeURIComponent(slug)}/versions`)
|
|
209
|
+
return rows.map((v) => ({
|
|
210
|
+
id: v.id,
|
|
211
|
+
number: v.number,
|
|
212
|
+
status: v.status,
|
|
213
|
+
createdAt: v.createdAt,
|
|
214
|
+
createdVia: v.createdVia ?? null,
|
|
215
|
+
}))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Push a definition as a new draft, or — with `dryRun` — validate it and
|
|
220
|
+
* write nothing.
|
|
221
|
+
*
|
|
222
|
+
* The definition goes up as the raw body. There is no `{ definition: … }`
|
|
223
|
+
* wrapper: the route passes `ctx.body` straight to the parser, so a wrapper
|
|
224
|
+
* would fail validation on every field at once.
|
|
225
|
+
*/
|
|
226
|
+
push(slug: string, definition: unknown, opts: { dryRun?: boolean } = {}): Promise<PushResult> {
|
|
227
|
+
const query = opts.dryRun ? '?dryRun=true' : ''
|
|
228
|
+
return this.call<PushResult>(`/v1/workflows/${encodeURIComponent(slug)}/versions${query}`, {
|
|
229
|
+
method: 'PUT',
|
|
230
|
+
body: definition,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
promote(slug: string, number: number): Promise<{ id: string; number: number; status: string }> {
|
|
235
|
+
return this.call(`/v1/workflows/${encodeURIComponent(slug)}/versions/${number}/promote`, { method: 'POST' })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
lineage(slug: string, number: number): Promise<VersionLineage> {
|
|
239
|
+
return this.call<VersionLineage>(`/v1/workflows/${encodeURIComponent(slug)}/versions/${number}/lineage`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
run(slug: string, body: RunRequest): Promise<{ runId: string; versionNumber: number; reused?: boolean }> {
|
|
243
|
+
return this.call(`/v1/workflows/${encodeURIComponent(slug)}/run`, { method: 'POST', body })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The newest runs, at the service's default page size.
|
|
248
|
+
*
|
|
249
|
+
* The route also takes `limit` and a `before` keyset cursor; neither is
|
|
250
|
+
* carried here, because no verb pages yet and a parameter no caller passes
|
|
251
|
+
* reads as a capability the CLI has. `function runs` is capped the same way.
|
|
252
|
+
*/
|
|
253
|
+
runs(slug: string): Promise<RunSummary[]> {
|
|
254
|
+
return this.call<RunSummary[]>(`/v1/workflows/${encodeURIComponent(slug)}/runs`)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One run and, with `depth: 'all'`, the child runs beneath it. */
|
|
258
|
+
runById(id: string, depth: 'run' | 'all' = 'all'): Promise<RunTree> {
|
|
259
|
+
return this.call<RunTree>(`/v1/workflows/runs/${encodeURIComponent(id)}?depth=${depth}`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
cancel(id: string): Promise<{ status: string }> {
|
|
263
|
+
return this.call(`/v1/workflows/runs/${encodeURIComponent(id)}/cancel`, { method: 'POST' })
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** `details.errors` from a 400, when the failure was the definition itself. */
|
|
268
|
+
function readDefinitionErrors(details: unknown): string[] | null {
|
|
269
|
+
const errors = (details as { errors?: unknown } | null)?.errors
|
|
270
|
+
if (!Array.isArray(errors) || errors.length === 0) return null
|
|
271
|
+
return errors.map((e) => (typeof e === 'string' ? e : JSON.stringify(e)))
|
|
272
|
+
}
|
package/src/commands/registry.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { knowledgeCommands } from './knowledge/index-commands'
|
|
|
34
34
|
import { packCommands } from './pack/index-commands'
|
|
35
35
|
import { secretCommands } from './secret/index-commands'
|
|
36
36
|
import { automationCommands } from './automation/index-commands'
|
|
37
|
+
import { workflowCommands } from './workflow/index-commands'
|
|
37
38
|
import { completionCommand } from './completion'
|
|
38
39
|
import { initCommand } from './init'
|
|
39
40
|
import { loginCommand } from './login'
|
|
@@ -98,6 +99,7 @@ export const COMMANDS: readonly Command[] = [
|
|
|
98
99
|
...packCommands,
|
|
99
100
|
...secretCommands,
|
|
100
101
|
...automationCommands,
|
|
102
|
+
...workflowCommands,
|
|
101
103
|
|
|
102
104
|
blueprintList,
|
|
103
105
|
blueprintGet,
|
|
@@ -157,6 +159,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
|
|
|
157
159
|
pack: 'Reusable skill bundles — author once, install per workspace',
|
|
158
160
|
secret: 'Workspace secrets — named here, never printed back',
|
|
159
161
|
function: 'Functions — TypeScript deployed here, run on a schedule',
|
|
162
|
+
workflow: 'Workflows — declared steps, versioned, promoted, then run',
|
|
160
163
|
blueprint: 'The shared model of the organization — what an app can read',
|
|
161
164
|
action: 'Governed Actions — arm the write path a published Action runs',
|
|
162
165
|
capability: 'What an agent may DO — plugin operations granted to it',
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { WorkflowApi } from '../../api/workflow-api'
|
|
2
|
+
import type { Command } from '../types'
|
|
3
|
+
import { requireRunId } from './run'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stop a run that is still going.
|
|
7
|
+
*
|
|
8
|
+
* Takes a run id and not a slug: cancelling "the workflow" is not a thing the
|
|
9
|
+
* engine can do — each run is cancelled on its own root id, and a slug would
|
|
10
|
+
* have to pick one silently.
|
|
11
|
+
*
|
|
12
|
+
* Cancellation propagates to child runs at the service, and a run that has
|
|
13
|
+
* already finished is a no-op rather than an error, so this is safe to call on
|
|
14
|
+
* an id read from a stale listing.
|
|
15
|
+
*/
|
|
16
|
+
export const workflowCancel: Command = {
|
|
17
|
+
meta: {
|
|
18
|
+
noun: 'workflow',
|
|
19
|
+
verb: 'cancel',
|
|
20
|
+
args: [{ name: 'runId', required: true, description: 'the run to stop' }],
|
|
21
|
+
flags: {},
|
|
22
|
+
summary: 'Stop a running workflow run, and the child runs beneath it',
|
|
23
|
+
examples: ['frontera workflow cancel 0f2c8b1e-7a41-4c96-9d3e-52b0c4a7e118'],
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async run(ctx) {
|
|
27
|
+
const runId = requireRunId(ctx.positional[0])
|
|
28
|
+
const result = await new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId).cancel(runId)
|
|
29
|
+
return {
|
|
30
|
+
data: result,
|
|
31
|
+
// The status is reported rather than assumed: a run that had already
|
|
32
|
+
// succeeded comes back `succeeded`, and printing "canceled" there would
|
|
33
|
+
// be a lie the caller acts on.
|
|
34
|
+
text: `${runId}: ${result.status}`,
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { WorkflowApi } from '../../api/workflow-api'
|
|
2
|
+
import { table } from '../../table'
|
|
3
|
+
import type { Command } from '../types'
|
|
4
|
+
import { requireSlug } from './push'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One workflow: what is live, what is drafted, and what it is bound to.
|
|
8
|
+
*
|
|
9
|
+
* The version table is here rather than under a separate `versions` verb
|
|
10
|
+
* because the two questions are never asked apart — "what is live" is only
|
|
11
|
+
* meaningful beside "what else exists", and `promote` needs a number from this
|
|
12
|
+
* list.
|
|
13
|
+
*/
|
|
14
|
+
export const workflowGet: Command = {
|
|
15
|
+
meta: {
|
|
16
|
+
noun: 'workflow',
|
|
17
|
+
verb: 'get',
|
|
18
|
+
args: [{ name: 'slug', required: true, description: 'workflow slug' }],
|
|
19
|
+
flags: {},
|
|
20
|
+
summary: 'Show one workflow, its versions and which is live',
|
|
21
|
+
examples: ['frontera workflow get invoice-intake'],
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async run(ctx) {
|
|
25
|
+
const slug = requireSlug(ctx)
|
|
26
|
+
const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
27
|
+
const [workflow, versions] = await Promise.all([client.get(slug), client.versions(slug)])
|
|
28
|
+
|
|
29
|
+
const facts = [
|
|
30
|
+
`${workflow.slug}${workflow.enabled ? '' : ' (disabled)'}`,
|
|
31
|
+
workflow.description ? ` ${workflow.description}` : '',
|
|
32
|
+
` live ${workflow.liveVersion === null ? 'nothing — no version has been promoted' : `v${workflow.liveVersion}`}`,
|
|
33
|
+
` triggers ${(workflow.triggerKinds ?? []).join(', ') || 'none'}`,
|
|
34
|
+
// Only when bound. An unbound workflow has no subject line to print, and
|
|
35
|
+
// an empty one reads as a missing value rather than an absent concept.
|
|
36
|
+
workflow.subjectApiName ? ` subject ${workflow.subjectLabel ?? workflow.subjectApiName}` : '',
|
|
37
|
+
` runs ${workflow.runCount ?? 0}${workflow.lastRunStatus ? `, last ${workflow.lastRunStatus}` : ''}`,
|
|
38
|
+
].filter(Boolean)
|
|
39
|
+
|
|
40
|
+
const versionTable = versions.length === 0
|
|
41
|
+
? ' (none — `frontera workflow push` stores the first)'
|
|
42
|
+
: table(
|
|
43
|
+
['VERSION', 'STATUS', 'PUSHED', 'VIA'],
|
|
44
|
+
versions.map((v) => [
|
|
45
|
+
`v${v.number}`,
|
|
46
|
+
v.status,
|
|
47
|
+
v.createdAt,
|
|
48
|
+
v.createdVia ?? '',
|
|
49
|
+
]),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
data: { workflow, versions },
|
|
54
|
+
text: `${facts.join('\n')}\n\nVersions\n${versionTable}`,
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { workflowCancel } from './cancel'
|
|
2
|
+
import { workflowGet } from './get'
|
|
3
|
+
import { workflowLineage } from './lineage'
|
|
4
|
+
import { workflowList } from './list'
|
|
5
|
+
import { workflowPromote } from './promote'
|
|
6
|
+
import { workflowPush, workflowValidate } from './push'
|
|
7
|
+
import { workflowRun } from './run'
|
|
8
|
+
import { workflowRuns } from './runs'
|
|
9
|
+
import type { Command } from '../types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Workflows, which had no client at all.
|
|
13
|
+
*
|
|
14
|
+
* The routes were written for this: `/v1/workflows` authenticates through the
|
|
15
|
+
* same programmatic middleware Functions use so a workspace key can push, and
|
|
16
|
+
* `pushWorkflowVersion` stamps `createdVia: 'cli'` by default — an origin no
|
|
17
|
+
* caller could produce. Until now the only way to author a workflow was the
|
|
18
|
+
* Console, which means a definition could not be reviewed in a pull request or
|
|
19
|
+
* promoted from CI.
|
|
20
|
+
*
|
|
21
|
+
* Organization keys are deliberately absent, and that is the service's
|
|
22
|
+
* decision rather than an omission here: a manual run executes the workflow's
|
|
23
|
+
* derived grants, and which principal a machine credential runs as is the same
|
|
24
|
+
* open question that keeps `POST /v1/functions/:slug/run` undeclared. A key
|
|
25
|
+
* that pushed and promoted but could not run would be the more confusing half.
|
|
26
|
+
*
|
|
27
|
+
* Bindings are absent for a different reason: an agent binding is a standing
|
|
28
|
+
* permission for a model to start runs, it gates on `edit`, and it belongs
|
|
29
|
+
* beside the agent it grants — not beside the definition it points at.
|
|
30
|
+
*/
|
|
31
|
+
export const workflowCommands: readonly Command[] = [
|
|
32
|
+
workflowList,
|
|
33
|
+
workflowGet,
|
|
34
|
+
workflowValidate,
|
|
35
|
+
workflowPush,
|
|
36
|
+
workflowPromote,
|
|
37
|
+
workflowRun,
|
|
38
|
+
workflowRuns,
|
|
39
|
+
workflowCancel,
|
|
40
|
+
workflowLineage,
|
|
41
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { WorkflowApi } from '../../api/workflow-api'
|
|
2
|
+
import { table } from '../../table'
|
|
3
|
+
import type { Command } from '../types'
|
|
4
|
+
import { requireSlug } from './push'
|
|
5
|
+
import { requireVersionNumber } from './promote'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What one version reads, writes and invokes.
|
|
9
|
+
*
|
|
10
|
+
* Derived from the definition at push time, never authored — which is exactly
|
|
11
|
+
* why it is worth printing. An author reads their own steps and sees intent;
|
|
12
|
+
* this reads the same steps and reports reach, and the two differ whenever a
|
|
13
|
+
* step touches something through a reference the author did not follow.
|
|
14
|
+
*
|
|
15
|
+
* The version is required rather than defaulting to live: "what does this
|
|
16
|
+
* touch" asked of a workflow with an unpromoted draft has two different
|
|
17
|
+
* answers, and guessing which one was meant is how a review misses a change.
|
|
18
|
+
*/
|
|
19
|
+
export const workflowLineage: Command = {
|
|
20
|
+
meta: {
|
|
21
|
+
noun: 'workflow',
|
|
22
|
+
verb: 'lineage',
|
|
23
|
+
args: [
|
|
24
|
+
{ name: 'slug', required: true, description: 'workflow slug' },
|
|
25
|
+
{ name: 'version', required: true, description: 'version number' },
|
|
26
|
+
],
|
|
27
|
+
flags: {},
|
|
28
|
+
summary: 'What one version reads, writes and invokes — and the grants it runs with',
|
|
29
|
+
examples: ['frontera workflow lineage invoice-intake 3'],
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async run(ctx) {
|
|
33
|
+
const slug = requireSlug(ctx)
|
|
34
|
+
const number = requireVersionNumber(ctx)
|
|
35
|
+
const lineage = await new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId).lineage(slug, number)
|
|
36
|
+
|
|
37
|
+
const head = `${slug} v${lineage.version} (${lineage.status})\n`
|
|
38
|
+
+ ` grants ${lineage.grants.length > 0 ? lineage.grants.join(', ') : 'none'}`
|
|
39
|
+
|
|
40
|
+
if (lineage.lineage.length === 0) {
|
|
41
|
+
return { data: lineage, text: `${head}\n\n This version touches nothing outside itself.` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
data: lineage,
|
|
46
|
+
text: `${head}\n\n${table(
|
|
47
|
+
['EDGE', 'KIND', 'RESOURCE', 'PROPERTY', 'STEP'],
|
|
48
|
+
lineage.lineage.map((e) => [
|
|
49
|
+
e.kind,
|
|
50
|
+
e.resourceKind,
|
|
51
|
+
e.label ?? e.resourceId,
|
|
52
|
+
e.property ?? '',
|
|
53
|
+
e.nodeId ?? '',
|
|
54
|
+
]),
|
|
55
|
+
)}`,
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { WorkflowApi } from '../../api/workflow-api'
|
|
2
|
+
import { table } from '../../table'
|
|
3
|
+
import type { Command } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every workflow in the workspace, and whether anything can start one.
|
|
7
|
+
*
|
|
8
|
+
* `LIVE` is the column that decides: a workflow with drafts and no live
|
|
9
|
+
* version parses, validates and never runs, and nothing else in the CLI says
|
|
10
|
+
* so. `TRIGGERS` is beside it because a live version whose only trigger is
|
|
11
|
+
* `manual` will also never fire on its own — the two facts are read together.
|
|
12
|
+
*/
|
|
13
|
+
export const workflowList: Command = {
|
|
14
|
+
meta: {
|
|
15
|
+
noun: 'workflow',
|
|
16
|
+
verb: 'list',
|
|
17
|
+
args: [],
|
|
18
|
+
flags: {},
|
|
19
|
+
summary: 'List workflows, their live version and what can start them',
|
|
20
|
+
examples: ['frontera workflow list'],
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async run(ctx) {
|
|
24
|
+
const rows = await new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId).list()
|
|
25
|
+
|
|
26
|
+
if (rows.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
data: { workflows: [] },
|
|
29
|
+
text: 'No workflows in this workspace.\n'
|
|
30
|
+
+ ' frontera workflow push <slug> <file.yaml> # to create one',
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
data: { workflows: rows },
|
|
36
|
+
text: table(
|
|
37
|
+
['SLUG', 'LIVE', 'DRAFT', 'TRIGGERS', 'RUNS', 'LAST'],
|
|
38
|
+
rows.map((r) => [
|
|
39
|
+
r.slug,
|
|
40
|
+
// A disabled workflow is skipped by the cron dispatcher
|
|
41
|
+
// (`workflow-cron.ts` filters on `enabled`), so one with a live
|
|
42
|
+
// version and a schedule still never fires. Rendered identically to
|
|
43
|
+
// an armed one, this column would answer the question wrongly.
|
|
44
|
+
// Both facts, because they are different: `v3 off` is armed-but-held,
|
|
45
|
+
// `— ` is nothing promoted. Collapsing them would lose which.
|
|
46
|
+
`${r.liveVersion === null ? '—' : `v${r.liveVersion}`}${r.enabled === false ? ' off' : ''}`,
|
|
47
|
+
r.latestDraftNumber === null ? '' : `v${r.latestDraftNumber}`,
|
|
48
|
+
// The cron expression rather than the word "cron": a schedule nobody
|
|
49
|
+
// can read is the most common reason a workflow fires at a surprising
|
|
50
|
+
// time, and it fits.
|
|
51
|
+
(r.triggerKinds ?? []).map((k) => (k === 'cron' && r.cron ? `cron(${r.cron})` : k)).join(' ') || '—',
|
|
52
|
+
String(r.runCount ?? 0),
|
|
53
|
+
r.lastRunStatus ?? '',
|
|
54
|
+
]),
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { WorkflowApi } from '../../api/workflow-api'
|
|
2
|
+
import { UsageError } from '../../errors'
|
|
3
|
+
import type { Command, CommandContext } from '../types'
|
|
4
|
+
import { describeReach, requireSlug } from './push'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `<n>`, rejected locally when it is not a version number.
|
|
8
|
+
*
|
|
9
|
+
* Forwarded as typed, `promote wf abc` reaches a route declared `t.Numeric()`
|
|
10
|
+
* and comes back as a schema violation naming a parameter the author never
|
|
11
|
+
* saw. The word they typed is the answer, so it is named here.
|
|
12
|
+
*/
|
|
13
|
+
export function requireVersionNumber(ctx: CommandContext): number {
|
|
14
|
+
const raw = ctx.positional[1]
|
|
15
|
+
if (!raw) {
|
|
16
|
+
throw new UsageError('missing <version>', 'frontera workflow get <slug> lists the versions')
|
|
17
|
+
}
|
|
18
|
+
const parsed = Number(raw)
|
|
19
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
20
|
+
throw new UsageError(`"${raw}" is not a version number`, 'versions are whole numbers from 1')
|
|
21
|
+
}
|
|
22
|
+
return parsed
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Make one version the live one.
|
|
27
|
+
*
|
|
28
|
+
* The verb that arms a definition, and the only one that changes what a
|
|
29
|
+
* trigger or an agent binding will execute. Separate from `push` on purpose:
|
|
30
|
+
* the draft an author is iterating on and the version their organization runs
|
|
31
|
+
* are not the same act, and collapsing them would make every save a deploy.
|
|
32
|
+
*/
|
|
33
|
+
export const workflowPromote: Command = {
|
|
34
|
+
meta: {
|
|
35
|
+
noun: 'workflow',
|
|
36
|
+
verb: 'promote',
|
|
37
|
+
args: [
|
|
38
|
+
{ name: 'slug', required: true, description: 'workflow slug' },
|
|
39
|
+
{ name: 'version', required: true, description: 'version number to make live' },
|
|
40
|
+
],
|
|
41
|
+
flags: {},
|
|
42
|
+
summary: 'Make one version live — what triggers and agents will run',
|
|
43
|
+
examples: ['frontera workflow promote invoice-intake 3'],
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async run(ctx) {
|
|
47
|
+
const slug = requireSlug(ctx)
|
|
48
|
+
const number = requireVersionNumber(ctx)
|
|
49
|
+
const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
50
|
+
const promoted = await client.promote(slug, number)
|
|
51
|
+
|
|
52
|
+
// Read AFTER the promote, so the reach printed is the one now armed rather
|
|
53
|
+
// than the one the author last validated. On a workflow promoted from CI
|
|
54
|
+
// these are the same; on one promoted days after the push they need not be.
|
|
55
|
+
//
|
|
56
|
+
// The failure is REPORTED, not swallowed. Silently omitting the block on a
|
|
57
|
+
// 403 or a 5xx would show "v3 is live" with no reach, which reads as "it
|
|
58
|
+
// touches nothing" — the opposite of what this read exists to tell the
|
|
59
|
+
// person who just armed it. The promote itself already succeeded, so this
|
|
60
|
+
// must not throw.
|
|
61
|
+
let lineage = null
|
|
62
|
+
let lineageError: string | null = null
|
|
63
|
+
try {
|
|
64
|
+
lineage = await client.lineage(slug, number)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
lineageError = (err as Error).message
|
|
67
|
+
}
|
|
68
|
+
const reach = lineage ? describeReach(lineage.grants, lineage.lineage) : ''
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
data: { promoted, lineage, lineageError },
|
|
72
|
+
text: `${slug}: v${number} is live\n`
|
|
73
|
+
+ (reach ? `${reach}\n` : '')
|
|
74
|
+
+ (lineageError ? ` (could not read what it touches: ${lineageError})\n` : '')
|
|
75
|
+
+ ` frontera workflow run ${slug} # to start one now`,
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}
|