@frontera-sdk/cli 1.52.39 → 1.52.41
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 +49 -5
- 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/draft-changes.ts +1 -1
- 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 +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- 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 +876 -89
- 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 +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- 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/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- 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 +113 -2
- package/src/kit.ts +1 -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 +212 -22
- 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 +181 -8
- 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 +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- 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-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- 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 +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- 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 +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- 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-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- 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/scopes.ts +1 -1
- 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 +17 -14
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
5
|
+
|
|
6
|
+
import { parse } from 'yaml'
|
|
7
|
+
|
|
8
|
+
import { allTargets } from './local-dev-sync'
|
|
9
|
+
import { contentHash, loadBuild, type BuildEntry } from './local-applied'
|
|
10
|
+
import type { LocalProject } from './local-project'
|
|
11
|
+
import { call, type Api } from './local-test-runner'
|
|
12
|
+
import type { LocalCredentials } from './local-state'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A workflow's test cases, run on the local runtime by `frontera local test`.
|
|
16
|
+
*
|
|
17
|
+
* workflows/tests/<slug>.yaml cases for workflows/<slug>.yaml
|
|
18
|
+
*
|
|
19
|
+
* workflow: claim-intake optional; the file's name when absent
|
|
20
|
+
* cases:
|
|
21
|
+
* - name: approves a small claim
|
|
22
|
+
* input: { amount: 120 } the run's input, as its trigger declares it
|
|
23
|
+
* subject: CLM-0001 optional: the record a record-bound workflow runs on
|
|
24
|
+
* timeoutSeconds: 120 optional; 120 when absent
|
|
25
|
+
* expect:
|
|
26
|
+
* status: succeeded succeeded (the default), failed or canceled
|
|
27
|
+
* failedStep: check-limit the step that must fail (implies failed); a `stop` with status failed counts
|
|
28
|
+
* failedCode: CONFIRM_UNAVAILABLE the error code a failed step (or the run) must carry (implies failed)
|
|
29
|
+
* output: checks on what the run returned, each one must pass
|
|
30
|
+
* - path: decision a dot path into what the run returned (`output.decision` too, unless it returned a key named `output`)
|
|
31
|
+
* equals: approved
|
|
32
|
+
* - path: steps.score.output.reasons `steps.<id>.` reads a step's own output
|
|
33
|
+
* - path: steps.each.output.0.submit.requestId a forEach step's output is its list of iterations, `{ <innerStepId>: <its output> }`
|
|
34
|
+
* contains: under limit
|
|
35
|
+
* - path: note
|
|
36
|
+
* exists: false
|
|
37
|
+
*
|
|
38
|
+
* Each case runs exactly the version `local dev` pushed the workflow's file
|
|
39
|
+
* as (the build record keeps it, with the file's hash): as a test run when it
|
|
40
|
+
* is a draft, so it never counts as a live run, or as a run of the live
|
|
41
|
+
* version once a binding promoted it. When the file changed since it was
|
|
42
|
+
* applied, or its apply failed, the cases are refused with what to run, never
|
|
43
|
+
* run on code that is neither the file nor live. A workflow the project has
|
|
44
|
+
* no file for runs its newest draft (else its newest version). The run is
|
|
45
|
+
* waited for, then judged.
|
|
46
|
+
*
|
|
47
|
+
* The folder sits beside the workflows, one level down, so `local dev`
|
|
48
|
+
* never takes a test file for a workflow.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export interface WorkflowOutputCheck {
|
|
52
|
+
path: string
|
|
53
|
+
equals?: unknown
|
|
54
|
+
contains?: unknown
|
|
55
|
+
exists?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WorkflowTestCase {
|
|
59
|
+
name: string
|
|
60
|
+
input?: Record<string, unknown>
|
|
61
|
+
subject?: string
|
|
62
|
+
timeoutSeconds?: number
|
|
63
|
+
expect?: {
|
|
64
|
+
status?: 'succeeded' | 'failed' | 'canceled'
|
|
65
|
+
failedStep?: string
|
|
66
|
+
/** The error code a failed step, or the run, must carry (`CONFIRM_UNAVAILABLE`). */
|
|
67
|
+
failedCode?: string
|
|
68
|
+
output?: WorkflowOutputCheck[]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface WorkflowTestFile {
|
|
73
|
+
workflow: string
|
|
74
|
+
file: string
|
|
75
|
+
cases: WorkflowTestCase[]
|
|
76
|
+
/** Why the file cannot run, said once per case. */
|
|
77
|
+
problem?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface WorkflowTestResult {
|
|
81
|
+
workflow: string
|
|
82
|
+
file: string
|
|
83
|
+
name: string
|
|
84
|
+
passed: boolean
|
|
85
|
+
reason?: string
|
|
86
|
+
ms: number
|
|
87
|
+
runId?: string
|
|
88
|
+
version?: number
|
|
89
|
+
status?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The test folder, relative to the project root. */
|
|
93
|
+
export const WORKFLOW_TESTS_DIR = join('workflows', 'tests')
|
|
94
|
+
|
|
95
|
+
/** What a case file must hold, said as a problem rather than thrown, so one bad file fails only its own cases. */
|
|
96
|
+
function readCases(doc: unknown): { cases: WorkflowTestCase[]; problem?: string } {
|
|
97
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { cases: [], problem: 'the file is not a mapping with `cases:`' }
|
|
98
|
+
const raw = (doc as { cases?: unknown }).cases
|
|
99
|
+
if (!Array.isArray(raw)) return { cases: [], problem: '`cases` is not a list' }
|
|
100
|
+
const cases: WorkflowTestCase[] = []
|
|
101
|
+
for (const [i, c] of raw.entries()) {
|
|
102
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) return { cases, problem: `case ${i + 1} is not a mapping` }
|
|
103
|
+
const o = c as Record<string, unknown>
|
|
104
|
+
if (typeof o.name !== 'string' || !o.name.trim()) return { cases, problem: `case ${i + 1} has no \`name\`` }
|
|
105
|
+
if (o.input !== undefined && (!o.input || typeof o.input !== 'object' || Array.isArray(o.input))) return { cases, problem: `case "${o.name}": \`input\` must be a mapping` }
|
|
106
|
+
if (o.subject !== undefined && typeof o.subject !== 'string') return { cases, problem: `case "${o.name}": \`subject\` must be the record's primary key, as text` }
|
|
107
|
+
cases.push(o as unknown as WorkflowTestCase)
|
|
108
|
+
}
|
|
109
|
+
return { cases }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Every workflow test file in the project, optionally only one workflow's. */
|
|
113
|
+
export function findWorkflowTestFiles(root: string, workflow?: string): WorkflowTestFile[] {
|
|
114
|
+
const dir = join(root, WORKFLOW_TESTS_DIR)
|
|
115
|
+
if (!existsSync(dir)) return []
|
|
116
|
+
const out: WorkflowTestFile[] = []
|
|
117
|
+
for (const name of readdirSync(dir).sort()) {
|
|
118
|
+
if (!/\.ya?ml$/.test(name) || name.startsWith('.')) continue
|
|
119
|
+
const path = join(dir, name)
|
|
120
|
+
const file = relative(root, path)
|
|
121
|
+
let doc: unknown
|
|
122
|
+
try {
|
|
123
|
+
doc = parse(readFileSync(path, 'utf8'))
|
|
124
|
+
} catch (err) {
|
|
125
|
+
out.push({ workflow: name.replace(/\.ya?ml$/, ''), file, cases: [], problem: `the file is not YAML: ${(err as Error).message.split('\n')[0]}` })
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const named = (doc as { workflow?: unknown } | null)?.workflow
|
|
129
|
+
const slug = typeof named === 'string' && named ? named : name.replace(/\.ya?ml$/, '')
|
|
130
|
+
if (workflow && slug !== workflow) continue
|
|
131
|
+
const { cases, problem } = readCases(doc)
|
|
132
|
+
out.push({ workflow: slug, file, cases, ...(problem ? { problem } : {}) })
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A forEach step's output as the run keeps it: `{ mode, count, results }`, where `$steps` gives the list. */
|
|
138
|
+
function isForEachView(v: unknown): v is { results: unknown[]; count?: number; mode?: string } {
|
|
139
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) && Array.isArray((v as { results?: unknown }).results) && typeof (v as { count?: unknown }).count === 'number' && typeof (v as { mode?: unknown }).mode === 'string'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `decision`, `items.0.id`, `steps.score.output.reasons`: a value in the run,
|
|
144
|
+
* or `undefined` when the path leads nowhere. A path reads what the run
|
|
145
|
+
* returned; a leading `output.` names that too (`output.decision` is
|
|
146
|
+
* `decision`), except when what it returned has a key named `output`, which
|
|
147
|
+
* the path then reads (`output.withdrawn` of `{ runId, output: { withdrawn } }`). A forEach step's output reads
|
|
148
|
+
* as `$steps` gives it, the list of its iterations (`steps.each.output.0.x`,
|
|
149
|
+
* `steps.each.output.length`); the run's own view of it (`.results.0.x`,
|
|
150
|
+
* `.count`, `.mode`) reads too. An iteration is `{ <innerStepId>: <its output> }`,
|
|
151
|
+
* so `steps.each.output.0.submit.requestId` reads an inner step's output; the
|
|
152
|
+
* older `steps.each.output.0.submit.output.requestId` form reads the same value.
|
|
153
|
+
*/
|
|
154
|
+
export function valueAt(run: { output: unknown; nodes?: Array<{ nodeId: string; output?: unknown; status?: string; error?: unknown }> }, path: string): unknown {
|
|
155
|
+
const parts = path.split('.').filter(Boolean)
|
|
156
|
+
let at: unknown = run.output
|
|
157
|
+
// Where, in `parts`, an iteration's inner step `output` segment may sit (forEach only).
|
|
158
|
+
let innerOutputAt = -1
|
|
159
|
+
if (parts[0] === 'steps' && parts.length >= 2) {
|
|
160
|
+
const node = (run.nodes ?? []).find((n) => n.nodeId === parts[1])
|
|
161
|
+
if (!node) return undefined
|
|
162
|
+
at = node
|
|
163
|
+
parts.splice(0, 2)
|
|
164
|
+
const head: string | undefined = parts[0]
|
|
165
|
+
if (head === 'output' && isForEachView(node.output)) {
|
|
166
|
+
const view = node.output
|
|
167
|
+
parts.shift()
|
|
168
|
+
const next: string | undefined = parts[0]
|
|
169
|
+
at = next === 'results' || next === 'count' || next === 'mode' ? view : view.results
|
|
170
|
+
// `<index>.<innerStepId>.output.<path>` (or `results.<index>.…`)
|
|
171
|
+
innerOutputAt = next === 'results' ? 3 : 2
|
|
172
|
+
}
|
|
173
|
+
} else if (parts[0] === 'output' && !(isRecord(run.output) && 'output' in run.output)) {
|
|
174
|
+
// `output.decision` and `decision` are the same path, unless what the run returned has a key named
|
|
175
|
+
// `output` (`{ runId, output }`, a Function step's result returned whole): then the path reads that key.
|
|
176
|
+
parts.shift()
|
|
177
|
+
}
|
|
178
|
+
for (const [i, part] of parts.entries()) {
|
|
179
|
+
// An iteration holds each inner step's output directly (`{ escalate: { lifecycle } }`), so
|
|
180
|
+
// `0.escalate.output.lifecycle` and `0.escalate.lifecycle` are the same path.
|
|
181
|
+
if (i === innerOutputAt && part === 'output' && isRecord(at) && !('output' in at)) continue
|
|
182
|
+
if (Array.isArray(at) && /^\d+$/.test(part)) at = at[Number(part)]
|
|
183
|
+
else if (Array.isArray(at) && part === 'length') at = at.length
|
|
184
|
+
else if (at && typeof at === 'object') at = (at as Record<string, unknown>)[part]
|
|
185
|
+
else return undefined
|
|
186
|
+
}
|
|
187
|
+
return at
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
191
|
+
|
|
192
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
193
|
+
if (a === b) return true
|
|
194
|
+
// A number written in YAML and the same number the run returned as text are not equal: types count.
|
|
195
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]))
|
|
196
|
+
if (isRecord(a) && isRecord(b)) {
|
|
197
|
+
const keys = Object.keys(a)
|
|
198
|
+
return keys.length === Object.keys(b).length && keys.every((k) => deepEqual(a[k], b[k]))
|
|
199
|
+
}
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Whether `actual` contains `expected`: text holds it (ignoring case), a list has an equal item, a mapping holds its keys. */
|
|
204
|
+
export function containsValue(actual: unknown, expected: unknown): boolean {
|
|
205
|
+
if (typeof actual === 'string' && typeof expected === 'string') return actual.toLowerCase().includes(expected.toLowerCase())
|
|
206
|
+
if (Array.isArray(actual)) return actual.some((item) => deepEqual(item, expected) || (isRecord(item) && isRecord(expected) && containsValue(item, expected)))
|
|
207
|
+
if (isRecord(actual) && isRecord(expected)) return Object.entries(expected).every(([k, v]) => k in actual && (deepEqual(actual[k], v) || containsValue(actual[k], v)))
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const show = (v: unknown) => (v === undefined ? 'nothing' : JSON.stringify(v))
|
|
212
|
+
|
|
213
|
+
/** Why one output check failed, or null when it passed. */
|
|
214
|
+
export function runOutputCheck(check: WorkflowOutputCheck, run: Parameters<typeof valueAt>[0]): string | null {
|
|
215
|
+
if (typeof check.path !== 'string' || !check.path) return 'an output check has no `path`'
|
|
216
|
+
const actual = valueAt(run, check.path)
|
|
217
|
+
if (check.exists !== undefined) {
|
|
218
|
+
const present = actual !== undefined && actual !== null
|
|
219
|
+
if (present !== check.exists) return check.exists ? `${check.path} is missing` : `${check.path} is ${show(actual)}, and the case expects nothing there`
|
|
220
|
+
}
|
|
221
|
+
if ('equals' in check && !deepEqual(actual, check.equals)) return `${check.path} is ${show(actual)}, not ${show(check.equals)}`
|
|
222
|
+
if ('contains' in check && !containsValue(actual, check.contains)) return `${check.path} is ${show(actual)}, which does not contain ${show(check.contains)}`
|
|
223
|
+
if (check.exists === undefined && !('equals' in check) && !('contains' in check)) return `the check on ${check.path} says neither equals, contains nor exists`
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface JudgedRun {
|
|
228
|
+
status: string
|
|
229
|
+
error?: unknown
|
|
230
|
+
output: unknown
|
|
231
|
+
nodes?: Array<{ nodeId: string; kind?: string; status?: string; output?: unknown; error?: unknown; childRun?: JudgedRun | null }>
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The steps that failed, nested ones included, in the order the run holds
|
|
236
|
+
* them. A `stop` step with `status: failed` is recorded as succeeded (it did
|
|
237
|
+
* what it says) while it fails its run: in a failed run, the stop that ended
|
|
238
|
+
* it counts as the step that failed, so `failedStep` can name it.
|
|
239
|
+
*/
|
|
240
|
+
function failedSteps(run: JudgedRun): string[] {
|
|
241
|
+
const nodes = run.nodes ?? []
|
|
242
|
+
const own = nodes.flatMap((n) => [...(n.status === 'failed' ? [n.nodeId] : []), ...(n.childRun ? failedSteps(n.childRun) : [])])
|
|
243
|
+
if (run.status !== 'failed') return own
|
|
244
|
+
// The last stop that ran is the one that ended the run.
|
|
245
|
+
const stop = [...nodes].reverse().find((n) => n.kind === 'stop' && n.status === 'succeeded')
|
|
246
|
+
return stop && !own.includes(stop.nodeId) ? [...own, stop.nodeId] : own
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function errorText(error: unknown): string {
|
|
250
|
+
if (!error) return ''
|
|
251
|
+
if (typeof error === 'string') return error
|
|
252
|
+
const { message, issues } = error as { message?: unknown; issues?: unknown }
|
|
253
|
+
if (typeof message !== 'string') return JSON.stringify(error)
|
|
254
|
+
// Each input the step's error names (`issues`), unless the message says them already.
|
|
255
|
+
const named = (Array.isArray(issues) ? issues : []).flatMap((i) => {
|
|
256
|
+
const issue = (i && typeof i === 'object' ? i : {}) as { path?: unknown; message?: unknown }
|
|
257
|
+
return typeof issue.message === 'string' && !message.includes(issue.message) ? [`${typeof issue.path === 'string' && issue.path ? `${issue.path}: ` : ''}${issue.message}`] : []
|
|
258
|
+
})
|
|
259
|
+
return named.length ? `${message} (${named.join('; ')})` : message
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** An error's code: `{ code }`, or `CODE: message` as text. */
|
|
263
|
+
function errorCode(error: unknown): string | null {
|
|
264
|
+
if (!error) return null
|
|
265
|
+
if (typeof error === 'string') return /^([A-Z][A-Z0-9_]{2,})\b/.exec(error)?.[1] ?? null
|
|
266
|
+
const code = (error as { code?: unknown }).code
|
|
267
|
+
return typeof code === 'string' ? code : null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The error codes the run's failed steps (nested ones included) and the run itself carry. */
|
|
271
|
+
function failureCodes(run: JudgedRun): string[] {
|
|
272
|
+
const nodes = (run.nodes ?? []).flatMap((n) => [...(n.status === 'failed' ? [errorCode(n.error)] : []), ...(n.childRun ? failureCodes(n.childRun) : [])])
|
|
273
|
+
return [...new Set([...nodes, errorCode(run.error)].filter((c): c is string => !!c))]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Whether a finished run is what the case expects; the first thing that differs when it is not. */
|
|
277
|
+
export function judgeRun(c: WorkflowTestCase, run: JudgedRun): { passed: boolean; reason?: string } {
|
|
278
|
+
const failedStep = c.expect?.failedStep
|
|
279
|
+
const failedCode = c.expect?.failedCode
|
|
280
|
+
const want = c.expect?.status ?? (failedStep || failedCode ? 'failed' : 'succeeded')
|
|
281
|
+
if (run.status !== want) {
|
|
282
|
+
const failed = failedSteps(run)
|
|
283
|
+
const why = run.status === 'failed' ? `${failed.length ? ` at ${failed.join(', ')}` : ''}${run.error ? `: ${errorText(run.error).slice(0, 300)}` : ''}` : ''
|
|
284
|
+
return { passed: false, reason: `the run ${run.status}${why}; the case expects ${want}` }
|
|
285
|
+
}
|
|
286
|
+
if (failedStep) {
|
|
287
|
+
const failed = failedSteps(run)
|
|
288
|
+
if (!failed.includes(failedStep)) return { passed: false, reason: `step ${failedStep} did not fail; ${failed.length ? `${failed.join(', ')} did` : 'no step failed'}` }
|
|
289
|
+
}
|
|
290
|
+
if (failedCode) {
|
|
291
|
+
const codes = failureCodes(run)
|
|
292
|
+
if (!codes.includes(failedCode)) return { passed: false, reason: `no step failed with ${failedCode}; ${codes.length ? `the codes were ${codes.join(', ')}` : 'no failure carried a code'}` }
|
|
293
|
+
}
|
|
294
|
+
for (const check of c.expect?.output ?? []) {
|
|
295
|
+
const why = runOutputCheck(check, run)
|
|
296
|
+
if (why) return { passed: false, reason: why }
|
|
297
|
+
}
|
|
298
|
+
return { passed: true }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const POLL_MS = 1_500
|
|
302
|
+
const DEFAULT_TIMEOUT_S = 120
|
|
303
|
+
|
|
304
|
+
/** The version a case runs: the newest draft, else the newest version, else none (never pushed). */
|
|
305
|
+
export function versionToTest(versions: Array<{ number: number; status: string }>): { number: number; draft: boolean } | null {
|
|
306
|
+
const sorted = [...versions].sort((a, b) => b.number - a.number)
|
|
307
|
+
const draft = sorted.find((v) => v.status === 'draft')
|
|
308
|
+
if (draft) return { number: draft.number, draft: true }
|
|
309
|
+
const newest = sorted.find((v) => v.status !== 'retired')
|
|
310
|
+
return newest ? { number: newest.number, draft: false } : null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A workflow's file in the project and what `local dev` last did with it (the build record). */
|
|
314
|
+
export interface WorkflowFileState {
|
|
315
|
+
/** `workflows/<slug>.yaml`. */
|
|
316
|
+
file: string
|
|
317
|
+
/** The file's content hash now. */
|
|
318
|
+
hash: string | null
|
|
319
|
+
/** The build record's entry for it; absent when it was never applied to this runtime. */
|
|
320
|
+
applied?: Pick<BuildEntry, 'hash' | 'status' | 'version' | 'error'>
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The project's file for a workflow and its build record, or null when the project has no file for the slug. */
|
|
324
|
+
export function workflowFileState(project: LocalProject, organizationId: string | null, slug: string): WorkflowFileState | null {
|
|
325
|
+
const target = allTargets(project.root).find((t) => t.kind === 'workflow' && t.slug === slug)
|
|
326
|
+
if (!target || target.kind !== 'workflow') return null
|
|
327
|
+
const entry = loadBuild(project, organizationId)[target.key]
|
|
328
|
+
return { file: target.file, hash: contentHash(project, target), ...(entry ? { applied: entry } : {}) }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const APPLY_AGAIN = 'run `frontera local dev --once` (or keep `frontera local dev` running), then test again'
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The version a case runs when the project has the workflow's file: the one
|
|
335
|
+
* `local dev` pushed that file as, live or draft, provided the runtime still
|
|
336
|
+
* has it and the file has not changed since. Anything else is a problem to
|
|
337
|
+
* say, not a version to guess.
|
|
338
|
+
*/
|
|
339
|
+
export function versionForFile(slug: string, state: WorkflowFileState, versions: Array<{ number: number; status: string }>): { number: number; draft: boolean } | { problem: string } {
|
|
340
|
+
const { applied, file } = state
|
|
341
|
+
if (!applied) return { problem: `${file} is not applied to the local runtime yet: ${APPLY_AGAIN}` }
|
|
342
|
+
if (applied.status === 'failed' || applied.status === 'blocked') {
|
|
343
|
+
return { problem: `${file} did not apply (${applied.status}${applied.error ? `: ${applied.error}` : ''}), so the runtime does not run it: fix it, ${APPLY_AGAIN}` }
|
|
344
|
+
}
|
|
345
|
+
if (applied.hash !== state.hash) return { problem: `${file} changed since \`local dev\` applied it${applied.version ? ` (as v${applied.version})` : ''}, so the runtime does not run what the file says: ${APPLY_AGAIN}` }
|
|
346
|
+
if (!applied.version) return { problem: `the build record does not say which version ${file} was pushed as (it was applied by an earlier release): ${APPLY_AGAIN}` }
|
|
347
|
+
const found = versions.find((v) => v.number === applied.version)
|
|
348
|
+
if (!found) return { problem: `workflow ${slug} v${applied.version}, which \`local dev\` pushed ${file} as, is not on the runtime: run \`frontera local dev --once --reapply\`, then test again` }
|
|
349
|
+
if (found.status === 'retired') return { problem: `workflow ${slug} v${applied.version}, which \`local dev\` pushed ${file} as, is retired: run \`frontera local dev --once --reapply\`, then test again` }
|
|
350
|
+
return { number: found.number, draft: found.status === 'draft' }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function runWorkflowTests(args: {
|
|
354
|
+
serviceUrl: string
|
|
355
|
+
creds: LocalCredentials
|
|
356
|
+
files: WorkflowTestFile[]
|
|
357
|
+
onResult: (r: WorkflowTestResult) => void
|
|
358
|
+
wait?: (ms: number) => Promise<void>
|
|
359
|
+
/**
|
|
360
|
+
* The project's file for a workflow and its build record (`workflowFileState`):
|
|
361
|
+
* its cases run the version the file was pushed as. Null (or absent) for a
|
|
362
|
+
* workflow the project has no file for: its newest draft runs.
|
|
363
|
+
*/
|
|
364
|
+
fileState?: (slug: string) => WorkflowFileState | null
|
|
365
|
+
}): Promise<WorkflowTestResult[]> {
|
|
366
|
+
const api: Api = { serviceUrl: args.serviceUrl, creds: args.creds }
|
|
367
|
+
const wait = args.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
|
|
368
|
+
const results: WorkflowTestResult[] = []
|
|
369
|
+
const done = (r: WorkflowTestResult) => {
|
|
370
|
+
results.push(r)
|
|
371
|
+
args.onResult(r)
|
|
372
|
+
}
|
|
373
|
+
for (const file of args.files) {
|
|
374
|
+
const base = { workflow: file.workflow, file: file.file }
|
|
375
|
+
if (file.problem) {
|
|
376
|
+
done({ ...base, name: file.cases[0]?.name ?? file.file, passed: false, reason: `${file.file}: ${file.problem}`, ms: 0 })
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
let version: { number: number; draft: boolean } | null = null
|
|
380
|
+
let setup: string | undefined
|
|
381
|
+
try {
|
|
382
|
+
const versions = await call<Array<{ number: number; status: string }>>(api, `/v1/workflows/${encodeURIComponent(file.workflow)}/versions`)
|
|
383
|
+
const state = args.fileState?.(file.workflow) ?? null
|
|
384
|
+
const pinned = state ? versionForFile(file.workflow, state, versions) : versionToTest(versions)
|
|
385
|
+
if (pinned && 'problem' in pinned) setup = pinned.problem
|
|
386
|
+
else version = pinned
|
|
387
|
+
if (!version && !setup) setup = `workflow ${file.workflow} has no version on the runtime: run \`frontera local dev\``
|
|
388
|
+
} catch (err) {
|
|
389
|
+
setup = /answered 404/.test((err as Error).message) ? `workflow ${file.workflow} is not on the runtime: add workflows/${file.workflow}.yaml and run \`frontera local dev\`` : (err as Error).message
|
|
390
|
+
}
|
|
391
|
+
for (const c of file.cases) {
|
|
392
|
+
const started = Date.now()
|
|
393
|
+
if (setup || !version) {
|
|
394
|
+
done({ ...base, name: c.name, passed: false, reason: setup ?? 'no version to run', ms: 0 })
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const run = await call<{ runId: string; versionNumber: number }>(api, `/v1/workflows/${encodeURIComponent(file.workflow)}/run`, {
|
|
399
|
+
body: {
|
|
400
|
+
input: c.input ?? {},
|
|
401
|
+
...(c.subject ? { subject: { pk: c.subject } } : {}),
|
|
402
|
+
version: version.number,
|
|
403
|
+
// A draft's run is a test run: it never counts as a live one.
|
|
404
|
+
...(version.draft ? { source: 'test' } : {}),
|
|
405
|
+
},
|
|
406
|
+
})
|
|
407
|
+
const deadline = started + Math.max(1, c.timeoutSeconds ?? DEFAULT_TIMEOUT_S) * 1000
|
|
408
|
+
let seen: (JudgedRun & { id?: string }) | null = null
|
|
409
|
+
for (;;) {
|
|
410
|
+
seen = await call<JudgedRun & { id?: string }>(api, `/v1/workflows/runs/${encodeURIComponent(run.runId)}?depth=all`)
|
|
411
|
+
if (['succeeded', 'failed', 'canceled', 'cancelled'].includes(seen.status)) break
|
|
412
|
+
if (Date.now() >= deadline) break
|
|
413
|
+
await wait(POLL_MS)
|
|
414
|
+
}
|
|
415
|
+
const finished = ['succeeded', 'failed', 'canceled', 'cancelled'].includes(seen.status)
|
|
416
|
+
const verdict = finished ? judgeRun(c, { ...seen, status: seen.status === 'cancelled' ? 'canceled' : seen.status }) : { passed: false, reason: `the run was still ${seen.status} after ${c.timeoutSeconds ?? DEFAULT_TIMEOUT_S}s` }
|
|
417
|
+
done({ ...base, name: c.name, passed: verdict.passed, ...(verdict.reason ? { reason: verdict.reason } : {}), ms: Date.now() - started, runId: run.runId, version: run.versionNumber, status: seen.status })
|
|
418
|
+
} catch (err) {
|
|
419
|
+
done({ ...base, name: c.name, passed: false, reason: (err as Error).message, ms: Date.now() - started })
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return results
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A workflow test case's last local run: what Studio's Evaluations and Forge's home show beside it. */
|
|
427
|
+
export interface WorkflowCaseOutcome {
|
|
428
|
+
passed: boolean
|
|
429
|
+
at: string
|
|
430
|
+
reason?: string
|
|
431
|
+
runId?: string
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Last runs by case: `<workflow>\u0000<file>\u0000<name>`. */
|
|
435
|
+
export type WorkflowTestRun = Record<string, WorkflowCaseOutcome>
|
|
436
|
+
|
|
437
|
+
export function workflowCaseKey(r: { workflow: string; file: string; name: string }): string {
|
|
438
|
+
return `${r.workflow}\u0000${r.file}\u0000${r.name}`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const workflowRunPath = (stateDir: string) => join(stateDir, 'workflow-test-results.json')
|
|
442
|
+
|
|
443
|
+
/** Each workflow case's last run, as `frontera local test` and `local check` left it; none before one ran. */
|
|
444
|
+
export function readWorkflowTestRun(stateDir: string): WorkflowTestRun {
|
|
445
|
+
try {
|
|
446
|
+
return JSON.parse(readFileSync(workflowRunPath(stateDir), 'utf8')) as WorkflowTestRun
|
|
447
|
+
} catch {
|
|
448
|
+
return {}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Keep these results as each case's last run; cases that did not run keep theirs. Never throws. */
|
|
453
|
+
export function recordWorkflowTestRun(project: LocalProject, results: WorkflowTestResult[], at = new Date().toISOString()): void {
|
|
454
|
+
if (!results.length) return
|
|
455
|
+
try {
|
|
456
|
+
const last = readWorkflowTestRun(project.stateDir)
|
|
457
|
+
for (const r of results) last[workflowCaseKey(r)] = { passed: r.passed, at, ...(r.passed || !r.reason ? {} : { reason: r.reason.slice(0, 300) }), ...(r.runId ? { runId: r.runId } : {}) }
|
|
458
|
+
writeFileAtomic(workflowRunPath(project.stateDir), `${JSON.stringify(last, null, 2)}\n`, 0o644)
|
|
459
|
+
} catch {
|
|
460
|
+
// The run's results were said; keeping them is a convenience.
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** A workflow's test file with each case's name and its last run, for Studio's Evaluations. */
|
|
465
|
+
export interface WorkflowTestListing {
|
|
466
|
+
workflow: string
|
|
467
|
+
file: string
|
|
468
|
+
cases: number
|
|
469
|
+
list: Array<{ name: string; last: WorkflowCaseOutcome | null }>
|
|
470
|
+
problem?: string
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function workflowTestListing(root: string, stateDir: string): WorkflowTestListing[] {
|
|
474
|
+
const last = readWorkflowTestRun(stateDir)
|
|
475
|
+
return findWorkflowTestFiles(root).map((f) => ({
|
|
476
|
+
workflow: f.workflow,
|
|
477
|
+
file: f.file,
|
|
478
|
+
cases: f.cases.length,
|
|
479
|
+
list: f.cases.map((c) => ({ name: c.name, last: last[workflowCaseKey({ workflow: f.workflow, file: f.file, name: c.name })] ?? null })),
|
|
480
|
+
...(f.problem ? { problem: f.problem } : {}),
|
|
481
|
+
}))
|
|
482
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parse as parseYaml } from 'yaml'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `blueprint/workspace-grants.yaml`: what the workspace itself — its
|
|
8
|
+
* workflows, Functions and Apps, which read through the workspace rather
|
|
9
|
+
* than as an Agent — may read of an object type beyond plain `read`. Today
|
|
10
|
+
* that is `read_sensitive`, clearance over a type's `confidential` and
|
|
11
|
+
* `restricted` fields (an Agent's own clearance is in its bindings.json).
|
|
12
|
+
*
|
|
13
|
+
* grants:
|
|
14
|
+
* - objectType: objectType/Customer
|
|
15
|
+
* permissions: [read, read_sensitive]
|
|
16
|
+
*
|
|
17
|
+
* `read` is always included; `read_sensitive` is the only other permission.
|
|
18
|
+
* `local dev` applies it after each Blueprint publish (every other object
|
|
19
|
+
* type is granted plain `read`). A pull writes it when the workspace holds
|
|
20
|
+
* `read_sensitive` on some type (none: no file), and a change request
|
|
21
|
+
* carries it as the Blueprint item `blueprint/workspace-grants`: each type
|
|
22
|
+
* it lists gets the listed grant, a type it leaves out keeps its own. A
|
|
23
|
+
* deletion is never proposed, since deleting the file changes no grant: to
|
|
24
|
+
* take `read_sensitive` away, list the type with `[read]`.
|
|
25
|
+
*/
|
|
26
|
+
export const WORKSPACE_GRANTS_FILE = 'blueprint/workspace-grants.yaml'
|
|
27
|
+
/** The item's name under `blueprint/`: its ref is `blueprint/workspace-grants`. */
|
|
28
|
+
export const WORKSPACE_GRANTS_NAME = 'workspace-grants'
|
|
29
|
+
export const WORKSPACE_GRANTS_REF = `blueprint/${WORKSPACE_GRANTS_NAME}`
|
|
30
|
+
export const WORKSPACE_PERMISSIONS = ['read', 'read_sensitive'] as const
|
|
31
|
+
|
|
32
|
+
export interface WorkspaceGrant {
|
|
33
|
+
/** The object type's API name. */
|
|
34
|
+
objectType: string
|
|
35
|
+
permissions: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The file's grants, and what is wrong with it; null when the project has none. */
|
|
39
|
+
export function readWorkspaceGrants(root: string): { grants: WorkspaceGrant[]; problems: string[] } | null {
|
|
40
|
+
const path = join(root, WORKSPACE_GRANTS_FILE)
|
|
41
|
+
if (!existsSync(path)) return null
|
|
42
|
+
let doc: unknown
|
|
43
|
+
try {
|
|
44
|
+
doc = parseYaml(readFileSync(path, 'utf8'))
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { grants: [], problems: [`${WORKSPACE_GRANTS_FILE} is not YAML: ${(err as Error).message.split('\n')[0]}`] }
|
|
47
|
+
}
|
|
48
|
+
const list = (doc as { grants?: unknown } | null)?.grants
|
|
49
|
+
if (doc === null || doc === undefined) return { grants: [], problems: [] }
|
|
50
|
+
if (!Array.isArray(list)) return { grants: [], problems: [`${WORKSPACE_GRANTS_FILE} needs a \`grants:\` list (\`frontera local schema blueprint-workspace-grants\`)`] }
|
|
51
|
+
const grants: WorkspaceGrant[] = []
|
|
52
|
+
const problems: string[] = []
|
|
53
|
+
list.forEach((entry, i) => {
|
|
54
|
+
const at = `${WORKSPACE_GRANTS_FILE}: grants.${i}`
|
|
55
|
+
const e = (entry && typeof entry === 'object' ? entry : {}) as { objectType?: unknown; permissions?: unknown }
|
|
56
|
+
const ref = typeof e.objectType === 'string' ? e.objectType.replace(/^objectType\//, '') : ''
|
|
57
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(ref)) {
|
|
58
|
+
problems.push(`${at}: objectType names an object type, as objectType/<ApiName>`)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
const given = Array.isArray(e.permissions) ? e.permissions.filter((p): p is string => typeof p === 'string') : []
|
|
62
|
+
const unknown = given.filter((p) => !(WORKSPACE_PERMISSIONS as readonly string[]).includes(p))
|
|
63
|
+
if (unknown.length) {
|
|
64
|
+
problems.push(`${at}: ${unknown.join(', ')} ${unknown.length === 1 ? 'is not a permission' : 'are not permissions'} a workspace grant takes (read, read_sensitive)`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
grants.push({ objectType: ref, permissions: ['read', ...(given.includes('read_sensitive') ? ['read_sensitive'] : [])] })
|
|
68
|
+
})
|
|
69
|
+
return { grants, problems }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The file's types granted more than `read`, by permission set: `read,read_sensitive` → [Customer]. */
|
|
73
|
+
export function widerGrants(grants: WorkspaceGrant[]): Map<string, string[]> {
|
|
74
|
+
const out = new Map<string, string[]>()
|
|
75
|
+
for (const g of grants) {
|
|
76
|
+
if (g.permissions.length <= 1) continue
|
|
77
|
+
const key = g.permissions.join(',')
|
|
78
|
+
out.set(key, [...(out.get(key) ?? []), g.objectType])
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|