@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,216 @@
|
|
|
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 { loadBuild, refOfTarget, refWords } from './local-applied'
|
|
7
|
+
import { allTargets } from './local-dev-sync'
|
|
8
|
+
import type { LocalProject } from './local-project'
|
|
9
|
+
import { objectTypeFit } from './local-portability'
|
|
10
|
+
import { parseRef, type Unresolved } from './local-sync-refs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a file names that the local runtime will never have, applied around
|
|
14
|
+
* instead of refusing the whole file (design §8, "adapted").
|
|
15
|
+
*
|
|
16
|
+
* Two kinds of missing are told apart:
|
|
17
|
+
* - **Coming**: the project holds it (a Function file, a workflow, an object
|
|
18
|
+
* type the runtime builds) and it is not applied yet. The file waits for it
|
|
19
|
+
* (`blocked`), and the retry in `local dev` applies it once it is there.
|
|
20
|
+
* - **Absent**: nothing in the project provides it: a built-in connection
|
|
21
|
+
* signed in on the platform, a knowledge base not pulled, an object type or
|
|
22
|
+
* governed Action the runtime leaves off. Waiting would wait forever, so the
|
|
23
|
+
* file is applied without the part that names it, and each omission is an
|
|
24
|
+
* adaptation in the build, in words.
|
|
25
|
+
*
|
|
26
|
+
* What is left out is the smallest part that still makes sense: a link in
|
|
27
|
+
* guidance text stays as plain words, a procedure's tool or knowledge scope
|
|
28
|
+
* loses that one entry, a step is left out, an action is left out with the
|
|
29
|
+
* steps that run it. A procedure whose completion or verification needs the
|
|
30
|
+
* missing action is switched off rather than run half-built.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Whether the project provides a reference the runtime does not have yet, so a file naming it should wait. */
|
|
34
|
+
export function projectProvides(project: LocalProject, organizationId?: string | null): (ref: string) => boolean {
|
|
35
|
+
const refs = new Set(allTargets(project.root).map(refOfTarget))
|
|
36
|
+
const blueprint = join(project.root, 'blueprint')
|
|
37
|
+
const docsIn = (dir: string): Array<Record<string, unknown>> => {
|
|
38
|
+
const at = join(blueprint, dir)
|
|
39
|
+
if (!existsSync(at)) return []
|
|
40
|
+
return readdirSync(at).flatMap((name) => {
|
|
41
|
+
if (!/\.(ya?ml|json)$/.test(name) || name.startsWith('.')) return []
|
|
42
|
+
try {
|
|
43
|
+
const doc = parseYaml(readFileSync(join(at, name), 'utf8')) as Record<string, unknown> | null
|
|
44
|
+
return doc && typeof doc.apiName === 'string' ? [doc] : []
|
|
45
|
+
} catch {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
const objectTypes = new Set(docsIn('object-types').filter((d) => !objectTypeFit(d, () => true).leftOff).map((d) => String(d.apiName)))
|
|
51
|
+
const leftOff = organizationId ? loadBuild(project, organizationId).blueprint?.actions ?? {} : {}
|
|
52
|
+
const actions = new Set(docsIn('actions').map((d) => String(d.apiName)).filter((name) => leftOff[name]?.status !== 'left-off'))
|
|
53
|
+
// A saved object set is named by its file (`blueprint/object-sets/open-cases.yaml`).
|
|
54
|
+
const setsDir = join(blueprint, 'object-sets')
|
|
55
|
+
const objectSets = new Set(existsSync(setsDir) ? readdirSync(setsDir).flatMap((n) => (/\.(ya?ml|json)$/.test(n) && !n.startsWith('.') ? [n.replace(/\.(ya?ml|json)$/, '')] : [])) : [])
|
|
56
|
+
return (ref: string) => {
|
|
57
|
+
const parsed = parseRef(ref)
|
|
58
|
+
if (!parsed) return false
|
|
59
|
+
if (parsed.kind === 'objectType') return objectTypes.has(parsed.name)
|
|
60
|
+
if (parsed.kind === 'action') return actions.has(parsed.name)
|
|
61
|
+
if (parsed.kind === 'objectSet') return objectSets.has(parsed.name)
|
|
62
|
+
return refs.has(ref)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Why an absent reference is absent, in words: "isn’t connected here". */
|
|
67
|
+
export function absentWhy(ref: string): string {
|
|
68
|
+
const kind = parseRef(ref)?.kind
|
|
69
|
+
if (kind === 'plugin') return 'isn’t connected here'
|
|
70
|
+
if (kind === 'objectType' || kind === 'action') return 'the local runtime leaves it off'
|
|
71
|
+
return 'isn’t on this runtime'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LINK = /\[\[([A-Za-z]+):([^\]\s]+)\]\]/g
|
|
75
|
+
|
|
76
|
+
type Json = Record<string, unknown>
|
|
77
|
+
|
|
78
|
+
function at(doc: unknown, path: string[]): unknown {
|
|
79
|
+
let v = doc
|
|
80
|
+
for (const p of path) v = v && typeof v === 'object' ? (v as Json)[p] : undefined
|
|
81
|
+
return v
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function setAt(doc: unknown, path: string[], value: unknown): void {
|
|
85
|
+
const parent = at(doc, path.slice(0, -1))
|
|
86
|
+
if (parent && typeof parent === 'object') (parent as Json)[path[path.length - 1]!] = value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A label for where a link sits: a procedure by name, an Agent's instructions, or the field. */
|
|
90
|
+
function placeOf(doc: unknown, path: string[]): string {
|
|
91
|
+
if (path[0] === 'procedures' && /^\d+$/.test(path[1] ?? '')) return `procedure ‘${procedureName(at(doc, path.slice(0, 2)))}’`
|
|
92
|
+
if (path[0] === 'prompts') return 'its instructions'
|
|
93
|
+
return path.filter((p) => !/^\d+$/.test(p)).join('.') || 'the file'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function procedureName(proc: unknown): string {
|
|
97
|
+
const p = (proc ?? {}) as { name?: unknown; id?: unknown }
|
|
98
|
+
return String(p.name || p.id || 'unnamed')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Each link to an absent reference becomes its name as plain words
|
|
103
|
+
* (`[[plugin:google-sheets]]` → `google-sheets`), so the guidance still reads
|
|
104
|
+
* and no link points nowhere. Returns the warnings, one per link.
|
|
105
|
+
*/
|
|
106
|
+
export function plainLinks(doc: unknown, links: Unresolved[]): string[] {
|
|
107
|
+
const warnings: string[] = []
|
|
108
|
+
for (const u of links) {
|
|
109
|
+
const path = u.path.split('.')
|
|
110
|
+
const text = at(doc, path)
|
|
111
|
+
const ref = parseRef(u.value)
|
|
112
|
+
if (typeof text !== 'string' || !ref) continue
|
|
113
|
+
setAt(doc, path, text.replace(LINK, (whole, kind: string, name: string) => (kind === ref.kind && name === ref.name ? name : whole)))
|
|
114
|
+
warnings.push(` warning: The link to ${refWords(u.value)} in ${placeOf(doc, path)} is plain text here: it ${absentWhy(u.value)}`)
|
|
115
|
+
}
|
|
116
|
+
return [...new Set(warnings)]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const REMOVE = Symbol('remove')
|
|
120
|
+
|
|
121
|
+
/** Arrays whose elements are procedure steps. */
|
|
122
|
+
const STEP_LISTS = new Set(['steps', 'onFailure', 'otherwise'])
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* An Agent's behavior settings without what names an absent reference
|
|
126
|
+
* (`procedures.2.actions.0.functionId` = `function/x`). `doc` is changed in
|
|
127
|
+
* place. Returns one warning per omission, in words the build shows:
|
|
128
|
+
* "Procedure ‘Book a visit’ step 3 left out: uses Plugin google-sheets, which
|
|
129
|
+
* isn’t connected here".
|
|
130
|
+
*/
|
|
131
|
+
export function adaptOperating(doc: Json, absent: Unresolved[]): string[] {
|
|
132
|
+
const warnings: string[] = []
|
|
133
|
+
const removedActions = new Map<Json, Map<string, string>>()
|
|
134
|
+
const uses = (u: Unresolved) => `uses ${refWords(u.value)}, which ${absentWhy(u.value)}`
|
|
135
|
+
const remove = (path: string[]) => setAt(doc, path, REMOVE)
|
|
136
|
+
for (const u of absent) {
|
|
137
|
+
const path = u.path.split('.')
|
|
138
|
+
if (path[0] === 'procedures' && /^\d+$/.test(path[1] ?? '')) {
|
|
139
|
+
const proc = at(doc, path.slice(0, 2)) as Json | undefined
|
|
140
|
+
if (!proc || typeof proc !== 'object') continue
|
|
141
|
+
const name = procedureName(proc)
|
|
142
|
+
const rel = path.slice(2)
|
|
143
|
+
if ((rel[0] === 'tools' || rel[0] === 'knowledge') && rel[1] === 'ids' && /^\d+$/.test(rel[2] ?? '')) {
|
|
144
|
+
remove(path.slice(0, 5))
|
|
145
|
+
warnings.push(` warning: Procedure ‘${name}’ goes without ${refWords(u.value)}: it ${absentWhy(u.value)}`)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (rel[0] === 'actions' && /^\d+$/.test(rel[1] ?? '')) {
|
|
149
|
+
const action = at(proc, rel.slice(0, 2)) as Json | undefined
|
|
150
|
+
if (action && typeof action.id === 'string') {
|
|
151
|
+
const byProc = removedActions.get(proc) ?? new Map<string, string>()
|
|
152
|
+
byProc.set(action.id, `Procedure ‘${name}’ action ‘${String(action.label || action.id)}’ ${uses(u)}`)
|
|
153
|
+
removedActions.set(proc, byProc)
|
|
154
|
+
}
|
|
155
|
+
remove(path.slice(0, 4))
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
// The innermost step that holds it.
|
|
159
|
+
let step = -1
|
|
160
|
+
for (let i = rel.length - 2; i >= 0; i--) {
|
|
161
|
+
if (STEP_LISTS.has(rel[i]!) && /^\d+$/.test(rel[i + 1] ?? '')) {
|
|
162
|
+
step = i
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (step >= 0) {
|
|
167
|
+
remove([...path.slice(0, 2), ...rel.slice(0, step + 2)])
|
|
168
|
+
warnings.push(` warning: Procedure ‘${name}’ step ${Number(rel[step + 1]) + 1} left out: ${uses(u)}`)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
proc.enabled = false
|
|
172
|
+
warnings.push(` warning: Procedure ‘${name}’ is switched off: it ${uses(u)}`)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
// Outside the procedures: the innermost list entry that holds it, else the field.
|
|
176
|
+
const last = path.findLastIndex((p) => /^\d+$/.test(p))
|
|
177
|
+
const held = last >= 0 ? path.slice(0, last + 1) : path
|
|
178
|
+
remove(held)
|
|
179
|
+
const field = held.filter((p) => !/^\d+$/.test(p)).join('.') || 'a setting'
|
|
180
|
+
warnings.push(` warning: ${last >= 0 ? `An entry of ${field}` : `The setting ${field}`} left out: ${uses(u)}`)
|
|
181
|
+
}
|
|
182
|
+
// An action left out takes the steps that run it, and the strict block before it, along.
|
|
183
|
+
for (const [proc, actions] of removedActions) {
|
|
184
|
+
const ids = new Set(actions.keys())
|
|
185
|
+
const needed = [(proc.verification as Json | undefined)?.actionId, (proc.completion as Json | undefined)?.actionId].some((id) => typeof id === 'string' && ids.has(id))
|
|
186
|
+
if (needed) {
|
|
187
|
+
proc.enabled = false
|
|
188
|
+
for (const why of actions.values()) warnings.push(` warning: ${why}; the procedure is switched off, as it can’t finish without it`)
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
const strip = (v: unknown): unknown => {
|
|
192
|
+
if (Array.isArray(v)) return v.filter((s) => !(s && typeof s === 'object' && (((s as Json).type === 'run' && ids.has(String((s as Json).actionId))) || ((s as Json).anchor === 'before-action' && ids.has(String((s as Json).actionId)))))).map(strip)
|
|
193
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v as Json).map(([k, x]) => [k, strip(x)]))
|
|
194
|
+
return v
|
|
195
|
+
}
|
|
196
|
+
for (const key of ['blocks', 'steps']) if (proc[key] !== undefined) proc[key] = strip(proc[key])
|
|
197
|
+
for (const why of actions.values()) warnings.push(` warning: ${why}; it is left out, with the steps that run it`)
|
|
198
|
+
}
|
|
199
|
+
sweep(doc)
|
|
200
|
+
return [...new Set(warnings)]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Drop what was marked for removal, at any depth. */
|
|
204
|
+
function sweep(v: unknown): void {
|
|
205
|
+
if (Array.isArray(v)) {
|
|
206
|
+
for (let i = v.length - 1; i >= 0; i--) if (v[i] === REMOVE) v.splice(i, 1)
|
|
207
|
+
for (const x of v) sweep(x)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (v && typeof v === 'object') {
|
|
211
|
+
for (const [k, x] of Object.entries(v as Json)) {
|
|
212
|
+
if (x === REMOVE) delete (v as Json)[k]
|
|
213
|
+
else sweep(x)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -3,9 +3,11 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { contentHash, loadHashes, saveHashes } from './local-applied'
|
|
6
|
+
import { localDirectory, resolveTarget } from './local-dev-references'
|
|
6
7
|
import { localProfileName } from './local-control'
|
|
7
8
|
import { targetFor } from './local-dev-sync'
|
|
8
9
|
import type { LocalProject } from './local-project'
|
|
10
|
+
import { readState } from './local-state'
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Studio's editor, the counterpart of Mastra Studio's: a change made in the
|
|
@@ -94,6 +96,7 @@ export async function saveAgentEdit(
|
|
|
94
96
|
organizationId: string,
|
|
95
97
|
slug: string,
|
|
96
98
|
edit: AgentFileEdit,
|
|
99
|
+
serviceUrl?: string,
|
|
97
100
|
): Promise<{ path: string; changed: boolean; applied: boolean; output: string }> {
|
|
98
101
|
const path = agentFilePath(project.root, slug)
|
|
99
102
|
const rel = join('agents', slug, 'agent.json')
|
|
@@ -109,9 +112,13 @@ export async function saveAgentEdit(
|
|
|
109
112
|
}
|
|
110
113
|
if (next === null) return { path: rel, changed: false, applied: true, output: '' }
|
|
111
114
|
writeFileSync(path, next)
|
|
112
|
-
|
|
115
|
+
// Names in the file (`skill/acme-reply-style`) are applied as this runtime's ids, as `local dev` does.
|
|
116
|
+
const target = targetFor(project.root, path)
|
|
117
|
+
const creds = readState(project)?.credentials
|
|
118
|
+
const resolved = target && serviceUrl && creds ? await resolveTarget(project, target, () => localDirectory(serviceUrl, creds), creds.workspaceId) : { ok: true, output: '' }
|
|
119
|
+
if (!resolved.ok) return { path: rel, changed: true, applied: false, output: resolved.output.split('\n').slice(0, 6).join('\n') }
|
|
120
|
+
const result = await applyWithCli(project, slug, 'file' in resolved && resolved.file ? resolved.file : rel)
|
|
113
121
|
if (result.ok) {
|
|
114
|
-
const target = targetFor(project.root, path)
|
|
115
122
|
if (target) {
|
|
116
123
|
const applied = loadHashes(project, organizationId)
|
|
117
124
|
applied.set(target.key, contentHash(project, target))
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { parseRef } from './local-sync-refs'
|
|
2
|
+
import type { LocalCredentials } from './local-state'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An Agent's Context and data on the local runtime: the object types it may
|
|
6
|
+
* read through its Blueprint tools, from the `blueprint` list in its
|
|
7
|
+
* `agents/<slug>/bindings.json` (`{ "objectType": "objectType/Incident",
|
|
8
|
+
* "permissions": ["read"] }`). On the platform these are the Agent's
|
|
9
|
+
* Blueprint grants; without them it has no Blueprint tools at all.
|
|
10
|
+
*
|
|
11
|
+
* Granting is a Steward's call on the organization's Blueprint, so it is made
|
|
12
|
+
* with the runtime's organization key, after the Blueprint is applied and
|
|
13
|
+
* published (bindings apply after it). The runtime ends up with the file's
|
|
14
|
+
* grants: missing ones are granted, changed permissions replaced, and grants
|
|
15
|
+
* the file does not list revoked. An object type the runtime leaves off
|
|
16
|
+
* (media files) is skipped with a warning; one it does not hold yet waits
|
|
17
|
+
* for the Blueprint to build, so the Agent's tools read as blocked on it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface AgentGrant {
|
|
21
|
+
objectType: string
|
|
22
|
+
permissions?: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GrantOutcome {
|
|
26
|
+
/** One line each, ` objectType/X: why`, as the other bindings report a refusal. */
|
|
27
|
+
failures: string[]
|
|
28
|
+
/** ` warning: …` lines (adaptations) and plain lines for the log. */
|
|
29
|
+
notes: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The build's word on object types it left off, by API name, with why. */
|
|
33
|
+
export type LeftOff = (apiName: string) => string | null
|
|
34
|
+
|
|
35
|
+
export async function applyAgentGrants(input: {
|
|
36
|
+
slug: string
|
|
37
|
+
grants: AgentGrant[]
|
|
38
|
+
serviceUrl: string
|
|
39
|
+
creds: LocalCredentials
|
|
40
|
+
/** Object types the last Blueprint build left off (`build.json`). */
|
|
41
|
+
leftOff: LeftOff
|
|
42
|
+
/** Whether the project's Blueprint has a file for this object type. */
|
|
43
|
+
inProject: (apiName: string) => boolean
|
|
44
|
+
fetch?: typeof fetch
|
|
45
|
+
}): Promise<GrantOutcome> {
|
|
46
|
+
const { slug, creds } = input
|
|
47
|
+
const doFetch = input.fetch ?? fetch
|
|
48
|
+
const failures: string[] = []
|
|
49
|
+
const notes: string[] = []
|
|
50
|
+
const call = async (key: string | undefined, path: string, method = 'GET', body?: unknown): Promise<{ ok: boolean; status: number; data: unknown; text: string }> => {
|
|
51
|
+
const res = await doFetch(new URL(path, input.serviceUrl), {
|
|
52
|
+
method,
|
|
53
|
+
headers: { authorization: `Bearer ${key ?? ''}`, 'content-type': 'application/json' },
|
|
54
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
55
|
+
})
|
|
56
|
+
const text = await res.text()
|
|
57
|
+
let data: unknown = null
|
|
58
|
+
try {
|
|
59
|
+
data = JSON.parse(text)
|
|
60
|
+
} catch {
|
|
61
|
+
// Not JSON: the text is the message.
|
|
62
|
+
}
|
|
63
|
+
return { ok: res.ok, status: res.status, data, text }
|
|
64
|
+
}
|
|
65
|
+
const list = <T>(body: unknown): T[] => {
|
|
66
|
+
let v = body
|
|
67
|
+
for (let i = 0; i < 3 && v && !Array.isArray(v) && typeof v === 'object' && 'data' in (v as object); i++) v = (v as { data: unknown }).data
|
|
68
|
+
return Array.isArray(v) ? (v as T[]) : []
|
|
69
|
+
}
|
|
70
|
+
const brief = (r: { status: number; text: string }) => `${r.status} ${r.text.slice(0, 200)}`
|
|
71
|
+
|
|
72
|
+
// Wanted: by API name, the file's permissions (read when it names none).
|
|
73
|
+
const wanted = new Map<string, string[]>()
|
|
74
|
+
for (const g of input.grants) {
|
|
75
|
+
const ref = typeof g?.objectType === 'string' ? parseRef(g.objectType) : null
|
|
76
|
+
if (!ref || ref.kind !== 'objectType') {
|
|
77
|
+
failures.push(` ${String(g?.objectType)}: not an object type reference (objectType/<apiName>)`)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
wanted.set(ref.name, [...(Array.isArray(g.permissions) && g.permissions.length ? g.permissions : ['read'])].sort())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The workspace's own list, which holds Agents never published too; the organization's
|
|
84
|
+
// Blueprint list (`org-agents`) leaves those out, and a pulled draft is one of them.
|
|
85
|
+
const agents = await call(creds.workspaceKey, '/v1/sync/agents')
|
|
86
|
+
if (!agents.ok) return { failures: [...failures, ` agent/${slug}: the runtime’s Agents could not be read: ${brief(agents)}`], notes }
|
|
87
|
+
const agent = list<{ id: string; agentId?: string }>(agents.data).find((a) => a.agentId === slug)
|
|
88
|
+
if (!agent) return { failures: [...failures, ` agent/${slug}: not found on the runtime`], notes }
|
|
89
|
+
|
|
90
|
+
const held = await call(creds.workspaceKey, '/v1/blueprint/object-types')
|
|
91
|
+
const onRuntime = new Set(held.ok ? list<{ apiName?: string }>(held.data).flatMap((t) => (t.apiName ? [t.apiName] : [])) : [])
|
|
92
|
+
const current = await call(creds.orgKey, `/v1/blueprint/grants/agent/${encodeURIComponent(agent.id)}`)
|
|
93
|
+
if (!current.ok) return { failures: [...failures, ` agent/${slug}: its Blueprint access could not be read: ${brief(current)}`], notes }
|
|
94
|
+
const has = new Map(list<{ objectTypeApiName: string; permissions?: string[] }>(current.data).map((g) => [g.objectTypeApiName, [...(g.permissions ?? ['read'])].sort()]))
|
|
95
|
+
|
|
96
|
+
let granted = 0
|
|
97
|
+
for (const [apiName, permissions] of wanted) {
|
|
98
|
+
const why = input.leftOff(apiName)
|
|
99
|
+
if (why) {
|
|
100
|
+
notes.push(` warning: Context and data: ${apiName} is left off here (${why}), so ${slug} can’t read it locally`)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (!onRuntime.has(apiName)) {
|
|
104
|
+
// The Blueprint does not hold it (yet): blocked until it builds, or named by nothing in the project.
|
|
105
|
+
failures.push(` objectType/${apiName}: ${input.inProject(apiName) ? 'not on the runtime yet: the Blueprint isn’t built' : 'not found in the project’s Blueprint'}`)
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
if (has.get(apiName)?.join(',') !== permissions.join(',')) {
|
|
109
|
+
const res = await call(creds.orgKey, '/v1/blueprint/grants/agent', 'POST', { agentId: agent.id, objectTypeApiName: apiName, permissions })
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
failures.push(` objectType/${apiName}: ${brief(res)}`)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
granted += 1
|
|
116
|
+
}
|
|
117
|
+
// The file is what the Agent may read: a grant it does not list is withdrawn.
|
|
118
|
+
for (const apiName of has.keys()) {
|
|
119
|
+
if (wanted.has(apiName)) continue
|
|
120
|
+
const res = await call(creds.orgKey, `/v1/blueprint/grants/agent/${encodeURIComponent(agent.id)}/${encodeURIComponent(apiName)}`, 'DELETE')
|
|
121
|
+
if (!res.ok) failures.push(` objectType/${apiName}: could not be revoked: ${brief(res)}`)
|
|
122
|
+
}
|
|
123
|
+
if (granted) notes.push(` Context and data: ${granted} object type${granted === 1 ? '' : 's'}`)
|
|
124
|
+
return { failures, notes }
|
|
125
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { LocalCredentials } from './local-state'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An Agent's name and description on the local runtime, as its file has them.
|
|
8
|
+
*
|
|
9
|
+
* The Agent's row carries the name the platform lists it by (Studio's lists,
|
|
10
|
+
* breadcrumbs and pickers). On the platform, publishing moves the draft's
|
|
11
|
+
* name onto the row; `local dev` keeps Agents as drafts (Studio runs the
|
|
12
|
+
* draft), so the row kept whatever it was created with: the slug, since
|
|
13
|
+
* `agent create` was called without one. A pulled "Telco Operations" read
|
|
14
|
+
* `telco-operations` everywhere.
|
|
15
|
+
*
|
|
16
|
+
* So `local dev` creates an Agent with its name, and after each apply of
|
|
17
|
+
* `agents/<slug>/agent.json` sets the row's name and description when they
|
|
18
|
+
* differ from the file's. Only the row changes: the draft already holds them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface AgentIdentity {
|
|
22
|
+
name?: string
|
|
23
|
+
description?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `config.name` and `config.description` from an Agent's file; empty when it has none or does not parse. */
|
|
27
|
+
export function agentIdentity(root: string, file: string): AgentIdentity {
|
|
28
|
+
try {
|
|
29
|
+
const doc = JSON.parse(readFileSync(join(root, file), 'utf8')) as { config?: { name?: unknown; description?: unknown }; snapshot?: { config?: { name?: unknown; description?: unknown } } }
|
|
30
|
+
const config = doc.snapshot?.config ?? doc.config ?? {}
|
|
31
|
+
const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
|
32
|
+
return { ...(text(config.name) ? { name: text(config.name) } : {}), ...(text(config.description) ? { description: text(config.description) } : {}) }
|
|
33
|
+
} catch {
|
|
34
|
+
return {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The flags `agent create` takes for them. */
|
|
39
|
+
export function createFlags(identity: AgentIdentity): string[] {
|
|
40
|
+
return [...(identity.name ? ['--name', identity.name] : []), ...(identity.description ? ['--description', identity.description] : [])]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Set the row's name and description to the file's where they differ.
|
|
45
|
+
* Returns a line for the log (a `warning:` one when it could not), or null
|
|
46
|
+
* when nothing changed. Never throws:
|
|
47
|
+
* a name that could not be set is a warning, not a failed apply.
|
|
48
|
+
*/
|
|
49
|
+
export async function syncAgentName(input: { slug: string; identity: AgentIdentity; serviceUrl: string; creds: LocalCredentials; fetch?: typeof fetch }): Promise<string | null> {
|
|
50
|
+
const { identity, creds } = input
|
|
51
|
+
if (!identity.name && !identity.description) return null
|
|
52
|
+
const doFetch = input.fetch ?? fetch
|
|
53
|
+
const headers = { authorization: `Bearer ${creds.workspaceKey ?? ''}`, 'content-type': 'application/json' }
|
|
54
|
+
try {
|
|
55
|
+
const listed = await doFetch(new URL('/v1/sync/agents', input.serviceUrl), { headers })
|
|
56
|
+
if (!listed.ok) return ` warning: ${input.slug}'s name could not be checked (${listed.status})`
|
|
57
|
+
const body = (await listed.json()) as { data?: Array<{ id: string; agentId: string; name?: string | null; description?: string | null }> }
|
|
58
|
+
const row = body.data?.find((a) => a.agentId === input.slug)
|
|
59
|
+
if (!row) return null
|
|
60
|
+
const patch: Record<string, string> = {}
|
|
61
|
+
if (identity.name && row.name !== identity.name) patch.name = identity.name
|
|
62
|
+
if (identity.description && (row.description ?? '') !== identity.description) patch.description = identity.description
|
|
63
|
+
if (!Object.keys(patch).length) return null
|
|
64
|
+
// `direct`: the row itself, as a publish would set it; the draft already has these.
|
|
65
|
+
const res = await doFetch(new URL(`/v1/config/agents/${encodeURIComponent(row.id)}?direct=1`, input.serviceUrl), { method: 'PATCH', headers, body: JSON.stringify(patch) })
|
|
66
|
+
if (!res.ok) return ` warning: ${input.slug}'s name could not be set (${res.status})`
|
|
67
|
+
return patch.name ? ` named ${input.slug} “${patch.name}”` : ` updated ${input.slug}’s description`
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return ` warning: ${input.slug}'s name could not be set: ${(err as Error).message}`
|
|
70
|
+
}
|
|
71
|
+
}
|