@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.
- package/package.json +4 -4
- package/src/commands/kit/vendor.ts +62 -48
- package/src/commands/local/check.ts +17 -9
- package/src/commands/local/index-commands.ts +7 -1
- package/src/commands/local/init.ts +22 -2
- package/src/commands/local/snapshot.ts +8 -2
- package/src/commands/local/sync-request.ts +95 -9
- package/src/commands/local/sync.ts +27 -6
- package/src/commands/registry.ts +2 -1
- package/src/flag-help.ts +1 -0
- package/src/local/local-case-evidence.ts +3 -2
- package/src/local/local-db-snapshot.ts +23 -3
- package/src/local/local-dev-references.ts +59 -7
- package/src/local/local-env.ts +21 -0
- package/src/local/local-history-carry.ts +103 -0
- package/src/local/local-key-custodian.ts +64 -0
- package/src/local/local-keys.ts +5 -3
- package/src/local/local-knowledge-uploads.ts +62 -5
- package/src/local/local-scaffold.ts +48 -13
- package/src/local/local-schemas.ts +1 -1
- package/src/local/local-seed.ts +1 -1
- package/src/local/local-studio-server.ts +14 -8
- package/src/local/local-supervisor.ts +13 -0
- package/src/local/local-sync-link.ts +43 -2
- package/src/local/local-sync-models.ts +118 -0
- package/src/local/local-sync-plan.ts +19 -9
- package/src/local/local-sync-portable.ts +269 -0
- package/src/local/local-sync-precheck.ts +94 -0
- package/src/local/local-sync-push.ts +45 -7
- package/src/local/local-sync-remote.ts +39 -8
- package/src/local/local-sync-snapshot.ts +9 -1
- package/src/vendor/kit-assets.json +66 -5
|
@@ -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
|
-
/**
|
|
59
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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<
|
|
195
|
-
: quietly(warnings, 'Models', async () => rows<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 } : {}),
|