@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,611 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse } from 'yaml'
5
+
6
+ import type { RunTree, VersionSummary, WebhookListen } from '../../api/workflow-api'
7
+ import { METRIC_GRAINS, metricQueryBody, metricRows, resolveRangeWindow, timeseriesOf, type MetricQueryResult } from '../../blueprint/metric-query'
8
+ import type { LocalProject } from '../../local/local-project'
9
+ import { errorResult, intArg, jsonResult, stringArg, type McpTool } from '../mcp-protocol'
10
+ import { requireRuntime, serviceCall, studioLink, type RuntimeLink } from '../mcp-runtime'
11
+
12
+ /**
13
+ * Run a workflow and read the Blueprint's records on the local runtime.
14
+ * Records are read only: writes go through the governed Actions an App or an
15
+ * Agent calls, never through MCP, so what the agent tests is the same path a
16
+ * person's request takes.
17
+ */
18
+
19
+ const TERMINAL = new Set(['succeeded', 'failed', 'canceled', 'cancelled'])
20
+
21
+ const OUTPUT_CLIP = 2_000
22
+
23
+ const fits = (value: unknown) => (JSON.stringify(value) ?? 'null').length <= OUTPUT_CLIP
24
+
25
+ /** A list cut to the items that fit, then how many were left out. */
26
+ function cutList(list: unknown[], keep?: number): unknown[] {
27
+ let n = keep ?? list.length
28
+ while (n > 1 && !fits(list.slice(0, n))) n = Math.max(1, Math.floor(n / 2))
29
+ return n < list.length ? [...list.slice(0, n), `… ${list.length - n} more`] : list
30
+ }
31
+
32
+ /**
33
+ * A step's output as the answer carries it: whole when small; else its lists
34
+ * cut to their first items (so its shape and keys still show), else its JSON
35
+ * clipped; and said so.
36
+ */
37
+ function shown(value: unknown): { output?: unknown; outputClipped?: true } {
38
+ if (value === undefined) return {}
39
+ if (fits(value)) return { output: value }
40
+ if (Array.isArray(value)) {
41
+ const cut = cutList(value)
42
+ if (fits(cut)) return { output: cut, outputClipped: true }
43
+ } else if (value && typeof value === 'object') {
44
+ const cut: Record<string, unknown> = { ...(value as Record<string, unknown>) }
45
+ const lists = Object.entries(cut).filter(([, v]) => Array.isArray(v)).sort(([, a], [, b]) => (JSON.stringify(b) ?? '').length - (JSON.stringify(a) ?? '').length)
46
+ for (const [key, list] of lists) {
47
+ if (fits(cut)) break
48
+ cut[key] = cutList(list as unknown[], 3)
49
+ }
50
+ if (fits(cut)) return { output: cut, outputClipped: true }
51
+ }
52
+ const text = JSON.stringify(value) ?? 'null'
53
+ return { output: `${text.slice(0, OUTPUT_CLIP)}… (${text.length - OUTPUT_CLIP} more characters; the run's link shows it whole)`, outputClipped: true }
54
+ }
55
+
56
+ /** A top-level step is one of the definition's own nodes ("/4:escalate"), not a node inside a branch, loop or parallel. */
57
+ const isTopLevel = (path: string) => /^\/?\d+:[^/]+$/.test(path)
58
+
59
+ /**
60
+ * One step as the agent reads it, with `output` as `$steps.<id>` reads it in
61
+ * an expression. A run records a `forEach` as `{ mode, count, results }`,
62
+ * while `$steps.<forEach>` is the bare `results` list: one entry per item,
63
+ * each that iteration's `$steps` keyed by the loop body's node ids (a
64
+ * failed iteration skipped with skipFailure is `{ error }`). So the loop's
65
+ * output is that list, and what the run view shows is beside it, labelled.
66
+ */
67
+ export function describeStep(n: RunTree['nodes'][number]) {
68
+ const path = n.nodePath || n.nodeId
69
+ const base = { step: path, kind: n.kind, status: n.status, ...(n.error ? { error: n.error } : {}), ...(n.attempt > 1 ? { attempts: n.attempt } : {}) }
70
+ if (n.status === 'skipped') return { ...base, note: `skipped: $steps.${n.nodeId} has no entry` }
71
+ const recorded = n.output
72
+ if (n.kind === 'forEach' && recorded && typeof recorded === 'object' && !Array.isArray(recorded) && 'count' in recorded) {
73
+ const { results, ...rest } = recorded as { results?: unknown[]; count?: number; mode?: string }
74
+ const body = Array.isArray(results) ? results.find((r) => r && typeof r === 'object' && !('error' in r)) : undefined
75
+ const first = body ? Object.keys(body as object)[0] : undefined
76
+ return {
77
+ ...base,
78
+ ...(Array.isArray(results) ? shown(results) : {}),
79
+ recorded: rest,
80
+ reads: Array.isArray(results)
81
+ ? `$steps.${n.nodeId} is this list, not the run view's { mode, count, results }: one entry per item, each that iteration's $steps by body node id${first ? `, e.g. $steps.${n.nodeId}.${first} for every item's ${first} output, $steps.${n.nodeId}[0].${first} for the first` : ''}; a failed iteration skipped with skipFailure is { error }. $count($steps.${n.nodeId}) is the number of items.`
82
+ : `The loop failed before it finished, so it has no results: with continueOnError, $steps.${n.nodeId} is { error }.`,
83
+ }
84
+ }
85
+ return { ...base, ...(isTopLevel(path) ? {} : { inside: path.split('/').slice(0, -1).join('/') }), ...shown(recorded) }
86
+ }
87
+
88
+ function describe(run: RunTree) {
89
+ return {
90
+ runId: run.id,
91
+ workflow: run.slug,
92
+ status: run.status,
93
+ version: run.versionNumber,
94
+ output: run.output,
95
+ error: run.error ?? undefined,
96
+ // Each step's output is what `$steps.<id>` reads in an expression.
97
+ steps: run.nodes.map(describeStep),
98
+ startedAt: run.startedAt,
99
+ endedAt: run.endedAt,
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The version a run asks for: a number as given, `draft` (or its synonym
105
+ * `latest`) for the newest version that is not retired — what `apply` last
106
+ * pushed, which is the live one when a person promoted it (promoting retires
107
+ * the older drafts) — or undefined for the live one.
108
+ */
109
+ export function pickVersion(requested: unknown, versions: () => Promise<VersionSummary[]>, slug: string): Promise<number | undefined> | number | undefined {
110
+ if (requested === undefined || requested === null || requested === '') return undefined
111
+ const n = typeof requested === 'number' ? requested : /^\d+$/.test(String(requested).trim()) ? Number(String(requested).trim()) : NaN
112
+ if (Number.isFinite(n)) {
113
+ if (!Number.isInteger(n) || n < 1) throw new Error(`version must be a whole number from 1, "draft" or "latest"; got ${String(requested)}`)
114
+ return n
115
+ }
116
+ const word = String(requested).trim().toLowerCase()
117
+ if (word !== 'draft' && word !== 'latest') throw new Error(`version must be a whole number from 1, "draft" or "latest"; got "${String(requested)}"`)
118
+ return versions().then((rows) => {
119
+ const sorted = [...rows].sort((a, b) => b.number - a.number)
120
+ // Not "the newest with status draft": after a promotion the newest is live and its older drafts are retired.
121
+ const found = sorted.find((v) => v.status !== 'retired')
122
+ if (!found) {
123
+ throw new Error(
124
+ sorted.length
125
+ ? `Workflow "${slug}" has only retired versions on the local runtime (${sorted.map((v) => `${v.number} ${v.status}`).join(', ')}). Apply its file (\`frontera local dev --once\`), then run it with version "${word}".`
126
+ : `Workflow "${slug}" has no version on the local runtime yet. Apply its file first (\`frontera local dev --once\`).`,
127
+ )
128
+ }
129
+ return found.number
130
+ })
131
+ }
132
+
133
+ async function startRun(link: RuntimeLink, slug: string, body: Record<string, unknown>): Promise<{ runId: string; versionNumber: number }> {
134
+ try {
135
+ return await serviceCall<{ runId: string; versionNumber: number }>(link, `/v1/workflows/${encodeURIComponent(slug)}/run`, { body })
136
+ } catch (err) {
137
+ const message = (err as Error).message
138
+ // The runtime's words are about its API; the agent needs the argument to pass.
139
+ if (/answered 409/.test(message) && /no live version/i.test(message) && body.version === undefined) {
140
+ throw new Error(`Workflow "${slug}" has no live version on the local runtime. Pass version: "draft" to run the draft you applied (or a version number); promoting stays with a person.`)
141
+ }
142
+ throw err
143
+ }
144
+ }
145
+
146
+ /** Wait for a run to finish, at most until `deadline`: the run as it is then. */
147
+ async function followRun(link: RuntimeLink, runId: string, deadline: number): Promise<{ run: RunTree; finished: boolean }> {
148
+ for (;;) {
149
+ const run = await serviceCall<RunTree>(link, `/v1/workflows/runs/${encodeURIComponent(runId)}?depth=run`)
150
+ if (TERMINAL.has(run.status)) return { run, finished: true }
151
+ if (Date.now() >= deadline) return { run, finished: false }
152
+ await new Promise((r) => setTimeout(r, 1_500))
153
+ }
154
+ }
155
+
156
+ /** The webhook door a delivery goes to: the one named by path, or the only one. */
157
+ export function pickDoor(doors: WebhookListen['doors'], path: string | undefined): WebhookListen['doors'][number] | string {
158
+ if (!doors.length) return 'has no webhook trigger, so there is no door to deliver to. workflow_run runs it with an input instead.'
159
+ const wanted = path?.replace(/^\/+/, '')
160
+ if (wanted !== undefined) {
161
+ const door = doors.find((d) => d.path.replace(/^\/+/, '') === wanted)
162
+ return door ?? `has no webhook door at path "${path}"; its doors: ${doors.map((d) => `"${d.path}" (${d.methods.join(', ')})`).join('; ')}.`
163
+ }
164
+ if (doors.length > 1) return `has ${doors.length} webhook doors: pass path, one of ${doors.map((d) => `"${d.path}"`).join(', ')}.`
165
+ return doors[0]!
166
+ }
167
+
168
+ /** The test door's address on the local runtime, whatever base the runtime wrote it with. */
169
+ export function localDoorUrl(test: string, serviceUrl: string): string {
170
+ const u = new URL(test, serviceUrl)
171
+ return new URL(`${u.pathname}${u.search}`, serviceUrl).toString()
172
+ }
173
+
174
+ /** Headers as the answer shows them: names kept, values of anything that may be a credential blanked. */
175
+ export function shownHeaders(headers: Record<string, string>): Record<string, string> {
176
+ return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, /auth|token|secret|signature|key|cookie|session/i.test(name) ? '[given]' : value]))
177
+ }
178
+
179
+ /** A webhook trigger's own auth, as its workflow file declares it (the platform's `auth` on a `kind: webhook` trigger). */
180
+ export type TriggerAuth =
181
+ | { type: 'none' }
182
+ | { type: 'header'; name: string; secret: string }
183
+ | { type: 'basic'; username: string; secret: string }
184
+ | { type: 'hmac'; header: string; secret: string; algorithm?: string; encoding?: string; prefix?: string }
185
+
186
+ /** The auth of the webhook trigger at `doorPath` in workflows/<slug>.yaml (or .yml, .json), or null when the file or trigger isn't there. */
187
+ export function triggerAuthOf(root: string, slug: string, doorPath: string): TriggerAuth | null {
188
+ const file = ['yaml', 'yml', 'json'].map((ext) => join(root, 'workflows', `${slug}.${ext}`)).find((f) => existsSync(f))
189
+ if (!file) return null
190
+ let doc: unknown
191
+ try {
192
+ doc = parse(readFileSync(file, 'utf8'))
193
+ } catch {
194
+ return null
195
+ }
196
+ const raw = (doc as { trigger?: unknown; triggers?: unknown } | null)?.trigger ?? (doc as { triggers?: unknown } | null)?.triggers
197
+ const triggers = (Array.isArray(raw) ? raw : raw ? [raw] : []) as Array<{ kind?: unknown; path?: unknown; auth?: unknown }>
198
+ const bare = (p: unknown) => (typeof p === 'string' ? p.replace(/^\/+|\/+$/g, '') : '')
199
+ const hooks = triggers.filter((t) => t?.kind === 'webhook')
200
+ const trigger = hooks.find((t) => bare(t.path) === bare(doorPath)) ?? (hooks.length === 1 ? hooks[0] : undefined)
201
+ const auth = trigger?.auth as TriggerAuth | undefined
202
+ return auth && typeof auth === 'object' && typeof auth.type === 'string' ? auth : trigger ? { type: 'none' } : null
203
+ }
204
+
205
+ /** The header a trigger's auth reads its credential from: the secret header, Authorization for basic, the signature header for HMAC. */
206
+ export function authHeaderOf(auth: TriggerAuth): string | null {
207
+ if (auth.type === 'header') return auth.name
208
+ if (auth.type === 'basic') return 'authorization'
209
+ if (auth.type === 'hmac') return auth.header
210
+ return null
211
+ }
212
+
213
+ /** What the runtime's own delivery to a test door answers (`POST /v1/workflows/:slug/test-delivery`): the door's answer and the run, never the secret. */
214
+ interface TestDelivery {
215
+ version: number
216
+ path: string
217
+ method: string
218
+ status: number
219
+ headers?: Record<string, string>
220
+ body?: unknown
221
+ runId?: string | null
222
+ /** How the runtime authenticated the delivery: the trigger's auth type and the secret's name. */
223
+ auth?: { type: string; secret?: string }
224
+ }
225
+
226
+ /** The test run a delivery started: the id the door answered with, else the newest test run of that version since it was sent. */
227
+ async function deliveredRun(link: RuntimeLink, slug: string, version: number, sentAt: string, answered: unknown): Promise<string | null> {
228
+ const fromBody = answered && typeof answered === 'object' && typeof (answered as { runId?: unknown }).runId === 'string' ? (answered as { runId: string }).runId : null
229
+ if (fromBody) return fromBody
230
+ for (let attempt = 0; attempt < 6; attempt++) {
231
+ const page = await serviceCall<unknown>(link, `/v1/workflows/${encodeURIComponent(slug)}/runs?source=test&from=${encodeURIComponent(sentAt)}&limit=10`).catch(() => null)
232
+ const rows = (Array.isArray(page) ? page : ((page as { items?: unknown[]; runs?: unknown[]; data?: unknown[] } | null)?.items ?? (page as { runs?: unknown[] } | null)?.runs ?? (page as { data?: unknown[] } | null)?.data ?? [])) as Array<{
233
+ id: string
234
+ versionNumber?: number | null
235
+ startedAt?: string
236
+ }>
237
+ const mine = rows.filter((r) => r.versionNumber === undefined || r.versionNumber === null || r.versionNumber === version).sort((a, b) => Date.parse(b.startedAt ?? '') - Date.parse(a.startedAt ?? ''))
238
+ if (mine[0]) return mine[0].id
239
+ await new Promise((r) => setTimeout(r, 500))
240
+ }
241
+ return null
242
+ }
243
+
244
+ export interface SavedObjectSet {
245
+ id: string
246
+ name: string
247
+ description?: string | null
248
+ kind?: string
249
+ /** The file that defines it, `blueprint/object-sets/<file>.yaml`, when the project has one by that name. */
250
+ file?: string
251
+ objectType?: string
252
+ }
253
+
254
+ /** The project's object set files by the set's name: `blueprint/object-sets/<file>.yaml` and the object type it names. */
255
+ function objectSetFiles(root: string): Map<string, { file: string; objectType?: string }> {
256
+ const dir = join(root, 'blueprint', 'object-sets')
257
+ const out = new Map<string, { file: string; objectType?: string }>()
258
+ if (!existsSync(dir)) return out
259
+ for (const entry of readdirSync(dir)) {
260
+ if (!/\.ya?ml$/.test(entry)) continue
261
+ try {
262
+ const doc = parse(readFileSync(join(dir, entry), 'utf8')) as { name?: unknown; objectType?: unknown } | null
263
+ if (typeof doc?.name === 'string') out.set(doc.name.toLowerCase(), { file: entry.replace(/\.ya?ml$/, ''), ...(typeof doc.objectType === 'string' ? { objectType: doc.objectType } : {}) })
264
+ } catch {
265
+ // A file that does not parse is apply's to report.
266
+ }
267
+ }
268
+ return out
269
+ }
270
+
271
+ /** The saved object sets on the local runtime, with the file each comes from: what a query, a workflow or an App addresses by id. */
272
+ export async function savedObjectSets(link: RuntimeLink, root: string): Promise<SavedObjectSet[]> {
273
+ const rows = await serviceCall<Array<{ id: string; name: string; description?: string | null; kind?: string; deletedAt?: string | null; objectTypeApiName?: string | null }> | { items?: unknown[] }>(link, '/v1/blueprint/object-sets', { timeoutMs: 15_000 })
274
+ const list = (Array.isArray(rows) ? rows : ((rows?.items ?? []) as never[])) as Array<{ id: string; name: string; description?: string | null; kind?: string; deletedAt?: string | null; objectTypeApiName?: string | null }>
275
+ const files = objectSetFiles(root)
276
+ return list
277
+ .filter((r) => r && typeof r.id === 'string' && !r.deletedAt)
278
+ .map((r) => {
279
+ const file = files.get(String(r.name).toLowerCase())
280
+ // The type its records are: as the runtime lists it, else as the file names it.
281
+ const objectType = r.objectTypeApiName ?? file?.objectType
282
+ return { id: r.id, name: r.name, ...(r.description ? { description: r.description } : {}), ...(r.kind ? { kind: r.kind } : {}), ...(file ? { file: file.file } : {}), ...(objectType ? { objectType } : {}) }
283
+ })
284
+ }
285
+
286
+ /**
287
+ * A saved object set by what the agent knows it as: its id, its name (any
288
+ * case), its file name (`open-work-orders`), or a project reference
289
+ * (`objectSet/open-work-orders`, `objectSet/Open work orders`).
290
+ */
291
+ export function findObjectSet(sets: SavedObjectSet[], wanted: string): SavedObjectSet | string {
292
+ const ref = wanted.trim().replace(/^objectSet\//, '').replace(/^blueprint\/object-sets\//, '').replace(/\.ya?ml$/, '')
293
+ const lower = ref.toLowerCase()
294
+ const slug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
295
+ const found = sets.find((s) => s.id === ref) ?? sets.find((s) => s.name.toLowerCase() === lower) ?? sets.find((s) => s.file === ref) ?? sets.find((s) => slug(s.name) === slug(ref))
296
+ if (found) return found
297
+ return sets.length
298
+ ? `No saved object set "${wanted}" on the local runtime. Its sets: ${sets.map((s) => `"${s.name}"${s.file ? ` (${s.file})` : ''}`).join(', ')}.`
299
+ : `No saved object set "${wanted}": the local runtime has none. Sets are files under blueprint/object-sets/; apply puts them on the runtime.`
300
+ }
301
+
302
+ export function workflowTools(project: LocalProject): McpTool[] {
303
+ return [
304
+ {
305
+ name: 'workflow_run',
306
+ description:
307
+ 'Run a workflow on the local runtime with an input, wait for it to finish (up to waitSeconds), and return each step\'s status, output and any error, and the run\'s output. A step\'s output is what $steps.<id> reads in an expression: for a forEach that is the list of per-item $steps (the run view\'s { mode, count } is beside it as recorded). Runs the live version unless version says otherwise: pass version "draft" to run what you last applied (the live one, once a person promoted it). A run is real, on the local runtime only: its steps read and write the runtime’s own records (the project’s datasets, never the workspace’s), and a governed Action it submits, from an action step or from a Function step’s code, is applied and changes those records when the Action runs here (it creates, updates or deletes Blueprint records). An Action whose effect would reach another system (a transition or a command) is switched off here and does not run. The writes stay: frontera local snapshot restore (or frontera local reset) takes them back; frontera local check rolls back its own tests’ writes, this tool does not.',
308
+ inputSchema: {
309
+ type: 'object',
310
+ properties: {
311
+ workflow: { type: 'string', description: 'The workflow\'s slug (its file name under workflows/).' },
312
+ input: { type: 'object', description: 'The run\'s input, as its trigger declares it.' },
313
+ subject: { type: 'string', description: 'The primary key of the record it runs on, for a record-triggered workflow.' },
314
+ version: {
315
+ type: ['integer', 'string'],
316
+ description: 'Which version to run: a version number, or "draft" (the newest version that is not retired: what apply last pushed, which is the live one once a person promoted it; "latest" means the same). Default: the live version.',
317
+ },
318
+ waitSeconds: { type: 'integer', minimum: 0, maximum: 300, description: 'Wait at most this long for it to finish (default 120; 0 returns at once).' },
319
+ },
320
+ required: ['workflow'],
321
+ additionalProperties: false,
322
+ },
323
+ annotations: { title: 'Run a workflow', readOnlyHint: false, openWorldHint: false },
324
+ async run(input) {
325
+ const link = requireRuntime(project)
326
+ const slug = stringArg(input, 'workflow', { required: true })
327
+ const subject = stringArg(input, 'subject')
328
+ const version = await pickVersion(input.version, () => serviceCall<VersionSummary[]>(link, `/v1/workflows/${encodeURIComponent(slug)}/versions`), slug)
329
+ const started = await startRun(link, slug, {
330
+ ...(input.input && typeof input.input === 'object' ? { input: input.input } : {}),
331
+ ...(subject ? { subject: { pk: subject } } : {}),
332
+ ...(version !== undefined ? { version } : {}),
333
+ })
334
+ const runLink = studioLink(link, `/workflows/runs/${encodeURIComponent(started.runId)}`)
335
+ const deadline = Date.now() + intArg(input, 'waitSeconds', 120, 0, 300) * 1000
336
+ for (;;) {
337
+ const run = await serviceCall<RunTree>(link, `/v1/workflows/runs/${encodeURIComponent(started.runId)}?depth=run`)
338
+ if (TERMINAL.has(run.status)) return jsonResult({ ...describe(run), link: runLink })
339
+ if (Date.now() >= deadline) return jsonResult({ ...describe(run), link: runLink, note: 'Still running. Call workflow_run again later or open the link.' })
340
+ await new Promise((r) => setTimeout(r, 1_500))
341
+ }
342
+ },
343
+ },
344
+ {
345
+ name: 'workflow_webhook_test',
346
+ description:
347
+ "Test a workflow's webhook trigger on its draft, as Studio's Listen does: arms the draft's test door for one delivery, sends your request to it (body, headers, method, query), and returns what the door answered (status, headers, body), then the run it started with each step's status, output and error. Tests the mapping from $request and the trigger's respond settings before anything is live. By default the newest version that is not retired (what apply last pushed, the live one once a person promoted it); version names another. The trigger's auth (a secret header, basic auth or an HMAC signature, naming a workspace secret) is applied by the local runtime from its own secret store when you leave its header out, so you never handle the secret: set its value once with `frontera secret set NAME`. Pass the header in headers only to test what a wrong or missing credential does; header values that look like credentials are not repeated in the answer.",
348
+ inputSchema: {
349
+ type: 'object',
350
+ properties: {
351
+ workflow: { type: 'string', description: 'The workflow\'s slug (its file name under workflows/).' },
352
+ body: { description: 'The request body: an object or array is sent as JSON, a string as it is.' },
353
+ headers: { type: 'object', additionalProperties: { type: 'string' }, description: 'Request headers, e.g. the trigger\'s auth header. content-type defaults to application/json for an object body.' },
354
+ method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], description: 'Default: POST when the door takes it, else its first method.' },
355
+ path: { type: 'string', description: 'Which webhook door, by its trigger path, when the workflow has more than one.' },
356
+ query: { type: 'object', additionalProperties: { type: 'string' }, description: 'Query parameters to add to the door\'s address.' },
357
+ version: { type: ['integer', 'string'], description: 'Which version to test: "draft" (default: the newest version that is not retired, what apply last pushed, which is the live one once a person promoted it) or a version number. The local runtime\'s test door takes the live version too.' },
358
+ waitSeconds: { type: 'integer', minimum: 0, maximum: 300, description: 'Wait at most this long for the run to finish (default 120; 0 returns after the delivery).' },
359
+ },
360
+ required: ['workflow'],
361
+ additionalProperties: false,
362
+ },
363
+ annotations: { title: "Test a workflow's webhook", readOnlyHint: false, openWorldHint: false },
364
+ async run(input) {
365
+ const link = requireRuntime(project)
366
+ const slug = stringArg(input, 'workflow', { required: true })
367
+ const version = await pickVersion(input.version ?? 'draft', () => serviceCall<VersionSummary[]>(link, `/v1/workflows/${encodeURIComponent(slug)}/versions`), slug)
368
+ let listen: WebhookListen
369
+ try {
370
+ listen = await serviceCall<WebhookListen>(link, `/v1/workflows/${encodeURIComponent(slug)}/listen`, { body: version === undefined ? {} : { version } })
371
+ } catch (err) {
372
+ const message = (err as Error).message
373
+ if (/answered 404/.test(message)) return errorResult(`The local runtime cannot listen on a workflow's test door (${message.slice(0, 200)}). Update the runtime, or test the mapping with workflow_run and an input shaped like $request.`)
374
+ throw err
375
+ }
376
+ const door = pickDoor(listen.doors, stringArg(input, 'path'))
377
+ if (typeof door === 'string') return errorResult(`Workflow "${slug}" v${listen.version} ${door}`)
378
+ const given = input.method === undefined ? undefined : String(input.method).toUpperCase()
379
+ const method = given ?? (door.methods.includes('POST') ? 'POST' : door.methods[0] ?? 'POST')
380
+ if (!door.methods.includes(method)) return errorResult(`The door at "${door.path}" takes ${door.methods.join(', ')}, not ${method}.`)
381
+ const url = new URL(localDoorUrl(door.test, link.serviceUrl))
382
+ for (const [k, v] of Object.entries((input.query && typeof input.query === 'object' ? input.query : {}) as Record<string, unknown>)) url.searchParams.set(k, String(v))
383
+ const headers: Record<string, string> = Object.fromEntries(Object.entries((input.headers && typeof input.headers === 'object' ? input.headers : {}) as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
384
+ const raw = input.body
385
+ const hasBody = raw !== undefined && method !== 'GET'
386
+ const payload = !hasBody ? undefined : typeof raw === 'string' ? raw : JSON.stringify(raw)
387
+ if (hasBody && typeof raw !== 'string' && !Object.keys(headers).some((h) => h.toLowerCase() === 'content-type')) headers['content-type'] = 'application/json'
388
+ const sentAt = new Date(Date.now() - 1_000).toISOString()
389
+ const waitSeconds = intArg(input, 'waitSeconds', 120, 0, 300)
390
+ // The trigger checks a credential and none was given: the runtime sends the delivery with the secret it names, read from its own store.
391
+ const auth = triggerAuthOf(project.root, slug, door.path)
392
+ const authHeader = auth ? authHeaderOf(auth) : null
393
+ if (auth && authHeader && auth.type !== 'none' && !Object.keys(headers).some((h) => h.toLowerCase() === authHeader.toLowerCase())) {
394
+ let delivered: TestDelivery
395
+ try {
396
+ delivered = await serviceCall<TestDelivery>(link, `/v1/workflows/${encodeURIComponent(slug)}/test-delivery`, {
397
+ body: { version: listen.version, path: door.path, method, headers, query: Object.fromEntries(url.searchParams), ...(payload !== undefined ? { body: payload } : {}), auth: 'trigger' },
398
+ timeoutMs: (waitSeconds + 30) * 1000,
399
+ })
400
+ } catch (err) {
401
+ const message = (err as Error).message
402
+ const secret = 'secret' in auth ? auth.secret : ''
403
+ if (/answered 404/.test(message) && /test-delivery|not found/i.test(message)) {
404
+ return errorResult(`The trigger at "${door.path}" checks ${auth.type === 'hmac' ? `an HMAC signature in ${auth.header}` : auth.type === 'basic' ? 'basic auth' : `the ${auth.name} header`} against the secret ${secret}, and this local runtime can't send a delivery with its own secret yet. Update the runtime; until then pass the credential in headers as the caller would (never in a file you commit).`)
405
+ }
406
+ if (/WEBHOOK_SECRET_MISSING|secret/i.test(message) && /answered 409|answered 400/.test(message)) {
407
+ return errorResult(`The trigger at "${door.path}" needs the secret ${secret}, which has no value on this runtime: set it with \`frontera secret set ${secret}\` in the project folder, then test again.`)
408
+ }
409
+ throw err
410
+ }
411
+ const answeredBody = delivered.body
412
+ const doorAnswer = {
413
+ url: `${url.pathname}${url.search}`,
414
+ method: delivered.method ?? method,
415
+ sent: { headers: { ...shownHeaders(headers), [authHeader]: `[from secret ${'secret' in auth ? auth.secret : ''}]` }, ...(hasBody ? { body: typeof raw === 'string' ? raw.slice(0, 2_000) : raw } : {}) },
416
+ status: delivered.status,
417
+ headers: delivered.headers ?? {},
418
+ body: typeof answeredBody === 'string' ? answeredBody.slice(0, 4_000) : answeredBody,
419
+ }
420
+ if (delivered.status === 401 || delivered.status === 403 || delivered.status === 404) {
421
+ return { ...jsonResult({ workflow: slug, version: delivered.version ?? listen.version, door: doorAnswer, why: delivered.status === 404 ? 'The test door refused the delivery: the method or path does not match the trigger. Check its methods and path.' : `The trigger refused the runtime's own credential: check that ${'secret' in auth ? auth.secret : 'its secret'} holds the value the trigger expects.` }), isError: true }
422
+ }
423
+ const runId = delivered.runId ?? (await deliveredRun(link, slug, delivered.version ?? listen.version, sentAt, answeredBody))
424
+ if (!runId) return jsonResult({ workflow: slug, version: delivered.version ?? listen.version, door: doorAnswer, note: 'The door answered, but no test run of this version showed up: trace_list or Studio\'s workflow runs show what happened.', link: studioLink(link, `/workflows/${encodeURIComponent(slug)}`) })
425
+ const { run, finished } = await followRun(link, runId, Date.now() + waitSeconds * 1000)
426
+ return jsonResult({
427
+ workflow: slug,
428
+ version: delivered.version ?? listen.version,
429
+ door: doorAnswer,
430
+ run: describe(run),
431
+ ...(finished ? {} : { note: 'The run is still going: its link shows it as it goes. Calling workflow_webhook_test again sends another delivery.' }),
432
+ link: studioLink(link, `/workflows/runs/${encodeURIComponent(run.id)}`),
433
+ })
434
+ }
435
+ // The door can hold the delivery until the run answers (respond onFinish), so it gets the run's time too.
436
+ const res = await fetch(url, { method, headers, body: payload, signal: AbortSignal.timeout((waitSeconds + 30) * 1000) })
437
+ const text = await res.text()
438
+ let answered: unknown = text
439
+ try {
440
+ answered = text ? JSON.parse(text) : null
441
+ } catch {
442
+ // Not JSON: the door answered text, kept as it is.
443
+ }
444
+ const delivery = {
445
+ url: `${url.pathname}${url.search}`,
446
+ method,
447
+ sent: { headers: shownHeaders(headers), ...(hasBody ? { body: typeof raw === 'string' ? raw.slice(0, 2_000) : raw } : {}) },
448
+ status: res.status,
449
+ headers: Object.fromEntries([...res.headers].filter(([k]) => !/^(date|connection|keep-alive|transfer-encoding|access-control-)/i.test(k))),
450
+ body: typeof answered === 'string' ? answered.slice(0, 4_000) : answered,
451
+ }
452
+ if (res.status === 404 || res.status === 401 || res.status === 403) {
453
+ return {
454
+ ...jsonResult({
455
+ workflow: slug,
456
+ version: listen.version,
457
+ door: delivery,
458
+ why:
459
+ res.status === 404
460
+ ? "The test door refused the delivery: its one delivery was already used, the Listen ended, or the method or path does not match the trigger. Call workflow_webhook_test again to listen for another; check the trigger's methods and path."
461
+ : "The trigger's own auth refused the credential in headers: leave its header out and the runtime sends the delivery with the secret the trigger names.",
462
+ }),
463
+ isError: true,
464
+ }
465
+ }
466
+ const runId = await deliveredRun(link, slug, listen.version, sentAt, answered)
467
+ if (!runId) return jsonResult({ workflow: slug, version: listen.version, door: delivery, note: 'The door answered, but no test run of this version showed up: trace_list or Studio\'s workflow runs show what happened.', link: studioLink(link, `/workflows/${encodeURIComponent(slug)}`) })
468
+ const { run, finished } = await followRun(link, runId, Date.now() + waitSeconds * 1000)
469
+ return jsonResult({
470
+ workflow: slug,
471
+ version: listen.version,
472
+ door: delivery,
473
+ run: describe(run),
474
+ ...(finished ? {} : { note: 'The run is still going: its link shows it as it goes. Calling workflow_webhook_test again sends another delivery.' }),
475
+ link: studioLink(link, `/workflows/runs/${encodeURIComponent(run.id)}`),
476
+ })
477
+ },
478
+ },
479
+ {
480
+ name: 'blueprint_query',
481
+ description:
482
+ "Read records of a Blueprint object type, or of a saved object set, on the local runtime: optional equality filters, the properties to return, an order and a limit. A set is named by its name, its file name under blueprint/object-sets/ or its id; the answer gives its id, which an App's or a query's { type: 'reference', objectSetId } needs. Without objectType or objectSet it lists the saved object sets. A metric's numbers are blueprint_query_metric's. Read only.",
483
+ inputSchema: {
484
+ type: 'object',
485
+ properties: {
486
+ objectType: { type: 'string', description: 'The object type\'s API name, e.g. Ticket.' },
487
+ objectSet: { type: 'string', description: 'A saved object set instead of a whole type: its name ("Open work orders"), its file name (open-work-orders) or its id.' },
488
+ where: { type: 'object', additionalProperties: { type: ['string', 'number', 'boolean'] }, description: 'Property equals value, every one must hold, e.g. {"status":"open"}. On a date or time property the value can be the time the query runs: "$now", or "$now" plus or minus m (min), h, d or w ("$now-7d"); a range (before, after) is `frontera blueprint query --where \'dueAt:lt:$now\'`.' },
489
+ select: { type: 'array', items: { type: 'string' }, description: 'Only these properties.' },
490
+ orderBy: { type: 'string', description: 'A property, optionally with :desc, e.g. "createdAt:desc".' },
491
+ limit: { type: 'integer', minimum: 1, maximum: 100, description: 'At most this many records (default 20).' },
492
+ },
493
+ additionalProperties: false,
494
+ },
495
+ annotations: { title: 'Read Blueprint records', readOnlyHint: true, openWorldHint: false },
496
+ async run(input) {
497
+ const link = requireRuntime(project)
498
+ const objectTypeArg = stringArg(input, 'objectType')
499
+ const setArg = stringArg(input, 'objectSet')
500
+ if (!objectTypeArg && !setArg) {
501
+ const sets = await savedObjectSets(link, project.root)
502
+ return jsonResult({ objectSets: sets, ...(sets.length ? {} : { note: 'No saved object sets on the local runtime. Sets are files under blueprint/object-sets/; apply puts them on the runtime.' }), next: 'Pass objectType, or objectSet with a set\'s name, file or id, to read records.' })
503
+ }
504
+ let set: SavedObjectSet | null = null
505
+ if (setArg) {
506
+ const found = findObjectSet(await savedObjectSets(link, project.root), setArg)
507
+ if (typeof found === 'string') return errorResult(found)
508
+ set = found
509
+ if (objectTypeArg && set.objectType && set.objectType !== objectTypeArg) return errorResult(`The set "${set.name}" is over ${set.objectType}, not ${objectTypeArg}. Leave objectType out, or name a set over ${objectTypeArg}.`)
510
+ }
511
+ const where = input.where && typeof input.where === 'object' ? Object.entries(input.where as Record<string, unknown>) : []
512
+ const base = set ? { type: 'reference', objectSetId: set.id } : { type: 'base', objectType: objectTypeArg }
513
+ const objectSet =
514
+ where.length === 0
515
+ ? base
516
+ : {
517
+ type: 'filter',
518
+ objectSet: base,
519
+ where: where.length === 1 ? { property: where[0]![0], op: 'eq', value: where[0]![1] } : { and: where.map(([property, value]) => ({ property, op: 'eq', value })) },
520
+ }
521
+ const order = stringArg(input, 'orderBy')
522
+ const [property, dir] = order ? order.split(':') : []
523
+ const res = await serviceCall<{ rows?: unknown[]; data?: unknown[]; nextPageToken?: string; objectType?: string }>(link, '/v1/blueprint/query', {
524
+ body: {
525
+ objectSet,
526
+ ...(Array.isArray(input.select) && input.select.length ? { select: input.select } : {}),
527
+ ...(property ? { orderBy: [{ property, dir: dir === 'desc' ? 'desc' : 'asc' }] } : {}),
528
+ pageSize: intArg(input, 'limit', 20, 1, 100),
529
+ },
530
+ })
531
+ const rows = res.rows ?? res.data ?? []
532
+ const objectType = objectTypeArg ?? res.objectType ?? set?.objectType ?? null
533
+ return jsonResult({
534
+ objectType,
535
+ ...(set ? { objectSet: { id: set.id, name: set.name, ...(set.file ? { file: `blueprint/object-sets/${set.file}.yaml` } : {}), reference: { type: 'reference', objectSetId: set.id } } } : {}),
536
+ count: rows.length,
537
+ more: Boolean(res.nextPageToken),
538
+ rows,
539
+ ...(objectType ? { link: studioLink(link, `/blueprint/object-types/${encodeURIComponent(objectType)}`) } : {}),
540
+ })
541
+ },
542
+ },
543
+ {
544
+ name: 'blueprint_query_metric',
545
+ description:
546
+ "Query a Blueprint metric's numbers on the local runtime, through the route an App's useMetric and an Agent's blueprint_query_metric use: every measure (or `measures`), grouped by `dimensions`, over `timeRange` (an ISO duration, or a start and an end), as a series by `grain` (the metric's timeseries dimension is added for it), with `compare` adding the window before (<measure>_prev, _delta, _pct). An isoDuration window is counted back from the newest value of the metric's timeseries in the data, not from now: P7D is after newest − 7 days, through newest. The answer's `window` gives those bounds; compare rows against them, not against now. Use it to check a metric you just authored. Read only.",
547
+ inputSchema: {
548
+ type: 'object',
549
+ properties: {
550
+ metric: { type: 'string', description: 'The metric\'s API name (blueprint/metrics/<apiName>.yaml).' },
551
+ measures: { type: 'array', items: { type: 'string' }, description: 'Only these measures; all when absent.' },
552
+ dimensions: { type: 'array', items: { type: 'string' }, description: 'The metric\'s dimensions to group by.' },
553
+ grain: { enum: [...METRIC_GRAINS], description: 'A series by this bucket over the metric\'s timeseries dimension.' },
554
+ timeRange: {
555
+ type: 'object',
556
+ properties: { isoDuration: { type: 'string', description: 'An ISO-8601 duration, e.g. P7D, counted back from the newest value of the metric\'s timeseries in the data (not from now).' }, start: { type: 'string', description: 'ISO date or timestamp, inclusive.' }, end: { type: 'string', description: 'ISO date or timestamp, exclusive.' } },
557
+ additionalProperties: false,
558
+ },
559
+ compare: { description: 'true: the window before timeRange; or { start, end }: that window.', anyOf: [{ type: 'boolean' }, { type: 'object', properties: { start: { type: 'string' }, end: { type: 'string' } }, required: ['start', 'end'], additionalProperties: false }] },
560
+ },
561
+ required: ['metric'],
562
+ additionalProperties: false,
563
+ },
564
+ annotations: { title: 'Query a Blueprint metric', readOnlyHint: true, openWorldHint: false },
565
+ async run(input) {
566
+ const link = requireRuntime(project)
567
+ const metric = stringArg(input, 'metric')
568
+ if (!metric) return errorResult('Name the metric: { "metric": "<apiName>" }.')
569
+ const grain = stringArg(input, 'grain')
570
+ const range = (input.timeRange && typeof input.timeRange === 'object' ? input.timeRange : {}) as { isoDuration?: string; start?: string; end?: string }
571
+ const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : undefined)
572
+ let timeseries: string | null | undefined
573
+ if (grain) timeseries = await serviceCall<unknown>(link, `/v1/blueprint/metrics/${encodeURIComponent(metric)}`).then(timeseriesOf, () => undefined)
574
+ let body
575
+ try {
576
+ body = metricQueryBody({
577
+ measures: strings(input.measures),
578
+ dimensions: strings(input.dimensions),
579
+ grain,
580
+ range: range.isoDuration,
581
+ from: range.start,
582
+ to: range.end,
583
+ compare: input.compare === true ? true : input.compare && typeof input.compare === 'object' ? (input.compare as { start: string; end: string }) : undefined,
584
+ }, timeseries)
585
+ } catch (err) {
586
+ return errorResult(`${(err as Error).message}.`)
587
+ }
588
+ const result = await serviceCall<MetricQueryResult>(link, `/v1/blueprint/metrics/${encodeURIComponent(metric)}/query`, { body })
589
+ const rows = metricRows(result)
590
+ // The window ends at the newest record, not at now: the bounds a check against rows needs.
591
+ const window = body.timeRange?.isoDuration
592
+ ? await resolveRangeWindow(body.timeRange.isoDuration, {
593
+ metric: () => serviceCall<unknown>(link, `/v1/blueprint/metrics/${encodeURIComponent(metric)}`),
594
+ objectTypes: () => serviceCall<unknown>(link, '/v1/blueprint/object-types'),
595
+ aggregate: (aggregate) => serviceCall<unknown>(link, '/v1/blueprint/aggregate', { body: aggregate }),
596
+ })
597
+ : undefined
598
+ return jsonResult({
599
+ metric,
600
+ query: body,
601
+ ...(window ? { window } : {}),
602
+ count: rows.length,
603
+ rows,
604
+ ...(result && !Array.isArray(result) && result.truncated ? { truncated: true } : {}),
605
+ ...(result && !Array.isArray(result) && result.compareWindow ? { compareWindow: result.compareWindow } : {}),
606
+ ...(rows.length ? {} : { note: 'No rows: the metric\'s object type may have no records in that window. blueprint_query shows its records.' }),
607
+ })
608
+ },
609
+ },
610
+ ]
611
+ }
package/src/output.ts CHANGED
@@ -146,7 +146,7 @@ export function createOutput(
146
146
  streams.err(`error [${envelope.code}]: ${envelope.message}\n`)
147
147
  if (envelope.hint) streams.err(` ${envelope.hint}\n`)
148
148
  }
149
- return exitCodeFor(envelope.code)
149
+ return exitCodeFor(envelope.code, envelope.details)
150
150
  },
151
151
  }
152
152
  }