@frontera-sdk/cli 1.52.43 → 1.52.45

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.
@@ -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
  /**
package/src/flag-help.ts CHANGED
@@ -276,6 +276,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
276
276
  conflicts: 'the conflicts the picks were chosen against, as `diff --json` names them; refused if they moved',
277
277
  'from-workspace': 'start the project from a platform workspace instead of the examples',
278
278
  'no-sample-data': 'do not generate sample rows for the pulled Blueprint',
279
+ 'no-kit': 'leave out the Frontera skills (the kit) that local init vendors into the project; for a machine that uses the installed plugin instead',
279
280
  'keep-sample-data': 'leave the sample datasets as they are; by default they are made again (same seed, rows and day) after a pull that brings Blueprint items, or when an older generator made them',
280
281
  'remake-sample-data': 'make the sample datasets again with the newest sample rules (same seed, rows and day), even the ones a pull wrote, which local dev otherwise keeps as pulled',
281
282
  synthetic: 'generate sample rows for the Blueprint: values fit each property, links stay consistent',
@@ -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}` }
@@ -875,7 +875,8 @@ async function localModels(local: { serviceUrl: string; creds: LocalCredentials
875
875
  * A knowledge base: created when the runtime has none by its name, given the
876
876
  * file's settings (retrieval mode, chunking, top-K and threshold, hybrid
877
877
  * search and reranking, extraction and OCR), then the folder's documents the
878
- * base does not hold yet are uploaded.
878
+ * base does not hold yet are uploaded, and those edited here since are
879
+ * uploaded again in place of the runtime's older copy.
879
880
  */
880
881
  /**
881
882
  * What to say of a knowledge base with no documents in its folder: an Agent
@@ -987,7 +988,7 @@ export async function applyKnowledge(
987
988
  return await done({ ok: true, output: '' })
988
989
  }
989
990
  const sources = await run(['knowledge', 'sources', name, '--json', ...p])
990
- const listedSources = parseJson<Array<{ id?: string; fileName?: string; folderId?: string | null; status?: string; updatedAt?: string }>>(sources.output) ?? []
991
+ const listedSources = parseJson<Array<{ id?: string; fileName?: string; folderId?: string | null; status?: string; updatedAt?: string; sizeBytes?: number | null }>>(sources.output) ?? []
991
992
  const key = local?.creds.workspaceKey
992
993
  const http = async (method: string, path: string, body?: FormData | Record<string, unknown>) => {
993
994
  const res = await fetch(new URL(path, local!.serviceUrl), {
@@ -1008,15 +1009,54 @@ export async function applyKnowledge(
1008
1009
  // A document the runtime holds pending or processing with no ingestion behind it (a rollback took its
1009
1010
  // reading with the database it replaced) is queued to be read again: uploading it again would add a second.
1010
1011
  if (key && id) notes.push(...(await requeueStuckDocuments(project, local!, name, id, listedSources)))
1011
- const fresh = documents.filter((f) => !have.has(f))
1012
- if (!fresh.length) return await done({ ok: true, output: '' })
1012
+ // A document edited in place keeps its name: the runtime's older copy is found by content, uploaded over, then taken off.
1013
+ const { loadUploadedDocuments, saveUploadedDocuments, staleDocuments } = await import('./local-knowledge-uploads')
1014
+ const { sha256Of } = await import('./local-knowledge-documents')
1015
+ const contents = documents.flatMap((path) => {
1016
+ try {
1017
+ const bytes = readFileSync(join(project.root, dirname(target.file), path))
1018
+ return [{ path, sha256: sha256Of(bytes), bytes: bytes.byteLength }]
1019
+ } catch {
1020
+ return []
1021
+ }
1022
+ })
1023
+ const onRuntime = listedSources.flatMap((s) => (s.id ? [{ id: s.id, path: documentPath(s.folderId ? folders.get(s.folderId) : null, s.fileName ?? ''), sizeBytes: s.sizeBytes }] : []))
1024
+ const uploaded = key && id ? loadUploadedDocuments(project.stateDir, local!.creds.organizationId, id) : {}
1025
+ const stale = staleDocuments(contents, onRuntime, uploaded)
1026
+ if (stale.length && (!key || !id)) {
1027
+ notes.push(` warning: Knowledge base ${name}: ${stale.map((s) => s.path).join(', ')} changed here, but the runtime keeps ${stale.length === 1 ? 'its older copy' : 'their older copies'} until it has a workspace key`)
1028
+ }
1029
+ const replacing = key && id ? stale : []
1030
+ // What the runtime now holds for each document, recorded so the next apply tells an edit from no change.
1031
+ const record = async () => {
1032
+ if (!key || !id) return
1033
+ const now = rows<{ id?: string; fileName?: string; folderId?: string | null; createdAt?: string }>(await http('GET', `/v1/workspace-knowledge/${encodeURIComponent(id)}/sources`).catch(() => null))
1034
+ // Folders this apply made are not in `folders` yet.
1035
+ const filedNow = now.some((s) => s.folderId && !folders.has(s.folderId))
1036
+ ? folderPaths((((await http('GET', `/v1/workspace-knowledge/${encodeURIComponent(id)}/folders`).catch(() => null))?.folders ?? []) as Array<{ id: string; parentId?: string | null; name: string }>))
1037
+ : folders
1038
+ const next: typeof uploaded = {}
1039
+ for (const doc of contents) {
1040
+ const copies = now.filter((s) => s.id && documentPath(s.folderId ? filedNow.get(s.folderId) : null, s.fileName ?? '') === doc.path)
1041
+ const newest = copies.sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')))[0]
1042
+ if (newest?.id) next[doc.path] = { sourceId: newest.id, sha256: doc.sha256 }
1043
+ }
1044
+ saveUploadedDocuments(project.stateDir, local!.creds.organizationId, id, next)
1045
+ }
1046
+ const fresh = [...documents.filter((f) => !have.has(f)), ...replacing.map((s) => s.path)]
1047
+ if (!fresh.length) {
1048
+ await record()
1049
+ return await done({ ok: true, output: '' })
1050
+ }
1013
1051
  const top = fresh.filter((f) => !f.includes('/'))
1014
1052
  const filed = fresh.filter((f) => f.includes('/'))
1015
1053
  const lines: string[] = []
1054
+ const sent = new Set<string>()
1016
1055
  if (top.length) {
1017
- const uploaded = await run(['knowledge', 'upload', name, ...top.map((f) => join(dirname(target.file), f)), ...p])
1018
- if (!uploaded.ok) return await done(uploaded)
1019
- if (uploaded.output.trim()) lines.push(uploaded.output.trim())
1056
+ const uploadedTop = await run(['knowledge', 'upload', name, ...top.map((f) => join(dirname(target.file), f)), ...p])
1057
+ if (!uploadedTop.ok) return await done(uploadedTop)
1058
+ if (uploadedTop.output.trim()) lines.push(uploadedTop.output.trim())
1059
+ for (const f of top) sent.add(f)
1020
1060
  }
1021
1061
  if (filed.length && (!key || !id)) {
1022
1062
  notes.push(` warning: Knowledge base ${name}: ${filed.length} document${filed.length === 1 ? ' in a folder waits' : 's in folders wait'} until the runtime has a workspace key`)
@@ -1048,11 +1088,23 @@ export async function applyKnowledge(
1048
1088
  form.append('folderId', await folderFor(folder!))
1049
1089
  await http('POST', `/v1/workspace-knowledge/${encodeURIComponent(id!)}/sources`, form)
1050
1090
  lines.push(` uploaded ${path}`)
1091
+ sent.add(path)
1051
1092
  } catch (err) {
1052
1093
  notes.push(` warning: Knowledge base ${name}: ${path} wasn’t uploaded (${(err as Error).message})`)
1053
1094
  }
1054
1095
  }
1055
1096
  }
1097
+ // The older copies come off only once the new content is up, so the base is never without the document.
1098
+ for (const doc of replacing) {
1099
+ if (!sent.has(doc.path)) continue
1100
+ try {
1101
+ for (const sourceId of doc.replaces) await http('DELETE', `/v1/workspace-knowledge/${encodeURIComponent(id!)}/sources/${encodeURIComponent(sourceId)}`)
1102
+ lines.push(` replaced ${doc.path}: it changed here`)
1103
+ } catch (err) {
1104
+ notes.push(` warning: Knowledge base ${name}: ${doc.path}’s older copy wasn’t taken off (${(err as Error).message}); search may find both`)
1105
+ }
1106
+ }
1107
+ await record()
1056
1108
  return await done({ ok: true, output: lines.join('\n') })
1057
1109
  }
1058
1110
 
@@ -118,6 +118,21 @@ export const SERVICE_OUTBOUND = [
118
118
  'AUTOMATION_RUNNER_URL',
119
119
  ] as const
120
120
 
121
+ /** The service's settings for a hosted key custodian (OpenBao / Vault transit), blanked on the local runtime. */
122
+ export const KEY_CUSTODIAN_SETTINGS = [
123
+ 'BAO_ADDR',
124
+ 'BAO_TOKEN',
125
+ 'BAO_CACERT',
126
+ 'VAULT_ADDR',
127
+ 'VAULT_TOKEN',
128
+ 'SECRETS_TRANSIT_AUTH',
129
+ 'SECRETS_TRANSIT_MOUNT',
130
+ 'SECRETS_TRANSIT_KEY',
131
+ 'SECRETS_TRANSIT_K8S_ROLE',
132
+ 'SECRETS_TRANSIT_K8S_MOUNT',
133
+ 'SECRETS_TRANSIT_K8S_TOKEN_FILE',
134
+ ] as const
135
+
121
136
  /** What the Function runner dials on this machine. */
122
137
  export const RUNNER_OUTBOUND = ['SERVICE_URL', 'S3_ENDPOINT', 'INNGEST_BASE_URL'] as const
123
138
 
@@ -182,6 +197,12 @@ export function serviceEnv(args: {
182
197
  DAYTONA_API_KEY: '',
183
198
  DAYTONA_API_URL: '',
184
199
  DAYTONA_TARGET: '',
200
+ // The runtime holds its own keys: ENCRYPTION_KEY from its secrets.json wraps every data key.
201
+ // A checkout's .env can name a hosted OpenBao instead (the service loads it from its
202
+ // directory), and every data key wrapped there is unreadable once that OpenBao goes away.
203
+ // Empty values win over the file.
204
+ SECRETS_KEY_PROVIDER: 'env',
205
+ ...Object.fromEntries(KEY_CUSTODIAN_SETTINGS.map((key) => [key, ''])),
185
206
  // Traces go to the runtime's own local traces, read back by Studio.
186
207
  LANGFUSE_BASE_URL: `http://127.0.0.1:${ports.traces}`,
187
208
  LANGFUSE_PUBLIC_KEY: LOCAL_TRACE_KEYS.publicKey,
@@ -0,0 +1,103 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import { POSTGRES_DB, POSTGRES_USER } from './local-env'
4
+ import type { QueryFn } from './local-secret-carry'
5
+
6
+ /**
7
+ * The run history carried across a database restore.
8
+ *
9
+ * A restore (`local check`'s rollback, `frontera local snapshot restore`)
10
+ * throws away what was written since the baseline. What happened is not test
11
+ * data: the conversations (an Agent test case's included), Agent sessions and
12
+ * runs, workflow and Function runs and evaluation runs are how a change is
13
+ * compared with the one before it. So a restore keeps them as they are now,
14
+ * unless asked for the baseline's (`--exact`). The records those runs wrote
15
+ * are still rolled back.
16
+ *
17
+ * The rows are dumped inside the database container before the database is
18
+ * dropped (`pg_dump --data-only --inserts --on-conflict-do-nothing`), then
19
+ * loaded into the restored one, a row a statement: a row the baseline holds
20
+ * stays as the baseline holds it, and a row that names something the restore
21
+ * took away (a work item a test made) is left out and counted. Nothing leaves
22
+ * the container, and the dump is removed once loaded.
23
+ */
24
+
25
+ /** The tables that hold what happened, parents before children (pg_dump orders them by their keys too). */
26
+ export const HISTORY_TABLES = [
27
+ 'conversations',
28
+ 'messages',
29
+ 'message_feedback',
30
+ 'conversation_labels',
31
+ 'conversation_files',
32
+ 'trace_annotations',
33
+ 'session_handoffs',
34
+ 'agent_sessions',
35
+ 'agent_session_events',
36
+ 'agent_session_analyses',
37
+ 'agent_timeline_events',
38
+ 'agent_exchanges',
39
+ 'pi_runs',
40
+ 'pi_run_events',
41
+ 'pi_model_calls',
42
+ 'workflow_runs',
43
+ 'workflow_run_nodes',
44
+ 'workflow_step_test_results',
45
+ 'function_runs',
46
+ 'function_run_steps',
47
+ 'ctx_agent_turns',
48
+ 'dataset_runs',
49
+ 'dataset_run_items',
50
+ 'knowledge_eval_runs',
51
+ 'knowledge_eval_run_fixtures',
52
+ 'scheduled_task_runs',
53
+ 'agent_routine_runs',
54
+ ] as const
55
+
56
+ /** The history dumped from the current database, waiting in the container for the restored one. */
57
+ export interface HistoryHeld {
58
+ file: string
59
+ rows: number
60
+ }
61
+
62
+ export interface HistoryCarried {
63
+ ok: boolean
64
+ /** Rows dumped before the restore (those the baseline already held included). */
65
+ rows: number
66
+ /** Rows left out: they name something the restored database lacks. */
67
+ notKept: number
68
+ /** Why nothing was carried, when it failed. */
69
+ problem?: string
70
+ }
71
+
72
+ const sh = (script: string) => ['sh', '-c', script]
73
+
74
+ /** Dump the history tables as they are now into a file in the container, or why it could not be. */
75
+ export function dumpHistory(query: QueryFn, container: string): HistoryHeld | { problem: string } {
76
+ const file = `/tmp/frontera-history-${randomBytes(6).toString('hex')}.sql`
77
+ const tables = HISTORY_TABLES.map((t) => `-t public.${t}`).join(' ')
78
+ const res = query(container, sh(`pg_dump -U ${POSTGRES_USER} -d ${POSTGRES_DB} --data-only --inserts --on-conflict-do-nothing ${tables} -f ${file} || exit 1; grep -c '^INSERT INTO' ${file}; exit 0`))
79
+ const rows = Number.parseInt(res.out.trim().split('\n').pop() ?? '', 10)
80
+ if (!res.ok || !Number.isFinite(rows)) {
81
+ query(container, sh(`rm -f ${file}`))
82
+ return { problem: 'the run history could not be read' }
83
+ }
84
+ return { file, rows }
85
+ }
86
+
87
+ /** Load the dumped history into the restored database, then remove the dump. */
88
+ export function loadHistory(query: QueryFn, container: string, held: HistoryHeld): HistoryCarried {
89
+ // No ON_ERROR_STOP: each row is its own statement, and one the restored database refuses is counted, not fatal.
90
+ const res = query(container, sh(`psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -q -f ${held.file} 2>&1 | grep -c 'ERROR:'; rm -f ${held.file}`))
91
+ const notKept = Number.parseInt(res.out.trim().split('\n').pop() ?? '', 10)
92
+ if (!res.ok || !Number.isFinite(notKept)) return { ok: false, rows: held.rows, notKept: 0, problem: 'the run history could not be written back' }
93
+ return { ok: true, rows: held.rows, notKept }
94
+ }
95
+
96
+ /** What a restore does with the run history, in words. */
97
+ export function historyWords(history: (HistoryCarried & { mode: 'keep' }) | { mode: 'baseline' } | undefined): string {
98
+ if (!history) return ''
99
+ if (history.mode === 'baseline') return 'the run history is as the baseline holds it (--exact)'
100
+ if (!history.ok) return `the run history couldn’t be kept (${history.problem ?? 'unknown'}), so conversations and runs are as the baseline holds them`
101
+ const kept = 'kept the run history (conversations, runs and evaluations)'
102
+ return history.notKept ? `${kept}, except ${history.notKept} row${history.notKept === 1 ? '' : 's'} naming records the rollback took away` : kept
103
+ }
@@ -0,0 +1,64 @@
1
+ import { withFix } from './fix-words'
2
+
3
+ /**
4
+ * The local runtime's key custodian, as its service reports it on
5
+ * `/v1/health`: whether the keys that lock its secrets and governed Action
6
+ * records answer. A runtime holds its own keys (`serviceEnv` pins the env
7
+ * custodian); one whose data keys were wrapped by a hosted OpenBao it no
8
+ * longer reaches cannot read them again, so the only fix is a clean runtime.
9
+ * Read by `local start` (which refuses), `local status`, `local check` and
10
+ * Forge's Runtime page. The report carries categories and a count, never a
11
+ * key, token or address.
12
+ */
13
+
14
+ export interface KeyCustodianReport {
15
+ state: 'ready' | 'failed' | 'none'
16
+ provider: 'env' | 'transit' | null
17
+ reason?: 'unreachable' | 'misconfigured' | 'unreadable-keys'
18
+ foreignKeys: number
19
+ }
20
+
21
+ /** What fixes a broken custodian: nothing else can unwrap those data keys. */
22
+ export const KEY_CUSTODIAN_FIX = 'run `frontera local reset` for a clean runtime that holds its own keys (it wipes this runtime’s data)'
23
+
24
+ /** How every custodian problem starts, so a failed start's error is recognised as one. */
25
+ export const KEY_CUSTODIAN_LEAD = 'the runtime’s encryption keys can’t be read'
26
+
27
+ /** The problem a report describes, with its fix; null when the keys answer (or the report is from a service that has none). */
28
+ export function keyCustodianProblem(report: KeyCustodianReport | null | undefined): string | null {
29
+ if (!report) return null
30
+ const what = 'so its secrets and governed Action records can’t be read or saved'
31
+ if (report.foreignKeys > 0) {
32
+ const keys = report.foreignKeys === 1 ? '1 data key was' : `${report.foreignKeys} data keys were`
33
+ return withFix(`${KEY_CUSTODIAN_LEAD}: ${keys} locked by a key service it doesn’t hold (an OpenBao named in a checkout’s .env), ${what}`, KEY_CUSTODIAN_FIX)
34
+ }
35
+ if (report.state !== 'failed') return null
36
+ const why =
37
+ report.reason === 'unreachable'
38
+ ? 'its key service doesn’t answer'
39
+ : report.reason === 'unreadable-keys'
40
+ ? 'its stored data keys don’t open with the keys in .frontera/local/secrets.json'
41
+ : 'its key service is set up wrong'
42
+ return withFix(`${KEY_CUSTODIAN_LEAD}: ${why}, ${what}`, KEY_CUSTODIAN_FIX)
43
+ }
44
+
45
+ /** Whether a failed start's error is a custodian problem (its fix is a reset, not a restart). */
46
+ export function isKeyCustodianProblem(error: string | null | undefined): boolean {
47
+ return !!error && error.includes(KEY_CUSTODIAN_LEAD)
48
+ }
49
+
50
+ /**
51
+ * The custodian problem the running service reports, or null: its keys
52
+ * answer, it can't be reached (other checks say so), or it is a release from
53
+ * before the report.
54
+ */
55
+ export async function runtimeKeyCustodianProblem(serviceUrl: string, doFetch: (url: string, init?: RequestInit) => Promise<Response> = fetch): Promise<string | null> {
56
+ try {
57
+ const res = await doFetch(`${serviceUrl.replace(/\/+$/, '')}/v1/health`, { signal: AbortSignal.timeout(3000) })
58
+ if (!res.ok) return null
59
+ const body = (await res.json().catch(() => null)) as { data?: { keyCustodian?: KeyCustodianReport | null } } | null
60
+ return keyCustodianProblem(body?.data?.keyCustodian)
61
+ } catch {
62
+ return null
63
+ }
64
+ }
@@ -21,8 +21,8 @@ const SERVICE = 'frontera-local-model-key'
21
21
  export interface ProviderSpec {
22
22
  id: string
23
23
  label: string
24
- /** The variable the service reads for this provider. */
25
- envVar: string
24
+ /** The variable the service reads for this provider; none for a key only an organization holds. */
25
+ envVar?: string
26
26
  }
27
27
 
28
28
  /** Providers the local runtime can pass a key to; mirrors the service's built-in providers. */
@@ -32,6 +32,8 @@ export const LOCAL_PROVIDERS: readonly ProviderSpec[] = [
32
32
  { id: 'openrouter', label: 'OpenRouter', envVar: 'OPENROUTER_API_KEY' },
33
33
  { id: 'google', label: 'Google Gemini', envVar: 'GOOGLE_GENERATIVE_AI_API_KEY' },
34
34
  { id: 'cohere', label: 'Cohere (reranking)', envVar: 'COHERE_API_KEY' },
35
+ // Agents' web search; stored as the local organization's own key, never an environment variable.
36
+ { id: 'openrouter-web-search', label: 'OpenRouter web search' },
35
37
  ]
36
38
 
37
39
  export function findProvider(id: string): ProviderSpec | undefined {
@@ -100,7 +102,7 @@ export function providerEnv(): Record<string, string> {
100
102
  const env: Record<string, string> = {}
101
103
  for (const p of LOCAL_PROVIDERS) {
102
104
  const key = getKey(p.id)
103
- if (key) env[p.envVar] = key
105
+ if (key && p.envVar) env[p.envVar] = key
104
106
  }
105
107
  return env
106
108
  }
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto'
2
- import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
3
  import { dirname, join, resolve, sep } from 'node:path'
4
4
 
5
5
  import { KNOWLEDGE_DOCUMENT } from './local-dev-references'
@@ -23,10 +23,10 @@ import { rows } from './local-sync-remote'
23
23
  * - a name already in the folder, unless the request says to replace it;
24
24
  * - a folder or file that is a symbolic link, or resolves outside `knowledge/`.
25
25
  *
26
- * Removing a document takes its file out of the folder. The apply only adds
27
- * what the runtime lacks, so the server takes the runtime's copy off first
28
- * (`removeRuntimeCopies`), as it does before a replaced file is written, so
29
- * the next apply uploads the new content.
26
+ * Removing a document takes its file out of the folder, and the server takes
27
+ * the runtime's copy off (`removeRuntimeCopies`), as it does before a replaced
28
+ * file is written. A file edited in place (by a coding agent, say) the apply
29
+ * uploads again itself, then takes the older copy off (`staleDocuments`).
30
30
  */
31
31
 
32
32
  /** The extensions `local dev` uploads into a knowledge base, as `KNOWLEDGE_DOCUMENT` matches them. */
@@ -211,6 +211,63 @@ export async function removeRuntimeCopies(call: ServiceCall, knowledgeId: string
211
211
  return copies.length
212
212
  }
213
213
 
214
+ /** What the last apply uploaded for each document, by its path: the runtime's copy and the content it was. */
215
+ export type UploadedDocuments = Record<string, { sourceId: string; sha256: string }>
216
+
217
+ const uploadsPath = (stateDir: string) => join(stateDir, 'knowledge-uploads.json')
218
+
219
+ /** The documents the apply uploaded into one knowledge base on this runtime; nothing after a reset (another organization). */
220
+ export function loadUploadedDocuments(stateDir: string, organizationId: string, knowledgeId: string): UploadedDocuments {
221
+ try {
222
+ const saved = JSON.parse(readFileSync(uploadsPath(stateDir), 'utf8')) as { organizationId?: string; bases?: Record<string, UploadedDocuments> }
223
+ if (saved.organizationId === organizationId) return saved.bases?.[knowledgeId] ?? {}
224
+ } catch {
225
+ // Missing or unreadable: no copy counts as recorded.
226
+ }
227
+ return {}
228
+ }
229
+
230
+ export function saveUploadedDocuments(stateDir: string, organizationId: string, knowledgeId: string, uploaded: UploadedDocuments): void {
231
+ let bases: Record<string, UploadedDocuments> = {}
232
+ try {
233
+ const saved = JSON.parse(readFileSync(uploadsPath(stateDir), 'utf8')) as { organizationId?: string; bases?: Record<string, UploadedDocuments> }
234
+ if (saved.organizationId === organizationId && saved.bases) bases = saved.bases
235
+ } catch {
236
+ // Starts afresh.
237
+ }
238
+ try {
239
+ mkdirSync(stateDir, { recursive: true })
240
+ writeFileSync(uploadsPath(stateDir), `${JSON.stringify({ organizationId, bases: { ...bases, [knowledgeId]: uploaded } })}\n`, { mode: 0o600 })
241
+ } catch {
242
+ // Losing this only means the next apply judges an edit by the copy's size.
243
+ }
244
+ }
245
+
246
+ /**
247
+ * The documents the runtime holds an older copy of, each with the copies to
248
+ * take off once the new content is up. A copy the last apply recorded is
249
+ * older when the file's content differs from what was uploaded; a copy no
250
+ * apply here recorded (uploaded before the record, or by hand) is older when
251
+ * its size differs, and otherwise taken as the file's.
252
+ */
253
+ export function staleDocuments(
254
+ local: Array<{ path: string; sha256: string; bytes: number }>,
255
+ runtime: Array<{ id: string; path: string; sizeBytes?: number | null }>,
256
+ uploaded: UploadedDocuments,
257
+ ): Array<{ path: string; replaces: string[] }> {
258
+ const out: Array<{ path: string; replaces: string[] }> = []
259
+ for (const doc of local) {
260
+ const copies = runtime.filter((s) => s.path === doc.path)
261
+ if (!copies.length) continue
262
+ const was = uploaded[doc.path]
263
+ const stale = was && copies.some((c) => c.id === was.sourceId)
264
+ ? was.sha256 !== doc.sha256
265
+ : copies.length === 1 && typeof copies[0]!.sizeBytes === 'number' && copies[0]!.sizeBytes !== doc.bytes
266
+ if (stale) out.push({ path: doc.path, replaces: copies.map((c) => c.id) })
267
+ }
268
+ return out
269
+ }
270
+
214
271
  /** The project's documents for a knowledge base, each with whether the runtime holds it yet. */
215
272
  export function projectDocuments(root: string, folder: string, onRuntime: ReadonlySet<string> | null): Array<{ path: string; onRuntime: boolean | null }> {
216
273
  return documentPaths(root, folder).map((path) => ({ path, onRuntime: onRuntime ? onRuntime.has(path) : null }))