@frontera-sdk/cli 1.50.18 → 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/platform-api.ts +1 -6
- package/src/api/workflow-api.ts +272 -0
- package/src/commands/agent/compose.ts +1 -2
- package/src/commands/agent/index-commands.ts +6 -17
- 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
|
}
|
package/src/api/platform-api.ts
CHANGED
|
@@ -689,12 +689,7 @@ export class PlatformApi {
|
|
|
689
689
|
* chain from here is `agent apply` then `agent publish`, and both resolve a
|
|
690
690
|
* slug through `GET /config/agents/:ref`, which does see it.
|
|
691
691
|
*/
|
|
692
|
-
createAgent(body: {
|
|
693
|
-
agentId: string
|
|
694
|
-
name?: string
|
|
695
|
-
description?: string
|
|
696
|
-
kind?: 'conversation' | 'work'
|
|
697
|
-
}) {
|
|
692
|
+
createAgent(body: { agentId: string; name?: string; description?: string }) {
|
|
698
693
|
return this.client.request<unknown>('/v1/config/agents', { method: 'POST', body })
|
|
699
694
|
}
|
|
700
695
|
|
|
@@ -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
|
+
}
|
|
@@ -18,7 +18,7 @@ export interface Lookups {
|
|
|
18
18
|
/** workspace skill id → readable name. */
|
|
19
19
|
skills: Map<string, string>
|
|
20
20
|
/** Identity the snapshot's config does not carry. */
|
|
21
|
-
identity?: { id?: string; slug?: string;
|
|
21
|
+
identity?: { id?: string; slug?: string; version?: number }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function arr(value: unknown): Row[] {
|
|
@@ -55,7 +55,6 @@ export function renderComposition(snapshot: Row, lookups: Lookups): string {
|
|
|
55
55
|
for (const [k, v] of [
|
|
56
56
|
['slug', str(config, 'agentId') || (lookups.identity?.slug ?? '')],
|
|
57
57
|
['id', str(config, 'id') || (lookups.identity?.id ?? '')],
|
|
58
|
-
['kind', str(config, 'kind') || (lookups.identity?.kind ?? '')],
|
|
59
58
|
['status', str(config, 'lifecycleStatus')],
|
|
60
59
|
['default mode', str(config, 'defaultMode')],
|
|
61
60
|
['live version', lookups.identity?.version ? String(lookups.identity.version) : ''],
|
|
@@ -31,7 +31,7 @@ async function target(ctx: CommandContext, verb: string): Promise<{ api: Platfor
|
|
|
31
31
|
*/
|
|
32
32
|
async function buildLookups(
|
|
33
33
|
client: PlatformApi,
|
|
34
|
-
identity?: { id?: string; slug?: string
|
|
34
|
+
identity?: { id?: string; slug?: string },
|
|
35
35
|
): Promise<Lookups> {
|
|
36
36
|
const [plugins, knowledge, skills] = await Promise.all([
|
|
37
37
|
client.pluginInstalls().catch(() => [] as unknown[]),
|
|
@@ -107,12 +107,9 @@ const create: Command = {
|
|
|
107
107
|
description: 'lowercase slug every other agent command takes, e.g. claims-triage',
|
|
108
108
|
},
|
|
109
109
|
],
|
|
110
|
-
flags: { name: 'string', description: 'string'
|
|
110
|
+
flags: { name: 'string', description: 'string' },
|
|
111
111
|
summary: 'Create an agent, ready for `agent apply` and `agent publish`',
|
|
112
|
-
examples: [
|
|
113
|
-
'frontera agent create claims-triage --name "Claims Triage"',
|
|
114
|
-
'frontera agent create nightly-extract --kind work',
|
|
115
|
-
],
|
|
112
|
+
examples: ['frontera agent create claims-triage --name "Claims Triage"'],
|
|
116
113
|
},
|
|
117
114
|
async run(ctx) {
|
|
118
115
|
const slug = ctx.positional[0]
|
|
@@ -126,11 +123,6 @@ const create: Command = {
|
|
|
126
123
|
)
|
|
127
124
|
}
|
|
128
125
|
|
|
129
|
-
const kind = flagString(ctx, 'kind')
|
|
130
|
-
if (kind && kind !== 'conversation' && kind !== 'work') {
|
|
131
|
-
throw new UsageError(`unknown --kind "${kind}"`, 'conversation (default) or work')
|
|
132
|
-
}
|
|
133
|
-
|
|
134
126
|
const client = api(ctx)
|
|
135
127
|
|
|
136
128
|
// Checked before the write: the service answers a duplicate slug from deep
|
|
@@ -153,7 +145,6 @@ const create: Command = {
|
|
|
153
145
|
agentId: slug,
|
|
154
146
|
...(name ? { name } : {}),
|
|
155
147
|
...(description ? { description } : {}),
|
|
156
|
-
...(kind ? { kind: kind as 'conversation' | 'work' } : {}),
|
|
157
148
|
})) as { data?: AgentRow } | AgentRow
|
|
158
149
|
const agent = ((row as { data?: AgentRow }).data ?? row) as AgentRow
|
|
159
150
|
|
|
@@ -188,8 +179,8 @@ const list: Command = {
|
|
|
188
179
|
? 'No agents in this workspace.'
|
|
189
180
|
: // Either ID or SLUG works wherever an agent is named.
|
|
190
181
|
table(
|
|
191
|
-
['id', 'slug', '
|
|
192
|
-
rows.map((a) => [a.id ?? '?', a.agentId ?? '', a.
|
|
182
|
+
['id', 'slug', 'name'],
|
|
183
|
+
rows.map((a) => [a.id ?? '?', a.agentId ?? '', a.name ?? '']),
|
|
193
184
|
),
|
|
194
185
|
}
|
|
195
186
|
},
|
|
@@ -228,7 +219,7 @@ const get: Command = {
|
|
|
228
219
|
// knowledge, plugins and channels are the reason to read an agent.
|
|
229
220
|
// The lookups turn the snapshot's uuids into names — without them the
|
|
230
221
|
// human output is a list of identifiers nobody can act on.
|
|
231
|
-
// The snapshot's config carries neither id, slug
|
|
222
|
+
// The snapshot's config carries neither id, slug nor the published
|
|
232
223
|
// version, so the config ROW is read alongside it. Two requests for a
|
|
233
224
|
// complete picture beats one that silently omits what an agent is.
|
|
234
225
|
const [snapshot, row, lookups] = await Promise.all([
|
|
@@ -239,7 +230,6 @@ const get: Command = {
|
|
|
239
230
|
const identity = (row ?? {}) as {
|
|
240
231
|
id?: string
|
|
241
232
|
agentId?: string
|
|
242
|
-
kind?: string
|
|
243
233
|
currentVersion?: { versionNumber?: number }
|
|
244
234
|
}
|
|
245
235
|
return {
|
|
@@ -249,7 +239,6 @@ const get: Command = {
|
|
|
249
239
|
identity: {
|
|
250
240
|
id: identity.id ?? t.id,
|
|
251
241
|
slug: identity.agentId,
|
|
252
|
-
kind: identity.kind,
|
|
253
242
|
version: identity.currentVersion?.versionNumber,
|
|
254
243
|
},
|
|
255
244
|
}),
|
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
|
+
}
|