@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
@@ -1,16 +1,13 @@
1
- import { docsCopyPath, serveDocsMcp } from '../../local/local-docs'
2
1
  import { localProject } from '../../local/local-project'
3
- import { cliSchema, readKeptSchema } from '../../local/local-schemas'
2
+ import { serveMcp } from '../../mcp/mcp-protocol'
3
+ import { MCP_INFO, mcpTools } from '../../mcp/mcp-server'
4
4
  import type { Command } from '../types'
5
5
 
6
6
  /**
7
- * Frontera's documentation as an MCP server on stdio, for the coding agents
8
- * working in this project. `local init` registers it in `.mcp.json` (Claude
9
- * Code) and `.cursor/mcp.json`; Codex takes the same command. It reads the
10
- * documentation `local start` copied for the release the runtime runs, and
11
- * the formats of the project's files that start kept, so it needs no key and
12
- * keeps working while the runtime is stopped. Nothing but protocol goes to
13
- * stdout.
7
+ * The earlier name of `frontera mcp`, kept for one release so projects
8
+ * registered with `frontera-docs` keep their documentation tools. It serves
9
+ * the same server; Forge's Set up agents and `local init` now register
10
+ * `frontera mcp` instead.
14
11
  */
15
12
  export const localDocsMcp: Command = {
16
13
  meta: {
@@ -18,19 +15,14 @@ export const localDocsMcp: Command = {
18
15
  verb: 'docs-mcp',
19
16
  args: [],
20
17
  flags: {},
21
- summary: "Serve Frontera's documentation to coding agents over MCP (stdio), for the local runtime's release",
22
- examples: ['frontera local docs-mcp'],
18
+ summary: 'Deprecated: the same server as `frontera mcp`',
19
+ examples: ['frontera mcp'],
23
20
  offline: true,
24
21
  internal: true,
25
22
  },
26
23
  async run(ctx) {
27
- const project = localProject(ctx.cwd)
28
- // Not started yet: it still speaks MCP, and each tool call says what to do.
29
- await serveDocsMcp({
30
- docsPath: docsCopyPath(project),
31
- schemaFor: (kind) => cliSchema(kind) ?? readKeptSchema(project, kind),
32
- })
33
- // The stream is the protocol: exit here, before the CLI renders a result onto it.
24
+ process.stderr.write('frontera local docs-mcp is deprecated: register `frontera mcp` instead (Forge: Set up agents).\n')
25
+ await serveMcp({ info: MCP_INFO, tools: mcpTools(localProject(ctx.cwd)) })
34
26
  process.exit(0)
35
27
  },
36
28
  }
@@ -3,12 +3,23 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'
3
3
  import { join, resolve } from 'node:path'
4
4
 
5
5
  import { CliError, UsageError } from '../../errors'
6
+ import { EXIT } from '../../exit'
6
7
  import { writeFileAtomic } from '../../fs-atomic'
7
8
  import { solutionStatus } from '../../local/local-applied'
9
+ import type { LiveAction } from '../../local/local-actions'
10
+ import type { LiveApp } from '../../local/local-app-runtime'
11
+ import { runtimeBuildInputs } from '../../local/local-build-inputs'
12
+ import { listPersonas, type LocalPersona } from '../../local/local-personas'
13
+ import type { SecretPresence } from '../../local/local-secrets'
14
+ import { findObjectSet, normalizeObjectSets, projectObjectSetNames } from '../../blueprint/object-sets'
15
+ import { actionsStatusText, appsStatusText, buildStatusText, emptyKnowledgeLines, itemKindsText, notPulledLines, objectSetsStatusText, projectBuildRows, projectBuildSummary, projectFilesLines } from '../../local/local-build-summary'
16
+ import { inheritedActionWarnings } from '../../local/local-check-inherited'
17
+ import { checkoutVersion, docsStatus, versionAtStart } from '../../local/local-docs'
8
18
  import { dockerAvailable } from '../../local/local-docker'
19
+ import { downPhase, downToRestart, liveComponents, type DownComponent } from '../../local/local-component-health'
9
20
  import { readConfig, writeConfig, type LocalConfig } from '../../local/local-env'
10
21
  import { configuredProviders } from '../../local/local-keys'
11
- import { claimPorts, isPortFree, portHolder, releasePorts } from '../../local/local-ports'
22
+ import { claimPortBlock, claimPorts, isPortFree, portHolder, reclaimedNote, releasePorts } from '../../local/local-ports'
12
23
  import { localProject, type LocalProject } from '../../local/local-project'
13
24
  import {
14
25
  destroyRuntimeData,
@@ -22,21 +33,35 @@ import {
22
33
  } from '../../local/local-control'
23
34
  import { clearState, ensureStateDir, isAlive, logPath, readState, type ComponentName, type LocalState } from '../../local/local-state'
24
35
  import { newSessionToken } from '../../local/local-studio-server'
36
+ import { migrateMcpConfigs } from '../../local/local-scaffold'
25
37
  import { DEFAULT_REGISTRY, runSupervisor } from '../../local/local-supervisor'
26
- import { findCaseFiles, runCases } from '../../local/local-test-runner'
38
+ import { compareWithLastRun, readLastRun, recordRun } from '../../local/local-test-baseline'
39
+ import { agentFilesProblem, caseConcurrency, caseCount, runCases, selectCaseFiles, waitedWords, type CaseFile, type CaseResult } from '../../local/local-test-runner'
40
+ import { devServerLine, listDevServers, stopDevServers } from '../../mcp/mcp-apps'
41
+ import { caseTraces, evidenceLine, failingCaseEvidence } from '../../local/local-case-evidence'
42
+ import { notRunGroups, notRunWords } from '../../local/local-case-not-run'
43
+ import { findWorkflowTestFiles, recordWorkflowTestRun, runWorkflowTests, workflowFileState, type WorkflowTestFile, type WorkflowTestResult } from '../../local/local-workflow-tests'
27
44
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
28
45
  import { localCheck } from './check'
46
+ import { projectNotices } from '../../local/local-project-notices'
47
+ import { readBaseline } from '../../local/local-db-snapshot'
48
+ import { stuckKnowledge } from '../../local/local-knowledge-ingestion'
49
+ import { orphanLine, projectOrphans } from '../../local/local-orphans'
29
50
  import { localData } from './data'
51
+ import { localSdkRefresh } from './sdk-refresh'
30
52
  import { localDev } from './dev'
31
53
  import { localDocsMcp } from './docs-mcp'
32
54
  import { localInit } from './init'
33
55
  import { localKeyList, localKeyRemove, localKeySet } from './key'
34
56
  import { localModelAdd, localModelDefault, localModelRemove, localModelsAvailable, localProviderAdd, localProviderRemove, localProviders } from './providers'
57
+ import { localRemove } from './remove'
35
58
  import { localSchema } from './schema'
36
59
  import { localSync } from './sync'
60
+ import { keylessEmbeddingsStatus, KEYLESS_RETRIEVAL_NOTE } from '../../local/local-org-settings'
61
+ import { withFix } from '../../local/fix-words'
37
62
 
38
63
  /**
39
- * `frontera local …`: Frontera's runtimes on this machine, for internal FDEs.
64
+ * `frontera local …`: Frontera's runtimes on this machine, for FDEs and the coding agents working with them.
40
65
  *
41
66
  * Modelled on `supabase start`: one command brings up only the runtimes a
42
67
  * solution needs (no product UI, no kernel), seeds an owner, organization,
@@ -49,7 +74,7 @@ const OPTIONAL: ComponentName[] = ['jobs', 'runner', 'storage']
49
74
 
50
75
  const sessionFile = (project: LocalProject) => join(project.stateDir, 'studio-session')
51
76
 
52
- function readSession(project: LocalProject): string | null {
77
+ export function readSession(project: LocalProject): string | null {
53
78
  const path = sessionFile(project)
54
79
  return existsSync(path) ? readFileSync(path, 'utf8').trim() : null
55
80
  }
@@ -68,13 +93,15 @@ function studioUrl(state: LocalState, project: LocalProject): string {
68
93
  return `http://127.0.0.1:${state.ports.studio}/${token ? `?token=${token}` : ''}`
69
94
  }
70
95
 
71
- const DOT: Record<string, string> = { running: '●', starting: '◐', off: '○', stopped: '○', failed: '✕' }
96
+ const DOT: Record<string, string> = { running: '●', starting: '◐', off: '○', stopped: '○', failed: '✕', down: '✕' }
72
97
 
73
- function renderStatus(state: LocalState, project: LocalProject, profile?: { profile: string; bound: boolean }): string {
98
+ function renderStatus(state: LocalState, project: LocalProject, profile?: { profile: string; bound: boolean }, live: ReadonlyMap<string, LiveAction> | null = null, setIds: Record<string, string> | null = null, personas: LocalPersona[] | null = null, secrets: SecretPresence | null = null, liveApps: ReadonlyMap<string, LiveApp> | null = null, down: DownComponent[] = []): string {
74
99
  const lines: string[] = []
75
- const phase = state.supervisorPid && !isAlive(state.supervisorPid) && state.phase !== 'stopped' ? 'stopped (supervisor exited)' : state.phase
100
+ // What is down now (`liveComponents`), not what the state file said at start: a container can exit under the supervisor.
101
+ const phase = state.supervisorPid && !isAlive(state.supervisorPid) && state.phase !== 'stopped' ? 'stopped (supervisor exited)' : down.length ? downPhase(down) : state.phase
76
102
  lines.push(`Frontera local runtime · ${project.root.split('/').pop()} · ${phase}`)
77
103
  if (state.error) lines.push(` ${state.error}`)
104
+ if (down.length) lines.push(' Reads and writes fail until it is back: `frontera local start` restarts what is down and keeps the data')
78
105
  lines.push('')
79
106
  if (state.phase === 'ready') {
80
107
  lines.push(` Studio ${studioUrl(state, project)}`)
@@ -88,8 +115,11 @@ function renderStatus(state: LocalState, project: LocalProject, profile?: { prof
88
115
  }
89
116
  for (const c of state.components) {
90
117
  const where = c.kind === 'container' ? 'container' : c.kind === 'process' ? 'process' : 'supervisor'
91
- lines.push(` ${DOT[c.status] ?? '·'} ${c.name.padEnd(9)} ${c.status.padEnd(9)} ${c.status === 'off' ? (c.detail ?? '') : where}${c.status === 'failed' && c.detail ? ` — ${c.detail}` : ''}`)
118
+ const status = down.some((d) => d.name === c.name) ? 'down' : c.status
119
+ lines.push(` ${DOT[status] ?? '·'} ${c.name.padEnd(9)} ${status.padEnd(9)} ${c.status === 'off' ? (c.detail ?? '') : where}${c.status === 'failed' && c.detail ? ` — ${c.detail}` : ''}`)
92
120
  }
121
+ const cache = down.find((d) => d.name === 'cache')
122
+ if (cache) lines.push(` ✕ ${'cache'.padEnd(9)} ${'down'.padEnd(9)} container — ${cache.why}; \`frontera local start\` brings it back`)
93
123
  if (state.credentials) {
94
124
  lines.push('')
95
125
  lines.push(` Signed in as ${state.credentials.email}`)
@@ -99,21 +129,64 @@ function renderStatus(state: LocalState, project: LocalProject, profile?: { prof
99
129
  ? ` CLI profile ${name} — this directory is bound to another profile; pass \`--profile ${name}\``
100
130
  : ` CLI profile ${name} (this directory uses it)`,
101
131
  )
102
- lines.push(` Blueprint pass \`--profile ${name}-org\` (Blueprint commands need an organization key)`)
132
+ lines.push(
133
+ profile?.bound === false
134
+ ? ` Org profile ${name}-org — pass \`--profile ${name}-org\` to Blueprint and Action commands`
135
+ : ` Org profile ${name}-org (Blueprint and Action commands here use it)`,
136
+ )
103
137
  const files = solutionStatus(project, state.credentials.organizationId)
104
- if (files.length) {
105
- const behind = files.filter((f) => f.state !== 'applied')
106
- lines.push(
107
- behind.length
108
- ? ` Project ${behind.length} of ${files.length} files not on the runtime (${behind.map((f) => f.name).join(', ')}) — run \`frontera local dev\``
109
- : ` Project all ${files.length} files applied`,
110
- )
138
+ if (files.length) lines.push(...projectFilesLines(files))
139
+ // Counted over the same rows it lists: the problems by name, under the line.
140
+ const build = projectBuildSummary(project.root, project.id, live, secrets, liveApps)
141
+ if (build) {
142
+ lines.push(` Build ${buildStatusText(build)}`)
143
+ // Each setting that differs from the workspace: what it changes when testing here, and whether it can be aligned.
144
+ for (const d of build.settingDetails) lines.push(` Setting ${d.line}${d.effect ? `: ${d.effect}` : ''}${d.align ? ` (${d.align.charAt(0).toLowerCase()}${d.align.slice(1)})` : ''}`)
145
+ const rows = projectBuildRows(project.root, project.id, live, setIds, secrets, liveApps) ?? []
146
+ for (const row of rows.filter((r) => r.state === 'failed' || r.state === 'blocked').slice(0, 12)) {
147
+ lines.push(` ✕ ${row.ref} ${row.state}${row.reason ? `: ${row.reason}` : ''}`)
148
+ if (row.fix) lines.push(` fix: ${row.fix}`)
149
+ }
150
+ // Applied, but naming a secret this runtime has no value for: it fails when used.
151
+ for (const row of rows.filter((r) => r.state === 'needs-setup').slice(0, 12)) lines.push(` ! ${row.ref} needs setup${row.reason ? `: ${row.reason}` : ''}`)
152
+ // Each kind the build counts, so the line above adds up: `--json` lists every row.
153
+ lines.push(` Items ${itemKindsText(rows)}`)
154
+ lines.push(` Actions ${actionsStatusText(rows)}`)
155
+ const sets = objectSetsStatusText(rows)
156
+ if (sets) lines.push(` Object sets ${sets}`)
157
+ if (personas?.length) lines.push(` Personas ${personas.map((p) => `${p.key} (${p.displayName})`).join(', ')}: act as one with \`--as <key>\` on action available, submit, approve and reject`)
158
+ const apps = appsStatusText(rows)
159
+ if (apps) lines.push(` Apps ${apps}`)
160
+ // A knowledge base an Agent finds nothing in, and what the workspace has that was never pulled: each with its command.
161
+ for (const k of emptyKnowledgeLines(rows)) lines.push(` Knowledge ${k}`)
162
+ for (const n of notPulledLines(project.root)) lines.push(` Not pulled ${n}`)
163
+ // What the pull couldn't bring and why, App code on the platform, older sample data or SDK: each with its command.
164
+ const notices = projectNotices(project.root)
165
+ for (const n of [...notices.pull, ...(notices.agentNotes ?? [])]) lines.push(` Pull note ${n}`)
166
+ for (const n of notices.apps) lines.push(` App code ${n}`)
167
+ if (notices.sampleData) lines.push(` Sample data ${notices.sampleData}`)
168
+ if (notices.sdk) lines.push(` SDK ${notices.sdk}`)
169
+ for (const n of notices.agents) lines.push(` Agent tools ${n}`)
170
+ // An Agent whose bound Functions or workflows failed to apply: it runs without them here.
171
+ for (const row of rows) for (const w of row.warnings ?? []) if (/^agent\/[\w-]+: missing tools:/.test(w)) lines.push(` Agent tools ${w}`)
172
+ // A governed Action deployed as the workspace runs it, with what its owner fixes there.
173
+ for (const w of inheritedActionWarnings(rows)) lines.push(` Action ${w}`)
174
+ for (const n of notices.workflows) lines.push(` Workflow ${n}`)
111
175
  }
112
176
  }
177
+ const docs = docsStatus(project, { mode: state.mode, platformVersion: state.platformVersion, platformSource: readConfig(project)?.platformSource })
178
+ lines.push(` Docs ${docs.present ? `${docs.version ?? 'unknown version'}${docs.builtAt ? `, built ${docs.builtAt.slice(0, 10)}` : ''}${docs.note ? ` — ${docs.note}` : ''}` : docs.note}`)
113
179
  const providers = configuredProviders()
114
180
  lines.push(` Model keys ${providers.length ? providers.join(', ') : 'none — run `frontera local key-set <provider>`'}`)
115
181
  if (state.credentials?.modelProviders?.includes('local-embeddings')) {
116
- lines.push(' Embeddings local: words hashed into vectors, no key (an OpenAI or OpenRouter key replaces it)')
182
+ // With an OpenRouter key stored, the local embeddings mean the provider refused it for embeddings at start.
183
+ lines.push(
184
+ providers.includes('openrouter')
185
+ ? ' Embeddings local: words hashed into vectors, because OpenRouter refused this computer’s key for its embedding model at start (`frontera local logs service` shows its answer after `[embedding] preflight failed`); `frontera local key-set openrouter` with a key it takes replaces them'
186
+ : ' Embeddings local: words hashed into vectors, no key (an OpenAI or OpenRouter key replaces it)',
187
+ )
188
+ // What that does to knowledge search, and what gives the platform's: said where an agent reads the runtime first.
189
+ lines.push(` Knowledge ${KEYLESS_RETRIEVAL_NOTE.charAt(0).toUpperCase()}${KEYLESS_RETRIEVAL_NOTE.slice(1)}`)
117
190
  }
118
191
  return lines.join('\n')
119
192
  }
@@ -141,11 +214,26 @@ function parseList(raw: string | undefined): ComponentName[] {
141
214
 
142
215
  async function start(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
143
216
  const project = localProject(ctx.cwd)
217
+ // Coding-agent configs from before `frontera mcp` launched the documentation server alone.
218
+ for (const file of migrateMcpConfigs(project.root)) ctx.output.note(` ${file}: now launches \`frontera mcp\` (was \`frontera local docs-mcp\`)`)
144
219
  const existing = readState(project)
145
- if (existing?.supervisorPid && isAlive(existing.supervisorPid) && existing.phase !== 'stopped') {
220
+ // Up, or partly up: what is down (a container that exited under it, a process that crashed) is brought back.
221
+ const restart = existing ? await downToRestart(existing) : null
222
+ if (existing && !restart && existing.supervisorPid && isAlive(existing.supervisorPid) && existing.phase !== 'stopped') {
146
223
  const profile = existing.credentials ? await registerLocalProfile(project, existing) : undefined
147
224
  return { data: existing, text: `Already running.\n\n${renderStatus(existing, project, profile)}` }
148
225
  }
226
+ if (restart?.length) {
227
+ ctx.output.note(` ${restart.map((d) => `${d.name} is down (${d.why})`).join('; ')}: restarting the runtime; its data is kept`)
228
+ await ctx.output.task('Stopping what is left of it', () => stopRuntime(project))
229
+ } else if (existing?.supervisorPid && !isAlive(existing.supervisorPid) && existing.phase !== 'stopped') {
230
+ // The supervisor went away and left its containers up: they are stopped (not removed), and started again below.
231
+ try {
232
+ await stopRuntime(project)
233
+ } catch {
234
+ // No container runtime: the check below says so.
235
+ }
236
+ }
149
237
 
150
238
  const docker = dockerAvailable()
151
239
  if (!docker.ok) {
@@ -156,24 +244,28 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
156
244
  }
157
245
 
158
246
  const sourceFlag = flagString(ctx, 'source')
159
- const useImage = flagBool(ctx, 'image')
160
- const source = sourceFlag ? resolve(ctx.cwd, sourceFlag) : useImage ? null : (process.env.FRONTERA_PLATFORM_SOURCE ?? detectPlatformSource())
247
+ const previous = readConfig(project)
248
+ // A restart of what went down, with no flags, runs as it ran: the same release image or checkout.
249
+ const keep = !!restart?.length && !sourceFlag && !flagBool(ctx, 'image') && !!previous
250
+ const useImage = flagBool(ctx, 'image') || (keep && previous!.mode === 'image')
251
+ const source = sourceFlag ? resolve(ctx.cwd, sourceFlag) : useImage ? null : ((keep ? previous!.platformSource : undefined) ?? process.env.FRONTERA_PLATFORM_SOURCE ?? detectPlatformSource())
161
252
  if (sourceFlag && !isPlatformSource(source!)) {
162
253
  throw new UsageError(`${source} is not a Frontera platform checkout`, 'point --source at the repository root that holds packages/service')
163
254
  }
164
- const exclude = parseList(flagString(ctx, 'exclude'))
165
- const previous = readConfig(project)
255
+ const exclude = keep && !flagString(ctx, 'exclude') ? previous!.exclude : parseList(flagString(ctx, 'exclude'))
166
256
  const config: LocalConfig = {
167
257
  mode: source ? 'source' : 'image',
168
258
  platformSource: source ?? undefined,
169
- platformVersion: flagString(ctx, 'version') ?? previous?.platformVersion ?? cliVersion(),
259
+ platformVersion: versionAtStart({ checkout: source ? checkoutVersion(source) : null, flag: flagString(ctx, 'version'), previous, cli: cliVersion() }),
170
260
  registry: normalizeRegistry(flagString(ctx, 'registry') ?? process.env.FRONTERA_LOCAL_REGISTRY ?? previous?.registry),
171
261
  exclude,
172
262
  }
173
263
  ensureStateDir(project)
174
264
  writeConfig(project, config)
175
265
 
176
- const ports = claimPorts(project.id, project.root)
266
+ const { ports, reclaimed } = claimPortBlock(project.id, project.root)
267
+ const freed = reclaimedNote(reclaimed)
268
+ if (freed) ctx.output.note(freed)
177
269
  for (const name of [...COMPONENTS, 'cache', 'embeddings', 'traces'] as const) {
178
270
  if (exclude.includes(name as ComponentName)) continue
179
271
  if (!(await isPortFree(ports[name]))) {
@@ -223,7 +315,6 @@ const startMeta = {
223
315
  summary: 'Start Frontera\'s runtimes on this machine and seed an owner, workspace and keys',
224
316
  examples: ['frontera local start', 'frontera local start --exclude runner', 'frontera local start --image --version 1.52.19', 'frontera local start --image --registry registry.example.com/frontera'],
225
317
  offline: true,
226
- internal: true,
227
318
  } as const
228
319
 
229
320
  export const localStart: Command = { meta: { noun: 'local', verb: 'start', ...startMeta }, run: start }
@@ -231,16 +322,20 @@ export const localStart: Command = { meta: { noun: 'local', verb: 'start', ...st
231
322
  async function stop(ctx: CommandContext) {
232
323
  const project = localProject(ctx.cwd)
233
324
  const state = readState(project)
234
- if (!state) return { data: { stopped: false }, text: 'No local runtime here.' }
325
+ // The project's App dev servers point at this runtime: they stop with it (only this project's records are read).
326
+ const apps = stopDevServers(project)
327
+ const appsLine = apps.length ? ` Stopped ${apps.length} App dev server${apps.length === 1 ? '' : 's'}: ${apps.map((a) => `${a.slug} (port ${a.port})`).join(', ')}.` : ''
328
+ const appsField = apps.length ? { appDevServersStopped: apps.map((a) => ({ app: a.slug, port: a.port, pid: a.pid })) } : {}
329
+ if (!state) return { data: { stopped: false, ...appsField }, text: `No local runtime here.${appsLine}` }
235
330
  await ctx.output.task('Stopping the local runtime', () => stopRuntime(project))
236
331
  if (flagBool(ctx, 'no-backup')) {
237
332
  destroyRuntimeData(project)
238
333
  clearState(project)
239
334
  rmSync(join(project.stateDir, 'secrets.json'), { force: true })
240
335
  releasePorts(project.id)
241
- return { data: { stopped: true, dataRemoved: true }, text: 'Stopped, and removed the database and local secrets.' }
336
+ return { data: { stopped: true, dataRemoved: true, ...appsField }, text: `Stopped, and removed the database and local secrets.${appsLine}` }
242
337
  }
243
- return { data: { stopped: true }, text: 'Stopped. Data is kept; `frontera local start` brings it back.' }
338
+ return { data: { stopped: true, ...appsField }, text: `Stopped. Data is kept; \`frontera local start\` brings it back.${appsLine}` }
244
339
  }
245
340
 
246
341
  export const localStop: Command = {
@@ -249,14 +344,36 @@ export const localStop: Command = {
249
344
  verb: 'stop',
250
345
  args: [],
251
346
  flags: { 'no-backup': 'boolean' },
252
- summary: 'Stop the local runtime (keeps its data unless --no-backup)',
347
+ summary: 'Stop the local runtime and the project\'s App dev servers (keeps its data unless --no-backup)',
253
348
  examples: ['frontera local stop', 'frontera local stop --no-backup'],
254
349
  offline: true,
255
- internal: true,
256
350
  },
257
351
  run: stop,
258
352
  }
259
353
 
354
+ /**
355
+ * Each project object set's id on the runtime, by its file's reference, read
356
+ * where `frontera blueprint object-sets` reads them; null when the runtime is
357
+ * stopped or does not answer (the last apply's record is shown instead).
358
+ */
359
+ async function runtimeObjectSetIds(state: LocalState, project: LocalProject): Promise<Record<string, string> | null> {
360
+ const files = projectObjectSetNames(project.root)
361
+ if (!files.size || state.phase !== 'ready' || !isAlive(state.supervisorPid) || !state.credentials?.workspaceKey) return null
362
+ try {
363
+ const res = await fetch(`http://127.0.0.1:${state.ports.service}/v1/sync/object-sets`, { headers: { authorization: `Bearer ${state.credentials.workspaceKey}` } })
364
+ if (!res.ok) return null
365
+ const sets = normalizeObjectSets(await res.json(), files)
366
+ const out: Record<string, string> = {}
367
+ for (const ref of files.keys()) {
368
+ const found = findObjectSet(sets, ref, files)
369
+ if ('set' in found) out[ref] = found.set.id
370
+ }
371
+ return out
372
+ } catch {
373
+ return null
374
+ }
375
+ }
376
+
260
377
  async function status(ctx: CommandContext) {
261
378
  const project = localProject(ctx.cwd)
262
379
  const state = readState(project)
@@ -279,10 +396,48 @@ async function status(ctx: CommandContext) {
279
396
  ]
280
397
  return { data: Object.fromEntries(lines.map((l) => l.split(/=(.*)/s).slice(0, 2))), text: lines.join('\n') }
281
398
  }
282
- const { credentials, ...rest } = state
399
+ // Each component as it is now: a container that exited under the supervisor is down, whatever the state file says.
400
+ const { components, down } = await liveComponents(state)
401
+ const { credentials, ...rest } = { ...state, components }
402
+ // Each governed Action as the runtime deploys it, and its secrets now (names, and whether each has a value):
403
+ // read as Studio's build summary reads them, so the two count the same failures.
404
+ const { live, secrets, apps: liveApps } = await runtimeBuildInputs(state, project.root)
405
+ const setIds = await runtimeObjectSetIds(state, project)
406
+ // The local personas, by key and name: never a session.
407
+ const personas = state.phase === 'ready' && isAlive(state.supervisorPid) && state.credentials ? await listPersonas(`http://127.0.0.1:${state.ports.service}`, state.credentials) : null
408
+ const summary = projectBuildSummary(project.root, project.id, live, secrets, liveApps ?? null)
409
+ const devServers = listDevServers(project)
410
+ // A document left unread by a rollback reads as built here otherwise: said with its fix.
411
+ const stuck = (state.phase === 'ready' && isAlive(state.supervisorPid) && state.credentials ? await stuckKnowledge(`http://127.0.0.1:${state.ports.service}`, state.credentials, readBaseline(project, state.credentials.organizationId)?.restoredAt) : null) ?? []
412
+ // What the runtime holds with no file in the project (an Agent whose folder was deleted): said with its fix.
413
+ const orphans = (state.phase === 'ready' && isAlive(state.supervisorPid) && state.credentials ? await projectOrphans(project, `http://127.0.0.1:${state.ports.service}`, state.credentials).catch(() => null) : null) ?? []
283
414
  return {
284
- data: { ...rest, credentials: credentials ? { email: credentials.email, organizationId: credentials.organizationId, workspaceId: credentials.workspaceId } : null },
285
- text: renderStatus(state, project),
415
+ data: {
416
+ ...rest,
417
+ credentials: credentials ? { email: credentials.email, organizationId: credentials.organizationId, workspaceId: credentials.workspaceId } : null,
418
+ // Down now (`components` says `failed` for each), with why; `frontera local start` brings them back.
419
+ ...(down.length ? { down } : {}),
420
+ profiles: { workspace: localProfileName(project), organization: `${localProfileName(project)}-org`.slice(0, 64) },
421
+ // `counts` is over exactly `items`.
422
+ build: summary ? { line: summary.line, counts: summary.counts, items: projectBuildRows(project.root, project.id, live, setIds, secrets, liveApps ?? null) ?? [] } : null,
423
+ personas: personas ?? [],
424
+ // Knowledge search on the keyless local embeddings: approximate, with what gives the platform's.
425
+ ...(state.credentials?.modelProviders?.includes('local-embeddings') ? { embeddings: keylessEmbeddingsStatus() } : {}),
426
+ docs: docsStatus(project, { mode: state.mode, platformVersion: state.platformVersion, platformSource: readConfig(project)?.platformSource }),
427
+ notices: projectNotices(project.root),
428
+ // The App dev servers running against this runtime, with their ports: `frontera local stop` stops them.
429
+ appDevServers: devServers.map((s) => ({ app: s.slug, port: s.port, url: s.url, pid: s.pid, startedAt: s.startedAt, startedBy: s.owner?.kind ?? 'hand', ...(s.owner ? { ownerPid: s.owner.pid } : {}) })),
430
+ // Knowledge documents pending or processing with no ingestion behind them, each with its fix.
431
+ ...(stuck.length ? { knowledgeStuck: stuck } : {}),
432
+ // On the runtime, not in the files: each with what fixes it (`local dev` archives an Agent or workflow whose file was deleted).
433
+ ...(orphans.length ? { notInProject: orphans } : {}),
434
+ },
435
+ text: [
436
+ renderStatus({ ...state, components }, project, undefined, live, setIds, personas, secrets, liveApps ?? null, down),
437
+ ...(stuck.length ? ['', 'Knowledge', ...stuck.map((k) => ` warning: ${k.problem}`)] : []),
438
+ ...(orphans.length ? ['', 'On the runtime, not in the project', ...orphans.map((o) => ` warning: ${orphanLine(o)}`)] : []),
439
+ ...(devServers.length ? ['', 'App dev servers', ...devServers.map((s) => ` ${devServerLine(s)}`)] : []),
440
+ ].join('\n'),
286
441
  }
287
442
  }
288
443
 
@@ -296,7 +451,6 @@ export const localStatus: Command = {
296
451
  summary: 'Show the local runtime: components, URLs and credentials (-o env for scripts)',
297
452
  examples: ['frontera local status', 'eval "$(frontera local status -o env)"'],
298
453
  offline: true,
299
- internal: true,
300
454
  },
301
455
  run: status,
302
456
  }
@@ -330,7 +484,6 @@ export const localLogs: Command = {
330
484
  summary: 'Print a component\'s log (service by default)',
331
485
  examples: ['frontera local logs', 'frontera local logs jobs -f'],
332
486
  offline: true,
333
- internal: true,
334
487
  },
335
488
  run: logs,
336
489
  }
@@ -379,45 +532,123 @@ export const localUi: Command = {
379
532
  summary: 'Open Frontera Studio for the running local runtime',
380
533
  examples: ['frontera local ui', 'frontera local ui --no-open'],
381
534
  offline: true,
382
- internal: true,
383
535
  },
384
536
  run: ui,
385
537
  }
386
538
 
539
+ /**
540
+ * The case files `frontera local test` runs. `agent` takes one Agent's cases,
541
+ * `workflow` one workflow's; neither runs both kinds. `regression`: only the
542
+ * Agents' regression datasets, as the workspace chose them. `file`: one case
543
+ * file, an Agent's (agents/<slug>/evals/*.yaml) or a workflow's
544
+ * (workflows/tests/*.yaml), chosen as MCP `test_run` chooses it.
545
+ */
546
+ export function localTestFiles(root: string, scope: { agent?: string; workflow?: string; file?: string; regression?: boolean }): { files: CaseFile[]; workflowFiles: WorkflowTestFile[] } {
547
+ const file = scope.file?.replace(/^\.\//, '')
548
+ const files = scope.workflow ? [] : selectCaseFiles(root, { agent: scope.agent, file, regression: scope.regression })
549
+ const workflowFiles = scope.agent || scope.regression ? [] : findWorkflowTestFiles(root, scope.workflow).filter((f) => !file || f.file === file)
550
+ return { files, workflowFiles }
551
+ }
552
+
387
553
  async function test(ctx: CommandContext) {
388
554
  const project = localProject(ctx.cwd)
389
555
  const state = readState(project)
390
556
  if (!state || state.phase !== 'ready' || !state.credentials?.session) {
391
557
  throw new CliError('the local runtime is not running', { code: 'LOCAL_NOT_RUNNING', hint: 'run `frontera local start` first' })
392
558
  }
393
- // `--regression`: only the Agents' regression datasets, as the workspace chose them.
394
- const files = findCaseFiles(project.root, flagString(ctx, 'agent')).filter((f) => !flagBool(ctx, 'regression') || f.regression)
395
- const total = files.reduce((n, f) => n + f.cases.length, 0)
396
- if (total === 0 && flagBool(ctx, 'regression')) {
397
- return { data: { passed: 0, failed: 0, results: [] }, text: 'No regression datasets. A pull marks the dataset each Agent is tested on before publishing in the workspace (`regression: true`).' }
559
+ const agent = flagString(ctx, 'agent')
560
+ const workflow = flagString(ctx, 'workflow')
561
+ const regression = flagBool(ctx, 'regression')
562
+ const file = flagString(ctx, 'file')?.replace(/^\.\//, '')
563
+ const { files, workflowFiles } = localTestFiles(project.root, { agent, workflow, file, regression })
564
+ const total = caseCount(files)
565
+ const workflowTotal = workflowFiles.reduce((n, f) => n + Math.max(f.cases.length, f.problem ? 1 : 0), 0)
566
+ const empty = { passed: 0, failed: 0, results: [], workflows: [] }
567
+ if (total + workflowTotal === 0 && regression) {
568
+ return { data: empty, text: 'No regression datasets. A pull marks the dataset each Agent is tested on before publishing in the workspace (`regression: true`).' }
569
+ }
570
+ if (total + workflowTotal === 0 && file) {
571
+ throw new UsageError(`no test case file ${file}${agent ? ` for Agent ${agent}` : workflow ? ` for workflow ${workflow}` : ''}`, 'give its path from the project root, e.g. agents/<slug>/evals/<name>.yaml or workflows/tests/<slug>.yaml')
398
572
  }
399
- if (total === 0) {
573
+ if (total + workflowTotal === 0) {
400
574
  return {
401
- data: { passed: 0, failed: 0, results: [] },
402
- text: 'No test cases. Save one from Studio (Agents → Save as test case) or add agents/<slug>/evals/*.yaml.',
575
+ data: empty,
576
+ text: workflow
577
+ ? `No test cases for workflow ${workflow}. Add workflows/tests/${workflow}.yaml (\`frontera local schema workflow-tests\` is its format).`
578
+ : 'No test cases. Save one from Studio (Agents → Save as test case), add agents/<slug>/evals/*.yaml, or add workflows/tests/<slug>.yaml.',
403
579
  }
404
580
  }
405
- const results = await runCases({
406
- serviceUrl: `http://127.0.0.1:${state.ports.service}`,
407
- creds: state.credentials,
408
- files,
409
- live: flagBool(ctx, 'live'),
410
- onResult: (r) => ctx.output.note(` ${r.passed ? '✓' : '✕'} ${r.agent} · ${r.name}${r.passed ? '' : ` — ${r.reason}`} (${r.ms} ms)`),
411
- })
412
- const failed = results.filter((r) => !r.passed)
413
- const summary = `${results.length - failed.length} passed, ${failed.length} failed`
414
- if (failed.length) {
415
- throw new CliError(`${summary}: ${failed.map((f) => `${f.agent} · ${f.name}`).join('; ')}`, {
416
- code: 'LOCAL_TESTS_FAILED',
417
- hint: 'open the Agent in Studio (`frontera local ui`) to see the conversation, then fix the Agent or the case',
418
- })
581
+ const serviceUrl = `http://127.0.0.1:${state.ports.service}`
582
+ let concurrency: number | undefined
583
+ try {
584
+ concurrency = caseConcurrency(flagString(ctx, 'concurrency'))
585
+ } catch (err) {
586
+ throw new UsageError((err as Error).message, 'frontera local test --concurrency 1')
587
+ }
588
+ const results: CaseResult[] = total
589
+ ? await runCases({
590
+ serviceUrl,
591
+ creds: state.credentials,
592
+ files,
593
+ live: flagBool(ctx, 'live'),
594
+ // The draft is what the files say, or the cases say what to run first.
595
+ staleDraft: (slug) => agentFilesProblem(project, state.credentials!.organizationId, slug),
596
+ concurrency,
597
+ onResult: (r) => {
598
+ // A case never sent after the run stopped is said once, below, not once each.
599
+ if (r.notRun?.skipped) return
600
+ ctx.output.note(` ${r.passed ? '✓' : r.notRun ? '–' : '✕'} ${r.agent} · ${r.name}${r.passed ? '' : r.notRun ? ` — couldn’t run: ${r.notRun.what}` : ` — ${r.reason}`}${r.passed && r.retried ? ' (run twice: the provider refused the first try for its limits)' : ''}${r.waited?.length ? ` (${waitedWords(r.waited)})` : ''} (${r.ms} ms)`)
601
+ },
602
+ })
603
+ : []
604
+ // What kept cases from running, once each, with its fix: never a verdict on the Agent.
605
+ const notRun = notRunGroups(results)
606
+ for (const g of notRun) {
607
+ if (g.skipped) ctx.output.note(` – ${g.skipped} more case${g.skipped === 1 ? '' : 's'} not sent: ${g.what}`)
608
+ ctx.output.note(` couldn’t run ${g.cases.length} case${g.cases.length === 1 ? '' : 's'}: ${withFix(g.what, g.fix)}`)
419
609
  }
420
- return { data: { passed: results.length, failed: 0, results }, text: summary }
610
+ const workflows: WorkflowTestResult[] = workflowTotal
611
+ ? await runWorkflowTests({
612
+ serviceUrl,
613
+ creds: state.credentials,
614
+ files: workflowFiles,
615
+ // Exactly the version `local dev` pushed each file as, never a stale draft.
616
+ fileState: (slug) => workflowFileState(project, state.credentials!.organizationId, slug),
617
+ onResult: (r) => ctx.output.note(` ${r.passed ? '✓' : '✕'} workflow ${r.workflow} · ${r.name}${r.passed ? '' : ` — ${r.reason}`} (${r.ms} ms)`),
618
+ })
619
+ : []
620
+ // Each failing Agent case's conversation, last turn's trace and reply: what to read to tell a regression from a bad case.
621
+ const evidence = await failingCaseEvidence(project, results)
622
+ for (const [i, e] of evidence) ctx.output.note(` ${results[i]!.agent} · ${results[i]!.name}: ${evidenceLine(e)}`)
623
+ // Every case's last-turn trace beside its conversation (`--json`), passing ones included; a failing one's came with its evidence.
624
+ const traces = await caseTraces(project, results, { known: evidence })
625
+ const comparison = compareWithLastRun(results, readLastRun(project))
626
+ if (results.length) recordRun(project, results)
627
+ // Each workflow case's last run, for Studio's Evaluations and Forge's home.
628
+ recordWorkflowTestRun(project, workflows)
629
+ const failed = [...results.filter((r) => !r.passed && !r.notRun).map((f) => `${f.agent} · ${f.name}`), ...workflows.filter((r) => !r.passed).map((f) => `workflow ${f.workflow} · ${f.name}`)]
630
+ const notRunCount = notRun.reduce((n, g) => n + g.cases.length, 0)
631
+ const passed = results.length + workflows.length - failed.length - notRunCount
632
+ const newly = comparison.newlyFailing.length ? `, ${comparison.newlyFailing.length} newly failing since the last run` : ''
633
+ const summary = `${passed} passed, ${failed.length} failed${newly}${notRun.length ? `, ${notRunWords(notRun)}` : ''}`
634
+ const data = { passed, failed: failed.length, ...(notRun.length ? { notRun: notRunCount, couldNotRun: notRun.map((g) => ({ what: `couldn’t run: ${g.what}`, fix: g.fix, cases: g.cases, ...(g.skipped ? { notSent: g.skipped } : {}) })) } : {}), fixed: comparison.fixed.length, results: results.map((r, i) => ({ ...r, ...traces.get(i), ...evidence.get(i) })), workflows }
635
+ if (failed.length || notRun.length) {
636
+ // Only cases that couldn't run: the run did not pass, and the cause is the provider's or the runtime's, not the Agent's.
637
+ const onlyNotRun = !failed.length
638
+ const code = onlyNotRun ? 'LOCAL_TESTS_NOT_RUN' : 'LOCAL_TESTS_FAILED'
639
+ const message = onlyNotRun ? summary : `${summary}: ${failed.join('; ')}`
640
+ const caseHint = workflows.some((r) => !r.passed)
641
+ ? 'open the run in Studio (`frontera local ui` → Workflows → Runs) or the Agent\'s conversation, then fix the workflow, the Agent or the case'
642
+ : 'open the Agent in Studio (`frontera local ui`) to see the conversation, then fix the Agent or the case'
643
+ const hint = [onlyNotRun ? null : caseHint, ...notRun.map((g) => g.fix)].filter(Boolean).join('; ')
644
+ // `--json`: the per-case results ARE the data, failed or not, so they go to stdout (exit 1 says a case failed), as `local check` does.
645
+ if (flagBool(ctx, 'json')) {
646
+ ctx.output.note(`error [${code}]: ${message}`)
647
+ return { data: { ...data, error: { code, message, hint } }, text: '', exitCode: EXIT.FAILURE }
648
+ }
649
+ throw new CliError(message, { code, hint, details: data })
650
+ }
651
+ return { data, text: summary }
421
652
  }
422
653
 
423
654
  export const localTest: Command = {
@@ -425,11 +656,15 @@ export const localTest: Command = {
425
656
  noun: 'local',
426
657
  verb: 'test',
427
658
  args: [],
428
- flags: { agent: 'string', live: 'boolean', regression: 'boolean' },
429
- summary: 'Run the project\'s saved Agent test cases against the local runtime',
430
- examples: ['frontera local test', 'frontera local test --agent claims-triage', 'frontera local test --live', 'frontera local test --regression'],
659
+ flags: { agent: 'string', workflow: 'string', file: 'string', live: 'boolean', regression: 'boolean', concurrency: 'string' },
660
+ flagHelp: { file: 'run only this case file, by its path from the project root: agents/<slug>/evals/<name>.yaml or workflows/tests/<slug>.yaml' },
661
+ summary: 'Run the project\'s Agent test cases and workflow tests against the local runtime',
662
+ details: [
663
+ '`--file` runs one case file, by its path from the project root: an Agent\'s (agents/<slug>/evals/<name>.yaml) or a workflow\'s (workflows/tests/<slug>.yaml), the same file MCP `test_run` takes. A path that names no case file is refused.',
664
+ 'A workflow test runs the workflow for real on the local runtime: a governed Action one of its steps submits (an action step, or a Function step\'s code) is applied and changes the runtime\'s records when the Action runs here; one whose effect would reach another system is switched off here. The writes stay; `frontera local check` rolls back its own tests\' writes, and `frontera local snapshot save` / `restore` do it by hand. The workspace is never touched.',
665
+ ],
666
+ examples: ['frontera local test', 'frontera local test --agent claims-triage', 'frontera local test --workflow claim-intake', 'frontera local test --file agents/claims-triage/evals/refunds.yaml', 'frontera local test --live', 'frontera local test --regression', 'frontera local test --concurrency 1'],
431
667
  offline: true,
432
- internal: true,
433
668
  },
434
669
  run: test,
435
670
  }
@@ -463,6 +698,7 @@ export const localCommands: readonly Command[] = [
463
698
  localStatus,
464
699
  localLogs,
465
700
  localReset,
701
+ localRemove,
466
702
  localUi,
467
703
  localDev,
468
704
  localTest,
@@ -480,6 +716,7 @@ export const localCommands: readonly Command[] = [
480
716
  localDocsMcp,
481
717
  localSchema,
482
718
  localData,
719
+ localSdkRefresh,
483
720
  localSync,
484
721
  localDaemon,
485
722
  ]