@frontera-sdk/cli 1.52.38 → 1.52.40
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/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +48 -4
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +331 -31
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +875 -88
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +211 -21
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +180 -7
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +24 -21
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { WorkflowApi, type RunRequest, type RunTree, type RunTreeNode } from '../../api/workflow-api'
|
|
1
|
+
import { WorkflowApi, type RunRequest, type RunTree, type RunTreeNode, type VersionSummary } from '../../api/workflow-api'
|
|
2
2
|
import { CliError, UsageError } from '../../errors'
|
|
3
3
|
import { readSecretValue } from '../../secrets'
|
|
4
4
|
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
@@ -85,22 +85,54 @@ export async function parseInputFlag(ctx: CommandContext): Promise<Record<string
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* `--version N`, or undefined for the live one.
|
|
88
|
+
* `--version N`, `--version draft`, or undefined for the live one.
|
|
89
89
|
*
|
|
90
90
|
* Rejected here rather than forwarded for the reason `promote` rejects its
|
|
91
91
|
* argument: `--version abc` arriving as `NaN` comes back naming a field the
|
|
92
92
|
* author never typed.
|
|
93
|
+
*
|
|
94
|
+
* `draft` (and its synonym `latest`) is the alias the MCP `workflow_run` and
|
|
95
|
+
* `frontera local test` already take: the newest version that is not retired,
|
|
96
|
+
* which `resolveVersion` looks up. Without it an author had to read the number
|
|
97
|
+
* from `workflow get` first, and that number moves on every push or apply.
|
|
93
98
|
*/
|
|
94
|
-
export function parseVersionFlag(ctx: CommandContext): number | undefined {
|
|
99
|
+
export function parseVersionFlag(ctx: CommandContext): number | 'draft' | undefined {
|
|
95
100
|
const raw = flagString(ctx, 'version')
|
|
96
101
|
if (raw === undefined) return undefined
|
|
102
|
+
const word = raw.trim().toLowerCase()
|
|
103
|
+
if (word === 'draft' || word === 'latest') return 'draft'
|
|
97
104
|
const parsed = Number(raw)
|
|
98
105
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
99
|
-
throw new UsageError(`"${raw}" is not a version number`, 'frontera workflow get <slug> lists the versions')
|
|
106
|
+
throw new UsageError(`"${raw}" is not a version number or "draft"`, 'frontera workflow get <slug> lists the versions; `--version draft` runs the newest')
|
|
100
107
|
}
|
|
101
108
|
return parsed
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The version number a `--version` flag names: a number as given, and `draft` as
|
|
113
|
+
* the newest version that is not retired. That is the draft just pushed, or the
|
|
114
|
+
* live version when a person promoted it (promoting retires the older drafts),
|
|
115
|
+
* the same rule as the MCP `workflow_run`'s `pickVersion`.
|
|
116
|
+
*/
|
|
117
|
+
export async function resolveVersion(
|
|
118
|
+
requested: number | 'draft' | undefined,
|
|
119
|
+
versions: () => Promise<VersionSummary[]>,
|
|
120
|
+
slug: string,
|
|
121
|
+
): Promise<number | undefined> {
|
|
122
|
+
if (requested !== 'draft') return requested
|
|
123
|
+
const rows = await versions()
|
|
124
|
+
const newest = [...rows].sort((a, b) => b.number - a.number).find((v) => v.status !== 'retired')
|
|
125
|
+
if (!newest) {
|
|
126
|
+
throw new CliError(
|
|
127
|
+
rows.length
|
|
128
|
+
? `workflow "${slug}" has only retired versions (${rows.map((v) => `v${v.number}`).join(', ')}), so there is no draft to run`
|
|
129
|
+
: `workflow "${slug}" has no version yet, so there is no draft to run`,
|
|
130
|
+
{ code: 'NOT_FOUND', hint: `push its definition first: \`frontera workflow push ${slug} <file>\`, or in a local project \`frontera local dev --once\`` },
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
return newest.number
|
|
134
|
+
}
|
|
135
|
+
|
|
104
136
|
/** One line per node, nested under the run each child step started. */
|
|
105
137
|
export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
|
|
106
138
|
const lines: string[] = []
|
|
@@ -108,6 +140,8 @@ export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
|
|
|
108
140
|
const indent = ' '.repeat(depth)
|
|
109
141
|
const failed = node.status === 'failed' ? ' ✗' : ''
|
|
110
142
|
lines.push(`${indent}${node.nodeId} ${node.kind} ${node.status}${failed}`)
|
|
143
|
+
// A failed step's error, and each input it named (`issues`): the reason, where it happened.
|
|
144
|
+
if (node.error) lines.push(...stepErrorLines(node.error, `${indent} `))
|
|
111
145
|
// A `forEach` or a `workflow` step is a run of its own, and its steps are
|
|
112
146
|
// where its failure is explained — flattening them would report a failed
|
|
113
147
|
// parent with no reason anywhere in the output.
|
|
@@ -116,6 +150,26 @@ export function describeNodes(nodes: RunTreeNode[], depth = 1): string[] {
|
|
|
116
150
|
return lines
|
|
117
151
|
}
|
|
118
152
|
|
|
153
|
+
/**
|
|
154
|
+
* A step's recorded error as lines: `{ code, message }`, then each of its
|
|
155
|
+
* `issues` (`{ path, message }`, the input it named and the rule it broke).
|
|
156
|
+
*/
|
|
157
|
+
export function stepErrorLines(error: unknown, indent: string): string[] {
|
|
158
|
+
if (typeof error === 'string') return [`${indent}error ${error}`]
|
|
159
|
+
if (!error || typeof error !== 'object') return []
|
|
160
|
+
const e = error as { code?: unknown; message?: unknown; issues?: unknown }
|
|
161
|
+
const head = [typeof e.code === 'string' ? e.code : '', typeof e.message === 'string' ? e.message : ''].filter(Boolean).join(': ')
|
|
162
|
+
const issues = Array.isArray(e.issues)
|
|
163
|
+
? e.issues.flatMap((i) => {
|
|
164
|
+
const issue = (i && typeof i === 'object' ? i : {}) as { path?: unknown; message?: unknown }
|
|
165
|
+
const message = typeof issue.message === 'string' ? issue.message : ''
|
|
166
|
+
if (!message) return []
|
|
167
|
+
return [`${indent} · ${typeof issue.path === 'string' && issue.path ? `${issue.path}: ` : ''}${message}`]
|
|
168
|
+
})
|
|
169
|
+
: []
|
|
170
|
+
return [`${indent}error ${head || JSON.stringify(error)}`, ...issues]
|
|
171
|
+
}
|
|
172
|
+
|
|
119
173
|
/**
|
|
120
174
|
* The subject of a bound run, as "<type> <pk>".
|
|
121
175
|
*
|
|
@@ -136,7 +190,7 @@ export function describeRun(run: RunTree): string {
|
|
|
136
190
|
if (run.subject) lines.push(` subject ${describeSubject(run.subject)}`)
|
|
137
191
|
// The error before the steps: it is the reason the reader opened this, and
|
|
138
192
|
// burying it under a trail of successful steps is how it gets missed.
|
|
139
|
-
if (run.error) lines.push(
|
|
193
|
+
if (run.error) lines.push(...stepErrorLines(run.error, ' '))
|
|
140
194
|
lines.push(...describeNodes(run.nodes))
|
|
141
195
|
if (run.output !== null && run.output !== undefined) lines.push(` returned ${JSON.stringify(run.output)}`)
|
|
142
196
|
return lines.join('\n')
|
|
@@ -166,13 +220,17 @@ export const workflowRun: Command = {
|
|
|
166
220
|
verb: 'run',
|
|
167
221
|
args: [{ name: 'slug', required: true, description: 'workflow slug' }],
|
|
168
222
|
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',
|
|
223
|
+
summary: 'Run the live version now — or a named one, or the newest draft — and report what it did',
|
|
224
|
+
details: [
|
|
225
|
+
'`--version <n>` runs that version. `--version draft` runs the newest version that is not retired: the draft you last pushed or applied, or the live version once a person promoted it (`latest` means the same). Without `--version`, the live version runs.',
|
|
226
|
+
],
|
|
170
227
|
examples: [
|
|
171
228
|
'frontera workflow run invoice-intake',
|
|
172
229
|
'frontera workflow run invoice-intake --input \'{"threshold": 500}\'',
|
|
173
230
|
'frontera workflow run invoice-intake --input @input.json',
|
|
174
231
|
'frontera workflow run shipment-review --subject SHP-0000001',
|
|
175
232
|
'frontera workflow run invoice-intake --version 3 --no-wait',
|
|
233
|
+
'frontera workflow run invoice-intake --version draft --input @input.json',
|
|
176
234
|
],
|
|
177
235
|
},
|
|
178
236
|
|
|
@@ -188,7 +246,7 @@ export const workflowRun: Command = {
|
|
|
188
246
|
// pk and nothing else, and asking for `{"pk":"…"}` on a command line would
|
|
189
247
|
// be quoting ceremony around a single value.
|
|
190
248
|
if (subject) body.subject = { pk: subject }
|
|
191
|
-
const version = parseVersionFlag(ctx)
|
|
249
|
+
const version = await resolveVersion(parseVersionFlag(ctx), () => client.versions(slug), slug)
|
|
192
250
|
if (version !== undefined) body.version = version
|
|
193
251
|
|
|
194
252
|
const started = await client.run(slug, body)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WorkflowApi } from '../../api/workflow-api'
|
|
2
|
-
import { CliError } from '../../errors'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
3
|
import { table } from '../../table'
|
|
4
|
-
import type
|
|
4
|
+
import { flagString, type Command } from '../types'
|
|
5
5
|
import { describeRun, describeSubject, requireRunId } from './run'
|
|
6
6
|
import { requireSlug } from './push'
|
|
7
7
|
|
|
@@ -25,10 +25,11 @@ export const workflowRuns: Command = {
|
|
|
25
25
|
description: 'one run to open, with its steps; omit to list recent runs',
|
|
26
26
|
},
|
|
27
27
|
],
|
|
28
|
-
flags: {},
|
|
29
|
-
summary: 'List recent runs, newest first — or open one run and its steps',
|
|
28
|
+
flags: { only: 'string' },
|
|
29
|
+
summary: 'List recent runs, newest first — or open one run and its steps (--only live|test: live runs, or test runs)',
|
|
30
30
|
examples: [
|
|
31
31
|
'frontera workflow runs invoice-intake',
|
|
32
|
+
'frontera workflow runs invoice-intake --only live',
|
|
32
33
|
'frontera workflow runs invoice-intake 0f2c8b1e-7a41-4c96-9d3e-52b0c4a7e118',
|
|
33
34
|
],
|
|
34
35
|
},
|
|
@@ -56,11 +57,13 @@ export const workflowRuns: Command = {
|
|
|
56
57
|
return { data: run, text: describeRun(run) }
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
const
|
|
60
|
+
const source = flagString(ctx, 'only')
|
|
61
|
+
if (source !== undefined && source !== 'live' && source !== 'test') throw new UsageError(`--only takes live or test, not "${source}"`, `e.g. \`frontera workflow runs ${slug} --only live\``)
|
|
62
|
+
const rows = await client.runs(slug, source)
|
|
60
63
|
if (rows.length === 0) {
|
|
61
64
|
return {
|
|
62
65
|
data: { runs: [] },
|
|
63
|
-
text: `No runs recorded for ${slug}.\n frontera workflow run ${slug} # to start one`,
|
|
66
|
+
text: `No ${source ? `${source} ` : ''}runs recorded for ${slug}.\n frontera workflow run ${slug} # to start one`,
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { WorkflowApi, type RunSummary, type WebhookListen } from '../../api/workflow-api'
|
|
4
|
+
import { CliError, UsageError } from '../../errors'
|
|
5
|
+
import { flagString, type Command } from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Send one delivery to a workflow's webhook on its DRAFT, and show what came
|
|
9
|
+
* back: the run the mapping (`$request.body`, headers, query) produced.
|
|
10
|
+
*
|
|
11
|
+
* The draft's test door (`/v1/hooks-test/…`) answers only while a Listen is
|
|
12
|
+
* armed, which was Studio's button alone, so a webhook's mapping could not be
|
|
13
|
+
* tried before a version went live. This arms it (`POST
|
|
14
|
+
* /v1/workflows/:slug/listen`: one delivery, within 120 seconds) and delivers
|
|
15
|
+
* straight away. The trigger's own auth still applies: pass what the sender
|
|
16
|
+
* would send with `--header`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The door to send to: the one at `path`, or the only one. */
|
|
20
|
+
export function pickDoor(listen: WebhookListen, path: string | undefined, slug: string): WebhookListen['doors'][number] {
|
|
21
|
+
const doors = listen.doors
|
|
22
|
+
if (!doors.length) throw new CliError(`workflow ${slug} v${listen.version} has no webhook trigger`, { code: 'USAGE', hint: 'add a `kind: webhook` trigger to its file, or run it with `frontera workflow run`' })
|
|
23
|
+
const wanted = path?.replace(/^\/+/, '')
|
|
24
|
+
if (wanted !== undefined) {
|
|
25
|
+
const door = doors.find((d) => d.path.replace(/^\/+/, '') === wanted)
|
|
26
|
+
if (!door) throw new UsageError(`workflow ${slug} has no webhook at "${wanted}"`, `its webhooks: ${doors.map((d) => d.path || '(no path)').join(', ')}`)
|
|
27
|
+
return door
|
|
28
|
+
}
|
|
29
|
+
if (doors.length > 1) throw new UsageError(`workflow ${slug} has ${doors.length} webhooks; say which with --path`, `its webhooks: ${doors.map((d) => d.path || '(no path)').join(', ')}`)
|
|
30
|
+
return doors[0]!
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The test door on the address this CLI talks to: the service names its public address, which a local runtime may not answer on. */
|
|
34
|
+
export function doorUrl(test: string, apiUrl: string, query: string | undefined): string {
|
|
35
|
+
const url = new URL(test)
|
|
36
|
+
const at = new URL(url.pathname, apiUrl.replace(/\/+$/, '') + '/')
|
|
37
|
+
if (query) for (const [k, v] of new URLSearchParams(query.replace(/^\?/, ''))) at.searchParams.append(k, v)
|
|
38
|
+
return at.toString()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `Name: value`, as a sender's header. */
|
|
42
|
+
export function parseHeader(raw: string | undefined): Record<string, string> {
|
|
43
|
+
if (!raw) return {}
|
|
44
|
+
const at = raw.indexOf(':')
|
|
45
|
+
if (at <= 0) throw new UsageError('--header takes `Name: value`', "e.g. --header 'X-Signature: abc123'")
|
|
46
|
+
return { [raw.slice(0, at).trim()]: raw.slice(at + 1).trim() }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `--version`: a draft's number, or `draft` (and `latest`) for the latest draft, as the MCP tool takes it. */
|
|
50
|
+
export function readVersionFlag(raw: string | undefined): number | undefined {
|
|
51
|
+
if (raw === undefined || /^(draft|latest)$/i.test(raw.trim())) return undefined
|
|
52
|
+
const version = Number(raw)
|
|
53
|
+
if (!Number.isInteger(version) || version < 1) throw new UsageError('--version takes a draft’s number, or draft for the latest one', 'e.g. --version 3 or --version draft; leave it out for the latest draft')
|
|
54
|
+
return version
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The run a delivery started: the id the door answered with (`respond:
|
|
59
|
+
* immediately` gives it), else the newest test run of that version since the
|
|
60
|
+
* delivery was sent. Null when none shows up within a few seconds.
|
|
61
|
+
*/
|
|
62
|
+
export async function findDeliveredRun(list: () => Promise<RunSummary[] | unknown>, version: number, sentAt: number, answered: unknown, tries = 6, waitMs = 500): Promise<string | null> {
|
|
63
|
+
const fromBody = answered && typeof answered === 'object' && typeof (answered as { runId?: unknown }).runId === 'string' ? (answered as { runId: string }).runId : null
|
|
64
|
+
if (fromBody) return fromBody
|
|
65
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
66
|
+
const page = await Promise.resolve().then(list).catch(() => null)
|
|
67
|
+
const rows = (Array.isArray(page) ? page : ((page as { items?: unknown[] } | null)?.items ?? [])) as Array<Partial<RunSummary>>
|
|
68
|
+
const mine = rows
|
|
69
|
+
.filter((r) => typeof r.id === 'string' && (r.versionNumber === undefined || r.versionNumber === null || r.versionNumber === version) && Date.parse(r.startedAt ?? '') >= sentAt)
|
|
70
|
+
.sort((a, b) => Date.parse(b.startedAt ?? '') - Date.parse(a.startedAt ?? ''))
|
|
71
|
+
if (mine[0]) return mine[0].id!
|
|
72
|
+
if (attempt < tries - 1) await new Promise((r) => setTimeout(r, waitMs))
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readBody(from: string | undefined): string | undefined {
|
|
78
|
+
if (from === undefined) return undefined
|
|
79
|
+
if (from === '-') return readFileSync(0, 'utf8')
|
|
80
|
+
try {
|
|
81
|
+
return readFileSync(from, 'utf8')
|
|
82
|
+
} catch {
|
|
83
|
+
throw new UsageError(`could not read ${from}`, 'pass a JSON file with --body, or - for standard input')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const workflowWebhookTest: Command = {
|
|
88
|
+
meta: {
|
|
89
|
+
noun: 'workflow',
|
|
90
|
+
verb: 'webhook-test',
|
|
91
|
+
args: [{ name: 'workflow', required: true, description: 'the workflow’s slug' }],
|
|
92
|
+
flags: { body: 'string', path: 'string', method: 'string', header: 'string', query: 'string', version: 'string' },
|
|
93
|
+
summary: 'Send one test delivery to a workflow’s webhook on its draft, and show the run it made',
|
|
94
|
+
examples: [
|
|
95
|
+
'frontera workflow webhook-test work-order-intake --body fault.json',
|
|
96
|
+
'frontera workflow webhook-test intake --path fault-reports --body -',
|
|
97
|
+
"frontera workflow webhook-test intake --body a.json --header 'X-Key: k1'",
|
|
98
|
+
'frontera workflow webhook-test intake --body a.json --version draft --json',
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async run(ctx) {
|
|
103
|
+
const slug = ctx.positional[0]
|
|
104
|
+
if (!slug) throw new UsageError('missing <workflow>', 'frontera workflow webhook-test <slug> --body payload.json')
|
|
105
|
+
const version = readVersionFlag(flagString(ctx, 'version'))
|
|
106
|
+
const body = readBody(flagString(ctx, 'body'))
|
|
107
|
+
if (body !== undefined) {
|
|
108
|
+
try {
|
|
109
|
+
JSON.parse(body)
|
|
110
|
+
} catch {
|
|
111
|
+
// A sender may post text or a form: sent as it is, said here.
|
|
112
|
+
ctx.output.note('[webhook-test] the body is not JSON: it is sent as text')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const headers = parseHeader(flagString(ctx, 'header'))
|
|
116
|
+
|
|
117
|
+
const api = new WorkflowApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
118
|
+
const listen = await api.listen(slug, version).catch((err: unknown) => {
|
|
119
|
+
if (err instanceof CliError && /not.?found|404/i.test(`${err.code} ${err.message}`) && /listen/i.test(err.message)) {
|
|
120
|
+
throw new CliError('this platform release cannot arm a draft’s webhook from the CLI', { code: 'UNSUPPORTED', hint: 'use Listen on the workflow’s page, or update to a release that supports `frontera workflow webhook-test`' })
|
|
121
|
+
}
|
|
122
|
+
throw err
|
|
123
|
+
})
|
|
124
|
+
const door = pickDoor(listen, flagString(ctx, 'path'), slug)
|
|
125
|
+
const method = (flagString(ctx, 'method') ?? (door.methods.includes('POST') ? 'POST' : door.methods[0] ?? 'POST')).toUpperCase()
|
|
126
|
+
if (door.methods.length && !door.methods.includes(method)) throw new UsageError(`the webhook at "${door.path}" takes ${door.methods.join(', ')}, not ${method}`, `--method ${door.methods[0]}`)
|
|
127
|
+
const url = doorUrl(door.test, ctx.apiUrl, flagString(ctx, 'query'))
|
|
128
|
+
const sent = method === 'GET' || method === 'HEAD' ? undefined : body
|
|
129
|
+
// A second back, so a run the runtime stamps a moment before the delivery returns still counts.
|
|
130
|
+
const sentAt = Date.now() - 1_000
|
|
131
|
+
const res = await fetch(url, {
|
|
132
|
+
method,
|
|
133
|
+
headers: { ...(sent !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
|
|
134
|
+
...(sent !== undefined ? { body: sent } : {}),
|
|
135
|
+
})
|
|
136
|
+
const text = await res.text()
|
|
137
|
+
let answer: unknown = text
|
|
138
|
+
try {
|
|
139
|
+
answer = text ? JSON.parse(text) : null
|
|
140
|
+
} catch {
|
|
141
|
+
// Kept as text.
|
|
142
|
+
}
|
|
143
|
+
// The run the delivery made, so `--json` shows it (a refused delivery made none).
|
|
144
|
+
const runId = res.status === 401 || res.status === 403 || res.status === 404 ? null : await findDeliveredRun(() => api.runs(slug, 'test'), listen.version, sentAt, answer)
|
|
145
|
+
const data = { workflow: slug, version: listen.version, path: door.path, method, status: res.status, response: answer, runId }
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
throw new CliError(`the webhook answered ${res.status} on the draft (v${listen.version})`, {
|
|
148
|
+
code: 'WEBHOOK_TEST_FAILED',
|
|
149
|
+
hint: res.status === 401 || res.status === 403
|
|
150
|
+
? 'the trigger’s auth refused the delivery: pass what the sender sends with --header'
|
|
151
|
+
: res.status === 404
|
|
152
|
+
? 'the test door did not answer: the Listen may have ended, or the path or method differs from the trigger’s'
|
|
153
|
+
: `${typeof answer === 'string' ? answer.slice(0, 300) : JSON.stringify(answer).slice(0, 300)}`,
|
|
154
|
+
details: data,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
data,
|
|
159
|
+
text: [
|
|
160
|
+
`${method} ${door.path ? `/${door.path.replace(/^\/+/, '')}` : '(no path)'} on workflow ${slug} v${listen.version} (draft): ${res.status}`,
|
|
161
|
+
typeof answer === 'string' ? answer : JSON.stringify(answer, null, 2),
|
|
162
|
+
runId ? ` the run: frontera workflow runs ${slug} ${runId}` : ` the run: frontera workflow runs ${slug} --only test`,
|
|
163
|
+
].join('\n'),
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { credentialStore, type CredentialSource } from './credential-store'
|
|
|
4
4
|
import { CliError, UsageError } from './errors'
|
|
5
5
|
import { migrateLegacyConfig } from './profile-migration'
|
|
6
6
|
import { configPath, type Env } from './paths'
|
|
7
|
+
import { localRuntimeProfileOf } from './local/local-project'
|
|
7
8
|
import { findContext } from './project-context'
|
|
8
9
|
import {
|
|
9
10
|
assertOriginMatches,
|
|
11
|
+
getProfile,
|
|
10
12
|
isLegacyConfig,
|
|
11
13
|
listProfiles,
|
|
12
14
|
normalizeOrigin,
|
|
@@ -72,6 +74,25 @@ export interface CredentialDeps {
|
|
|
72
74
|
export interface ResolveOptions {
|
|
73
75
|
apiUrl?: string
|
|
74
76
|
profile?: string
|
|
77
|
+
/**
|
|
78
|
+
* The command acts at organization grain. In a directory bound to a local
|
|
79
|
+
* runtime's workspace profile (`local-<project>`), the runtime's
|
|
80
|
+
* organization profile beside it (`local-<project>-org`) is selected
|
|
81
|
+
* instead: the same runtime, the key its organization commands need.
|
|
82
|
+
*/
|
|
83
|
+
organization?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The organization profile `local start` registers beside a local runtime's
|
|
88
|
+
* workspace profile, when this is one and it has one.
|
|
89
|
+
*/
|
|
90
|
+
export function localOrganizationCompanion(profile: string, env: Env = process.env): string | null {
|
|
91
|
+
if (!profile.startsWith('local-')) return null
|
|
92
|
+
const org = `${profile}-org`.slice(0, 64)
|
|
93
|
+
if (org === profile || getProfile(profile, env)?.credentialKind !== 'workspace') return null
|
|
94
|
+
const companion = getProfile(org, env)
|
|
95
|
+
return companion?.credentialKind === 'organization' && companion.apiUrl === getProfile(profile, env)?.apiUrl ? org : null
|
|
75
96
|
}
|
|
76
97
|
|
|
77
98
|
/**
|
|
@@ -103,16 +124,24 @@ export function resolveProfileName(
|
|
|
103
124
|
* question rather than an answer, and `auth use` is how it gets answered.
|
|
104
125
|
*/
|
|
105
126
|
if (!found.trusted) {
|
|
127
|
+
// A copied or pulled project carries the machine it came from's selection. When this directory
|
|
128
|
+
// has its own local runtime, ITS profile is the one to bind: the named one belongs to another
|
|
129
|
+
// directory (another project's runtime, or another machine's).
|
|
130
|
+
const own = localRuntimeProfileOf(found.root)
|
|
131
|
+
const ownHere = own && own !== found.profile && getProfile(own, env) ? own : null
|
|
106
132
|
throw new CliError(
|
|
107
133
|
`${found.path} selects "${found.profile}", but this machine never bound that directory`,
|
|
108
134
|
{
|
|
109
135
|
code: 'PROJECT_CONTEXT_UNTRUSTED',
|
|
110
|
-
hint:
|
|
111
|
-
`
|
|
112
|
-
|
|
136
|
+
hint: ownHere
|
|
137
|
+
? `this directory has its own local runtime, whose profile is ${ownHere} (\`frontera local status\` names it): run \`frontera auth use ${ownHere}\` in ${found.root}. "${found.profile}" was bound where this project came from, and would reach that runtime instead`
|
|
138
|
+
: `if you meant to use it, run \`frontera auth use ${found.profile}\` in ${found.root} — `
|
|
139
|
+
+ 'a context file committed to a repository is not proof that you chose it',
|
|
113
140
|
},
|
|
114
141
|
)
|
|
115
142
|
}
|
|
143
|
+
const companion = opts.organization ? localOrganizationCompanion(found.profile, env) : null
|
|
144
|
+
if (companion) return { profile: companion, source: 'directory', path: found.path }
|
|
116
145
|
return { profile: found.profile, source: 'directory', path: found.path }
|
|
117
146
|
}
|
|
118
147
|
|
|
@@ -144,9 +173,18 @@ export async function resolveCredential(
|
|
|
144
173
|
await ensureMigrated(env)
|
|
145
174
|
|
|
146
175
|
const store = deps.store ?? credentialStore(env)
|
|
147
|
-
const selection = resolveProfileName(opts, deps)
|
|
148
|
-
|
|
149
176
|
const explicitOrigin = opts.apiUrl ?? env.FRONTERA_API_URL
|
|
177
|
+
const envToken = env.FRONTERA_TOKEN?.trim()
|
|
178
|
+
// A raw token with its own origin uses no stored key, so a context file this machine did not
|
|
179
|
+
// bind is nothing to refuse: it only names the profile the result reports (none, here).
|
|
180
|
+
let selection: ReturnType<typeof resolveProfileName>
|
|
181
|
+
try {
|
|
182
|
+
selection = resolveProfileName(opts, deps)
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (!(envToken && explicitOrigin && err instanceof CliError && err.code === 'PROJECT_CONTEXT_UNTRUSTED')) throw err
|
|
185
|
+
selection = { profile: null, source: 'none' }
|
|
186
|
+
}
|
|
187
|
+
|
|
150
188
|
const originSource: OriginSource = opts.apiUrl ? 'flag' : env.FRONTERA_API_URL ? 'environment' : 'profile'
|
|
151
189
|
|
|
152
190
|
/**
|
|
@@ -155,7 +193,6 @@ export async function resolveCredential(
|
|
|
155
193
|
* would be sent to whatever deployment the checked-out directory happens to
|
|
156
194
|
* select. That is a cross-customer credential leak with no visible symptom.
|
|
157
195
|
*/
|
|
158
|
-
const envToken = env.FRONTERA_TOKEN?.trim()
|
|
159
196
|
if (envToken) {
|
|
160
197
|
if (!explicitOrigin) {
|
|
161
198
|
throw new UsageError(
|
package/src/dev-broker.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { createServer } from 'node:net'
|
|
2
3
|
|
|
3
4
|
import type { BridgeInit } from '@frontera-sdk/core/bridge-protocol'
|
|
4
5
|
|
|
@@ -69,3 +70,54 @@ export function createDevSessionBroker(options: {
|
|
|
69
70
|
|
|
70
71
|
return { endpoint: `http://127.0.0.1:${server.port}${path}`, server }
|
|
71
72
|
}
|
|
73
|
+
|
|
74
|
+
/** The addresses an App's port must be free on: the browser resolves `localhost` to either loopback, and a wildcard listener answers both. */
|
|
75
|
+
export const LOOPBACK_ADDRESSES = ['127.0.0.1', '::1', '0.0.0.0', '::'] as const
|
|
76
|
+
|
|
77
|
+
function freeOn(port: number, host: string): Promise<boolean> {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const server = createServer()
|
|
80
|
+
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
81
|
+
// No IPv6 on this machine: nothing can hold the port there either.
|
|
82
|
+
resolve(err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT')
|
|
83
|
+
})
|
|
84
|
+
server.listen({ port, host, exclusive: true, ...(host === '::' ? { ipv6Only: true } : {}) }, () => server.close(() => resolve(true)))
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether nothing listens on `port` on either loopback, IPv4 or IPv6, nor on
|
|
90
|
+
* a wildcard. Probing 127.0.0.1 alone let a dev server start beside another
|
|
91
|
+
* process holding the port on ::1, and `localhost` and `127.0.0.1` then
|
|
92
|
+
* served two different Apps.
|
|
93
|
+
*/
|
|
94
|
+
export async function isLoopbackPortFree(port: number, hosts: readonly string[] = LOOPBACK_ADDRESSES): Promise<boolean> {
|
|
95
|
+
for (const host of hosts) if (!(await freeOn(port, host))) return false
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A port the system hands out on 127.0.0.1 (`listen(0)`), closed again at once. */
|
|
100
|
+
function systemPort(): Promise<number> {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const server = createServer()
|
|
103
|
+
server.once('error', reject)
|
|
104
|
+
server.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => {
|
|
105
|
+
const address = server.address()
|
|
106
|
+
const port = typeof address === 'object' && address ? address.port : 0
|
|
107
|
+
server.close(() => (port ? resolve(port) : reject(new Error('the system gave no port'))))
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A port free on every loopback address (`isLoopbackPortFree`), for `--port 0`:
|
|
114
|
+
* the system picks it on 127.0.0.1, and one something holds on ::1 or a
|
|
115
|
+
* wildcard is passed over for the next.
|
|
116
|
+
*/
|
|
117
|
+
export async function freeLoopbackPort(isFree: (port: number) => Promise<boolean> = isLoopbackPortFree): Promise<number> {
|
|
118
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
119
|
+
const port = await systemPort()
|
|
120
|
+
if (await isFree(port)) return port
|
|
121
|
+
}
|
|
122
|
+
throw new Error('no port was free on every loopback address after 20 tries')
|
|
123
|
+
}
|
package/src/exit.ts
CHANGED
|
@@ -16,7 +16,12 @@ export const EXIT = {
|
|
|
16
16
|
FAILURE: 1,
|
|
17
17
|
/** Bad invocation or bad input. Fix the command. */
|
|
18
18
|
USAGE: 2,
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Someone else changed it first, or the state of the record or the request
|
|
21
|
+
* refuses it (a governed Action's submission criteria, a request whose
|
|
22
|
+
* outcome is unknown). Re-read, then change the input or the record; the
|
|
23
|
+
* same request is refused again, so never retry it as it is.
|
|
24
|
+
*/
|
|
20
25
|
CONFLICT: 3,
|
|
21
26
|
/** Credentials or permission. Neither retry nor rewording helps. */
|
|
22
27
|
AUTH: 4,
|
|
@@ -58,8 +63,75 @@ const USAGE_CODES = new Set([
|
|
|
58
63
|
// Authoring kit. Both are resolved by running a named command, not by retry.
|
|
59
64
|
'KIT_VERSION_MISMATCH',
|
|
60
65
|
'GENERATED_FILE_CONFLICT',
|
|
66
|
+
// The Idempotency-Key was used for a different request: a new key fixes it,
|
|
67
|
+
// re-reading does not. (An effect another request already made is a
|
|
68
|
+
// conflict: see CONFLICT_REASONS.)
|
|
69
|
+
'ACTION_IDEMPOTENCY_CONFLICT',
|
|
70
|
+
// An Action that isn't there to submit, or a submit without the key it needs.
|
|
71
|
+
'ACTION_NOT_FOUND',
|
|
72
|
+
'ACTION_IDEMPOTENCY_KEY_REQUIRED',
|
|
61
73
|
])
|
|
62
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Someone changed it first: re-read, then decide or submit again. Each of
|
|
76
|
+
* these is fixed by re-reading the record or the request, which a retry of
|
|
77
|
+
* the same body never does.
|
|
78
|
+
*/
|
|
79
|
+
const CONFLICT_CODES = new Set([
|
|
80
|
+
'DRAFT_CONFLICT',
|
|
81
|
+
'CONFLICT',
|
|
82
|
+
'VALIDATION_STALE',
|
|
83
|
+
// The record moved past `expectedVersion`: read it again, submit with the new one.
|
|
84
|
+
'ACTION_SOURCE_VERSION_STALE',
|
|
85
|
+
// The request changed under its approvals: read the request again.
|
|
86
|
+
'ACTION_APPROVAL_STALE',
|
|
87
|
+
// The record already has an open request of this Action (`openRequests: one_per_subject`):
|
|
88
|
+
// read that request (`details.openRequest`) instead of submitting another.
|
|
89
|
+
'ACTION_REQUEST_ALREADY_OPEN',
|
|
90
|
+
// Refused by the state of the record, the Action or the request: the same
|
|
91
|
+
// request is refused every time, so exit 1 ("retry") would send a caller
|
|
92
|
+
// round a loop. Each is fixed by re-reading and changing something first.
|
|
93
|
+
// The record or the input fails the Action's submission criteria.
|
|
94
|
+
'ACTION_CRITERIA_FAILED',
|
|
95
|
+
// The Action is switched off, has no binding, its binding is revoked or
|
|
96
|
+
// suspended, or it has no deployment (`ACTION_NOT_DEPLOYED`, the CLI's word
|
|
97
|
+
// for that 409): nothing changes until someone turns it back on or deploys it.
|
|
98
|
+
'ACTION_DISABLED',
|
|
99
|
+
'ACTION_UNBOUND',
|
|
100
|
+
'ACTION_BINDING_REVOKED',
|
|
101
|
+
'ACTION_BINDING_SUSPENDED',
|
|
102
|
+
'ACTION_NOT_DEPLOYED',
|
|
103
|
+
// It needs an approval first, or affects more records than the Action allows.
|
|
104
|
+
'ACTION_APPROVAL_REQUIRED',
|
|
105
|
+
'ACTION_EFFECT_LIMIT_EXCEEDED',
|
|
106
|
+
// The outcome is settled or unknown: read the request (`action requests`),
|
|
107
|
+
// never submit again — a second submit could make the change twice.
|
|
108
|
+
'ACTION_CONFIRMED_NOT_APPLIED',
|
|
109
|
+
'ACTION_OUTCOME_UNKNOWN',
|
|
110
|
+
'ACTION_RESOLUTION_REQUIRED',
|
|
111
|
+
'AUTOMATION_PUBLISH_UNCERTAIN',
|
|
112
|
+
// The dataset's rows are written somewhere else now.
|
|
113
|
+
'DATASET_AUTHORITY_MOVED',
|
|
114
|
+
])
|
|
115
|
+
/**
|
|
116
|
+
* Refusals the service sends under another code with a `reason` that is a
|
|
117
|
+
* conflict: the approval step moved on, or another request already made the
|
|
118
|
+
* effect. They exit 3 whatever their code.
|
|
119
|
+
*/
|
|
120
|
+
const CONFLICT_REASONS = new Set([
|
|
121
|
+
'ACTION_APPROVAL_STEP_CHANGED',
|
|
122
|
+
'ACTION_LOGICAL_EFFECT_CONFLICT',
|
|
123
|
+
'ACTION_SOURCE_VERSION_STALE',
|
|
124
|
+
'ACTION_REQUEST_ALREADY_OPEN',
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
/** A refusal's `reason`, at the top of its details or nested one level under `details`. */
|
|
128
|
+
export function reasonOf(details: unknown): string | undefined {
|
|
129
|
+
if (typeof details !== 'object' || details === null) return undefined
|
|
130
|
+
const body = details as { reason?: unknown; details?: unknown }
|
|
131
|
+
if (typeof body.reason === 'string') return body.reason
|
|
132
|
+
const nested = body.details
|
|
133
|
+
return typeof nested === 'object' && nested !== null && typeof (nested as { reason?: unknown }).reason === 'string' ? (nested as { reason: string }).reason : undefined
|
|
134
|
+
}
|
|
63
135
|
const AUTH_CODES = new Set([
|
|
64
136
|
'UNAUTHORIZED',
|
|
65
137
|
'FORBIDDEN',
|
|
@@ -78,7 +150,9 @@ const AUTH_CODES = new Set([
|
|
|
78
150
|
* an SDK release — which means this function must treat "never seen it" as
|
|
79
151
|
* ordinary, not exceptional.
|
|
80
152
|
*/
|
|
81
|
-
export function exitCodeFor(code: string): ExitCode {
|
|
153
|
+
export function exitCodeFor(code: string, details?: unknown): ExitCode {
|
|
154
|
+
const reason = reasonOf(details)
|
|
155
|
+
if (reason && CONFLICT_REASONS.has(reason)) return EXIT.CONFLICT
|
|
82
156
|
if (USAGE_CODES.has(code)) return EXIT.USAGE
|
|
83
157
|
if (CONFLICT_CODES.has(code)) return EXIT.CONFLICT
|
|
84
158
|
if (AUTH_CODES.has(code)) return EXIT.AUTH
|
|
@@ -98,6 +172,10 @@ const SERVICE_HINTS: Record<string, string> = {
|
|
|
98
172
|
// which case they are in throw a CliError with a specific hint instead.
|
|
99
173
|
CONFLICT: 'the message above names what conflicted — resolve that, then retry',
|
|
100
174
|
VALIDATION_STALE: 're-fetch the resource and re-run validation against the current revision',
|
|
175
|
+
ACTION_SOURCE_VERSION_STALE: 'the record changed first — read it again (`frontera blueprint query`), then submit with its current version as expectedVersion',
|
|
176
|
+
ACTION_APPROVAL_STALE: 'the request changed under its approvals — read it again (`frontera action requests`), then decide',
|
|
177
|
+
ACTION_IDEMPOTENCY_CONFLICT: 'that Idempotency-Key belongs to another request — submit with a new key',
|
|
178
|
+
ACTION_REQUEST_ALREADY_OPEN: 'this record already has an open request of this Action — follow it (`frontera action requests <requestId>`, the id in the details) instead of submitting another',
|
|
101
179
|
// Names neither kind. Both are valid here — a workspace key for agents,
|
|
102
180
|
// skills, plugins and knowledge; an organization key for Blueprint authoring,
|
|
103
181
|
// datasets and Sources — and naming only one sent holders of the other
|
|
@@ -223,6 +301,19 @@ function hintFor(code: string, details: unknown): string | undefined {
|
|
|
223
301
|
return SERVICE_HINTS[code]
|
|
224
302
|
}
|
|
225
303
|
|
|
304
|
+
/**
|
|
305
|
+
* A refusal that names what it refused only in its details (`invalid_plugins`
|
|
306
|
+
* with `{ invalid: ['decision'] }`), said with the names: the message alone
|
|
307
|
+
* leaves nobody able to tell which entry to take out.
|
|
308
|
+
*/
|
|
309
|
+
export function withRefusedNames(message: string, details: unknown): string {
|
|
310
|
+
if (!/^invalid_[a-z_]+$/.test(message) || typeof details !== 'object' || details === null) return message
|
|
311
|
+
const body = details as { invalid?: unknown; details?: { invalid?: unknown } }
|
|
312
|
+
const invalid = Array.isArray(body.invalid) ? body.invalid : Array.isArray(body.details?.invalid) ? body.details.invalid : null
|
|
313
|
+
const names = invalid?.filter((n): n is string => typeof n === 'string') ?? []
|
|
314
|
+
return names.length ? `${message}: ${names.join(', ')}` : message
|
|
315
|
+
}
|
|
316
|
+
|
|
226
317
|
function isFronteraError(
|
|
227
318
|
err: unknown,
|
|
228
319
|
): err is { code: string; message: string; details?: unknown } {
|
|
@@ -246,8 +337,16 @@ export function toEnvelope(err: unknown): ErrorEnvelope {
|
|
|
246
337
|
}
|
|
247
338
|
|
|
248
339
|
if (isFronteraError(err)) {
|
|
340
|
+
// Invoking an Action that has no deployment, through any client: said as that.
|
|
341
|
+
const details = err.details as { details?: { reason?: unknown; readinessReason?: unknown; deploymentStatus?: unknown } ; reason?: unknown; readinessReason?: unknown; deploymentStatus?: unknown } | undefined
|
|
342
|
+
const nd = details?.reason === 'ACTION_NOT_DEPLOYED' ? details : details?.details?.reason === 'ACTION_NOT_DEPLOYED' ? details.details : null
|
|
343
|
+
if (nd) {
|
|
344
|
+
const why = typeof nd.readinessReason === 'string' ? nd.readinessReason : typeof nd.deploymentStatus === 'string' ? `its deployment is ${nd.deploymentStatus}` : 'it has no active deployment'
|
|
345
|
+
return { error: true, code: 'ACTION_NOT_DEPLOYED', message: `${err.message.replace(/[.]$/, '')}: the Action isn't deployed: ${why}`, hint: '`frontera action list` shows why each Action cannot run; `frontera action deploy <name>` builds its write path' }
|
|
346
|
+
}
|
|
249
347
|
const hint = hintFor(err.code, err.details)
|
|
250
|
-
|
|
348
|
+
const reason = reasonOf(err.details)
|
|
349
|
+
return { error: true, code: err.code, message: withRefusedNames(err.message, err.details), ...(hint ? { hint } : {}), ...(reason ? { details: { reason } } : {}) }
|
|
251
350
|
}
|
|
252
351
|
|
|
253
352
|
return {
|