@frontera-sdk/cli 1.52.39 → 1.52.41
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/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
5
|
+
import { loadHashes } from './local-applied'
|
|
6
|
+
import { allTargets } from './local-dev-sync'
|
|
7
|
+
import { functionSlugOf } from './local-function-state'
|
|
8
|
+
import type { LocalProject } from './local-project'
|
|
9
|
+
import type { LocalCredentials } from './local-state'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the local runtime holds that the project's files don't (e2e G3).
|
|
13
|
+
*
|
|
14
|
+
* `local dev` applies files; until now nothing happened when one was
|
|
15
|
+
* deleted: an Agent whose folder was removed stayed on the runtime, `status`
|
|
16
|
+
* never named it, and only `local reset` (every row gone) took it away.
|
|
17
|
+
*
|
|
18
|
+
* An Agent, workflow, Function or knowledge base on the runtime with no file
|
|
19
|
+
* in the project is an **orphan**. `status` lists each with its fix. One the
|
|
20
|
+
* project applied before (its key is in the applied record) had its file
|
|
21
|
+
* deleted, and `local dev` takes it out of service the safest way the runtime
|
|
22
|
+
* offers, reversibly, and puts it back when the file returns:
|
|
23
|
+
*
|
|
24
|
+
* Agent archived (the platform's delete is an archive: its versions
|
|
25
|
+
* and conversations stay) — restored when its folder returns
|
|
26
|
+
* workflow archived (versions and runs stay) — restored before its next push
|
|
27
|
+
* Function switched off: the runtime has no way to remove a Function —
|
|
28
|
+
* switched on again when its file is deployed again
|
|
29
|
+
* knowledge base left as it is: deleting one takes its documents with it and
|
|
30
|
+
* cannot be undone, so `status` only says it
|
|
31
|
+
*
|
|
32
|
+
* An orphan the project never had (made in Studio, say) is only said: it may
|
|
33
|
+
* be work someone means to pull into the files. A Function switched off with
|
|
34
|
+
* no file is not one: it doesn't run, and there is nothing left to do.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export type OrphanKind = 'agent' | 'workflow' | 'function' | 'knowledge'
|
|
38
|
+
|
|
39
|
+
export interface Orphan {
|
|
40
|
+
/** `agent/assistant`, `workflow/hello`, `function/word-count`, `knowledge/Field desk policies`. */
|
|
41
|
+
ref: string
|
|
42
|
+
kind: OrphanKind
|
|
43
|
+
name: string
|
|
44
|
+
/** The runtime's id, where the kind is addressed by one (an Agent, a knowledge base). */
|
|
45
|
+
id?: string
|
|
46
|
+
/** The project applied it from a file that is gone: `local dev` takes it out of service. */
|
|
47
|
+
deleted: boolean
|
|
48
|
+
/** What is wrong, in a sentence. */
|
|
49
|
+
problem: string
|
|
50
|
+
/** What fixes it. */
|
|
51
|
+
fix: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What the runtime holds of the kinds files make, by name; a kind the runtime didn't list is absent. */
|
|
55
|
+
export interface RuntimeItems {
|
|
56
|
+
agents?: Array<{ id: string; slug: string }>
|
|
57
|
+
workflows?: Array<{ slug: string }>
|
|
58
|
+
functions?: Array<{ slug: string; enabled: boolean | null }>
|
|
59
|
+
knowledge?: Array<{ id: string; name: string }>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rowsOf<T>(body: unknown): T[] {
|
|
63
|
+
let v = body
|
|
64
|
+
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
|
|
65
|
+
if (v && !Array.isArray(v) && typeof v === 'object' && Array.isArray((v as { items?: unknown }).items)) v = (v as { items: unknown[] }).items
|
|
66
|
+
return Array.isArray(v) ? (v as T[]) : []
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Call = (path: string, init?: { method?: string; body?: unknown }) => Promise<{ ok: boolean; status: number; body: unknown }>
|
|
70
|
+
|
|
71
|
+
/** Calls to the runtime with the workspace key; null without one. */
|
|
72
|
+
export function orphanCalls(serviceUrl: string, creds: Pick<LocalCredentials, 'workspaceKey'>): Call | null {
|
|
73
|
+
if (!creds.workspaceKey) return null
|
|
74
|
+
return async (path, init = {}) => {
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(new URL(path, serviceUrl), {
|
|
77
|
+
method: init.method ?? 'GET',
|
|
78
|
+
headers: { authorization: `Bearer ${creds.workspaceKey}`, 'content-type': 'application/json' },
|
|
79
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
80
|
+
signal: AbortSignal.timeout(10_000),
|
|
81
|
+
})
|
|
82
|
+
const text = await res.text()
|
|
83
|
+
let body: unknown = text
|
|
84
|
+
try {
|
|
85
|
+
body = text ? JSON.parse(text) : null
|
|
86
|
+
} catch {
|
|
87
|
+
// Not JSON: the text is the body.
|
|
88
|
+
}
|
|
89
|
+
return { ok: res.ok, status: res.status, body }
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return { ok: false, status: 0, body: (err as Error).message }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The Agents, workflows, Functions and knowledge bases on the runtime; each kind that didn't answer is left out. */
|
|
97
|
+
export async function runtimeItems(call: Call, workspaceId: string): Promise<RuntimeItems> {
|
|
98
|
+
const [agents, workflows, functions, knowledge] = await Promise.all([
|
|
99
|
+
// The sync listing: drafts never published included, archived and platform Agents left out.
|
|
100
|
+
call('/v1/sync/agents'),
|
|
101
|
+
// Archived workflows are left out of the listing.
|
|
102
|
+
call('/v1/workflows'),
|
|
103
|
+
call('/v1/functions'),
|
|
104
|
+
call(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(workspaceId)}`),
|
|
105
|
+
])
|
|
106
|
+
const out: RuntimeItems = {}
|
|
107
|
+
if (agents.ok) {
|
|
108
|
+
out.agents = rowsOf<{ id?: string; agentId?: string; lifecycleStatus?: string }>(agents.body)
|
|
109
|
+
// A preview clone is the runtime's machinery for a draft chat, never an Agent of the project's.
|
|
110
|
+
.filter((a) => a.id && a.agentId && !a.agentId.startsWith('preview_') && a.lifecycleStatus !== 'archived')
|
|
111
|
+
.map((a) => ({ id: a.id!, slug: a.agentId! }))
|
|
112
|
+
}
|
|
113
|
+
if (workflows.ok) out.workflows = rowsOf<{ slug?: string; archivedAt?: string | null }>(workflows.body).filter((w) => w.slug && !w.archivedAt).map((w) => ({ slug: w.slug! }))
|
|
114
|
+
if (functions.ok) out.functions = rowsOf<{ slug?: string; enabled?: boolean | null }>(functions.body).filter((f) => f.slug).map((f) => ({ slug: f.slug!, enabled: f.enabled ?? null }))
|
|
115
|
+
if (knowledge.ok) out.knowledge = rowsOf<{ id?: string; name?: string }>(knowledge.body).filter((k) => k.id && k.name).map((k) => ({ id: k.id!, name: k.name! }))
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The names the project's files give each kind: an Agent's folder, a workflow's file, a Function's file or folder, a knowledge base's `name`. */
|
|
120
|
+
export function projectNames(root: string): Record<OrphanKind, Set<string>> {
|
|
121
|
+
const out: Record<OrphanKind, Set<string>> = { agent: new Set(), workflow: new Set(), function: new Set(), knowledge: new Set() }
|
|
122
|
+
for (const t of allTargets(root)) {
|
|
123
|
+
if (t.kind === 'agent') out.agent.add(t.slug)
|
|
124
|
+
else if (t.kind === 'workflow') out.workflow.add(t.slug)
|
|
125
|
+
else if (t.kind === 'function') {
|
|
126
|
+
const slug = functionSlugOf(t.key)
|
|
127
|
+
if (slug) out.function.add(slug)
|
|
128
|
+
} else if (t.kind === 'knowledge') {
|
|
129
|
+
out.knowledge.add(t.name)
|
|
130
|
+
try {
|
|
131
|
+
const name = (JSON.parse(readFileSync(join(root, t.file), 'utf8')) as { name?: unknown }).name
|
|
132
|
+
if (typeof name === 'string' && name) out.knowledge.add(name)
|
|
133
|
+
} catch {
|
|
134
|
+
// Unreadable: its folder's name is the one it goes by.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The applied-record keys a deleted item's files went by, of a kind and name. */
|
|
142
|
+
export function appliedKeysOf(kind: OrphanKind, name: string): string[] {
|
|
143
|
+
if (kind === 'agent') return [`agent:${name}`, `operating:${name}`, `bindings:${name}`, `lessons:${name}`]
|
|
144
|
+
if (kind === 'workflow') return [`workflow:${name}`]
|
|
145
|
+
if (kind === 'function') return [`function:${name}.ts`, `function:${name}/`]
|
|
146
|
+
return []
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether the project applied this item from a file before: a key its file went by is in the applied record. */
|
|
150
|
+
function wasApplied(kind: OrphanKind, name: string, applied: ReadonlySet<string>): boolean {
|
|
151
|
+
// An Agent is its agent.json: behavior settings or tools alone never made one.
|
|
152
|
+
if (kind === 'agent') return applied.has(`agent:${name}`)
|
|
153
|
+
return appliedKeysOf(kind, name).some((k) => applied.has(k))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const WORDS: Record<OrphanKind, string> = { agent: 'Agent', workflow: 'workflow', function: 'Function', knowledge: 'knowledge base' }
|
|
157
|
+
|
|
158
|
+
/** The file an item of a kind would have in the project. */
|
|
159
|
+
function fileOf(kind: OrphanKind, name: string): string {
|
|
160
|
+
if (kind === 'agent') return `agents/${name}/agent.json`
|
|
161
|
+
if (kind === 'workflow') return `workflows/${name}.yaml`
|
|
162
|
+
if (kind === 'function') return `functions/${name}.ts`
|
|
163
|
+
return 'knowledge/<folder>/knowledge.json'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Each item on the runtime with no file in the project, with its fix.
|
|
168
|
+
* `applied`: the keys of the files the project applied (`applied-hashes.json`).
|
|
169
|
+
*/
|
|
170
|
+
export function findOrphans(root: string, items: RuntimeItems, applied: ReadonlySet<string>): Orphan[] {
|
|
171
|
+
const names = projectNames(root)
|
|
172
|
+
const out: Orphan[] = []
|
|
173
|
+
const add = (kind: OrphanKind, name: string, id?: string) => {
|
|
174
|
+
if (names[kind].has(name)) return
|
|
175
|
+
const deleted = kind !== 'knowledge' && wasApplied(kind, name, applied)
|
|
176
|
+
const what = `${WORDS[kind]} ${name}`
|
|
177
|
+
const base = deleted ? `${what} is on the local runtime, but its file is gone from the project` : `${what} is on the local runtime, but the project has no ${fileOf(kind, name)}`
|
|
178
|
+
const problem = base
|
|
179
|
+
let fix: string
|
|
180
|
+
if (kind === 'agent') fix = deleted ? '`frontera local dev` archives it (or `frontera agent archive ' + name + '` now)' : `add its folder to the project, or \`frontera agent archive ${name}\` if it isn’t wanted`
|
|
181
|
+
else if (kind === 'workflow') fix = deleted ? '`frontera local dev` archives it (or `frontera workflow archive ' + name + '` now)' : `add its file to the project, or \`frontera workflow archive ${name}\` if it isn’t wanted`
|
|
182
|
+
else if (kind === 'function') {
|
|
183
|
+
fix = deleted
|
|
184
|
+
? `\`frontera local dev\` switches it off (the runtime can’t remove a Function), or \`frontera function disable ${name}\` now`
|
|
185
|
+
: `add its file to the project, or \`frontera function disable ${name}\` to switch it off (the runtime can’t remove a Function)`
|
|
186
|
+
} else fix = 'add its folder to the project, or delete it from its page in Studio if it isn’t wanted: deleting a knowledge base takes its documents with it, so `frontera local dev` never does'
|
|
187
|
+
out.push({ ref: `${kind}/${name}`, kind, name, ...(id ? { id } : {}), deleted, problem, fix })
|
|
188
|
+
}
|
|
189
|
+
for (const a of items.agents ?? []) add('agent', a.slug, a.id)
|
|
190
|
+
for (const w of items.workflows ?? []) add('workflow', w.slug)
|
|
191
|
+
// A Function switched off and with no file: it doesn't run, and the runtime can't remove it, so there is nothing to fix.
|
|
192
|
+
for (const f of items.functions ?? []) if (f.enabled !== false) add('function', f.slug)
|
|
193
|
+
for (const k of items.knowledge ?? []) add('knowledge', k.name, k.id)
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The orphans of a running runtime, as `status` lists them; null when it didn't answer. */
|
|
198
|
+
export async function projectOrphans(project: LocalProject, serviceUrl: string, creds: LocalCredentials): Promise<Orphan[] | null> {
|
|
199
|
+
const call = orphanCalls(serviceUrl, creds)
|
|
200
|
+
if (!call) return null
|
|
201
|
+
const items = await runtimeItems(call, creds.workspaceId)
|
|
202
|
+
if (!Object.keys(items).length) return null
|
|
203
|
+
return findOrphans(project.root, items, new Set(loadHashes(project, creds.organizationId).keys()))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** An orphan as `status` says it: the problem, then the fix. */
|
|
207
|
+
export function orphanLine(o: Orphan): string {
|
|
208
|
+
return `${o.problem}: ${o.fix}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── What `local dev` took out of service, so a returning file puts it back ──
|
|
212
|
+
|
|
213
|
+
interface RetiredFile {
|
|
214
|
+
organizationId: string
|
|
215
|
+
/** By `kind/name`: when it was taken out, and how. */
|
|
216
|
+
items: Record<string, { how: 'archived' | 'switched-off'; id?: string; at: string }>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const retiredPath = (project: Pick<LocalProject, 'stateDir'>) => join(project.stateDir, 'retired.json')
|
|
220
|
+
|
|
221
|
+
export function readRetired(project: Pick<LocalProject, 'stateDir'>, organizationId: string): RetiredFile['items'] {
|
|
222
|
+
try {
|
|
223
|
+
const saved = JSON.parse(readFileSync(retiredPath(project), 'utf8')) as Partial<RetiredFile>
|
|
224
|
+
if (saved.organizationId === organizationId && saved.items) return saved.items
|
|
225
|
+
} catch {
|
|
226
|
+
// None yet.
|
|
227
|
+
}
|
|
228
|
+
return {}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function writeRetired(project: Pick<LocalProject, 'stateDir'>, organizationId: string, items: RetiredFile['items']): void {
|
|
232
|
+
try {
|
|
233
|
+
if (!Object.keys(items).length && !existsSync(retiredPath(project))) return
|
|
234
|
+
writeFileAtomic(retiredPath(project), `${JSON.stringify({ organizationId, items }, null, 2)}\n`, 0o644)
|
|
235
|
+
} catch {
|
|
236
|
+
// Losing it only means a returning Function is not switched on by itself.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Take each orphan whose file was deleted out of service (see the top of the
|
|
242
|
+
* file). Returns the lines to say and the applied-record keys to forget, so
|
|
243
|
+
* the file, when it comes back, is applied again even unchanged.
|
|
244
|
+
*/
|
|
245
|
+
export async function retireDeleted(project: Pick<LocalProject, 'stateDir'>, organizationId: string, call: Call, orphans: readonly Orphan[]): Promise<{ lines: string[]; forget: string[] }> {
|
|
246
|
+
const lines: string[] = []
|
|
247
|
+
const forget: string[] = []
|
|
248
|
+
const retired = { ...readRetired(project, organizationId) }
|
|
249
|
+
const at = new Date().toISOString()
|
|
250
|
+
const said = (res: { status: number; body: unknown }) => `${res.status || 'no answer'} ${typeof res.body === 'string' ? res.body.slice(0, 160) : JSON.stringify(res.body).slice(0, 160)}`
|
|
251
|
+
for (const o of orphans) {
|
|
252
|
+
if (!o.deleted) continue
|
|
253
|
+
if (o.kind === 'agent' && o.id) {
|
|
254
|
+
const res = await call(`/v1/config/agents/${encodeURIComponent(o.id)}`, { method: 'DELETE' })
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
lines.push(` warning: Agent ${o.name}’s folder is gone, but it couldn’t be archived here: ${said(res)}; \`frontera agent archive ${o.name}\` archives it`)
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
retired[o.ref] = { how: 'archived', id: o.id, at }
|
|
260
|
+
lines.push(` archived Agent ${o.name}: its folder is gone from the project (its versions and conversations are kept; it is restored if agents/${o.name}/ comes back)`)
|
|
261
|
+
} else if (o.kind === 'workflow') {
|
|
262
|
+
const res = await call(`/v1/workflows/${encodeURIComponent(o.name)}/archive`, { method: 'POST' })
|
|
263
|
+
if (!res.ok) {
|
|
264
|
+
lines.push(` warning: workflow ${o.name}’s file is gone, but it couldn’t be archived here: ${said(res)}; \`frontera workflow archive ${o.name}\` archives it`)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
retired[o.ref] = { how: 'archived', at }
|
|
268
|
+
lines.push(` archived workflow ${o.name}: its file is gone from the project (its versions and runs are kept; it is restored if the file comes back)`)
|
|
269
|
+
} else if (o.kind === 'function') {
|
|
270
|
+
const res = await call(`/v1/functions/${encodeURIComponent(o.name)}/enabled`, { method: 'POST', body: { enabled: false } })
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
lines.push(` warning: Function ${o.name}’s file is gone, but it couldn’t be switched off here: ${said(res)}; \`frontera function disable ${o.name}\` switches it off`)
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
retired[o.ref] = { how: 'switched-off', at }
|
|
276
|
+
lines.push(` switched off Function ${o.name}: its file is gone from the project, and the runtime can’t remove a Function (it is switched on again if the file comes back)`)
|
|
277
|
+
} else continue
|
|
278
|
+
forget.push(...appliedKeysOf(o.kind, o.name))
|
|
279
|
+
}
|
|
280
|
+
writeRetired(project, organizationId, retired)
|
|
281
|
+
return { lines, forget }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Before an Agent's or workflow's file is applied: one `local dev` archived
|
|
286
|
+
* (its file was deleted, and is back), or archived by hand, is restored, so
|
|
287
|
+
* the file reaches a working item. Returns the line to say, or null.
|
|
288
|
+
*/
|
|
289
|
+
export async function restoreIfArchived(project: Pick<LocalProject, 'stateDir'>, organizationId: string, call: Call, kind: 'agent' | 'workflow', name: string): Promise<string | null> {
|
|
290
|
+
const retired = readRetired(project, organizationId)
|
|
291
|
+
const ref = `${kind}/${name}`
|
|
292
|
+
let line: string | null = null
|
|
293
|
+
if (kind === 'agent') {
|
|
294
|
+
const row = await call(`/v1/config/agents/${encodeURIComponent(name)}`)
|
|
295
|
+
const agent = (row.ok ? ((row.body as { data?: unknown })?.data ?? row.body) : null) as { id?: string; lifecycleStatus?: string } | null
|
|
296
|
+
if (agent?.id && agent.lifecycleStatus === 'archived') {
|
|
297
|
+
const res = await call(`/v1/config/agents/${encodeURIComponent(agent.id)}/restore`, { method: 'POST', body: {} })
|
|
298
|
+
line = res.ok ? ` note: restored Agent ${name}: it was archived here, and its folder is in the project` : ` warning: Agent ${name} is archived here and couldn’t be restored (${res.status}): \`frontera agent restore ${name}\``
|
|
299
|
+
}
|
|
300
|
+
} else if (retired[ref]) {
|
|
301
|
+
const res = await call(`/v1/workflows/${encodeURIComponent(name)}/restore`, { method: 'POST' })
|
|
302
|
+
line = res.ok ? ` note: restored workflow ${name}: it was archived here when its file was deleted, and the file is back` : null
|
|
303
|
+
}
|
|
304
|
+
if (retired[ref]) {
|
|
305
|
+
const { [ref]: _gone, ...rest } = retired
|
|
306
|
+
writeRetired(project, organizationId, rest)
|
|
307
|
+
}
|
|
308
|
+
return line
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** After a Function's file is deployed again: one `local dev` switched off when its file was deleted is switched on. */
|
|
312
|
+
export async function reviveFunction(project: Pick<LocalProject, 'stateDir'>, organizationId: string, call: Call, slug: string): Promise<string | null> {
|
|
313
|
+
const retired = readRetired(project, organizationId)
|
|
314
|
+
const ref = `function/${slug}`
|
|
315
|
+
if (retired[ref]?.how !== 'switched-off') return null
|
|
316
|
+
const res = await call(`/v1/functions/${encodeURIComponent(slug)}/enabled`, { method: 'POST', body: { enabled: true } })
|
|
317
|
+
if (!res.ok) return ` warning: Function ${slug} was switched off here when its file was deleted, and couldn’t be switched on again (${res.status}): \`frontera function enable ${slug}\``
|
|
318
|
+
const { [ref]: _gone, ...rest } = retired
|
|
319
|
+
writeRetired(project, organizationId, rest)
|
|
320
|
+
return ` note: switched on Function ${slug}: it was switched off here when its file was deleted, and the file is back`
|
|
321
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
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 { CliError, UsageError } from '../errors'
|
|
7
|
+
import { isIgnoredName } from './local-dev-sync'
|
|
8
|
+
import { localRuntimeAt } from './local-runtime-target'
|
|
9
|
+
import type { LocalCredentials } from './local-state'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Local personas: seeded people on the LOCAL runtime only, so an agent can
|
|
13
|
+
* walk a governed Action's approval chain end to end without a person's
|
|
14
|
+
* session (the service's `/v1/local/personas`, which answers 404 anywhere
|
|
15
|
+
* else and takes only the runtime's organization key).
|
|
16
|
+
*
|
|
17
|
+
* `local dev` seeds one persona per approval step the project's Action files
|
|
18
|
+
* name, keyed by the step (`dispatch_lead`), plus a `submitter`: the
|
|
19
|
+
* approver may not be the submitter, and a chain needs one person per step.
|
|
20
|
+
* The submitter is an `owner` (an Action grants its invoke capability to
|
|
21
|
+
* owners only); approvers are `admin`s. `frontera action available|submit|approve|reject --as
|
|
22
|
+
* <persona>` then runs as that persona. The session it uses is minted for the
|
|
23
|
+
* one command, sent only to the local runtime, and never printed or logged.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The persona that raises requests, apart from every approver. */
|
|
27
|
+
export const SUBMITTER = 'submitter'
|
|
28
|
+
|
|
29
|
+
/** The commands `--as` works on. */
|
|
30
|
+
export const ACT_AS_COMMANDS = ['action available', 'action submit', 'action approve', 'action reject'] as const
|
|
31
|
+
|
|
32
|
+
const KEY = /^[a-z][a-z0-9_-]{0,62}$/
|
|
33
|
+
/** The most personas one seed takes (the route's limit). */
|
|
34
|
+
const MAX_PERSONAS = 20
|
|
35
|
+
|
|
36
|
+
/** A persona key from what a person types or a step is named: `Dispatch lead` → `dispatch_lead`. */
|
|
37
|
+
export function personaKey(name: string): string {
|
|
38
|
+
const key = name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 63)
|
|
39
|
+
return KEY.test(key) ? key : ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PersonaSpec {
|
|
43
|
+
key: string
|
|
44
|
+
displayName: string
|
|
45
|
+
/**
|
|
46
|
+
* The organization role it holds: `owner` for the submitter (an Action
|
|
47
|
+
* grants its invoke capability to owners only), `admin` for an approver.
|
|
48
|
+
*/
|
|
49
|
+
orgRole: 'owner' | 'admin'
|
|
50
|
+
/** What the persona is for, in words: "decides step dispatch_lead of escalateWorkOrder". */
|
|
51
|
+
decides: string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PersonaPlan {
|
|
55
|
+
personas: PersonaSpec[]
|
|
56
|
+
/** What could not be seeded, one line each (a step decided by an App role). */
|
|
57
|
+
notes: string[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const words = (key: string) => {
|
|
61
|
+
const w = key.replace(/[_-]+/g, ' ').trim()
|
|
62
|
+
return w.charAt(0).toUpperCase() + w.slice(1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type Doc = Record<string, unknown>
|
|
66
|
+
const isRecord = (v: unknown): v is Doc => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
67
|
+
|
|
68
|
+
/** The project's Action files' documents, parsed; unreadable ones are left out. */
|
|
69
|
+
function actionDocs(root: string): Array<{ file: string; doc: Doc }> {
|
|
70
|
+
const dir = join(root, 'blueprint', 'actions')
|
|
71
|
+
if (!existsSync(dir)) return []
|
|
72
|
+
return readdirSync(dir).sort().flatMap((name) => {
|
|
73
|
+
if (isIgnoredName(name) || !/\.(ya?ml|json)$/.test(name)) return []
|
|
74
|
+
try {
|
|
75
|
+
const doc = parseYaml(readFileSync(join(dir, name), 'utf8'))
|
|
76
|
+
return isRecord(doc) ? [{ file: join('blueprint', 'actions', name), doc }] : []
|
|
77
|
+
} catch {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The personas the project's Actions need: one per approval step (a chain's
|
|
85
|
+
* stage key; `approver` for an Action approved in one step), and a submitter
|
|
86
|
+
* when any Action asks for approval. A step decided by an App role is said,
|
|
87
|
+
* not seeded: a persona holds an organization role, not an App's.
|
|
88
|
+
*/
|
|
89
|
+
export function personaPlan(root: string): PersonaPlan {
|
|
90
|
+
const byKey = new Map<string, PersonaSpec>()
|
|
91
|
+
const notes: string[] = []
|
|
92
|
+
const add = (key: string, decides: string) => {
|
|
93
|
+
const at = byKey.get(key) ?? { key, displayName: words(key), orgRole: key === SUBMITTER ? ('owner' as const) : ('admin' as const), decides: [] }
|
|
94
|
+
at.decides.push(decides)
|
|
95
|
+
byKey.set(key, at)
|
|
96
|
+
}
|
|
97
|
+
for (const { file, doc } of actionDocs(root)) {
|
|
98
|
+
const apiName = typeof doc.apiName === 'string' ? doc.apiName : file
|
|
99
|
+
const approval = (isRecord(doc.governance) ? doc.governance.approval : undefined) ?? doc.approval
|
|
100
|
+
if (!isRecord(approval) || approval.mode === undefined || approval.mode === 'none') continue
|
|
101
|
+
if (approval.mode === 'chain' && Array.isArray(approval.stages)) {
|
|
102
|
+
for (const stage of approval.stages.filter(isRecord)) {
|
|
103
|
+
const raw = typeof stage.key === 'string' ? stage.key : ''
|
|
104
|
+
const key = personaKey(raw)
|
|
105
|
+
if (!key || key === SUBMITTER) {
|
|
106
|
+
notes.push(`${file}: step "${raw}" has no key a persona can take (a lowercase letter, then letters, digits, "_" or "-")`)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (isRecord(stage.approver) && typeof stage.approver.role === 'string') {
|
|
110
|
+
notes.push(`${file}: step ${raw} is decided by the App-user role ${stage.approver.role}, which local personas don't cover; decide it as an App user in the App`)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
add(key, `step ${raw} of ${apiName}`)
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
add('approver', `approval of ${apiName}`)
|
|
117
|
+
}
|
|
118
|
+
add(SUBMITTER, `requests for ${apiName}`)
|
|
119
|
+
}
|
|
120
|
+
// The submitter first, then the approvers as their steps come.
|
|
121
|
+
const personas = [...byKey.values()].sort((a, b) => Number(b.key === SUBMITTER) - Number(a.key === SUBMITTER))
|
|
122
|
+
if (personas.length > MAX_PERSONAS) notes.push(`${personas.length - MAX_PERSONAS} more approval steps than the ${MAX_PERSONAS} personas a runtime seeds at once: the rest were left out`)
|
|
123
|
+
return { personas: personas.slice(0, MAX_PERSONAS), notes }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface LocalPersona {
|
|
127
|
+
key: string
|
|
128
|
+
displayName: string
|
|
129
|
+
orgRole?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type Fetch = typeof fetch
|
|
133
|
+
|
|
134
|
+
/** Calls to `/v1/local/personas` with the runtime's organization key. */
|
|
135
|
+
function personaCall(serviceUrl: string, creds: LocalCredentials, fetcher: Fetch) {
|
|
136
|
+
return async (method: string, path: string, body?: unknown): Promise<{ status: number; body: unknown }> => {
|
|
137
|
+
const res = await fetcher(new URL(`/v1/local/personas${path}`, serviceUrl), {
|
|
138
|
+
method,
|
|
139
|
+
headers: {
|
|
140
|
+
authorization: `Bearer ${creds.orgKey}`,
|
|
141
|
+
'x-org-id': creds.organizationId,
|
|
142
|
+
'x-workspace-id': creds.workspaceId,
|
|
143
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
144
|
+
},
|
|
145
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
146
|
+
})
|
|
147
|
+
const text = await res.text()
|
|
148
|
+
let parsed: unknown = text
|
|
149
|
+
try {
|
|
150
|
+
parsed = text ? JSON.parse(text) : null
|
|
151
|
+
} catch {
|
|
152
|
+
// Keep the text.
|
|
153
|
+
}
|
|
154
|
+
return { status: res.status, body: parsed }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const inner = (body: unknown): unknown => (isRecord(body) && 'data' in body ? body.data : body)
|
|
159
|
+
|
|
160
|
+
function personasOf(body: unknown): LocalPersona[] {
|
|
161
|
+
const data = inner(body)
|
|
162
|
+
const rows = Array.isArray(data) ? data : isRecord(data) && Array.isArray(data.personas) ? data.personas : []
|
|
163
|
+
return rows.filter(isRecord).flatMap((p) => (typeof p.key === 'string' ? [{ key: p.key, displayName: typeof p.displayName === 'string' ? p.displayName : words(p.key), ...(typeof p.orgRole === 'string' ? { orgRole: p.orgRole } : {}) }] : []))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const messageOf = (answer: { status: number; body: unknown }) => {
|
|
167
|
+
const m = isRecord(answer.body) && typeof answer.body.message === 'string' ? answer.body.message : typeof answer.body === 'string' ? answer.body : ''
|
|
168
|
+
return `${answer.status}${m ? ` ${m.replace(/\s+/g, ' ').slice(0, 200)}` : ''}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The personas on the runtime; null when it has none to list (it predates them) or did not answer. */
|
|
172
|
+
export async function listPersonas(serviceUrl: string, creds: LocalCredentials, fetcher: Fetch = fetch): Promise<LocalPersona[] | null> {
|
|
173
|
+
try {
|
|
174
|
+
const answer = await personaCall(serviceUrl, creds, fetcher)('GET', '/')
|
|
175
|
+
return answer.status < 300 ? personasOf(answer.body) : null
|
|
176
|
+
} catch {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Seed the project's personas on the runtime (idempotent). Lines for the
|
|
183
|
+
* apply's output: what was seeded, or why not; never a failure of the build.
|
|
184
|
+
*/
|
|
185
|
+
export async function seedPersonas(root: string, serviceUrl: string, creds: LocalCredentials, fetcher: Fetch = fetch): Promise<string[]> {
|
|
186
|
+
const plan = personaPlan(root)
|
|
187
|
+
const lines = plan.notes.map((n) => ` note: ${n}`)
|
|
188
|
+
if (!plan.personas.length) return lines
|
|
189
|
+
let answer: { status: number; body: unknown }
|
|
190
|
+
try {
|
|
191
|
+
answer = await personaCall(serviceUrl, creds, fetcher)('POST', '/', { personas: plan.personas.map((p) => ({ key: p.key, displayName: p.displayName, orgRole: p.orgRole })) })
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return [...lines, ` note: local personas could not be seeded: ${(err as Error).message}`]
|
|
194
|
+
}
|
|
195
|
+
if (answer.status === 404) return [...lines, ' note: this runtime has no local personas (its platform predates them), so approval chains can only be decided by a person here']
|
|
196
|
+
if (answer.status >= 300) return [...lines, ` note: local personas could not be seeded: ${messageOf(answer)}`]
|
|
197
|
+
const keys = plan.personas.map((p) => p.key)
|
|
198
|
+
const approver = keys.find((k) => k !== SUBMITTER)
|
|
199
|
+
return [...lines, ` local personas ${keys.join(', ')}: act as one with \`--as\`${approver ? ` (\`frontera action approve <requestId> --as ${approver}\`)` : ''}`]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface PersonaSession {
|
|
203
|
+
/** The persona's session: sent only as this command's bearer, never printed. */
|
|
204
|
+
token: string
|
|
205
|
+
persona: LocalPersona
|
|
206
|
+
organizationId: string
|
|
207
|
+
workspaceId: string
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The session `--as <persona>` runs a command with: refused for any other
|
|
212
|
+
* command, and anywhere but the project's own local runtime (never a hosted
|
|
213
|
+
* deployment, whatever the credential). `wanted` is a persona key or the
|
|
214
|
+
* approval step it was seeded for.
|
|
215
|
+
*/
|
|
216
|
+
export async function actAsLocalPersona(opts: {
|
|
217
|
+
meta: { noun: string; verb: string }
|
|
218
|
+
cwd: string
|
|
219
|
+
apiUrl: string
|
|
220
|
+
wanted: string
|
|
221
|
+
fetcher?: Fetch
|
|
222
|
+
}): Promise<PersonaSession> {
|
|
223
|
+
const label = `${opts.meta.noun} ${opts.meta.verb}`
|
|
224
|
+
if (!(ACT_AS_COMMANDS as readonly string[]).includes(label)) {
|
|
225
|
+
throw new UsageError(`--as does nothing on \`${label}\``, `--as acts as a local persona on ${ACT_AS_COMMANDS.map((c) => `\`${c}\``).join(', ')}`)
|
|
226
|
+
}
|
|
227
|
+
const local = localRuntimeAt(opts.cwd, opts.apiUrl)
|
|
228
|
+
if (!local) {
|
|
229
|
+
throw new CliError(`--as acts as a local persona, and ${opts.apiUrl} is not this project's local runtime`, {
|
|
230
|
+
code: 'LOCAL_ONLY',
|
|
231
|
+
hint: 'personas exist only on a local runtime: run the command in its project folder while it runs (`frontera local status`); on a deployment, a person signs in (`frontera login`)',
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
const key = personaKey(opts.wanted)
|
|
235
|
+
if (!key) throw new UsageError(`"${opts.wanted}" names no persona`, 'pass a persona key or approval step, as `frontera local status` lists them')
|
|
236
|
+
const call = personaCall(local.serviceUrl, local.creds, opts.fetcher ?? fetch)
|
|
237
|
+
const answer = await call('POST', `/${encodeURIComponent(key)}/session`)
|
|
238
|
+
if (answer.status === 404) {
|
|
239
|
+
const known = await listPersonas(local.serviceUrl, local.creds, opts.fetcher ?? fetch)
|
|
240
|
+
throw new CliError(known ? `no local persona is named ${key}` : 'this runtime has no local personas', {
|
|
241
|
+
code: 'PERSONA_NOT_FOUND',
|
|
242
|
+
hint: known
|
|
243
|
+
? `${known.length ? `the personas here are ${known.map((p) => p.key).join(', ')}; ` : ''}\`frontera local dev\` seeds one per approval step in blueprint/actions/, plus ${SUBMITTER}`
|
|
244
|
+
: 'its platform predates them: start the runtime from a release that has them (`frontera local start`)',
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
const data = inner(answer.body)
|
|
248
|
+
const token = isRecord(data) && typeof data.token === 'string' ? data.token : ''
|
|
249
|
+
if (answer.status >= 300 || !token) throw new CliError(`a session for persona ${key} could not be started`, { code: 'PERSONA_SESSION_FAILED', hint: messageOf({ status: answer.status, body: isRecord(answer.body) ? { message: answer.body.message } : null }) })
|
|
250
|
+
const persona = isRecord(data) && isRecord(data.persona) ? personasOf({ data: [data.persona] })[0] : undefined
|
|
251
|
+
return { token, persona: persona ?? { key, displayName: words(key) }, organizationId: local.creds.organizationId, workspaceId: local.creds.workspaceId }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `value` with every occurrence of `secret` replaced: what an error says after a persona's session ran. */
|
|
255
|
+
export function redact<T>(value: T, secret: string, seen = new WeakSet<object>()): T {
|
|
256
|
+
if (!secret) return value
|
|
257
|
+
if (typeof value === 'string') return value.split(secret).join('[persona session]') as T
|
|
258
|
+
if (!value || typeof value !== 'object' || seen.has(value as object)) return value
|
|
259
|
+
seen.add(value as object)
|
|
260
|
+
if (Array.isArray(value)) {
|
|
261
|
+
value.forEach((v, i) => ((value as unknown[])[i] = redact(v, secret, seen)))
|
|
262
|
+
return value
|
|
263
|
+
}
|
|
264
|
+
for (const k of Object.getOwnPropertyNames(value)) {
|
|
265
|
+
try {
|
|
266
|
+
const v = (value as Record<string, unknown>)[k]
|
|
267
|
+
if (typeof v === 'string' || (v && typeof v === 'object')) (value as Record<string, unknown>)[k] = redact(v, secret, seen)
|
|
268
|
+
} catch {
|
|
269
|
+
// A read-only property keeps its value.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return value
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* What a key refused on a command that acts as a person is told on the
|
|
277
|
+
* project's own local runtime, instead of the deployment's "set FRONTERA_TOKEN
|
|
278
|
+
* to your session token": a local runtime has personas, and an agent is never
|
|
279
|
+
* sent to fetch a person's session. On a command `--as` works on it names the
|
|
280
|
+
* persona to act as (the submitter to see or raise requests, an approver to
|
|
281
|
+
* decide one) and the personas the runtime has (`personas`, null when they
|
|
282
|
+
* could not be read); on any other it says `--as` does not reach it.
|
|
283
|
+
*/
|
|
284
|
+
export function localPersonaHint(meta: { noun: string; verb: string }, personas: readonly LocalPersona[] | null): string {
|
|
285
|
+
const label = `${meta.noun} ${meta.verb}`
|
|
286
|
+
const approvers = (personas ?? []).filter((p) => p.key !== SUBMITTER).map((p) => p.key)
|
|
287
|
+
const approver = approvers[0] ?? 'approver'
|
|
288
|
+
const listed = personas?.length ? `the personas here are ${personas.map((p) => p.key).join(', ')}` : '`frontera local status` lists the personas (Personas)'
|
|
289
|
+
const never = 'never a person’s session token'
|
|
290
|
+
if (!(ACT_AS_COMMANDS as readonly string[]).includes(label)) {
|
|
291
|
+
return `on this local runtime no person signs in, and \`--as <persona>\` acts as a local persona only on ${ACT_AS_COMMANDS.map((c) => `\`${c}\``).join(', ')}; \`${label}\` runs against a deployment, where a person signs in (${never} here)`
|
|
292
|
+
}
|
|
293
|
+
const deciding = meta.verb === 'approve' || meta.verb === 'reject'
|
|
294
|
+
const example = deciding ? `frontera ${label} <requestId> --as ${approver}` : `frontera ${label}${meta.verb === 'submit' ? ' <apiName>' : ''} --as ${SUBMITTER}`
|
|
295
|
+
return `on this local runtime, act as a local persona: \`--as ${SUBMITTER}\` sees and raises requests, \`--as ${approver}\` approves or rejects them (\`${example}\`); ${listed}: ${never}`
|
|
296
|
+
}
|