@frontera-sdk/cli 1.52.43 → 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.
@@ -0,0 +1,118 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { modelMap, projectFile } from './local-sync-link'
5
+ import type { ModelMap } from './local-sync-portable'
6
+
7
+ /**
8
+ * Which model a project's model slug is, on this computer and in the
9
+ * workspace. A catalog slug is each catalog's own name for a model (this
10
+ * computer's provider calls it `deepseek-deepseek-v4-1-flash`, the
11
+ * workspace's `deepseek-v4-1-flash`), so models are matched by what they are:
12
+ * the provider's model id (`deepseek/deepseek-v4.1-flash`).
13
+ *
14
+ * the same model in the workspace the file's slug is written as the workspace's when proposed, and the
15
+ * workspace's as this computer's on pull: nothing to choose
16
+ * no such model in the workspace which model an Agent runs on stays the workspace's choice: the Agent
17
+ * keeps the workspace's model there (its default, for a new one), and the
18
+ * local one is this computer's own, never proposed (`local-sync-portable.ts`)
19
+ * `sync.models` in frontera.yaml an explicit choice, over the match
20
+ *
21
+ * This computer's catalog is kept when a sync command finds the runtime
22
+ * running (`.frontera/local/model-catalog.json`), so status and push match
23
+ * models offline; the workspace's comes with the snapshot.
24
+ */
25
+
26
+ export interface CatalogEntry {
27
+ slug: string
28
+ name?: string
29
+ kind?: string
30
+ provider?: string
31
+ modelId?: string
32
+ }
33
+
34
+ const LOCAL_CATALOG = join('.frontera', 'local', 'model-catalog.json')
35
+ const REMOTE_JSON = join('.frontera', 'sync', 'remote.json')
36
+
37
+ /** Keep this computer's catalog, as the running runtime listed it. */
38
+ export function rememberLocalCatalog(root: string, models: CatalogEntry[]): void {
39
+ const file = join(root, LOCAL_CATALOG)
40
+ try {
41
+ mkdirSync(dirname(file), { recursive: true })
42
+ writeFileSync(file, `${JSON.stringify({ version: 1, models: models.map(({ slug, name, kind, provider, modelId }) => ({ slug, name, kind, provider, modelId })) }, null, 2)}\n`)
43
+ } catch {
44
+ // Matching then falls back to the last copy, or to slugs alone.
45
+ }
46
+ }
47
+
48
+ function readJson<T>(file: string): T | null {
49
+ try {
50
+ return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as T) : null
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ /** The provider's id as two catalogs write it alike: case and spaces aside. */
57
+ const sameModelId = (id: string | undefined) => (id ?? '').trim().toLowerCase()
58
+
59
+ export interface ProjectModels {
60
+ /** A local slug and the workspace slug it goes as: matched by model, then `sync.models` over it. */
61
+ map: ModelMap
62
+ /** The workspace's model slugs; null when no snapshot lists them (then nothing is judged missing). */
63
+ workspace: Set<string> | null
64
+ /** The workspace's models by slug, for words. */
65
+ names: Record<string, string>
66
+ }
67
+
68
+ const cached = new Map<string, { stamp: string; value: ProjectModels }>()
69
+
70
+ const stampOf = (...files: string[]) =>
71
+ files
72
+ .map((f) => {
73
+ try {
74
+ return String(statSync(f).mtimeMs)
75
+ } catch {
76
+ return '-'
77
+ }
78
+ })
79
+ .join('|')
80
+
81
+ /** The project's models as push and status read them; read again only when one of their files changes. */
82
+ export function projectModels(root: string): ProjectModels {
83
+ const files = [projectFile(root), join(root, LOCAL_CATALOG), join(root, REMOTE_JSON)]
84
+ const stamp = stampOf(...files)
85
+ const known = cached.get(root)
86
+ if (known && known.stamp === stamp) return known.value
87
+ const remote = readJson<{ models?: CatalogEntry[]; names?: { model?: Record<string, string> } }>(join(root, REMOTE_JSON))
88
+ const theirs = remote?.models ?? Object.keys(remote?.names?.model ?? {}).map((slug) => ({ slug }) as CatalogEntry)
89
+ const ours = readJson<{ models?: CatalogEntry[] }>(join(root, LOCAL_CATALOG))?.models ?? []
90
+ const value = matchModels(ours, theirs.length ? theirs : null, modelMap(root))
91
+ cached.set(root, { stamp, value })
92
+ return value
93
+ }
94
+
95
+ /** The match itself: each local model the workspace has under another slug, by the provider's id; the explicit choices over it. */
96
+ export function matchModels(ours: CatalogEntry[], theirs: CatalogEntry[] | null, explicit: ModelMap = {}): ProjectModels {
97
+ if (!theirs) return { map: { ...explicit }, workspace: null, names: {} }
98
+ const workspace = new Set(theirs.map((m) => m.slug))
99
+ const byId = new Map<string, CatalogEntry[]>()
100
+ for (const m of theirs) {
101
+ if ((m.kind ?? 'text') !== 'text' || !m.modelId) continue
102
+ byId.set(sameModelId(m.modelId), [...(byId.get(sameModelId(m.modelId)) ?? []), m])
103
+ }
104
+ // A platform that lists its models without the provider's id (older than these fields): a slug made from the
105
+ // provider's id, whole or its model part (`deepseek/deepseek-v4.1-flash` → `deepseek-v4-1-flash`), is the same model.
106
+ const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
107
+ const bySlug = new Map(theirs.filter((t) => !t.modelId).map((t) => [slugify(t.slug), t]))
108
+ const map: ModelMap = {}
109
+ for (const m of ours) {
110
+ if (workspace.has(m.slug) || !m.modelId) continue
111
+ const guessed = [slugify(m.modelId), slugify(m.modelId.split('/').pop() ?? '')].map((s) => bySlug.get(s)).find(Boolean)
112
+ const same = byId.get(sameModelId(m.modelId)) ?? (guessed ? [guessed] : [])
113
+ // One model under two of the workspace's providers: the one named alike, else the first.
114
+ const pick = same.find((w) => w.name && w.name === m.name) ?? same[0]
115
+ if (pick) map[m.slug] = pick.slug
116
+ }
117
+ return { map: { ...map, ...explicit }, workspace, names: Object.fromEntries(theirs.map((m) => [m.slug, m.name ?? m.slug])) }
118
+ }
@@ -9,6 +9,8 @@ import { conflictsId, deepEqual, mergeItemFiles, type Choice, type Side } from '
9
9
  import { itemTitle } from './local-sync-names'
10
10
  import { toNames, type Directory, type Unresolved } from './local-sync-refs'
11
11
  import { comparableText, isPluginFile, withLocalSecrets } from './local-secrets'
12
+ import { projectModels } from './local-sync-models'
13
+ import { comparablePortable, toLocal } from './local-sync-portable'
12
14
 
13
15
  /**
14
16
  * A pull is a plan first. Each item in the workspace (an Agent with its
@@ -314,17 +316,24 @@ export function statusFor(base: string | null, local: string | null, remote: str
314
316
  return 'same'
315
317
  }
316
318
 
317
- /** A working file as it compares with the workspace's: a Plugin's secret values, this computer's own, left out. */
318
- function readComparable(root: string, rel: string, base?: (path: string) => string | null): string | null {
319
+ /**
320
+ * A working file as it compares with the workspace's: what is this
321
+ * computer's own left out (a Plugin's secret values, a model mapped in
322
+ * `sync.models`, multi-turn evaluation cases), and written as the
323
+ * workspace's or the base's text when it then says the same.
324
+ */
325
+ function readComparable(root: string, rel: string, base?: (path: string) => string | null, remote?: string | null): string | null {
319
326
  const text = readLocal(root, rel)
320
- return text === null ? null : comparableText(rel, text, isPluginFile(rel) ? (base?.(rel) ?? null) : null)
327
+ if (text === null) return null
328
+ if (isPluginFile(rel)) return comparableText(rel, text, base?.(rel) ?? null)
329
+ return comparablePortable(rel, text, projectModels(root), [remote, base?.(rel)])
321
330
  }
322
331
 
323
332
  /** Hash of the working files at these paths, or null when none of them exist. */
324
- function localHash(root: string, paths: string[], base?: (path: string) => string | null): string | null {
333
+ function localHash(root: string, paths: string[], base?: (path: string) => string | null, remote: Record<string, string> = {}): string | null {
325
334
  const files: Record<string, string> = {}
326
335
  for (const p of paths) {
327
- const content = readComparable(root, p, base)
336
+ const content = readComparable(root, p, base, remote[p])
328
337
  if (content !== null) files[p] = content
329
338
  }
330
339
  return Object.keys(files).length ? itemHash(files) : null
@@ -401,7 +410,7 @@ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock |
401
410
  seen.add(item.ref)
402
411
  const entry = lock?.items[item.ref]
403
412
  const paths = [...new Set([...Object.keys(item.files), ...Object.keys(entry?.files ?? {})])].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
404
- const files: FileChange[] = paths.map((path) => ({ path, local: readComparable(root, path, opts.base), remote: item.files[path] ?? null }))
413
+ const files: FileChange[] = paths.map((path) => ({ path, local: readComparable(root, path, opts.base, item.files[path] ?? null), remote: item.files[path] ?? null }))
405
414
  // An Agent's file that neither the base nor the workspace has but the folder does (its first tools, in a new
406
415
  // `bindings.json`) is a change here too, as push reads it: otherwise status says nothing while push proposes it.
407
416
  const added = item.kind === 'agent' ? AGENT_FILES.map((f) => `agents/${item.name}/${f}`).filter((p) => !paths.includes(p) && existsSync(join(root, p))) : []
@@ -409,7 +418,7 @@ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock |
409
418
  // The files as they stand over the base's paths, plus any path only the workspace has that is
410
419
  // already in the folder: a file the pull would overwrite counts as a change here.
411
420
  const basePaths = entry ? [...Object.keys(entry.files), ...files.filter((f) => !(f.path in entry.files) && f.local !== null).map((f) => f.path), ...added] : Object.keys(item.files)
412
- const found = statusFor(entry?.remote ?? null, localHash(root, basePaths, opts.base), itemHash(item.files), matches) ?? 'same'
421
+ const found = statusFor(entry?.remote ?? null, localHash(root, basePaths, opts.base, item.files), itemHash(item.files), matches) ?? 'same'
413
422
  // Pulled with conflicts and not resolved: it stays so until `resolve`, whatever the files say.
414
423
  const status: ItemStatus = entry?.conflict && found !== 'same' ? 'unresolved' : found
415
424
  let merge: PlanItem['merge']
@@ -579,8 +588,9 @@ export function applyItems(root: string, workspace: string, items: RemoteItem[],
579
588
  for (const [path, remote] of Object.entries(item.files)) {
580
589
  const full = safePath(root, path)
581
590
  mkdirSync(dirname(full), { recursive: true })
582
- // A Plugin's secret values set here stay: they are this computer's configuration.
583
- const content = isPluginFile(path) ? withLocalSecrets(remote, readLocal(root, path)) : remote
591
+ // A Plugin's secret values set here stay: they are this computer's configuration. So do a mapped model and
592
+ // the multi-turn evaluation cases (`local-sync-portable.ts`).
593
+ const content = isPluginFile(path) ? withLocalSecrets(remote, readLocal(root, path)) : toLocal(path, remote, readLocal(root, path), projectModels(root), opts.base?.(path) ?? null)
584
594
  if (readLocal(root, path) !== content) {
585
595
  writeFileSync(full, content)
586
596
  written.push(path)
@@ -0,0 +1,269 @@
1
+ import { isMap, isSeq, parseDocument, type Document } from 'yaml'
2
+
3
+ import { deepEqual } from './local-sync-merge'
4
+
5
+ /**
6
+ * What of a project file is this computer's only, and how the file reads in
7
+ * the workspace without it. Two things stay here:
8
+ *
9
+ * this computer's model slugs a model the workspace has under another slug is written as the
10
+ * workspace's (matched by the provider's model id, `local-sync-models.ts`,
11
+ * or `sync.models` in frontera.yaml). An Agent names models in agent.json
12
+ * (`modelSlug`), a workflow as `model/<slug>`.
13
+ * a model the workspace lacks which model an Agent runs on is the workspace's choice: the Agent keeps
14
+ * the workspace's model selection (its modes and default), a new Agent
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.
18
+ *
19
+ * `toWorkspace` is the file as the workspace has it: what Propose sends, and
20
+ * what status and push compare with the workspace. `toLocal` is the
21
+ * workspace's file as a pull writes it here, with this computer's parts put
22
+ * back. Like a Plugin's secret values (`local-secrets.ts`), they never
23
+ * leave the folder and never read as a change.
24
+ */
25
+
26
+ /** A local model slug and the workspace model it goes as, from `sync.models`. */
27
+ export type ModelMap = Record<string, string>
28
+
29
+ const AGENT_FILE = /^agents\/[^/]+\/agent\.json$/
30
+ const WORKFLOW_FILE = /^workflows\/[^/]+\.ya?ml$/
31
+ const EVAL_FILE = /^agents\/[^/]+\/evals\/[^/]+\.ya?ml$/
32
+
33
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
34
+
35
+ /** Each slug in `text` swapped as `map` says, where the file names a model. */
36
+ function swapModels(path: string, text: string, map: ModelMap): { text: string; swapped: Array<[string, string]> } {
37
+ const swapped: Array<[string, string]> = []
38
+ let out = text
39
+ for (const [from, to] of Object.entries(map)) {
40
+ if (!from || !to || from === to) continue
41
+ const pattern = AGENT_FILE.test(path)
42
+ ? new RegExp(`("modelSlug"\\s*:\\s*")${escape(from)}(")`, 'g')
43
+ : WORKFLOW_FILE.test(path)
44
+ ? new RegExp(`(\\bmodel/)${escape(from)}(?![\\w.-])()`, 'g')
45
+ : null
46
+ if (!pattern) continue
47
+ const next = out.replace(pattern, `$1${to}$2`)
48
+ if (next !== out) swapped.push([from, to])
49
+ out = next
50
+ }
51
+ return { text: out, swapped }
52
+ }
53
+
54
+ /** The workspace model each local one goes as, read the other way; a workspace model two local ones go as stays as it is. */
55
+ function reversed(map: ModelMap): ModelMap {
56
+ const count = new Map<string, number>()
57
+ for (const to of Object.values(map)) count.set(to, (count.get(to) ?? 0) + 1)
58
+ return Object.fromEntries(Object.entries(map).filter(([, to]) => count.get(to) === 1).map(([from, to]) => [to, from]))
59
+ }
60
+
61
+ interface CaseNode {
62
+ toJSON(): unknown
63
+ }
64
+
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 }> {
67
+ const cases = doc.get('cases', true)
68
+ if (!isSeq(cases)) return []
69
+ return cases.items.map((node) => {
70
+ 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 }>) : []
72
+ return {
73
+ node: node as CaseNode,
74
+ multiTurn: value?.scenario === undefined && turns.length > 1,
75
+ name: typeof value?.name === 'string' ? value.name : null,
76
+ question: typeof turns[0]?.user === 'string' ? (turns[0]!.user as string).trim() : null,
77
+ }
78
+ })
79
+ }
80
+
81
+ export interface Portable {
82
+ text: string
83
+ /** Models swapped for the workspace's, as `[local, workspace]`. */
84
+ models: Array<[string, string]>
85
+ /** Evaluation cases that stay here (more than one turn), by name. */
86
+ keptHere: string[]
87
+ /** Models the workspace lacks that the file names here; the workspace keeps its own selection (`upstream`: its models, none for a new Agent). */
88
+ localModels?: { slugs: string[]; upstream: string[] }
89
+ }
90
+
91
+ /** What the workspace's models are, for the files that name models. */
92
+ export interface ModelContext {
93
+ map?: ModelMap
94
+ /** The workspace's slugs; null or absent: unknown, so nothing is judged missing. */
95
+ workspace?: ReadonlySet<string> | null
96
+ }
97
+
98
+ interface AgentDoc {
99
+ config?: Record<string, unknown>
100
+ modelConfigs?: Array<Record<string, unknown>>
101
+ [key: string]: unknown
102
+ }
103
+
104
+ const slugsOf = (doc: AgentDoc | null) => (doc?.modelConfigs ?? []).flatMap((m) => (typeof m?.modelSlug === 'string' ? [m.modelSlug] : []))
105
+
106
+ function parseAgent(text: string | null | undefined): AgentDoc | null {
107
+ if (!text) return null
108
+ try {
109
+ const doc = JSON.parse(text) as AgentDoc
110
+ return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : null
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ const agentText = (doc: AgentDoc) => `${JSON.stringify(doc, null, 2)}\n`
117
+
118
+ /**
119
+ * An Agent naming a model the workspace lacks, as the workspace would have
120
+ * it: its model selection (modes and default) is the workspace's Agent's, or,
121
+ * for a new Agent, its modes on models the workspace has. Null when every
122
+ * model it names is the workspace's.
123
+ */
124
+ function agentAsWorkspace(text: string, workspace: ReadonlySet<string>, upstream: string | null | undefined): { text: string; slugs: string[]; upstream: string[] } | null {
125
+ const doc = parseAgent(text)
126
+ if (!doc) return null
127
+ const missing = slugsOf(doc).filter((s) => !workspace.has(s))
128
+ if (!missing.length) return null
129
+ const theirs = parseAgent(upstream)
130
+ const config = { ...(doc.config ?? {}) }
131
+ let modelConfigs: Array<Record<string, unknown>>
132
+ if (theirs) {
133
+ modelConfigs = theirs.modelConfigs ?? []
134
+ if (theirs.config && 'defaultMode' in theirs.config) config.defaultMode = theirs.config.defaultMode
135
+ else delete config.defaultMode
136
+ } else {
137
+ modelConfigs = (doc.modelConfigs ?? []).filter((m) => !(typeof m?.modelSlug === 'string' && missing.includes(m.modelSlug)))
138
+ if (!modelConfigs.some((m) => m.modeKey === config.defaultMode)) config.defaultMode = (modelConfigs[0]?.modeKey as string | undefined) ?? null
139
+ }
140
+ return { text: agentText({ ...doc, config, modelConfigs }), slugs: missing, upstream: slugsOf({ modelConfigs }) }
141
+ }
142
+
143
+ /** The file as the workspace has it. Unchanged text when nothing in it is this computer's. */
144
+ 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 }
146
+ const map = ctx.map ?? {}
147
+ if (EVAL_FILE.test(path)) {
148
+ let doc: Document
149
+ try {
150
+ doc = parseDocument(text)
151
+ } catch {
152
+ return { text, models: [], keptHere: [] }
153
+ }
154
+ if (doc.errors.length) return { text, models: [], keptHere: [] }
155
+ const cases = evaluationCases(doc)
156
+ const kept = cases.filter((c) => c.multiTurn)
157
+ if (!kept.length) return { text, models: [], keptHere: [] }
158
+ const seq = doc.get('cases', true) as { items: unknown[] }
159
+ seq.items = seq.items.filter((_, i) => !cases[i]!.multiTurn)
160
+ return { text: doc.toString(), models: [], keptHere: kept.map((c) => c.name ?? `case ${cases.indexOf(c) + 1}`) }
161
+ }
162
+ const { text: swapped, swapped: done } = swapModels(path, text, map)
163
+ if (AGENT_FILE.test(path) && ctx.workspace) {
164
+ const local = agentAsWorkspace(swapped, ctx.workspace, upstream)
165
+ if (local) return { text: local.text, models: done, keptHere: [], localModels: { slugs: local.slugs, upstream: local.upstream } }
166
+ }
167
+ return { text: swapped, models: done, keptHere: [] }
168
+ }
169
+
170
+ /**
171
+ * The workspace's file as a pull writes it here: a model it names that this
172
+ * project maps is written as the local one, and the evaluation cases that
173
+ * stay here are kept after the workspace's. An Agent this computer runs on a
174
+ * model the workspace lacks keeps it while the workspace's model selection is
175
+ * as the last pull left it (`base`); one the workspace changed comes here.
176
+ */
177
+ 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 }
179
+ const map = ctx.map ?? {}
180
+ if (EVAL_FILE.test(path)) {
181
+ if (local === null) return remote
182
+ let ours: Document
183
+ let theirs: Document
184
+ try {
185
+ ours = parseDocument(local)
186
+ theirs = parseDocument(remote)
187
+ } catch {
188
+ return remote
189
+ }
190
+ if (ours.errors.length || theirs.errors.length) return remote
191
+ const kept = evaluationCases(ours).filter((c) => c.multiTurn)
192
+ if (!kept.length) return remote
193
+ const present = evaluationCases(theirs)
194
+ const seq = theirs.get('cases', true)
195
+ if (!isSeq(seq)) return remote
196
+ for (const c of kept) {
197
+ // Already there (a merge kept it): not added twice.
198
+ if (present.some((p) => (c.name !== null && p.name === c.name) || deepEqual(p.node.toJSON(), c.node.toJSON()))) continue
199
+ seq.items.push(c.node as never)
200
+ }
201
+ return theirs.toString()
202
+ }
203
+ const written = swapModels(path, remote, reversed(map)).text
204
+ // An Agent this computer runs on a model the workspace lacks keeps it, while the workspace's selection is as it was.
205
+ if (AGENT_FILE.test(path) && local !== null && base && ctx.workspace) {
206
+ const ours = parseAgent(local)
207
+ const theirs = parseAgent(written)
208
+ const before = parseAgent(base)
209
+ 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 ?? [] })
212
+ }
213
+ }
214
+ return written
215
+ }
216
+
217
+ /** Whether two versions of a file say the same document (key order and formatting aside). */
218
+ export function sameDocumentText(path: string, a: string, b: string): boolean {
219
+ if (a === b) return true
220
+ try {
221
+ if (/\.json$/i.test(path)) return deepEqual(JSON.parse(a), JSON.parse(b))
222
+ if (/\.ya?ml$/i.test(path)) return deepEqual(parseDocument(a).toJS(), parseDocument(b).toJS())
223
+ } catch {
224
+ return false
225
+ }
226
+ return false
227
+ }
228
+
229
+ /**
230
+ * The file as it compares with the workspace: its workspace form, written as
231
+ * the workspace's or the base's text when it says the same, so this
232
+ * computer's parts and a re-serialized file never read as a change.
233
+ */
234
+ export function comparablePortable(path: string, text: string, models: ModelMap | ModelContext, others: Array<string | null | undefined>): string {
235
+ // The workspace's text, else the base's: the upstream an Agent's model selection is kept from.
236
+ const portable = toWorkspace(path, text, models, others.find((o) => o != null) ?? null).text
237
+ if (portable === text) return text
238
+ for (const other of others) if (other != null && sameDocumentText(path, portable, other)) return other
239
+ return portable
240
+ }
241
+
242
+ /** What stays here of an item's files, in words, for Propose's notes. */
243
+ export function portableNotes(files: Record<string, string>, models: ModelMap | ModelContext, names: Record<string, string> = {}, upstream: Record<string, string> = {}): string[] {
244
+ const notes: string[] = []
245
+ const swapped = new Map<string, string>()
246
+ for (const [path, text] of Object.entries(files)) {
247
+ const p = toWorkspace(path, text, models, upstream[path] ?? null)
248
+ for (const [from, to] of p.models) swapped.set(from, to)
249
+ if (p.localModels) {
250
+ const here = p.localModels.slugs.map((s) => names[s] ?? s).join(' and ')
251
+ const there = p.localModels.upstream.map((s) => names[s] ?? s)
252
+ 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
+ }
254
+ if (p.keptHere.length) {
255
+ 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`)
257
+ }
258
+ }
259
+ for (const [from, to] of swapped) notes.push(`Uses ${from} here; the workspace has the same model as ${names[to] ?? to}`)
260
+ return notes
261
+ }
262
+
263
+ /** Every model slug a file names, as the workspace would read it: an Agent's `modelSlug`s and a workflow's `model/<slug>` references. */
264
+ export function modelsNamed(path: string, text: string): string[] {
265
+ const out = new Set<string>()
266
+ if (AGENT_FILE.test(path)) for (const m of text.matchAll(/"modelSlug"\s*:\s*"([^"]+)"/g)) out.add(m[1]!)
267
+ else if (WORKFLOW_FILE.test(path)) for (const m of text.matchAll(/\bmodel\/([A-Za-z0-9][\w.-]*)/g)) out.add(m[1]!)
268
+ return [...out]
269
+ }
@@ -0,0 +1,94 @@
1
+ import { modelsNamed } from './local-sync-portable'
2
+ import type { Check, PushItem } from './local-sync-push'
3
+
4
+ /**
5
+ * What Propose knows before it is pressed, so an item the workspace would
6
+ * refuse is left out with its reason and its fix instead of refusing the
7
+ * whole request:
8
+ *
9
+ * a model the workspace lacks an item names a model only this computer has, and `sync.models`
10
+ * maps it to none: choose the workspace model it goes as
11
+ * the workspace's checks the request's items sent to `POST /v1/change-requests/check`, which runs
12
+ * the checks a proposal meets and records nothing (a Function without its
13
+ * build: its manifest is checked when it is proposed)
14
+ *
15
+ * Pure: the platform call is the command's (`commands/local/sync-request.ts`).
16
+ */
17
+
18
+ /** A model of the workspace's catalog, as `/v1/sync/models` lists it. */
19
+ export interface WorkspaceModel {
20
+ slug: string
21
+ name: string
22
+ kind: string
23
+ }
24
+
25
+ /**
26
+ * Block each item that names a model the workspace lacks, and return those
27
+ * models (local slugs) with the items that name them. The files are already
28
+ * the workspace's form, so a mapped model reads as the workspace's.
29
+ */
30
+ export function markMissingModels(items: PushItem[], workspace: ReadonlySet<string>): Map<string, string[]> {
31
+ const missing = new Map<string, string[]>()
32
+ for (const item of items) {
33
+ if (!item.proposable || item.change === 'deleted') continue
34
+ const named = [...new Set(Object.entries(item.files).flatMap(([path, text]) => modelsNamed(path, text)))].filter((slug) => !workspace.has(slug))
35
+ if (!named.length) continue
36
+ for (const slug of named) missing.set(slug, [...(missing.get(slug) ?? []), item.ref])
37
+ item.missingModels = named
38
+ Object.assign(item, {
39
+ proposable: false,
40
+ blocked: 'model',
41
+ reason: `Uses ${named.join(' and ')}, which the workspace doesn’t have`,
42
+ fix: `choose the workspace model it goes as (\`frontera local sync map-model ${named[0]} <workspace model>\`); it keeps using ${named.length === 1 ? 'it' : 'them'} here`,
43
+ })
44
+ }
45
+ return missing
46
+ }
47
+
48
+ /** The request's items as the check takes them: no Function build and no skill assets, which are made only when proposed. */
49
+ export function checkBody(items: PushItem[]): Array<Record<string, unknown>> {
50
+ return items
51
+ .filter((i) => i.proposable)
52
+ .map((i) => ({
53
+ ref: i.ref,
54
+ ...(i.id ? { id: i.id } : {}),
55
+ ...(i.baseRevision ? { baseRevision: i.baseRevision } : {}),
56
+ files: i.files,
57
+ ...(i.change === 'deleted' ? { deleted: true } : {}),
58
+ ...(i.documents ? { documents: { upload: i.documents.upload.map(({ change: _change, ...d }) => d), remove: i.documents.remove } } : {}),
59
+ }))
60
+ }
61
+
62
+ /** What makes a refused item proposable, in words, by the check's code and message; null when only the file can say. */
63
+ export function fixFor(check: Check): string | null {
64
+ const ref = check.ref
65
+ if (check.code === 'stale_base' || check.code === 'exists' || check.code === 'gone') return `pull it first (\`frontera local sync pull ${ref}\`), then propose`
66
+ if (check.code === 'unpublished_draft') return 'publish or discard its draft on the platform, then fetch and propose'
67
+ if (check.code === 'blueprint_backing') return 'leave it out: a dataset binding is set on the platform'
68
+ if (check.code === 'blueprint_draft') return 'publish or discard the Blueprint’s draft on the platform, then propose'
69
+ const model = /the model (\S+) is not in this workspace/.exec(check.message)
70
+ if (model) return `choose the workspace model it goes as (\`frontera local sync map-model ${model[1]} <workspace model>\`)`
71
+ if (check.code === 'unresolved') return 'propose what it names too, or create that in the workspace first'
72
+ return null
73
+ }
74
+
75
+ /** The message without the file it names, and the file: a row says why first. */
76
+ 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)
78
+ if (!m) return { why: message, file: null }
79
+ return { why: m[2]!.charAt(0).toUpperCase() + m[2]!.slice(1), file: m[1]! }
80
+ }
81
+
82
+ /** Block each item the workspace's checks would refuse, with the reason first and its fix. Returns the refs refused. */
83
+ export function markRefused(items: PushItem[], checks: Check[]): string[] {
84
+ const byRef = new Map(items.map((i) => [i.ref, i]))
85
+ const refused: string[] = []
86
+ for (const check of checks) {
87
+ const item = byRef.get(check.ref)
88
+ if (!item || !item.proposable) continue
89
+ const { why, file } = splitCheckMessage(check.message)
90
+ Object.assign(item, { proposable: false, blocked: 'refused', reason: file ? `${why} (${file})` : why, fix: fixFor(check) })
91
+ refused.push(item.ref)
92
+ }
93
+ return refused
94
+ }