@frontera-sdk/cli 1.52.39 → 1.52.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) 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 +49 -5
  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/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { allTargets } from './local-dev-sync'
7
+ import { functionSecretNames, hasSecretValue, type SecretPresence, workflowSecretNames } from './local-secrets'
8
+
9
+ /**
10
+ * The secrets a project needs, by name: `secrets.json` at its root.
11
+ *
12
+ * {
13
+ * "secrets": [
14
+ * { "name": "INTAKE_WEBHOOK_TOKEN", "description": "The token the intake webhook checks" }
15
+ * ]
16
+ * }
17
+ *
18
+ * Names and descriptions only, never values: a value is set on each computer
19
+ * with `frontera secret set NAME` and stays on that runtime. The file makes a
20
+ * project reproducible from its files: after `local reset`, or on another
21
+ * computer, `local dev` and `local check` say which secrets the runtime still
22
+ * needs a value for, and why each one is there. A `value` in the file is
23
+ * refused (said, and ignored), so a value is never written into a commit by
24
+ * mistake.
25
+ */
26
+
27
+ export const PROJECT_SECRETS_FILE = 'secrets.json'
28
+
29
+ export interface ProjectSecret {
30
+ name: string
31
+ description?: string
32
+ }
33
+
34
+ export interface ProjectSecrets {
35
+ secrets: ProjectSecret[]
36
+ /** What is wrong with the file, one line each: said as warnings. */
37
+ problems: string[]
38
+ }
39
+
40
+ const NAME = /^[A-Z][A-Z0-9_]*$/
41
+
42
+ /** The project's `secrets.json`, or null when it has none. Never throws: a bad file is its problems. */
43
+ export function readProjectSecrets(root: string): ProjectSecrets | null {
44
+ const path = join(root, PROJECT_SECRETS_FILE)
45
+ if (!existsSync(path)) return null
46
+ let doc: unknown
47
+ try {
48
+ doc = JSON.parse(readFileSync(path, 'utf8'))
49
+ } catch (err) {
50
+ return { secrets: [], problems: [`${PROJECT_SECRETS_FILE} is not valid JSON: ${(err as Error).message}`] }
51
+ }
52
+ const raw = (doc as { secrets?: unknown } | null)?.secrets
53
+ if (!Array.isArray(raw)) return { secrets: [], problems: [`${PROJECT_SECRETS_FILE} has no \`secrets\` list: { "secrets": [ { "name": "API_TOKEN", "description": "…" } ] }`] }
54
+ const secrets: ProjectSecret[] = []
55
+ const problems: string[] = []
56
+ for (const [i, entry] of raw.entries()) {
57
+ const o = (entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}) as Record<string, unknown>
58
+ const name = typeof o.name === 'string' ? o.name.trim() : ''
59
+ if (!NAME.test(name)) {
60
+ problems.push(`${PROJECT_SECRETS_FILE}: secret ${i + 1} needs a \`name\` in capitals, digits and underscores (API_TOKEN)`)
61
+ continue
62
+ }
63
+ if ('value' in o) problems.push(`${PROJECT_SECRETS_FILE}: ${name} has a \`value\`, which is ignored and must not be committed: remove it, and set the value with \`frontera secret set ${name}\``)
64
+ if (secrets.some((s) => s.name === name)) continue
65
+ secrets.push({ name, ...(typeof o.description === 'string' && o.description.trim() ? { description: o.description.trim() } : {}) })
66
+ }
67
+ return { secrets, problems }
68
+ }
69
+
70
+ /**
71
+ * The secret names the project's Functions and workflows name in their files,
72
+ * each with the first file that names it: a Function's `secret:NAME` grants
73
+ * (a single-file Function, or a folder's entry) and a workflow's `auth`.
74
+ */
75
+ export function secretsNamedInFiles(root: string): Map<string, string> {
76
+ const out = new Map<string, string>()
77
+ for (const target of allTargets(root)) {
78
+ if (target.kind !== 'function' && target.kind !== 'workflow') continue
79
+ let names: string[] = []
80
+ try {
81
+ const text = readFileSync(join(root, target.file), 'utf8')
82
+ names = target.kind === 'function' ? functionSecretNames(text) : workflowSecretNames(parseYaml(text))
83
+ } catch {
84
+ // Unreadable: it names nothing here.
85
+ }
86
+ for (const name of names) if (!out.has(name)) out.set(name, target.file)
87
+ }
88
+ return out
89
+ }
90
+
91
+ /** The warning for a secret the runtime has no value for; a listed one says it lost its value. */
92
+ function needsValue(name: string, who: string, listed: boolean): string {
93
+ return listed
94
+ ? ` warning: Needs a value for ${name} (${who}): the runtime lists it, but has no value for it (restoring the database can leave a secret without one); set it again with \`frontera secret set ${name}\` in this folder`
95
+ : ` warning: Needs a value for ${name} (${who}): set it with \`frontera secret set ${name}\` in this folder`
96
+ }
97
+
98
+ /**
99
+ * What to say about the secrets the project needs against what the runtime
100
+ * holds (`have`: the names it has a value for, or what its secrets list says;
101
+ * null when it could not say): each secret `secrets.json` declares or a
102
+ * workflow or Function names that has no value here, said for every item on
103
+ * each call (not only for an item an apply changed, so a value lost to a
104
+ * restore is said), each secret a file names that `secrets.json` leaves out,
105
+ * and the file's own problems. `warning:` lines, as `local dev` says them.
106
+ */
107
+ export function projectSecretLines(root: string, have: ReadonlySet<string> | SecretPresence | null): string[] {
108
+ const declared = readProjectSecrets(root)
109
+ const named = secretsNamedInFiles(root)
110
+ if (!declared && !named.size) return []
111
+ const lines = (declared?.problems ?? []).map((p) => ` warning: ${p}`)
112
+ if (have) {
113
+ const presence: SecretPresence = have instanceof Set ? { names: new Set(have), withoutValue: new Set() } : (have as SecretPresence)
114
+ const said = new Set<string>()
115
+ for (const s of declared?.secrets ?? []) {
116
+ if (hasSecretValue(presence, s.name)) continue
117
+ said.add(s.name)
118
+ lines.push(needsValue(s.name, `${PROJECT_SECRETS_FILE}${s.description ? `: ${s.description}` : ''}`, presence.names.has(s.name)))
119
+ }
120
+ for (const [name, file] of named) {
121
+ if (said.has(name) || hasSecretValue(presence, name)) continue
122
+ said.add(name)
123
+ lines.push(needsValue(name, file, presence.names.has(name)))
124
+ }
125
+ }
126
+ if (declared) {
127
+ const names = new Set(declared.secrets.map((s) => s.name))
128
+ for (const [name, file] of named) {
129
+ if (!names.has(name)) lines.push(` warning: ${file} names the secret ${name}, which ${PROJECT_SECRETS_FILE} doesn't list: add { "name": "${name}", "description": "…" } so the project says what it needs`)
130
+ }
131
+ }
132
+ return lines
133
+ }
@@ -56,3 +56,15 @@ export function localProject(cwd: string): LocalProject {
56
56
  stateDir: join(root, '.frontera', 'local'),
57
57
  }
58
58
  }
59
+
60
+ /**
61
+ * The CLI profile of the local runtime this directory owns
62
+ * (`local-<id>`, as `local status` names it), or null when the directory has
63
+ * never started one. The profile name only: whether it is registered on this
64
+ * machine is the caller's to check.
65
+ */
66
+ export function localRuntimeProfileOf(root: string): string | null {
67
+ const project = localProject(root)
68
+ if (!existsSync(join(project.stateDir, 'state.json'))) return null
69
+ return `local-${project.id}`.slice(0, 64)
70
+ }
@@ -0,0 +1,66 @@
1
+ import { caseCouldNotRun, notRunFix, withDirectModel, type NotRunKind } from './local-case-not-run'
2
+ import { findProvider } from './local-keys'
3
+ import { directModelFor } from './local-seed'
4
+
5
+ /**
6
+ * A model provider's refusal met outside a test case: an `agent_chat` turn,
7
+ * or a Function run whose Agent call the provider refused. Said as the cause
8
+ * (the provider account, named when its words name it) and the fix, the way
9
+ * `test_run` says a case that couldn't run, instead of the provider's raw
10
+ * words. Only the provider's refusals are turned into this: a timeout or a
11
+ * runtime that does not answer keeps its own message.
12
+ */
13
+ export interface ProviderRefusal {
14
+ cause: 'provider'
15
+ kind: Extract<NotRunKind, 'credits' | 'rate-limit' | 'key' | 'provider-error'>
16
+ /** The refusing provider's id and label, when its words name it. */
17
+ provider?: string
18
+ providerLabel?: string
19
+ /** "the OpenRouter account refused the request: it is out of credit (402)". */
20
+ what: string
21
+ fix: string
22
+ /** A model the runtime offers directly from another provider whose key it holds, for a credit refusal. */
23
+ directModel?: { slug: string; name: string; provider: string }
24
+ /** The provider's own words, for the record. */
25
+ providerSaid: string
26
+ }
27
+
28
+ const WHAT: Record<ProviderRefusal['kind'], (account: string, status: string) => string> = {
29
+ credits: (account, status) => `the ${account} refused the request: it is out of credit (${status || '402'})`,
30
+ 'rate-limit': (account, status) => `the ${account} is limiting requests (${status || '429'})`,
31
+ key: (account, status) => `the ${account} refused the key${status ? ` (${status})` : ''}`,
32
+ 'provider-error': (account, status) => `the ${account.replace(/ account$/, '')} failed to answer${status ? ` (${status})` : ''}`,
33
+ }
34
+
35
+ /**
36
+ * The refusal in `message`, or null when it is not the model provider's.
37
+ * `providers` are the ids the local runtime applied keys for (never a key),
38
+ * so a credit refusal can name a model another held key runs; `again` is
39
+ * what the person does once it is fixed ("send the message again").
40
+ */
41
+ export function providerRefusal(message: string, opts: { providers?: readonly string[]; again: string }): ProviderRefusal | null {
42
+ const n = caseCouldNotRun(message)
43
+ if (!n || n.cause !== 'provider') return null
44
+ const kind = n.kind as ProviderRefusal['kind']
45
+ const label = n.provider ? (findProvider(n.provider)?.label ?? n.provider) : null
46
+ const status = /\((\d{3})/.exec(n.what)?.[1] ?? ''
47
+ const account = label ? `${label} account` : 'model provider account'
48
+ const direct = kind === 'credits' ? directModelFor(opts.providers, n.provider) : null
49
+ const base = { ...n, fix: notRunFix(n.kind, opts.again) }
50
+ const fix = withDirectModel(base, direct, opts.again).fix
51
+ const named = label ? fix.replace(/^the model provider account is out of credit/, `the ${label} account is out of credit`) : fix
52
+ return {
53
+ cause: 'provider',
54
+ kind,
55
+ ...(n.provider ? { provider: n.provider, providerLabel: label! } : {}),
56
+ what: WHAT[kind](account, status),
57
+ fix: named,
58
+ ...(direct ? { directModel: { slug: direct.slug, name: direct.name, provider: direct.provider } } : {}),
59
+ providerSaid: message.replace(/\s+/g, ' ').trim().slice(0, 400),
60
+ }
61
+ }
62
+
63
+ /** One line for a person: "couldn't run: <what>. Fix: <fix>". */
64
+ export function refusalLine(r: ProviderRefusal): string {
65
+ return `couldn’t run: ${r.what}. Fix: ${r.fix}`
66
+ }
@@ -0,0 +1,112 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { caseKey, compareWithLastRun, type LastRun } from './local-test-baseline'
5
+ import type { CaseFile, CaseResult } from './local-test-runner'
6
+
7
+ /**
8
+ * Evidence a change request carries (design 2026-10-18 §6): an Agent's gated
9
+ * evals against its last applied run, an App's last render, screenshots
10
+ * from a Forge session. Pure where it can be; the command attaches what
11
+ * these build.
12
+ */
13
+
14
+ /** What the platform's gate reads from an Agent's evals (`change-request-gate.ts`). */
15
+ export interface EvalsDetail {
16
+ gated: number
17
+ passed: number
18
+ failed: number
19
+ newlyFailing: Array<{ name: string; file: string; reason: string | null }>
20
+ /**
21
+ * Cases that couldn't run (the model provider refused them, the runtime did
22
+ * not answer): evidence with any is not attached, since none newly failing
23
+ * would then say nothing about them.
24
+ */
25
+ couldNotRun: Array<{ name: string; what: string; fix: string }>
26
+ fixed: string[]
27
+ stillFailing: string[]
28
+ firstRun: number
29
+ passRate: number | null
30
+ baselinePassRate: number | null
31
+ efficiency: { latencyMs: number | null; toolCalls: number | null; baseline: { latencyMs: number | null; toolCalls: number | null } | null }
32
+ judged: { ran: boolean; reason: string | null }
33
+ ranAt: string
34
+ /** Where they ran. */
35
+ runtime: string
36
+ }
37
+
38
+ const mean = (values: Array<number | undefined>): number | null => {
39
+ const known = values.filter((v): v is number => typeof v === 'number')
40
+ return known.length ? known.reduce((a, b) => a + b, 0) / known.length : null
41
+ }
42
+
43
+ /**
44
+ * One Agent's gated cases, compared with its applied baseline (or its last
45
+ * run, when it was never applied from here): what the gate holds Apply on
46
+ * (newly failing cases), the pass rate either side, and efficiency per case,
47
+ * which is reported and never gates.
48
+ */
49
+ export function evalsDetail(input: { files: CaseFile[]; results: CaseResult[]; baseline: LastRun; runtime: string; now?: Date }): EvalsDetail {
50
+ const { results, baseline } = input
51
+ const compared = compareWithLastRun(results, baseline)
52
+ const before = results.map((r) => baseline[caseKey(r)]).filter((b) => b !== undefined)
53
+ const judgedCases = input.files.some((f) => f.cases.some((c) => c.judged?.length))
54
+ return {
55
+ gated: results.length,
56
+ passed: results.filter((r) => r.passed).length,
57
+ failed: results.filter((r) => !r.passed && !r.notRun).length,
58
+ newlyFailing: compared.newlyFailing.map((r) => ({ name: r.name, file: r.file, reason: r.reason ?? null })),
59
+ couldNotRun: compared.couldNotRun.map((r) => ({ name: r.name, what: r.notRun!.what, fix: r.notRun!.fix })),
60
+ fixed: compared.fixed.map((r) => r.name),
61
+ stillFailing: compared.stillFailing.map((r) => r.name),
62
+ firstRun: compared.firstRun.length,
63
+ passRate: results.length ? results.filter((r) => r.passed).length / results.length : null,
64
+ baselinePassRate: before.length ? before.filter((b) => b!.passed).length / before.length : null,
65
+ efficiency: {
66
+ latencyMs: mean(results.map((r) => r.ms)),
67
+ toolCalls: mean(results.map((r) => r.toolCalls)),
68
+ baseline: before.length ? { latencyMs: mean(before.map((b) => b!.ms)), toolCalls: mean(before.map((b) => b!.toolCalls)) } : null,
69
+ },
70
+ // Judged expectations are graded by the platform's judge model; a local run grades the rest.
71
+ judged: { ran: false, reason: judgedCases ? 'judged expectations are graded on the platform, with its judge model' : null },
72
+ ranAt: (input.now ?? new Date()).toISOString(),
73
+ runtime: input.runtime,
74
+ }
75
+ }
76
+
77
+ /** A short line for the person: how the gated cases went. */
78
+ export function evalsLine(title: string, d: EvalsDetail): string {
79
+ if (!d.gated) return `${title}: no gated cases (mark the ones that guard it with gate: true)`
80
+ if (d.couldNotRun.length) return `${title}: ${d.couldNotRun.length} of ${d.gated} gated case${d.gated === 1 ? '' : 's'} couldn’t run: ${[...new Set(d.couldNotRun.map((c) => c.what))].join('; ')}; ${d.couldNotRun[0]!.fix}`
81
+ const rate = d.passRate === null ? '' : `, ${Math.round(d.passRate * 100)}% pass${d.baselinePassRate === null ? '' : ` (was ${Math.round(d.baselinePassRate * 100)}%)`}`
82
+ return `${title}: ${d.gated} gated case${d.gated === 1 ? '' : 's'}, ${d.newlyFailing.length ? `${d.newlyFailing.length} newly failing (${d.newlyFailing.map((c) => c.name).join(', ')})` : 'none newly failing'}${rate}`
83
+ }
84
+
85
+ /** The newest pictures a session saved (`.frontera/evidence/<session>/`), newest first, at most `limit`. */
86
+ export function sessionScreenshots(root: string, session: string, limit = 6): Array<{ path: string; name: string; bytes: number; at: number }> {
87
+ if (!/^[\w.-]{1,120}$/.test(session)) return []
88
+ const dir = join(root, '.frontera', 'evidence', session)
89
+ if (!existsSync(dir)) return []
90
+ return readdirSync(dir)
91
+ .filter((n) => /\.(png|jpe?g|webp)$/i.test(n))
92
+ .map((n) => {
93
+ const st = statSync(join(dir, n))
94
+ return { path: join(dir, n), name: n, bytes: st.size, at: st.mtimeMs }
95
+ })
96
+ .sort((a, b) => b.at - a.at)
97
+ .slice(0, limit)
98
+ }
99
+
100
+ /** An App's last render, as `app_render` and `frontera app dev` keep it in its folder. */
101
+ export function lastRender(appRoot: string): { detail: Record<string, unknown>; image: Buffer | null } | null {
102
+ const json = join(appRoot, '.frontera', 'last-render.json')
103
+ if (!existsSync(json)) return null
104
+ let detail: Record<string, unknown>
105
+ try {
106
+ detail = JSON.parse(readFileSync(json, 'utf8')) as Record<string, unknown>
107
+ } catch {
108
+ return null
109
+ }
110
+ const png = join(appRoot, '.frontera', 'last-render.png')
111
+ return { detail, image: existsSync(png) ? readFileSync(png) : null }
112
+ }
@@ -0,0 +1,123 @@
1
+ import { portAnswers } from './local-component-health'
2
+ import { snapshotInProgress } from './local-db-snapshot'
3
+ import type { LocalProject } from './local-project'
4
+ import { isAlive, readState } from './local-state'
5
+
6
+ /**
7
+ * Whether the local runtime answers, for telling an apply that failed for its
8
+ * files from one that failed because the runtime went away under it (stopped,
9
+ * restarting, Postgres going down, a database snapshot being restored).
10
+ */
11
+
12
+ /** What an item's error says when the runtime wasn't answering. */
13
+ export const RUNTIME_DOWN_ERROR = 'the local runtime isn’t answering (start it with `frontera local start`)'
14
+ /** What `local dev` says when the runtime went down during an apply. */
15
+ export const RUNTIME_STOPPED_LINE = 'runtime stopped mid-apply; the next apply retries it'
16
+
17
+ /** Output a command prints when it could not reach the service at all. */
18
+ const CONNECTION_ERROR = /ECONNREFUSED|ECONNRESET|EPIPE|Unable to connect|fetch failed|socket connection was closed|connection (?:was )?(?:refused|reset|closed)|ConnectionRefused|ConnectionClosed|FailedToOpenSocket|network error/i
19
+
20
+ export function isConnectionError(output: string): boolean {
21
+ return CONNECTION_ERROR.test(output)
22
+ }
23
+
24
+ export interface HealthDeps {
25
+ fetch?: (url: string, init?: RequestInit) => Promise<Response>
26
+ /** Whether something accepts a connection on a local port (Postgres's). */
27
+ portAnswers?: (port: number) => Promise<boolean>
28
+ }
29
+
30
+ /**
31
+ * The runtime answers: its supervisor is up and says it is ready, no database
32
+ * snapshot holds the database, Postgres takes connections, and the service's
33
+ * health check answers. The service's `/health` answers from memory, so a
34
+ * Postgres that went down under it is asked on its own port.
35
+ */
36
+ export async function runtimeAnswering(project: LocalProject, deps: HealthDeps = {}): Promise<boolean> {
37
+ const state = readState(project)
38
+ if (!state || state.phase !== 'ready' || !state.supervisorPid || !isAlive(state.supervisorPid)) return false
39
+ if (snapshotInProgress(project)) return false
40
+ if (state.ports.postgres && !(await (deps.portAnswers ?? portAnswers)(state.ports.postgres))) return false
41
+ try {
42
+ const res = await (deps.fetch ?? fetch)(`http://127.0.0.1:${state.ports.service}/health`, { signal: AbortSignal.timeout(3000) })
43
+ return res.ok
44
+ } catch {
45
+ return false
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Whether a failed apply failed because the runtime wasn't answering: checked
51
+ * once, now. A runtime that answers again was down for the moment only when
52
+ * the command itself couldn't reach it; a refusal it answered (a 500
53
+ * included) is the item's own failure.
54
+ */
55
+ export async function failedForRuntime(project: LocalProject, output: string, deps: HealthDeps = {}): Promise<boolean> {
56
+ if (!(await runtimeAnswering(project, deps))) return true
57
+ const first = output.split('\n').find((l) => /^error/i.test(l.trim())) ?? ''
58
+ return /\[INTERNAL_ERROR\]/.test(first) && isConnectionError(first)
59
+ }
60
+
61
+ export interface DatabaseReadyOptions {
62
+ /** How long to keep asking, in all (30 s). */
63
+ timeoutMs?: number
64
+ /** Rounds in a row in which every request found the database (2). */
65
+ rounds?: number
66
+ /** Requests sent at once in a round, so more than one of the service's pooled connections answers (4). */
67
+ parallel?: number
68
+ /** Pause between rounds (250 ms). */
69
+ intervalMs?: number
70
+ fetch?: (url: string, init?: RequestInit) => Promise<Response>
71
+ sleep?: (ms: number) => Promise<void>
72
+ now?: () => number
73
+ }
74
+
75
+ /**
76
+ * Whether the service reaches its database again after the database was
77
+ * dropped and made again (a snapshot restore), or its connections ended (a
78
+ * save): `/health` answers from memory, so the service's database-backed
79
+ * health route (`/v1/health`, which runs a query) is asked instead, several
80
+ * requests at once and several rounds in a row, until every request says the
81
+ * database is connected. A pooled connection ended under the service fails
82
+ * its next query, so one answer alone is not enough. A runtime from before
83
+ * that route answers 404 there; its `/health` is taken instead.
84
+ */
85
+ export async function waitForDatabaseReady(serviceUrl: string, options: DatabaseReadyOptions = {}): Promise<boolean> {
86
+ const doFetch = options.fetch ?? fetch
87
+ const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms))
88
+ const now = options.now ?? Date.now
89
+ const rounds = options.rounds ?? 2
90
+ const parallel = Math.max(1, options.parallel ?? 4)
91
+ const interval = options.intervalMs ?? 250
92
+ const deadline = now() + (options.timeoutMs ?? 30_000)
93
+ const base = serviceUrl.replace(/\/+$/, '')
94
+ const ask = async (): Promise<'connected' | 'no-route' | 'not-yet'> => {
95
+ try {
96
+ const res = await doFetch(`${base}/v1/health`, { signal: AbortSignal.timeout(3000) })
97
+ if (res.status === 404) return 'no-route'
98
+ if (!res.ok) return 'not-yet'
99
+ const body = (await res.json().catch(() => null)) as { data?: { database?: unknown } } | null
100
+ return body?.data?.database === 'connected' ? 'connected' : 'not-yet'
101
+ } catch {
102
+ return 'not-yet'
103
+ }
104
+ }
105
+ let inARow = 0
106
+ while (now() < deadline) {
107
+ const answers = await Promise.all(Array.from({ length: parallel }, ask))
108
+ if (answers.every((a) => a === 'no-route')) {
109
+ try {
110
+ const res = await doFetch(`${base}/health`, { signal: AbortSignal.timeout(3000) })
111
+ if (res.ok) return true
112
+ } catch {
113
+ // Not up yet.
114
+ }
115
+ inARow = 0
116
+ } else if (answers.every((a) => a === 'connected')) {
117
+ inARow += 1
118
+ if (inARow >= rounds) return true
119
+ } else inARow = 0
120
+ await sleep(interval)
121
+ }
122
+ return false
123
+ }
@@ -0,0 +1,50 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { writeFileAtomic } from '../fs-atomic'
5
+ import type { LocalProject } from './local-project'
6
+
7
+ /**
8
+ * Each time the project's runtime started or stopped, newest last: Forge's
9
+ * Activity lists them beside the applies, so "Applied 290 items: runtime
10
+ * started" has the start it follows, and a stop explains why nothing ran
11
+ * after it. Written by the supervisor; a missed record never fails a start
12
+ * or a stop.
13
+ */
14
+
15
+ export interface RuntimeRecord {
16
+ event: 'started' | 'stopped' | 'failed'
17
+ /** When it happened: a start when the runtime was ready, a stop when it had stopped. */
18
+ at: string
19
+ /** A start: when it began starting, so its duration can be said. */
20
+ since?: string
21
+ /** A failed start: which part, and why. */
22
+ detail?: string
23
+ }
24
+
25
+ /** How many are kept: Activity shows the recent ones. */
26
+ const KEEP = 30
27
+
28
+ const historyPath = (stateDir: string) => join(stateDir, 'runtime-history.json')
29
+
30
+ /** The records kept, oldest first; none when the file is missing or unreadable. */
31
+ export function readRuntimeHistory(stateDir: string): RuntimeRecord[] {
32
+ try {
33
+ const list = JSON.parse(readFileSync(historyPath(stateDir), 'utf8')) as unknown
34
+ return Array.isArray(list)
35
+ ? list.filter((r): r is RuntimeRecord => !!r && typeof (r as RuntimeRecord).at === 'string' && ['started', 'stopped', 'failed'].includes((r as RuntimeRecord).event))
36
+ : []
37
+ } catch {
38
+ return []
39
+ }
40
+ }
41
+
42
+ /** Keep one record. Never throws. */
43
+ export function recordRuntime(project: Pick<LocalProject, 'stateDir'>, record: RuntimeRecord): void {
44
+ try {
45
+ const list = [...readRuntimeHistory(project.stateDir), record].slice(-KEEP)
46
+ writeFileAtomic(historyPath(project.stateDir), `${JSON.stringify(list, null, 2)}\n`, 0o644)
47
+ } catch {
48
+ // The runtime itself is what matters.
49
+ }
50
+ }
@@ -0,0 +1,42 @@
1
+ import { localProject, type LocalProject } from './local-project'
2
+ import { isAlive, readState, type LocalCredentials, type LocalState } from './local-state'
3
+
4
+ /**
5
+ * The local runtime a command reaches, when it reaches one: the project the
6
+ * working directory is in has a ready runtime, and the command's API address
7
+ * is that runtime's service. Commands that behave differently on the local
8
+ * runtime (resolving a project file's names, acting as a seeded persona) ask
9
+ * this, so nothing local-only ever reaches a hosted deployment.
10
+ */
11
+ export interface LocalRuntimeTarget {
12
+ project: LocalProject
13
+ state: LocalState
14
+ creds: LocalCredentials
15
+ serviceUrl: string
16
+ }
17
+
18
+ /** `http://localhost:4100/` and `http://127.0.0.1:4100` are the same service. */
19
+ function sameService(a: string, b: string): boolean {
20
+ try {
21
+ const x = new URL(a)
22
+ const y = new URL(b)
23
+ const host = (h: string) => (h === 'localhost' || h === '[::1]' ? '127.0.0.1' : h)
24
+ return x.protocol === y.protocol && host(x.hostname) === host(y.hostname) && x.port === y.port
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ export function localRuntimeAt(cwd: string, apiUrl: string): LocalRuntimeTarget | null {
31
+ let project: LocalProject
32
+ try {
33
+ project = localProject(cwd)
34
+ } catch {
35
+ return null
36
+ }
37
+ const state = readState(project)
38
+ if (!state?.credentials || state.phase !== 'ready' || !isAlive(state.supervisorPid)) return null
39
+ const serviceUrl = `http://127.0.0.1:${state.ports.service}`
40
+ if (!sameService(serviceUrl, apiUrl)) return null
41
+ return { project, state, creds: state.credentials, serviceUrl }
42
+ }