@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.
Files changed (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -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
+ }