@frontera-sdk/cli 1.52.38 → 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,107 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import { POSTGRES_DB, POSTGRES_USER } from './local-env'
4
+
5
+ /**
6
+ * The workspace secrets carried across a database restore.
7
+ *
8
+ * A restore (`frontera local snapshot restore`, `local check`'s rollback)
9
+ * throws away what was written since the baseline: test data. Workspace
10
+ * secrets are configuration, set by the person (`frontera secret set`), not
11
+ * by a test, so a restore keeps them as they are now unless asked to put the
12
+ * baseline's back (`--exact`).
13
+ *
14
+ * The rows are copied as stored, never decrypted: a value is `enc:v2`
15
+ * ciphertext under its workspace's data key version, and that key is a
16
+ * wrapped row in `workspace_deks` that only the service can unwrap. So the
17
+ * key versions the restored database lacks come with the secrets, and the
18
+ * value decrypts there exactly as before. A value under a key version the
19
+ * restored database holds as a different key would not decrypt: it is left
20
+ * as the baseline holds it and named, to be set again. Values never leave
21
+ * the database container as plain text, and nothing here is written to disk.
22
+ */
23
+
24
+ export type QueryFn = (container: string, cmd: string[], input?: string) => { ok: boolean; out: string }
25
+
26
+ /** What the current database holds, read before it is dropped: its workspaces, their secrets and data keys, as JSON. */
27
+ export interface SecretsHeld {
28
+ json: string
29
+ count: number
30
+ }
31
+
32
+ export interface SecretsCarried {
33
+ ok: boolean
34
+ /** How many secrets are as they were before the restore. */
35
+ kept: number
36
+ /** Secrets whose value would not decrypt in the restored database, by name: set them again. */
37
+ notKept: string[]
38
+ /** Why nothing was carried, when it failed. */
39
+ problem?: string
40
+ }
41
+
42
+ const psqlArgs = (extra: string[]) => ['psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-v', 'ON_ERROR_STOP=1', '-q', '-At', ...extra]
43
+
44
+ export const READ_SECRETS_SQL = `SELECT json_build_object(
45
+ 'workspaces', coalesce((SELECT json_agg(id) FROM workspaces), '[]'::json),
46
+ 'secrets', coalesce((SELECT json_agg(s) FROM workspace_secrets s), '[]'::json),
47
+ 'deks', coalesce((SELECT json_agg(d) FROM workspace_deks d), '[]'::json))`
48
+
49
+ /** The secrets and data keys as the database holds them now, or why they could not be read. */
50
+ export function readSecrets(query: QueryFn, container: string): SecretsHeld | { problem: string } {
51
+ const res = query(container, psqlArgs(['-c', READ_SECRETS_SQL.replace(/\s+/g, ' ')]))
52
+ if (!res.ok) return { problem: 'the current secrets could not be read' }
53
+ try {
54
+ const parsed = JSON.parse(res.out) as { secrets?: unknown[] }
55
+ return { json: res.out, count: Array.isArray(parsed.secrets) ? parsed.secrets.length : 0 }
56
+ } catch {
57
+ return { problem: 'the current secrets could not be read' }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The SQL that puts the held secrets into the restored database, in one
63
+ * transaction: the data key versions it lacks are added, each workspace's
64
+ * secrets replaced by the held ones (a secret deleted since the baseline
65
+ * stays deleted), except those under a key version it holds differently.
66
+ * Answers one JSON line: how many were kept, and the names that weren't.
67
+ */
68
+ export function carrySql(json: string, tag = `carry_${randomBytes(6).toString('hex')}`): string {
69
+ if (json.includes(`$${tag}$`)) throw new Error('the held secrets contain the quoting tag')
70
+ const q = `$${tag}$`
71
+ return [
72
+ 'BEGIN;',
73
+ `CREATE TEMP TABLE carry ON COMMIT DROP AS SELECT ${q}${json}${q}::json AS j;`,
74
+ // Only workspaces both databases have: one made after the baseline is gone with it.
75
+ `CREATE TEMP TABLE carry_ws ON COMMIT DROP AS SELECT (value #>> '{}')::uuid AS id FROM json_array_elements((SELECT j->'workspaces' FROM carry)) WHERE (value #>> '{}')::uuid IN (SELECT id FROM workspaces);`,
76
+ `CREATE TEMP TABLE carry_deks ON COMMIT DROP AS SELECT * FROM json_populate_recordset(NULL::workspace_deks, (SELECT j->'deks' FROM carry)) WHERE workspace_id IN (SELECT id FROM carry_ws);`,
77
+ `CREATE TEMP TABLE carry_secrets ON COMMIT DROP AS SELECT * FROM json_populate_recordset(NULL::workspace_secrets, (SELECT j->'secrets' FROM carry)) WHERE workspace_id IN (SELECT id FROM carry_ws);`,
78
+ // A key version the restored database holds as another key: a value under it would not decrypt there.
79
+ `CREATE TEMP TABLE carry_skip ON COMMIT DROP AS SELECT s.workspace_id, s.name FROM carry_secrets s JOIN carry_deks c ON c.workspace_id = s.workspace_id AND s.encrypted_value LIKE 'enc:v2:dek.' || c.version || ':%' JOIN workspace_deks d ON d.workspace_id = c.workspace_id AND d.version = c.version AND d.wrapped_dek <> c.wrapped_dek;`,
80
+ `INSERT INTO workspace_deks SELECT c.* FROM carry_deks c WHERE NOT EXISTS (SELECT 1 FROM workspace_deks d WHERE d.workspace_id = c.workspace_id AND d.version = c.version) ON CONFLICT DO NOTHING;`,
81
+ `DELETE FROM workspace_secrets t WHERE t.workspace_id IN (SELECT id FROM carry_ws) AND NOT EXISTS (SELECT 1 FROM carry_skip k WHERE k.workspace_id = t.workspace_id AND k.name = t.name);`,
82
+ `INSERT INTO workspace_secrets SELECT s.* FROM carry_secrets s WHERE NOT EXISTS (SELECT 1 FROM carry_skip k WHERE k.workspace_id = s.workspace_id AND k.name = s.name) ON CONFLICT DO NOTHING;`,
83
+ `SELECT json_build_object('kept', (SELECT count(*) FROM workspace_secrets t JOIN carry_secrets s ON s.id = t.id AND s.encrypted_value = t.encrypted_value), 'notKept', coalesce((SELECT json_agg(name ORDER BY name) FROM carry_skip), '[]'::json));`,
84
+ 'COMMIT;',
85
+ '',
86
+ ].join('\n')
87
+ }
88
+
89
+ /** Put the held secrets into the restored database (`carrySql`), and say what became of them. */
90
+ export function carrySecrets(query: QueryFn, container: string, held: SecretsHeld): SecretsCarried {
91
+ let sql: string
92
+ try {
93
+ sql = carrySql(held.json)
94
+ } catch (err) {
95
+ return { ok: false, kept: 0, notKept: [], problem: (err as Error).message }
96
+ }
97
+ const res = query(container, psqlArgs(['-f', '-']), sql)
98
+ if (!res.ok) return { ok: false, kept: 0, notKept: [], problem: 'the restored database refused them' }
99
+ try {
100
+ const line = res.out.split('\n').map((l) => l.trim()).filter(Boolean).at(-1) ?? '{}'
101
+ const said = JSON.parse(line) as { kept?: number; notKept?: string[] }
102
+ return { ok: true, kept: Number(said.kept ?? 0), notKept: Array.isArray(said.notKept) ? said.notKept.filter((n) => typeof n === 'string') : [] }
103
+ } catch {
104
+ // Committed, but its answer could not be read: what the runtime lists says the rest.
105
+ return { ok: true, kept: held.count, notKept: [] }
106
+ }
107
+ }
@@ -115,27 +115,57 @@ export function needsValueLine(name: string, who: string): string {
115
115
  /** The build's lines for the secrets `names` that the local runtime has no value for. */
116
116
  export async function missingSecretLines(names: string[], who: string, serviceUrl: string, creds: LocalCredentials): Promise<string[]> {
117
117
  if (!names.length) return []
118
- const have = await localSecretNames(serviceUrl, creds)
119
- if (!have) return []
120
- return names.filter((n) => !have.has(n)).map((n) => needsValueLine(n, who))
118
+ const presence = await localSecretPresence(serviceUrl, creds)
119
+ if (!presence) return []
120
+ return names.filter((n) => !hasSecretValue(presence, n)).map((n) => needsValueLine(n, who))
121
121
  }
122
122
 
123
- /** The secret names the local runtime holds (never their values); `null` when it could not say. */
124
- export async function localSecretNames(serviceUrl: string, creds: LocalCredentials): Promise<Set<string> | null> {
123
+ /**
124
+ * What the local runtime holds of its secrets, never their values: each name
125
+ * the secrets list route lists, and those of them it says have no value
126
+ * (`hasValue: false` on a row; a runtime that does not say counts a listed
127
+ * secret as having one).
128
+ */
129
+ export interface SecretPresence {
130
+ names: Set<string>
131
+ withoutValue: Set<string>
132
+ }
133
+
134
+ /** Whether the runtime has a value for `name`: listed, and not said to be without one. */
135
+ export function hasSecretValue(presence: SecretPresence, name: string): boolean {
136
+ return presence.names.has(name) && !presence.withoutValue.has(name)
137
+ }
138
+
139
+ /** The secrets list route's answer, read as `SecretPresence`; `null` when it could not say. */
140
+ export async function localSecretPresence(serviceUrl: string, creds: LocalCredentials, deps: { fetch?: (url: string | URL, init?: RequestInit) => Promise<Response> } = {}): Promise<SecretPresence | null> {
125
141
  if (!creds.workspaceKey || !creds.workspaceId) return null
126
142
  try {
127
- const res = await fetch(new URL(`/v1/workspaces/${encodeURIComponent(creds.workspaceId)}/secrets`, serviceUrl), {
143
+ const res = await (deps.fetch ?? fetch)(new URL(`/v1/workspaces/${encodeURIComponent(creds.workspaceId)}/secrets`, serviceUrl), {
128
144
  headers: { authorization: `Bearer ${creds.workspaceKey}` },
145
+ signal: AbortSignal.timeout(5000),
129
146
  })
130
147
  if (!res.ok) return null
131
- const body = (await res.json()) as { data?: Array<{ name?: string }> } | Array<{ name?: string }>
148
+ const body = (await res.json()) as { data?: Array<{ name?: string; hasValue?: unknown }> } | Array<{ name?: string; hasValue?: unknown }>
132
149
  const rows = Array.isArray(body) ? body : (body.data ?? [])
133
- return new Set(rows.flatMap((r) => (typeof r.name === 'string' ? [r.name] : [])))
150
+ const names = new Set<string>()
151
+ const withoutValue = new Set<string>()
152
+ for (const r of rows) {
153
+ if (typeof r.name !== 'string') continue
154
+ names.add(r.name)
155
+ if (r.hasValue === false) withoutValue.add(r.name)
156
+ }
157
+ return { names, withoutValue }
134
158
  } catch {
135
159
  return null
136
160
  }
137
161
  }
138
162
 
163
+ /** The secret names the local runtime holds a value for (never the values); `null` when it could not say. */
164
+ export async function localSecretNames(serviceUrl: string, creds: LocalCredentials): Promise<Set<string> | null> {
165
+ const presence = await localSecretPresence(serviceUrl, creds)
166
+ return presence ? new Set([...presence.names].filter((n) => !presence.withoutValue.has(n))) : null
167
+ }
168
+
139
169
  /**
140
170
  * A Plugin file as a push sends it: every header, key-like setting and
141
171
  * environment value empty, as the pull writes them. The platform keeps its
@@ -1,6 +1,6 @@
1
1
  import { appendFileSync } from 'node:fs'
2
2
 
3
- import { findProvider, getKey, keylessEmbeddings, LOCAL_PROVIDERS } from './local-keys'
3
+ import { findProvider, getKey, LOCAL_PROVIDERS, localEmbeddingsReachable } from './local-keys'
4
4
  import type { LocalCredentials } from './local-state'
5
5
 
6
6
  /**
@@ -174,11 +174,35 @@ const EMBEDDING: Array<{ provider: string; model: string }> = [
174
174
  { provider: 'openrouter', model: 'openai/text-embedding-3-small' },
175
175
  ]
176
176
 
177
+ /**
178
+ * The runtime's embedding choice, when it has one its credentials can run
179
+ * (`/v1/config/embedding` lists only credentialed providers): one chosen
180
+ * before, by `local dev` to match the workspace or by its owner, is kept.
181
+ */
182
+ async function credentialedEmbedding(serviceUrl: string, token: string, orgId: string): Promise<{ provider: string; model: string } | null> {
183
+ try {
184
+ const res = await call<{ data?: EmbeddingView } & EmbeddingView>(serviceUrl, '/v1/config/embedding', { token, orgId })
185
+ if (res.status >= 300) return null
186
+ const view = res.body?.data ?? res.body
187
+ const { provider, model } = view ?? {}
188
+ if (typeof provider !== 'string' || !provider || typeof model !== 'string' || !model) return null
189
+ return Array.isArray(view?.providers) && view.providers.some((p) => p?.id === provider) ? { provider, model } : null
190
+ } catch {
191
+ return null
192
+ }
193
+ }
194
+
195
+ interface EmbeddingView {
196
+ provider?: string | null
197
+ model?: string | null
198
+ providers?: Array<{ id?: string }>
199
+ }
200
+
177
201
  /**
178
202
  * Copy the FDE's stored keys into the local organization's credentials, and
179
- * pick an embedding model when one is possible. Returns what was applied, for
180
- * the status line; failures are logged, never fatal — a runtime without a model
181
- * key still starts, and says so.
203
+ * pick an embedding model when one is possible and none that runs is chosen
204
+ * yet. Returns what was applied, for the status line; failures are logged,
205
+ * never fatal — a runtime without a model key still starts, and says so.
182
206
  */
183
207
  export async function applyModelKeys(args: { serviceUrl: string; password: string; organizationId: string; logFile: string }): Promise<string[]> {
184
208
  const applied: string[] = []
@@ -202,33 +226,56 @@ export async function applyModelKeys(args: { serviceUrl: string; password: strin
202
226
  if (res.status >= 200 && res.status < 300) applied.push(p.id)
203
227
  else log(args.logFile, `storing the ${findProvider(p.id)?.label} key answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
204
228
  }
205
- let embedding = EMBEDDING.find((e) => applied.includes(e.provider))
206
- if (!embedding && keylessEmbeddings()) {
207
- // A placeholder OpenAI credential: the service's OpenAI client is pointed
208
- // at the local embeddings (see serviceEnv), which embed without a key.
209
- const res = await call(args.serviceUrl, '/v1/admin/provider-credentials/openai', {
210
- method: 'PUT',
211
- token,
212
- orgId: args.organizationId,
213
- body: { fields: { apiKey: 'local-embeddings' } },
214
- })
215
- if (res.status >= 200 && res.status < 300) {
216
- embedding = EMBEDDING[0]
217
- applied.push('local-embeddings')
218
- } else log(args.logFile, `storing the local embeddings credential answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
229
+ // A choice that runs with this runtime's keys stays: the workspace's model (`mirrorOrgSettings`) or the owner's.
230
+ // The local embeddings' stand-in OpenAI credential is not a key: a real key's model replaces it when the provider takes it.
231
+ const kept = await credentialedEmbedding(args.serviceUrl, token, args.organizationId)
232
+ const standIn = !!kept && isLocalEmbeddings(kept)
233
+ if (kept && !standIn) {
234
+ log(args.logFile, `kept the embedding model ${kept.provider} / ${kept.model}`)
235
+ return applied
219
236
  }
220
- if (embedding) {
221
- const res = await call(args.serviceUrl, '/v1/config/embedding', {
222
- method: 'PUT',
223
- token,
224
- orgId: args.organizationId,
225
- body: embedding,
226
- })
227
- if (res.status >= 300) log(args.logFile, `setting the embedding model answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
237
+ const setEmbedding = async (embedding: { provider: string; model: string }) => {
238
+ const res = await call(args.serviceUrl, '/v1/config/embedding', { method: 'PUT', token, orgId: args.organizationId, body: embedding })
239
+ if (res.status < 300) return true
240
+ log(args.logFile, `setting the embedding model ${embedding.provider} / ${embedding.model} answered ${res.status}: ${JSON.stringify(res.body).slice(0, 300)}`)
241
+ return false
242
+ }
243
+ for (const embedding of EMBEDDING.filter((e) => applied.includes(e.provider))) {
244
+ if (await setEmbedding(embedding)) return applied
245
+ }
246
+ if (standIn) {
247
+ applied.push('local-embeddings')
248
+ return applied
249
+ }
250
+ // No key, or the provider refused the one stored (the line above says its answer): the keyless local
251
+ // embeddings, so knowledge bases still ingest and search here. They need the service's OpenAI client,
252
+ // which points at them while this computer has no OpenAI key (see serviceEnv).
253
+ if (!localEmbeddingsReachable()) return applied
254
+ const res = await call(args.serviceUrl, '/v1/admin/provider-credentials/openai', {
255
+ method: 'PUT',
256
+ token,
257
+ orgId: args.organizationId,
258
+ body: { fields: { apiKey: LOCAL_EMBEDDINGS_CREDENTIAL } },
259
+ })
260
+ if (res.status >= 300) {
261
+ log(args.logFile, `storing the local embeddings credential answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
262
+ return applied
263
+ }
264
+ if (await setEmbedding(EMBEDDING[0]!)) {
265
+ applied.push('local-embeddings')
266
+ if (applied.some((p) => p === 'openrouter')) log(args.logFile, 'embedding with the keyless local embeddings: the stored key’s embedding model was refused (see the line above)')
228
267
  }
229
268
  return applied
230
269
  }
231
270
 
271
+ /** The placeholder OpenAI credential that sends the service's OpenAI client to the keyless local embeddings. */
272
+ const LOCAL_EMBEDDINGS_CREDENTIAL = 'local-embeddings'
273
+
274
+ /** Whether a choice is the keyless local embeddings: OpenAI's model while this computer holds no OpenAI key. */
275
+ export function isLocalEmbeddings(embedding: { provider: string | null; model: string | null }): boolean {
276
+ return embedding.provider === 'openai' && localEmbeddingsReachable()
277
+ }
278
+
232
279
  /**
233
280
  * Runtimes started before the echo model was removed still list it: the
234
281
  * `local` custom provider, its `local-model` catalog entry, and that model as
@@ -273,6 +320,35 @@ export const PROVIDER_MODELS: Record<string, Array<{ slug: string; modelId: stri
273
320
  { slug: 'gemini-3-8-flash', modelId: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
274
321
  { slug: 'gpt-5-6-terra', modelId: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra' },
275
322
  ],
323
+ // Direct from Anthropic, beside OpenRouter's: a stored Anthropic key runs Claude without OpenRouter's
324
+ // credit. Their slugs name the provider, so they never take the OpenRouter models' slugs.
325
+ anthropic: [
326
+ { slug: 'anthropic-claude-haiku-5-5', modelId: 'claude-haiku-5-5', name: 'Claude Haiku 5.5 (Anthropic)' },
327
+ { slug: 'anthropic-claude-sonnet-5-5', modelId: 'claude-sonnet-5-5', name: 'Claude Sonnet 5.5 (Anthropic)' },
328
+ ],
329
+ }
330
+
331
+ /** A model the local catalog offers directly from a provider (not through OpenRouter), and that provider. */
332
+ export interface DirectModel {
333
+ slug: string
334
+ name: string
335
+ provider: string
336
+ providerLabel: string
337
+ }
338
+
339
+ /**
340
+ * The first model offered directly by a provider whose key the runtime holds
341
+ * (`providers`: the ids the runtime applied keys for, never a key), other than
342
+ * the provider that refused (`refusedBy`); null when there is none. What a
343
+ * credit refusal suggests switching to.
344
+ */
345
+ export function directModelFor(providers: readonly string[] | undefined, refusedBy?: string | null): DirectModel | null {
346
+ for (const [provider, models] of Object.entries(PROVIDER_MODELS)) {
347
+ if (provider === 'openrouter' || provider === refusedBy || !providers?.includes(provider) || !models.length) continue
348
+ const m = models[0]!
349
+ return { slug: m.slug, name: m.name.replace(/\s*\([^)]*\)$/, ''), provider, providerLabel: findProvider(provider)?.label ?? provider }
350
+ }
351
+ return null
276
352
  }
277
353
 
278
354
  /**
@@ -34,7 +34,7 @@ import { rows } from './local-sync-remote'
34
34
  * - **Documentation** (Frontera's documentation for Agents): its stand-in
35
35
  * `serves: "documentation"`, answering the plugin's tools from the
36
36
  * documentation `local start` copied for the runtime's release (the same
37
- * copy `frontera local docs-mcp` serves the project's coding agents). The
37
+ * copy `frontera mcp` serves the project's coding agents). The
38
38
  * Agent's documentation tools work here.
39
39
  * - **Analytics**: the workspace's analytics tools read its analytics
40
40
  * project, which a local runtime does not have (design §8). The pull
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
+ import { ignoreInFronteraDir } from '../project-context'
4
5
  import { writeFileAtomic } from '../fs-atomic'
5
6
  import type { LocalProject } from './local-project'
6
7
 
@@ -88,8 +89,8 @@ export function ensureStateDir(project: LocalProject): void {
88
89
  } catch {
89
90
  // Best effort; a filesystem without modes still works.
90
91
  }
91
- const ignore = join(project.root, '.frontera', '.gitignore')
92
- if (!existsSync(ignore)) writeFileAtomic(ignore, 'local/\n', 0o644)
92
+ // The runtime's state, and which profile this machine bound the directory to, stay out of git.
93
+ ignoreInFronteraDir(project.root, ['local/'])
93
94
  }
94
95
 
95
96
  export function readState(project: LocalProject): LocalState | null {
@@ -163,3 +164,43 @@ export function readApplied(project: LocalProject): LastApplied | null {
163
164
  return null
164
165
  }
165
166
  }
167
+
168
+ /**
169
+ * An apply under way: `local dev` writes it as it starts applying what is
170
+ * queued and removes it when the queue is done, so Forge and Studio say
171
+ * "Applying…" rather than show a half-made count as if it were the result.
172
+ */
173
+ export interface ApplyingNow {
174
+ /** When this round of applies began. */
175
+ since: string
176
+ /** How many items were queued when it began. */
177
+ queued: number
178
+ /** The `local dev` applying them: a record whose process is gone is stale. */
179
+ pid: number
180
+ /** What started it, in words: "runtime started", "files changed: agents/desk/agent.json". */
181
+ trigger?: string
182
+ }
183
+
184
+ function applyingPath(project: LocalProject): string {
185
+ return join(project.stateDir, 'applying.json')
186
+ }
187
+
188
+ /** Say an apply is under way (`entry`), or that it is done (`null`). Never throws. */
189
+ export function recordApplying(project: LocalProject, entry: ApplyingNow | null): void {
190
+ try {
191
+ if (entry) writeFileAtomic(applyingPath(project), `${JSON.stringify(entry)}\n`, 0o644)
192
+ else rmSync(applyingPath(project), { force: true })
193
+ } catch {
194
+ // A missed hint must never fail an apply.
195
+ }
196
+ }
197
+
198
+ /** The apply under way, or null: none, or its `local dev` is gone. */
199
+ export function readApplying(stateDir: string): ApplyingNow | null {
200
+ try {
201
+ const entry = JSON.parse(readFileSync(join(stateDir, 'applying.json'), 'utf8')) as ApplyingNow
202
+ return entry && typeof entry.since === 'string' && isAlive(entry.pid) ? entry : null
203
+ } catch {
204
+ return null
205
+ }
206
+ }
@@ -5,11 +5,15 @@ import { extname, join, normalize } from 'node:path'
5
5
  import type { LocalProject } from './local-project'
6
6
  import { AgentFileError, saveAgentEdit, type AgentFileEdit } from './local-agent-files'
7
7
  import { solutionStatus } from './local-applied'
8
+ import { appCodeStates } from './local-apps'
8
9
  import { projectBlueprintOutline } from './local-blueprint-outline'
9
- import { itemBuild } from './local-build-summary'
10
- import { logPath, readApplied, readState, type ComponentName } from './local-state'
11
- import { appendTestCase, renderTestCase } from './local-test-cases'
10
+ import { currentBuildRows, currentBuildSummary } from './local-build-inputs'
11
+ import { appStatusRow, itemBuild } from './local-build-summary'
12
+ import { KnowledgeDocumentError, KNOWLEDGE_UPLOAD_EXTENSIONS, MAX_KNOWLEDGE_UPLOAD_BYTES, checkUpload, documentInFolder, knowledgeFolderOf, projectDocuments, removeKnowledgeDocument, removeRuntimeCopies, runtimeDocuments, saveKnowledgeDocument, type ServiceCall } from './local-knowledge-uploads'
13
+ import { isAlive, logPath, readApplied, readState, type ComponentName } from './local-state'
14
+ import { appendTestCase, decisionOfApprovals, MIXED_DECISIONS_LINE, noteRedactedVariables, redactedVariablesLine, redactedVariablesOfTimeline, renderTestCase, variablesOfTimeline } from './local-test-cases'
12
15
  import { findCaseFiles } from './local-test-runner'
16
+ import { workflowTestListing } from './local-workflow-tests'
13
17
  import { createTestRuns } from './local-test-runs'
14
18
  import { docsCopyPath } from './local-docs'
15
19
  import { standInMcp } from './local-stand-ins'
@@ -62,6 +66,16 @@ export function newSessionToken(): string {
62
66
  return randomBytes(24).toString('base64url')
63
67
  }
64
68
 
69
+ /** An Agent's display name from `agents/<slug>/agent.json` (`config.name`), or null when it gives none. */
70
+ export function agentFileName(root: string, slug: string): string | null {
71
+ try {
72
+ const name = (JSON.parse(readFileSync(join(root, 'agents', slug, 'agent.json'), 'utf8')) as { config?: { name?: unknown } }).config?.name
73
+ return typeof name === 'string' && name.trim() ? name.trim() : null
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+
65
79
  function sameToken(a: string, b: string): boolean {
66
80
  const ab = Buffer.from(a)
67
81
  const bb = Buffer.from(b)
@@ -95,7 +109,7 @@ export function startStudioServer(opts: StudioServerOptions) {
95
109
  const json = (data: unknown, status = 200) =>
96
110
  new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json; charset=utf-8' } })
97
111
 
98
- const localRoute = (url: URL): Response => {
112
+ const localRoute = (url: URL): Response | Promise<Response> => {
99
113
  if (url.pathname === '/__local/state') {
100
114
  const state = readState(project)
101
115
  if (!state) return json({ error: 'no local runtime state' }, 404)
@@ -125,6 +139,36 @@ export function startStudioServer(opts: StudioServerOptions) {
125
139
  if (!/^[a-z-]+\/[A-Za-z0-9][\w./-]{0,200}$/.test(ref)) return json({ error: 'name an item, as agent/<slug>' }, 400)
126
140
  return json({ build: itemBuild(project.root, ref, project.id) })
127
141
  }
142
+ // The build in a few numbers, counted as `local status` counts it (the runtime's Action deployments and
143
+ // secrets read the same way): Studio's footer says its failures, the number `local status` prints.
144
+ if (url.pathname === '/__local/build-summary') {
145
+ return currentBuildSummary(project.root, project.id, readState(project)).then((summary) =>
146
+ json({ summary: summary ? { problems: summary.problems, counts: summary.counts, headline: summary.headline } : null }),
147
+ )
148
+ }
149
+ // The build's rows of one kind (`?kind=function`), each with its state, reason and fix, as `local status` lists
150
+ // them: Studio's lists mark the ones that didn't apply or need setup, with the same words.
151
+ if (url.pathname === '/__local/builds') {
152
+ const kind = url.searchParams.get('kind') ?? ''
153
+ if (!/^[a-zA-Z-]{1,40}$/.test(kind)) return json({ error: 'name a kind, as function' }, 400)
154
+ return currentBuildRows(project.root, project.id, readState(project)).then((rows) =>
155
+ json({
156
+ rows: (rows ?? [])
157
+ .filter((r) => r.ref.startsWith(`${kind}/`))
158
+ .map((r) => ({ ref: r.ref, state: r.state, label: r.label, reason: r.reason, fix: r.fix ?? null, ...(r.warnings?.length ? { warnings: r.warnings } : {}) })),
159
+ }),
160
+ )
161
+ }
162
+ // Where each Custom App's code is (its folder, not pulled, or none yet), with its one status as `local status` says
163
+ // it (deployed to this runtime, built, not built, no code): Studio's Apps page says it, and how to work on it.
164
+ if (url.pathname === '/__local/apps') {
165
+ return json({
166
+ apps: appCodeStates(project.root).map((code) => {
167
+ const row = appStatusRow(project.root, code)
168
+ return { ...code, status: row.appStatus, label: row.label, fix: row.fix ?? null }
169
+ }),
170
+ })
171
+ }
128
172
  // The Blueprint's definitions as the files state them, so they show before anything is applied.
129
173
  if (url.pathname === '/__local/blueprint') {
130
174
  return json({ objectTypes: projectBlueprintOutline(project.root) })
@@ -132,11 +176,17 @@ export function startStudioServer(opts: StudioServerOptions) {
132
176
  // The solution's saved cases (`agents/<slug>/evals/*.yaml`), read fresh each time.
133
177
  if (url.pathname === '/__local/test-cases') {
134
178
  try {
135
- return json({ files: findCaseFiles(project.root) })
179
+ // Each Agent by the name its agent.json gives it, as the home and Agents say it, not its folder.
180
+ return json({ files: findCaseFiles(project.root).map((f) => ({ ...f, agentName: agentFileName(project.root, f.agent) })) })
136
181
  } catch (err) {
137
182
  return json({ error: `a case file could not be read: ${(err as Error).message}` }, 422)
138
183
  }
139
184
  }
185
+ // The workflows' test files (`workflows/tests/*.yaml`): `frontera local test` runs them; Evaluations lists them.
186
+ if (url.pathname === '/__local/workflow-tests') {
187
+ // Each case by name, with its last run (`frontera local test` keeps it), so Evaluations lists them.
188
+ return json({ files: workflowTestListing(project.root, project.stateDir) })
189
+ }
140
190
  if (url.pathname === '/__local/test-runs') return json({ runs: testRuns.list() })
141
191
  const runMatch = /^\/__local\/test-runs\/([0-9a-f-]+)$/.exec(url.pathname)
142
192
  if (runMatch) {
@@ -171,10 +221,122 @@ export function startStudioServer(opts: StudioServerOptions) {
171
221
  const agent = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}`)) as { agentId?: string }
172
222
  const messages = (await get(`/v1/sessions/${encodeURIComponent(body.conversationId)}/messages`)) as unknown
173
223
  const list = Array.isArray(messages) ? messages : ((messages as { messages?: unknown[] })?.messages ?? [])
174
- const yaml = renderTestCase({ conversationId: body.conversationId, messages: list as never[], savedAt: new Date().toISOString() })
224
+ // The variables the conversation started with, when its Test setup recorded them: the case replays as the same customer.
225
+ const session = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}/try/conversations/${encodeURIComponent(body.conversationId)}/session`).catch(() => null)) as { timeline?: Array<{ payload?: Record<string, unknown> | null }> } | null
226
+ const variables = variablesOfTimeline(session?.timeline ?? [])
227
+ // A sensitive variable is named in the setup, never its value: the case says to fill it in by hand.
228
+ const redacted = redactedVariablesOfTimeline(session?.timeline ?? [])
229
+ // The tool approvals it stopped on, as the person decided them: the case answers them the same way.
230
+ const cards = (await get(`/v1/chat/approvals?conversationId=${encodeURIComponent(body.conversationId)}`).catch(() => [])) as unknown
231
+ const { decision, mixed } = decisionOfApprovals(Array.isArray(cards) ? cards : [])
232
+ const yaml = noteRedactedVariables(renderTestCase({ conversationId: body.conversationId, messages: list as never[], savedAt: new Date().toISOString(), ...(variables ? { variables } : {}), ...(decision ? { decision } : {}) }), redacted)
175
233
  const path = appendTestCase(project.root, agent.agentId ?? body.agentId, yaml)
176
- return json({ path })
234
+ const notes = [...(redacted.length ? [redactedVariablesLine(redacted, path)] : []), ...(mixed ? [MIXED_DECISIONS_LINE] : [])]
235
+ return json({ path, ...(decision ? { decision } : {}), ...(redacted.length ? { redactedVariables: redacted } : {}), ...(notes.length ? { note: notes.join(' ') } : {}) })
236
+ } catch (err) {
237
+ return json({ error: (err as Error).message }, 502)
238
+ }
239
+ }
240
+
241
+ /** The local service as the seeded owner, for the routes that act on the runtime themselves; null before it is seeded. */
242
+ const serviceCall = (): ServiceCall | null => {
243
+ const creds = readState(project)?.credentials
244
+ const bearer = creds?.session ?? creds?.workspaceKey
245
+ if (!creds || !bearer) return null
246
+ return async (method, path) => {
247
+ const res = await fetch(new URL(path, opts.serviceUrl), { method, headers: { authorization: `Bearer ${bearer}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId } })
248
+ const text = await res.text()
249
+ if (!res.ok) throw new Error(`the runtime answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`)
250
+ return text ? (JSON.parse(text) as unknown) : null
251
+ }
252
+ }
253
+
254
+ /** Whether a `local dev` watcher is applying this project's saved files (Forge keeps one running). */
255
+ const watching = (): boolean => {
256
+ try {
257
+ return isAlive(Number(readFileSync(join(project.stateDir, 'dev.pid'), 'utf8').trim()))
258
+ } catch {
259
+ return false
260
+ }
261
+ }
262
+
263
+ /**
264
+ * A knowledge base's documents as the project's files (`local-knowledge-uploads.ts`):
265
+ * GET /__local/knowledge?base=<name>&id=<knowledge id> its folder, its documents and whether the runtime has each, its apply state
266
+ * POST /__local/knowledge/documents?base=&id=[&replace=1] save one document (multipart `file`) into its folder
267
+ * DELETE /__local/knowledge/documents?base=&id=&sourceId= take a document off the runtime and out of the folder
268
+ */
269
+ const knowledgeRoute = async (req: Request, url: URL): Promise<Response> => {
270
+ const base = (url.searchParams.get('base') ?? '').trim()
271
+ const id = url.searchParams.get('id') ?? ''
272
+ if (!base || base.length > 200) return json({ error: 'name the knowledge base, as base=<name>' }, 400)
273
+ if (id && !/^[\w-]{1,80}$/.test(id)) return json({ error: 'id is not a knowledge base id' }, 400)
274
+ const folder = knowledgeFolderOf(project.root, base)
275
+ try {
276
+ if (url.pathname === '/__local/knowledge' && req.method === 'GET') {
277
+ const accepted = { extensions: KNOWLEDGE_UPLOAD_EXTENSIONS, maxBytes: MAX_KNOWLEDGE_UPLOAD_BYTES }
278
+ if (!folder) return json({ folder: null, documents: [], apply: null, watching: watching(), accepted })
279
+ const call = id ? serviceCall() : null
280
+ const onRuntime = call ? await runtimeDocuments(call, id).then((docs) => new Set(docs.map((d) => d.path)), () => null) : null
281
+ const status = solutionStatus(project, readState(project)?.credentials?.organizationId ?? null, (t) => t.kind === 'knowledge' && t.name === folder)[0]
282
+ return json({
283
+ folder: `knowledge/${folder}/`,
284
+ documents: projectDocuments(project.root, folder, onRuntime),
285
+ apply: status ? { state: status.state, ...(status.detail ? { detail: status.detail } : {}), ...(status.fix ? { fix: status.fix } : {}), ...(status.waiting ? { waiting: true } : {}) } : null,
286
+ watching: watching(),
287
+ accepted,
288
+ })
289
+ }
290
+ if (url.pathname !== '/__local/knowledge/documents') return json({ error: 'not found' }, 404)
291
+ if (req.method === 'POST') {
292
+ if (!folder) return json({ error: `${base} has no folder in the project (knowledge/<name>/knowledge.json), so a document can't be saved to it here` }, 404)
293
+ // Refused before the body is read: the form's boundary and fields add little beyond the file.
294
+ if (Number(req.headers.get('content-length') ?? 0) > MAX_KNOWLEDGE_UPLOAD_BYTES + 64 * 1024) {
295
+ return json({ error: `the document is larger than ${MAX_KNOWLEDGE_UPLOAD_BYTES / 1024 / 1024} MB, the most a knowledge base takes` }, 413)
296
+ }
297
+ const form = await req.formData().catch(() => null)
298
+ const file = form?.get('file')
299
+ if (!(file instanceof Blob)) return json({ error: 'send the document as the multipart field "file"' }, 400)
300
+ const name = checkUpload((file as File).name ?? '', file.size)
301
+ const replace = url.searchParams.get('replace') === '1'
302
+ // Replacing: the runtime's copy goes first, so the apply uploads the new content (it only adds what the runtime lacks).
303
+ if (replace && id && documentInFolder(project.root, folder, name)) {
304
+ const call = serviceCall()
305
+ if (call) {
306
+ try {
307
+ await removeRuntimeCopies(call, id, name)
308
+ } catch (err) {
309
+ return json({ error: `the runtime's copy of ${name} couldn't be taken off (${(err as Error).message}), so knowledge/${folder}/${name} is left as it was` }, 502)
310
+ }
311
+ }
312
+ }
313
+ const saved = saveKnowledgeDocument(project.root, folder, name, new Uint8Array(await file.arrayBuffer()), { replace })
314
+ return json({ ...saved, watching: watching() })
315
+ }
316
+ if (req.method === 'DELETE') {
317
+ const sourceId = url.searchParams.get('sourceId') ?? ''
318
+ if (!id || !/^[\w-]{1,80}$/.test(sourceId)) return json({ error: 'name the document, as id=<knowledge id>&sourceId=<document id>' }, 400)
319
+ const call = serviceCall()
320
+ if (!call) return json({ error: 'the runtime is not seeded yet' }, 409)
321
+ const source = (await runtimeDocuments(call, id)).find((d) => d.id === sourceId)
322
+ if (!source) return json({ error: 'the runtime has no such document in this knowledge base' }, 404)
323
+ await call('DELETE', `/v1/workspace-knowledge/${encodeURIComponent(id)}/sources/${encodeURIComponent(sourceId)}`)
324
+ // Its file too, so the next apply does not upload it again; a base with no folder had only the runtime's copy.
325
+ // A runtime document of a kind or name the folder can't hold (a spreadsheet uploaded on the runtime) has no file to remove.
326
+ const removed = (() => {
327
+ if (!folder) return { path: null, removed: false }
328
+ try {
329
+ return removeKnowledgeDocument(project.root, folder, source.path)
330
+ } catch (err) {
331
+ if (err instanceof KnowledgeDocumentError && err.status !== 409) return { path: null, removed: false }
332
+ throw err
333
+ }
334
+ })()
335
+ return json({ path: removed.path, removedFile: removed.removed })
336
+ }
337
+ return json({ error: 'not found' }, 404)
177
338
  } catch (err) {
339
+ if (err instanceof KnowledgeDocumentError) return json({ error: err.message, ...err.extra }, err.status)
178
340
  return json({ error: (err as Error).message }, 502)
179
341
  }
180
342
  }
@@ -316,6 +478,7 @@ export function startStudioServer(opts: StudioServerOptions) {
316
478
  const started = testRuns.start({ agent: typeof body.agent === 'string' ? body.agent : undefined, live: body.live === true })
317
479
  return 'run' in started ? json(started.run, 202) : json({ error: started.error }, started.status)
318
480
  }
481
+ if (url.pathname === '/__local/knowledge' || url.pathname === '/__local/knowledge/documents') return knowledgeRoute(req, url)
319
482
  if (url.pathname.startsWith('/__local/')) return localRoute(url)
320
483
  // `/api/…` is the service; `/api` alone is Studio's API page.
321
484
  if (url.pathname.startsWith('/api/')) return proxy(req, url)