@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { parse as parseYaml } from 'yaml'
|
|
3
|
+
|
|
4
|
+
import { WorkflowApi, type LineageEdge } from '../../api/workflow-api'
|
|
5
|
+
import { CliError, UsageError } from '../../errors'
|
|
6
|
+
import type { Command, CommandContext } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The slug a verb acts on, refused early rather than sent as an empty segment.
|
|
10
|
+
*
|
|
11
|
+
* `/v1/workflows//versions` is a different route from the one intended and
|
|
12
|
+
* answers something unrelated, so a missing slug has to fail here.
|
|
13
|
+
*/
|
|
14
|
+
export function requireSlug(ctx: CommandContext): string {
|
|
15
|
+
const slug = ctx.positional[0]
|
|
16
|
+
if (!slug) {
|
|
17
|
+
throw new UsageError(
|
|
18
|
+
'missing <slug>',
|
|
19
|
+
'run `frontera workflow list` — then pass the slug of the one you mean',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
return slug
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read a definition file as YAML or JSON.
|
|
27
|
+
*
|
|
28
|
+
* One parser for both: YAML is a superset of JSON, so a `.json` file parses
|
|
29
|
+
* correctly here and a caller never has to think about which flag to pass.
|
|
30
|
+
* The failure is reported against the FILE, because a parse error forwarded to
|
|
31
|
+
* the service arrives as "definition must be an object" — true, and useless
|
|
32
|
+
* for finding the line with the bad indent.
|
|
33
|
+
*/
|
|
34
|
+
export async function readDefinitionFile(path: string): Promise<unknown> {
|
|
35
|
+
let text: string
|
|
36
|
+
try {
|
|
37
|
+
text = await readFile(path, 'utf8')
|
|
38
|
+
} catch {
|
|
39
|
+
throw new UsageError(`cannot read ${path}`, 'pass the path to the workflow definition, YAML or JSON')
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return parseYaml(text)
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new UsageError(`${path} is not valid YAML or JSON`, (err as Error).message)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requireFile(ctx: CommandContext): string {
|
|
49
|
+
const path = ctx.positional[1]
|
|
50
|
+
if (!path) {
|
|
51
|
+
throw new UsageError(
|
|
52
|
+
'missing <file>',
|
|
53
|
+
'frontera workflow push <slug> workflow.yaml',
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return path
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a definition reaches, as the author should read it before promoting.
|
|
61
|
+
*
|
|
62
|
+
* Grants and lineage are DERIVED from the definition — nobody writes them —
|
|
63
|
+
* so the push is the first and only chance to notice that a step reaches an
|
|
64
|
+
* object type or an Action the author did not intend to touch.
|
|
65
|
+
*/
|
|
66
|
+
export function describeReach(grants: string[] = [], lineage: LineageEdge[] = []): string {
|
|
67
|
+
const lines: string[] = []
|
|
68
|
+
if (grants.length > 0) lines.push(` grants ${grants.join(', ')}`)
|
|
69
|
+
for (const edge of lineage) {
|
|
70
|
+
const property = edge.property ? `.${edge.property}` : ''
|
|
71
|
+
const where = edge.nodeId ? ` (${edge.nodeId})` : ''
|
|
72
|
+
lines.push(` ${edge.kind.padEnd(7)} ${edge.resourceKind} ${edge.label ?? edge.resourceId}${property}${where}`)
|
|
73
|
+
}
|
|
74
|
+
return lines.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Push a definition as a new draft version.
|
|
79
|
+
*
|
|
80
|
+
* A push never runs anything and never changes what is live — it validates,
|
|
81
|
+
* derives the grants and lineage, and stores a draft. `promote` is the verb
|
|
82
|
+
* that arms it, deliberately separate, because the definition an author is
|
|
83
|
+
* still editing and the one their organization executes should not be the
|
|
84
|
+
* same act.
|
|
85
|
+
*
|
|
86
|
+
* The workflow is created on first push, the way a Function's first deploy
|
|
87
|
+
* creates its shell. There is no `create` verb for that reason.
|
|
88
|
+
*/
|
|
89
|
+
export const workflowPush: Command = {
|
|
90
|
+
meta: {
|
|
91
|
+
noun: 'workflow',
|
|
92
|
+
verb: 'push',
|
|
93
|
+
args: [
|
|
94
|
+
{ name: 'slug', required: true, description: 'workflow slug' },
|
|
95
|
+
{ name: 'file', required: true, description: 'definition file, YAML or JSON' },
|
|
96
|
+
],
|
|
97
|
+
flags: {},
|
|
98
|
+
summary: 'Validate a definition and store it as a new draft version',
|
|
99
|
+
examples: [
|
|
100
|
+
'frontera workflow push invoice-intake workflow.yaml',
|
|
101
|
+
'frontera workflow push invoice-intake workflow.yaml --json',
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async run(ctx) {
|
|
106
|
+
const slug = requireSlug(ctx)
|
|
107
|
+
const definition = await readDefinitionFile(requireFile(ctx))
|
|
108
|
+
const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
109
|
+
const result = await client.push(slug, definition)
|
|
110
|
+
|
|
111
|
+
const reach = describeReach(result.grants, result.lineage)
|
|
112
|
+
return {
|
|
113
|
+
data: result,
|
|
114
|
+
text: `${slug}: pushed draft v${result.version?.number}\n`
|
|
115
|
+
+ (reach ? `${reach}\n` : '')
|
|
116
|
+
+ ` frontera workflow promote ${slug} ${result.version?.number} # to make it live`,
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check a definition without writing anything.
|
|
123
|
+
*
|
|
124
|
+
* Separate from `push --dry-run` because the two answer different questions and
|
|
125
|
+
* one of them is used in a loop: this is the verb an author runs while editing,
|
|
126
|
+
* and it must not leave a trail of draft versions behind. The service supports
|
|
127
|
+
* it directly (`?dryRun=true`), including the grants and lineage — so a reach
|
|
128
|
+
* an author did not intend is visible BEFORE a draft exists.
|
|
129
|
+
*/
|
|
130
|
+
export const workflowValidate: Command = {
|
|
131
|
+
meta: {
|
|
132
|
+
noun: 'workflow',
|
|
133
|
+
verb: 'validate',
|
|
134
|
+
args: [
|
|
135
|
+
{ name: 'slug', required: true, description: 'workflow slug the definition is for' },
|
|
136
|
+
{ name: 'file', required: true, description: 'definition file, YAML or JSON' },
|
|
137
|
+
],
|
|
138
|
+
flags: {},
|
|
139
|
+
summary: 'Validate a definition and report its reach — writes nothing',
|
|
140
|
+
examples: ['frontera workflow validate invoice-intake workflow.yaml'],
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async run(ctx) {
|
|
144
|
+
const slug = requireSlug(ctx)
|
|
145
|
+
const definition = await readDefinitionFile(requireFile(ctx))
|
|
146
|
+
const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
147
|
+
// A dry run reports problems as DATA, not as a 400 — the service does this
|
|
148
|
+
// so the Console can show them beside the draft. So the refusal arrives
|
|
149
|
+
// here as `ok: false` and has to be rendered, not thrown.
|
|
150
|
+
const result = await client.push(slug, definition, { dryRun: true })
|
|
151
|
+
|
|
152
|
+
// Thrown, not returned. A command result carries no exit code, so a
|
|
153
|
+
// rendered refusal would leave `validate` exiting 0 on an invalid
|
|
154
|
+
// definition — and the one place this verb is worth having is a CI step
|
|
155
|
+
// that must fail. `push` gets the same status for the same input, from the
|
|
156
|
+
// 400 the non-dry route answers with.
|
|
157
|
+
if (!result.ok) {
|
|
158
|
+
throw new CliError(`${slug}: the definition is not valid`, {
|
|
159
|
+
code: 'INVALID_DEFINITION',
|
|
160
|
+
hint: (result.errors ?? []).join('\n '),
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const reach = describeReach(result.grants, result.lineage)
|
|
165
|
+
return {
|
|
166
|
+
data: result,
|
|
167
|
+
text: `${slug}: the definition is valid\n`
|
|
168
|
+
+ (reach ? `${reach}\n` : '')
|
|
169
|
+
+ ` frontera workflow push ${slug} ${ctx.positional[1]} # to store it as a draft`,
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { WorkflowApi, type RunRequest, type RunTree, type RunTreeNode } from '../../api/workflow-api'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
|
+
import { readSecretValue } from '../../secrets'
|
|
4
|
+
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
5
|
+
import { requireSlug } from './push'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long to wait for a run to reach a terminal state.
|
|
9
|
+
*
|
|
10
|
+
* The same budget `function run` uses, for the same reason: a CLI that blocks
|
|
11
|
+
* for the engine's own ceiling reads as hung, and giving up here loses
|
|
12
|
+
* nothing — the run is recorded and the timeout message says where to read it.
|
|
13
|
+
* Bounding the WAIT is not bounding the RUN.
|
|
14
|
+
*/
|
|
15
|
+
const WAIT_TIMEOUT_MS = 120_000
|
|
16
|
+
const POLL_INTERVAL_MS = 2_000
|
|
17
|
+
|
|
18
|
+
const TERMINAL = new Set(['succeeded', 'failed', 'canceled'])
|
|
19
|
+
|
|
20
|
+
/** Has this run stopped, or might it still change? */
|
|
21
|
+
export function isTerminal(status: string): boolean {
|
|
22
|
+
return TERMINAL.has(status)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Is this the refusal that will still be a refusal in two seconds?
|
|
27
|
+
*
|
|
28
|
+
* The two codes `credentialFailure` mints. Kept as a predicate rather than an
|
|
29
|
+
* `instanceof CliError` check because most CliErrors ARE worth retrying — it
|
|
30
|
+
* is specifically the credential ones that are not.
|
|
31
|
+
*/
|
|
32
|
+
export function isCredentialFailure(err: unknown): boolean {
|
|
33
|
+
const code = (err as { code?: string } | null)?.code
|
|
34
|
+
return code === 'UNAUTHORIZED' || code === 'FORBIDDEN'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A run id, refused locally when it is not one.
|
|
41
|
+
*
|
|
42
|
+
* The same principle `promote` applies to a version number: both run routes
|
|
43
|
+
* declare `t.String({ format: 'uuid' })`, and a bad value forwarded to them
|
|
44
|
+
* comes back as Elysia's validation payload — which is not shaped like the
|
|
45
|
+
* `details.errors` the definition routes send, so it renders as "Validation
|
|
46
|
+
* failed" plus a JSON dump of the validator's internals. The author typed a
|
|
47
|
+
* wrong id; that is the answer, and it is available here.
|
|
48
|
+
*/
|
|
49
|
+
export function requireRunId(raw: string | undefined): string {
|
|
50
|
+
if (!raw) {
|
|
51
|
+
throw new UsageError(
|
|
52
|
+
'missing <runId>',
|
|
53
|
+
'run `frontera workflow runs <slug>` — the last column is the id',
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
if (!UUID.test(raw)) {
|
|
57
|
+
throw new UsageError(`"${raw}" is not a run id`, 'a run id is a uuid — the last column of `frontera workflow runs <slug>`')
|
|
58
|
+
}
|
|
59
|
+
return raw
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `--input '<json>'`, `--input @file.json`, or `--input @-` (stdin).
|
|
64
|
+
*
|
|
65
|
+
* Parsed locally, before anything is sent, so a typo fails against the text
|
|
66
|
+
* the author typed rather than as a schema violation against a run that was
|
|
67
|
+
* already started. `@…` reads through `readSecretValue`, the helper the rest
|
|
68
|
+
* of the CLI's `@`-prefixed arguments use — which is also what makes `@-`
|
|
69
|
+
* work here without a second stdin implementation.
|
|
70
|
+
*/
|
|
71
|
+
export async function parseInputFlag(ctx: CommandContext): Promise<Record<string, unknown> | undefined> {
|
|
72
|
+
const raw = flagString(ctx, 'input')
|
|
73
|
+
if (raw === undefined) return undefined
|
|
74
|
+
const text = raw.startsWith('@') ? await readSecretValue(raw) : raw
|
|
75
|
+
let parsed: unknown
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(text)
|
|
78
|
+
} catch {
|
|
79
|
+
throw new UsageError('--input must be a JSON object (or @file.json)', '--input \'{"key": "value"}\'')
|
|
80
|
+
}
|
|
81
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
82
|
+
throw new UsageError('--input must be a JSON object', '--input \'{"key": "value"}\'')
|
|
83
|
+
}
|
|
84
|
+
return parsed as Record<string, unknown>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `--version N`, or undefined for the live one.
|
|
89
|
+
*
|
|
90
|
+
* Rejected here rather than forwarded for the reason `promote` rejects its
|
|
91
|
+
* argument: `--version abc` arriving as `NaN` comes back naming a field the
|
|
92
|
+
* author never typed.
|
|
93
|
+
*/
|
|
94
|
+
export function parseVersionFlag(ctx: CommandContext): number | undefined {
|
|
95
|
+
const raw = flagString(ctx, 'version')
|
|
96
|
+
if (raw === undefined) return undefined
|
|
97
|
+
const parsed = Number(raw)
|
|
98
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
99
|
+
throw new UsageError(`"${raw}" is not a version number`, 'frontera workflow get <slug> lists the versions')
|
|
100
|
+
}
|
|
101
|
+
return parsed
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One line per node, nested under the run each child step started. */
|
|
105
|
+
export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
|
|
106
|
+
const lines: string[] = []
|
|
107
|
+
for (const node of nodes) {
|
|
108
|
+
const indent = ' '.repeat(depth)
|
|
109
|
+
const failed = node.status === 'failed' ? ' ✗' : ''
|
|
110
|
+
lines.push(`${indent}${node.nodeId} ${node.kind} ${node.status}${failed}`)
|
|
111
|
+
// A `forEach` or a `workflow` step is a run of its own, and its steps are
|
|
112
|
+
// where its failure is explained — flattening them would report a failed
|
|
113
|
+
// parent with no reason anywhere in the output.
|
|
114
|
+
if (node.childRun) lines.push(...describeNodes(node.childRun.nodes, depth + 1))
|
|
115
|
+
}
|
|
116
|
+
return lines
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The subject of a bound run, as "<type> <pk>".
|
|
121
|
+
*
|
|
122
|
+
* NOT `label ?? pk`. `label` is the object TYPE's display name
|
|
123
|
+
* (`workflow-run-tree.ts:209` reads `subjectType.displayName`), and the type
|
|
124
|
+
* and the pk are set together — so a fallback means the pk never renders and
|
|
125
|
+
* every run of one workflow reads identically as "Shipment". The pk is the
|
|
126
|
+
* part that says WHICH shipment, and it is the part a caller needs.
|
|
127
|
+
*/
|
|
128
|
+
export function describeSubject(subject: { pk: string; label: string | null } | null): string {
|
|
129
|
+
if (!subject) return ''
|
|
130
|
+
return [subject.label, subject.pk].filter(Boolean).join(' ')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A finished run, its steps, and what it returned. */
|
|
134
|
+
export function describeRun(run: RunTree): string {
|
|
135
|
+
const lines = [`${run.status} — v${run.versionNumber ?? '?'}, ${run.nodeCalls} node call(s)`]
|
|
136
|
+
if (run.subject) lines.push(` subject ${describeSubject(run.subject)}`)
|
|
137
|
+
// The error before the steps: it is the reason the reader opened this, and
|
|
138
|
+
// burying it under a trail of successful steps is how it gets missed.
|
|
139
|
+
if (run.error) lines.push(` error ${typeof run.error === 'string' ? run.error : JSON.stringify(run.error)}`)
|
|
140
|
+
lines.push(...describeNodes(run.nodes))
|
|
141
|
+
if (run.output !== null && run.output !== undefined) lines.push(` returned ${JSON.stringify(run.output)}`)
|
|
142
|
+
return lines.join('\n')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Start a run of the live version and, by default, wait for it.
|
|
147
|
+
*
|
|
148
|
+
* Waiting is the default because the reason to start a workflow by hand is to
|
|
149
|
+
* find out what it does; `--no-wait` is for the caller who only needs the id.
|
|
150
|
+
*
|
|
151
|
+
* There is no baseline-and-compare here, unlike `function run`: the route
|
|
152
|
+
* answers with the `runId` it created, so the run this command reports on is
|
|
153
|
+
* the run it started, even when a cron fires into the same second.
|
|
154
|
+
*
|
|
155
|
+
* A run that ends `failed` exits 0, deliberately. The command succeeded — it
|
|
156
|
+
* started a run and reported the outcome — and this is what `function run`
|
|
157
|
+
* already does, so the two sibling verbs cannot disagree about what an exit
|
|
158
|
+
* code means. It does read oddly beside `validate`, which throws so a CI step
|
|
159
|
+
* fails; the difference is that an invalid definition is a broken INPUT, while
|
|
160
|
+
* a failed run is a true ANSWER. A caller gating on the outcome should read
|
|
161
|
+
* `.status` from `--json`.
|
|
162
|
+
*/
|
|
163
|
+
export const workflowRun: Command = {
|
|
164
|
+
meta: {
|
|
165
|
+
noun: 'workflow',
|
|
166
|
+
verb: 'run',
|
|
167
|
+
args: [{ name: 'slug', required: true, description: 'workflow slug' }],
|
|
168
|
+
flags: { 'no-wait': 'boolean', input: 'string', subject: 'string', version: 'string' },
|
|
169
|
+
summary: 'Run the live version now — or a named one — and report what it did',
|
|
170
|
+
examples: [
|
|
171
|
+
'frontera workflow run invoice-intake',
|
|
172
|
+
'frontera workflow run invoice-intake --input \'{"threshold": 500}\'',
|
|
173
|
+
'frontera workflow run invoice-intake --input @input.json',
|
|
174
|
+
'frontera workflow run shipment-review --subject SHP-0000001',
|
|
175
|
+
'frontera workflow run invoice-intake --version 3 --no-wait',
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
async run(ctx) {
|
|
180
|
+
const slug = requireSlug(ctx)
|
|
181
|
+
const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
182
|
+
|
|
183
|
+
const body: RunRequest = {}
|
|
184
|
+
const input = await parseInputFlag(ctx)
|
|
185
|
+
if (input) body.input = input
|
|
186
|
+
const subject = flagString(ctx, 'subject')
|
|
187
|
+
// The primary key, not a JSON object. A subject-bound workflow takes one
|
|
188
|
+
// pk and nothing else, and asking for `{"pk":"…"}` on a command line would
|
|
189
|
+
// be quoting ceremony around a single value.
|
|
190
|
+
if (subject) body.subject = { pk: subject }
|
|
191
|
+
const version = parseVersionFlag(ctx)
|
|
192
|
+
if (version !== undefined) body.version = version
|
|
193
|
+
|
|
194
|
+
const started = await client.run(slug, body)
|
|
195
|
+
|
|
196
|
+
if (flagBool(ctx, 'no-wait')) {
|
|
197
|
+
return {
|
|
198
|
+
data: started,
|
|
199
|
+
text: `Started ${slug} v${started.versionNumber}.\n`
|
|
200
|
+
+ ` ${started.runId}\n`
|
|
201
|
+
+ ` frontera workflow runs ${slug} ${started.runId} # to see how it went`,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const deadline = Date.now() + WAIT_TIMEOUT_MS
|
|
206
|
+
let seen: RunTree | undefined
|
|
207
|
+
let pollFailure: Error | undefined
|
|
208
|
+
while (Date.now() < deadline) {
|
|
209
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))
|
|
210
|
+
// A failed POLL must not be reported as a failed RUN. The run was
|
|
211
|
+
// started — the id above is the only handle on it — and throwing here
|
|
212
|
+
// would lose that id to an error describing something else entirely.
|
|
213
|
+
// The concrete case is a key holding `execute` but not `view`: it starts
|
|
214
|
+
// the run and is then refused the read, which `credentialFailure` words
|
|
215
|
+
// as a rejected credential. A transient 5xx reads the same way.
|
|
216
|
+
try {
|
|
217
|
+
seen = await client.runById(started.runId)
|
|
218
|
+
} catch (err) {
|
|
219
|
+
pollFailure = err as Error
|
|
220
|
+
// A credential refusal will not heal, so retrying it only delays the
|
|
221
|
+
// answer by the rest of the wait budget. Anything else might be
|
|
222
|
+
// transient and is worth another poll.
|
|
223
|
+
if (isCredentialFailure(err)) break
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
pollFailure = undefined
|
|
227
|
+
if (isTerminal(seen.status)) {
|
|
228
|
+
// The id, on the human path too. A failed run exits 0 by design, so
|
|
229
|
+
// this is the common way a caller learns something went wrong — and
|
|
230
|
+
// without the id printed here they have nothing to pass to
|
|
231
|
+
// `workflow runs` or `workflow cancel`. `--json` always carried it.
|
|
232
|
+
return { data: seen, text: `${slug}: ${describeRun(seen)}\n ${seen.id}` }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// An error about the WAIT, not about the workflow. The run may still
|
|
237
|
+
// succeed, and reporting a slow run as a failed one sends an author to
|
|
238
|
+
// debug working steps. Either way the run id is carried out, because it is
|
|
239
|
+
// the only way back to what was started.
|
|
240
|
+
throw new CliError(
|
|
241
|
+
pollFailure
|
|
242
|
+
? `the run started, but reading it back failed: ${pollFailure.message}`
|
|
243
|
+
: `the run started but was still ${seen?.status ?? 'queued'} after ${WAIT_TIMEOUT_MS / 1000}s`,
|
|
244
|
+
{ code: 'REQUEST_FAILED', hint: `frontera workflow runs ${slug} ${started.runId}` },
|
|
245
|
+
)
|
|
246
|
+
},
|
|
247
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { WorkflowApi } from '../../api/workflow-api'
|
|
2
|
+
import { CliError } from '../../errors'
|
|
3
|
+
import { table } from '../../table'
|
|
4
|
+
import type { Command } from '../types'
|
|
5
|
+
import { describeRun, describeSubject, requireRunId } from './run'
|
|
6
|
+
import { requireSlug } from './push'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recent runs, newest first — or one run and its steps.
|
|
10
|
+
*
|
|
11
|
+
* One verb rather than two, the way `function runs` does it: the list is where
|
|
12
|
+
* a failure is noticed and the step trail is where it is explained, and
|
|
13
|
+
* splitting them puts a second command name between the two halves of one
|
|
14
|
+
* question.
|
|
15
|
+
*/
|
|
16
|
+
export const workflowRuns: Command = {
|
|
17
|
+
meta: {
|
|
18
|
+
noun: 'workflow',
|
|
19
|
+
verb: 'runs',
|
|
20
|
+
args: [
|
|
21
|
+
{ name: 'slug', required: true, description: 'workflow slug' },
|
|
22
|
+
{
|
|
23
|
+
name: 'runId',
|
|
24
|
+
required: false,
|
|
25
|
+
description: 'one run to open, with its steps; omit to list recent runs',
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
flags: {},
|
|
29
|
+
summary: 'List recent runs, newest first — or open one run and its steps',
|
|
30
|
+
examples: [
|
|
31
|
+
'frontera workflow runs invoice-intake',
|
|
32
|
+
'frontera workflow runs invoice-intake 0f2c8b1e-7a41-4c96-9d3e-52b0c4a7e118',
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async run(ctx) {
|
|
37
|
+
const slug = requireSlug(ctx)
|
|
38
|
+
const client = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
39
|
+
|
|
40
|
+
if (ctx.positional[1] !== undefined) {
|
|
41
|
+
const runId = requireRunId(ctx.positional[1])
|
|
42
|
+
// `depth: 'all'`, so a step that started a child workflow explains itself
|
|
43
|
+
// here rather than pointing at another id to look up.
|
|
44
|
+
const run = await client.runById(runId, 'all')
|
|
45
|
+
// The route is workspace-global — it takes the id and ignores the slug —
|
|
46
|
+
// so without this check a mistyped slug silently prints a DIFFERENT
|
|
47
|
+
// workflow's run, and nothing in the output reveals it. The slug is not
|
|
48
|
+
// dropped from the signature instead, because it is what makes the
|
|
49
|
+
// `runs <slug>` listing and this detail view one verb.
|
|
50
|
+
if (run.slug !== slug) {
|
|
51
|
+
throw new CliError(`run ${runId} belongs to "${run.slug}", not to "${slug}"`, {
|
|
52
|
+
code: 'NOT_FOUND',
|
|
53
|
+
hint: `frontera workflow runs ${run.slug} ${runId}`,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
return { data: run, text: describeRun(run) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rows = await client.runs(slug)
|
|
60
|
+
if (rows.length === 0) {
|
|
61
|
+
return {
|
|
62
|
+
data: { runs: [] },
|
|
63
|
+
text: `No runs recorded for ${slug}.\n frontera workflow run ${slug} # to start one`,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
data: { runs: rows },
|
|
69
|
+
text: table(
|
|
70
|
+
['STARTED', 'STATUS', 'VERSION', 'TRIGGER', 'SUBJECT', 'RUN ID'],
|
|
71
|
+
rows.map((r) => [
|
|
72
|
+
r.startedAt,
|
|
73
|
+
r.status,
|
|
74
|
+
`v${r.versionNumber}`,
|
|
75
|
+
r.triggerSource,
|
|
76
|
+
describeSubject(r.subjectPk ? { pk: r.subjectPk, label: r.subjectLabel } : null),
|
|
77
|
+
// Never truncated: an id cut to fit the terminal cannot be pasted
|
|
78
|
+
// into the detail view, which is the only reason it is printed.
|
|
79
|
+
r.id,
|
|
80
|
+
]),
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
package/src/flag-help.ts
CHANGED
|
@@ -75,8 +75,16 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
75
75
|
'no-install': 'scaffold without running `bun install` — for an offline machine or a sandbox',
|
|
76
76
|
'no-git': 'scaffold without creating a repository and an initial commit',
|
|
77
77
|
'no-components': 'scaffold without fetching the baseline shadcn components',
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// Worded for every consumer, not for the first one. `FLAG_HELP` is keyed by
|
|
79
|
+
// flag name globally, and `--version` is now read by three commands: two
|
|
80
|
+
// choose which version to RUN, one chooses which to publish. The old text
|
|
81
|
+
// described only the third, so `workflow run --help` and `function run
|
|
82
|
+
// --help` both advertised package.json.
|
|
83
|
+
version: 'the version to act on — the one to run, or on a deploy the one to publish',
|
|
84
|
+
input: "JSON object of the declared inputs — inline ('{\"key\": 1}') or @file.json",
|
|
85
|
+
// The primary key alone. A subject-bound workflow takes exactly one, so the
|
|
86
|
+
// flag spends no characters on the `{"pk": …}` the route models it as.
|
|
87
|
+
subject: 'primary key of the object a subject-bound workflow runs for',
|
|
80
88
|
'no-promote': 'publish the version without moving the live pointer',
|
|
81
89
|
'no-wait': 'return as soon as the run is queued instead of waiting for it to finish',
|
|
82
90
|
// Named for what it RUNS, not where it runs: an author reads this to decide
|
|
@@ -172,7 +180,10 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
|
|
|
172
180
|
plan: 'path',
|
|
173
181
|
dir: 'path',
|
|
174
182
|
file: 'path',
|
|
175
|
-
|
|
183
|
+
// Not `semver`: `app deploy` takes one, but `function run` and `workflow run`
|
|
184
|
+
// take an integer version number, and this map is keyed by flag name for all
|
|
185
|
+
// three. `<version>` is the only placeholder true of every consumer.
|
|
186
|
+
version: 'version',
|
|
176
187
|
framework: 'next|react',
|
|
177
188
|
port: 'number',
|
|
178
189
|
origin: 'url',
|