@frontera-sdk/cli 1.52.45 → 1.52.47

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 (105) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +3 -1
  3. package/src/api/automation-api.ts +32 -0
  4. package/src/api/credential-failure.ts +1 -1
  5. package/src/api/governed-action-api.ts +24 -0
  6. package/src/api/media-api.ts +24 -0
  7. package/src/api/platform-api.ts +13 -0
  8. package/src/binding-registry.ts +9 -2
  9. package/src/blueprint/compile.ts +3 -1
  10. package/src/blueprint/ontology-edit-plan.ts +71 -4
  11. package/src/blueprint/org-name-check.ts +64 -0
  12. package/src/blueprint/tree.ts +28 -0
  13. package/src/commands/action/deploy.ts +22 -5
  14. package/src/commands/action/index-commands.ts +2 -0
  15. package/src/commands/action/retire.ts +60 -0
  16. package/src/commands/agent/compose.ts +1 -0
  17. package/src/commands/agent/index-commands.ts +42 -0
  18. package/src/commands/app/deploy.ts +1 -1
  19. package/src/commands/app/dev.ts +2 -2
  20. package/src/commands/app/save.ts +1 -1
  21. package/src/commands/auth/add.ts +5 -1
  22. package/src/commands/auth/current.ts +6 -0
  23. package/src/commands/auth/index-commands.ts +3 -0
  24. package/src/commands/auth/login.ts +84 -0
  25. package/src/commands/auth/use.ts +21 -2
  26. package/src/commands/automation/bindings.ts +54 -10
  27. package/src/commands/automation/index-commands.ts +2 -3
  28. package/src/commands/blueprint/bind.ts +12 -1
  29. package/src/commands/blueprint/declarative.ts +6 -2
  30. package/src/commands/conversation/index-commands.ts +66 -0
  31. package/src/commands/conversation/local-runtime.ts +18 -0
  32. package/src/commands/local/check.ts +57 -11
  33. package/src/commands/local/data.ts +75 -7
  34. package/src/commands/local/dev.ts +77 -9
  35. package/src/commands/local/embeddings.ts +74 -0
  36. package/src/commands/local/index-commands.ts +133 -11
  37. package/src/commands/local/new.ts +78 -0
  38. package/src/commands/local/publish.ts +34 -0
  39. package/src/commands/local/steps.ts +105 -0
  40. package/src/commands/local/sync-request.ts +69 -23
  41. package/src/commands/local/sync.ts +2 -0
  42. package/src/commands/media/index-commands.ts +58 -3
  43. package/src/commands/registry.ts +11 -3
  44. package/src/commands/trace/index-commands.ts +56 -0
  45. package/src/commands/workspace/index-commands.ts +14 -7
  46. package/src/config.ts +4 -1
  47. package/src/dev-broker.ts +15 -1
  48. package/src/device-login.ts +143 -0
  49. package/src/exit.ts +36 -8
  50. package/src/flag-help.ts +30 -0
  51. package/src/forge/claude-adapter.ts +17 -4
  52. package/src/forge/permission-rules.ts +158 -0
  53. package/src/forge/touches.ts +7 -6
  54. package/src/function-outside-imports.ts +13 -2
  55. package/src/function-source.ts +90 -0
  56. package/src/harness.ts +4 -4
  57. package/src/key-reach.ts +64 -0
  58. package/src/local/local-actions.ts +15 -3
  59. package/src/local/local-apply-error.ts +26 -0
  60. package/src/local/local-apps.ts +9 -3
  61. package/src/local/local-blueprint-schemas.ts +15 -1
  62. package/src/local/local-confidential.ts +81 -0
  63. package/src/local/local-data-load.ts +130 -0
  64. package/src/local/local-dev-references.ts +24 -4
  65. package/src/local/local-embedding-setup.ts +62 -0
  66. package/src/local/local-jobs.ts +108 -0
  67. package/src/local/local-new.ts +148 -0
  68. package/src/local/local-org-settings.ts +1 -1
  69. package/src/local/local-person-steps.ts +340 -0
  70. package/src/local/local-personas.ts +16 -5
  71. package/src/local/local-portability.ts +22 -1
  72. package/src/local/local-publish.ts +26 -0
  73. package/src/local/local-request-evidence.ts +35 -6
  74. package/src/local/local-runtime-history.ts +4 -3
  75. package/src/local/local-said-once.ts +51 -0
  76. package/src/local/local-scaffold.ts +40 -2
  77. package/src/local/local-schemas.ts +60 -11
  78. package/src/local/local-stand-ins.ts +44 -0
  79. package/src/local/local-supervisor.ts +22 -0
  80. package/src/local/local-sync-models.ts +5 -2
  81. package/src/local/local-sync-names.ts +3 -0
  82. package/src/local/local-sync-portable.ts +64 -15
  83. package/src/local/local-sync-precheck.ts +30 -1
  84. package/src/local/local-sync-push.ts +81 -8
  85. package/src/local/local-sync-remote.ts +10 -7
  86. package/src/local/local-test-models.ts +173 -0
  87. package/src/local/local-test-runner.ts +232 -25
  88. package/src/local/local-test-scope.ts +174 -0
  89. package/src/local/local-ungranted-reads.ts +55 -0
  90. package/src/local/local-writers.ts +171 -0
  91. package/src/main.ts +75 -17
  92. package/src/mcp/browser/ax-snapshot.ts +13 -1
  93. package/src/mcp/browser/page-scripts.ts +1 -0
  94. package/src/mcp/mcp-conversations.ts +171 -0
  95. package/src/mcp/mcp-server.ts +4 -1
  96. package/src/mcp/mcp-traces.ts +7 -2
  97. package/src/mcp/tools/agent-tools.ts +94 -2
  98. package/src/mcp/tools/project-tools.ts +149 -13
  99. package/src/mcp/tools/test-tools.ts +64 -13
  100. package/src/output.ts +22 -7
  101. package/src/pack.ts +16 -0
  102. package/src/project-context.ts +18 -5
  103. package/src/project.ts +4 -0
  104. package/src/remote-guard.ts +52 -0
  105. package/src/vendor/kit-assets.json +11 -11
@@ -13,8 +13,12 @@ import { deepEqual } from './local-sync-merge'
13
13
  * a model the workspace lacks which model an Agent runs on is the workspace's choice: the Agent keeps
14
14
  * the workspace's model selection (its modes and default), a new Agent
15
15
  * takes the workspace's default, and the local model stays this computer's.
16
- * a multi-turn evaluation case `frontera local test` runs a conversation of several turns; the workspace's
17
- * evaluation datasets hold single questions, so those cases stay in the file here.
16
+ * a multi-turn evaluation case `frontera local test` runs a conversation of several turns. A workspace whose
17
+ * evaluation datasets hold conversations (`evaluation-conversations`, L3) takes
18
+ * them, except one that waits before a turn (`waitSeconds`), which runs on this
19
+ * computer only; an older workspace holds single questions, so those stay here.
20
+ * an Action's `attachment` how the local runtime deploys an Action that takes a file (L6): in the
21
+ * workspace the person who deploys it chooses, so it stays in the file here.
18
22
  *
19
23
  * `toWorkspace` is the file as the workspace has it: what Propose sends, and
20
24
  * what status and push compare with the workspace. `toLocal` is the
@@ -29,6 +33,7 @@ export type ModelMap = Record<string, string>
29
33
  const AGENT_FILE = /^agents\/[^/]+\/agent\.json$/
30
34
  const WORKFLOW_FILE = /^workflows\/[^/]+\.ya?ml$/
31
35
  const EVAL_FILE = /^agents\/[^/]+\/evals\/[^/]+\.ya?ml$/
36
+ const ACTION_FILE = /^blueprint\/actions\/[^/]+\.ya?ml$/
32
37
 
33
38
  const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
34
39
 
@@ -62,16 +67,21 @@ interface CaseNode {
62
67
  toJSON(): unknown
63
68
  }
64
69
 
65
- /** An evaluation file's cases, each with whether it is a conversation of more than one turn. */
66
- function evaluationCases(doc: Document): Array<{ node: CaseNode; multiTurn: boolean; name: string | null; question: string | null }> {
70
+ /**
71
+ * An evaluation file's cases, each with whether it stays here: a
72
+ * conversation of more than one turn, unless the workspace holds
73
+ * conversations (`conversations`) and none of its turns waits.
74
+ */
75
+ function evaluationCases(doc: Document, conversations = false): Array<{ node: CaseNode; multiTurn: boolean; name: string | null; question: string | null }> {
67
76
  const cases = doc.get('cases', true)
68
77
  if (!isSeq(cases)) return []
69
78
  return cases.items.map((node) => {
70
79
  const value = (isMap(node) ? node.toJSON() : null) as { name?: unknown; conversation?: unknown; scenario?: unknown } | null
71
- const turns = Array.isArray(value?.conversation) ? (value!.conversation as Array<{ user?: unknown }>) : []
80
+ const turns = Array.isArray(value?.conversation) ? (value!.conversation as Array<{ user?: unknown; waitSeconds?: unknown } | null>) : []
81
+ const waits = turns.some((t) => t?.waitSeconds !== undefined)
72
82
  return {
73
83
  node: node as CaseNode,
74
- multiTurn: value?.scenario === undefined && turns.length > 1,
84
+ multiTurn: value?.scenario === undefined && turns.length > 1 && (!conversations || waits),
75
85
  name: typeof value?.name === 'string' ? value.name : null,
76
86
  question: typeof turns[0]?.user === 'string' ? (turns[0]!.user as string).trim() : null,
77
87
  }
@@ -84,6 +94,8 @@ export interface Portable {
84
94
  models: Array<[string, string]>
85
95
  /** Evaluation cases that stay here (more than one turn), by name. */
86
96
  keptHere: string[]
97
+ /** An Action file's `attachment`, its deployment choice for the local runtime, stays here (L6). */
98
+ attachmentKept?: boolean
87
99
  /** Models the workspace lacks that the file names here; the workspace keeps its own selection (`upstream`: its models, none for a new Agent). */
88
100
  localModels?: { slugs: string[]; upstream: string[] }
89
101
  }
@@ -93,6 +105,8 @@ export interface ModelContext {
93
105
  map?: ModelMap
94
106
  /** The workspace's slugs; null or absent: unknown, so nothing is judged missing. */
95
107
  workspace?: ReadonlySet<string> | null
108
+ /** The workspace's evaluation datasets hold conversations (L3), so a multi-turn case goes unless it waits. */
109
+ conversations?: boolean
96
110
  }
97
111
 
98
112
  interface AgentDoc {
@@ -131,18 +145,25 @@ function agentAsWorkspace(text: string, workspace: ReadonlySet<string>, upstream
131
145
  let modelConfigs: Array<Record<string, unknown>>
132
146
  if (theirs) {
133
147
  modelConfigs = theirs.modelConfigs ?? []
134
- if (theirs.config && 'defaultMode' in theirs.config) config.defaultMode = theirs.config.defaultMode
135
- else delete config.defaultMode
148
+ // The default and the fallback name modes: both are the workspace's selection.
149
+ for (const key of ['defaultMode', 'fallbackMode'] as const) {
150
+ if (theirs.config && key in theirs.config) config[key] = theirs.config[key]
151
+ else delete config[key]
152
+ }
136
153
  } else {
137
154
  modelConfigs = (doc.modelConfigs ?? []).filter((m) => !(typeof m?.modelSlug === 'string' && missing.includes(m.modelSlug)))
138
155
  if (!modelConfigs.some((m) => m.modeKey === config.defaultMode)) config.defaultMode = (modelConfigs[0]?.modeKey as string | undefined) ?? null
156
+ // Gone with its model, or now the default: either way no longer a fallback.
157
+ if (config.fallbackMode && (config.fallbackMode === config.defaultMode || !modelConfigs.some((m) => m.modeKey === config.fallbackMode))) {
158
+ config.fallbackMode = null
159
+ }
139
160
  }
140
161
  return { text: agentText({ ...doc, config, modelConfigs }), slugs: missing, upstream: slugsOf({ modelConfigs }) }
141
162
  }
142
163
 
143
164
  /** The file as the workspace has it. Unchanged text when nothing in it is this computer's. */
144
165
  export function toWorkspace(path: string, text: string, models: ModelMap | ModelContext = {}, upstream?: string | null): Portable {
145
- const ctx: ModelContext = 'map' in models || 'workspace' in models ? (models as ModelContext) : { map: models as ModelMap }
166
+ const ctx: ModelContext = 'map' in models || 'workspace' in models || 'conversations' in models ? (models as ModelContext) : { map: models as ModelMap }
146
167
  const map = ctx.map ?? {}
147
168
  if (EVAL_FILE.test(path)) {
148
169
  let doc: Document
@@ -152,13 +173,20 @@ export function toWorkspace(path: string, text: string, models: ModelMap | Model
152
173
  return { text, models: [], keptHere: [] }
153
174
  }
154
175
  if (doc.errors.length) return { text, models: [], keptHere: [] }
155
- const cases = evaluationCases(doc)
176
+ const cases = evaluationCases(doc, ctx.conversations)
156
177
  const kept = cases.filter((c) => c.multiTurn)
157
178
  if (!kept.length) return { text, models: [], keptHere: [] }
158
179
  const seq = doc.get('cases', true) as { items: unknown[] }
159
180
  seq.items = seq.items.filter((_, i) => !cases[i]!.multiTurn)
160
181
  return { text: doc.toString(), models: [], keptHere: kept.map((c) => c.name ?? `case ${cases.indexOf(c) + 1}`) }
161
182
  }
183
+ // An Action's `attachment` is how this project's runtime deploys it; in the workspace the person deploying it chooses (L6).
184
+ if (ACTION_FILE.test(path)) {
185
+ const doc = parseDocument(text)
186
+ if (doc.errors.length || !doc.has('attachment')) return { text, models: [], keptHere: [] }
187
+ doc.delete('attachment')
188
+ return { text: doc.toString(), models: [], keptHere: [], attachmentKept: true }
189
+ }
162
190
  const { text: swapped, swapped: done } = swapModels(path, text, map)
163
191
  if (AGENT_FILE.test(path) && ctx.workspace) {
164
192
  const local = agentAsWorkspace(swapped, ctx.workspace, upstream)
@@ -175,7 +203,7 @@ export function toWorkspace(path: string, text: string, models: ModelMap | Model
175
203
  * as the last pull left it (`base`); one the workspace changed comes here.
176
204
  */
177
205
  export function toLocal(path: string, remote: string, local: string | null, models: ModelMap | ModelContext = {}, base?: string | null): string {
178
- const ctx: ModelContext = 'map' in models || 'workspace' in models ? (models as ModelContext) : { map: models as ModelMap }
206
+ const ctx: ModelContext = 'map' in models || 'workspace' in models || 'conversations' in models ? (models as ModelContext) : { map: models as ModelMap }
179
207
  const map = ctx.map ?? {}
180
208
  if (EVAL_FILE.test(path)) {
181
209
  if (local === null) return remote
@@ -188,7 +216,7 @@ export function toLocal(path: string, remote: string, local: string | null, mode
188
216
  return remote
189
217
  }
190
218
  if (ours.errors.length || theirs.errors.length) return remote
191
- const kept = evaluationCases(ours).filter((c) => c.multiTurn)
219
+ const kept = evaluationCases(ours, ctx.conversations).filter((c) => c.multiTurn)
192
220
  if (!kept.length) return remote
193
221
  const present = evaluationCases(theirs)
194
222
  const seq = theirs.get('cases', true)
@@ -200,6 +228,15 @@ export function toLocal(path: string, remote: string, local: string | null, mode
200
228
  }
201
229
  return theirs.toString()
202
230
  }
231
+ if (ACTION_FILE.test(path)) {
232
+ // The pulled Action with this project's `attachment` put back.
233
+ if (local === null) return remote
234
+ const ours = parseDocument(local)
235
+ const theirs = parseDocument(remote)
236
+ if (ours.errors.length || theirs.errors.length || !ours.has('attachment') || theirs.has('attachment')) return remote
237
+ theirs.set('attachment', ours.get('attachment', true))
238
+ return theirs.toString()
239
+ }
203
240
  const written = swapModels(path, remote, reversed(map)).text
204
241
  // An Agent this computer runs on a model the workspace lacks keeps it, while the workspace's selection is as it was.
205
242
  if (AGENT_FILE.test(path) && local !== null && base && ctx.workspace) {
@@ -207,8 +244,16 @@ export function toLocal(path: string, remote: string, local: string | null, mode
207
244
  const theirs = parseAgent(written)
208
245
  const before = parseAgent(base)
209
246
  const localOnly = agentAsWorkspace(swapModels(path, local, map).text, ctx.workspace, base)
210
- if (ours && theirs && before && localOnly && deepEqual(before.modelConfigs ?? [], parseAgent(remote)?.modelConfigs ?? []) && deepEqual(before.config?.defaultMode ?? null, parseAgent(remote)?.config?.defaultMode ?? null)) {
211
- return agentText({ ...theirs, config: { ...(theirs.config ?? {}), defaultMode: ours.config?.defaultMode ?? null }, modelConfigs: ours.modelConfigs ?? [] })
247
+ if (ours && theirs && before && localOnly && deepEqual(before.modelConfigs ?? [], parseAgent(remote)?.modelConfigs ?? []) && deepEqual(before.config?.defaultMode ?? null, parseAgent(remote)?.config?.defaultMode ?? null) && deepEqual(before.config?.fallbackMode ?? null, parseAgent(remote)?.config?.fallbackMode ?? null)) {
248
+ return agentText({
249
+ ...theirs,
250
+ config: {
251
+ ...(theirs.config ?? {}),
252
+ defaultMode: ours.config?.defaultMode ?? null,
253
+ ...(ours.config && 'fallbackMode' in ours.config ? { fallbackMode: ours.config.fallbackMode } : {}),
254
+ },
255
+ modelConfigs: ours.modelConfigs ?? [],
256
+ })
212
257
  }
213
258
  }
214
259
  return written
@@ -251,9 +296,13 @@ export function portableNotes(files: Record<string, string>, models: ModelMap |
251
296
  const there = p.localModels.upstream.map((s) => names[s] ?? s)
252
297
  notes.push(`Runs on ${here} here, a model the workspace doesn’t have: ${upstream[path] ? `the workspace keeps its own model selection${there.length ? ` (${there.join(', ')})` : ''}` : there.length ? `the workspace runs it on ${there.join(', ')}` : 'the workspace runs it on its default model'}`)
253
298
  }
299
+ if (p.attachmentKept) notes.push('Its attachment stays in this project: it is how the local runtime deploys the Action, and in the workspace the person who deploys it chooses (`frontera action deploy --attach … --file-input …`)')
254
300
  if (p.keptHere.length) {
255
301
  const n = p.keptHere.length
256
- notes.push(`${n} multi-turn case${n === 1 ? '' : 's'} stay${n === 1 ? 's' : ''} here (${p.keptHere.slice(0, 3).join(', ')}${n > 3 ? ', …' : ''}): the workspace’s evaluation datasets hold single questions, so \`frontera local test\` runs ${n === 1 ? 'it' : 'them'} on this computer only`)
302
+ const why = (models as ModelContext).conversations
303
+ ? `${n === 1 ? 'it waits' : 'they wait'} before a turn, and the workspace’s runner sends each turn as soon as the reply before it ends`
304
+ : 'the workspace’s evaluation datasets hold single questions'
305
+ notes.push(`${n} multi-turn case${n === 1 ? '' : 's'} stay${n === 1 ? 's' : ''} here (${p.keptHere.slice(0, 3).join(', ')}${n > 3 ? ', …' : ''}): ${why}, so \`frontera local test\` runs ${n === 1 ? 'it' : 'them'} on this computer only`)
257
306
  }
258
307
  }
259
308
  for (const [from, to] of swapped) notes.push(`Uses ${from} here; the workspace has the same model as ${names[to] ?? to}`)
@@ -69,12 +69,41 @@ export function fixFor(check: Check): string | null {
69
69
  const model = /the model (\S+) is not in this workspace/.exec(check.message)
70
70
  if (model) return `choose the workspace model it goes as (\`frontera local sync map-model ${model[1]} <workspace model>\`)`
71
71
  if (check.code === 'unresolved') return 'propose what it names too, or create that in the workspace first'
72
+ if (check.code === 'object_set_unresolved') return 'propose the set’s file (blueprint/object-sets/<name>.yaml) in this request too, or create the saved object set in the workspace first'
73
+ if (check.code === 'name_taken_in_organization') return 'rename the object type here to a more specific API name, or ask a Blueprint Steward to grant the workspace the organization’s type'
72
74
  return null
73
75
  }
74
76
 
77
+ /** One step of Apply's plan, as the check returns it (N3): in Apply's order, with what it needs. */
78
+ export interface ApplyStep {
79
+ key: string
80
+ phase: string
81
+ ref: string
82
+ label: string
83
+ /** `afterApply`: Apply goes ahead and leaves the step for a person afterwards (N10). */
84
+ needs: Array<{ code: string; message: string; fix: string; met: boolean; afterApply?: boolean }>
85
+ }
86
+
87
+ /** The plan from the check's answer; empty from a platform that sends none. */
88
+ export function planOf(answer: unknown): ApplyStep[] {
89
+ const plan = (answer as { plan?: unknown } | null)?.plan
90
+ return Array.isArray(plan) ? (plan as ApplyStep[]).filter((s) => s && typeof s.key === 'string' && Array.isArray(s.needs)) : []
91
+ }
92
+
93
+ /**
94
+ * The steps Apply would meet that the project's credentials can't take:
95
+ * a grant a Blueprint Steward makes, an Action's deployment decision. They
96
+ * don't stop a proposal; they are what a person does before Apply, or, for
97
+ * a new Agent's grants, after it (`after`), so Propose shows them under
98
+ * **Needs a person**.
99
+ */
100
+ export function needsAPerson(plan: ApplyStep[]): Array<{ step: string; ref: string; code: string; message: string; fix: string; after?: true }> {
101
+ return plan.flatMap((s) => s.needs.filter((n) => !n.met).map((n) => ({ step: s.label, ref: s.ref, code: n.code, message: n.message, fix: n.fix, ...(n.afterApply ? { after: true as const } : {}) })))
102
+ }
103
+
75
104
  /** The message without the file it names, and the file: a row says why first. */
76
105
  export function splitCheckMessage(message: string): { why: string; file: string | null } {
77
- const m = /^((?:agents|workflows|functions|skills|knowledge|blueprint|mcp|chat-apps|apps)\/[^:\s]+):\s+(.*)$/s.exec(message)
106
+ const m = /^((?:agents|workflows|functions|skills|knowledge|blueprint|datasets|mcp|chat-apps|apps)\/[^:\s]+):\s+(.*)$/s.exec(message)
78
107
  if (!m) return { why: message, file: null }
79
108
  return { why: m[2]!.charAt(0).toUpperCase() + m[2]!.slice(1), file: m[1]! }
80
109
  }
@@ -31,7 +31,9 @@ import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync
31
31
  *
32
32
  * what is pushed items changed here (a merge or a resolve leaves them so) and items only here
33
33
  * what is refused an item the workspace changed too (pull and merge first), one with conflicts,
34
- * a deletion, and kinds a request does not carry (plugins, evaluations, datasets)
34
+ * a deletion, and kinds a request does not carry (the brand, Library installations)
35
+ * datasets a blank dataset's columns, listed when an object type the push proposes reads it
36
+ * or when named, never its rows
35
37
  * each item the lock's id, the revision its base is (the Blueprint's for a Blueprint file),
36
38
  * every file of the item, and for a Function its `function deploy` build
37
39
  * the request `.frontera/sync/requests.json` (not committed): the current one, so the next
@@ -42,18 +44,18 @@ import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync
42
44
  */
43
45
 
44
46
  /** The kinds a change request carries. */
45
- export const PUSH_KINDS: readonly SyncKind[] = ['blueprint', 'skill', 'knowledge', 'plugin', 'function', 'workflow', 'agent', 'evaluation', 'chat-app', 'lessons']
47
+ export const PUSH_KINDS: readonly SyncKind[] = ['blueprint', 'dataset', 'skill', 'knowledge', 'plugin', 'function', 'workflow', 'agent', 'evaluation', 'chat-app', 'lessons', 'app']
46
48
 
47
49
  /** Why the other kinds are not proposed, in the reader's words. */
48
50
  export const NOT_PROPOSED: Partial<Record<SyncKind, string>> = {
49
- dataset: 'Datasets aren’t proposed; upload them on the platform',
50
51
  brand: 'The organization’s brand is set on the platform, in Settings; it isn’t proposed from a project',
51
52
  library: 'Library installations are made from the library on the platform; this file only records them',
52
- app: 'An App’s code ships with its own project, and the Functions it may start are set on the platform',
53
53
  }
54
54
 
55
55
  /** Kinds the platform cannot delete, and what to do instead. */
56
56
  const NOT_DELETED: Partial<Record<SyncKind, string>> = {
57
+ app: 'A Custom App is archived on the platform, in its settings; a change request sets its name, icon and the Functions it may start',
58
+ dataset: 'A dataset holds the organization’s records and other workspaces may read it: archive it on the platform (Blueprint, Datasets)',
57
59
  function: 'A Function can’t be deleted in a workspace: switch it off on the platform, or keep its file',
58
60
  lessons: 'An Agent’s rules are retired on the platform, where they are reviewed: a change request only proposes new ones',
59
61
  }
@@ -130,7 +132,7 @@ export interface PushItem {
130
132
  documents?: PushDocuments
131
133
  }
132
134
 
133
- const BLUEPRINT_DIRS = ['semantic-types', 'shared-fields', 'object-types', 'link-types', 'metrics', 'actions'] as const
135
+ const BLUEPRINT_DIRS = ['semantic-types', 'shared-fields', 'object-types', 'link-types', 'metrics', 'actions', 'object-sets'] as const
134
136
 
135
137
  const BLUEPRINT_WORD: Record<string, string> = {
136
138
  'semantic-types': 'semantic type',
@@ -139,6 +141,7 @@ const BLUEPRINT_WORD: Record<string, string> = {
139
141
  'link-types': 'link type',
140
142
  metrics: 'metric',
141
143
  actions: 'Action',
144
+ 'object-sets': 'saved object set',
142
145
  }
143
146
 
144
147
  const SKIP_DIRS = new Set(['node_modules', '.frontera', '.git', 'dist', 'build', 'coverage'])
@@ -238,11 +241,23 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
238
241
  case 'blueprint':
239
242
  paths = [`blueprint/${name}.yaml`, `blueprint/${name}.yml`, `blueprint/${name}.json`]
240
243
  break
244
+ case 'dataset': {
245
+ // Its columns, never its rows (X6): a request carries a blank dataset's declaration.
246
+ const declared = datasetDeclaration(root, name)
247
+ if ('problem' in declared) return { files: {}, notes, problem: declared.problem }
248
+ notes.push(...declared.notes)
249
+ return { files: { [`datasets/${name}.json`]: declared.text }, notes }
250
+ }
251
+ case 'app':
252
+ // Its settings (X6): its code is its own project, deployed from apps/<slug>/, and a release goes with `--release`.
253
+ paths = [`apps/${name}.json`]
254
+ notes.push('Proposes its name, description, icon and the Functions it may start; its code is deployed from its own folder, and `--release` adds the deployment to go live')
255
+ break
241
256
  default:
242
257
  return { files: {}, notes }
243
258
  }
244
259
  // What the last pull wrote for it comes along when it is still here (an agent's files are fixed; the rest may not be).
245
- if (kind !== 'agent' && kind !== 'knowledge') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
260
+ if (kind !== 'agent' && kind !== 'knowledge' && kind !== 'app') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
246
261
  const files: Record<string, string> = {}
247
262
  const here: Record<string, string> = {}
248
263
  const models = projectModels(root)
@@ -294,7 +309,10 @@ export function projectRefs(root: string): Map<string, { kind: SyncKind; name: s
294
309
  // The workspace's own grants beyond read: one Blueprint item, `blueprint/workspace-grants`.
295
310
  if (existsSync(join(root, WORKSPACE_GRANTS_FILE))) add('blueprint', WORKSPACE_GRANTS_NAME)
296
311
  for (const f of dirs('mcp')) if (f.endsWith('.plugin.json') && f !== '.plugin.json') add('plugin', f.slice(0, -'.plugin.json'.length))
312
+ for (const f of dirs('datasets')) if (f.endsWith('.json') && f !== '.json') add('dataset', f.slice(0, -'.json'.length))
297
313
  for (const f of dirs('chat-apps')) if (f.endsWith('.json') && f !== '.json') add('chat-app', f.slice(0, -'.json'.length))
314
+ // A Custom App's settings file (X6); its code is the folder beside it, and its release a request's own file.
315
+ for (const f of dirs('apps')) if (/^[a-z0-9][a-z0-9-]*\.json$/.test(f) && !f.endsWith('.release.json')) add('app', f.slice(0, -'.json'.length))
298
316
  for (const slug of dirs('agents')) if (existsSync(join(root, lessonsPath(slug)))) add('lessons', slug)
299
317
  for (const slug of dirs('agents')) {
300
318
  for (const f of dirs(`agents/${slug}/evals`)) {
@@ -312,6 +330,8 @@ function titleFor(kind: SyncKind, name: string, files: Record<string, string>, k
312
330
 
313
331
  export interface CandidateInput {
314
332
  root: string
333
+ /** The items the person named; a dataset is listed when named, or when an object type the push proposes reads it. */
334
+ named?: readonly string[]
315
335
  plan: PlanItem[]
316
336
  lock: SyncLock | null
317
337
  snapshot: RemoteSnapshot | null
@@ -473,6 +493,8 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
473
493
  else if (status === 'unresolved') block('conflicts', BLOCK_REASON.conflicts)
474
494
  else if (deleted && NOT_DELETED[kind]) block('shape', NOT_DELETED[kind]!)
475
495
  // Never proposed as a deletion: the platform refuses one, since deleting the file changes no grant.
496
+ // A saved object set is saved by Apply, never removed: what else names it would stop resolving (X6).
497
+ else if (deleted && ref.startsWith('blueprint/object-sets/')) block('shape', 'A change request doesn’t remove a saved object set: remove it on the platform, or put its file back')
476
498
  else if (deleted && ref === WORKSPACE_GRANTS_REF) block('shape', `Deleting ${WORKSPACE_GRANTS_FILE} changes no grant: to take read_sensitive away, put the file back and list the type with \`permissions: [read]\``)
477
499
  else if (status === 'deleted-remote') block('workspace-deleted', BLOCK_REASON['workspace-deleted'])
478
500
  else if (deleted) {
@@ -497,12 +519,63 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
497
519
  }
498
520
  out.push(item)
499
521
  }
500
- return out
522
+ // A dataset goes with what reads it: listed every push otherwise, since no pull records it (X6).
523
+ // A platform without `change-request-datasets` refuses the kind, so none is listed for it.
524
+ const takesDatasets = !!snapshot?.capabilities.includes('change-request-datasets')
525
+ const read = new Set(out.filter((i) => i.proposable && i.ref.startsWith('blueprint/object-types/')).flatMap((i) => Object.values(i.files).map(backingDataset)).filter((d): d is string => !!d))
526
+ return out.filter((i) => i.kind !== 'dataset' || (takesDatasets && (read.has(i.name) || input.named?.includes(i.ref))))
527
+ }
528
+
529
+ /** The dataset an object type's file reads (`backing.dataset`), when it names one. */
530
+ function backingDataset(text: string): string | null {
531
+ try {
532
+ const dataset = (parseYaml(text) as { backing?: { dataset?: unknown } } | null)?.backing?.dataset
533
+ return typeof dataset === 'string' ? dataset : null
534
+ } catch {
535
+ return null
536
+ }
537
+ }
538
+
539
+ /**
540
+ * `datasets/<apiName>.json` as a request carries it: a blank declaration of
541
+ * its columns and key (X6). A blank dataset goes as written. A file dataset
542
+ * goes as its columns when the file declares every included column's type
543
+ * and whether it may be empty, so its contract is in the file and not only in
544
+ * the CSV; its rows stay here. A Source dataset is made on the platform.
545
+ */
546
+ export function datasetDeclaration(root: string, name: string): { text: string; notes: string[] } | { problem: string } {
547
+ const path = `datasets/${name}.json`
548
+ const text = readLocal(root, path)
549
+ if (text === null) return { problem: `${path}: no file` }
550
+ let doc: Record<string, unknown>
551
+ try {
552
+ doc = JSON.parse(text) as Record<string, unknown>
553
+ } catch (err) {
554
+ return { problem: `${path} could not be read: ${(err as Error).message}` }
555
+ }
556
+ const kind = doc.kind ?? 'blank'
557
+ const common = {
558
+ apiName: name,
559
+ ...(typeof doc.displayName === 'string' ? { displayName: doc.displayName } : {}),
560
+ ...(typeof doc.description === 'string' ? { description: doc.description } : {}),
561
+ kind: 'blank',
562
+ }
563
+ const key = Array.isArray(doc.keyColumns) && doc.keyColumns.length ? { keyColumns: doc.keyColumns, deterministicKeyConfirmed: doc.deterministicKeyConfirmed === true } : {}
564
+ if (kind === 'blank') return { text: `${JSON.stringify({ ...common, columns: doc.columns, ...key }, null, 2)}\n`, notes: ['Proposes its columns: Apply creates the dataset, with no rows, when the organization has none of this name'] }
565
+ if (kind === 'source') return { problem: 'A Source dataset reads a connected Source: create it on the platform (Blueprint, Datasets)' }
566
+ const included = Array.isArray(doc.includedColumns) ? doc.includedColumns.filter((c): c is string => typeof c === 'string') : []
567
+ const declared = new Map((Array.isArray(doc.columns) ? doc.columns : []).map((c) => [(c as { name?: string }).name, c as { databaseType?: string; nullable?: boolean }]))
568
+ const open = included.filter((c) => typeof declared.get(c)?.databaseType !== 'string' || typeof declared.get(c)?.nullable !== 'boolean')
569
+ if (!included.length || open.length) {
570
+ return { problem: `Its columns come from the CSV${open.length ? ` (${open.slice(0, 5).join(', ')}${open.length > 5 ? '…' : ''})` : ''}: to propose the dataset, give each included column its databaseType and nullable under columns, so the file states the whole contract` }
571
+ }
572
+ const columns = included.map((c) => ({ name: c, databaseType: declared.get(c)!.databaseType, nullable: declared.get(c)!.nullable }))
573
+ return { text: `${JSON.stringify({ ...common, columns, ...key }, null, 2)}\n`, notes: ['Proposes its columns as a blank dataset: its rows (the CSV) stay here, and Apply creates the dataset, empty, when the organization has none of this name'] }
501
574
  }
502
575
 
503
576
  /** Whether pushing these needs the organization key at Apply: any Blueprint item does. */
504
577
  export function needsOrganizationKey(items: Array<{ kind: string; proposable?: boolean }>): boolean {
505
- return items.some((i) => i.kind === 'blueprint' && i.proposable !== false)
578
+ return items.some((i) => (i.kind === 'blueprint' || i.kind === 'dataset') && i.proposable !== false)
506
579
  }
507
580
 
508
581
  // ── What the platform takes ─────────────────────────────────────────────────
@@ -298,7 +298,7 @@ function stripRow(row: Record<string, unknown>): Record<string, unknown> {
298
298
  * evaluator settings, and its work-item statuses and labels.
299
299
  */
300
300
  const AGENT_CONFIG_KEYS = [
301
- 'name', 'description', 'avatarSeed', 'avatarConfig', 'defaultMode', 'utilityMode', 'utilityOverrides', 'agentType', 'agentModeEnabled', 'disableWebBrowsing',
301
+ 'name', 'description', 'avatarSeed', 'avatarConfig', 'defaultMode', 'fallbackMode', 'utilityMode', 'utilityOverrides', 'agentType', 'agentModeEnabled', 'disableWebBrowsing',
302
302
  'plugins', 'features', 'memory', 'voice', 'compression', 'streaming', 'upload', 'planning', 'delegation', 'modelRouting', 'toolPhasing', 'stopConditions',
303
303
  'maxCostPerRun', 'permissions', 'functionGuardrails', 'audit', 'harness', 'statuses', 'defaultStatus', 'defaultLabels',
304
304
  ]
@@ -1453,7 +1453,7 @@ async function readEvalDatasets(ws: SyncClient, first: unknown): Promise<EvalDat
1453
1453
  }
1454
1454
 
1455
1455
  /** The check types the local runner grades in code, as the platform's expectation grading does. */
1456
- const LOCAL_CHECKS = new Set(['contains', 'regex', 'tool_called', 'tool_not_called'])
1456
+ const LOCAL_CHECKS = new Set(['contains', 'not_contains', 'regex', 'tool_called', 'tool_not_called'])
1457
1457
 
1458
1458
  /**
1459
1459
  * A scenario's simulated results and expected actions name Functions and
@@ -1482,7 +1482,7 @@ function scenarioNames(value: unknown, byId: Map<string, { kind: RefKind; name:
1482
1482
 
1483
1483
  export interface EvalCaseOut {
1484
1484
  name: string
1485
- conversation?: Array<{ user: string }>
1485
+ conversation?: Array<{ user: string } | { assistant: string }>
1486
1486
  expected?: string
1487
1487
  expectedContains?: string[]
1488
1488
  checks?: Array<Record<string, unknown>>
@@ -1525,15 +1525,18 @@ export function evalCase(item: EvalItem, name: string, byId: Map<string, { kind:
1525
1525
  for (const e of expectations) {
1526
1526
  const type = String(e.check?.type ?? e.check?.kind ?? '')
1527
1527
  if (e.check && LOCAL_CHECKS.has(type)) {
1528
- if (type === 'contains' && typeof e.check.value === 'string' && e.check.value) contains.push(e.check.value)
1529
- else if (type !== 'contains') checks.push({ ...(typeof e.text === 'string' && e.text ? { text: e.text } : {}), ...e.check, type })
1528
+ // A phrase on the last reply is `expectedContains`; one on a named turn stays a check, with its turn (L3).
1529
+ if (type === 'contains' && typeof e.check.value === 'string' && e.check.value && e.check.turn === undefined) contains.push(e.check.value)
1530
+ else if (type !== 'contains' || e.check.turn !== undefined) checks.push({ ...(typeof e.text === 'string' && e.text ? { text: e.text } : {}), ...e.check, type })
1530
1531
  } else if (typeof e.text === 'string' && e.text.trim()) judged.push(e.text.trim())
1531
1532
  }
1532
1533
  const expected = textOf(item.expectedOutput)
1534
+ // A conversation item (L3): its turns in order, as `local test` sends them.
1535
+ const turns = Array.isArray(input?.turns) ? (input!.turns as Array<{ role?: unknown; text?: unknown }>).flatMap((t) => (typeof t?.text === 'string' && t.text.trim() ? [t.role === 'assistant' ? { assistant: t.text } : { user: t.text }] : [])) : []
1533
1536
  return {
1534
1537
  case: {
1535
1538
  name,
1536
- conversation: [{ user: question }],
1539
+ conversation: turns.filter((t) => 'user' in t).length > 1 ? turns : [{ user: question }],
1537
1540
  ...(contains.length ? { expectedContains: contains } : expected ? { expected } : {}),
1538
1541
  ...(checks.length ? { checks } : {}),
1539
1542
  ...(judged.length ? { judged } : {}),
@@ -1616,7 +1619,7 @@ async function readEvaluations(source: SyncSource, lists: Lists, directory: Dire
1616
1619
  })
1617
1620
  if (conversations) notes.push(`${conversations} case${conversations === 1 ? ' replays' : 's replay'} a real conversation from the workspace, so ${conversations === 1 ? 'it stays' : 'they stay'} on the platform`)
1618
1621
  if (noQuestion) notes.push(`${noQuestion} item${noQuestion === 1 ? '' : 's'} had no question the local runner can send`)
1619
- if (judged) notes.push(`${judged} expectation${judged === 1 ? ' is' : 's are'} graded by the platform’s judge; ${judged === 1 ? 'it is' : 'they are'} kept in the file (\`judged\`) and not graded locally`)
1622
+ if (judged) notes.push(`${judged} expectation${judged === 1 ? ' is' : 's are'} graded by a judge: ${judged === 1 ? 'it is' : 'they are'} kept in the file (\`judged\`), and \`local test\` grades ${judged === 1 ? 'it' : 'them'} with the local runtime’s grading model`)
1620
1623
  if (gold) notes.push(`${gold} gold case${gold === 1 ? '' : 's'} (human-certified)`)
1621
1624
  if (unnamed.size) notes.push(`A scenario names ${unnamed.size} Function${unnamed.size === 1 ? ' or workflow' : 's or workflows'} by a platform id the workspace no longer has`)
1622
1625
  if (ds.totalItems > ds.items.length) notes.push(`The first ${ds.items.length} of ${ds.totalItems} items were read`)
@@ -0,0 +1,173 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { PlatformApi } from '../api/platform-api'
5
+ import { CliError } from '../errors'
6
+ import type { LocalProject } from './local-project'
7
+ import type { LocalCredentials } from './local-state'
8
+ import { caseKey, compareWithLastRun, type LastRun, type RunComparison } from './local-test-baseline'
9
+ import type { CaseResult } from './local-test-runner'
10
+ import { acquireLock } from './local-writers'
11
+
12
+ /**
13
+ * Comparing models on the local runtime (Forge improvement plan L1, T9).
14
+ *
15
+ * `frontera local test --model <slug>` runs the cases with each Agent in
16
+ * scope on that model: its draft's models are swapped for the run and put
17
+ * back after it, whatever happened, so the files and the published versions
18
+ * never change. Every run is kept under `.frontera/local/test-runs/`, with
19
+ * the model it ran on, and `--baseline <run>` compares a run with one kept
20
+ * earlier: which cases the other model made fail, and which it fixed.
21
+ */
22
+
23
+ export interface SavedRun {
24
+ id: string
25
+ at: string
26
+ /** The model `--model` swapped in; null for the Agents' own. */
27
+ model: string | null
28
+ cases: LastRun
29
+ }
30
+
31
+ const DIR = 'test-runs'
32
+ const KEEP = 20
33
+
34
+ /** The draft's models, every one on `slug`; null when the draft names none (it runs on the runtime's default). */
35
+ export function withModel(snapshot: Record<string, unknown>, slug: string): Array<Record<string, unknown>> | null {
36
+ const configs = Array.isArray(snapshot.modelConfigs) ? (snapshot.modelConfigs as Array<Record<string, unknown>>) : []
37
+ if (!configs.length) return null
38
+ return configs.map((c) => ({ ...c, modelSlug: slug }))
39
+ }
40
+
41
+ export interface DraftAccess {
42
+ draft(agentId: string): Promise<{ snapshot: Record<string, unknown> } | null>
43
+ patch(agentId: string, patch: Record<string, unknown>): Promise<unknown>
44
+ }
45
+
46
+ /**
47
+ * Put each Agent's draft on `model`, and return how to put it back. An Agent
48
+ * whose draft names no model is left as it is, and said.
49
+ */
50
+ export async function swapModels(access: DraftAccess, agents: Array<{ slug: string; id: string }>, model: string): Promise<{ swapped: string[]; unchanged: string[]; restore: () => Promise<string[]> }> {
51
+ const before: Array<{ slug: string; id: string; modelConfigs: unknown }> = []
52
+ const unchanged: string[] = []
53
+ try {
54
+ for (const a of agents) {
55
+ const draft = await access.draft(a.id)
56
+ const next = draft ? withModel(draft.snapshot, model) : null
57
+ if (!draft || !next) {
58
+ unchanged.push(a.slug)
59
+ continue
60
+ }
61
+ await access.patch(a.id, { modelConfigs: next })
62
+ before.push({ slug: a.slug, id: a.id, modelConfigs: draft.snapshot.modelConfigs })
63
+ }
64
+ } catch (err) {
65
+ // What was swapped before the failure is put back before it is said.
66
+ for (const b of before.reverse()) await access.patch(b.id, { modelConfigs: b.modelConfigs }).catch(() => {})
67
+ throw err
68
+ }
69
+ return {
70
+ swapped: before.map((b) => b.slug),
71
+ unchanged,
72
+ // Each one tried, so one that fails doesn't keep the rest on the other model; the ones that failed are returned.
73
+ restore: async () => {
74
+ const failed: string[] = []
75
+ for (const b of [...before].reverse()) await access.patch(b.id, { modelConfigs: b.modelConfigs }).catch(() => failed.push(b.slug))
76
+ return failed
77
+ },
78
+ }
79
+ }
80
+
81
+ const runDir = (project: LocalProject) => join(project.stateDir, DIR)
82
+
83
+ /** Keep this run, newest last; the oldest beyond twenty go. */
84
+ export function saveTestRun(project: LocalProject, results: CaseResult[], model: string | null, now = new Date()): SavedRun {
85
+ const at = now.toISOString()
86
+ const id = `${at.replace(/[:.]/g, '-').replace(/Z$/, '')}${model ? `-${model.replace(/[^A-Za-z0-9._-]/g, '_')}` : ''}`
87
+ const cases: LastRun = {}
88
+ for (const r of results) if (!r.notRun) cases[caseKey(r)] = { passed: r.passed, at, ms: r.ms, ...(r.passed || !r.reason ? {} : { reason: r.reason.slice(0, 300) }) }
89
+ const run: SavedRun = { id, at, model, cases }
90
+ mkdirSync(runDir(project), { recursive: true })
91
+ writeFileSync(join(runDir(project), `${id}.json`), `${JSON.stringify(run, null, 2)}\n`)
92
+ for (const old of listTestRuns(project).slice(0, -KEEP)) rmSync(join(runDir(project), `${old}.json`), { force: true })
93
+ return run
94
+ }
95
+
96
+ /** The kept runs' ids, oldest first. */
97
+ export function listTestRuns(project: LocalProject): string[] {
98
+ if (!existsSync(runDir(project))) return []
99
+ return readdirSync(runDir(project)).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -'.json'.length)).sort()
100
+ }
101
+
102
+ /** A kept run by its id, a prefix of it, `last` (the newest), or `last-<model>`; null when none matches. */
103
+ export function readTestRun(project: LocalProject, ref: string): SavedRun | null {
104
+ const ids = listTestRuns(project)
105
+ const read = (id: string | undefined) => {
106
+ if (!id) return null
107
+ try {
108
+ return JSON.parse(readFileSync(join(runDir(project), `${id}.json`), 'utf8')) as SavedRun
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+ if (ref === 'last') return read(ids[ids.length - 1])
114
+ if (ref === 'last-own') return [...ids].reverse().map(read).find((r) => r && r.model === null) ?? null
115
+ if (ref.startsWith('last-')) return [...ids].reverse().map(read).find((r) => r?.model === ref.slice('last-'.length)) ?? null
116
+ const matches = ids.filter((id) => id.startsWith(ref))
117
+ return matches.length === 1 ? read(matches[0]) : null
118
+ }
119
+
120
+ /** This run against a kept one: the cases the change of model (or of anything else) made fail, fixed or left failing. */
121
+ export function compareWithRun(results: CaseResult[], baseline: SavedRun): RunComparison {
122
+ return compareWithLastRun(results, baseline.cases)
123
+ }
124
+
125
+ export function comparisonWords(c: RunComparison, baseline: SavedRun): string {
126
+ const on = baseline.model ? `on ${baseline.model}` : 'on the Agents’ own models'
127
+ return `against run ${baseline.id} (${on}): ${c.newlyFailing.length} newly failing, ${c.fixed.length} fixed, ${c.stillFailing.length} still failing${c.firstRun.length ? `, ${c.firstRun.length} not in that run` : ''}`
128
+ }
129
+
130
+ /**
131
+ * Run `run` with these Agents on `model`, holding the runtime (X9) so no
132
+ * other run tests the swapped drafts, and put each back after, whatever
133
+ * happened. `stuck` names any that could not be put back.
134
+ */
135
+ export async function runOnModel<T>(
136
+ project: LocalProject,
137
+ link: { serviceUrl: string; creds: LocalCredentials },
138
+ slugs: readonly string[],
139
+ model: string,
140
+ run: (on: { swapped: string[]; unchanged: string[] }) => Promise<T>,
141
+ deps: { access?: DraftAccess; agentIds?: () => Promise<Array<{ id?: string; agentId?: string }>> } = {},
142
+ ): Promise<{ value: T; swapped: string[]; unchanged: string[]; stuck: string[] }> {
143
+ const release = acquireLock(project.stateDir, 'local test --model')
144
+ try {
145
+ const platform = new PlatformApi(link.serviceUrl, link.creds.workspaceKey ?? link.creds.orgKey, link.creds.workspaceId)
146
+ const access = deps.access ?? { draft: (id: string) => platform.agentDraft(id), patch: (id: string, patch: Record<string, unknown>) => platform.patchAgentDraft(id, patch) }
147
+ let swap: Awaited<ReturnType<typeof swapModels>>
148
+ try {
149
+ const listed = await (deps.agentIds ?? (() => platform.allAgents() as Promise<Array<{ id?: string; agentId?: string }>>))()
150
+ const agents = [...new Set(slugs)].flatMap((slug) => {
151
+ const row = listed.find((a) => a.agentId === slug)
152
+ return row?.id ? [{ slug, id: row.id }] : []
153
+ })
154
+ swap = await swapModels(access, agents, model)
155
+ } catch (err) {
156
+ throw new CliError(`the Agents could not be put on ${model} for the run: ${(err as Error).message}`, { code: 'LOCAL_TEST_MODEL', hint: '`frontera local models` lists the models this runtime has' })
157
+ }
158
+ let stuck: string[] = []
159
+ try {
160
+ const value = await run({ swapped: swap.swapped, unchanged: swap.unchanged })
161
+ stuck = await swap.restore()
162
+ return { value, swapped: swap.swapped, unchanged: swap.unchanged, stuck }
163
+ } catch (err) {
164
+ await swap.restore()
165
+ throw err
166
+ }
167
+ } finally {
168
+ release()
169
+ }
170
+ }
171
+
172
+ export const stuckWords = (stuck: readonly string[], model: string) =>
173
+ `${stuck.join(', ')} ${stuck.length === 1 ? 'is' : 'are'} still on ${model}: run \`frontera local dev --once --reapply\` to put the files’ models back`