@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.
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
@@ -25,7 +25,7 @@ import { resolveSkillRef } from './resolve'
25
25
  * sidecar metadata file.
26
26
  */
27
27
 
28
- interface SkillDoc {
28
+ export interface SkillDoc {
29
29
  id?: string
30
30
  name?: string
31
31
  displayName?: string
@@ -67,17 +67,17 @@ const MIME_BY_EXT: Record<string, string> = {
67
67
  pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
68
68
  }
69
69
 
70
- function mimeFromPath(path: string): string {
70
+ export function mimeFromPath(path: string): string {
71
71
  return MIME_BY_EXT[path.split('.').pop()?.toLowerCase() ?? ''] ?? 'application/octet-stream'
72
72
  }
73
73
 
74
74
  /** `workspace/{ws}/skill-assets/{sha}.{ext}` → `{sha}.{ext}`, or null. */
75
- function assetFilename(storageKey: string): string | null {
75
+ export function assetFilename(storageKey: string): string | null {
76
76
  const m = /^workspace\/[^/]+\/skill-assets\/([0-9a-f]{6,64}\.[A-Za-z0-9]+)$/.exec(storageKey)
77
77
  return m ? m[1]! : null
78
78
  }
79
79
 
80
- function toSkillMd(doc: SkillDoc): string {
80
+ export function toSkillMd(doc: SkillDoc): string {
81
81
  const data: Record<string, unknown> = { name: doc.name }
82
82
  if (doc.displayName != null) data.displayName = doc.displayName
83
83
  if (doc.trigger != null) data.trigger = doc.trigger
@@ -87,7 +87,9 @@ function toSkillMd(doc: SkillDoc): string {
87
87
  if (typeof doc.sortOrder === 'number') data.sortOrder = doc.sortOrder
88
88
  const visible = (doc.assets ?? []).filter((a) => a.visible).map((a) => a.path)
89
89
  if (visible.length) data.visibleAssets = visible
90
- return matter.stringify(doc.body ?? '', data)
90
+ // As content, not a string: gray-matter parses a string's own front matter
91
+ // first, so a body opening with a `---` block was merged in, or threw.
92
+ return matter.stringify({ content: doc.body ?? '' }, data)
91
93
  }
92
94
 
93
95
  /**
@@ -202,7 +204,8 @@ export function readBundle(dir: string): {
202
204
  const full = join(current, entry)
203
205
  const rel = relative(dir, full).split(sep).join('/')
204
206
  const base = entry
205
- if (base === '.DS_Store' || base.startsWith('._') || base === 'Thumbs.db' || base === '.git') continue
207
+ // `<file>.workspace` is a sync conflict's copy of the workspace's version, never part of the bundle.
208
+ if (base === '.DS_Store' || base.startsWith('._') || base === 'Thumbs.db' || base === '.git' || base.endsWith('.workspace')) continue
206
209
  if (statSync(full).isDirectory()) {
207
210
  walk(full)
208
211
  continue
package/src/errors.ts CHANGED
@@ -12,13 +12,16 @@
12
12
  export class CliError extends Error {
13
13
  readonly code: string
14
14
  readonly hint?: string
15
+ /** What a caller reading `--json` needs beyond the message: the checks a refusal names, per item. */
16
+ readonly details?: unknown
15
17
 
16
18
  /** `cause` keeps the original error — and its `details` — when one is reworded. */
17
- constructor(message: string, opts: { code: string; hint?: string; cause?: unknown }) {
19
+ constructor(message: string, opts: { code: string; hint?: string; cause?: unknown; details?: unknown }) {
18
20
  super(message, opts.cause === undefined ? undefined : { cause: opts.cause })
19
21
  this.name = 'CliError'
20
22
  this.code = opts.code
21
23
  if (opts.hint) this.hint = opts.hint
24
+ if (opts.details !== undefined) this.details = opts.details
22
25
  }
23
26
  }
24
27
 
package/src/exit.ts CHANGED
@@ -29,6 +29,8 @@ export interface ErrorEnvelope {
29
29
  code: string
30
30
  message: string
31
31
  hint?: string
32
+ /** A CliError's `details`, when it has them (a refused push's checks). */
33
+ details?: unknown
32
34
  }
33
35
 
34
36
  const USAGE_CODES = new Set([
@@ -207,6 +209,7 @@ export function toEnvelope(err: unknown): ErrorEnvelope {
207
209
  code: err.code,
208
210
  message: err.message,
209
211
  ...(err.hint ? { hint: err.hint } : {}),
212
+ ...(err.details !== undefined ? { details: err.details } : {}),
210
213
  }
211
214
  }
212
215
 
package/src/flag-help.ts CHANGED
@@ -85,6 +85,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
85
85
  lines: 'how many of the latest log lines to print (default 100)',
86
86
  stdin: 'read the value from standard input instead of a hidden prompt',
87
87
  live: 'test the published Agent instead of its draft',
88
+ regression: 'run only the files marked `regression: true`: each Agent’s regression dataset in the workspace',
88
89
  once: 'apply the solution once and exit instead of watching for changes',
89
90
  'no-publish': 'keep Blueprint changes as a draft instead of publishing them to the local workspace',
90
91
  registry: 'the registry host and namespace image mode pulls the platform images from, such as registry.example.com/frontera',
@@ -206,6 +207,37 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
206
207
  'token-stdin': 'read the key from stdin instead of prompting — sk-ws- or sk-org-',
207
208
  'no-input': 'never prompt; fail instead, for use in scripts and CI',
208
209
  config: 'print the resolved configuration and where each value came from',
210
+
211
+ // A local project's link to a platform workspace (`local sync`, `local init --from-workspace`).
212
+ platform: 'the platform\'s API origin, e.g. https://api.acme.frontera.example',
213
+ 'workspace-key': '`-` to read the workspace key (sk-ws-…) from stdin; never pass the key itself',
214
+ 'org-key': '`-` to read an organization key (sk-org-…) from stdin, after the workspace key; widens the Blueprint to the whole model',
215
+ blueprint: 'workspace (the slice granted to the workspace, default) or organization (the whole model; needs an organization key)',
216
+ relink: 'point a project already linked to another workspace at this one',
217
+ preview: 'list what a pull would bring, or a push would propose, file by file, and change nothing',
218
+ kinds: 'comma-separated: blueprint, skills, functions, plugins, knowledge, workflows, agents, evaluations',
219
+ drafts: 'comma-separated items (agent/<slug>, workflow/<slug>, function/<slug>, blueprint) to read as their draft instead of the live version; the Blueprint’s draft takes an organization key',
220
+ 'all-changed': 'pull every item the workspace changed since the last pull, and every clean merge; never one that conflicts or replaces local edits',
221
+ offline: 'do not ask the platform; use only what the project and the last fetch record',
222
+ fetch: 'fetch from the workspace first, then answer from the new snapshot',
223
+ full: 'read the whole sync index, deletions included, instead of what changed since the last fetch',
224
+ 'forget-link': 'also remove the link from frontera.yaml',
225
+ snapshot: 'the snapshot a preview showed; if the workspace moved since, plan again and write nothing',
226
+ 'delete-here': 'comma-separated items the workspace deleted: delete their files here too',
227
+ 'keep-local': 'comma-separated items the workspace deleted: keep them here as the project’s own',
228
+ keep: 'workspace or mine: the side every conflict takes, unless --picks says otherwise',
229
+ picks: 'a side per conflict, numbered as `diff` shows them: 1=workspace,2=mine; =both keeps mine then the workspace’s where `diff` says both can be kept',
230
+ edited: 'take the files as you edited them; the workspace’s copies beside them go',
231
+ title: 'the change request’s title; a push that opens a new one needs it',
232
+ migration: 'for a Blueprint change that is not additive: what happens to existing records',
233
+ documents: 'comma-separated knowledge bases whose documents a pull brings, or a push proposes (`*` for every one); without it they stay as they are',
234
+ lessons: 'bring each Agent’s reviewed rules from customer feedback into agents/<slug>/lessons.yaml (never the feedback itself); off by default',
235
+ conflicts: 'the conflicts the picks were chosen against, as `diff --json` names them; refused if they moved',
236
+ 'from-workspace': 'start the project from a platform workspace instead of the examples',
237
+ 'no-sample-data': 'do not generate sample rows for the pulled Blueprint',
238
+ synthetic: 'generate sample rows for the Blueprint: values fit each property, links stay consistent',
239
+ rows: 'rows per object type (default 200; types others link to get half)',
240
+ seed: 'the same seed writes the same rows (default 7)',
209
241
  }
210
242
 
211
243
  /**
@@ -234,6 +266,8 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
234
266
  'expect-revision': 'hash',
235
267
  notes: 'text',
236
268
  description: 'text',
269
+ title: 'text',
270
+ migration: 'text',
237
271
  'display-name': 'text',
238
272
  strategy: 'auto|text|ocr',
239
273
  from: '-|path',
@@ -255,6 +289,20 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
255
289
  'max-distance': '0-2',
256
290
  permissions: 'read,write',
257
291
  template: 'non-negative|percentage|status-enum|code-length',
292
+ platform: 'url',
293
+ 'workspace-key': '-',
294
+ 'org-key': '-',
295
+ blueprint: 'workspace|organization',
296
+ kinds: 'a,b',
297
+ drafts: 'kind/name,…',
298
+ snapshot: 'id',
299
+ 'delete-here': 'kind/name,…',
300
+ 'keep-local': 'kind/name,…',
301
+ documents: 'name,…',
302
+ keep: 'workspace|mine',
303
+ picks: 'n=side,…',
304
+ rows: 'n',
305
+ seed: 'n',
258
306
  }
259
307
 
260
308
  /**
@@ -0,0 +1,306 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import { deriveOntologyEditPlan, PlanDerivationError, type ObjectTypeShape, type PublishedActionDefinition } from '../blueprint/ontology-edit-plan'
4
+ import type { ActionFit } from './local-portability'
5
+ import type { LocalCredentials } from './local-state'
6
+
7
+ /**
8
+ * Governed Actions on the local runtime (design §15.1, D3): pulled with the
9
+ * Blueprint and deployed here automatically, so a workflow naming
10
+ * `action/<apiName>` applies and runs. The platform's own deployment decision
11
+ * is never touched: every call below goes to the local runtime, and
12
+ * `localActionCalls` refuses any other address.
13
+ *
14
+ * It is the platform's deployment lane, run by the local owner, the same steps
15
+ * `frontera action prepare`, `action deploy` and `action review` take:
16
+ *
17
+ * before the publish the draft moves to schema 2 if it is older (Actions need it), and
18
+ * each Action gets its first decision, "ships switched off", which
19
+ * publishing requires
20
+ * after the publish an Action that writes only the Blueprint's records gets its write
21
+ * path (an ontology edit derived from the Action), built and validated
22
+ * by the organization key, then reviewed and activated by the owner's
23
+ * session (the service refuses a reviewer who built it); the owner role
24
+ * may then invoke it
25
+ *
26
+ * An Action whose effect would reach another system (a transition, a command)
27
+ * keeps its switched-off decision: it is published, workflows that name it
28
+ * apply, and the build says it is switched off here.
29
+ */
30
+
31
+ export interface Answer {
32
+ status: number
33
+ body: unknown
34
+ }
35
+
36
+ /** One call to the local runtime, as the organization key or as the owner's session. */
37
+ export type Call = (as: 'org' | 'session', method: string, path: string, body?: unknown) => Promise<Answer>
38
+
39
+ const LOOPBACK = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/
40
+
41
+ /** Calls to the local runtime with the project's own keys. Never a platform: a non-loopback address is refused. */
42
+ export function localActionCalls(serviceUrl: string, creds: LocalCredentials, fetcher: typeof fetch = fetch): Call {
43
+ if (!LOOPBACK.test(serviceUrl)) throw new Error(`governed Actions are deployed on the local runtime only, not on ${new URL(serviceUrl).host}`)
44
+ return async (as, method, path, body) => {
45
+ const token = as === 'org' ? creds.orgKey : creds.session
46
+ if (!token) return { status: 401, body: { message: as === 'session' ? 'the runtime has no owner session; restart it' : 'the runtime has no organization key' } }
47
+ const res = await fetcher(new URL(path, serviceUrl), {
48
+ method,
49
+ headers: {
50
+ authorization: `Bearer ${token}`,
51
+ 'x-org-id': creds.organizationId,
52
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
53
+ },
54
+ body: body === undefined ? undefined : JSON.stringify(body),
55
+ })
56
+ const text = await res.text()
57
+ let parsed: unknown = text
58
+ try {
59
+ parsed = text ? JSON.parse(text) : null
60
+ } catch {
61
+ // Keep the text.
62
+ }
63
+ return { status: res.status, body: parsed }
64
+ }
65
+ }
66
+
67
+ /** `{data}` or `{error:false,data}` envelopes, unwrapped. */
68
+ function data<T>(answer: Answer): T {
69
+ const b = answer.body as { data?: unknown } | null
70
+ return (b && typeof b === 'object' && 'data' in b ? b.data : b) as T
71
+ }
72
+
73
+ function message(answer: Answer): string {
74
+ const b = answer.body as { message?: unknown; error?: unknown } | null
75
+ const text = typeof b?.message === 'string' ? b.message : typeof b === 'string' ? b : JSON.stringify(b)
76
+ return `${answer.status} ${String(text).replace(/\s+/g, ' ').slice(0, 240)}`
77
+ }
78
+
79
+ const ADMIN = '/v1/blueprint/governed-actions/admin'
80
+
81
+ /** The checks a validation report failed, by name: its findings with `passed: false`, anywhere in it. */
82
+ function failedChecks(report: unknown): string {
83
+ const names: string[] = []
84
+ const visit = (v: unknown) => {
85
+ if (Array.isArray(v)) return v.forEach(visit)
86
+ if (!v || typeof v !== 'object') return
87
+ const o = v as Record<string, unknown>
88
+ if (o.passed === false) names.push(String(o.code ?? o.check ?? o.kind ?? o.name ?? 'a check'))
89
+ for (const x of Object.values(o)) visit(x)
90
+ }
91
+ visit(report)
92
+ return names.length ? `failed ${[...new Set(names)].join(', ')}` : `status ${String((report as { status?: unknown } | null)?.status ?? 'unknown')}`
93
+ }
94
+
95
+ /**
96
+ * Before `blueprint apply` writes Actions: a draft still on schema 1 cannot
97
+ * hold them, so it moves to schema 2 (a steward's call: the owner's session).
98
+ * Returns a note when it moved, null when nothing needed doing.
99
+ */
100
+ export async function ensureActionDraft(call: Call): Promise<{ ok: boolean; note: string | null }> {
101
+ const lifecycle = await call('org', 'GET', '/v1/blueprint/lifecycle')
102
+ if (lifecycle.status === 404) return { ok: true, note: null }
103
+ if (lifecycle.status >= 300) return { ok: false, note: `the Blueprint draft could not be read: ${message(lifecycle)}` }
104
+ const draft = data<{ draft?: { revision?: number; definition?: { schemaVersion?: number } } }>(lifecycle)?.draft
105
+ if (draft?.definition?.schemaVersion !== 1 || !draft.revision) return { ok: true, note: null }
106
+ const migrated = await call('session', 'POST', '/v1/blueprint/lifecycle/draft/migrate-v2', { expectedRevision: draft.revision })
107
+ if (migrated.status >= 300) return { ok: false, note: `the Blueprint draft could not move to schema 2, which governed Actions need: ${message(migrated)}` }
108
+ return { ok: true, note: 'moved the Blueprint draft to schema 2 for its governed Actions' }
109
+ }
110
+
111
+ /**
112
+ * Each Action's first decision, before the publish: it ships switched off. An
113
+ * Action that already has one (published before) needs nothing. Returns the
114
+ * Actions that could not get one, with why.
115
+ */
116
+ export async function prepareActions(call: Call, apiNames: string[], authoredAgain: ReadonlySet<string> = new Set()): Promise<Map<string, string>> {
117
+ const failed = new Map<string, string>()
118
+ for (const apiName of apiNames) {
119
+ // Published already: it has its decision, and a deploy follows the release, not the draft.
120
+ // One authored again under a new id has none yet, whatever the release still holds.
121
+ if (!authoredAgain.has(apiName)) {
122
+ const published = await call('org', 'GET', `/v1/blueprint/actions/${encodeURIComponent(apiName)}?view=deployable`)
123
+ if (published.status < 300) continue
124
+ }
125
+ const res = await call('org', 'POST', `${ADMIN}/deployments`, { actionApiName: apiName, reason: 'Pulled into a local project: ships switched off until the local runtime deploys it.' })
126
+ if (res.status < 300) continue
127
+ const details = (res.body as { details?: { contractChanged?: boolean } } | null)?.details
128
+ if (res.status === 409 && !details?.contractChanged) continue
129
+ failed.set(apiName, details?.contractChanged ? 'it changed after it was first deployed here; the local runtime needs a reset to deploy it again' : message(res))
130
+ }
131
+ return failed
132
+ }
133
+
134
+ /** Of these Actions, the ones still waiting for their first decision: not published here yet. */
135
+ export async function unpublishedActions(call: Call, apiNames: string[]): Promise<string[]> {
136
+ const out: string[] = []
137
+ for (const apiName of apiNames) {
138
+ const published = await call('org', 'GET', `/v1/blueprint/actions/${encodeURIComponent(apiName)}?view=deployable`)
139
+ if (published.status >= 300) out.push(apiName)
140
+ }
141
+ return out
142
+ }
143
+
144
+ /**
145
+ * Actions whose contract moved after their decision was taken here (an edit
146
+ * to the Action, or to a field of its object type), which blocks the publish:
147
+ * a decision cannot follow a new contract. The draft's validation names them.
148
+ * On the platform the cure is authoring the Action again under a new id; on
149
+ * the local runtime `local dev` does that itself (see `dev.ts`).
150
+ */
151
+ export async function staleActions(call: Call, apiNames: string[]): Promise<{ stale: string[]; consumed: string[] }> {
152
+ const validated = await call('org', 'POST', '/v1/blueprint/lifecycle/validate', {})
153
+ const report = data<{ report?: { blockers?: Blocker[] }; blockers?: Blocker[] }>(validated)
154
+ const blockers = report?.report?.blockers ?? report?.blockers ?? []
155
+ const stale = [...new Set(blockers.flatMap((b) => (b.code === 'ACTION_BINDING_DIGEST_MISMATCH' ? [/Action "([^"]+)"/.exec(b.message ?? '')?.[1]].filter((n): n is string => !!n) : [])))]
156
+ // An Action taken out of the draft while its old deployment still runs: named by the released id.
157
+ const ids = new Set(blockers.flatMap((b) => (b.code === 'ACTION_BINDING_CONSUMER_INVALID' ? [/^actions\.([^.]+)\./.exec(b.path ?? '')?.[1]].filter((n): n is string => !!n) : [])))
158
+ const consumed: string[] = []
159
+ if (ids.size) {
160
+ for (const apiName of apiNames) {
161
+ const read = await call('org', 'GET', `/v1/blueprint/actions/${encodeURIComponent(apiName)}?view=deployable`)
162
+ if (read.status < 300 && ids.has(data<Deployable>(read).definition?.id)) consumed.push(apiName)
163
+ }
164
+ }
165
+ return { stale, consumed }
166
+ }
167
+
168
+ interface Blocker {
169
+ code?: string
170
+ message?: string
171
+ path?: string
172
+ }
173
+
174
+ /**
175
+ * Before an Action is authored again: its deployment here, if one is live,
176
+ * is retired, since a release may not drop a contract a live deployment
177
+ * still runs. Returns the Actions whose deployment could not be retired.
178
+ */
179
+ export async function retireActions(call: Call, apiNames: string[]): Promise<Map<string, string>> {
180
+ const failed = new Map<string, string>()
181
+ for (const apiName of apiNames) {
182
+ const read = await call('org', 'GET', `/v1/blueprint/actions/${encodeURIComponent(apiName)}?view=deployable`)
183
+ if (read.status >= 300) continue
184
+ const id = data<Deployable>(read).definition?.id
185
+ if (!id) continue
186
+ const listed = data<{ entries?: Array<{ actionDefinitionId: string; deployment?: { stateId?: string; generation?: number; state?: string } | null }> }>(
187
+ await call('session', 'GET', `${ADMIN}/bindings?actionDefinitionId=${encodeURIComponent(id)}`),
188
+ )
189
+ const current = listed?.entries?.find((e) => e.actionDefinitionId === id)?.deployment
190
+ if (!current?.stateId || typeof current.generation !== 'number' || (current.state !== 'active' && current.state !== 'suspended')) continue
191
+ const retired = await call('session', 'POST', `${ADMIN}/deployments/${current.stateId}/revoke`, { expectedGeneration: current.generation, reason: 'Authored again on the local runtime after its contract changed.' })
192
+ if (retired.status >= 300) failed.set(apiName, `its deployment here could not be revoked: ${message(retired)}`)
193
+ }
194
+ return failed
195
+ }
196
+
197
+ export type ActionBuildStatus = 'runs' | 'switched-off' | 'failed' | 'left-off'
198
+
199
+ export interface ActionBuild {
200
+ status: ActionBuildStatus
201
+ /** Why it is not running, in words. */
202
+ reason?: string
203
+ }
204
+
205
+ interface Deployable {
206
+ definition: PublishedActionDefinition
207
+ contractDigest: string
208
+ deploymentStatus?: string
209
+ }
210
+
211
+ /** The write path for one Action that writes the Blueprint's records: built, validated, reviewed, activated. */
212
+ async function deployOne(call: Call, published: Deployable): Promise<string | null> {
213
+ const { definition, contractDigest } = published
214
+ const types = data<Array<{ id?: string; apiName?: string }>>(await call('org', 'GET', '/v1/blueprint/object-types')) ?? []
215
+ const subject = (Array.isArray(types) ? types : []).find((t) => t.id === definition.subject.objectTypeId)?.apiName
216
+ if (!subject) return 'the object type it acts on is not published here'
217
+ const shape = data<{ objectType?: { id?: string; apiName?: string; primaryKeyPropertyId?: string | null }; properties?: ObjectTypeShape['properties'] }>(
218
+ await call('org', 'GET', `/v1/blueprint/object-types/${encodeURIComponent(subject)}`),
219
+ )
220
+ if (!shape?.objectType?.id) return `${subject} is not published here`
221
+ let plan
222
+ try {
223
+ plan = deriveOntologyEditPlan(definition, { id: shape.objectType.id, apiName: shape.objectType.apiName ?? subject, primaryKeyPropertyId: shape.objectType.primaryKeyPropertyId ?? null, properties: shape.properties ?? [] })
224
+ } catch (err) {
225
+ if (err instanceof PlanDerivationError) return err.message
226
+ throw err
227
+ }
228
+ const planRevision = await call('org', 'POST', `${ADMIN}/mutation-plans/${randomUUID()}/revisions`, { expectedRevision: 0, parentRevisionId: null, plan })
229
+ if (planRevision.status >= 300) return `its write plan was refused: ${message(planRevision)}`
230
+ const binding = await call('org', 'POST', `${ADMIN}/bindings/${randomUUID()}/revisions`, {
231
+ expectedRevision: 0,
232
+ parentRevisionId: null,
233
+ actionDefinitionId: definition.id,
234
+ actionContractDigest: contractDigest,
235
+ mutationPlanRevisionId: data<{ id: string }>(planRevision).id,
236
+ })
237
+ if (binding.status >= 300) return `its write path was refused: ${message(binding)}`
238
+ const bindingRevisionId = data<{ id: string }>(binding).id
239
+ const validation = await call('org', 'POST', `${ADMIN}/binding-revisions/${bindingRevisionId}/validate`, { expectedStateGeneration: 1 })
240
+ const report = data<{ id?: string; status?: string; findings?: unknown }>(validation)
241
+ if (validation.status >= 300 || report?.status !== 'valid') return `its write path did not validate: ${validation.status >= 300 ? message(validation) : failedChecks(report)}`
242
+ // A different principal from the key that built it: the service insists.
243
+ const reviewed = await call('session', 'POST', `${ADMIN}/binding-revisions/${bindingRevisionId}/review`, { validationReportId: report.id, expectedStateGeneration: 2 })
244
+ if (reviewed.status >= 300) return `its review was refused: ${message(reviewed)}`
245
+ const revision = data<{ stateId?: string; stateGeneration?: number; actionDefinitionId?: string }>(await call('session', 'GET', `${ADMIN}/binding-revisions/${bindingRevisionId}`))
246
+ if (!revision?.stateId || typeof revision.stateGeneration !== 'number') return 'it was reviewed, but its deployment state could not be read'
247
+ const listed = data<{ entries?: Array<{ actionDefinitionId: string; deployment?: { stateId?: string; generation?: number } | null }> }>(
248
+ await call('session', 'GET', `${ADMIN}/bindings?actionDefinitionId=${encodeURIComponent(definition.id)}`),
249
+ )
250
+ const current = listed?.entries?.find((e) => e.actionDefinitionId === definition.id)?.deployment
251
+ const activated = await call('session', 'POST', `${ADMIN}/deployments/${revision.stateId}/activate`, {
252
+ expectedGeneration: revision.stateGeneration,
253
+ expectedCurrentStateId: current?.stateId ?? null,
254
+ expectedCurrentGeneration: typeof current?.generation === 'number' ? current.generation : null,
255
+ reason: 'Activated on the local runtime by frontera local dev.',
256
+ })
257
+ if (activated.status >= 300) return `its activation was refused: ${message(activated)}`
258
+ return null
259
+ }
260
+
261
+ export interface LocalAction {
262
+ apiName: string
263
+ fit: ActionFit
264
+ /** The capability that lets a role invoke it (`governance.invokeCapability`). */
265
+ invokeCapability?: string
266
+ }
267
+
268
+ /**
269
+ * After the publish: deploy what runs here and let the owner role invoke it;
270
+ * leave the rest switched off. Already active Actions are left as they are.
271
+ * Each is read in its deployable view: the plain list holds only Actions that
272
+ * can be invoked, which a switched-off one cannot.
273
+ */
274
+ export async function deployActions(call: Call, actions: LocalAction[]): Promise<Record<string, ActionBuild>> {
275
+ const out: Record<string, ActionBuild> = {}
276
+ for (const action of actions) {
277
+ if (!action.fit.runsLocally) {
278
+ out[action.apiName] = { status: 'switched-off', reason: action.fit.reason }
279
+ continue
280
+ }
281
+ const read = await call('org', 'GET', `/v1/blueprint/actions/${encodeURIComponent(action.apiName)}?view=deployable`)
282
+ if (read.status === 404) {
283
+ out[action.apiName] = { status: 'failed', reason: 'it is not published on the local runtime' }
284
+ continue
285
+ }
286
+ if (read.status >= 300) {
287
+ out[action.apiName] = { status: 'failed', reason: `its published contract could not be read: ${message(read)}` }
288
+ continue
289
+ }
290
+ const published = data<Deployable>(read)
291
+ let failure: string | null = null
292
+ if (published.deploymentStatus !== 'active') {
293
+ try {
294
+ failure = await deployOne(call, published)
295
+ } catch (err) {
296
+ failure = (err as Error).message
297
+ }
298
+ }
299
+ if (!failure && action.invokeCapability) {
300
+ const granted = await call('session', 'POST', `${ADMIN}/capability-grants`, { capability: action.invokeCapability, role: 'owner', revoke: false })
301
+ if (granted.status >= 300) failure = `it is deployed, but the owner role could not be allowed to run it: ${message(granted)}`
302
+ }
303
+ out[action.apiName] = failure ? { status: 'failed', reason: failure } : { status: 'runs' }
304
+ }
305
+ return out
306
+ }