@frontera-sdk/cli 1.52.28 → 1.52.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- package/src/local/local-workflow-state.ts +131 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|