@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.
@@ -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
+ }
@@ -16,8 +16,11 @@ import { lessonsPath, lessonsProposal } from './local-lessons'
16
16
  import { importedFiles } from './local-function-shared'
17
17
  import { isIgnoredName } from './local-dev-sync'
18
18
  import { comparableText, withoutSecrets } from './local-secrets'
19
+ import { projectModels } from './local-sync-models'
19
20
  import { deepEqual } from './local-sync-merge'
21
+ import { portableNotes, sameDocumentText, toLocal, toWorkspace } from './local-sync-portable'
20
22
  import { itemTitle } from './local-sync-names'
23
+ import { isScaffoldSample } from './local-scaffold'
21
24
  import { hash, itemHash, readLocal, safePath, writeLock, type LockEntry, type PlanItem, type RemoteItem, type SyncKind, type SyncLock } from './local-sync-plan'
22
25
  import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync-snapshot'
23
26
 
@@ -55,8 +58,12 @@ const NOT_DELETED: Partial<Record<SyncKind, string>> = {
55
58
  lessons: 'An Agent’s rules are retired on the platform, where they are reviewed: a change request only proposes new ones',
56
59
  }
57
60
 
58
- /** Why an item cannot be proposed now. */
59
- export type BlockCode = 'moved' | 'conflicts' | 'deleted' | 'workspace-deleted' | 'kind' | 'shape'
61
+ /**
62
+ * Why an item cannot be proposed now. `model`: it names a model the workspace
63
+ * lacks and `sync.models` maps to none; `refused`: the workspace's checks
64
+ * would refuse it (`POST /v1/change-requests/check`), said before proposing.
65
+ */
66
+ export type BlockCode = 'moved' | 'conflicts' | 'deleted' | 'workspace-deleted' | 'kind' | 'shape' | 'model' | 'refused'
60
67
 
61
68
  /** A skill's file that is not text (an image, a font, a template): uploaded as a skill asset when proposed. */
62
69
  export interface PushAsset {
@@ -96,6 +103,15 @@ export interface PushItem {
96
103
  /** Why not, when it is not. */
97
104
  blocked: BlockCode | null
98
105
  reason: string | null
106
+ /** What makes it proposable, when something here can: the command or the choice. */
107
+ fix?: string | null
108
+ /**
109
+ * Still exactly the sample setting up the project wrote (`isScaffoldSample`):
110
+ * proposable, but left out unless named or ticked.
111
+ */
112
+ sample?: boolean
113
+ /** The models it names that the workspace lacks and `sync.models` maps to none, by local slug. */
114
+ missingModels?: string[]
99
115
  /** The platform's id, from the lock (or the snapshot). */
100
116
  id: string | null
101
117
  /** The revision the item was changed from; for a Blueprint file, the Blueprint's. */
@@ -165,7 +181,7 @@ function functionImports(root: string, entry: string): string[] {
165
181
  * service). The lock's paths that still exist come along, so nothing the
166
182
  * last pull wrote is left behind.
167
183
  */
168
- export function itemFiles(root: string, kind: SyncKind, name: string, entry?: LockEntry, opts: { documents?: boolean } = {}): { files: Record<string, string>; notes: string[]; problem?: string; assets?: string[] } {
184
+ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: LockEntry, opts: { documents?: boolean; upstream?: Record<string, string> } = {}): { files: Record<string, string>; notes: string[]; problem?: string; assets?: string[] } {
169
185
  const notes: string[] = []
170
186
  let paths: string[] = []
171
187
  const assets: string[] = []
@@ -228,6 +244,8 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
228
244
  // What the last pull wrote for it comes along when it is still here (an agent's files are fixed; the rest may not be).
229
245
  if (kind !== 'agent' && kind !== 'knowledge') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
230
246
  const files: Record<string, string> = {}
247
+ const here: Record<string, string> = {}
248
+ const models = projectModels(root)
231
249
  for (const path of [...new Set(paths)].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
232
250
  if (path.endsWith('.workspace') || assets.includes(path)) continue
233
251
  const text = readLocal(root, path)
@@ -236,8 +254,13 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
236
254
  const clean = withoutSecrets(text)
237
255
  files[path] = clean.text
238
256
  notes.push(...clean.notes)
239
- } else files[path] = text
257
+ } else {
258
+ // As the workspace will have it: a mapped model swapped, the multi-turn evaluation cases left here.
259
+ files[path] = toWorkspace(path, text, models, opts.upstream?.[path] ?? null).text
260
+ here[path] = text
261
+ }
240
262
  }
263
+ notes.push(...portableNotes(here, models, models.names, opts.upstream))
241
264
  return { files, notes, ...(kind === 'skill' ? { assets } : {}) }
242
265
  }
243
266
 
@@ -305,7 +328,7 @@ export interface CandidateInput {
305
328
  }
306
329
  }
307
330
 
308
- const BLOCK_REASON: Record<Exclude<BlockCode, 'kind' | 'shape'>, string> = {
331
+ const BLOCK_REASON: Record<Exclude<BlockCode, 'kind' | 'shape' | 'model' | 'refused'>, string> = {
309
332
  moved: 'Changed in the workspace too since your last pull: pull and merge it first',
310
333
  conflicts: 'Has conflicts from a pull: resolve them first',
311
334
  deleted: 'Deleted here',
@@ -389,7 +412,7 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
389
412
  if (status === 'new' || status === 'changed') continue
390
413
  const deleted = status === 'deleted-local'
391
414
  const bringDocuments = kind === 'knowledge' && !!input.documents?.names.has(name)
392
- const { files, notes, problem, assets: assetPaths } = deleted ? { files: {} as Record<string, string>, notes: [] as string[], problem: undefined, assets: undefined } : itemFiles(root, kind, name, entry, { documents: bringDocuments })
415
+ const { files, notes, problem, assets: assetPaths } = deleted ? { files: {} as Record<string, string>, notes: [] as string[], problem: undefined, assets: undefined } : itemFiles(root, kind, name, entry, { documents: bringDocuments, ...(theirs ? { upstream: theirs.files } : {}) })
393
416
  // What a file hash does not cover: a skill's assets, and a knowledge base's documents when they are brought.
394
417
  const assetState = kind === 'skill' && !deleted && (assetPaths?.length || theirs?.assets?.length) ? skillAssets(root, name, assetPaths ?? [], theirs) : null
395
418
  const docs = bringDocuments && !deleted && input.documents
@@ -422,6 +445,10 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
422
445
  base: baseFiles,
423
446
  notes,
424
447
  }
448
+ if (!deleted && isScaffoldSample(files)) {
449
+ item.sample = true
450
+ notes.push('A sample from setting up the project, not your work: it stays here unless you tick it (or name it to `frontera local sync push`)')
451
+ }
425
452
  if (assetState) {
426
453
  item.assets = assetState.assets
427
454
  for (const a of assetState.assets) if (a.change !== 'same') notes.push(`Asset ${a.path}: ${a.change === 'new' ? 'new' : 'changed'} (${kb(a.bytes)})`)
@@ -770,9 +797,20 @@ export function adoptApplied(root: string, workspace: string, refs: string[], re
770
797
  if (!item) continue
771
798
  for (const [path, text] of Object.entries(item.files)) {
772
799
  const here = readLocal(root, path)
773
- if (here !== null && here !== text && sameDocument(path, here, text)) {
800
+ if (here === null || here === text) continue
801
+ if (sameDocument(path, here, text)) {
774
802
  writeFileSync(safePath(root, path), text)
775
803
  rewritten.push(path)
804
+ continue
805
+ }
806
+ // What the workspace now holds, with this computer's parts kept (a mapped model, the multi-turn cases).
807
+ const portable = toWorkspace(path, here, projectModels(root), text).text
808
+ if (portable !== here && (sameDocument(path, portable, text) || sameDocumentText(path, portable, text))) {
809
+ const local = toLocal(path, text, here, projectModels(root))
810
+ if (local !== here) {
811
+ writeFileSync(safePath(root, path), local)
812
+ rewritten.push(path)
813
+ }
776
814
  }
777
815
  }
778
816
  next.items[ref] = {
@@ -164,7 +164,8 @@ async function listActions(org: SyncClient): Promise<ActionRow[]> {
164
164
  const d = ((row as { definition?: unknown } | null)?.definition ?? row) as Partial<ActionRow> | null
165
165
  return d && typeof d.id === 'string' && typeof d.apiName === 'string' ? [{ id: d.id, apiName: d.apiName }] : []
166
166
  }
167
- const draft = rows<unknown>(await org.get('/v1/blueprint/actions?view=draft')).flatMap(pick)
167
+ // An organization that has never published a Blueprint has no draft to read: no Actions (404).
168
+ const draft = rows<unknown>(await org.find('/v1/blueprint/actions?view=draft')).flatMap(pick)
168
169
  const published = rows<unknown>(await org.get('/v1/blueprint/actions').catch(() => null)).flatMap(pick)
169
170
  return [...new Map([...published, ...draft].map((a) => [a.id, a])).values()]
170
171
  }
@@ -191,8 +192,8 @@ export async function buildDirectory(source: SyncSource, warnings: string[] = []
191
192
  quietly(warnings, 'Blueprint object types', async () => rows<{ id: string; apiName: string }>(await ws.get('/v1/blueprint/object-types')), []),
192
193
  // The model catalog is organization-grain: a workspace key reads its names only, where the platform offers them.
193
194
  source.organization
194
- ? quietly(warnings, 'Models', async () => rows<{ id: string; slug: string }>(await source.organization!.get('/v1/config/models/catalog')), [])
195
- : quietly(warnings, 'Models', async () => rows<{ id: string; slug: string }>(await ws.find<unknown>('/v1/sync/models')), []),
195
+ ? quietly(warnings, 'Models', async () => rows<CatalogModelRow>(await source.organization!.get('/v1/config/models/catalog')), [])
196
+ : quietly(warnings, 'Models', async () => rows<CatalogModelRow>(await ws.find<unknown>('/v1/sync/models')), []),
196
197
  // So are governed Actions' definitions: without the organization key they are not listed.
197
198
  source.organization ? quietly<ActionRow[] | null>(warnings, 'Governed Actions', () => listActions(source.organization!), null) : Promise.resolve(null),
198
199
  // A platform without the route has none to name: absent, as for Actions.
@@ -221,7 +222,28 @@ export async function buildDirectory(source: SyncSource, warnings: string[] = []
221
222
  ...(objectSets ? { objectSet: new Map((objectSets ?? []).map((o) => [setNames.get(o.id)!, o.id])) } : {}),
222
223
  ...(objectDefinitions ? { object: new Map(objectDefinitions.map((o) => [o.slug, o.id])) } : {}),
223
224
  }
224
- return { directory, lists: { agents, workflows, functions, knowledge, apps, skills, objectSets: objectSets ?? [], objectDefinitions } }
225
+ const catalog = models.map((m) => ({ slug: m.slug, name: m.name ?? m.slug, kind: m.kind ?? 'text', ...(m.provider ? { provider: m.provider } : {}), ...(m.modelId ? { modelId: m.modelId } : {}) }))
226
+ return { directory, lists: { agents, workflows, functions, knowledge, apps, skills, objectSets: objectSets ?? [], objectDefinitions, models: catalog } }
227
+ }
228
+
229
+ /** A model of the organization's catalog as the workspace lists it; the provider's model id tells two slugs for one model apart. */
230
+ interface CatalogModelRow {
231
+ id: string
232
+ slug: string
233
+ name?: string
234
+ kind?: string
235
+ provider?: string
236
+ modelId?: string
237
+ }
238
+
239
+ /** A model of the workspace's catalog, kept with the snapshot: what a project's model can be matched against. */
240
+ export interface WorkspaceCatalogModel {
241
+ slug: string
242
+ name: string
243
+ kind: string
244
+ provider?: string
245
+ /** The provider's id for it (`deepseek/deepseek-v4.1-flash`): the same model under another slug has the same one. */
246
+ modelId?: string
225
247
  }
226
248
 
227
249
  export interface Lists {
@@ -235,6 +257,8 @@ export interface Lists {
235
257
  objectSets?: ObjectSetRow[]
236
258
  /** The object definitions an Agent's object tools work on; null when the platform doesn't list them. */
237
259
  objectDefinitions?: ObjectDefinitionRow[] | null
260
+ /** The organization's model catalog, as far as the key reads it. */
261
+ models?: WorkspaceCatalogModel[]
238
262
  }
239
263
 
240
264
  export interface ReadOptions {
@@ -1251,7 +1275,13 @@ async function readBlueprint(
1251
1275
  // The workspace's grants beyond read, from its slice (the organization's model has none of a workspace's).
1252
1276
  let grants: RemoteItem | null = null
1253
1277
  if (mode === 'organization' && source.organization) {
1254
- const lifecycle = one<Lifecycle>(await source.organization.get('/v1/blueprint/lifecycle'))
1278
+ // None (404) for an organization that has never published or drafted a Blueprint: nothing to pull.
1279
+ const found = await source.organization.find<unknown>('/v1/blueprint/lifecycle')
1280
+ if (!found) {
1281
+ notes.push('The organization has no Blueprint yet; nothing was pulled into blueprint/')
1282
+ return []
1283
+ }
1284
+ const lifecycle = one<Lifecycle>(found)
1255
1285
  fromDraft = !!lifecycle.draft?.definition && (!!opts.draft || !lifecycle.activeRelease?.definition)
1256
1286
  answer = { definition: (fromDraft ? lifecycle.draft?.definition : lifecycle.activeRelease?.definition) ?? null, datasetNames: await datasetNames(source.organization) }
1257
1287
  if (fromDraft) notes.push(opts.draft ? 'The Blueprint was read from its unpublished draft, as asked' : 'The Blueprint has no published release; its draft was read')
@@ -1270,7 +1300,8 @@ async function readBlueprint(
1270
1300
  if (opts.draft && !source.organization) notes.push('Reading the Blueprint’s unpublished draft takes an organization key; its published release was read')
1271
1301
  if (opts.draft && source.organization && answer) {
1272
1302
  // The draft, cut to the object types the workspace's slice holds now.
1273
- const lifecycle = one<Lifecycle>(await source.organization.get('/v1/blueprint/lifecycle'))
1303
+ const found = await source.organization.find<unknown>('/v1/blueprint/lifecycle')
1304
+ const lifecycle = found ? one<Lifecycle>(found) : ({} as Lifecycle)
1274
1305
  const granted = new Set(((answer.definition as { objects?: Array<{ apiName: string }> } | null)?.objects ?? []).map((o) => o.apiName))
1275
1306
  if (lifecycle.draft?.definition) {
1276
1307
  answer = { definition: sliceDraft(lifecycle.draft.definition, granted), datasetNames: { ...(answer.datasetNames ?? {}), ...(await datasetNames(source.organization)) } }
@@ -1606,7 +1637,7 @@ async function readEvaluations(source: SyncSource, lists: Lists, directory: Dire
1606
1637
  * collects what could not be read (a kind the key may not see, a platform
1607
1638
  * too old for an export), so the plan says so instead of failing.
1608
1639
  */
1609
- export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}): Promise<{ items: RemoteItem[]; warnings: string[]; notes: string[]; directory: Directory }> {
1640
+ export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}): Promise<{ items: RemoteItem[]; warnings: string[]; notes: string[]; directory: Directory; models: WorkspaceCatalogModel[] }> {
1610
1641
  const warnings: string[] = []
1611
1642
  const notes: string[] = []
1612
1643
  const { directory, lists } = await buildDirectory(source, warnings)
@@ -1694,7 +1725,7 @@ export async function readWorkspace(source: SyncSource, opts: ReadOptions = {}):
1694
1725
  ? 'Governed Actions are named only with an organization key (they are organization-wide); an Action step shows as an id until you connect one'
1695
1726
  : 'Models and governed Actions are named only with an organization key (both are organization-wide); an LLM step’s model or an Action step shows as an id until you connect one')
1696
1727
  }
1697
- return { items, warnings, notes, directory }
1728
+ return { items, warnings, notes, directory, models: lists.models ?? [] }
1698
1729
  }
1699
1730
 
1700
1731
  /**
@@ -7,7 +7,7 @@ import { writeFileAtomic } from '../fs-atomic'
7
7
  import { hash, itemHash, safePath, SYNC_KINDS, type RemoteItem, type SyncKind, type SyncLock } from './local-sync-plan'
8
8
  import { readOrgSettings, type OrgSettings } from './local-org-settings'
9
9
  import type { Portability } from './local-portability'
10
- import { readWorkspace, type SyncSource } from './local-sync-remote'
10
+ import { readWorkspace, type SyncSource, type WorkspaceCatalogModel } from './local-sync-remote'
11
11
  import { REF_KINDS, type Directory, type RefKind, type Unresolved } from './local-sync-refs'
12
12
 
13
13
  /**
@@ -86,6 +86,12 @@ export interface RemoteSnapshot {
86
86
  * now written `action/resetLine`) from a change.
87
87
  */
88
88
  names?: Partial<Record<RefKind, Record<string, string>>>
89
+ /**
90
+ * The organization's model catalog as of the last read, with each model's
91
+ * provider id, so a model this computer names under another slug is known
92
+ * as the workspace's (`local-sync-models.ts`).
93
+ */
94
+ models?: WorkspaceCatalogModel[]
89
95
  /**
90
96
  * Read with an organization key as well: it brings governed Actions (and,
91
97
  * for older platforms, the Blueprint). A snapshot read with other keys is
@@ -423,6 +429,7 @@ export async function fetchWorkspace(root: string, source: SyncSource, opts: Fet
423
429
  warnings: read.warnings,
424
430
  notes: read.notes,
425
431
  names: namesOf(read.directory),
432
+ ...(read.models.length ? { models: read.models } : {}),
426
433
  organizationKey: !!source.organization,
427
434
  reader: SNAPSHOT_READER,
428
435
  ...(settings ? { settings } : {}),
@@ -522,6 +529,7 @@ export async function fetchWorkspace(root: string, source: SyncSource, opts: Fet
522
529
  warnings: read?.warnings ?? [],
523
530
  notes: read ? read.notes : previous?.notes ?? [],
524
531
  ...(read ? { names: namesOf(read.directory) } : previous?.names ? { names: previous.names } : {}),
532
+ ...(read?.models.length ? { models: read.models } : previous?.models ? { models: previous.models } : {}),
525
533
  organizationKey: !!source.organization,
526
534
  reader: SNAPSHOT_READER,
527
535
  ...(settings ? { settings } : previous?.settings ? { settings: previous.settings } : {}),