@frontera-sdk/cli 1.52.42 → 1.52.44

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.52.42",
3
+ "version": "1.52.44",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.52.42",
44
- "@frontera-sdk/functions": "1.52.42",
43
+ "@frontera-sdk/core": "1.52.44",
44
+ "@frontera-sdk/functions": "1.52.44",
45
45
  "ai": "^6.0.116",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.52.42",
52
+ "@frontera-sdk/forge-contracts": "1.52.44",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -26,6 +26,7 @@ import { agentFilesProblem, caseConcurrency, findCaseFiles, runCases, selectCase
26
26
  import { findWorkflowTestFiles, recordWorkflowTestRun, runWorkflowTests, workflowFileState } from '../../local/local-workflow-tests'
27
27
  import { vendoredSdkLine, vendoredSdkState } from '../../local/local-vendored-sdk'
28
28
  import { withFix } from '../../local/fix-words'
29
+ import { runtimeKeyCustodianProblem } from '../../local/local-key-custodian'
29
30
  import { ensureAppPackages } from '../../project-bootstrap'
30
31
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
31
32
 
@@ -53,8 +54,10 @@ import { flagBool, flagString, type Command, type CommandContext } from '../type
53
54
  * case with `realActions` or a `decision`), so after the apply the database
54
55
  * is kept as a baseline (`local-db-snapshot.ts`, taken again only when what
55
56
  * is applied changed), and after the tests it is restored: each check starts
56
- * from the data the project's files make. `--keep-state` keeps what the tests
57
- * wrote, to look at it.
57
+ * from the data the project's files make. The run history (conversations,
58
+ * runs, evaluations) is kept across it (`local-history-carry.ts`), so one
59
+ * check's results can be compared with the last. `--keep-state` keeps what the
60
+ * tests wrote, to look at it.
58
61
  *
59
62
  * Knowledge documents are read by a background job whose work lands in the
60
63
  * database: before the baseline and the tests, the check waits (bounded)
@@ -373,8 +376,13 @@ async function check(ctx: CommandContext) {
373
376
  continue
374
377
  }
375
378
  if (step === 'start') {
376
- if (running(project)) record({ step, status: 'skipped', note: 'the runtime is already running', ms: 0 })
377
- else await command(step, root, self(['local', 'start']), 'frontera local start')
379
+ if (running(project)) {
380
+ // Running, with keys it can't read: every later step would fail on a secret or an Action record.
381
+ const state = readState(project)!
382
+ const keyProblem = await runtimeKeyCustodianProblem(`http://127.0.0.1:${state.ports.service}`)
383
+ if (keyProblem) record({ step, status: 'failed', note: keyProblem, ms: 0 })
384
+ else record({ step, status: 'skipped', note: 'the runtime is already running', ms: 0 })
385
+ } else await command(step, root, self(['local', 'start']), 'frontera local start')
378
386
  continue
379
387
  }
380
388
  if (step === 'apply') {
@@ -560,10 +568,9 @@ async function check(ctx: CommandContext) {
560
568
  })
561
569
  recordRun(project, results)
562
570
  const bad = results.filter((r) => !r.passed && !r.notRun)
563
- // Each failing case's conversation, last turn's trace and reply, read before the rollback takes the conversation.
571
+ // Each failing case's conversation, last turn's trace and reply, read before the rollback (which keeps the conversation).
564
572
  const evidence = await failingCaseEvidence(project, results)
565
- const rollsBack = baselineReady && !!testData && !testData.kept
566
- for (const [i, e] of evidence) ctx.output.note(` ${results[i]!.agent} · ${results[i]!.name}: ${evidenceLine(e, { rolledBack: rollsBack })}`)
573
+ for (const [i, e] of evidence) ctx.output.note(` ${results[i]!.agent} · ${results[i]!.name}: ${evidenceLine(e)}`)
567
574
  const notRun = notRunGroups(results)
568
575
  for (const g of notRun) {
569
576
  if (g.skipped) ctx.output.note(` – ${g.skipped} more case${g.skipped === 1 ? '' : 's'} not sent: ${g.what}`)
@@ -604,9 +611,10 @@ async function check(ctx: CommandContext) {
604
611
  const state = readState(project)
605
612
  if (state?.credentials && running(project)) {
606
613
  const restored = await restoreBaseline(project, state.credentials.organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) })
607
- // Traces live outside the platform database: a failing case's trace outlives the rollback, its conversation does not.
614
+ // The run history is kept across the rollback; when it couldn't be, a failing case's conversation went with it (its trace stays: traces live outside the database).
608
615
  const traced = steps.some((st) => st.failures?.some((f) => f.traceId))
609
- testData = { ...testData, rolledBack: restored.ok, ms: restored.ms, note: restored.ok ? `the tests’ writes were rolled back: ${restored.note}${traced ? '; the failing cases’ conversations went with them, their traces stay (trace_get, Studio’s Traces)' : ''}` : `the tests’ writes were not rolled back: ${restored.note}` }
616
+ const historyLost = restored.history?.mode === 'keep' && !restored.history.ok
617
+ testData = { ...testData, rolledBack: restored.ok, ms: restored.ms, note: restored.ok ? `the tests’ writes were rolled back: ${restored.note}${traced && historyLost ? '; the failing cases’ conversations went with them, their traces stay (trace_get, Studio’s Traces)' : ''}` : `the tests’ writes were not rolled back: ${restored.note}` }
610
618
  ctx.output.note(`── test data: ${testData.note}${restored.ok ? ' (`--keep-state` keeps them)' : ''}`)
611
619
  // The documents the baseline holds unread are read again: their reading went with the database the restore replaced.
612
620
  const ingest = restored.ok ? localIngestApi(`http://127.0.0.1:${state.ports.service}`, state.credentials) : null
@@ -59,6 +59,7 @@ import { localSchema } from './schema'
59
59
  import { localSync } from './sync'
60
60
  import { keylessEmbeddingsStatus, KEYLESS_RETRIEVAL_NOTE } from '../../local/local-org-settings'
61
61
  import { withFix } from '../../local/fix-words'
62
+ import { isKeyCustodianProblem, KEY_CUSTODIAN_FIX, runtimeKeyCustodianProblem } from '../../local/local-key-custodian'
62
63
 
63
64
  /**
64
65
  * `frontera local …`: Frontera's runtimes on this machine, for FDEs and the coding agents working with them.
@@ -302,7 +303,8 @@ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string
302
303
  if (state.phase !== 'ready') {
303
304
  throw new CliError(`the local runtime did not start: ${state.error ?? state.phase}`, {
304
305
  code: 'LOCAL_START',
305
- hint: 'see `frontera local logs <component>`; fix the cause and run `frontera local start` again',
306
+ // Keys a restart can't bring back: the error carries its fix, a reset.
307
+ hint: isKeyCustodianProblem(state.error) ? KEY_CUSTODIAN_FIX : 'see `frontera local logs <component>`; fix the cause and run `frontera local start` again',
306
308
  })
307
309
  }
308
310
  const profile = await registerLocalProfile(project, state)
@@ -411,6 +413,8 @@ async function status(ctx: CommandContext) {
411
413
  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
414
  // What the runtime holds with no file in the project (an Agent whose folder was deleted): said with its fix.
413
415
  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) ?? []
416
+ // Encryption keys the runtime can't read (data keys a hosted OpenBao locked): every secret and governed Action record fails until a reset.
417
+ const keyProblem = state.phase === 'ready' && isAlive(state.supervisorPid) ? await runtimeKeyCustodianProblem(`http://127.0.0.1:${state.ports.service}`) : null
414
418
  return {
415
419
  data: {
416
420
  ...rest,
@@ -431,9 +435,11 @@ async function status(ctx: CommandContext) {
431
435
  ...(stuck.length ? { knowledgeStuck: stuck } : {}),
432
436
  // On the runtime, not in the files: each with what fixes it (`local dev` archives an Agent or workflow whose file was deleted).
433
437
  ...(orphans.length ? { notInProject: orphans } : {}),
438
+ ...(keyProblem ? { keyCustodian: { problem: keyProblem, fix: KEY_CUSTODIAN_FIX } } : {}),
434
439
  },
435
440
  text: [
436
441
  renderStatus({ ...state, components }, project, undefined, live, setIds, personas, secrets, liveApps ?? null, down),
442
+ ...(keyProblem ? ['', 'Encryption keys', ` ✕ ${keyProblem}`] : []),
437
443
  ...(stuck.length ? ['', 'Knowledge', ...stuck.map((k) => ` warning: ${k.problem}`)] : []),
438
444
  ...(orphans.length ? ['', 'On the runtime, not in the project', ...orphans.map((o) => ` warning: ${orphanLine(o)}`)] : []),
439
445
  ...(devServers.length ? ['', 'App dev servers', ...devServers.map((s) => ` ${devServerLine(s)}`)] : []),
@@ -1,6 +1,7 @@
1
1
  import { CliError, UsageError } from '../../errors'
2
2
  import { appsLostWords } from '../../local/local-app-restore'
3
3
  import { BASELINE_DB, readBaseline, restoreBaseline, saveBaseline, secretsWords } from '../../local/local-db-snapshot'
4
+ import { historyWords } from '../../local/local-history-carry'
4
5
  import { localIngestApi, settleKnowledge } from '../../local/local-knowledge-ingestion'
5
6
  import { localProject } from '../../local/local-project'
6
7
  import { projectSecretLines } from '../../local/local-project-secrets'
@@ -15,7 +16,8 @@ import { flagBool, type Command } from '../types'
15
16
  * same, by hand: save after setting up data to come back to, restore to throw
16
17
  * away what was written since. The catalog of managed datasets, object
17
18
  * storage and local traces are not part of it. Workspace secrets are
18
- * configuration, not data: a restore keeps them as they are now, unless
19
+ * configuration, not data, and the run history (conversations, runs,
20
+ * evaluations) is what happened: a restore keeps both as they are now, unless
19
21
  * `--exact` puts the baseline's back too.
20
22
  */
21
23
 
@@ -31,6 +33,7 @@ export const localSnapshot: Command = {
31
33
  details: [
32
34
  'save keeps the database as it is now as the baseline, replacing the one before. restore puts the database back as the baseline holds it; the service, job engine and runner keep running and reconnect, and the command returns once the service reaches the database again (it waits up to 30 seconds).',
33
35
  'Workspace secrets are configuration, not data: restore keeps them as they are now (set, changed or deleted since the baseline), and `local check` keeps them across its rollback too. `--exact` puts the baseline\'s secrets back as well.',
36
+ 'The run history is kept the same way: conversations (an Agent test case\'s included), Agent sessions and runs, workflow and Function runs and evaluation runs are as they were before the restore, so results can be compared across runs; the records those runs wrote are rolled back. A history row that names a record the restore took away is left out and counted. `--exact` puts the baseline\'s history back as well.',
34
37
  'Items applied after the baseline count as not applied once it is restored: the next `frontera local dev` applies them again (a running one does so at once).',
35
38
  'Knowledge documents are read by a background job. save waits (up to two minutes) until no document is still being read, so the baseline never holds one half-read; restore queues any document the baseline holds unread to be read again, and waits for it.',
36
39
  ],
@@ -64,7 +67,7 @@ export const localSnapshot: Command = {
64
67
  }
65
68
  const exact = flagBool(ctx, 'exact')
66
69
  const restored = await ctx.output.task('Restoring the database from the baseline', () =>
67
- restoreBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, { secrets: exact ? 'baseline' : 'keep' }),
70
+ restoreBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(`http://127.0.0.1:${state.ports.service}`) }, { secrets: exact ? 'baseline' : 'keep', history: exact ? 'baseline' : 'keep' }),
68
71
  )
69
72
  if (!restored.ok) throw new CliError(restored.note, { code: 'LOCAL_SNAPSHOT_FAILED', hint: 'see `frontera local logs postgres` and `frontera local logs service`' })
70
73
  // The documents the baseline holds unread are read again: their reading went with the database the restore replaced.
@@ -85,6 +88,8 @@ export const localSnapshot: Command = {
85
88
  ...(lost.length ? { appsLost: lost.map((a) => ({ app: a.name, dir: a.dir, deployment: a.lost, baselineDeployment: a.back })) } : {}),
86
89
  // Names and counts only: what became of the workspace secrets.
87
90
  workspaceSecrets: restored.secrets?.mode === 'keep' ? { kept: restored.secrets.ok, count: restored.secrets.kept, setAgain: restored.secrets.notKept, ...(restored.secrets.problem ? { problem: restored.secrets.problem } : {}) } : { kept: false, exact: exact },
91
+ // Counts only: what became of the run history.
92
+ runHistory: restored.history?.mode === 'keep' ? { kept: restored.history.ok, rows: restored.history.rows, notKept: restored.history.notKept, ...(restored.history.problem ? { problem: restored.history.problem } : {}) } : { kept: false, exact },
88
93
  ...(secrets.length ? { secrets } : {}),
89
94
  ...(knowledge.length ? { knowledge: knowledge.map((l) => l.replace(/^warning:\s*/, '')) } : {}),
90
95
  },
@@ -92,6 +97,7 @@ export const localSnapshot: Command = {
92
97
  `Restored the database from the baseline: what was written since is gone (${restored.ms} ms).${items ? ` ${items} item${items === 1 ? '' : 's'} applied after the baseline will apply again on the next \`frontera local dev\`.` : ''}`,
93
98
  ...(lost.length ? [` ${appsLostWords(lost).replace(/^./, (c) => c.toUpperCase())}.`] : []),
94
99
  ...(restored.secrets ? [` ${secretsWords(restored.secrets).replace(/^./, (c) => c.toUpperCase())}${restored.secrets.mode === 'keep' && restored.secrets.ok && !restored.secrets.notKept.length ? ': secrets are configuration, not test data (`--exact` restores the baseline’s).' : '.'}`] : []),
100
+ ...(restored.history ? [` ${historyWords(restored.history).replace(/^./, (c) => c.toUpperCase())}.`] : []),
95
101
  ...secrets.map((l) => ` warning: ${l}`),
96
102
  ...knowledge.map((l) => ` ${l}`),
97
103
  ].join('\n'),
@@ -12,8 +12,10 @@ import { adoptProposedLessons } from '../../local/local-lessons'
12
12
  import { localProject, type LocalProject } from '../../local/local-project'
13
13
  import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
14
14
  import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
15
- import { getSyncKey, readLink, type SyncLink } from '../../local/local-sync-link'
15
+ import { getSyncKey, readLink, setModelMapping, type SyncLink } from '../../local/local-sync-link'
16
16
  import { itemTitle } from '../../local/local-sync-names'
17
+ import { checkBody, markMissingModels, markRefused, type WorkspaceModel } from '../../local/local-sync-precheck'
18
+ import { rows } from '../../local/local-sync-remote'
17
19
  import { evalsDetail, evalsLine, lastRender, sessionScreenshots } from '../../local/local-request-evidence'
18
20
  import { readAppliedRun, readLastRun, recordApplied, recordRun } from '../../local/local-test-baseline'
19
21
  import { agentFilesProblem, runCases, selectCaseFiles } from '../../local/local-test-runner'
@@ -330,6 +332,9 @@ function itemJson(item: PushItem) {
330
332
  proposable: item.proposable,
331
333
  blocked: item.blocked,
332
334
  reason: item.reason,
335
+ ...(item.fix ? { fix: item.fix } : {}),
336
+ ...(item.sample ? { sample: true } : {}),
337
+ ...(item.missingModels?.length ? { missingModels: item.missingModels } : {}),
333
338
  files: item.diff,
334
339
  notes: item.notes,
335
340
  ...(item.assets ? { assets: item.assets.map((a) => ({ path: a.path, bytes: a.bytes, change: a.change })) } : {}),
@@ -404,8 +409,41 @@ async function deletionUsers(project: LocalProject, link: SyncLink, items: PushI
404
409
  }
405
410
  }
406
411
 
412
+ /**
413
+ * The workspace's checks for what would be proposed, run before anything is
414
+ * sent (`POST /v1/change-requests/check`); each item they refuse is blocked
415
+ * with its reason and fix. A platform without the check leaves them to
416
+ * Propose: `checked` says which.
417
+ */
418
+ async function precheck(api: Platform, items: PushItem[]): Promise<{ checked: boolean; refused: string[]; problem?: string }> {
419
+ const body = checkBody(items)
420
+ if (!body.length) return { checked: true, refused: [] }
421
+ try {
422
+ const answer = await api.call<{ checks?: unknown[] }>('POST', '/v1/change-requests/check', { items: body }, { timeoutMs: 120_000 })
423
+ return { checked: true, refused: markRefused(items, checksOf(answer)) }
424
+ } catch (err) {
425
+ if (err instanceof CliError && (err.code === 'NOT_FOUND' || err.code === 'SYNC_CHECKS')) {
426
+ // An older platform has no check; a refusal of the check itself names the items as the push would.
427
+ if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details)) }
428
+ return { checked: false, refused: [] }
429
+ }
430
+ return { checked: false, refused: [], problem: (err as Error).message.split('\n')[0] }
431
+ }
432
+ }
433
+
434
+ /** The workspace's chat models, offered in place of one only this computer has; empty when the platform can't list them. */
435
+ async function workspaceModels(api: Platform): Promise<WorkspaceModel[]> {
436
+ try {
437
+ const answer = await api.call<unknown>('GET', '/v1/sync/models')
438
+ return rows<{ slug?: string; name?: string; kind?: string }>(answer).flatMap((m) => (m.slug && (m.kind ?? 'text') === 'text' ? [{ slug: m.slug, name: m.name ?? m.slug, kind: m.kind ?? 'text' }] : []))
439
+ } catch {
440
+ return []
441
+ }
442
+ }
443
+
407
444
  function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map()): string {
408
- const ok = items.filter((i) => i.proposable)
445
+ const ok = items.filter((i) => i.proposable && !i.sample)
446
+ const samples = items.filter((i) => i.proposable && i.sample)
409
447
  const not = items.filter((i) => !i.proposable)
410
448
  const name = link.workspaceName ?? link.workspace
411
449
  const lines = [
@@ -413,7 +451,8 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
413
451
  // Each item's notes under it.
414
452
  ...ok.flatMap((i) => [` [x] ${pushItemLine(i)}`, ...(usedBy.has(i.ref) ? [` ! Still used by ${usedBy.get(i.ref)!.join(', ')}: applying would be refused`] : []), ...i.notes.map((n) => ` · ${n}`)]),
415
453
  ]
416
- if (not.length) lines.push('', 'Not proposed:', ...not.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`))
454
+ if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}`))
455
+ if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
417
456
  if (orgNote) lines.push('', orgNote)
418
457
  if (!available) lines.push('', 'This platform does not take change requests yet.')
419
458
  if (ok.length && available) lines.push('', 'Propose with `frontera local sync push -t "<title>"`, or name items to propose only those.')
@@ -443,6 +482,36 @@ async function releaseItems(api: Platform, asked: Array<{ slug: string; deployme
443
482
  return out
444
483
  }
445
484
 
485
+ /**
486
+ * `frontera local sync map-model <local model> <workspace model>`: the
487
+ * workspace's model a model only this computer has goes as when proposed
488
+ * (`sync.models` in frontera.yaml); the files keep naming the local one.
489
+ * `--remove` stops mapping it; with no model, the mappings are listed.
490
+ */
491
+ export async function syncMapModel(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
492
+ const project = localProject(ctx.cwd)
493
+ const link = needLink(project)
494
+ const [local, workspace] = ctx.positional.slice(1)
495
+ const name = link.workspaceName ?? link.workspace
496
+ if (!local) {
497
+ const models = link.models ?? {}
498
+ const lines = Object.entries(models).map(([from, to]) => ` ${from} → ${to}`)
499
+ return { data: { models }, text: lines.length ? [`Models proposed to ${name} as the workspace’s:`, ...lines].join('\n') : `No models mapped. \`frontera local sync push --preview\` names any model ${name} doesn’t have.` }
500
+ }
501
+ if (flagBool(ctx, 'remove')) {
502
+ setModelMapping(project.root, local, null)
503
+ return { data: { local, workspace: null }, text: `${local} is no longer mapped: an item that names it isn’t proposed until it is mapped again.` }
504
+ }
505
+ if (!workspace) throw new UsageError('name the workspace model it goes as', `frontera local sync map-model ${local} <workspace model>; \`push --preview\` lists ${name}’s models`)
506
+ const offered = await workspaceModels(platformFor(project, link))
507
+ if (offered.length && !offered.some((m) => m.slug === workspace)) {
508
+ throw new UsageError(`${name} has no chat model ${workspace}`, `choose one of: ${offered.map((m) => m.slug).slice(0, 12).join(', ')}${offered.length > 12 ? ', …' : ''}`)
509
+ }
510
+ setModelMapping(project.root, local, workspace)
511
+ const label = offered.find((m) => m.slug === workspace)?.name ?? workspace
512
+ return { data: { local, workspace }, text: `${local} goes to ${name} as ${label} when proposed; the project keeps using ${local} here (sync.models in frontera.yaml).` }
513
+ }
514
+
446
515
  export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
447
516
  const project = localProject(ctx.cwd)
448
517
  const link = needLink(project)
@@ -462,6 +531,11 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
462
531
  ? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(platformFor(project, link), documentNames, remote.items) }
463
532
  : undefined
464
533
  const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, ...(documents ? { documents } : {}) })
534
+ // Before anything is sent: models only this computer has, then the workspace's own checks.
535
+ const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
536
+ const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
537
+ const api = platformFor(project, link)
538
+ const checked = available ? await precheck(api, all) : { checked: false, refused: [] as string[] }
465
539
  const kept = currentRequest(readRequests(project.root, link.workspace))
466
540
  const updating = kept && isOpen(kept.state) ? kept : null
467
541
  const orgStored = !!getSyncKey(project, 'organization')
@@ -474,6 +548,8 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
474
548
  if (unknown.length) throw new UsageError(`nothing to propose in ${unknown.join(', ')}: ${unknown.length === 1 ? 'it matches' : 'they match'} the workspace, or the project has no such item`, 'list what can be proposed with `frontera local sync push --preview`')
475
549
  chosen = named.map((r) => byRef.get(r)!)
476
550
  } else chosen = all
551
+ // Samples from setting up the project go only when named.
552
+ const leftSamples = named.length ? [] : chosen.filter((i) => i.proposable && i.sample)
477
553
  const orgNote = needsOrganizationKey(chosen) ? (orgStored ? 'Blueprint changes are applied with the organization key stored for this project.' : 'Blueprint changes need the organization key to apply: connect again with `--org-key -` before applying.') : null
478
554
 
479
555
  if (preview) {
@@ -484,7 +560,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
484
560
  available,
485
561
  request: updating ? { id: updating.id, title: updating.title, state: updating.state, items: updating.refs.length, refs: updating.refs } : null,
486
562
  items: chosen.map((i) => ({ ...itemJson(i), ...(usedBy.has(i.ref) ? { usedBy: usedBy.get(i.ref) } : {}) })),
487
- counts: { proposable: chosen.filter((i) => i.proposable).length, blocked: chosen.filter((i) => !i.proposable).length },
563
+ counts: { proposable: chosen.filter((i) => i.proposable && !i.sample).length, samples: leftSamples.length, blocked: chosen.filter((i) => !i.proposable).length },
564
+ // Whether the workspace's checks ran before proposing; a platform without them checks at Propose.
565
+ checked: checked.checked,
566
+ ...(('problem' in checked && checked.problem) ? { checkProblem: checked.problem } : {}),
567
+ models: {
568
+ missing: [...missing].map(([slug, refs]) => ({ slug, refs })),
569
+ workspace: missing.size ? await workspaceModels(api) : [],
570
+ mapped: link.models ?? {},
571
+ },
488
572
  organizationKey: { needed: needsOrganizationKey(chosen), stored: orgStored },
489
573
  fetchedAt: snapshot.fetchedAt,
490
574
  },
@@ -503,12 +587,11 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
503
587
  details: { blocked: stopping.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })) },
504
588
  })
505
589
  }
506
- const going = chosen.filter((i) => i.proposable)
590
+ const going = chosen.filter((i) => i.proposable && !leftSamples.includes(i))
507
591
  const releaseAsks = parseReleases(flagString(ctx, 'release'))
508
592
  if (!going.length && !releaseAsks.length) throw new CliError('nothing to propose: nothing changed here since the last pull', { code: 'SYNC_NOTHING_TO_PUSH', hint: 'change an item in the project first; `frontera local sync status` shows where it stands' })
509
593
  const title = flagString(ctx, 'title')?.trim()
510
594
  const description = flagString(ctx, 'description')
511
- const api = platformFor(project, link)
512
595
  // The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
513
596
  let target = updating
514
597
  if (target) {
@@ -554,14 +637,16 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
554
637
  request: { id: view.request.id, title: view.request.title, state: view.request.state, items: view.items.length, refs: view.items.map((i) => i.ref) },
555
638
  pushed: [...going.map((i) => i.ref), ...releases.map((r) => r.ref)],
556
639
  releases: releases.map((r) => ({ ref: r.ref, deployment: r.deployment })),
557
- notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })),
640
+ notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })),
641
+ samples: leftSamples.map((i) => i.ref),
558
642
  organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
559
643
  },
560
644
  text: [
561
645
  `${target ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${link.workspaceName ?? link.workspace}: ${plural(view.items.length, 'item')}.`,
562
646
  ...going.map((i) => ` ${pushItemLine(i)}`),
563
647
  ...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
564
- ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`)] : []),
648
+ ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
649
+ ...(leftSamples.length ? ['', `Left out: ${plural(leftSamples.length, 'sample')} from setting up the project (${leftSamples.map((i) => i.title).join(', ')}); name ${leftSamples.length === 1 ? 'it' : 'them'} to propose`] : []),
565
650
  ...(orgNote ? ['', orgNote] : []),
566
651
  '',
567
652
  'Apply it with `frontera local sync request apply`; see it with `frontera local sync request`.',
@@ -812,7 +897,8 @@ async function apply(ctx: CommandContext) {
812
897
  if (force && !reason) throw new UsageError('--force needs --reason', 'frontera local sync request apply --force --reason "why it applies without its required checks"')
813
898
  let answer: View
814
899
  try {
815
- answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
900
+ // The key in the body: the platform's edge strips `x-frontera-*` headers a client sends. The header stays for an older platform.
901
+ answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}), ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
816
902
  } catch (err) {
817
903
  if (err instanceof CliError && err.code === 'SYNC_APPLY_GATED') {
818
904
  const gate = (err.details as { gate?: Gate } | undefined)?.gate
@@ -35,6 +35,8 @@ import {
35
35
  type SyncKind,
36
36
  } from '../../local/local-sync-plan'
37
37
  import { readWorkspace, type SyncSource } from '../../local/local-sync-remote'
38
+ import { rememberLocalCatalog } from '../../local/local-sync-models'
39
+ import { runningTarget, runtimeCatalog } from '../../local/local-providers'
38
40
  import { buildStatusText, projectBuildSummary } from '../../local/local-build-summary'
39
41
  import { kindTitle } from '../../local/local-sync-names'
40
42
  import { describeUnresolved } from '../../local/local-sync-refs'
@@ -59,7 +61,7 @@ import {
59
61
  } from '../../local/local-sync-snapshot'
60
62
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
61
63
  import { currentRequest, readRequests } from '../../local/local-sync-push'
62
- import { syncPush, syncRequest } from './sync-request'
64
+ import { syncMapModel, syncPush, syncRequest } from './sync-request'
63
65
 
64
66
  /**
65
67
  * `frontera local sync`: a local project's link to one platform workspace.
@@ -231,7 +233,16 @@ function sourceIfKeyed(project: LocalProject, link: SyncLink): SyncSource | unde
231
233
  * unchanged copies only, `online` from the platform too, at the lock's
232
234
  * revision. `bases` says what was found and what was not.
233
235
  */
236
+ /** This computer's model catalog, kept while its runtime runs, so models are matched by what they are offline too. */
237
+ async function refreshLocalCatalog(project: LocalProject): Promise<void> {
238
+ const target = runningTarget(project.root)
239
+ if (!target) return
240
+ const models = await runtimeCatalog(target).catch(() => null)
241
+ if (models?.length) rememberLocalCatalog(project.root, models)
242
+ }
243
+
234
244
  export async function planFor(project: LocalProject, link: SyncLink, opts: { kinds?: Set<SyncKind>; drafts?: Set<string>; fetch?: 'auto' | 'always' | 'never'; repair?: 'offline' | 'online' } = {}) {
245
+ await refreshLocalCatalog(project)
235
246
  const local = localItems(project).filter((l) => !opts.kinds || opts.kinds.has(l.kind))
236
247
  const readFromBase = (path: string) => readBase(project.root, path)
237
248
  const noBases: BaseRepair[] = []
@@ -397,8 +408,12 @@ async function checkKeys(platform: string, keys: { workspace: string; organizati
397
408
  org = syncClient(platform, keys.organization)
398
409
  const orgMe = await org.find<WhoAmI>('/v1/whoami').catch(() => null)
399
410
  if (orgMe?.orgId && orgMe.orgId !== me.orgId) throw new CliError('the organization key belongs to another organization than the workspace', { code: 'USAGE', hint: 'use a key from the organization that owns the workspace' })
400
- // The lifecycle is what an organization key is for here; reading it proves the key.
401
- await org.get('/v1/blueprint/lifecycle')
411
+ // The lifecycle is what an organization key is for here; reading it proves the key. The read is authorized before
412
+ // the lookup, so a 404 (an organization that has never published a Blueprint: no lifecycle yet) proves it too.
413
+ await org.find('/v1/blueprint/lifecycle').catch((err: Error) => {
414
+ if (err instanceof CliError && (err.code === 'UNAUTHORIZED' || err.code === 'FORBIDDEN')) throw err
415
+ throw new CliError(`the organization key couldn’t read the organization’s Blueprint: ${err.message.split('\n')[0]}`, { code: 'SYNC_ORG_KEY_UNREADABLE', hint: 'check the key is a current organization key (sk-org-…) for this platform, then connect again' })
416
+ })
402
417
  }
403
418
  return { me, identity }
404
419
  }
@@ -425,6 +440,8 @@ const connect = async (ctx: CommandContext) => {
425
440
  organization: keys.organization ? me.orgId! : previous?.organization,
426
441
  organizationName: identity?.organization.name ?? previous?.organizationName,
427
442
  blueprint: (blueprintFlag as SyncLink['blueprint'] | undefined) ?? (previous?.blueprint === 'organization' && keys.organization ? 'organization' : 'workspace'),
443
+ // The models mapped for this workspace stay; another workspace has its own catalog.
444
+ ...(previous?.models && previous.workspace === me.workspaceId ? { models: previous.models } : {}),
428
445
  }
429
446
  // A folder that is not a project yet gets the project file the link lives in.
430
447
  if (!existsSync(projectFile(project.root))) writeFileSync(projectFile(project.root), `# A Frontera project. \`frontera local dev\` applies agents/, workflows/, functions/ and blueprint/.\nname: ${basename(project.root)}\n`)
@@ -948,6 +965,7 @@ const SUBCOMMANDS: Record<string, (ctx: CommandContext) => Promise<{ data: unkno
948
965
  // Called through, not referenced: `sync-request.ts` imports this module back.
949
966
  push: (ctx) => syncPush(ctx),
950
967
  request: (ctx) => syncRequest(ctx),
968
+ 'map-model': (ctx) => syncMapModel(ctx),
951
969
  }
952
970
 
953
971
  export const localSync: Command = {
@@ -955,8 +973,8 @@ export const localSync: Command = {
955
973
  noun: 'local',
956
974
  verb: 'sync',
957
975
  args: [
958
- { name: 'action', required: true, description: 'connect | fetch | status | pull | diff | resolve | push | request | disconnect' },
959
- { name: 'items', required: false, description: 'kind/name … to pull or push, the one to diff or resolve, or show | apply | close after request' },
976
+ { name: 'action', required: true, description: 'connect | fetch | status | pull | diff | resolve | push | request | map-model | disconnect' },
977
+ { name: 'items', required: false, description: 'kind/name … to pull or push, the one to diff or resolve, show | apply | close after request, or the local and workspace models after map-model' },
960
978
  ],
961
979
  flags: {
962
980
  platform: 'string',
@@ -994,6 +1012,7 @@ export const localSync: Command = {
994
1012
  renders: 'boolean',
995
1013
  screenshots: 'string',
996
1014
  trace: 'string',
1015
+ remove: 'boolean',
997
1016
  },
998
1017
  aliases: { t: 'title', d: 'description' },
999
1018
  summary: 'Link the project to a platform workspace, pull and merge what you choose, and propose changes back',
@@ -1024,6 +1043,8 @@ export const localSync: Command = {
1024
1043
  'frontera local sync request apply',
1025
1044
  'frontera local sync request apply --force --reason "hotfix, checked by hand"',
1026
1045
  'frontera local sync request close',
1046
+ 'frontera local sync map-model llama-4-70b claude-haiku-5-5',
1047
+ 'frontera local sync map-model llama-4-70b --remove',
1027
1048
  'frontera local sync disconnect --forget-link',
1028
1049
  ],
1029
1050
  offline: true,
@@ -1032,7 +1053,7 @@ export const localSync: Command = {
1032
1053
  const action = ctx.positional[0]
1033
1054
  const run = action ? SUBCOMMANDS[action] : undefined
1034
1055
  const named = action ? `\`${action}\`` : '(none given)'
1035
- if (!run) throw new UsageError(`unknown sync action ${named}`, 'use connect, fetch, status, pull, diff, resolve, push, request or disconnect')
1056
+ if (!run) throw new UsageError(`unknown sync action ${named}`, 'use connect, fetch, status, pull, diff, resolve, push, request, map-model or disconnect')
1036
1057
  return run(ctx)
1037
1058
  },
1038
1059
  }
@@ -222,7 +222,8 @@ export function nounSummary(noun: string): string {
222
222
 
223
223
  const KEYS_ARE_SETTINGS_ONLY =
224
224
  'organization keys are managed in Organization settings → API Keys, workspace keys in '
225
- + 'Workspace settings → API Keys — by a signed-in administrator, never by the CLI: a '
225
+ + 'Workspace settings → API Keys, and an agent’s server and public website keys under the '
226
+ + 'agent’s API keys — by a signed-in administrator, never by the CLI: a '
226
227
  + 'credential that mints credentials is the escalation that separation exists to prevent'
227
228
 
228
229
  /**
@@ -11,8 +11,9 @@ import type { CaseResult } from './local-test-runner'
11
11
  * matching traces by their time.
12
12
  *
13
13
  * Traces are kept by the local traces store, outside the platform database:
14
- * `local check` rolls back the database after its tests (the conversation goes
15
- * with it), and the trace stays readable (`trace_get`, Studio's Traces page).
14
+ * `local check` rolls back the database after its tests, keeping the run
15
+ * history (the conversation included) unless that fails, and the trace stays
16
+ * readable either way (`trace_get`, Studio's Traces page).
16
17
  */
17
18
  export interface CaseEvidence {
18
19
  conversationId?: string
@@ -6,6 +6,7 @@ import { writeFileAtomic } from '../fs-atomic'
6
6
  import { loadHashes } from './local-applied'
7
7
  import { appDeployments, appsLostByRestore, appsLostWords, markAppsLostByRestore, type AppLostByRestore } from './local-app-restore'
8
8
  import { execInContainer, queryInContainer } from './local-docker'
9
+ import { dumpHistory, historyWords, loadHistory, type HistoryCarried } from './local-history-carry'
9
10
  import { POSTGRES_DB, POSTGRES_USER } from './local-env'
10
11
  import type { LocalProject } from './local-project'
11
12
  import { carrySecrets, readSecrets, type QueryFn, type SecretsCarried } from './local-secret-carry'
@@ -34,6 +35,12 @@ import { containerName } from './local-supervisor'
34
35
  * encrypted under, copied as stored), unless it is asked for the baseline's
35
36
  * (`secrets: 'baseline'`, `frontera local snapshot restore --exact`).
36
37
  *
38
+ * The run history is what happened, not test data either: conversations,
39
+ * Agent sessions and runs, workflow and Function runs and evaluation runs are
40
+ * kept as they are now (`local-history-carry.ts`), so a change's results can be
41
+ * compared with the last ones, unless the baseline's are asked for
42
+ * (`history: 'baseline'`, `--exact`). The records those runs wrote go back.
43
+ *
37
44
  * The service, job engine and runner keep running: connections to the
38
45
  * database are refused for the second or two it takes (`ALLOW_CONNECTIONS
39
46
  * false`), the open ones ended (`pg_terminate_backend`), and the pools
@@ -103,6 +110,8 @@ export interface SnapshotOutcome {
103
110
  appsLost?: AppLostByRestore[]
104
111
  /** restore: the workspace secrets kept as they were before it, or `baseline` when the baseline's were put back. */
105
112
  secrets?: (SecretsCarried & { mode: 'keep' }) | { mode: 'baseline' }
113
+ /** restore: the run history kept as it was before it, or `baseline` when the baseline's was put back. */
114
+ history?: (HistoryCarried & { mode: 'keep' }) | { mode: 'baseline' }
106
115
  }
107
116
 
108
117
  /** The secrets part of a restore's line: what was kept, what has to be set again, or that the baseline's are back. */
@@ -284,11 +293,12 @@ function changedSince(baseline: string | null, current: string | null): number {
284
293
  * ended, the database dropped and made again from the baseline, the cache
285
294
  * emptied. The CLI's record of what is applied is put back with it.
286
295
  */
287
- export async function restoreBaseline(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}, opts: { secrets?: RestoreSecrets } = {}): Promise<SnapshotOutcome> {
296
+ export async function restoreBaseline(project: LocalProject, organizationId: string, deps: SnapshotDeps = {}, opts: { secrets?: RestoreSecrets; history?: RestoreSecrets } = {}): Promise<SnapshotOutcome> {
288
297
  const exec = deps.exec ?? execInContainer
289
298
  // A stand-in `exec` without a stand-in `query` carries nothing: tests never reach docker.
290
299
  const query = deps.query ?? (deps.exec ? undefined : queryInContainer)
291
300
  const keepSecrets = (opts.secrets ?? 'keep') === 'keep'
301
+ const keepHistory = (opts.history ?? 'keep') === 'keep'
292
302
  const now = deps.now ?? Date.now
293
303
  const psql = psqlFor(project, exec)
294
304
  const stored = readBaseline(project, organizationId)
@@ -299,14 +309,18 @@ export async function restoreBaseline(project: LocalProject, organizationId: str
299
309
  // The secrets as they are now, read before the database goes: configuration, kept across the restore.
300
310
  const pg = containerName(project, 'postgres')
301
311
  const held = keepSecrets && query ? readSecrets(query, pg) : null
312
+ // What happened since the baseline, dumped in the container: kept across the restore too.
313
+ const happened = keepHistory && query ? dumpHistory(query, pg) : null
302
314
  psql(refuse(POSTGRES_DB, false))
303
315
  psql(terminate(POSTGRES_DB))
304
316
  if (!psql(`DROP DATABASE ${POSTGRES_DB} WITH (FORCE)`)) {
305
317
  psql(refuse(POSTGRES_DB, true))
318
+ if (happened && query && !('problem' in happened)) query(pg, ['rm', '-f', happened.file])
306
319
  return { ok: false, ms: now() - started, note: `couldn’t drop ${POSTGRES_DB} to restore it; the database is as the tests left it (see \`frontera local logs postgres\`)` }
307
320
  }
308
321
  // A new database takes connections: nothing is left refused, whichever way this ends.
309
322
  if (!copyWhileHeld(psql, POSTGRES_DB, BASELINE_DB)) {
323
+ if (happened && query && !('problem' in happened)) query(pg, ['rm', '-f', happened.file])
310
324
  return { ok: false, ms: now() - started, note: `the database was dropped and couldn’t be made again from ${BASELINE_DB}: run \`frontera local snapshot restore\` to try again, or \`frontera local reset\`` }
311
325
  }
312
326
  // Before the cache is emptied and the service reads them: the secrets as they were.
@@ -315,6 +329,11 @@ export async function restoreBaseline(project: LocalProject, organizationId: str
315
329
  : held && query
316
330
  ? { mode: 'keep', ...('problem' in held ? { ok: false, kept: 0, notKept: [], problem: held.problem } : carrySecrets(query, pg, held)) }
317
331
  : undefined
332
+ const history: SnapshotOutcome['history'] = !keepHistory
333
+ ? { mode: 'baseline' }
334
+ : happened && query
335
+ ? { mode: 'keep', ...('problem' in happened ? { ok: false, rows: 0, notKept: 0, problem: happened.problem } : loadHistory(query, pg, happened)) }
336
+ : undefined
318
337
  // The service's cache holds rows of the database that was dropped.
319
338
  exec(`frontera-${project.id}-cache`, ['redis-cli', 'FLUSHALL'])
320
339
  await settle(deps)
@@ -335,9 +354,10 @@ export async function restoreBaseline(project: LocalProject, organizationId: str
335
354
  // Written last: a watching `local dev` reads it to take the record above.
336
355
  writeBaseline(project, { ...stored, restoredAt, restoreMs: ms })
337
356
  const kept = secretsWords(secrets)
357
+ const ran = historyWords(history)
338
358
  const lostApps = appsLostWords(appsLost)
339
- const tail = `${reapply ? `; ${reapply} item${reapply === 1 ? '' : 's'} applied after the baseline will apply again on the next \`frontera local dev\`` : ''}${lostApps ? `; ${lostApps}` : ''}${kept ? `; ${kept}` : ''}`
340
- const withSecrets = secrets ? { secrets } : {}
359
+ const tail = `${reapply ? `; ${reapply} item${reapply === 1 ? '' : 's'} applied after the baseline will apply again on the next \`frontera local dev\`` : ''}${lostApps ? `; ${lostApps}` : ''}${kept ? `; ${kept}` : ''}${ran ? `; ${ran}` : ''}`
360
+ const withSecrets = { ...(secrets ? { secrets } : {}), ...(history ? { history } : {}) }
341
361
  const counted = { reapply: reapply + appsLost.length, ...(appsLost.length ? { appsLost } : {}) }
342
362
  if (!answering) return { ok: false, ms, ...counted, ...withSecrets, note: `restored the database (${ms} ms), but the service isn’t answering since: see \`frontera local logs service\`${tail}` }
343
363
  return { ok: true, ms, ...counted, ...withSecrets, note: `restored the database as applied (${ms} ms)${tail}` }