@frontera-sdk/cli 1.52.45 → 1.52.47
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/api/apps-api.ts +3 -1
- package/src/api/automation-api.ts +32 -0
- package/src/api/credential-failure.ts +1 -1
- package/src/api/governed-action-api.ts +24 -0
- package/src/api/media-api.ts +24 -0
- package/src/api/platform-api.ts +13 -0
- package/src/binding-registry.ts +9 -2
- package/src/blueprint/compile.ts +3 -1
- package/src/blueprint/ontology-edit-plan.ts +71 -4
- package/src/blueprint/org-name-check.ts +64 -0
- package/src/blueprint/tree.ts +28 -0
- package/src/commands/action/deploy.ts +22 -5
- package/src/commands/action/index-commands.ts +2 -0
- package/src/commands/action/retire.ts +60 -0
- package/src/commands/agent/compose.ts +1 -0
- package/src/commands/agent/index-commands.ts +42 -0
- package/src/commands/app/deploy.ts +1 -1
- package/src/commands/app/dev.ts +2 -2
- package/src/commands/app/save.ts +1 -1
- package/src/commands/auth/add.ts +5 -1
- package/src/commands/auth/current.ts +6 -0
- package/src/commands/auth/index-commands.ts +3 -0
- package/src/commands/auth/login.ts +84 -0
- package/src/commands/auth/use.ts +21 -2
- package/src/commands/automation/bindings.ts +54 -10
- package/src/commands/automation/index-commands.ts +2 -3
- package/src/commands/blueprint/bind.ts +12 -1
- package/src/commands/blueprint/declarative.ts +6 -2
- package/src/commands/conversation/index-commands.ts +66 -0
- package/src/commands/conversation/local-runtime.ts +18 -0
- package/src/commands/local/check.ts +57 -11
- package/src/commands/local/data.ts +75 -7
- package/src/commands/local/dev.ts +77 -9
- package/src/commands/local/embeddings.ts +74 -0
- package/src/commands/local/index-commands.ts +133 -11
- package/src/commands/local/new.ts +78 -0
- package/src/commands/local/publish.ts +34 -0
- package/src/commands/local/steps.ts +105 -0
- package/src/commands/local/sync-request.ts +69 -23
- package/src/commands/local/sync.ts +2 -0
- package/src/commands/media/index-commands.ts +58 -3
- package/src/commands/registry.ts +11 -3
- package/src/commands/trace/index-commands.ts +56 -0
- package/src/commands/workspace/index-commands.ts +14 -7
- package/src/config.ts +4 -1
- package/src/dev-broker.ts +15 -1
- package/src/device-login.ts +143 -0
- package/src/exit.ts +36 -8
- package/src/flag-help.ts +30 -0
- package/src/forge/claude-adapter.ts +17 -4
- package/src/forge/permission-rules.ts +158 -0
- package/src/forge/touches.ts +7 -6
- package/src/function-outside-imports.ts +13 -2
- package/src/function-source.ts +90 -0
- package/src/harness.ts +4 -4
- package/src/key-reach.ts +64 -0
- package/src/local/local-actions.ts +15 -3
- package/src/local/local-apply-error.ts +26 -0
- package/src/local/local-apps.ts +9 -3
- package/src/local/local-blueprint-schemas.ts +15 -1
- package/src/local/local-confidential.ts +81 -0
- package/src/local/local-data-load.ts +130 -0
- package/src/local/local-dev-references.ts +24 -4
- package/src/local/local-embedding-setup.ts +62 -0
- package/src/local/local-jobs.ts +108 -0
- package/src/local/local-new.ts +148 -0
- package/src/local/local-org-settings.ts +1 -1
- package/src/local/local-person-steps.ts +340 -0
- package/src/local/local-personas.ts +16 -5
- package/src/local/local-portability.ts +22 -1
- package/src/local/local-publish.ts +26 -0
- package/src/local/local-request-evidence.ts +35 -6
- package/src/local/local-runtime-history.ts +4 -3
- package/src/local/local-said-once.ts +51 -0
- package/src/local/local-scaffold.ts +40 -2
- package/src/local/local-schemas.ts +60 -11
- package/src/local/local-stand-ins.ts +44 -0
- package/src/local/local-supervisor.ts +22 -0
- package/src/local/local-sync-models.ts +5 -2
- package/src/local/local-sync-names.ts +3 -0
- package/src/local/local-sync-portable.ts +64 -15
- package/src/local/local-sync-precheck.ts +30 -1
- package/src/local/local-sync-push.ts +81 -8
- package/src/local/local-sync-remote.ts +10 -7
- package/src/local/local-test-models.ts +173 -0
- package/src/local/local-test-runner.ts +232 -25
- package/src/local/local-test-scope.ts +174 -0
- package/src/local/local-ungranted-reads.ts +55 -0
- package/src/local/local-writers.ts +171 -0
- package/src/main.ts +75 -17
- package/src/mcp/browser/ax-snapshot.ts +13 -1
- package/src/mcp/browser/page-scripts.ts +1 -0
- package/src/mcp/mcp-conversations.ts +171 -0
- package/src/mcp/mcp-server.ts +4 -1
- package/src/mcp/mcp-traces.ts +7 -2
- package/src/mcp/tools/agent-tools.ts +94 -2
- package/src/mcp/tools/project-tools.ts +149 -13
- package/src/mcp/tools/test-tools.ts +64 -13
- package/src/output.ts +22 -7
- package/src/pack.ts +16 -0
- package/src/project-context.ts +18 -5
- package/src/project.ts +4 -0
- package/src/remote-guard.ts +52 -0
- package/src/vendor/kit-assets.json +11 -11
|
@@ -13,8 +13,12 @@ import { deepEqual } from './local-sync-merge'
|
|
|
13
13
|
* a model the workspace lacks which model an Agent runs on is the workspace's choice: the Agent keeps
|
|
14
14
|
* the workspace's model selection (its modes and default), a new Agent
|
|
15
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
|
|
17
|
-
* evaluation datasets hold
|
|
16
|
+
* a multi-turn evaluation case `frontera local test` runs a conversation of several turns. A workspace whose
|
|
17
|
+
* evaluation datasets hold conversations (`evaluation-conversations`, L3) takes
|
|
18
|
+
* them, except one that waits before a turn (`waitSeconds`), which runs on this
|
|
19
|
+
* computer only; an older workspace holds single questions, so those stay here.
|
|
20
|
+
* an Action's `attachment` how the local runtime deploys an Action that takes a file (L6): in the
|
|
21
|
+
* workspace the person who deploys it chooses, so it stays in the file here.
|
|
18
22
|
*
|
|
19
23
|
* `toWorkspace` is the file as the workspace has it: what Propose sends, and
|
|
20
24
|
* what status and push compare with the workspace. `toLocal` is the
|
|
@@ -29,6 +33,7 @@ export type ModelMap = Record<string, string>
|
|
|
29
33
|
const AGENT_FILE = /^agents\/[^/]+\/agent\.json$/
|
|
30
34
|
const WORKFLOW_FILE = /^workflows\/[^/]+\.ya?ml$/
|
|
31
35
|
const EVAL_FILE = /^agents\/[^/]+\/evals\/[^/]+\.ya?ml$/
|
|
36
|
+
const ACTION_FILE = /^blueprint\/actions\/[^/]+\.ya?ml$/
|
|
32
37
|
|
|
33
38
|
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
34
39
|
|
|
@@ -62,16 +67,21 @@ interface CaseNode {
|
|
|
62
67
|
toJSON(): unknown
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
70
|
+
/**
|
|
71
|
+
* An evaluation file's cases, each with whether it stays here: a
|
|
72
|
+
* conversation of more than one turn, unless the workspace holds
|
|
73
|
+
* conversations (`conversations`) and none of its turns waits.
|
|
74
|
+
*/
|
|
75
|
+
function evaluationCases(doc: Document, conversations = false): Array<{ node: CaseNode; multiTurn: boolean; name: string | null; question: string | null }> {
|
|
67
76
|
const cases = doc.get('cases', true)
|
|
68
77
|
if (!isSeq(cases)) return []
|
|
69
78
|
return cases.items.map((node) => {
|
|
70
79
|
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 }>) : []
|
|
80
|
+
const turns = Array.isArray(value?.conversation) ? (value!.conversation as Array<{ user?: unknown; waitSeconds?: unknown } | null>) : []
|
|
81
|
+
const waits = turns.some((t) => t?.waitSeconds !== undefined)
|
|
72
82
|
return {
|
|
73
83
|
node: node as CaseNode,
|
|
74
|
-
multiTurn: value?.scenario === undefined && turns.length > 1,
|
|
84
|
+
multiTurn: value?.scenario === undefined && turns.length > 1 && (!conversations || waits),
|
|
75
85
|
name: typeof value?.name === 'string' ? value.name : null,
|
|
76
86
|
question: typeof turns[0]?.user === 'string' ? (turns[0]!.user as string).trim() : null,
|
|
77
87
|
}
|
|
@@ -84,6 +94,8 @@ export interface Portable {
|
|
|
84
94
|
models: Array<[string, string]>
|
|
85
95
|
/** Evaluation cases that stay here (more than one turn), by name. */
|
|
86
96
|
keptHere: string[]
|
|
97
|
+
/** An Action file's `attachment`, its deployment choice for the local runtime, stays here (L6). */
|
|
98
|
+
attachmentKept?: boolean
|
|
87
99
|
/** Models the workspace lacks that the file names here; the workspace keeps its own selection (`upstream`: its models, none for a new Agent). */
|
|
88
100
|
localModels?: { slugs: string[]; upstream: string[] }
|
|
89
101
|
}
|
|
@@ -93,6 +105,8 @@ export interface ModelContext {
|
|
|
93
105
|
map?: ModelMap
|
|
94
106
|
/** The workspace's slugs; null or absent: unknown, so nothing is judged missing. */
|
|
95
107
|
workspace?: ReadonlySet<string> | null
|
|
108
|
+
/** The workspace's evaluation datasets hold conversations (L3), so a multi-turn case goes unless it waits. */
|
|
109
|
+
conversations?: boolean
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
interface AgentDoc {
|
|
@@ -131,18 +145,25 @@ function agentAsWorkspace(text: string, workspace: ReadonlySet<string>, upstream
|
|
|
131
145
|
let modelConfigs: Array<Record<string, unknown>>
|
|
132
146
|
if (theirs) {
|
|
133
147
|
modelConfigs = theirs.modelConfigs ?? []
|
|
134
|
-
|
|
135
|
-
|
|
148
|
+
// The default and the fallback name modes: both are the workspace's selection.
|
|
149
|
+
for (const key of ['defaultMode', 'fallbackMode'] as const) {
|
|
150
|
+
if (theirs.config && key in theirs.config) config[key] = theirs.config[key]
|
|
151
|
+
else delete config[key]
|
|
152
|
+
}
|
|
136
153
|
} else {
|
|
137
154
|
modelConfigs = (doc.modelConfigs ?? []).filter((m) => !(typeof m?.modelSlug === 'string' && missing.includes(m.modelSlug)))
|
|
138
155
|
if (!modelConfigs.some((m) => m.modeKey === config.defaultMode)) config.defaultMode = (modelConfigs[0]?.modeKey as string | undefined) ?? null
|
|
156
|
+
// Gone with its model, or now the default: either way no longer a fallback.
|
|
157
|
+
if (config.fallbackMode && (config.fallbackMode === config.defaultMode || !modelConfigs.some((m) => m.modeKey === config.fallbackMode))) {
|
|
158
|
+
config.fallbackMode = null
|
|
159
|
+
}
|
|
139
160
|
}
|
|
140
161
|
return { text: agentText({ ...doc, config, modelConfigs }), slugs: missing, upstream: slugsOf({ modelConfigs }) }
|
|
141
162
|
}
|
|
142
163
|
|
|
143
164
|
/** The file as the workspace has it. Unchanged text when nothing in it is this computer's. */
|
|
144
165
|
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 }
|
|
166
|
+
const ctx: ModelContext = 'map' in models || 'workspace' in models || 'conversations' in models ? (models as ModelContext) : { map: models as ModelMap }
|
|
146
167
|
const map = ctx.map ?? {}
|
|
147
168
|
if (EVAL_FILE.test(path)) {
|
|
148
169
|
let doc: Document
|
|
@@ -152,13 +173,20 @@ export function toWorkspace(path: string, text: string, models: ModelMap | Model
|
|
|
152
173
|
return { text, models: [], keptHere: [] }
|
|
153
174
|
}
|
|
154
175
|
if (doc.errors.length) return { text, models: [], keptHere: [] }
|
|
155
|
-
const cases = evaluationCases(doc)
|
|
176
|
+
const cases = evaluationCases(doc, ctx.conversations)
|
|
156
177
|
const kept = cases.filter((c) => c.multiTurn)
|
|
157
178
|
if (!kept.length) return { text, models: [], keptHere: [] }
|
|
158
179
|
const seq = doc.get('cases', true) as { items: unknown[] }
|
|
159
180
|
seq.items = seq.items.filter((_, i) => !cases[i]!.multiTurn)
|
|
160
181
|
return { text: doc.toString(), models: [], keptHere: kept.map((c) => c.name ?? `case ${cases.indexOf(c) + 1}`) }
|
|
161
182
|
}
|
|
183
|
+
// An Action's `attachment` is how this project's runtime deploys it; in the workspace the person deploying it chooses (L6).
|
|
184
|
+
if (ACTION_FILE.test(path)) {
|
|
185
|
+
const doc = parseDocument(text)
|
|
186
|
+
if (doc.errors.length || !doc.has('attachment')) return { text, models: [], keptHere: [] }
|
|
187
|
+
doc.delete('attachment')
|
|
188
|
+
return { text: doc.toString(), models: [], keptHere: [], attachmentKept: true }
|
|
189
|
+
}
|
|
162
190
|
const { text: swapped, swapped: done } = swapModels(path, text, map)
|
|
163
191
|
if (AGENT_FILE.test(path) && ctx.workspace) {
|
|
164
192
|
const local = agentAsWorkspace(swapped, ctx.workspace, upstream)
|
|
@@ -175,7 +203,7 @@ export function toWorkspace(path: string, text: string, models: ModelMap | Model
|
|
|
175
203
|
* as the last pull left it (`base`); one the workspace changed comes here.
|
|
176
204
|
*/
|
|
177
205
|
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 }
|
|
206
|
+
const ctx: ModelContext = 'map' in models || 'workspace' in models || 'conversations' in models ? (models as ModelContext) : { map: models as ModelMap }
|
|
179
207
|
const map = ctx.map ?? {}
|
|
180
208
|
if (EVAL_FILE.test(path)) {
|
|
181
209
|
if (local === null) return remote
|
|
@@ -188,7 +216,7 @@ export function toLocal(path: string, remote: string, local: string | null, mode
|
|
|
188
216
|
return remote
|
|
189
217
|
}
|
|
190
218
|
if (ours.errors.length || theirs.errors.length) return remote
|
|
191
|
-
const kept = evaluationCases(ours).filter((c) => c.multiTurn)
|
|
219
|
+
const kept = evaluationCases(ours, ctx.conversations).filter((c) => c.multiTurn)
|
|
192
220
|
if (!kept.length) return remote
|
|
193
221
|
const present = evaluationCases(theirs)
|
|
194
222
|
const seq = theirs.get('cases', true)
|
|
@@ -200,6 +228,15 @@ export function toLocal(path: string, remote: string, local: string | null, mode
|
|
|
200
228
|
}
|
|
201
229
|
return theirs.toString()
|
|
202
230
|
}
|
|
231
|
+
if (ACTION_FILE.test(path)) {
|
|
232
|
+
// The pulled Action with this project's `attachment` put back.
|
|
233
|
+
if (local === null) return remote
|
|
234
|
+
const ours = parseDocument(local)
|
|
235
|
+
const theirs = parseDocument(remote)
|
|
236
|
+
if (ours.errors.length || theirs.errors.length || !ours.has('attachment') || theirs.has('attachment')) return remote
|
|
237
|
+
theirs.set('attachment', ours.get('attachment', true))
|
|
238
|
+
return theirs.toString()
|
|
239
|
+
}
|
|
203
240
|
const written = swapModels(path, remote, reversed(map)).text
|
|
204
241
|
// An Agent this computer runs on a model the workspace lacks keeps it, while the workspace's selection is as it was.
|
|
205
242
|
if (AGENT_FILE.test(path) && local !== null && base && ctx.workspace) {
|
|
@@ -207,8 +244,16 @@ export function toLocal(path: string, remote: string, local: string | null, mode
|
|
|
207
244
|
const theirs = parseAgent(written)
|
|
208
245
|
const before = parseAgent(base)
|
|
209
246
|
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({
|
|
247
|
+
if (ours && theirs && before && localOnly && deepEqual(before.modelConfigs ?? [], parseAgent(remote)?.modelConfigs ?? []) && deepEqual(before.config?.defaultMode ?? null, parseAgent(remote)?.config?.defaultMode ?? null) && deepEqual(before.config?.fallbackMode ?? null, parseAgent(remote)?.config?.fallbackMode ?? null)) {
|
|
248
|
+
return agentText({
|
|
249
|
+
...theirs,
|
|
250
|
+
config: {
|
|
251
|
+
...(theirs.config ?? {}),
|
|
252
|
+
defaultMode: ours.config?.defaultMode ?? null,
|
|
253
|
+
...(ours.config && 'fallbackMode' in ours.config ? { fallbackMode: ours.config.fallbackMode } : {}),
|
|
254
|
+
},
|
|
255
|
+
modelConfigs: ours.modelConfigs ?? [],
|
|
256
|
+
})
|
|
212
257
|
}
|
|
213
258
|
}
|
|
214
259
|
return written
|
|
@@ -251,9 +296,13 @@ export function portableNotes(files: Record<string, string>, models: ModelMap |
|
|
|
251
296
|
const there = p.localModels.upstream.map((s) => names[s] ?? s)
|
|
252
297
|
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
298
|
}
|
|
299
|
+
if (p.attachmentKept) notes.push('Its attachment stays in this project: it is how the local runtime deploys the Action, and in the workspace the person who deploys it chooses (`frontera action deploy --attach … --file-input …`)')
|
|
254
300
|
if (p.keptHere.length) {
|
|
255
301
|
const n = p.keptHere.length
|
|
256
|
-
|
|
302
|
+
const why = (models as ModelContext).conversations
|
|
303
|
+
? `${n === 1 ? 'it waits' : 'they wait'} before a turn, and the workspace’s runner sends each turn as soon as the reply before it ends`
|
|
304
|
+
: 'the workspace’s evaluation datasets hold single questions'
|
|
305
|
+
notes.push(`${n} multi-turn case${n === 1 ? '' : 's'} stay${n === 1 ? 's' : ''} here (${p.keptHere.slice(0, 3).join(', ')}${n > 3 ? ', …' : ''}): ${why}, so \`frontera local test\` runs ${n === 1 ? 'it' : 'them'} on this computer only`)
|
|
257
306
|
}
|
|
258
307
|
}
|
|
259
308
|
for (const [from, to] of swapped) notes.push(`Uses ${from} here; the workspace has the same model as ${names[to] ?? to}`)
|
|
@@ -69,12 +69,41 @@ export function fixFor(check: Check): string | null {
|
|
|
69
69
|
const model = /the model (\S+) is not in this workspace/.exec(check.message)
|
|
70
70
|
if (model) return `choose the workspace model it goes as (\`frontera local sync map-model ${model[1]} <workspace model>\`)`
|
|
71
71
|
if (check.code === 'unresolved') return 'propose what it names too, or create that in the workspace first'
|
|
72
|
+
if (check.code === 'object_set_unresolved') return 'propose the set’s file (blueprint/object-sets/<name>.yaml) in this request too, or create the saved object set in the workspace first'
|
|
73
|
+
if (check.code === 'name_taken_in_organization') return 'rename the object type here to a more specific API name, or ask a Blueprint Steward to grant the workspace the organization’s type'
|
|
72
74
|
return null
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/** One step of Apply's plan, as the check returns it (N3): in Apply's order, with what it needs. */
|
|
78
|
+
export interface ApplyStep {
|
|
79
|
+
key: string
|
|
80
|
+
phase: string
|
|
81
|
+
ref: string
|
|
82
|
+
label: string
|
|
83
|
+
/** `afterApply`: Apply goes ahead and leaves the step for a person afterwards (N10). */
|
|
84
|
+
needs: Array<{ code: string; message: string; fix: string; met: boolean; afterApply?: boolean }>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The plan from the check's answer; empty from a platform that sends none. */
|
|
88
|
+
export function planOf(answer: unknown): ApplyStep[] {
|
|
89
|
+
const plan = (answer as { plan?: unknown } | null)?.plan
|
|
90
|
+
return Array.isArray(plan) ? (plan as ApplyStep[]).filter((s) => s && typeof s.key === 'string' && Array.isArray(s.needs)) : []
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The steps Apply would meet that the project's credentials can't take:
|
|
95
|
+
* a grant a Blueprint Steward makes, an Action's deployment decision. They
|
|
96
|
+
* don't stop a proposal; they are what a person does before Apply, or, for
|
|
97
|
+
* a new Agent's grants, after it (`after`), so Propose shows them under
|
|
98
|
+
* **Needs a person**.
|
|
99
|
+
*/
|
|
100
|
+
export function needsAPerson(plan: ApplyStep[]): Array<{ step: string; ref: string; code: string; message: string; fix: string; after?: true }> {
|
|
101
|
+
return plan.flatMap((s) => s.needs.filter((n) => !n.met).map((n) => ({ step: s.label, ref: s.ref, code: n.code, message: n.message, fix: n.fix, ...(n.afterApply ? { after: true as const } : {}) })))
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
/** The message without the file it names, and the file: a row says why first. */
|
|
76
105
|
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)
|
|
106
|
+
const m = /^((?:agents|workflows|functions|skills|knowledge|blueprint|datasets|mcp|chat-apps|apps)\/[^:\s]+):\s+(.*)$/s.exec(message)
|
|
78
107
|
if (!m) return { why: message, file: null }
|
|
79
108
|
return { why: m[2]!.charAt(0).toUpperCase() + m[2]!.slice(1), file: m[1]! }
|
|
80
109
|
}
|
|
@@ -31,7 +31,9 @@ import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync
|
|
|
31
31
|
*
|
|
32
32
|
* what is pushed items changed here (a merge or a resolve leaves them so) and items only here
|
|
33
33
|
* what is refused an item the workspace changed too (pull and merge first), one with conflicts,
|
|
34
|
-
* a deletion, and kinds a request does not carry (
|
|
34
|
+
* a deletion, and kinds a request does not carry (the brand, Library installations)
|
|
35
|
+
* datasets a blank dataset's columns, listed when an object type the push proposes reads it
|
|
36
|
+
* or when named, never its rows
|
|
35
37
|
* each item the lock's id, the revision its base is (the Blueprint's for a Blueprint file),
|
|
36
38
|
* every file of the item, and for a Function its `function deploy` build
|
|
37
39
|
* the request `.frontera/sync/requests.json` (not committed): the current one, so the next
|
|
@@ -42,18 +44,18 @@ import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync
|
|
|
42
44
|
*/
|
|
43
45
|
|
|
44
46
|
/** The kinds a change request carries. */
|
|
45
|
-
export const PUSH_KINDS: readonly SyncKind[] = ['blueprint', 'skill', 'knowledge', 'plugin', 'function', 'workflow', 'agent', 'evaluation', 'chat-app', 'lessons']
|
|
47
|
+
export const PUSH_KINDS: readonly SyncKind[] = ['blueprint', 'dataset', 'skill', 'knowledge', 'plugin', 'function', 'workflow', 'agent', 'evaluation', 'chat-app', 'lessons', 'app']
|
|
46
48
|
|
|
47
49
|
/** Why the other kinds are not proposed, in the reader's words. */
|
|
48
50
|
export const NOT_PROPOSED: Partial<Record<SyncKind, string>> = {
|
|
49
|
-
dataset: 'Datasets aren’t proposed; upload them on the platform',
|
|
50
51
|
brand: 'The organization’s brand is set on the platform, in Settings; it isn’t proposed from a project',
|
|
51
52
|
library: 'Library installations are made from the library on the platform; this file only records them',
|
|
52
|
-
app: 'An App’s code ships with its own project, and the Functions it may start are set on the platform',
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/** Kinds the platform cannot delete, and what to do instead. */
|
|
56
56
|
const NOT_DELETED: Partial<Record<SyncKind, string>> = {
|
|
57
|
+
app: 'A Custom App is archived on the platform, in its settings; a change request sets its name, icon and the Functions it may start',
|
|
58
|
+
dataset: 'A dataset holds the organization’s records and other workspaces may read it: archive it on the platform (Blueprint, Datasets)',
|
|
57
59
|
function: 'A Function can’t be deleted in a workspace: switch it off on the platform, or keep its file',
|
|
58
60
|
lessons: 'An Agent’s rules are retired on the platform, where they are reviewed: a change request only proposes new ones',
|
|
59
61
|
}
|
|
@@ -130,7 +132,7 @@ export interface PushItem {
|
|
|
130
132
|
documents?: PushDocuments
|
|
131
133
|
}
|
|
132
134
|
|
|
133
|
-
const BLUEPRINT_DIRS = ['semantic-types', 'shared-fields', 'object-types', 'link-types', 'metrics', 'actions'] as const
|
|
135
|
+
const BLUEPRINT_DIRS = ['semantic-types', 'shared-fields', 'object-types', 'link-types', 'metrics', 'actions', 'object-sets'] as const
|
|
134
136
|
|
|
135
137
|
const BLUEPRINT_WORD: Record<string, string> = {
|
|
136
138
|
'semantic-types': 'semantic type',
|
|
@@ -139,6 +141,7 @@ const BLUEPRINT_WORD: Record<string, string> = {
|
|
|
139
141
|
'link-types': 'link type',
|
|
140
142
|
metrics: 'metric',
|
|
141
143
|
actions: 'Action',
|
|
144
|
+
'object-sets': 'saved object set',
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
const SKIP_DIRS = new Set(['node_modules', '.frontera', '.git', 'dist', 'build', 'coverage'])
|
|
@@ -238,11 +241,23 @@ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: Lo
|
|
|
238
241
|
case 'blueprint':
|
|
239
242
|
paths = [`blueprint/${name}.yaml`, `blueprint/${name}.yml`, `blueprint/${name}.json`]
|
|
240
243
|
break
|
|
244
|
+
case 'dataset': {
|
|
245
|
+
// Its columns, never its rows (X6): a request carries a blank dataset's declaration.
|
|
246
|
+
const declared = datasetDeclaration(root, name)
|
|
247
|
+
if ('problem' in declared) return { files: {}, notes, problem: declared.problem }
|
|
248
|
+
notes.push(...declared.notes)
|
|
249
|
+
return { files: { [`datasets/${name}.json`]: declared.text }, notes }
|
|
250
|
+
}
|
|
251
|
+
case 'app':
|
|
252
|
+
// Its settings (X6): its code is its own project, deployed from apps/<slug>/, and a release goes with `--release`.
|
|
253
|
+
paths = [`apps/${name}.json`]
|
|
254
|
+
notes.push('Proposes its name, description, icon and the Functions it may start; its code is deployed from its own folder, and `--release` adds the deployment to go live')
|
|
255
|
+
break
|
|
241
256
|
default:
|
|
242
257
|
return { files: {}, notes }
|
|
243
258
|
}
|
|
244
259
|
// 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).
|
|
245
|
-
if (kind !== 'agent' && kind !== 'knowledge') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
|
|
260
|
+
if (kind !== 'agent' && kind !== 'knowledge' && kind !== 'app') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
|
|
246
261
|
const files: Record<string, string> = {}
|
|
247
262
|
const here: Record<string, string> = {}
|
|
248
263
|
const models = projectModels(root)
|
|
@@ -294,7 +309,10 @@ export function projectRefs(root: string): Map<string, { kind: SyncKind; name: s
|
|
|
294
309
|
// The workspace's own grants beyond read: one Blueprint item, `blueprint/workspace-grants`.
|
|
295
310
|
if (existsSync(join(root, WORKSPACE_GRANTS_FILE))) add('blueprint', WORKSPACE_GRANTS_NAME)
|
|
296
311
|
for (const f of dirs('mcp')) if (f.endsWith('.plugin.json') && f !== '.plugin.json') add('plugin', f.slice(0, -'.plugin.json'.length))
|
|
312
|
+
for (const f of dirs('datasets')) if (f.endsWith('.json') && f !== '.json') add('dataset', f.slice(0, -'.json'.length))
|
|
297
313
|
for (const f of dirs('chat-apps')) if (f.endsWith('.json') && f !== '.json') add('chat-app', f.slice(0, -'.json'.length))
|
|
314
|
+
// A Custom App's settings file (X6); its code is the folder beside it, and its release a request's own file.
|
|
315
|
+
for (const f of dirs('apps')) if (/^[a-z0-9][a-z0-9-]*\.json$/.test(f) && !f.endsWith('.release.json')) add('app', f.slice(0, -'.json'.length))
|
|
298
316
|
for (const slug of dirs('agents')) if (existsSync(join(root, lessonsPath(slug)))) add('lessons', slug)
|
|
299
317
|
for (const slug of dirs('agents')) {
|
|
300
318
|
for (const f of dirs(`agents/${slug}/evals`)) {
|
|
@@ -312,6 +330,8 @@ function titleFor(kind: SyncKind, name: string, files: Record<string, string>, k
|
|
|
312
330
|
|
|
313
331
|
export interface CandidateInput {
|
|
314
332
|
root: string
|
|
333
|
+
/** The items the person named; a dataset is listed when named, or when an object type the push proposes reads it. */
|
|
334
|
+
named?: readonly string[]
|
|
315
335
|
plan: PlanItem[]
|
|
316
336
|
lock: SyncLock | null
|
|
317
337
|
snapshot: RemoteSnapshot | null
|
|
@@ -473,6 +493,8 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
|
|
|
473
493
|
else if (status === 'unresolved') block('conflicts', BLOCK_REASON.conflicts)
|
|
474
494
|
else if (deleted && NOT_DELETED[kind]) block('shape', NOT_DELETED[kind]!)
|
|
475
495
|
// Never proposed as a deletion: the platform refuses one, since deleting the file changes no grant.
|
|
496
|
+
// A saved object set is saved by Apply, never removed: what else names it would stop resolving (X6).
|
|
497
|
+
else if (deleted && ref.startsWith('blueprint/object-sets/')) block('shape', 'A change request doesn’t remove a saved object set: remove it on the platform, or put its file back')
|
|
476
498
|
else if (deleted && ref === WORKSPACE_GRANTS_REF) block('shape', `Deleting ${WORKSPACE_GRANTS_FILE} changes no grant: to take read_sensitive away, put the file back and list the type with \`permissions: [read]\``)
|
|
477
499
|
else if (status === 'deleted-remote') block('workspace-deleted', BLOCK_REASON['workspace-deleted'])
|
|
478
500
|
else if (deleted) {
|
|
@@ -497,12 +519,63 @@ export function pushCandidates(input: CandidateInput): PushItem[] {
|
|
|
497
519
|
}
|
|
498
520
|
out.push(item)
|
|
499
521
|
}
|
|
500
|
-
|
|
522
|
+
// A dataset goes with what reads it: listed every push otherwise, since no pull records it (X6).
|
|
523
|
+
// A platform without `change-request-datasets` refuses the kind, so none is listed for it.
|
|
524
|
+
const takesDatasets = !!snapshot?.capabilities.includes('change-request-datasets')
|
|
525
|
+
const read = new Set(out.filter((i) => i.proposable && i.ref.startsWith('blueprint/object-types/')).flatMap((i) => Object.values(i.files).map(backingDataset)).filter((d): d is string => !!d))
|
|
526
|
+
return out.filter((i) => i.kind !== 'dataset' || (takesDatasets && (read.has(i.name) || input.named?.includes(i.ref))))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** The dataset an object type's file reads (`backing.dataset`), when it names one. */
|
|
530
|
+
function backingDataset(text: string): string | null {
|
|
531
|
+
try {
|
|
532
|
+
const dataset = (parseYaml(text) as { backing?: { dataset?: unknown } } | null)?.backing?.dataset
|
|
533
|
+
return typeof dataset === 'string' ? dataset : null
|
|
534
|
+
} catch {
|
|
535
|
+
return null
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* `datasets/<apiName>.json` as a request carries it: a blank declaration of
|
|
541
|
+
* its columns and key (X6). A blank dataset goes as written. A file dataset
|
|
542
|
+
* goes as its columns when the file declares every included column's type
|
|
543
|
+
* and whether it may be empty, so its contract is in the file and not only in
|
|
544
|
+
* the CSV; its rows stay here. A Source dataset is made on the platform.
|
|
545
|
+
*/
|
|
546
|
+
export function datasetDeclaration(root: string, name: string): { text: string; notes: string[] } | { problem: string } {
|
|
547
|
+
const path = `datasets/${name}.json`
|
|
548
|
+
const text = readLocal(root, path)
|
|
549
|
+
if (text === null) return { problem: `${path}: no file` }
|
|
550
|
+
let doc: Record<string, unknown>
|
|
551
|
+
try {
|
|
552
|
+
doc = JSON.parse(text) as Record<string, unknown>
|
|
553
|
+
} catch (err) {
|
|
554
|
+
return { problem: `${path} could not be read: ${(err as Error).message}` }
|
|
555
|
+
}
|
|
556
|
+
const kind = doc.kind ?? 'blank'
|
|
557
|
+
const common = {
|
|
558
|
+
apiName: name,
|
|
559
|
+
...(typeof doc.displayName === 'string' ? { displayName: doc.displayName } : {}),
|
|
560
|
+
...(typeof doc.description === 'string' ? { description: doc.description } : {}),
|
|
561
|
+
kind: 'blank',
|
|
562
|
+
}
|
|
563
|
+
const key = Array.isArray(doc.keyColumns) && doc.keyColumns.length ? { keyColumns: doc.keyColumns, deterministicKeyConfirmed: doc.deterministicKeyConfirmed === true } : {}
|
|
564
|
+
if (kind === 'blank') return { text: `${JSON.stringify({ ...common, columns: doc.columns, ...key }, null, 2)}\n`, notes: ['Proposes its columns: Apply creates the dataset, with no rows, when the organization has none of this name'] }
|
|
565
|
+
if (kind === 'source') return { problem: 'A Source dataset reads a connected Source: create it on the platform (Blueprint, Datasets)' }
|
|
566
|
+
const included = Array.isArray(doc.includedColumns) ? doc.includedColumns.filter((c): c is string => typeof c === 'string') : []
|
|
567
|
+
const declared = new Map((Array.isArray(doc.columns) ? doc.columns : []).map((c) => [(c as { name?: string }).name, c as { databaseType?: string; nullable?: boolean }]))
|
|
568
|
+
const open = included.filter((c) => typeof declared.get(c)?.databaseType !== 'string' || typeof declared.get(c)?.nullable !== 'boolean')
|
|
569
|
+
if (!included.length || open.length) {
|
|
570
|
+
return { problem: `Its columns come from the CSV${open.length ? ` (${open.slice(0, 5).join(', ')}${open.length > 5 ? '…' : ''})` : ''}: to propose the dataset, give each included column its databaseType and nullable under columns, so the file states the whole contract` }
|
|
571
|
+
}
|
|
572
|
+
const columns = included.map((c) => ({ name: c, databaseType: declared.get(c)!.databaseType, nullable: declared.get(c)!.nullable }))
|
|
573
|
+
return { text: `${JSON.stringify({ ...common, columns, ...key }, null, 2)}\n`, notes: ['Proposes its columns as a blank dataset: its rows (the CSV) stay here, and Apply creates the dataset, empty, when the organization has none of this name'] }
|
|
501
574
|
}
|
|
502
575
|
|
|
503
576
|
/** Whether pushing these needs the organization key at Apply: any Blueprint item does. */
|
|
504
577
|
export function needsOrganizationKey(items: Array<{ kind: string; proposable?: boolean }>): boolean {
|
|
505
|
-
return items.some((i) => i.kind === 'blueprint' && i.proposable !== false)
|
|
578
|
+
return items.some((i) => (i.kind === 'blueprint' || i.kind === 'dataset') && i.proposable !== false)
|
|
506
579
|
}
|
|
507
580
|
|
|
508
581
|
// ── What the platform takes ─────────────────────────────────────────────────
|
|
@@ -298,7 +298,7 @@ function stripRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
|
298
298
|
* evaluator settings, and its work-item statuses and labels.
|
|
299
299
|
*/
|
|
300
300
|
const AGENT_CONFIG_KEYS = [
|
|
301
|
-
'name', 'description', 'avatarSeed', 'avatarConfig', 'defaultMode', 'utilityMode', 'utilityOverrides', 'agentType', 'agentModeEnabled', 'disableWebBrowsing',
|
|
301
|
+
'name', 'description', 'avatarSeed', 'avatarConfig', 'defaultMode', 'fallbackMode', 'utilityMode', 'utilityOverrides', 'agentType', 'agentModeEnabled', 'disableWebBrowsing',
|
|
302
302
|
'plugins', 'features', 'memory', 'voice', 'compression', 'streaming', 'upload', 'planning', 'delegation', 'modelRouting', 'toolPhasing', 'stopConditions',
|
|
303
303
|
'maxCostPerRun', 'permissions', 'functionGuardrails', 'audit', 'harness', 'statuses', 'defaultStatus', 'defaultLabels',
|
|
304
304
|
]
|
|
@@ -1453,7 +1453,7 @@ async function readEvalDatasets(ws: SyncClient, first: unknown): Promise<EvalDat
|
|
|
1453
1453
|
}
|
|
1454
1454
|
|
|
1455
1455
|
/** The check types the local runner grades in code, as the platform's expectation grading does. */
|
|
1456
|
-
const LOCAL_CHECKS = new Set(['contains', 'regex', 'tool_called', 'tool_not_called'])
|
|
1456
|
+
const LOCAL_CHECKS = new Set(['contains', 'not_contains', 'regex', 'tool_called', 'tool_not_called'])
|
|
1457
1457
|
|
|
1458
1458
|
/**
|
|
1459
1459
|
* A scenario's simulated results and expected actions name Functions and
|
|
@@ -1482,7 +1482,7 @@ function scenarioNames(value: unknown, byId: Map<string, { kind: RefKind; name:
|
|
|
1482
1482
|
|
|
1483
1483
|
export interface EvalCaseOut {
|
|
1484
1484
|
name: string
|
|
1485
|
-
conversation?: Array<{ user: string }>
|
|
1485
|
+
conversation?: Array<{ user: string } | { assistant: string }>
|
|
1486
1486
|
expected?: string
|
|
1487
1487
|
expectedContains?: string[]
|
|
1488
1488
|
checks?: Array<Record<string, unknown>>
|
|
@@ -1525,15 +1525,18 @@ export function evalCase(item: EvalItem, name: string, byId: Map<string, { kind:
|
|
|
1525
1525
|
for (const e of expectations) {
|
|
1526
1526
|
const type = String(e.check?.type ?? e.check?.kind ?? '')
|
|
1527
1527
|
if (e.check && LOCAL_CHECKS.has(type)) {
|
|
1528
|
-
|
|
1529
|
-
|
|
1528
|
+
// A phrase on the last reply is `expectedContains`; one on a named turn stays a check, with its turn (L3).
|
|
1529
|
+
if (type === 'contains' && typeof e.check.value === 'string' && e.check.value && e.check.turn === undefined) contains.push(e.check.value)
|
|
1530
|
+
else if (type !== 'contains' || e.check.turn !== undefined) checks.push({ ...(typeof e.text === 'string' && e.text ? { text: e.text } : {}), ...e.check, type })
|
|
1530
1531
|
} else if (typeof e.text === 'string' && e.text.trim()) judged.push(e.text.trim())
|
|
1531
1532
|
}
|
|
1532
1533
|
const expected = textOf(item.expectedOutput)
|
|
1534
|
+
// A conversation item (L3): its turns in order, as `local test` sends them.
|
|
1535
|
+
const turns = Array.isArray(input?.turns) ? (input!.turns as Array<{ role?: unknown; text?: unknown }>).flatMap((t) => (typeof t?.text === 'string' && t.text.trim() ? [t.role === 'assistant' ? { assistant: t.text } : { user: t.text }] : [])) : []
|
|
1533
1536
|
return {
|
|
1534
1537
|
case: {
|
|
1535
1538
|
name,
|
|
1536
|
-
conversation: [{ user: question }],
|
|
1539
|
+
conversation: turns.filter((t) => 'user' in t).length > 1 ? turns : [{ user: question }],
|
|
1537
1540
|
...(contains.length ? { expectedContains: contains } : expected ? { expected } : {}),
|
|
1538
1541
|
...(checks.length ? { checks } : {}),
|
|
1539
1542
|
...(judged.length ? { judged } : {}),
|
|
@@ -1616,7 +1619,7 @@ async function readEvaluations(source: SyncSource, lists: Lists, directory: Dire
|
|
|
1616
1619
|
})
|
|
1617
1620
|
if (conversations) notes.push(`${conversations} case${conversations === 1 ? ' replays' : 's replay'} a real conversation from the workspace, so ${conversations === 1 ? 'it stays' : 'they stay'} on the platform`)
|
|
1618
1621
|
if (noQuestion) notes.push(`${noQuestion} item${noQuestion === 1 ? '' : 's'} had no question the local runner can send`)
|
|
1619
|
-
if (judged) notes.push(`${judged} expectation${judged === 1 ? ' is' : 's are'} graded by
|
|
1622
|
+
if (judged) notes.push(`${judged} expectation${judged === 1 ? ' is' : 's are'} graded by a judge: ${judged === 1 ? 'it is' : 'they are'} kept in the file (\`judged\`), and \`local test\` grades ${judged === 1 ? 'it' : 'them'} with the local runtime’s grading model`)
|
|
1620
1623
|
if (gold) notes.push(`${gold} gold case${gold === 1 ? '' : 's'} (human-certified)`)
|
|
1621
1624
|
if (unnamed.size) notes.push(`A scenario names ${unnamed.size} Function${unnamed.size === 1 ? ' or workflow' : 's or workflows'} by a platform id the workspace no longer has`)
|
|
1622
1625
|
if (ds.totalItems > ds.items.length) notes.push(`The first ${ds.items.length} of ${ds.totalItems} items were read`)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { PlatformApi } from '../api/platform-api'
|
|
5
|
+
import { CliError } from '../errors'
|
|
6
|
+
import type { LocalProject } from './local-project'
|
|
7
|
+
import type { LocalCredentials } from './local-state'
|
|
8
|
+
import { caseKey, compareWithLastRun, type LastRun, type RunComparison } from './local-test-baseline'
|
|
9
|
+
import type { CaseResult } from './local-test-runner'
|
|
10
|
+
import { acquireLock } from './local-writers'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Comparing models on the local runtime (Forge improvement plan L1, T9).
|
|
14
|
+
*
|
|
15
|
+
* `frontera local test --model <slug>` runs the cases with each Agent in
|
|
16
|
+
* scope on that model: its draft's models are swapped for the run and put
|
|
17
|
+
* back after it, whatever happened, so the files and the published versions
|
|
18
|
+
* never change. Every run is kept under `.frontera/local/test-runs/`, with
|
|
19
|
+
* the model it ran on, and `--baseline <run>` compares a run with one kept
|
|
20
|
+
* earlier: which cases the other model made fail, and which it fixed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface SavedRun {
|
|
24
|
+
id: string
|
|
25
|
+
at: string
|
|
26
|
+
/** The model `--model` swapped in; null for the Agents' own. */
|
|
27
|
+
model: string | null
|
|
28
|
+
cases: LastRun
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DIR = 'test-runs'
|
|
32
|
+
const KEEP = 20
|
|
33
|
+
|
|
34
|
+
/** The draft's models, every one on `slug`; null when the draft names none (it runs on the runtime's default). */
|
|
35
|
+
export function withModel(snapshot: Record<string, unknown>, slug: string): Array<Record<string, unknown>> | null {
|
|
36
|
+
const configs = Array.isArray(snapshot.modelConfigs) ? (snapshot.modelConfigs as Array<Record<string, unknown>>) : []
|
|
37
|
+
if (!configs.length) return null
|
|
38
|
+
return configs.map((c) => ({ ...c, modelSlug: slug }))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DraftAccess {
|
|
42
|
+
draft(agentId: string): Promise<{ snapshot: Record<string, unknown> } | null>
|
|
43
|
+
patch(agentId: string, patch: Record<string, unknown>): Promise<unknown>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Put each Agent's draft on `model`, and return how to put it back. An Agent
|
|
48
|
+
* whose draft names no model is left as it is, and said.
|
|
49
|
+
*/
|
|
50
|
+
export async function swapModels(access: DraftAccess, agents: Array<{ slug: string; id: string }>, model: string): Promise<{ swapped: string[]; unchanged: string[]; restore: () => Promise<string[]> }> {
|
|
51
|
+
const before: Array<{ slug: string; id: string; modelConfigs: unknown }> = []
|
|
52
|
+
const unchanged: string[] = []
|
|
53
|
+
try {
|
|
54
|
+
for (const a of agents) {
|
|
55
|
+
const draft = await access.draft(a.id)
|
|
56
|
+
const next = draft ? withModel(draft.snapshot, model) : null
|
|
57
|
+
if (!draft || !next) {
|
|
58
|
+
unchanged.push(a.slug)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
await access.patch(a.id, { modelConfigs: next })
|
|
62
|
+
before.push({ slug: a.slug, id: a.id, modelConfigs: draft.snapshot.modelConfigs })
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// What was swapped before the failure is put back before it is said.
|
|
66
|
+
for (const b of before.reverse()) await access.patch(b.id, { modelConfigs: b.modelConfigs }).catch(() => {})
|
|
67
|
+
throw err
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
swapped: before.map((b) => b.slug),
|
|
71
|
+
unchanged,
|
|
72
|
+
// Each one tried, so one that fails doesn't keep the rest on the other model; the ones that failed are returned.
|
|
73
|
+
restore: async () => {
|
|
74
|
+
const failed: string[] = []
|
|
75
|
+
for (const b of [...before].reverse()) await access.patch(b.id, { modelConfigs: b.modelConfigs }).catch(() => failed.push(b.slug))
|
|
76
|
+
return failed
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const runDir = (project: LocalProject) => join(project.stateDir, DIR)
|
|
82
|
+
|
|
83
|
+
/** Keep this run, newest last; the oldest beyond twenty go. */
|
|
84
|
+
export function saveTestRun(project: LocalProject, results: CaseResult[], model: string | null, now = new Date()): SavedRun {
|
|
85
|
+
const at = now.toISOString()
|
|
86
|
+
const id = `${at.replace(/[:.]/g, '-').replace(/Z$/, '')}${model ? `-${model.replace(/[^A-Za-z0-9._-]/g, '_')}` : ''}`
|
|
87
|
+
const cases: LastRun = {}
|
|
88
|
+
for (const r of results) if (!r.notRun) cases[caseKey(r)] = { passed: r.passed, at, ms: r.ms, ...(r.passed || !r.reason ? {} : { reason: r.reason.slice(0, 300) }) }
|
|
89
|
+
const run: SavedRun = { id, at, model, cases }
|
|
90
|
+
mkdirSync(runDir(project), { recursive: true })
|
|
91
|
+
writeFileSync(join(runDir(project), `${id}.json`), `${JSON.stringify(run, null, 2)}\n`)
|
|
92
|
+
for (const old of listTestRuns(project).slice(0, -KEEP)) rmSync(join(runDir(project), `${old}.json`), { force: true })
|
|
93
|
+
return run
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The kept runs' ids, oldest first. */
|
|
97
|
+
export function listTestRuns(project: LocalProject): string[] {
|
|
98
|
+
if (!existsSync(runDir(project))) return []
|
|
99
|
+
return readdirSync(runDir(project)).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -'.json'.length)).sort()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A kept run by its id, a prefix of it, `last` (the newest), or `last-<model>`; null when none matches. */
|
|
103
|
+
export function readTestRun(project: LocalProject, ref: string): SavedRun | null {
|
|
104
|
+
const ids = listTestRuns(project)
|
|
105
|
+
const read = (id: string | undefined) => {
|
|
106
|
+
if (!id) return null
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(readFileSync(join(runDir(project), `${id}.json`), 'utf8')) as SavedRun
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (ref === 'last') return read(ids[ids.length - 1])
|
|
114
|
+
if (ref === 'last-own') return [...ids].reverse().map(read).find((r) => r && r.model === null) ?? null
|
|
115
|
+
if (ref.startsWith('last-')) return [...ids].reverse().map(read).find((r) => r?.model === ref.slice('last-'.length)) ?? null
|
|
116
|
+
const matches = ids.filter((id) => id.startsWith(ref))
|
|
117
|
+
return matches.length === 1 ? read(matches[0]) : null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** This run against a kept one: the cases the change of model (or of anything else) made fail, fixed or left failing. */
|
|
121
|
+
export function compareWithRun(results: CaseResult[], baseline: SavedRun): RunComparison {
|
|
122
|
+
return compareWithLastRun(results, baseline.cases)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function comparisonWords(c: RunComparison, baseline: SavedRun): string {
|
|
126
|
+
const on = baseline.model ? `on ${baseline.model}` : 'on the Agents’ own models'
|
|
127
|
+
return `against run ${baseline.id} (${on}): ${c.newlyFailing.length} newly failing, ${c.fixed.length} fixed, ${c.stillFailing.length} still failing${c.firstRun.length ? `, ${c.firstRun.length} not in that run` : ''}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Run `run` with these Agents on `model`, holding the runtime (X9) so no
|
|
132
|
+
* other run tests the swapped drafts, and put each back after, whatever
|
|
133
|
+
* happened. `stuck` names any that could not be put back.
|
|
134
|
+
*/
|
|
135
|
+
export async function runOnModel<T>(
|
|
136
|
+
project: LocalProject,
|
|
137
|
+
link: { serviceUrl: string; creds: LocalCredentials },
|
|
138
|
+
slugs: readonly string[],
|
|
139
|
+
model: string,
|
|
140
|
+
run: (on: { swapped: string[]; unchanged: string[] }) => Promise<T>,
|
|
141
|
+
deps: { access?: DraftAccess; agentIds?: () => Promise<Array<{ id?: string; agentId?: string }>> } = {},
|
|
142
|
+
): Promise<{ value: T; swapped: string[]; unchanged: string[]; stuck: string[] }> {
|
|
143
|
+
const release = acquireLock(project.stateDir, 'local test --model')
|
|
144
|
+
try {
|
|
145
|
+
const platform = new PlatformApi(link.serviceUrl, link.creds.workspaceKey ?? link.creds.orgKey, link.creds.workspaceId)
|
|
146
|
+
const access = deps.access ?? { draft: (id: string) => platform.agentDraft(id), patch: (id: string, patch: Record<string, unknown>) => platform.patchAgentDraft(id, patch) }
|
|
147
|
+
let swap: Awaited<ReturnType<typeof swapModels>>
|
|
148
|
+
try {
|
|
149
|
+
const listed = await (deps.agentIds ?? (() => platform.allAgents() as Promise<Array<{ id?: string; agentId?: string }>>))()
|
|
150
|
+
const agents = [...new Set(slugs)].flatMap((slug) => {
|
|
151
|
+
const row = listed.find((a) => a.agentId === slug)
|
|
152
|
+
return row?.id ? [{ slug, id: row.id }] : []
|
|
153
|
+
})
|
|
154
|
+
swap = await swapModels(access, agents, model)
|
|
155
|
+
} catch (err) {
|
|
156
|
+
throw new CliError(`the Agents could not be put on ${model} for the run: ${(err as Error).message}`, { code: 'LOCAL_TEST_MODEL', hint: '`frontera local models` lists the models this runtime has' })
|
|
157
|
+
}
|
|
158
|
+
let stuck: string[] = []
|
|
159
|
+
try {
|
|
160
|
+
const value = await run({ swapped: swap.swapped, unchanged: swap.unchanged })
|
|
161
|
+
stuck = await swap.restore()
|
|
162
|
+
return { value, swapped: swap.swapped, unchanged: swap.unchanged, stuck }
|
|
163
|
+
} catch (err) {
|
|
164
|
+
await swap.restore()
|
|
165
|
+
throw err
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
release()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const stuckWords = (stuck: readonly string[], model: string) =>
|
|
173
|
+
`${stuck.join(', ')} ${stuck.length === 1 ? 'is' : 'are'} still on ${model}: run \`frontera local dev --once --reapply\` to put the files’ models back`
|