@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.50.19",
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.19",
43
- "@frontera-sdk/core": "1.50.19",
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.19",
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
+ }
@@ -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
+ }