@frontera-sdk/cli 1.52.42 → 1.52.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/commands/local/check.ts +17 -9
- package/src/commands/local/index-commands.ts +7 -1
- 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/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-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-studio-server.ts +1 -1
- 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
|
@@ -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
|
-
/**
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|