@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.
Files changed (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. 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
- try {
42
- operatingConfig = JSON.parse(readFileSync(args.file, 'utf8'))
43
- } catch (err) {
44
- return { ok: false, output: `error [USAGE]: ${args.file} is not valid JSON: ${(err as Error).message}` }
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
+ }