@frontera-sdk/cli 1.52.28 → 1.52.30
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,124 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { RemoteItem } from './local-sync-plan'
|
|
5
|
+
import { invert, type Directory } from './local-sync-refs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the workspace installed from the library: each installation's entry
|
|
9
|
+
* (`frontera/returns-desk`), the release it is at, and what it made there
|
|
10
|
+
* (the Agent, the skills it created or reused), by name. A pull records it
|
|
11
|
+
* as `library.json`, so the project keeps where its Agents and skills came
|
|
12
|
+
* from; the Agents and skills themselves come as the project's own items.
|
|
13
|
+
*
|
|
14
|
+
* `local dev` checks the local runtime's library for each entry and
|
|
15
|
+
* release and says whether it is there. It does not install them again:
|
|
16
|
+
* an installation makes its own Agent and managed skills, which would stand
|
|
17
|
+
* beside the project's copies, and a managed skill cannot take the
|
|
18
|
+
* project's edits. Never proposed back: installing is the library's own
|
|
19
|
+
* flow on the platform.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** An installation as `/v1/sync/library` lists it. */
|
|
23
|
+
export interface LibraryRow {
|
|
24
|
+
id: string
|
|
25
|
+
entry: { namespace: string; slug: string; kind: 'agent' | 'skill'; name: string; source?: string }
|
|
26
|
+
release: { number: number; version: string; digest?: string }
|
|
27
|
+
installType?: 'agent' | 'skill'
|
|
28
|
+
state?: string
|
|
29
|
+
agentId?: string | null
|
|
30
|
+
resources?: Array<{ type: 'agent' | 'skill'; id: string | null; ownership?: string }>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LibraryInstallation {
|
|
34
|
+
entry: string
|
|
35
|
+
kind: 'agent' | 'skill'
|
|
36
|
+
name: string
|
|
37
|
+
release: { number: number; version: string }
|
|
38
|
+
state?: string
|
|
39
|
+
agent?: string
|
|
40
|
+
skills?: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LibraryFile {
|
|
44
|
+
installations: LibraryInstallation[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const LIBRARY_PATH = 'library.json'
|
|
48
|
+
|
|
49
|
+
/** The workspace's installations as one project item (`library`). */
|
|
50
|
+
export function readLibrary(rows: LibraryRow[], directory: Directory): RemoteItem[] {
|
|
51
|
+
if (!rows.length) return []
|
|
52
|
+
const ids = invert(directory)
|
|
53
|
+
const nameOf = (id: string | null | undefined, kind: 'agent' | 'skill') => {
|
|
54
|
+
const hit = id ? ids.get(id) : undefined
|
|
55
|
+
return hit && hit.kind === kind ? hit.name : undefined
|
|
56
|
+
}
|
|
57
|
+
const installations: LibraryInstallation[] = rows.map((r) => {
|
|
58
|
+
const agent = nameOf(r.agentId, 'agent')
|
|
59
|
+
const made = (r.resources ?? []).flatMap((x) => (x.type === 'skill' && nameOf(x.id, 'skill') ? [`skill/${nameOf(x.id, 'skill')}`] : []))
|
|
60
|
+
return {
|
|
61
|
+
entry: `${r.entry.namespace}/${r.entry.slug}`,
|
|
62
|
+
kind: r.entry.kind,
|
|
63
|
+
name: r.entry.name,
|
|
64
|
+
release: { number: r.release.number, version: r.release.version },
|
|
65
|
+
...(r.state && r.state !== 'installed' ? { state: r.state } : {}),
|
|
66
|
+
...(agent ? { agent: `agent/${agent}` } : {}),
|
|
67
|
+
...(made.length ? { skills: [...new Set(made)].sort() } : {}),
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
const doc: LibraryFile = { installations }
|
|
71
|
+
const n = installations.length
|
|
72
|
+
return [{
|
|
73
|
+
ref: 'library',
|
|
74
|
+
kind: 'library',
|
|
75
|
+
name: 'library',
|
|
76
|
+
title: 'Library installations',
|
|
77
|
+
files: { [LIBRARY_PATH]: `${JSON.stringify(doc, null, 2)}\n` },
|
|
78
|
+
unresolved: [],
|
|
79
|
+
notes: [`Where ${n === 1 ? 'one Agent or skill' : `${n} Agents and skills`} came from in the library, recorded here; the items themselves are the project’s own`],
|
|
80
|
+
requires: [],
|
|
81
|
+
}]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
|
|
85
|
+
|
|
86
|
+
const dataOf = (body: unknown) => ((body as { data?: unknown } | null)?.data ?? body) as Record<string, unknown> | null
|
|
87
|
+
|
|
88
|
+
/** Why an installation is not installed again here, as the build says it. */
|
|
89
|
+
export const LIBRARY_NOT_REINSTALLED = 'The Agents and skills it installed are the project’s own copies here; installing them again would add a second copy'
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check the local runtime's library for each installation the project
|
|
93
|
+
* records: the same entry at the same release, a different release, or none.
|
|
94
|
+
* Nothing is installed (see above); the build says what it found.
|
|
95
|
+
*/
|
|
96
|
+
export async function applyLibrary(root: string, call: Call): Promise<{ ok: boolean; output: string }> {
|
|
97
|
+
const path = join(root, LIBRARY_PATH)
|
|
98
|
+
if (!existsSync(path)) return { ok: false, output: `error [LIBRARY]: ${LIBRARY_PATH} is missing` }
|
|
99
|
+
let doc: LibraryFile
|
|
100
|
+
try {
|
|
101
|
+
doc = JSON.parse(readFileSync(path, 'utf8')) as LibraryFile
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return { ok: false, output: `error [USAGE]: ${LIBRARY_PATH} is not valid JSON: ${(err as Error).message}` }
|
|
104
|
+
}
|
|
105
|
+
const installations = Array.isArray(doc.installations) ? doc.installations : []
|
|
106
|
+
const offered = new Map<string, { number: number; version: string } | null>()
|
|
107
|
+
for (let offset = 0; offset < 2000; offset += 50) {
|
|
108
|
+
const listed = await call('GET', `/v1/library/entries?limit=50&offset=${offset}`)
|
|
109
|
+
if (listed.status >= 400) return { ok: true, output: ` ${installations.length} library installation${installations.length === 1 ? '' : 's'} recorded; this runtime’s library could not be read (${listed.status})` }
|
|
110
|
+
const page = (dataOf(listed.body)?.data ?? dataOf(listed.body)) as { entries?: Array<{ key: string; offeredRelease?: { releaseNumber: number; contentVersion: string } | null }>; hasMore?: boolean } | null
|
|
111
|
+
for (const e of page?.entries ?? []) offered.set(e.key, e.offeredRelease ? { number: e.offeredRelease.releaseNumber, version: e.offeredRelease.contentVersion } : null)
|
|
112
|
+
if (!page?.hasMore) break
|
|
113
|
+
}
|
|
114
|
+
const lines = installations.map((i) => {
|
|
115
|
+
const here = offered.get(i.entry)
|
|
116
|
+
const what = i.agent ?? i.skills?.join(', ') ?? i.entry
|
|
117
|
+
if (here === undefined) return ` ${i.name} (${what}): not in this runtime’s library; the project’s copy is used`
|
|
118
|
+
if (here && here.number === i.release.number) return ` ${i.name} (${what}): in this runtime’s library at the same release (${i.release.version})`
|
|
119
|
+
return ` ${i.name} (${what}): this runtime’s library offers ${here ? `release ${here.version}` : 'no release'}, the workspace has ${i.release.version}`
|
|
120
|
+
})
|
|
121
|
+
lines.unshift(` ${installations.length} library installation${installations.length === 1 ? '' : 's'} recorded`)
|
|
122
|
+
if (installations.length) lines.push(` warning: ${LIBRARY_NOT_REINSTALLED}`)
|
|
123
|
+
return { ok: true, output: lines.join('\n') }
|
|
124
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stand-in for a media-backed object type (design §8, Stubbed): a
|
|
3
|
+
* Media Set holds the platform's files, which never come down, so an object
|
|
4
|
+
* type over a set's catalog (`media_<set>`) is held locally over an empty
|
|
5
|
+
* Media Set of the same name, made on the runtime before the Blueprint
|
|
6
|
+
* applies. The type keeps its fields, links, metrics and Actions; it has no
|
|
7
|
+
* rows until files are uploaded to the local set. A type over a set's
|
|
8
|
+
* extracted text or passages is still left off (`local-portability.ts`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
|
|
12
|
+
|
|
13
|
+
const dataOf = (body: unknown) => ((body as { data?: unknown } | null)?.data ?? body) as unknown
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Make each Media Set the runtime lacks, by API name, as the runtime's owner.
|
|
17
|
+
* Returns why one could not be made, or null.
|
|
18
|
+
*/
|
|
19
|
+
export async function ensureMediaSets(sets: Record<string, string>, call: Call): Promise<string | null> {
|
|
20
|
+
const names = Object.keys(sets)
|
|
21
|
+
if (!names.length) return null
|
|
22
|
+
const listed = await call('GET', '/v1/media/sets')
|
|
23
|
+
if (listed.status >= 400) return `the runtime’s Media Sets couldn’t be read (${listed.status})`
|
|
24
|
+
const here = new Set(((dataOf(listed.body) as { mediaSets?: Array<{ apiName?: string }> } | null)?.mediaSets ?? []).map((s) => s.apiName))
|
|
25
|
+
for (const apiName of names) {
|
|
26
|
+
if (here.has(apiName)) continue
|
|
27
|
+
const made = await call('POST', '/v1/media/sets', { apiName, displayName: sets[apiName], description: 'An empty stand-in for the platform’s Media Set: its files stay on the platform.' })
|
|
28
|
+
if (made.status >= 400) return `the Media Set ${apiName} couldn’t be made (${made.status} ${JSON.stringify(made.body).slice(0, 160)})`
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parse as parseYaml } from 'yaml'
|
|
5
|
+
|
|
6
|
+
import { isIgnoredName } from './local-dev-sync'
|
|
7
|
+
import type { LocalCredentials } from './local-state'
|
|
8
|
+
import { objectSetNames, rows, type ObjectSetRow } from './local-sync-remote'
|
|
9
|
+
import { parseRef, referencesIn, toIds } from './local-sync-refs'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Saved object sets on the local runtime, from `blueprint/object-sets/`.
|
|
13
|
+
*
|
|
14
|
+
* A pull writes each of the workspace's saved sets there (`objectSetItems`):
|
|
15
|
+
* its name, the object type it returns, and its definition, or the keys of
|
|
16
|
+
* the records a static set lists. `blueprint apply` reads only the model's
|
|
17
|
+
* own folders, so `local dev` saves the sets itself once the Blueprint is
|
|
18
|
+
* published and granted here: a new one is created, a changed one updated,
|
|
19
|
+
* in the order their `reference`s to each other need. A static set whose
|
|
20
|
+
* records changed is created again (the platform keeps a static set's keys
|
|
21
|
+
* as they were made), so what names it is applied again.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface ObjectSetFile {
|
|
25
|
+
/** The file's name, which is the set's name in a reference (`objectSet/open-cases`). */
|
|
26
|
+
ref: string
|
|
27
|
+
file: string
|
|
28
|
+
doc: { name?: unknown; description?: unknown; objectType?: unknown; kind?: unknown; definition?: unknown; records?: unknown }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readObjectSetFiles(root: string): ObjectSetFile[] {
|
|
32
|
+
const dir = join(root, 'blueprint', 'object-sets')
|
|
33
|
+
if (!existsSync(dir)) return []
|
|
34
|
+
return readdirSync(dir).sort().flatMap((name) => {
|
|
35
|
+
if (isIgnoredName(name) || !/\.(ya?ml|json)$/.test(name)) return []
|
|
36
|
+
const file = join('blueprint', 'object-sets', name)
|
|
37
|
+
try {
|
|
38
|
+
const doc = parseYaml(readFileSync(join(root, file), 'utf8')) as ObjectSetFile['doc'] | null
|
|
39
|
+
return doc && typeof doc === 'object' ? [{ ref: name.replace(/\.(ya?ml|json)$/, ''), file, doc }] : []
|
|
40
|
+
} catch {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Fetcher = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
|
|
47
|
+
|
|
48
|
+
export function objectSetCalls(serviceUrl: string, creds: LocalCredentials, fetcher: typeof fetch = fetch): Fetcher {
|
|
49
|
+
return async (method, path, body) => {
|
|
50
|
+
const res = await fetcher(new URL(path, serviceUrl), {
|
|
51
|
+
method,
|
|
52
|
+
headers: {
|
|
53
|
+
authorization: `Bearer ${creds.session}`,
|
|
54
|
+
'x-org-id': creds.organizationId,
|
|
55
|
+
'x-workspace-id': creds.workspaceId,
|
|
56
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
57
|
+
},
|
|
58
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
59
|
+
})
|
|
60
|
+
const text = await res.text()
|
|
61
|
+
let parsed: unknown = text
|
|
62
|
+
try {
|
|
63
|
+
parsed = text ? JSON.parse(text) : null
|
|
64
|
+
} catch {
|
|
65
|
+
// Keep the text.
|
|
66
|
+
}
|
|
67
|
+
return { status: res.status, body: parsed }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const errorOf = (answer: { status: number; body: unknown }): string => {
|
|
72
|
+
const b = answer.body as { message?: unknown } | null
|
|
73
|
+
return `${answer.status} ${String(typeof b?.message === 'string' ? b.message : JSON.stringify(b)).replace(/\s+/g, ' ').slice(0, 200)}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
|
77
|
+
|
|
78
|
+
export interface ObjectSetsApplied {
|
|
79
|
+
/** One line per set saved, for the apply's output. */
|
|
80
|
+
lines: string[]
|
|
81
|
+
/** One `warning:` line per set that could not be saved here: an adaptation of the Blueprint build. */
|
|
82
|
+
warnings: string[]
|
|
83
|
+
/** Sets created again under a new id: what names them is applied again. */
|
|
84
|
+
recreated: string[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Save the project's object sets on the runtime. `leftOff` names the object
|
|
89
|
+
* types the runtime's Blueprint leaves off (media-backed): a set on one is
|
|
90
|
+
* left off too, with why.
|
|
91
|
+
*/
|
|
92
|
+
export async function applyObjectSets(root: string, call: Fetcher, leftOff: ReadonlySet<string> = new Set()): Promise<ObjectSetsApplied> {
|
|
93
|
+
const out: ObjectSetsApplied = { lines: [], warnings: [], recreated: [] }
|
|
94
|
+
const files = readObjectSetFiles(root)
|
|
95
|
+
if (!files.length) return out
|
|
96
|
+
const listed = await call('GET', '/v1/sync/object-sets')
|
|
97
|
+
if (listed.status === 404) {
|
|
98
|
+
out.warnings.push(' warning: saved object sets: this runtime cannot list them, so they were not saved here')
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
if (listed.status >= 400) {
|
|
102
|
+
out.warnings.push(` warning: saved object sets: ${errorOf(listed)}`)
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
const existing = rows<ObjectSetRow>(listed.body)
|
|
106
|
+
const names = objectSetNames(existing)
|
|
107
|
+
const byRef = new Map(existing.map((s) => [names.get(s.id)!, s]))
|
|
108
|
+
const ids = new Map([...byRef].map(([ref, s]) => [ref, s.id]))
|
|
109
|
+
|
|
110
|
+
let pending = files
|
|
111
|
+
while (pending.length) {
|
|
112
|
+
const next: ObjectSetFile[] = []
|
|
113
|
+
for (const f of pending) {
|
|
114
|
+
const doc = f.doc
|
|
115
|
+
const name = typeof doc.name === 'string' && doc.name ? doc.name : f.ref
|
|
116
|
+
const objectType = typeof doc.objectType === 'string' ? doc.objectType : ''
|
|
117
|
+
const kind = doc.kind === 'static' ? 'static' : 'dynamic'
|
|
118
|
+
if (!objectType) {
|
|
119
|
+
out.warnings.push(` warning: saved object set ${f.ref} names no objectType, so it was not saved here`)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (leftOff.has(objectType)) {
|
|
123
|
+
out.warnings.push(` warning: saved object set ${f.ref} is left off here: its object type ${objectType} is left off on this runtime`)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
// A set naming another waits for that one.
|
|
127
|
+
const waits = referencesIn(doc.definition).filter((r) => r.kind === 'objectSet' && !ids.has(r.name))
|
|
128
|
+
if (waits.length) {
|
|
129
|
+
next.push(f)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
const { doc: definition } = toIds(doc.definition, { objectSet: ids })
|
|
133
|
+
const description = typeof doc.description === 'string' ? doc.description : undefined
|
|
134
|
+
const records = Array.isArray(doc.records) ? (doc.records as Array<string | number>) : []
|
|
135
|
+
const have = byRef.get(f.ref)
|
|
136
|
+
let saved: { status: number; body: unknown }
|
|
137
|
+
if (have && kind === 'dynamic' && have.kind === 'dynamic') {
|
|
138
|
+
if (have.name === name && (have.description ?? undefined) === description && same(have.definition, definition)) continue
|
|
139
|
+
saved = await call('PUT', `/v1/blueprint/object-sets/${encodeURIComponent(have.id)}`, { name, description: description ?? null, definition })
|
|
140
|
+
} else {
|
|
141
|
+
if (have) {
|
|
142
|
+
if (have.kind === 'static' && kind === 'static' && have.name === name && (have.description ?? undefined) === description && same(have.staticPks, records) && have.objectType === objectType) continue
|
|
143
|
+
// A static set's keys, or a set's kind, cannot be edited in place: it is made again.
|
|
144
|
+
await call('DELETE', `/v1/blueprint/object-sets/${encodeURIComponent(have.id)}`)
|
|
145
|
+
out.recreated.push(f.ref)
|
|
146
|
+
}
|
|
147
|
+
if (kind === 'static' && !records.length) {
|
|
148
|
+
out.warnings.push(` warning: saved object set ${f.ref} lists no records, so it was not saved here`)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
saved = await call('POST', '/v1/blueprint/object-sets', {
|
|
152
|
+
name,
|
|
153
|
+
...(description ? { description } : {}),
|
|
154
|
+
kind,
|
|
155
|
+
...(kind === 'static' ? { objectType, staticPks: records } : { definition }),
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
if (saved.status >= 400) {
|
|
159
|
+
out.warnings.push(` warning: saved object set ${f.ref} could not be saved here: ${errorOf(saved)}`)
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
const row = ((saved.body as { data?: unknown })?.data ?? saved.body) as { id?: string }
|
|
163
|
+
if (row?.id) ids.set(f.ref, row.id)
|
|
164
|
+
out.lines.push(` saved object set ${f.ref}`)
|
|
165
|
+
}
|
|
166
|
+
if (next.length === pending.length) {
|
|
167
|
+
for (const f of next) {
|
|
168
|
+
const missing = referencesIn(f.doc.definition).filter((r) => r.kind === 'objectSet' && !ids.has(r.name)).map((r) => `objectSet/${r.name}`)
|
|
169
|
+
out.warnings.push(` warning: saved object set ${f.ref} was not saved here: it names ${missing.join(', ')}, which isn't in the project`)
|
|
170
|
+
}
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
pending = next
|
|
174
|
+
}
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Whether a file's text names one of these sets, so it is applied again with its new id. */
|
|
179
|
+
export function namesObjectSet(text: string, refs: string[]): boolean {
|
|
180
|
+
return refs.some((ref) => text.includes(`objectSet/${ref}`) && parseRef(`objectSet/${ref}`) !== null)
|
|
181
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { isUuid, parseRef, type Directory } from './local-sync-refs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An Agent's object tools (`objectBindings` in `agent.json`): each row binds
|
|
5
|
+
* it to an object definition of the workspace, whose records the Agent reads
|
|
6
|
+
* and writes through its object tools. The definition is the workspace's,
|
|
7
|
+
* not the Agent's, but nothing else in a project holds it, so a pull writes
|
|
8
|
+
* it into the row it belongs to (`definition`: name, description, icon and
|
|
9
|
+
* fields), and names the row's definition by slug (`object/<slug>`). The
|
|
10
|
+
* records stay on the platform (sync v2 design §8).
|
|
11
|
+
*
|
|
12
|
+
* `local dev` makes each definition on the runtime from the row (or brings
|
|
13
|
+
* the runtime's copy up to its fields), then binds the Agent to it. A row the
|
|
14
|
+
* pull could not name (an older platform) or a definition the runtime refuses
|
|
15
|
+
* leaves the Agent without those tools, said as an adaptation.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface ObjectDefinitionDoc {
|
|
19
|
+
name: string
|
|
20
|
+
description?: string
|
|
21
|
+
icon?: string
|
|
22
|
+
fields: Array<Record<string, unknown>>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A definition as `GET /v1/sync/object-definitions` lists it. */
|
|
26
|
+
export interface ObjectDefinitionRow {
|
|
27
|
+
id: string
|
|
28
|
+
slug: string
|
|
29
|
+
name: string
|
|
30
|
+
description?: string | null
|
|
31
|
+
icon?: string | null
|
|
32
|
+
fields?: unknown
|
|
33
|
+
shared?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a row carries of its definition: everything but its id and timestamps. */
|
|
37
|
+
export function definitionDoc(row: ObjectDefinitionRow): ObjectDefinitionDoc {
|
|
38
|
+
return {
|
|
39
|
+
name: row.name,
|
|
40
|
+
...(row.description ? { description: row.description } : {}),
|
|
41
|
+
...(row.icon ? { icon: row.icon } : {}),
|
|
42
|
+
fields: (Array.isArray(row.fields) ? row.fields : []) as Array<Record<string, unknown>>,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The pull's side: each binding row gets its definition, by the platform's id,
|
|
48
|
+
* before ids become names. Returns the notes for rows it could not complete.
|
|
49
|
+
*/
|
|
50
|
+
export function attachDefinitions(agent: Record<string, unknown>, definitions: ObjectDefinitionRow[] | null): string[] {
|
|
51
|
+
const rows = Array.isArray(agent.objectBindings) ? (agent.objectBindings as Array<Record<string, unknown>>) : []
|
|
52
|
+
if (!rows.length) return []
|
|
53
|
+
if (!definitions) return [`Its object tools (${rows.length}) couldn’t be read: this platform doesn’t list object definitions to this key, so locally it goes without them`]
|
|
54
|
+
const byId = new Map(definitions.map((d) => [d.id, d]))
|
|
55
|
+
let missing = 0
|
|
56
|
+
for (const row of rows) {
|
|
57
|
+
const found = typeof row.objectDefinitionId === 'string' ? byId.get(row.objectDefinitionId) : undefined
|
|
58
|
+
if (found) row.definition = definitionDoc(found)
|
|
59
|
+
else missing += 1
|
|
60
|
+
}
|
|
61
|
+
return missing ? [`${missing} of its object tools name${missing === 1 ? 's' : ''} an object definition this workspace doesn’t list, so locally it goes without ${missing === 1 ? 'it' : 'them'}`] : []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Rows whose definition the pull could not name: they stay platform ids. */
|
|
65
|
+
export function unnamedObjectBindings(agent: unknown): number {
|
|
66
|
+
const rows = (agent as { objectBindings?: unknown } | null)?.objectBindings
|
|
67
|
+
if (!Array.isArray(rows)) return 0
|
|
68
|
+
return rows.filter((r) => {
|
|
69
|
+
const id = (r as { objectDefinitionId?: unknown } | null)?.objectDefinitionId
|
|
70
|
+
return typeof id !== 'string' || !parseRef(id) || !(r as { definition?: unknown }).definition
|
|
71
|
+
}).length
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
|
|
75
|
+
|
|
76
|
+
const dataOf = (body: unknown) => ((body as { data?: unknown } | null)?.data ?? body) as unknown
|
|
77
|
+
|
|
78
|
+
const errorOf = (a: { status: number; body: unknown }) => {
|
|
79
|
+
const b = a.body as { message?: unknown } | null
|
|
80
|
+
return `${a.status} ${String(typeof b?.message === 'string' ? b.message : JSON.stringify(b)).replace(/\s+/g, ' ').slice(0, 160)}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The fields as the definition route takes them: each with its sort order. */
|
|
84
|
+
function fieldsBody(fields: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
|
85
|
+
return fields.map((f, i) => ({ ...f, sortOrder: typeof f.sortOrder === 'number' ? f.sortOrder : i }))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The runtime's side: make or update each definition the Agent's rows carry,
|
|
92
|
+
* put its id in the directory, and drop the `definition` the runtime does not
|
|
93
|
+
* take. A row naming nothing the runtime can hold is left off, with why.
|
|
94
|
+
* Changes `agent` and `dir`; returns the warnings.
|
|
95
|
+
*/
|
|
96
|
+
export async function ensureObjectDefinitions(agent: Record<string, unknown>, dir: Directory, call: Call | null, label: string): Promise<string[]> {
|
|
97
|
+
const rows = Array.isArray(agent.objectBindings) ? (agent.objectBindings as Array<Record<string, unknown>>) : []
|
|
98
|
+
if (!rows.length) return []
|
|
99
|
+
const warnings: string[] = []
|
|
100
|
+
const leaveOff = (row: Record<string, unknown>, why: string) => {
|
|
101
|
+
warnings.push(` warning: Its object tools for ${String((row.definition as ObjectDefinitionDoc | undefined)?.name ?? row.objectDefinitionId)} are left out here: ${why}`)
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
let local: ObjectDefinitionRow[] | null = null
|
|
105
|
+
const listLocal = async () => {
|
|
106
|
+
if (local || !call) return local ?? []
|
|
107
|
+
const listed = await call('GET', '/v1/objects/definitions')
|
|
108
|
+
local = listed.status < 400 ? ((dataOf(listed.body) as ObjectDefinitionRow[] | null) ?? []) : []
|
|
109
|
+
return local
|
|
110
|
+
}
|
|
111
|
+
dir.object ??= new Map()
|
|
112
|
+
const kept: Array<Record<string, unknown>> = []
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
const id = typeof row.objectDefinitionId === 'string' ? row.objectDefinitionId : ''
|
|
115
|
+
const ref = parseRef(id)
|
|
116
|
+
const definition = row.definition as ObjectDefinitionDoc | undefined
|
|
117
|
+
const { definition: _definition, ...bare } = row
|
|
118
|
+
if (!ref || ref.kind !== 'object') {
|
|
119
|
+
if (isUuid(id)) leaveOff(row, 'the pull couldn’t name its object definition on the platform; pull again from a newer platform')
|
|
120
|
+
else leaveOff(row, `${id || 'it'} doesn’t name an object definition`)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if (!definition || !Array.isArray(definition.fields)) {
|
|
124
|
+
if (dir.object.has(ref.name)) kept.push(bare)
|
|
125
|
+
else leaveOff(row, `the object ${ref.name} isn’t on this runtime, and the file doesn’t carry its fields`)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (!call) {
|
|
129
|
+
leaveOff(row, 'the runtime has no owner session; restart it')
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
const here = (await listLocal()).find((d) => d.slug === ref.name)
|
|
133
|
+
if (!here) {
|
|
134
|
+
const made = await call('POST', '/v1/objects/definitions', { slug: ref.name, name: definition.name, ...(definition.description ? { description: definition.description } : {}), ...(definition.icon ? { icon: definition.icon } : {}), fields: fieldsBody(definition.fields) })
|
|
135
|
+
const madeId = (dataOf(made.body) as { id?: unknown } | null)?.id
|
|
136
|
+
if (made.status >= 400 || typeof madeId !== 'string') {
|
|
137
|
+
leaveOff(row, `the runtime refused its object definition (${errorOf(made)})`)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
dir.object.set(ref.name, madeId)
|
|
141
|
+
;(local as ObjectDefinitionRow[] | null)?.push({ id: madeId, slug: ref.name, name: definition.name })
|
|
142
|
+
} else {
|
|
143
|
+
dir.object.set(ref.name, here.id)
|
|
144
|
+
const differs = here.name !== definition.name || !same(here.description || undefined, definition.description) || !same(here.icon || undefined, definition.icon) || !same(fieldsBody((here.fields as Array<Record<string, unknown>>) ?? []).map(comparable), fieldsBody(definition.fields).map(comparable))
|
|
145
|
+
if (differs) {
|
|
146
|
+
const put = await call('PUT', `/v1/objects/definitions/${encodeURIComponent(here.id)}`, { name: definition.name, ...(definition.description ? { description: definition.description } : {}), ...(definition.icon ? { icon: definition.icon } : {}), fields: fieldsBody(definition.fields) })
|
|
147
|
+
if (put.status >= 400) warnings.push(` warning: ${label}: the object ${ref.name}’s fields couldn’t be brought up to the file’s (${errorOf(put)}); its tools use the runtime’s`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
kept.push(bare)
|
|
151
|
+
}
|
|
152
|
+
agent.objectBindings = kept
|
|
153
|
+
return warnings
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A field as both sides compare it: the keys a definition route keeps. */
|
|
157
|
+
function comparable(f: Record<string, unknown>): Record<string, unknown> {
|
|
158
|
+
return Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined && v !== null).sort(([a], [b]) => a.localeCompare(b)))
|
|
159
|
+
}
|
|
@@ -36,12 +36,16 @@ export async function applyOperatingConfig(args: {
|
|
|
36
36
|
creds: LocalCredentials
|
|
37
37
|
slug: string
|
|
38
38
|
file: string
|
|
39
|
+
/** The document with names resolved to this runtime's ids; the file is read when absent. */
|
|
40
|
+
document?: unknown
|
|
39
41
|
}): Promise<Result> {
|
|
40
|
-
let operatingConfig: unknown
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
let operatingConfig: unknown = args.document
|
|
43
|
+
if (operatingConfig === undefined) {
|
|
44
|
+
try {
|
|
45
|
+
operatingConfig = JSON.parse(readFileSync(args.file, 'utf8'))
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return { ok: false, output: `error [USAGE]: ${args.file} is not valid JSON: ${(err as Error).message}` }
|
|
48
|
+
}
|
|
45
49
|
}
|
|
46
50
|
if (!args.creds.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime has no owner session; restart it' }
|
|
47
51
|
const headers = {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { LocalCredentials } from './local-state'
|
|
2
|
+
import type { SyncClient } from './local-sync-client'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The organization settings that change how Agents behave, as
|
|
6
|
+
* `/v1/sync/settings` returns them: the timezone, the AI settings and the
|
|
7
|
+
* feature flags. A fetch records the workspace's in the snapshot; `local dev`
|
|
8
|
+
* reads the local runtime's the same way, takes the workspace's timezone,
|
|
9
|
+
* and records every other difference in the build, in words, so the build
|
|
10
|
+
* summary says where the runtime behaves differently.
|
|
11
|
+
*/
|
|
12
|
+
export interface OrgSettings {
|
|
13
|
+
timezone?: string
|
|
14
|
+
computerMode?: boolean
|
|
15
|
+
memoryTree?: boolean
|
|
16
|
+
embedding?: { provider: string | null; model: string | null }
|
|
17
|
+
memoryEmbedding?: { provider: string | null; model: string | null }
|
|
18
|
+
webSearch?: string | null
|
|
19
|
+
imageModel?: { provider: string | null; model: string | null }
|
|
20
|
+
voiceSpeed?: number | null
|
|
21
|
+
featureFlags?: Record<string, boolean>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The settings, or null on a platform without the route (or that could not answer). */
|
|
25
|
+
export async function readOrgSettings(client: SyncClient): Promise<OrgSettings | null> {
|
|
26
|
+
try {
|
|
27
|
+
const found = await client.find<unknown>('/v1/sync/settings')
|
|
28
|
+
if (!found || typeof found !== 'object') return null
|
|
29
|
+
const body = found as { data?: unknown }
|
|
30
|
+
const value = (body.data && typeof body.data === 'object' ? body.data : found) as OrgSettings
|
|
31
|
+
return value && typeof value === 'object' ? value : null
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT = 'the deployment’s default'
|
|
38
|
+
|
|
39
|
+
const pair = (p?: { provider: string | null; model: string | null }) => (p?.provider || p?.model ? [p.provider, p.model].filter(Boolean).join(' / ') : DEFAULT)
|
|
40
|
+
|
|
41
|
+
const onOff = (v?: boolean) => (v ? 'on' : 'off')
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Where the runtime's settings differ from the workspace's, one line each,
|
|
45
|
+
* the timezone left out (`local dev` sets it). A flag or setting only one
|
|
46
|
+
* side reports (an older runtime) is left out too: nothing to compare.
|
|
47
|
+
*/
|
|
48
|
+
export function settingsDifferences(workspace: OrgSettings, local: OrgSettings): string[] {
|
|
49
|
+
const out: string[] = []
|
|
50
|
+
const differs = (a: unknown, b: unknown) => JSON.stringify(a ?? null) !== JSON.stringify(b ?? null)
|
|
51
|
+
const both = (k: keyof OrgSettings) => k in workspace && k in local
|
|
52
|
+
if (both('computerMode') && differs(workspace.computerMode, local.computerMode)) out.push(`Computer mode is ${onOff(workspace.computerMode)} in the workspace and ${onOff(local.computerMode)} here`)
|
|
53
|
+
if (both('memoryTree') && differs(workspace.memoryTree, local.memoryTree)) out.push(`The memory tree is ${onOff(workspace.memoryTree)} in the workspace and ${onOff(local.memoryTree)} here`)
|
|
54
|
+
if (both('embedding') && differs(workspace.embedding, local.embedding)) out.push(`Embeddings use ${pair(workspace.embedding)} in the workspace and ${pair(local.embedding)} here, so knowledge search ranks differently`)
|
|
55
|
+
if (both('memoryEmbedding') && differs(workspace.memoryEmbedding, local.memoryEmbedding)) out.push(`Memory embeddings use ${pair(workspace.memoryEmbedding)} in the workspace and ${pair(local.memoryEmbedding)} here`)
|
|
56
|
+
if (both('webSearch') && differs(workspace.webSearch, local.webSearch)) out.push(`Web search is ${workspace.webSearch ?? DEFAULT} in the workspace and ${local.webSearch ?? DEFAULT} here`)
|
|
57
|
+
if (both('imageModel') && differs(workspace.imageModel, local.imageModel)) out.push(`Images are made with ${pair(workspace.imageModel)} in the workspace and ${pair(local.imageModel)} here`)
|
|
58
|
+
if (both('voiceSpeed') && differs(workspace.voiceSpeed, local.voiceSpeed)) out.push(`Voice speed is ${workspace.voiceSpeed ?? DEFAULT} in the workspace and ${local.voiceSpeed ?? DEFAULT} here`)
|
|
59
|
+
const ws = workspace.featureFlags ?? {}
|
|
60
|
+
const here = local.featureFlags ?? {}
|
|
61
|
+
for (const key of Object.keys(ws).sort()) {
|
|
62
|
+
if (key in here && ws[key] !== here[key]) out.push(`Feature ${key} is ${onOff(ws[key])} in the workspace and ${onOff(here[key])} here`)
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
|
|
68
|
+
|
|
69
|
+
export function settingsCalls(serviceUrl: string, creds: LocalCredentials, fetcher: typeof fetch = fetch): Call {
|
|
70
|
+
return async (method, path, body) => {
|
|
71
|
+
const res = await fetcher(new URL(path, serviceUrl), {
|
|
72
|
+
method,
|
|
73
|
+
headers: {
|
|
74
|
+
authorization: `Bearer ${creds.session}`,
|
|
75
|
+
'x-org-id': creds.organizationId,
|
|
76
|
+
'x-workspace-id': creds.workspaceId,
|
|
77
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
78
|
+
},
|
|
79
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
80
|
+
})
|
|
81
|
+
const text = await res.text()
|
|
82
|
+
let parsed: unknown = text
|
|
83
|
+
try {
|
|
84
|
+
parsed = text ? JSON.parse(text) : null
|
|
85
|
+
} catch {
|
|
86
|
+
// Keep the text.
|
|
87
|
+
}
|
|
88
|
+
return { status: res.status, body: parsed }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface SettingsMirror {
|
|
93
|
+
/** What was done, for `local dev`'s output: the timezone set. */
|
|
94
|
+
lines: string[]
|
|
95
|
+
/** Where the runtime still behaves differently, in words. */
|
|
96
|
+
differences: string[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Make the runtime's organization settings match the workspace's where that
|
|
101
|
+
* is safe, and say where they still differ. Only the timezone is taken (the
|
|
102
|
+
* owner sets it, as Settings → Usage does): it decides how dates resolve in
|
|
103
|
+
* Blueprint queries and usage windows. The rest stays the runtime's: a
|
|
104
|
+
* provider or model there needs this computer's own credentials.
|
|
105
|
+
*/
|
|
106
|
+
export async function mirrorOrgSettings(workspace: OrgSettings, call: Call): Promise<SettingsMirror> {
|
|
107
|
+
const out: SettingsMirror = { lines: [], differences: [] }
|
|
108
|
+
const read = await call('GET', '/v1/sync/settings')
|
|
109
|
+
if (read.status >= 400) return out
|
|
110
|
+
const local = ((read.body as { data?: unknown })?.data ?? read.body) as OrgSettings
|
|
111
|
+
if (workspace.timezone && local.timezone && workspace.timezone !== local.timezone) {
|
|
112
|
+
const set = await call('PUT', '/v1/usage-limits/windows', { timezone: workspace.timezone })
|
|
113
|
+
if (set.status < 400) out.lines.push(` timezone set to ${workspace.timezone}, as in the workspace`)
|
|
114
|
+
else out.differences.push(`The timezone is ${workspace.timezone} in the workspace and ${local.timezone} here; it could not be set (${set.status})`)
|
|
115
|
+
}
|
|
116
|
+
out.differences.push(...settingsDifferences(workspace, local))
|
|
117
|
+
return out
|
|
118
|
+
}
|