@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.50.18",
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.18",
43
- "@frontera-sdk/core": "1.50.18",
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.18",
49
+ "@frontera-sdk/forge-contracts": "1.50.20",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -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; kind?: string; version?: number }
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; kind?: 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', kind: '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', 'kind', 'name'],
192
- rows.map((a) => [a.id ?? '?', a.agentId ?? '', a.kind ?? '', a.name ?? '']),
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, kind nor the published
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
  }),
@@ -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
+ }