@frontera-sdk/cli 1.52.39 → 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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { AuthoredFile } from './model'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the draft refuses in a metric file, found before anything is written.
|
|
5
|
+
*
|
|
6
|
+
* A measure's `filters` is ONE where node, as a query's `where` is: a
|
|
7
|
+
* condition (`{ property, op, value }`) or a combinator (`{ and: [...] }`,
|
|
8
|
+
* `{ or: [...] }`, `{ not: {...} }`). A list of conditions reads naturally
|
|
9
|
+
* in YAML and `plan` used to show it as a clean change, while the service
|
|
10
|
+
* refused it at apply (`where node must be a JSON object`). The same shape
|
|
11
|
+
* rules the service checks (`metric-definition.ts`), minus the properties,
|
|
12
|
+
* which only the draft knows: a relative date window and a location
|
|
13
|
+
* condition are refused in a metric too.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const GEO_OPS = new Set(['nearby', 'withinBbox'])
|
|
17
|
+
|
|
18
|
+
/** Why a where node is not one, or null when its shape is right. */
|
|
19
|
+
export function whereShapeProblem(node: unknown): string | null {
|
|
20
|
+
if (Array.isArray(node)) return 'it is a list; it must be one where node: a condition, or several combined as { and: [ … ] }'
|
|
21
|
+
if (!node || typeof node !== 'object') return 'it must be a where node (a mapping): { property, op, value }, or { and: [ … ] }, { or: [ … ] }, { not: … }'
|
|
22
|
+
const o = node as Record<string, unknown>
|
|
23
|
+
if (typeof o.property === 'string') {
|
|
24
|
+
if (o.op === 'dateRange') return 'dateRange presets are not supported in metric filters: a compiled metric is static, so use explicit comparisons'
|
|
25
|
+
if (typeof o.op === 'string' && GEO_OPS.has(o.op)) return 'location conditions (nearby, withinBbox) are not supported in metric filters'
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
for (const key of ['and', 'or'] as const) {
|
|
29
|
+
if (key in o) {
|
|
30
|
+
if (!Array.isArray(o[key])) return `\`${key}\` must be a list of where nodes`
|
|
31
|
+
for (const child of o[key] as unknown[]) {
|
|
32
|
+
const why = whereShapeProblem(child)
|
|
33
|
+
if (why) return why
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if ('not' in o) return whereShapeProblem(o.not)
|
|
39
|
+
return 'it must be a condition ({ property, op, value }) or and/or/not'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A property's type as the tree declares it, by `Type.property`: what a file
|
|
44
|
+
* says, `attribute` when it says nothing (`compile.ts` sends that default).
|
|
45
|
+
* A field carrying a shared field without its own `propertyType` takes the
|
|
46
|
+
* shared field's, which only the draft knows, so it is left out. So is a date
|
|
47
|
+
* or timestamp a file leaves unsaid: a new one is created `time`
|
|
48
|
+
* (`declaredPropertyType`), one already on the draft keeps its own.
|
|
49
|
+
*/
|
|
50
|
+
function declaredPropertyTypes(files: readonly AuthoredFile[]): Map<string, { propertyType: string; valueType: string; file: string }> {
|
|
51
|
+
const out = new Map<string, { propertyType: string; valueType: string; file: string }>()
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
if (file.kind !== 'object-type') continue
|
|
54
|
+
const properties = (file.document as { properties?: unknown }).properties
|
|
55
|
+
for (const p of Array.isArray(properties) ? (properties as Array<Record<string, unknown>>) : []) {
|
|
56
|
+
if (!p || typeof p.apiName !== 'string') continue
|
|
57
|
+
if (p.propertyType === undefined && p.sharedField !== undefined && p.sharedField !== null) continue
|
|
58
|
+
// A date or timestamp left unsaid is `time` when it is created, but one already on the draft keeps its own: unknown here.
|
|
59
|
+
if (p.propertyType === undefined && (p.valueType === 'timestamp' || p.valueType === 'date')) continue
|
|
60
|
+
out.set(`${file.apiName}.${p.apiName}`, {
|
|
61
|
+
propertyType: typeof p.propertyType === 'string' ? p.propertyType : 'attribute',
|
|
62
|
+
valueType: typeof p.valueType === 'string' ? p.valueType : 'string',
|
|
63
|
+
file: file.path,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the runtime checks of a metric against its type's properties
|
|
72
|
+
* (`validateMetricDefinitionAgainstType`): the timeseries is a `time`
|
|
73
|
+
* property, a dimension an `attribute` or `time` one, and a `sum` or `avg`
|
|
74
|
+
* reads a `measure`. `plan` passed a timeseries on an `attribute` timestamp
|
|
75
|
+
* and the runtime refused it. Checked only where the tree holds the type.
|
|
76
|
+
*/
|
|
77
|
+
function metricTypeProblems(file: AuthoredFile, types: ReturnType<typeof declaredPropertyTypes>): string[] {
|
|
78
|
+
const doc = file.document as { objectType?: unknown; definition?: Record<string, unknown> }
|
|
79
|
+
if (typeof doc.objectType !== 'string') return []
|
|
80
|
+
const type = doc.objectType
|
|
81
|
+
const definition = doc.definition ?? {}
|
|
82
|
+
const out: string[] = []
|
|
83
|
+
const fix = (property: string, want: string) => {
|
|
84
|
+
const declared = types.get(`${type}.${property}`)
|
|
85
|
+
return declared ? ` — give ${property} \`propertyType: ${want}\` in ${declared.file}` : ''
|
|
86
|
+
}
|
|
87
|
+
if (typeof definition.timeseries === 'string') {
|
|
88
|
+
const p = types.get(`${type}.${definition.timeseries}`)
|
|
89
|
+
if (p && p.propertyType !== 'time') out.push(`${file.path}: timeseries "${definition.timeseries}" must be a time property, but ${type}.${definition.timeseries} is "${p.propertyType}"${fix(definition.timeseries, 'time')}`)
|
|
90
|
+
}
|
|
91
|
+
for (const dim of Array.isArray(definition.dimensions) ? definition.dimensions : []) {
|
|
92
|
+
if (typeof dim !== 'string') continue
|
|
93
|
+
const p = types.get(`${type}.${dim}`)
|
|
94
|
+
if (p && p.propertyType !== 'attribute' && p.propertyType !== 'time') out.push(`${file.path}: dimension "${dim}" must be an attribute or time property, but ${type}.${dim} is "${p.propertyType}"`)
|
|
95
|
+
}
|
|
96
|
+
for (const [i, measure] of (Array.isArray(definition.measures) ? (definition.measures as Array<Record<string, unknown>>) : []).entries()) {
|
|
97
|
+
if (!measure || (measure.agg !== 'sum' && measure.agg !== 'avg') || typeof measure.property !== 'string' || measure.expression !== undefined) continue
|
|
98
|
+
const p = types.get(`${type}.${measure.property}`)
|
|
99
|
+
const name = typeof measure.label === 'string' ? measure.label : `#${i + 1}`
|
|
100
|
+
if (p && p.propertyType !== 'measure') out.push(`${file.path}: measure "${name}": agg "${measure.agg}" requires a measure property, but ${type}.${measure.property} is "${p.propertyType}"${fix(measure.property, 'measure')}`)
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Each refusal in the tree's metric files, as `<file>: measure "<label>": filters …`. */
|
|
106
|
+
export function metricRuleProblems(files: readonly AuthoredFile[]): string[] {
|
|
107
|
+
const out: string[] = []
|
|
108
|
+
const types = declaredPropertyTypes(files)
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (file.kind !== 'metric') continue
|
|
111
|
+
out.push(...metricTypeProblems(file, types))
|
|
112
|
+
const definition = (file.document as { definition?: { measures?: unknown } }).definition
|
|
113
|
+
const measures = Array.isArray(definition?.measures) ? (definition!.measures as Array<Record<string, unknown>>) : []
|
|
114
|
+
for (const [i, measure] of measures.entries()) {
|
|
115
|
+
if (!measure || typeof measure !== 'object' || measure.filters === undefined) continue
|
|
116
|
+
const why = whereShapeProblem(measure.filters)
|
|
117
|
+
const name = typeof measure.label === 'string' ? measure.label : typeof measure.apiName === 'string' ? measure.apiName : `#${i + 1}`
|
|
118
|
+
if (why) out.push(`${file.path}: measure "${name}": filters — ${why}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readObjectSetFiles } from '../local/local-object-sets'
|
|
2
|
+
import { isUuid, refName } from '../local/local-sync-refs'
|
|
3
|
+
import { objectSetNames } from '../local/local-sync-remote'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Saved object sets as the CLI shows and addresses them.
|
|
7
|
+
*
|
|
8
|
+
* Two list routes answer with sets, in two shapes: the workspace's slice
|
|
9
|
+
* (`/v1/sync/object-sets`, a workspace key; each row names its object type)
|
|
10
|
+
* and the organization's (`/v1/blueprint/object-sets`, an organization key;
|
|
11
|
+
* rows carry the object type's id). Both are read into one row here.
|
|
12
|
+
*
|
|
13
|
+
* A set is addressed the way a project file names it: its id, its name
|
|
14
|
+
* (`Open work orders`), its name as a reference holds it
|
|
15
|
+
* (`objectSet/open-work-orders`), or, in a project, its file's name
|
|
16
|
+
* (`blueprint/object-sets/open-work-orders.yaml` → `objectSet/open-work-orders`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface SavedObjectSet {
|
|
20
|
+
id: string
|
|
21
|
+
name: string
|
|
22
|
+
description: string | null
|
|
23
|
+
kind: string
|
|
24
|
+
/** The object type's API name, when the route says it; else its id. */
|
|
25
|
+
objectType: string
|
|
26
|
+
/** How a file names it: `objectSet/open-work-orders`. */
|
|
27
|
+
ref: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const text = (v: unknown): string | null => (typeof v === 'string' && v ? v : null)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Rows from either list route, unwrapped from `{ data }` when the client left one.
|
|
34
|
+
*
|
|
35
|
+
* `files` are a project's object set files (file name → the set's `name:`, from
|
|
36
|
+
* `projectObjectSetNames`): a set one file saves takes that file's name as its
|
|
37
|
+
* reference, as `status` names it, rather than a slug of its name (which stops at
|
|
38
|
+
* 24 characters, so `Critical open work orders` was `critical-open-work-order`).
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeObjectSets(body: unknown, files: ReadonlyMap<string, string> = new Map()): SavedObjectSet[] {
|
|
41
|
+
const raw = Array.isArray(body) ? body : Array.isArray((body as { data?: unknown } | null)?.data) ? ((body as { data: unknown[] }).data) : []
|
|
42
|
+
const rows = raw.flatMap((r) => {
|
|
43
|
+
const o = (r ?? {}) as Record<string, unknown>
|
|
44
|
+
const id = text(o.id)
|
|
45
|
+
const name = text(o.name)
|
|
46
|
+
if (!id || !name) return []
|
|
47
|
+
return [{
|
|
48
|
+
id,
|
|
49
|
+
name,
|
|
50
|
+
description: text(o.description),
|
|
51
|
+
kind: text(o.kind) ?? 'dynamic',
|
|
52
|
+
objectType: text(o.objectType) ?? text(o.objectTypeApiName) ?? text(o.resultObjectTypeApiName) ?? text(o.resultObjectTypeId) ?? '',
|
|
53
|
+
}]
|
|
54
|
+
})
|
|
55
|
+
const refs = objectSetNames(rows.map((r) => ({ ...r, kind: r.kind as 'dynamic' | 'static' })))
|
|
56
|
+
const fileRefs = new Map<string, string[]>()
|
|
57
|
+
for (const [file, name] of files) fileRefs.set(name, [...(fileRefs.get(name) ?? []), file])
|
|
58
|
+
// Only when one file and one set share the name: else the file's name could mean either.
|
|
59
|
+
const fileOf = (name: string) => {
|
|
60
|
+
const own = fileRefs.get(name)
|
|
61
|
+
return own?.length === 1 && rows.filter((r) => r.name === name).length === 1 ? own[0] : undefined
|
|
62
|
+
}
|
|
63
|
+
return rows.map((r) => ({ ...r, ref: `objectSet/${fileOf(r.name) ?? refs.get(r.id) ?? refName(r.name)}` }))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A project's object set files by the reference their file name gives (`open-work-orders`) → the set's name. */
|
|
67
|
+
export function projectObjectSetNames(root: string | null): Map<string, string> {
|
|
68
|
+
const out = new Map<string, string>()
|
|
69
|
+
if (!root) return out
|
|
70
|
+
for (const f of readObjectSetFiles(root)) out.set(f.ref, typeof f.doc.name === 'string' && f.doc.name ? f.doc.name : f.ref)
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The set `wanted` names: an id, a name (any case), `objectSet/<ref>` or
|
|
76
|
+
* `objectSet:<ref>`, or a project file's name. `{ set }` when one matches,
|
|
77
|
+
* else why not, with the near names.
|
|
78
|
+
*/
|
|
79
|
+
export function findObjectSet(sets: SavedObjectSet[], wanted: string, files: ReadonlyMap<string, string> = new Map()): { set: SavedObjectSet } | { problem: string; ambiguous?: SavedObjectSet[] } {
|
|
80
|
+
const raw = wanted.trim()
|
|
81
|
+
if (isUuid(raw)) {
|
|
82
|
+
const byId = sets.find((s) => s.id.toLowerCase() === raw.toLowerCase())
|
|
83
|
+
return byId ? { set: byId } : { problem: `no saved object set has the id ${raw}` }
|
|
84
|
+
}
|
|
85
|
+
const bare = raw.replace(/^objectSet[/:]/, '')
|
|
86
|
+
const lower = bare.toLowerCase()
|
|
87
|
+
const tries: Array<(s: SavedObjectSet) => boolean> = [
|
|
88
|
+
(s) => s.ref === `objectSet/${bare}`,
|
|
89
|
+
(s) => s.name.toLowerCase() === lower,
|
|
90
|
+
(s) => s.ref.toLowerCase() === `objectset/${refName(bare).toLowerCase()}`,
|
|
91
|
+
]
|
|
92
|
+
// A project file's name, as `local dev` saves it: the set carries the file's `name:`.
|
|
93
|
+
const fileName = files.get(bare)
|
|
94
|
+
if (fileName) tries.push((s) => s.name === fileName)
|
|
95
|
+
for (const match of tries) {
|
|
96
|
+
const found = sets.filter(match)
|
|
97
|
+
if (found.length === 1) return { set: found[0]! }
|
|
98
|
+
if (found.length > 1) return { problem: `${found.length} saved object sets match "${raw}"`, ambiguous: found }
|
|
99
|
+
}
|
|
100
|
+
return { problem: `no saved object set is named "${raw}"` }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A query document with each `{ type: "reference", objectSetId }` that names
|
|
105
|
+
* a set in words resolved to the set's id. `resolve` answers an id for a name,
|
|
106
|
+
* or throws why it can't. The document is copied, never changed in place.
|
|
107
|
+
*/
|
|
108
|
+
export async function resolveObjectSetReferences(doc: unknown, resolve: (name: string) => Promise<string>): Promise<unknown> {
|
|
109
|
+
if (Array.isArray(doc)) return Promise.all(doc.map((d) => resolveObjectSetReferences(d, resolve)))
|
|
110
|
+
if (!doc || typeof doc !== 'object') return doc
|
|
111
|
+
const o = doc as Record<string, unknown>
|
|
112
|
+
const out: Record<string, unknown> = {}
|
|
113
|
+
for (const [k, v] of Object.entries(o)) out[k] = await resolveObjectSetReferences(v, resolve)
|
|
114
|
+
if (o.type === 'reference' && typeof o.objectSetId === 'string' && !isUuid(o.objectSetId)) out.objectSetId = await resolve(o.objectSetId)
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
@@ -112,10 +112,18 @@ export function deriveOntologyEditPlan(
|
|
|
112
112
|
// deployment that wrote a different set would be executing something other
|
|
113
113
|
// than what was published and reviewed.
|
|
114
114
|
const set: Record<string, string> = {}
|
|
115
|
+
// A constant (`{ kind: literal }`) is written by the service from the binding's pinned
|
|
116
|
+
// contract, not from a parameter: it stays out of `set` and out of the fingerprint.
|
|
117
|
+
const constants: string[] = []
|
|
115
118
|
for (const outcome of action.businessOutcomes) {
|
|
116
119
|
for (const change of outcome.expectedChanges ?? []) {
|
|
117
120
|
if (change.kind !== 'property_set') continue
|
|
118
121
|
const value = change.value as { kind?: string; fieldId?: string } | undefined
|
|
122
|
+
if (value?.kind === 'literal') {
|
|
123
|
+
const property = propertyApiNameById.get(String(change.propertyId))
|
|
124
|
+
if (property) constants.push(property)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
119
127
|
if (value?.kind !== 'input' || !value.fieldId) continue
|
|
120
128
|
const property = propertyApiNameById.get(String(change.propertyId))
|
|
121
129
|
const parameter = inputApiNameById.get(value.fieldId)
|
|
@@ -124,10 +132,15 @@ export function deriveOntologyEditPlan(
|
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
// The plan must map at least one parameter, so constants alone cannot be deployed.
|
|
127
136
|
if (op !== 'delete' && Object.keys(set).length === 0) {
|
|
128
137
|
throw new PlanDerivationError(
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
constants.length
|
|
139
|
+
? `Action "${action.apiName}" sets only constants (${constants.join(', ')}), and its write path needs at least one property set from an input.`
|
|
140
|
+
: `Action "${action.apiName}" sets no properties, so there is nothing to deploy.`,
|
|
141
|
+
constants.length
|
|
142
|
+
? 'Set at least one property from an input ({ kind: input, field: <input> }) beside the constants.'
|
|
143
|
+
: 'Open the Action and tick at least one property under "What it changes".',
|
|
131
144
|
)
|
|
132
145
|
}
|
|
133
146
|
|
|
@@ -146,10 +159,35 @@ export function deriveOntologyEditPlan(
|
|
|
146
159
|
if (!pkParameter) {
|
|
147
160
|
throw new PlanDerivationError(
|
|
148
161
|
`Action "${action.apiName}" takes no "${pkApiName}" parameter, so an edit has no address.`,
|
|
149
|
-
`Add a required "${pkApiName}"
|
|
162
|
+
`Add a required "${pkApiName}" input to the Action (valueType: { kind: string, minLength: 1 }): ${op === 'create' ? 'the new record is filed under it' : 'the write finds its record by it'}. Then publish.`,
|
|
150
163
|
)
|
|
151
164
|
}
|
|
152
165
|
|
|
166
|
+
if (constants.includes(pkApiName)) {
|
|
167
|
+
throw new PlanDerivationError(
|
|
168
|
+
`Action "${action.apiName}" sets its primary key ${pkApiName} to a constant.`,
|
|
169
|
+
`Remove that change: the key comes from the required "${pkParameter}" input.`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
// The key addresses the record (an edit) or files it (a create): it is never also a written value.
|
|
173
|
+
if (Object.prototype.hasOwnProperty.call(set, pkApiName) && (op !== 'create' || set[pkApiName] === pkParameter)) {
|
|
174
|
+
throw new PlanDerivationError(
|
|
175
|
+
`Action "${action.apiName}" sets its primary key ${pkApiName}, which ${op === 'create' ? 'a create already files the new record under (from its "' + pkParameter + '" parameter)' : 'addresses the record and cannot change'}.`,
|
|
176
|
+
`Remove the property_set of ${pkApiName} from its outcome; keep the required "${pkParameter}" input.`,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
// One parameter, one role: the deployment refuses a parameter feeding two properties.
|
|
180
|
+
const byParameter = new Map<string, string[]>()
|
|
181
|
+
for (const [property, parameter] of Object.entries(set)) byParameter.set(parameter, [...(byParameter.get(parameter) ?? []), property])
|
|
182
|
+
for (const [parameter, properties] of byParameter) {
|
|
183
|
+
if (properties.length > 1) {
|
|
184
|
+
throw new PlanDerivationError(
|
|
185
|
+
`Action "${action.apiName}" sets ${properties.join(' and ')} from the same input, ${parameter}.`,
|
|
186
|
+
'Give each property its own input: every mapped parameter carries exactly one role.',
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
153
191
|
// Only when the Action asked for it. Compare-and-set is the Action's
|
|
154
192
|
// concurrency contract, not a deployment preference.
|
|
155
193
|
const expectedVersionParameter = action.concurrency.strategy === 'expected_version'
|
|
@@ -158,7 +196,13 @@ export function deriveOntologyEditPlan(
|
|
|
158
196
|
if (action.concurrency.strategy === 'expected_version' && !expectedVersionParameter) {
|
|
159
197
|
throw new PlanDerivationError(
|
|
160
198
|
`Action "${action.apiName}" declares expected_version concurrency but takes no "expectedVersion" parameter.`,
|
|
161
|
-
'Add
|
|
199
|
+
'Add a required input { apiName: expectedVersion, valueType: { kind: number, integer: true, minimum: 0 } }: the record version the change was decided on (a query row\'s _meta.recordVersion).',
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
if (expectedVersionParameter && Object.values(set).includes(expectedVersionParameter)) {
|
|
203
|
+
throw new PlanDerivationError(
|
|
204
|
+
`Action "${action.apiName}" writes a property from expectedVersion, which carries the record version.`,
|
|
205
|
+
'Give that property its own input: every mapped parameter carries exactly one role.',
|
|
162
206
|
)
|
|
163
207
|
}
|
|
164
208
|
|
|
@@ -80,10 +80,15 @@ function propertyToFile(
|
|
|
80
80
|
sharedApiNameById: Map<string, string>,
|
|
81
81
|
semanticApiNameById: Map<string, string>,
|
|
82
82
|
siblingApiNameById: Map<string, string>,
|
|
83
|
+
objectClassification?: unknown,
|
|
83
84
|
): Record<string, unknown> {
|
|
84
85
|
const body = rest(property, new Set(['id', 'sharedPropertyId', 'semanticTypeId']))
|
|
85
86
|
const location = locationToFile(property, objectApiName, siblingApiNameById)
|
|
86
87
|
if (location) body.calculation = location
|
|
88
|
+
// A field's own classification is written only where it differs from its object type's: a read
|
|
89
|
+
// gives every field its effective one, and writing the inherited value would pin it. A location
|
|
90
|
+
// takes its latitude's and longitude's, and declares none.
|
|
91
|
+
if (body.sensitivity !== undefined && (body.sensitivity === null || body.sensitivity === objectClassification || location)) delete body.sensitivity
|
|
87
92
|
const semanticType = resolveName(
|
|
88
93
|
property.semanticTypeId,
|
|
89
94
|
semanticApiNameById,
|
|
@@ -220,7 +225,7 @@ function objectToFile(
|
|
|
220
225
|
primaryKey: byId.get(object.primaryKeyPropertyId) ?? object.primaryKeyPropertyId,
|
|
221
226
|
title: byId.get(object.titlePropertyId) ?? object.titlePropertyId,
|
|
222
227
|
properties: object.properties.map((property) =>
|
|
223
|
-
propertyToFile(property, object.apiName, sharedApiNameById, semanticApiNameById, byId)),
|
|
228
|
+
propertyToFile(property, object.apiName, sharedApiNameById, semanticApiNameById, byId, (object.governance as { sensitivity?: { classification?: unknown } } | undefined)?.sensitivity?.classification)),
|
|
224
229
|
}
|
|
225
230
|
}
|
|
226
231
|
|
|
@@ -303,6 +308,23 @@ function nameFieldRefs(node: unknown, fields: Map<string, string>, properties: M
|
|
|
303
308
|
for (const value of Object.values(node)) nameFieldRefs(value, fields, properties)
|
|
304
309
|
}
|
|
305
310
|
|
|
311
|
+
/**
|
|
312
|
+
* A postcondition reads the record after the change, and that is the only timing it
|
|
313
|
+
* may have: the service stores it spelled out (`timing: after`) whether the author
|
|
314
|
+
* wrote it or not. The file format takes no timing, so the implied one is dropped
|
|
315
|
+
* (in place, anywhere below `node`): a pulled file stays one an author can write, and
|
|
316
|
+
* `blueprint plan` compares a file with the live Action as equal.
|
|
317
|
+
*/
|
|
318
|
+
export function withoutImpliedTiming(node: unknown): void {
|
|
319
|
+
if (Array.isArray(node)) {
|
|
320
|
+
for (const entry of node) withoutImpliedTiming(entry)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if (!isRecord(node)) return
|
|
324
|
+
if (node.kind === 'observation_property' && node.timing === 'after') delete node.timing
|
|
325
|
+
for (const value of Object.values(node)) withoutImpliedTiming(value)
|
|
326
|
+
}
|
|
327
|
+
|
|
306
328
|
/**
|
|
307
329
|
* An action as the bundle carries it → the portable file.
|
|
308
330
|
*
|
|
@@ -375,6 +397,7 @@ function actionToFile(action: Record<string, unknown>, names: ActionNames): Reco
|
|
|
375
397
|
if (!isRecord(postcondition)) continue
|
|
376
398
|
delete postcondition.id
|
|
377
399
|
nameFieldRefs(postcondition.assertion, scope, names.property)
|
|
400
|
+
withoutImpliedTiming(postcondition.assertion)
|
|
378
401
|
}
|
|
379
402
|
}
|
|
380
403
|
|
|
@@ -64,6 +64,89 @@ export function isSemanticTypeTemplate(value: string): value is SemanticTypeTemp
|
|
|
64
64
|
|
|
65
65
|
export interface ScaffoldOptions {
|
|
66
66
|
template?: SemanticTypeTemplate
|
|
67
|
+
/** An Action's shape: `edit` (the default) changes a record, `create` makes one. */
|
|
68
|
+
actionTemplate?: ActionTemplate
|
|
69
|
+
/** An Action's subject, and the name of its primary key (read from the tree when it has the type). */
|
|
70
|
+
objectType?: string
|
|
71
|
+
primaryKey?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const ACTION_TEMPLATES = ['edit', 'create'] as const
|
|
75
|
+
export type ActionTemplate = (typeof ACTION_TEMPLATES)[number]
|
|
76
|
+
|
|
77
|
+
export function isActionTemplate(value: string): value is ActionTemplate {
|
|
78
|
+
return (ACTION_TEMPLATES as readonly string[]).includes(value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A governed Action over the Blueprint's records that deploys as written once
|
|
83
|
+
* the placeholders are filled: every rule the write path holds it to is
|
|
84
|
+
* already met (`action-rules.ts`), so the reader starts from a file that
|
|
85
|
+
* works and changes what it sets.
|
|
86
|
+
*
|
|
87
|
+
* Every reference is a NAME. The service resolves them against the draft and mints
|
|
88
|
+
* the ids the contract owns, so nothing here carries a uuid — which is exactly what
|
|
89
|
+
* makes the file applicable to a second deployment.
|
|
90
|
+
*/
|
|
91
|
+
function actionScaffold(apiName: string, options: ScaffoldOptions): Record<string, unknown> {
|
|
92
|
+
const create = options.actionTemplate === 'create'
|
|
93
|
+
const objectType = options.objectType ?? 'CHANGE_ME'
|
|
94
|
+
const pk = options.primaryKey ?? 'id'
|
|
95
|
+
const field = (name: string, displayName: string, description: string, valueType: Record<string, unknown>, extra: Record<string, unknown> = { required: true }) => ({
|
|
96
|
+
apiName: name, displayName, description, valueType, ...extra, sensitivity: 'internal',
|
|
97
|
+
})
|
|
98
|
+
const inputs = [
|
|
99
|
+
// The key: an edit finds its record by it, a create files the new record under it.
|
|
100
|
+
field(pk, create ? 'New record ID' : 'Record', create ? 'The new record’s key; the record is filed under it.' : 'The record to change, by its key.', { kind: 'string', minLength: 1 }),
|
|
101
|
+
// Compare-and-set: the version the change was decided on (a query row's `_meta.recordVersion`).
|
|
102
|
+
...(create ? [] : [field('expectedVersion', 'Expected version', 'The record version the change was decided on.', { kind: 'number', integer: true, minimum: 0 })]),
|
|
103
|
+
field('value', 'CHANGE_ME', 'CHANGE_ME: the value it writes.', { kind: 'string', minLength: 1 }),
|
|
104
|
+
]
|
|
105
|
+
return {
|
|
106
|
+
apiName,
|
|
107
|
+
displayName: apiName,
|
|
108
|
+
description: 'CHANGE_ME',
|
|
109
|
+
subject: { objectType, mode: create ? 'create' : 'existing' },
|
|
110
|
+
effect: {
|
|
111
|
+
kind: create ? 'create' : 'update',
|
|
112
|
+
// The Blueprint's own records: `active-catalog` is the source system the service
|
|
113
|
+
// adds to the draft with the first object type (`blueprint-draft-catalog.ts`).
|
|
114
|
+
// Source systems have no route or file of their own, so this is the one name an
|
|
115
|
+
// Action that writes Blueprint records can give, here and on every deployment.
|
|
116
|
+
authoritySourceSystem: 'active-catalog',
|
|
117
|
+
description: 'CHANGE_ME',
|
|
118
|
+
},
|
|
119
|
+
inputs,
|
|
120
|
+
// `min(1)` on the create route: an action that declares no outcome cannot say
|
|
121
|
+
// what it did. Each change names its object type; each input feeds one property,
|
|
122
|
+
// and a create never sets its key (it is filed under the key input).
|
|
123
|
+
businessOutcomes: [{
|
|
124
|
+
code: create ? 'created' : 'applied',
|
|
125
|
+
displayName: create ? 'Created' : 'Applied',
|
|
126
|
+
description: 'CHANGE_ME',
|
|
127
|
+
effect: 'confirmed_applied',
|
|
128
|
+
expectedChanges: [
|
|
129
|
+
...(create ? [{ kind: 'object_created', objectType }] : []),
|
|
130
|
+
{ kind: 'property_set', objectType, property: 'CHANGE_ME', value: { kind: 'input', field: 'value' } },
|
|
131
|
+
],
|
|
132
|
+
postconditions: [],
|
|
133
|
+
output: [],
|
|
134
|
+
}],
|
|
135
|
+
// An edit compares the record's version; a create over the Blueprint's records is append_once.
|
|
136
|
+
concurrency: { strategy: create ? 'append_once' : 'expected_version' },
|
|
137
|
+
impact: { maxSubjects: 1 },
|
|
138
|
+
submissionCriteria: [],
|
|
139
|
+
governance: {
|
|
140
|
+
// Shaped, not `CHANGE_ME`: this one is validated by
|
|
141
|
+
// `/^[a-z][a-z0-9_-]{0,63}:[a-z][a-z0-9_-]{0,63}$/`, so a bare placeholder is
|
|
142
|
+
// the one field in this file that cannot apply as written — and it fails
|
|
143
|
+
// server-side, after the plan was confirmed.
|
|
144
|
+
invokeCapability: 'changeme:invoke',
|
|
145
|
+
approval: { mode: 'none' },
|
|
146
|
+
reason: 'optional',
|
|
147
|
+
sensitivity: 'internal',
|
|
148
|
+
},
|
|
149
|
+
}
|
|
67
150
|
}
|
|
68
151
|
|
|
69
152
|
export function scaffold(
|
|
@@ -141,48 +224,7 @@ export function scaffold(
|
|
|
141
224
|
}
|
|
142
225
|
|
|
143
226
|
if (kind === 'action') {
|
|
144
|
-
|
|
145
|
-
// the ids the contract owns, so nothing here carries a uuid — which is exactly
|
|
146
|
-
// what makes the file applicable to a second deployment.
|
|
147
|
-
return {
|
|
148
|
-
apiName,
|
|
149
|
-
displayName: apiName,
|
|
150
|
-
description: 'CHANGE_ME',
|
|
151
|
-
subject: { objectType: 'CHANGE_ME', mode: 'existing' },
|
|
152
|
-
effect: {
|
|
153
|
-
kind: 'update',
|
|
154
|
-
// Named, but note it cannot be authored from this tree: source systems have no
|
|
155
|
-
// route, and one is synthesised per organization. On a second deployment this
|
|
156
|
-
// name may resolve to nothing.
|
|
157
|
-
authoritySourceSystem: 'CHANGE_ME',
|
|
158
|
-
description: 'CHANGE_ME',
|
|
159
|
-
},
|
|
160
|
-
inputs: [],
|
|
161
|
-
// `min(1)` on the create route: an action that declares no outcome cannot say
|
|
162
|
-
// what it did, so a scaffold without one would not apply as written.
|
|
163
|
-
businessOutcomes: [{
|
|
164
|
-
code: 'applied',
|
|
165
|
-
displayName: 'Applied',
|
|
166
|
-
description: 'CHANGE_ME',
|
|
167
|
-
effect: 'confirmed_applied',
|
|
168
|
-
expectedChanges: [],
|
|
169
|
-
postconditions: [],
|
|
170
|
-
output: [],
|
|
171
|
-
}],
|
|
172
|
-
concurrency: { strategy: 'expected_version' },
|
|
173
|
-
impact: { maxSubjects: 1 },
|
|
174
|
-
submissionCriteria: [],
|
|
175
|
-
governance: {
|
|
176
|
-
// Shaped, not `CHANGE_ME`: this one is validated by
|
|
177
|
-
// `/^[a-z][a-z0-9_-]{0,63}:[a-z][a-z0-9_-]{0,63}$/`, so a bare placeholder is
|
|
178
|
-
// the one field in this file that cannot apply as written — and it fails
|
|
179
|
-
// server-side, after the plan was confirmed.
|
|
180
|
-
invokeCapability: 'changeme:invoke',
|
|
181
|
-
approval: { mode: 'none' },
|
|
182
|
-
reason: 'optional',
|
|
183
|
-
sensitivity: 'internal',
|
|
184
|
-
},
|
|
185
|
-
}
|
|
227
|
+
return actionScaffold(apiName, options)
|
|
186
228
|
}
|
|
187
229
|
|
|
188
230
|
if (kind === 'metric') {
|
|
@@ -48,7 +48,7 @@ export const actionAvailable: Command = {
|
|
|
48
48
|
authLane: 'session',
|
|
49
49
|
scope: 'workspace' as const,
|
|
50
50
|
args: [],
|
|
51
|
-
flags: {},
|
|
51
|
+
flags: { as: 'string' },
|
|
52
52
|
summary: 'List the Actions you can submit in this workspace, and their inputs',
|
|
53
53
|
examples: ['frontera action available', 'frontera action available --json'],
|
|
54
54
|
},
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parse } from 'yaml'
|
|
5
|
+
|
|
6
|
+
import { CliError } from '../../errors'
|
|
7
|
+
import { findSolutionRoot } from '../../local/local-project'
|
|
8
|
+
|
|
9
|
+
/** One criterion a refusal names: its code, and its message when known. */
|
|
10
|
+
export interface FailedCriterion {
|
|
11
|
+
code: string
|
|
12
|
+
message?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The criteria an `ACTION_CRITERIA_FAILED` refusal names (`details.criteria`,
|
|
17
|
+
* at the top or one level under `details`), with each one's message when the
|
|
18
|
+
* refusal carries it.
|
|
19
|
+
*/
|
|
20
|
+
export function failedCriteria(details: unknown): FailedCriterion[] {
|
|
21
|
+
const body = (details && typeof details === 'object' ? details : null) as { criteria?: unknown; details?: { criteria?: unknown } } | null
|
|
22
|
+
const raw = Array.isArray(body?.criteria) ? body.criteria : Array.isArray(body?.details?.criteria) ? body.details.criteria : []
|
|
23
|
+
return raw.flatMap((c) => {
|
|
24
|
+
const entry = (c && typeof c === 'object' ? c : null) as { code?: unknown; criterionId?: unknown; message?: unknown } | null
|
|
25
|
+
const code = typeof entry?.code === 'string' ? entry.code : typeof entry?.criterionId === 'string' ? entry.criterionId : null
|
|
26
|
+
if (!code) return []
|
|
27
|
+
return [{ code, ...(typeof entry?.message === 'string' && entry.message.trim() ? { message: entry.message.trim() } : {}) }]
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The messages the project's own file for an Action gives its submission
|
|
33
|
+
* criteria, by code: `blueprint/actions/<file>.yaml` whose `apiName` (or file
|
|
34
|
+
* name) is the Action's. Empty outside a project, or when no file matches.
|
|
35
|
+
*/
|
|
36
|
+
export function projectCriteriaMessages(cwd: string, apiName: string): Record<string, string> {
|
|
37
|
+
const dir = join(findSolutionRoot(cwd), 'blueprint', 'actions')
|
|
38
|
+
if (!existsSync(dir)) return {}
|
|
39
|
+
for (const name of readdirSync(dir).filter((f) => /\.(ya?ml|json)$/.test(f))) {
|
|
40
|
+
let doc: { apiName?: unknown; submissionCriteria?: unknown } | null
|
|
41
|
+
try {
|
|
42
|
+
doc = parse(readFileSync(join(dir, name), 'utf8')) as typeof doc
|
|
43
|
+
} catch {
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
const matches = doc?.apiName === apiName || (doc?.apiName === undefined && name.replace(/\.(ya?ml|json)$/, '') === apiName)
|
|
47
|
+
if (!matches || !Array.isArray(doc?.submissionCriteria)) continue
|
|
48
|
+
return Object.fromEntries(
|
|
49
|
+
doc.submissionCriteria.flatMap((c: { code?: unknown; message?: unknown }) => (typeof c?.code === 'string' && typeof c.message === 'string' ? [[c.code, c.message.trim()]] : [])),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
return {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The criterion every create carries: the record it would create must not exist yet. */
|
|
56
|
+
export const RECORD_ALREADY_EXISTS = 'record_already_exists'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A submission-criteria refusal said with each criterion's message, not only
|
|
60
|
+
* its code (`still_open: Only a request nobody has started on can be
|
|
61
|
+
* withdrawn`). The message comes from the refusal when the service sends it
|
|
62
|
+
* (it wins over the project's file, which may be ahead of what is deployed),
|
|
63
|
+
* else from the project's file for the Action. Null for any other refusal.
|
|
64
|
+
*/
|
|
65
|
+
export function criteriaRefusal(err: unknown, apiName: string, cwd: string): CliError | null {
|
|
66
|
+
if (!(err instanceof CliError) || err.code !== 'ACTION_CRITERIA_FAILED') return null
|
|
67
|
+
const failed = failedCriteria(err.details)
|
|
68
|
+
if (!failed.length) return null
|
|
69
|
+
const known = failed.some((c) => !c.message) ? projectCriteriaMessages(cwd, apiName) : {}
|
|
70
|
+
const said = failed.map((c) => {
|
|
71
|
+
const message = c.message ?? known[c.code]
|
|
72
|
+
return message ? `${c.code}: ${message}` : c.code
|
|
73
|
+
})
|
|
74
|
+
// A create's own criterion (the platform's, with no project file behind it): the record is there already.
|
|
75
|
+
const exists = failed.length === 1 && failed[0]!.code === RECORD_ALREADY_EXISTS
|
|
76
|
+
return new CliError(`${apiName} refused this request: its submission criteria were not met (${said.join('; ')})`, {
|
|
77
|
+
code: err.code,
|
|
78
|
+
hint: exists
|
|
79
|
+
? 'the record this request would create exists already, so the same request is refused again: give it another identity, or change the existing record with an Action that edits it (`frontera blueprint instance` shows it)'
|
|
80
|
+
: 'the record or the input does not meet the criteria, so the same request is refused again: change the input, or act on a record that meets them (`frontera blueprint instance` shows its current values)',
|
|
81
|
+
details: { reason: 'ACTION_CRITERIA_FAILED', criteria: failed.map((c) => ({ code: c.code, ...(c.message ?? known[c.code] ? { message: c.message ?? known[c.code] } : {}) })) },
|
|
82
|
+
cause: err,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
@@ -33,11 +33,14 @@ function decision(verb: 'approve' | 'reject'): Command {
|
|
|
33
33
|
// `--stage` names the approval-chain step being decided. Required for a
|
|
34
34
|
// request approved in steps; a decision for a step that is no longer
|
|
35
35
|
// the one waiting is refused, not recorded.
|
|
36
|
-
flags: { reason: 'string', stage: 'string' },
|
|
36
|
+
flags: { reason: 'string', stage: 'string', as: 'string' },
|
|
37
37
|
summary: verb === 'approve'
|
|
38
38
|
? 'Approve an Action request waiting on approval'
|
|
39
39
|
: 'Reject an Action request waiting on approval',
|
|
40
|
-
|
|
40
|
+
details: [
|
|
41
|
+
'On the local runtime, `--as <persona>` decides as a local persona: `local dev` seeds one per approval step in blueprint/actions/ (`--as dispatch_lead`), plus `submitter`. Refused anywhere else.',
|
|
42
|
+
],
|
|
43
|
+
examples: [`frontera action ${verb} <requestId> --reason "Checked against the ticket"`, `frontera action ${verb} <requestId> --stage lead --reason "Checked" --as lead`],
|
|
41
44
|
},
|
|
42
45
|
|
|
43
46
|
async run(ctx: CommandContext) {
|