@frontera-sdk/cli 1.52.28 → 1.52.29
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 +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- package/src/local/local-workflow-state.ts +131 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, posix } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import matter from 'gray-matter'
|
|
6
|
+
import { parse as parseYaml } from 'yaml'
|
|
7
|
+
|
|
8
|
+
import { mimeFromPath } from '../commands/skill/bundle-commands'
|
|
9
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
10
|
+
import { CliError } from '../errors'
|
|
11
|
+
import { documentChanges, localDocuments, splitDocumentPath, type PulledDocument } from './local-knowledge-documents'
|
|
12
|
+
import { QUALITY_CHECKS_FILE, qualityChecksPath, readQualityChecks } from './local-quality-checks'
|
|
13
|
+
import { lessonsPath, lessonsProposal } from './local-lessons'
|
|
14
|
+
import { importedFiles } from './local-function-shared'
|
|
15
|
+
import { isIgnoredName } from './local-dev-sync'
|
|
16
|
+
import { comparableText, withoutSecrets } from './local-secrets'
|
|
17
|
+
import { deepEqual } from './local-sync-merge'
|
|
18
|
+
import { itemTitle } from './local-sync-names'
|
|
19
|
+
import { hash, itemHash, readLocal, safePath, writeLock, type LockEntry, type PlanItem, type RemoteItem, type SyncKind, type SyncLock } from './local-sync-plan'
|
|
20
|
+
import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync-snapshot'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Change requests v1, the project's side (docs/specs/2026-10-17-change-requests-v1-design.md
|
|
24
|
+
* §4, §7, §11): what a push proposes, the request it keeps, and what Apply
|
|
25
|
+
* did, in words.
|
|
26
|
+
*
|
|
27
|
+
* what is pushed items changed here (a merge or a resolve leaves them so) and items only here
|
|
28
|
+
* what is refused an item the workspace changed too (pull and merge first), one with conflicts,
|
|
29
|
+
* a deletion, and kinds a request does not carry (plugins, evaluations, datasets)
|
|
30
|
+
* each item the lock's id, the revision its base is (the Blueprint's for a Blueprint file),
|
|
31
|
+
* every file of the item, and for a Function its `function deploy` build
|
|
32
|
+
* the request `.frontera/sync/requests.json` (not committed): the current one, so the next
|
|
33
|
+
* push updates it, and a copy of each item's base for the Changes view
|
|
34
|
+
*
|
|
35
|
+
* Pure but for the project's files: the platform calls and the build are the
|
|
36
|
+
* command's (`commands/local/sync-request.ts`).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** The kinds a change request carries. */
|
|
40
|
+
export const PUSH_KINDS: readonly SyncKind[] = ['blueprint', 'skill', 'knowledge', 'plugin', 'function', 'workflow', 'agent', 'evaluation', 'chat-app', 'lessons']
|
|
41
|
+
|
|
42
|
+
/** Why the other kinds are not proposed, in the reader's words. */
|
|
43
|
+
export const NOT_PROPOSED: Partial<Record<SyncKind, string>> = {
|
|
44
|
+
dataset: 'Datasets aren’t proposed; upload them on the platform',
|
|
45
|
+
brand: 'The organization’s brand is set on the platform, in Settings; it isn’t proposed from a project',
|
|
46
|
+
library: 'Library installations are made from the library on the platform; this file only records them',
|
|
47
|
+
app: 'An App’s code ships with its own project, and the Functions it may start are set on the platform',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Kinds the platform cannot delete, and what to do instead. */
|
|
51
|
+
const NOT_DELETED: Partial<Record<SyncKind, string>> = {
|
|
52
|
+
function: 'A Function can’t be deleted in a workspace: switch it off on the platform, or keep its file',
|
|
53
|
+
lessons: 'An Agent’s rules are retired on the platform, where they are reviewed: a change request only proposes new ones',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Why an item cannot be proposed now. */
|
|
57
|
+
export type BlockCode = 'moved' | 'conflicts' | 'deleted' | 'workspace-deleted' | 'kind' | 'shape'
|
|
58
|
+
|
|
59
|
+
/** A skill's file that is not text (an image, a font, a template): uploaded as a skill asset when proposed. */
|
|
60
|
+
export interface PushAsset {
|
|
61
|
+
/** Relative to the skill's folder, as the platform keeps it: `assets/logo.png`. */
|
|
62
|
+
path: string
|
|
63
|
+
/** Project path. */
|
|
64
|
+
file: string
|
|
65
|
+
sha256: string
|
|
66
|
+
bytes: number
|
|
67
|
+
mime: string
|
|
68
|
+
visible: boolean
|
|
69
|
+
change: 'new' | 'changed' | 'same'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the project brings of a knowledge base's documents. */
|
|
73
|
+
export interface PushDocuments {
|
|
74
|
+
upload: Array<{ fileName: string; sha256: string; bytes: number; mime: string; replaces: string | null; change: 'new' | 'changed' }>
|
|
75
|
+
remove: Array<{ fileName: string; sourceId: string }>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface PushFile {
|
|
79
|
+
path: string
|
|
80
|
+
/** The file as the item's base has it; null when the base does not have it (or the item is new). */
|
|
81
|
+
base: string | null
|
|
82
|
+
/** The file here; null when it was removed here. */
|
|
83
|
+
local: string | null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface PushItem {
|
|
87
|
+
ref: string
|
|
88
|
+
kind: SyncKind
|
|
89
|
+
name: string
|
|
90
|
+
title: string
|
|
91
|
+
/** `changed`: the workspace has it and it changed here. `new`: only here. `deleted`: deleted here, so Apply deletes it there. */
|
|
92
|
+
change: 'changed' | 'new' | 'deleted'
|
|
93
|
+
proposable: boolean
|
|
94
|
+
/** Why not, when it is not. */
|
|
95
|
+
blocked: BlockCode | null
|
|
96
|
+
reason: string | null
|
|
97
|
+
/** The platform's id, from the lock (or the snapshot). */
|
|
98
|
+
id: string | null
|
|
99
|
+
/** The revision the item was changed from; for a Blueprint file, the Blueprint's. */
|
|
100
|
+
baseRevision: string | null
|
|
101
|
+
/** Every file of the item as it stands here: what is sent. */
|
|
102
|
+
files: Record<string, string>
|
|
103
|
+
/** Each file that differs from the base, for the preview and the Changes view. */
|
|
104
|
+
diff: PushFile[]
|
|
105
|
+
/** The base's files, whole: kept with the request for its Changes view. */
|
|
106
|
+
base: Record<string, string>
|
|
107
|
+
/** What is left out, said rather than dropped (a secret's value, a knowledge base's documents). */
|
|
108
|
+
notes: string[]
|
|
109
|
+
/** A skill's assets, the whole set, when it has any here or in the workspace. */
|
|
110
|
+
assets?: PushAsset[]
|
|
111
|
+
/** A knowledge base's documents, when they are brought. */
|
|
112
|
+
documents?: PushDocuments
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const BLUEPRINT_DIRS = ['semantic-types', 'shared-fields', 'object-types', 'link-types', 'metrics', 'actions'] as const
|
|
116
|
+
|
|
117
|
+
const BLUEPRINT_WORD: Record<string, string> = {
|
|
118
|
+
'semantic-types': 'semantic type',
|
|
119
|
+
'shared-fields': 'shared field',
|
|
120
|
+
'object-types': 'object type',
|
|
121
|
+
'link-types': 'link type',
|
|
122
|
+
metrics: 'metric',
|
|
123
|
+
actions: 'Action',
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const SKIP_DIRS = new Set(['node_modules', '.frontera', '.git', 'dist', 'build', 'coverage'])
|
|
127
|
+
|
|
128
|
+
/** Files under a folder, project-relative, skipping hidden files, editor leftovers and the workspace's conflict copies. */
|
|
129
|
+
function filesUnder(root: string, rel: string, depth = 0): string[] {
|
|
130
|
+
const dir = join(root, rel)
|
|
131
|
+
if (depth > 8 || !existsSync(dir)) return []
|
|
132
|
+
let names: string[]
|
|
133
|
+
try {
|
|
134
|
+
names = readdirSync(dir).sort()
|
|
135
|
+
} catch {
|
|
136
|
+
return []
|
|
137
|
+
}
|
|
138
|
+
const out: string[] = []
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
if (SKIP_DIRS.has(name) || isIgnoredName(name)) continue
|
|
141
|
+
const path = posix.join(rel, name)
|
|
142
|
+
let stat
|
|
143
|
+
try {
|
|
144
|
+
stat = lstatSync(join(root, path))
|
|
145
|
+
} catch {
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (stat.isDirectory()) out.push(...filesUnder(root, path, depth + 1))
|
|
149
|
+
else if (stat.isFile()) out.push(path)
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The relative imports a Function file makes inside `functions/`, followed through its helpers. */
|
|
155
|
+
/** A Function's file and what it imports: its helpers in `functions/`, and the shared files it imports from outside it. */
|
|
156
|
+
function functionImports(root: string, entry: string): string[] {
|
|
157
|
+
return importedFiles(root, entry)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Every file of one item as the project holds it, in the layout a pull
|
|
162
|
+
* writes and a request carries (`change-request-candidates.ts` on the
|
|
163
|
+
* service). The lock's paths that still exist come along, so nothing the
|
|
164
|
+
* last pull wrote is left behind.
|
|
165
|
+
*/
|
|
166
|
+
export function itemFiles(root: string, kind: SyncKind, name: string, entry?: LockEntry, opts: { documents?: boolean } = {}): { files: Record<string, string>; notes: string[]; problem?: string; assets?: string[] } {
|
|
167
|
+
const notes: string[] = []
|
|
168
|
+
let paths: string[] = []
|
|
169
|
+
const assets: string[] = []
|
|
170
|
+
switch (kind) {
|
|
171
|
+
case 'agent':
|
|
172
|
+
paths = ['agent.json', 'operating.json', 'bindings.json'].map((f) => `agents/${name}/${f}`)
|
|
173
|
+
break
|
|
174
|
+
case 'workflow': {
|
|
175
|
+
paths = [`workflows/${name}.yaml`, `workflows/${name}.yml`]
|
|
176
|
+
if (!paths.some((p) => readLocal(root, p) !== null)) {
|
|
177
|
+
const other = [`workflows/${name}.json`, `workflows/${name}.workflow.yaml`, `workflows/${name}.workflow.yml`].find((p) => readLocal(root, p) !== null)
|
|
178
|
+
if (other) return { files: {}, notes, problem: `${other}: a change request carries a workflow as workflows/${name}.yaml; rename the file to propose it` }
|
|
179
|
+
}
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
case 'function': {
|
|
183
|
+
if (existsSync(join(root, 'functions', name, 'package.json'))) paths = filesUnder(root, `functions/${name}`)
|
|
184
|
+
else {
|
|
185
|
+
const entryFile = `functions/${name}.ts`
|
|
186
|
+
paths = [...functionImports(root, entryFile), `functions/${name}.test.ts`]
|
|
187
|
+
}
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
case 'skill': {
|
|
191
|
+
for (const path of filesUnder(root, `skills/${name}`)) {
|
|
192
|
+
const rel = path.slice(`skills/${name}/`.length)
|
|
193
|
+
const top = rel.split('/')[0]
|
|
194
|
+
if (rel === 'SKILL.md' || top === 'refs' || top === 'references' || top === 'scripts' || rel.endsWith('.md')) paths.push(path)
|
|
195
|
+
else if (!path.endsWith('.workspace')) assets.push(path)
|
|
196
|
+
}
|
|
197
|
+
break
|
|
198
|
+
}
|
|
199
|
+
case 'plugin':
|
|
200
|
+
paths = [`mcp/${name}.plugin.json`]
|
|
201
|
+
break
|
|
202
|
+
case 'chat-app':
|
|
203
|
+
paths = [`chat-apps/${name}.json`]
|
|
204
|
+
break
|
|
205
|
+
case 'lessons':
|
|
206
|
+
paths = [lessonsPath(name)]
|
|
207
|
+
break
|
|
208
|
+
case 'evaluation': {
|
|
209
|
+
const [agent, dataset] = name.split('.')
|
|
210
|
+
paths = [`agents/${agent}/evals/${dataset}.yaml`, `agents/${agent}/evals/${dataset}.yml`]
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
case 'knowledge': {
|
|
214
|
+
// Its settings and its quality checks; documents only when brought.
|
|
215
|
+
paths = [`knowledge/${name}/knowledge.json`, qualityChecksPath(name)]
|
|
216
|
+
const documents = filesUnder(root, `knowledge/${name}`).filter((p) => !paths.includes(p))
|
|
217
|
+
if (documents.length && !opts.documents) notes.push(`Its ${documents.length} document${documents.length === 1 ? '' : 's'} stay${documents.length === 1 ? 's' : ''} as the workspace has them unless you bring them; the request carries the knowledge base’s settings`)
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
case 'blueprint':
|
|
221
|
+
paths = [`blueprint/${name}.yaml`, `blueprint/${name}.yml`, `blueprint/${name}.json`]
|
|
222
|
+
break
|
|
223
|
+
default:
|
|
224
|
+
return { files: {}, notes }
|
|
225
|
+
}
|
|
226
|
+
// 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).
|
|
227
|
+
if (kind !== 'agent' && kind !== 'knowledge') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
|
|
228
|
+
const files: Record<string, string> = {}
|
|
229
|
+
for (const path of [...new Set(paths)].sort()) {
|
|
230
|
+
if (path.endsWith('.workspace') || assets.includes(path)) continue
|
|
231
|
+
const text = readLocal(root, path)
|
|
232
|
+
if (text === null) continue
|
|
233
|
+
if (kind === 'plugin') {
|
|
234
|
+
const clean = withoutSecrets(text)
|
|
235
|
+
files[path] = clean.text
|
|
236
|
+
notes.push(...clean.notes)
|
|
237
|
+
} else files[path] = text
|
|
238
|
+
}
|
|
239
|
+
return { files, notes, ...(kind === 'skill' ? { assets } : {}) }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The items the project holds, by ref, as a request names them: Agents,
|
|
244
|
+
* workflows, Functions (a file or a folder of their own), skills, knowledge
|
|
245
|
+
* bases, Blueprint files, and plugins (listed so a push can say why not).
|
|
246
|
+
*/
|
|
247
|
+
export function projectRefs(root: string): Map<string, { kind: SyncKind; name: string }> {
|
|
248
|
+
const out = new Map<string, { kind: SyncKind; name: string }>()
|
|
249
|
+
const add = (kind: SyncKind, name: string) => out.set(`${kind}/${name}`, { kind, name })
|
|
250
|
+
const dirs = (rel: string) => {
|
|
251
|
+
try {
|
|
252
|
+
return readdirSync(join(root, rel)).filter((n) => !isIgnoredName(n) && !SKIP_DIRS.has(n)).sort()
|
|
253
|
+
} catch {
|
|
254
|
+
return []
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const slug of dirs('agents')) if (existsSync(join(root, 'agents', slug, 'agent.json'))) add('agent', slug)
|
|
258
|
+
for (const f of dirs('workflows')) {
|
|
259
|
+
const m = /^(.+?)(?:\.workflow)?\.(ya?ml|json)$/.exec(f)
|
|
260
|
+
if (m) add('workflow', m[1]!)
|
|
261
|
+
}
|
|
262
|
+
for (const f of dirs('functions')) {
|
|
263
|
+
if (/\.ts$/.test(f) && !/\.(test|d)\.ts$/.test(f)) add('function', f.replace(/\.ts$/, ''))
|
|
264
|
+
else if (existsSync(join(root, 'functions', f, 'package.json'))) add('function', f)
|
|
265
|
+
}
|
|
266
|
+
for (const n of dirs('skills')) if (existsSync(join(root, 'skills', n, 'SKILL.md'))) add('skill', n)
|
|
267
|
+
for (const n of dirs('knowledge')) if (existsSync(join(root, 'knowledge', n, 'knowledge.json'))) add('knowledge', n)
|
|
268
|
+
for (const dir of BLUEPRINT_DIRS) for (const f of dirs(`blueprint/${dir}`)) if (/\.(ya?ml|json)$/.test(f)) add('blueprint', `${dir}/${f.replace(/\.(ya?ml|json)$/, '')}`)
|
|
269
|
+
for (const f of dirs('mcp')) if (f.endsWith('.plugin.json') && f !== '.plugin.json') add('plugin', f.slice(0, -'.plugin.json'.length))
|
|
270
|
+
for (const f of dirs('chat-apps')) if (f.endsWith('.json') && f !== '.json') add('chat-app', f.slice(0, -'.json'.length))
|
|
271
|
+
for (const slug of dirs('agents')) if (existsSync(join(root, lessonsPath(slug)))) add('lessons', slug)
|
|
272
|
+
for (const slug of dirs('agents')) {
|
|
273
|
+
for (const f of dirs(`agents/${slug}/evals`)) {
|
|
274
|
+
const m = /^([^.]+)\.ya?ml$/.exec(f)
|
|
275
|
+
if (m) add('evaluation', `${slug}.${m[1]}`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** An item's name as people know it, as every surface says it (`local-sync-names.ts`). */
|
|
282
|
+
function titleFor(kind: SyncKind, name: string, files: Record<string, string>, known?: string): string {
|
|
283
|
+
return itemTitle(kind, name, files, known)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface CandidateInput {
|
|
287
|
+
root: string
|
|
288
|
+
plan: PlanItem[]
|
|
289
|
+
lock: SyncLock | null
|
|
290
|
+
snapshot: RemoteSnapshot | null
|
|
291
|
+
/** The snapshot's items with their files: what the workspace holds as last fetched. */
|
|
292
|
+
remote: RemoteItem[]
|
|
293
|
+
/**
|
|
294
|
+
* Knowledge bases whose documents are brought (opt-in, as for a pull):
|
|
295
|
+
* what the pull wrote of each (`documents.json`) and what the workspace has now.
|
|
296
|
+
*/
|
|
297
|
+
documents?: {
|
|
298
|
+
names: Set<string>
|
|
299
|
+
pulled: Record<string, Record<string, PulledDocument>>
|
|
300
|
+
platform: Map<string, Array<{ id: string; fileName: string }>>
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const BLOCK_REASON: Record<Exclude<BlockCode, 'kind' | 'shape'>, string> = {
|
|
305
|
+
moved: 'Changed in the workspace too since your last pull: pull and merge it first',
|
|
306
|
+
conflicts: 'Has conflicts from a pull: resolve them first',
|
|
307
|
+
deleted: 'Deleted here',
|
|
308
|
+
'workspace-deleted': 'The workspace deleted it: decide in a pull whether to keep it here',
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
|
312
|
+
|
|
313
|
+
/** The paths a skill's front matter marks visible (`visibleAssets:`), as `skill push` reads them. */
|
|
314
|
+
function visibleAssets(skillMd: string | undefined): Set<string> {
|
|
315
|
+
if (!skillMd) return new Set()
|
|
316
|
+
try {
|
|
317
|
+
const fm = matter(skillMd).data as { visibleAssets?: unknown }
|
|
318
|
+
return new Set(Array.isArray(fm.visibleAssets) ? fm.visibleAssets.filter((v): v is string => typeof v === 'string') : [])
|
|
319
|
+
} catch {
|
|
320
|
+
return new Set()
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* A skill's assets here against the workspace's (the snapshot's copies):
|
|
326
|
+
* the whole set the push would send, each marked new, changed or the same,
|
|
327
|
+
* and those the workspace has that this folder no longer does.
|
|
328
|
+
*/
|
|
329
|
+
export function skillAssets(root: string, name: string, paths: string[], theirs: RemoteItem | undefined): { assets: PushAsset[]; removed: string[]; changed: boolean } {
|
|
330
|
+
const prefix = `skills/${name}/`
|
|
331
|
+
const visible = visibleAssets(readLocal(root, `${prefix}SKILL.md`) ?? undefined)
|
|
332
|
+
const remote = new Map((theirs?.assets ?? []).map((a) => [a.path, a]))
|
|
333
|
+
const assets: PushAsset[] = []
|
|
334
|
+
for (const file of paths) {
|
|
335
|
+
let bytes: Uint8Array
|
|
336
|
+
try {
|
|
337
|
+
bytes = new Uint8Array(readFileSync(safePath(root, file)))
|
|
338
|
+
} catch {
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
const rel = file.slice(prefix.length)
|
|
342
|
+
const digest = sha256(bytes)
|
|
343
|
+
const copy = remote.get(file)
|
|
344
|
+
let change: PushAsset['change'] = 'new'
|
|
345
|
+
if (copy) {
|
|
346
|
+
let theirBytes: Uint8Array | null = copy.bytes ?? null
|
|
347
|
+
if (!theirBytes && copy.from && existsSync(copy.from)) theirBytes = new Uint8Array(readFileSync(copy.from))
|
|
348
|
+
change = theirBytes && sha256(theirBytes) === digest ? 'same' : 'changed'
|
|
349
|
+
}
|
|
350
|
+
assets.push({ path: rel, file, sha256: digest, bytes: bytes.byteLength, mime: mimeFromPath(file), visible: visible.has(rel), change })
|
|
351
|
+
}
|
|
352
|
+
const removed = [...remote.keys()].filter((p) => !paths.includes(p))
|
|
353
|
+
return { assets, removed, changed: removed.length > 0 || assets.some((a) => a.change !== 'same') }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const kb = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`)
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* What a push would propose, item by item: every item changed here or only
|
|
360
|
+
* here, with what it sends, or why it cannot be proposed now. Items that
|
|
361
|
+
* match the workspace, or only changed there, are not listed.
|
|
362
|
+
*/
|
|
363
|
+
export function pushCandidates(input: CandidateInput): PushItem[] {
|
|
364
|
+
const { root, plan, snapshot } = input
|
|
365
|
+
const lock = input.lock && (!snapshot || input.lock.workspace === snapshot.workspace) ? input.lock : null
|
|
366
|
+
const remote = new Map(input.remote.map((r) => [r.ref, r]))
|
|
367
|
+
const byPlan = new Map(plan.map((p) => [p.ref, p]))
|
|
368
|
+
const here = projectRefs(root)
|
|
369
|
+
const refs = new Set<string>([...plan.map((p) => p.ref), ...here.keys()])
|
|
370
|
+
const out: PushItem[] = []
|
|
371
|
+
for (const ref of [...refs].sort()) {
|
|
372
|
+
const slash = ref.indexOf('/')
|
|
373
|
+
const kind = (byPlan.get(ref)?.kind ?? here.get(ref)?.kind ?? ref.slice(0, slash)) as SyncKind
|
|
374
|
+
const name = byPlan.get(ref)?.name ?? here.get(ref)?.name ?? ref.slice(slash + 1)
|
|
375
|
+
const p = byPlan.get(ref)
|
|
376
|
+
const entry = lock?.items[ref]
|
|
377
|
+
const theirs = remote.get(ref)
|
|
378
|
+
// A plan's "only here" for a Function in its own folder is named after its entry file: the folder names it here.
|
|
379
|
+
if (p?.status === 'local-only' && !entry && !here.has(ref)) continue
|
|
380
|
+
// An Agent's lessons are never in the plan: they are proposed while the file differs from what the last pull wrote.
|
|
381
|
+
const lessons = kind === 'lessons' ? lessonsProposal(root, input.lock?.workspace ?? snapshot?.workspace ?? '', name) : null
|
|
382
|
+
if (kind === 'lessons' && !lessons) continue
|
|
383
|
+
// Not in the plan: something the folder has that the workspace and the last pull never did.
|
|
384
|
+
const status = p?.status ?? 'local-only'
|
|
385
|
+
if (status === 'new' || status === 'changed') continue
|
|
386
|
+
const deleted = status === 'deleted-local'
|
|
387
|
+
const bringDocuments = kind === 'knowledge' && !!input.documents?.names.has(name)
|
|
388
|
+
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 })
|
|
389
|
+
// What a file hash does not cover: a skill's assets, and a knowledge base's documents when they are brought.
|
|
390
|
+
const assetState = kind === 'skill' && !deleted && (assetPaths?.length || theirs?.assets?.length) ? skillAssets(root, name, assetPaths ?? [], theirs) : null
|
|
391
|
+
const docs = bringDocuments && !deleted && input.documents
|
|
392
|
+
? documentChanges(localDocuments(root, name), input.documents.pulled[name] ?? {}, input.documents.platform.get(name) ?? [])
|
|
393
|
+
: null
|
|
394
|
+
const extra = Boolean(assetState?.changed) || Boolean(docs && (docs.upload.length || docs.remove.length))
|
|
395
|
+
// `same` in the plan reads the base's paths; a file added to the item since is still a change here.
|
|
396
|
+
// A Plugin whose only difference is this computer's secrets (a header set here) is the same, as status reads it.
|
|
397
|
+
const pluginSame = kind === 'plugin' && !!theirs && Object.keys(files).length > 0 && Object.keys(files).every((p) => theirs.files[p] !== undefined && comparableText(p, readLocal(root, p) ?? '', theirs.files[p]!) === theirs.files[p])
|
|
398
|
+
const textSame = !entry || itemHash(files) === entry.remote || !theirs || itemHash(theirs.files) !== entry.remote || pluginSame
|
|
399
|
+
if (status === 'same' && textSame && !extra) continue
|
|
400
|
+
if (status === 'same' && Object.keys(files).length === 0) continue
|
|
401
|
+
const isNew = !entry && !theirs
|
|
402
|
+
const baseFiles: Record<string, string> = !isNew && theirs && entry && itemHash(theirs.files) === entry.remote ? theirs.files : {}
|
|
403
|
+
const diffPaths = [...new Set([...Object.keys(baseFiles), ...Object.keys(files)])].sort()
|
|
404
|
+
const diff: PushFile[] = diffPaths.map((path) => ({ path, base: baseFiles[path] ?? null, local: files[path] ?? null })).filter((f) => f.base !== f.local)
|
|
405
|
+
const item: PushItem = {
|
|
406
|
+
ref,
|
|
407
|
+
kind,
|
|
408
|
+
name,
|
|
409
|
+
title: titleFor(kind, name, kind === 'lessons' ? { [`agents/${name}/agent.json`]: readLocal(root, `agents/${name}/agent.json`) ?? '' } : deleted ? baseFiles : files, theirs?.title ?? (p && p.title !== p.name ? p.title : undefined)),
|
|
410
|
+
change: deleted ? 'deleted' : isNew ? 'new' : 'changed',
|
|
411
|
+
proposable: true,
|
|
412
|
+
blocked: null,
|
|
413
|
+
reason: null,
|
|
414
|
+
id: entry?.id ?? theirs?.id ?? null,
|
|
415
|
+
baseRevision: null,
|
|
416
|
+
files,
|
|
417
|
+
diff,
|
|
418
|
+
base: baseFiles,
|
|
419
|
+
notes,
|
|
420
|
+
}
|
|
421
|
+
if (assetState) {
|
|
422
|
+
item.assets = assetState.assets
|
|
423
|
+
for (const a of assetState.assets) if (a.change !== 'same') notes.push(`Asset ${a.path}: ${a.change === 'new' ? 'new' : 'changed'} (${kb(a.bytes)})`)
|
|
424
|
+
for (const r of assetState.removed) notes.push(`Asset ${r.slice(`skills/${name}/`.length)}: removed here, so it leaves the skill`)
|
|
425
|
+
}
|
|
426
|
+
if (docs) {
|
|
427
|
+
item.documents = { upload: docs.upload.map(({ content: _content, ...d }) => d), remove: docs.remove }
|
|
428
|
+
for (const d of docs.upload) notes.push(`Document ${d.fileName}: ${d.change === 'new' ? 'new' : 'changed'} (${kb(d.bytes)})${d.replaces ? '; the copy you pulled is taken off once everything else is applied' : ''}`)
|
|
429
|
+
for (const d of docs.remove) notes.push(`Document ${d.fileName}: you removed it after bringing it, so it is taken off the workspace (this cannot be undone)`)
|
|
430
|
+
notes.push(...docs.notes)
|
|
431
|
+
}
|
|
432
|
+
if (lessons) {
|
|
433
|
+
item.change = lessons.pulled ? 'changed' : 'new'
|
|
434
|
+
if (lessons.added.length) notes.push(`${lessons.added.length} new rule${lessons.added.length === 1 ? '' : 's'}, proposed for review: ${lessons.added.length === 1 ? 'it reaches' : 'they reach'} the Agent once a reviewer approves ${lessons.added.length === 1 ? 'it' : 'them'} on the platform`)
|
|
435
|
+
if (lessons.removed.length) notes.push(`${lessons.removed.length} rule${lessons.removed.length === 1 ? '' : 's'} removed here ${lessons.removed.length === 1 ? 'isn’t' : 'aren’t'} proposed: an active rule is retired on the platform, where rules are reviewed`)
|
|
436
|
+
if (!lessons.added.length && !lessons.removed.length) notes.push('Proposes the rules the workspace doesn’t have yet, for review')
|
|
437
|
+
}
|
|
438
|
+
const block = (code: BlockCode, reason: string) => Object.assign(item, { proposable: false, blocked: code, reason })
|
|
439
|
+
if (NOT_PROPOSED[kind]) block('kind', NOT_PROPOSED[kind]!)
|
|
440
|
+
else if (!PUSH_KINDS.includes(kind)) block('kind', 'Not a kind a change request carries')
|
|
441
|
+
else if (status === 'conflict') block('moved', BLOCK_REASON.moved)
|
|
442
|
+
else if (status === 'unresolved') block('conflicts', BLOCK_REASON.conflicts)
|
|
443
|
+
else if (deleted && NOT_DELETED[kind]) block('shape', NOT_DELETED[kind]!)
|
|
444
|
+
else if (status === 'deleted-remote') block('workspace-deleted', BLOCK_REASON['workspace-deleted'])
|
|
445
|
+
else if (deleted) {
|
|
446
|
+
// Proposable: Apply deletes it there, after everything that names it is updated.
|
|
447
|
+
} else if (problem) block('shape', problem)
|
|
448
|
+
else if (!Object.keys(files).length) block('shape', 'No files to propose')
|
|
449
|
+
else if (kind === 'blueprint' && Object.keys(files).length > 1) block('shape', `${Object.keys(files).join(' and ')}: keep one file per Blueprint artifact`)
|
|
450
|
+
else if (assetState?.changed && entry?.revision && theirs?.revision && entry.revision !== theirs.revision && textSame) {
|
|
451
|
+
// A pull never writes over an asset the folder has, so one the workspace changed since reads as a change here.
|
|
452
|
+
block('moved', 'Its assets changed in the workspace since your last pull: delete the copies here to bring the workspace’s, then propose')
|
|
453
|
+
}
|
|
454
|
+
if (item.proposable && deleted) {
|
|
455
|
+
item.baseRevision = (kind === 'blueprint' || kind === 'evaluation' ? snapshot?.groups[kind] : undefined) ?? theirs?.revision ?? entry?.revision ?? null
|
|
456
|
+
if (!item.baseRevision) block('shape', 'The workspace’s revision of it isn’t known: fetch, then propose')
|
|
457
|
+
} else if (item.proposable && !isNew) {
|
|
458
|
+
// The base is what the workspace holds now (the plan says only this side moved): its current
|
|
459
|
+
// revision, which for a Blueprint file is the Blueprint's.
|
|
460
|
+
item.baseRevision = (kind === 'blueprint' || kind === 'evaluation' ? snapshot?.groups[kind] : undefined) ?? (theirs && entry && itemHash(theirs.files) === entry.remote ? theirs.revision : undefined) ?? entry?.revision ?? null
|
|
461
|
+
} else if (item.proposable && kind === 'blueprint') {
|
|
462
|
+
item.baseRevision = snapshot?.groups.blueprint ?? null
|
|
463
|
+
}
|
|
464
|
+
out.push(item)
|
|
465
|
+
}
|
|
466
|
+
return out
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Whether pushing these needs the organization key at Apply: any Blueprint item does. */
|
|
470
|
+
export function needsOrganizationKey(items: Array<{ kind: string; proposable?: boolean }>): boolean {
|
|
471
|
+
return items.some((i) => i.kind === 'blueprint' && i.proposable !== false)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── What the platform takes ─────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
export interface FunctionBuild {
|
|
477
|
+
manifest: Record<string, unknown>
|
|
478
|
+
/** base64 */
|
|
479
|
+
bundle: string
|
|
480
|
+
/** base64 tar.gz of the source, when the Function sits in a project */
|
|
481
|
+
source?: string | null
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export interface PushBodyItem {
|
|
485
|
+
ref: string
|
|
486
|
+
id?: string | null
|
|
487
|
+
baseRevision?: string | null
|
|
488
|
+
files: Record<string, string>
|
|
489
|
+
build?: FunctionBuild
|
|
490
|
+
deleted?: true
|
|
491
|
+
assets?: Array<{ path: string; storageKey: string; mime: string; bytes: number; sha256: string; visible?: boolean }>
|
|
492
|
+
documents?: { upload: Array<{ fileName: string; folder?: string; sha256: string; bytes: number; mime: string; replaces: string | null }>; remove: Array<{ fileName: string; sourceId: string }> }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** A document as a request lists it: its own file name, and its folder when it is in one. */
|
|
496
|
+
function asRequestDocument<T extends { fileName: string }>(d: T): T & { folder?: string } {
|
|
497
|
+
const { folder, fileName } = splitDocumentPath(d.fileName)
|
|
498
|
+
return { ...d, fileName, ...(folder ? { folder } : {}) }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The entry `function deploy` builds for a Function item. */
|
|
502
|
+
export function functionEntryOf(root: string, name: string, find: (root: string, name: string) => string | null): string {
|
|
503
|
+
if (existsSync(join(root, 'functions', name, 'package.json'))) {
|
|
504
|
+
const entry = find(root, name)
|
|
505
|
+
if (!entry) throw new CliError(`functions/${name}/ has no entry file to build`, { code: 'USAGE', hint: `add functions/${name}/index.ts, or a file whose automation() is named ${name}` })
|
|
506
|
+
return entry
|
|
507
|
+
}
|
|
508
|
+
return `functions/${name}.ts`
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Each proposable item as the platform takes it: a Function is built first,
|
|
513
|
+
* a skill's assets uploaded (content-addressed, so an asset the workspace
|
|
514
|
+
* has already is not stored twice), a deletion sent with no files, and a
|
|
515
|
+
* knowledge base's documents listed by digest (their bytes follow once the
|
|
516
|
+
* request exists).
|
|
517
|
+
*/
|
|
518
|
+
export async function pushBody(
|
|
519
|
+
items: PushItem[],
|
|
520
|
+
build: (item: PushItem) => Promise<FunctionBuild>,
|
|
521
|
+
uploadAsset: (item: PushItem, asset: PushAsset) => Promise<string> = async () => {
|
|
522
|
+
throw new CliError('skill assets need the platform to upload to', { code: 'USAGE', hint: 'propose with `frontera local sync push`, which uploads them' })
|
|
523
|
+
},
|
|
524
|
+
): Promise<PushBodyItem[]> {
|
|
525
|
+
const out: PushBodyItem[] = []
|
|
526
|
+
for (const item of items) {
|
|
527
|
+
if (!item.proposable) continue
|
|
528
|
+
const assets = item.assets ? await Promise.all(item.assets.map(async (a) => ({
|
|
529
|
+
path: a.path,
|
|
530
|
+
storageKey: await uploadAsset(item, a),
|
|
531
|
+
mime: a.mime,
|
|
532
|
+
bytes: a.bytes,
|
|
533
|
+
sha256: a.sha256,
|
|
534
|
+
...(a.visible ? { visible: true } : {}),
|
|
535
|
+
}))) : undefined
|
|
536
|
+
out.push({
|
|
537
|
+
ref: item.ref,
|
|
538
|
+
...(item.id ? { id: item.id } : {}),
|
|
539
|
+
...(item.baseRevision ? { baseRevision: item.baseRevision } : {}),
|
|
540
|
+
files: item.files,
|
|
541
|
+
...(item.change === 'deleted' ? { deleted: true as const } : {}),
|
|
542
|
+
...(item.kind === 'function' && item.change !== 'deleted' ? { build: await build(item) } : {}),
|
|
543
|
+
...(assets ? { assets } : {}),
|
|
544
|
+
...(item.documents ? { documents: { upload: item.documents.upload.map(({ change: _change, ...d }) => asRequestDocument(d)), remove: item.documents.remove.map(asRequestDocument).map(({ folder: _folder, ...d }) => d) } } : {}),
|
|
545
|
+
})
|
|
546
|
+
}
|
|
547
|
+
return out
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── The request this project keeps ──────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
export type RequestState = 'draft' | 'open' | 'approved' | 'merging' | 'merged' | 'closed'
|
|
553
|
+
|
|
554
|
+
export interface KeptRequest {
|
|
555
|
+
id: string
|
|
556
|
+
title: string
|
|
557
|
+
state: RequestState
|
|
558
|
+
refs: string[]
|
|
559
|
+
/** What people call each item, so the home and the page name them without reading the platform. */
|
|
560
|
+
titles: Record<string, string>
|
|
561
|
+
proposedAt: string
|
|
562
|
+
updatedAt: string
|
|
563
|
+
/** When Apply last ran, and whether it went through. */
|
|
564
|
+
applied?: { at: string; ok: boolean }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export interface RequestsFile {
|
|
568
|
+
version: 1
|
|
569
|
+
workspace: string
|
|
570
|
+
/** The request the next push updates: open until it is applied or closed. */
|
|
571
|
+
current: string | null
|
|
572
|
+
requests: Record<string, KeptRequest>
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const REQUESTS_JSON = join(SYNC_DIR, 'requests.json')
|
|
576
|
+
const REQUESTS_DIR = join(SYNC_DIR, 'requests')
|
|
577
|
+
|
|
578
|
+
/** `.frontera/sync/` stays out of git, the requests with it. */
|
|
579
|
+
function ignoreSync(root: string): void {
|
|
580
|
+
const ignore = join(root, '.frontera', '.gitignore')
|
|
581
|
+
mkdirSync(dirname(ignore), { recursive: true })
|
|
582
|
+
const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : ''
|
|
583
|
+
if (text.split(/\r?\n/).some((l) => l.trim() === 'sync/' || l.trim() === 'sync')) return
|
|
584
|
+
if (text) appendFileSync(ignore, `${text.endsWith('\n') ? '' : '\n'}sync/\n`)
|
|
585
|
+
else writeFileSync(ignore, 'local/\nsync/\n')
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** The requests file for this workspace; another workspace's (a relinked project) reads as none. */
|
|
589
|
+
export function readRequests(root: string, workspace: string): RequestsFile {
|
|
590
|
+
const empty: RequestsFile = { version: 1, workspace, current: null, requests: {} }
|
|
591
|
+
try {
|
|
592
|
+
const file = join(root, REQUESTS_JSON)
|
|
593
|
+
if (!existsSync(file)) return empty
|
|
594
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as RequestsFile
|
|
595
|
+
if (!parsed || parsed.version !== 1 || parsed.workspace !== workspace || typeof parsed.requests !== 'object' || !parsed.requests) return empty
|
|
596
|
+
return parsed
|
|
597
|
+
} catch {
|
|
598
|
+
return empty
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function writeRequests(root: string, file: RequestsFile): void {
|
|
603
|
+
ignoreSync(root)
|
|
604
|
+
writeFileAtomic(join(root, REQUESTS_JSON), `${JSON.stringify(file, null, 2)}\n`, 0o644)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** A request still taking pushes: the next push updates it rather than opening another. */
|
|
608
|
+
export function isOpen(state: string | null | undefined): boolean {
|
|
609
|
+
return state === 'draft' || state === 'open' || state === 'approved'
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The current request, when there is one still open or being applied. */
|
|
613
|
+
export function currentRequest(file: RequestsFile): KeptRequest | null {
|
|
614
|
+
const kept = file.current ? file.requests[file.current] : undefined
|
|
615
|
+
return kept ?? null
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** The platform's view of a request, as much of it as this file keeps. */
|
|
619
|
+
export interface RequestViewLike {
|
|
620
|
+
request: { id: string; title: string; state: string; createdAt?: string; updatedAt?: string }
|
|
621
|
+
items: Array<{ ref: string }>
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Record what the platform said about a request; an applied or closed one stops being current. */
|
|
625
|
+
export function recordRequest(root: string, workspace: string, view: RequestViewLike, opts: { titles?: Record<string, string>; applied?: { ok: boolean }; pushed?: boolean; now?: Date } = {}): RequestsFile {
|
|
626
|
+
const file = readRequests(root, workspace)
|
|
627
|
+
const now = (opts.now ?? new Date()).toISOString()
|
|
628
|
+
const before = file.requests[view.request.id]
|
|
629
|
+
const refs = view.items.map((i) => i.ref).sort()
|
|
630
|
+
const titles = Object.fromEntries(refs.map((r) => [r, opts.titles?.[r] ?? before?.titles[r] ?? r]))
|
|
631
|
+
file.requests[view.request.id] = {
|
|
632
|
+
id: view.request.id,
|
|
633
|
+
title: view.request.title,
|
|
634
|
+
state: view.request.state as RequestState,
|
|
635
|
+
refs,
|
|
636
|
+
titles,
|
|
637
|
+
proposedAt: before?.proposedAt ?? view.request.createdAt ?? now,
|
|
638
|
+
updatedAt: now,
|
|
639
|
+
// A push after a failed Apply proposes the request again: it no longer reads "Apply failed".
|
|
640
|
+
...(opts.applied ? { applied: { at: now, ok: opts.applied.ok } } : before?.applied && !opts.pushed ? { applied: before.applied } : {}),
|
|
641
|
+
}
|
|
642
|
+
// Applied or closed, it stays listed (the card says "Applied") and the next push opens a new one.
|
|
643
|
+
file.current = view.request.id
|
|
644
|
+
writeRequests(root, file)
|
|
645
|
+
return file
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The base versions kept with a request: the files each item was changed
|
|
650
|
+
* from, for its Changes view. `replace: false` (a push of named items) keeps
|
|
651
|
+
* the other items' copies.
|
|
652
|
+
*/
|
|
653
|
+
export function writeRequestBase(root: string, id: string, items: PushItem[], opts: { replace?: boolean } = {}): void {
|
|
654
|
+
if (!/^[0-9a-f-]{36}$/i.test(id)) return
|
|
655
|
+
ignoreSync(root)
|
|
656
|
+
const dir = join(root, REQUESTS_DIR, id, 'base')
|
|
657
|
+
const listed = join(dir, '.items')
|
|
658
|
+
const previous = opts.replace === false && existsSync(listed) ? readFileSync(listed, 'utf8').split('\n').filter(Boolean) : []
|
|
659
|
+
if (opts.replace !== false) rmSync(dir, { recursive: true, force: true })
|
|
660
|
+
mkdirSync(dir, { recursive: true })
|
|
661
|
+
for (const item of items) {
|
|
662
|
+
for (const [path, text] of Object.entries(item.base)) {
|
|
663
|
+
safePath(root, path)
|
|
664
|
+
const full = safePath(dir, path)
|
|
665
|
+
mkdirSync(dirname(full), { recursive: true })
|
|
666
|
+
writeFileSync(full, text)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Which items the copy is for, whether each was new (a new item has no base to keep), and its base's
|
|
670
|
+
// paths (a deletion sends no files, so its Changes view lists them from here).
|
|
671
|
+
const lines = new Map(previous.map((l) => [l.split('\t')[0]!, l]))
|
|
672
|
+
for (const i of items) lines.set(i.ref, `${i.ref}\t${i.change}\t${Object.keys(i.base).join('\u001f')}`)
|
|
673
|
+
writeFileSync(listed, `${[...lines.values()].join('\n')}\n`)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** A base file kept with a request, or null; `known` says whether the copy exists at all, `paths` an item's base files. */
|
|
677
|
+
export function readRequestBase(root: string, id: string): { known: boolean; read: (path: string) => string | null; paths: (ref: string) => string[] } {
|
|
678
|
+
const dir = join(root, REQUESTS_DIR, id, 'base')
|
|
679
|
+
const known = /^[0-9a-f-]{36}$/i.test(id) && existsSync(join(dir, '.items'))
|
|
680
|
+
const listed = known ? readFileSync(join(dir, '.items'), 'utf8').split('\n').filter(Boolean).map((l) => l.split('\t')) : []
|
|
681
|
+
return {
|
|
682
|
+
known,
|
|
683
|
+
paths: (ref) => (listed.find((l) => l[0] === ref)?.[2] ?? '').split('\u001f').filter(Boolean),
|
|
684
|
+
read: (path) => {
|
|
685
|
+
if (!known) return null
|
|
686
|
+
try {
|
|
687
|
+
const full = safePath(dir, path)
|
|
688
|
+
return existsSync(full) ? readFileSync(full, 'utf8') : null
|
|
689
|
+
} catch {
|
|
690
|
+
return null
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ── After Apply ─────────────────────────────────────────────────────────────
|
|
697
|
+
|
|
698
|
+
/** Whether two versions of a file say the same: the same document for JSON and YAML (key order aside), the same text otherwise. */
|
|
699
|
+
export function sameDocument(path: string, a: string, b: string): boolean {
|
|
700
|
+
if (a === b) return true
|
|
701
|
+
try {
|
|
702
|
+
if (/^agents\/[^/]+\/evals\/[^/]+\.ya?ml$/.test(path)) return deepEqual(evaluationCases(a), evaluationCases(b))
|
|
703
|
+
// A knowledge base's quality checks are a set: the platform lists them in its own order.
|
|
704
|
+
if (path.endsWith(`/${QUALITY_CHECKS_FILE}`)) return deepEqual(checkSet(a), checkSet(b))
|
|
705
|
+
if (/\.json$/i.test(path)) return deepEqual(JSON.parse(a), JSON.parse(b))
|
|
706
|
+
if (/\.ya?ml$/i.test(path)) return deepEqual(parseYaml(a), parseYaml(b))
|
|
707
|
+
} catch {
|
|
708
|
+
return false
|
|
709
|
+
}
|
|
710
|
+
return false
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Quality checks as a set: by question, each one's lists in order of their words. */
|
|
714
|
+
function checkSet(text: string): unknown {
|
|
715
|
+
return readQualityChecks(text)
|
|
716
|
+
.map((c) => ({ ...c, documents: [...(c.documents ?? [])].sort(), keywords: [...(c.keywords ?? [])].sort() }))
|
|
717
|
+
.sort((x, y) => x.question.localeCompare(y.question))
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* An evaluation file's cases as the platform keeps them: each question with
|
|
722
|
+
* its expected answer and phrases, in order. A case's name and gold flag are
|
|
723
|
+
* the platform's to give (a pull names cases by position), so two files with
|
|
724
|
+
* the same cases say the same.
|
|
725
|
+
*/
|
|
726
|
+
function evaluationCases(text: string): unknown {
|
|
727
|
+
const doc = (parseYaml(text) ?? {}) as { cases?: Array<Record<string, unknown>> }
|
|
728
|
+
return (doc.cases ?? []).map((c) =>
|
|
729
|
+
c.scenario !== undefined
|
|
730
|
+
? { scenario: c.scenario, expect: c.expect ?? null }
|
|
731
|
+
: {
|
|
732
|
+
question: (c.conversation as Array<{ user?: string }> | undefined)?.[0]?.user ?? null,
|
|
733
|
+
expected: typeof c.expected === 'string' ? c.expected : null,
|
|
734
|
+
contains: Array.isArray(c.expectedContains) ? c.expectedContains : [],
|
|
735
|
+
// A check's text is the platform's to word.
|
|
736
|
+
checks: Array.isArray(c.checks) ? (c.checks as Array<Record<string, unknown>>).map(({ text: _text, ...check }) => check) : [],
|
|
737
|
+
judged: Array.isArray(c.judged) ? c.judged : [],
|
|
738
|
+
},
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* After an Apply went through and a fetch read the new versions: each
|
|
744
|
+
* applied item's base moves to the workspace's version, as a pull leaves it.
|
|
745
|
+
* A file that says the same as the workspace's (the platform may order an
|
|
746
|
+
* Agent's keys its own way) takes the workspace's text, so the item reads as
|
|
747
|
+
* up to date; one that differs is left alone and shows as a change here.
|
|
748
|
+
* Returns the refs moved and the files rewritten.
|
|
749
|
+
*/
|
|
750
|
+
export function adoptApplied(root: string, workspace: string, refs: string[], remote: RemoteItem[], lock: SyncLock | null, now = new Date(), opts: { deleted?: string[] } = {}): { adopted: string[]; rewritten: string[] } {
|
|
751
|
+
const byRef = new Map(remote.map((r) => [r.ref, r]))
|
|
752
|
+
const next: SyncLock = { version: 2, workspace, ...(lock?.workspace === workspace && lock.pulledAt ? { pulledAt: lock.pulledAt } : {}), items: { ...(lock?.workspace === workspace ? lock.items : {}) } }
|
|
753
|
+
const moved: RemoteItem[] = []
|
|
754
|
+
const rewritten: string[] = []
|
|
755
|
+
const forgotten: string[] = []
|
|
756
|
+
for (const ref of refs) {
|
|
757
|
+
// Deleted here and applied, so gone there too: the item leaves the lock and its base, as a pull's Delete here
|
|
758
|
+
// leaves it (whatever a fetch that did not read the whole index still lists).
|
|
759
|
+
if (opts.deleted?.includes(ref)) {
|
|
760
|
+
if (next.items[ref]) {
|
|
761
|
+
forgotten.push(...Object.keys(next.items[ref]!.files))
|
|
762
|
+
delete next.items[ref]
|
|
763
|
+
}
|
|
764
|
+
continue
|
|
765
|
+
}
|
|
766
|
+
const item = byRef.get(ref)
|
|
767
|
+
if (!item) continue
|
|
768
|
+
for (const [path, text] of Object.entries(item.files)) {
|
|
769
|
+
const here = readLocal(root, path)
|
|
770
|
+
if (here !== null && here !== text && sameDocument(path, here, text)) {
|
|
771
|
+
writeFileSync(safePath(root, path), text)
|
|
772
|
+
rewritten.push(path)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
next.items[ref] = {
|
|
776
|
+
...(item.id ? { id: item.id } : {}),
|
|
777
|
+
...(item.revision ? { revision: item.revision } : {}),
|
|
778
|
+
remote: itemHash(item.files),
|
|
779
|
+
files: Object.fromEntries(Object.entries(item.files).map(([p, c]) => [p, hash(c)])),
|
|
780
|
+
...(item.version ? { version: item.version } : {}),
|
|
781
|
+
pulledAt: now.toISOString(),
|
|
782
|
+
}
|
|
783
|
+
moved.push(item)
|
|
784
|
+
}
|
|
785
|
+
if (!moved.length && !forgotten.length) return { adopted: [], rewritten }
|
|
786
|
+
if (moved.length) writeBase(root, moved, lock)
|
|
787
|
+
if (forgotten.length) dropBase(root, forgotten)
|
|
788
|
+
writeLock(root, next)
|
|
789
|
+
const gone = (opts.deleted ?? []).filter((r) => refs.includes(r))
|
|
790
|
+
return { adopted: [...moved.map((m) => m.ref), ...gone], rewritten }
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ── In words ────────────────────────────────────────────────────────────────
|
|
794
|
+
|
|
795
|
+
export const KIND_WORD: Record<SyncKind, string> = {
|
|
796
|
+
agent: 'Agent',
|
|
797
|
+
workflow: 'Workflow',
|
|
798
|
+
function: 'Function',
|
|
799
|
+
skill: 'Skill',
|
|
800
|
+
knowledge: 'Knowledge base',
|
|
801
|
+
blueprint: 'Blueprint',
|
|
802
|
+
plugin: 'Plugin',
|
|
803
|
+
evaluation: 'Evaluation',
|
|
804
|
+
dataset: 'Dataset',
|
|
805
|
+
theme: 'Slide theme',
|
|
806
|
+
'chat-app': 'Chat App',
|
|
807
|
+
app: 'App',
|
|
808
|
+
brand: 'Brand',
|
|
809
|
+
library: 'Library',
|
|
810
|
+
lessons: 'Lessons for Agent',
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* What an Agent's bindings file changes: its tools (Functions and workflows),
|
|
815
|
+
* its Context (the object types it reads), or both. Unread, it says both.
|
|
816
|
+
*/
|
|
817
|
+
function bindingsParts(file: PushFile | undefined): string[] {
|
|
818
|
+
const read = (text: string | null | undefined) => {
|
|
819
|
+
try {
|
|
820
|
+
const doc = JSON.parse(text ?? '{}') as Record<string, unknown>
|
|
821
|
+
return { tools: JSON.stringify([doc.functions ?? [], doc.workflows ?? []]), context: JSON.stringify(doc.blueprint ?? []) }
|
|
822
|
+
} catch {
|
|
823
|
+
return null
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const before = read(file?.base)
|
|
827
|
+
const after = read(file?.local)
|
|
828
|
+
if (!file || !before || !after) return ['tools and Context']
|
|
829
|
+
const parts = [before.tools !== after.tools ? 'tools' : '', before.context !== after.context ? 'Context' : ''].filter(Boolean)
|
|
830
|
+
return parts.length ? parts : ['tools and Context']
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** What changed in an item, in the product's words: "configuration and behavior settings". */
|
|
834
|
+
export function changedParts(kind: SyncKind, name: string, paths: string[], files: PushFile[] = []): string[] {
|
|
835
|
+
const parts = new Set<string>()
|
|
836
|
+
for (const path of paths) {
|
|
837
|
+
if (kind === 'agent') {
|
|
838
|
+
if (path.endsWith('/agent.json')) parts.add('configuration')
|
|
839
|
+
else if (path.endsWith('/operating.json')) parts.add('behavior settings')
|
|
840
|
+
else if (path.endsWith('/bindings.json')) for (const p of bindingsParts(files.find((f) => f.path === path))) parts.add(p)
|
|
841
|
+
} else if (kind === 'skill') {
|
|
842
|
+
const rel = path.slice(`skills/${name}/`.length)
|
|
843
|
+
parts.add(rel === 'SKILL.md' ? 'instructions' : rel.startsWith('scripts/') ? 'scripts' : 'references')
|
|
844
|
+
} else if (kind === 'workflow') parts.add('definition')
|
|
845
|
+
else if (kind === 'function') parts.add('code')
|
|
846
|
+
else if (kind === 'knowledge') parts.add(path.endsWith(`/${QUALITY_CHECKS_FILE}`) ? 'quality checks' : 'settings')
|
|
847
|
+
else if (kind === 'plugin') parts.add('settings and policy')
|
|
848
|
+
else if (kind === 'evaluation') parts.add('cases')
|
|
849
|
+
else if (kind === 'chat-app') parts.add('experience')
|
|
850
|
+
else if (kind === 'lessons') parts.add('rules')
|
|
851
|
+
}
|
|
852
|
+
return [...parts]
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function joinWords(words: string[]): string {
|
|
856
|
+
if (words.length <= 1) return words[0] ?? ''
|
|
857
|
+
return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** One line per item, as the Overview says it: "Agent Support Concierge: behavior settings changed". */
|
|
861
|
+
export function itemLine(item: { kind: SyncKind; name: string; title: string; change: 'changed' | 'new' | 'deleted'; changedPaths?: string[]; changedFiles?: PushFile[]; assetsChanged?: boolean; documentsChanged?: boolean }): string {
|
|
862
|
+
const [dir, apiName] = item.name.split('/')
|
|
863
|
+
const word = BLUEPRINT_WORD[dir ?? '']
|
|
864
|
+
// A Blueprint file says what it is: "Object type Ticket".
|
|
865
|
+
const label = item.kind === 'blueprint' ? (word && apiName ? `${word[0]!.toUpperCase()}${word.slice(1)} ${item.title}` : `Blueprint ${item.title}`) : `${KIND_WORD[item.kind]} ${item.title}`
|
|
866
|
+
if (item.change === 'deleted') return `${label}: delete`
|
|
867
|
+
// The platform keeps no lessons file, so "new" or "changed" depends only on
|
|
868
|
+
// whether this project pulled one; what the request does is the same either way.
|
|
869
|
+
if (item.kind === 'lessons') return `${label}: rules proposed for review`
|
|
870
|
+
if (item.change === 'new') return `${label}: new`
|
|
871
|
+
const parts = changedParts(item.kind, item.name, item.changedPaths ?? [], item.changedFiles)
|
|
872
|
+
if (item.assetsChanged) parts.push('assets')
|
|
873
|
+
if (item.documentsChanged) parts.push('documents')
|
|
874
|
+
return parts.length ? `${label}: ${joinWords(parts)} changed` : `${label}: changed`
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** The line for a candidate, with what its files do not show (assets, documents). */
|
|
878
|
+
export function pushItemLine(item: PushItem): string {
|
|
879
|
+
return itemLine({
|
|
880
|
+
...item,
|
|
881
|
+
changedPaths: item.diff.map((f) => f.path),
|
|
882
|
+
changedFiles: item.diff,
|
|
883
|
+
assetsChanged: Boolean(item.assets?.some((a) => a.change !== 'same')) || item.notes.some((n) => n.startsWith('Asset ') && n.includes('removed here')),
|
|
884
|
+
documentsChanged: Boolean(item.documents && (item.documents.upload.length || item.documents.remove.length)),
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// ── Apply's outcome ─────────────────────────────────────────────────────────
|
|
889
|
+
|
|
890
|
+
export type StepStatus = 'done' | 'failed' | 'not-run'
|
|
891
|
+
|
|
892
|
+
export interface ApplyStep {
|
|
893
|
+
key: string
|
|
894
|
+
phase: string
|
|
895
|
+
ref: string
|
|
896
|
+
label: string
|
|
897
|
+
status: StepStatus
|
|
898
|
+
error?: string
|
|
899
|
+
notes?: string[]
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export interface ApplyOutcome {
|
|
903
|
+
ok: boolean
|
|
904
|
+
steps: ApplyStep[]
|
|
905
|
+
failure?: { key: string; ref: string; itemIds: string[]; message: string }
|
|
906
|
+
compensation: Array<{ label: string; type: string; status: 'put-back' | 'failed' | 'irreversible'; error?: string }>
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Apply's outcome as the service sent it, read defensively: a field it lacks reads as empty. */
|
|
910
|
+
export function parseOutcome(raw: unknown): ApplyOutcome {
|
|
911
|
+
const o = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
|
912
|
+
const steps = (Array.isArray(o.steps) ? o.steps : []).flatMap((s): ApplyStep[] => {
|
|
913
|
+
if (!s || typeof s !== 'object') return []
|
|
914
|
+
const r = s as Record<string, unknown>
|
|
915
|
+
const status: StepStatus = r.status === 'done' || r.status === 'failed' ? r.status : 'not-run'
|
|
916
|
+
return [{
|
|
917
|
+
key: String(r.key ?? ''),
|
|
918
|
+
phase: String(r.phase ?? ''),
|
|
919
|
+
ref: String(r.ref ?? ''),
|
|
920
|
+
label: String(r.label ?? r.key ?? ''),
|
|
921
|
+
status,
|
|
922
|
+
...(typeof r.error === 'string' ? { error: r.error } : {}),
|
|
923
|
+
...(Array.isArray(r.notes) ? { notes: r.notes.filter((n): n is string => typeof n === 'string') } : {}),
|
|
924
|
+
}]
|
|
925
|
+
})
|
|
926
|
+
const f = o.failure as Record<string, unknown> | undefined
|
|
927
|
+
const compensation = (Array.isArray(o.compensation) ? o.compensation : []).flatMap((c) => {
|
|
928
|
+
if (!c || typeof c !== 'object') return []
|
|
929
|
+
const r = c as Record<string, unknown>
|
|
930
|
+
const status = r.status === 'failed' || r.status === 'irreversible' ? r.status : 'put-back'
|
|
931
|
+
return [{ label: String(r.label ?? ''), type: String(r.type ?? ''), status: status as 'put-back' | 'failed' | 'irreversible', ...(typeof r.error === 'string' ? { error: r.error } : {}) }]
|
|
932
|
+
})
|
|
933
|
+
return {
|
|
934
|
+
ok: o.ok === true,
|
|
935
|
+
steps,
|
|
936
|
+
...(f && typeof f === 'object' ? { failure: { key: String(f.key ?? ''), ref: String(f.ref ?? ''), itemIds: Array.isArray(f.itemIds) ? f.itemIds.map(String) : [], message: String(f.message ?? 'A step failed') } } : {}),
|
|
937
|
+
compensation,
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const STEP_MARK: Record<StepStatus, string> = { done: '✓', failed: '✗', 'not-run': '·' }
|
|
942
|
+
const BACK_WORD = { 'put-back': 'put back', failed: 'could not be put back', irreversible: 'cannot be withdrawn' } as const
|
|
943
|
+
|
|
944
|
+
/** Apply's steps, and after a failure what was put back, as `request apply` prints them. */
|
|
945
|
+
export function describeOutcome(outcome: ApplyOutcome, titles: Record<string, string> = {}): string[] {
|
|
946
|
+
const lines = outcome.steps.flatMap((s) => [
|
|
947
|
+
` ${STEP_MARK[s.status]} ${s.label}${s.status === 'not-run' && !outcome.ok ? ' (not run)' : ''}`,
|
|
948
|
+
...(s.notes ?? []).map((n) => ` · ${n}`),
|
|
949
|
+
])
|
|
950
|
+
if (outcome.ok) return [`Applied ${outcome.steps.length} step${outcome.steps.length === 1 ? '' : 's'}:`, ...lines]
|
|
951
|
+
const failed = outcome.failure
|
|
952
|
+
const name = failed ? titles[failed.ref] ?? failed.ref : 'a step'
|
|
953
|
+
return [
|
|
954
|
+
`Not applied: ${failed ? `${name} failed: ${failed.message}` : 'a step failed'}`,
|
|
955
|
+
...lines,
|
|
956
|
+
...(outcome.compensation.length
|
|
957
|
+
? ['', 'What was put back:', ...outcome.compensation.map((c) => ` ${c.label}: ${BACK_WORD[c.status]}${c.error ? ` (${c.error})` : ''}`)]
|
|
958
|
+
: ['', 'Nothing had changed yet, so nothing needed putting back.']),
|
|
959
|
+
]
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export interface Check {
|
|
963
|
+
ref: string
|
|
964
|
+
itemId?: string
|
|
965
|
+
code: string
|
|
966
|
+
message: string
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** The checks that say where an item stands, in the reader's words; the others say what is wrong in a file, as they are. */
|
|
970
|
+
const CHECK_WORDS: Record<string, string> = {
|
|
971
|
+
stale_base: 'Changed in the workspace since it was proposed: pull and merge it, then propose again',
|
|
972
|
+
gone: 'No longer in the workspace: propose it again as new, or leave it out',
|
|
973
|
+
exists: 'Now exists in the workspace: pull and merge it first',
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** The blocking checks a refusal carries (`details.checks`), read defensively. */
|
|
977
|
+
export function checksOf(details: unknown): Check[] {
|
|
978
|
+
const d = (details && typeof details === 'object' ? details : {}) as { checks?: unknown; details?: { checks?: unknown } }
|
|
979
|
+
const list = Array.isArray(d.checks) ? d.checks : Array.isArray(d.details?.checks) ? d.details!.checks as unknown[] : []
|
|
980
|
+
return list.flatMap((c) => {
|
|
981
|
+
if (!c || typeof c !== 'object') return []
|
|
982
|
+
const r = c as Record<string, unknown>
|
|
983
|
+
if (typeof r.ref !== 'string' || typeof r.message !== 'string') return []
|
|
984
|
+
return [{ ref: r.ref, ...(typeof r.itemId === 'string' ? { itemId: r.itemId } : {}), code: typeof r.code === 'string' ? r.code : 'invalid', message: (typeof r.code === 'string' && CHECK_WORDS[r.code]) || r.message }]
|
|
985
|
+
})
|
|
986
|
+
}
|