@frontera-sdk/cli 1.52.28 → 1.52.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- package/src/local/local-workflow-state.ts +131 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.29",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -39,14 +39,15 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
|
-
"@
|
|
43
|
-
"@frontera-sdk/core": "1.52.
|
|
42
|
+
"@faker-js/faker": "10.6.0",
|
|
43
|
+
"@frontera-sdk/core": "1.52.29",
|
|
44
|
+
"@frontera-sdk/functions": "1.52.29",
|
|
44
45
|
"ai": "^6.0.116",
|
|
45
46
|
"gray-matter": "^4.0.3",
|
|
46
47
|
"yaml": "^2.9.0"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
|
-
"@frontera-sdk/forge-contracts": "1.52.
|
|
50
|
+
"@frontera-sdk/forge-contracts": "1.52.29",
|
|
50
51
|
"@types/bun": "^1.3.14",
|
|
51
52
|
"typescript": "^5.9.3"
|
|
52
53
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { UsageError } from '../../errors'
|
|
5
|
+
import { localProject } from '../../local/local-project'
|
|
6
|
+
import { addMissingBackings, generateSyntheticData, loadScenario, SCENARIO_TEMPLATE } from '../../local/local-synthetic-data'
|
|
7
|
+
import { flagBool, flagString, type Command } from '../types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `frontera local data --synthetic`: sample rows for every object type in the
|
|
11
|
+
* project's Blueprint, written under `datasets/`, so a Blueprint pulled from
|
|
12
|
+
* a workspace (which brings no records) can be explored and used by Agents.
|
|
13
|
+
* `local dev` loads them like any dataset file. No real record is copied.
|
|
14
|
+
*/
|
|
15
|
+
export const localData: Command = {
|
|
16
|
+
meta: {
|
|
17
|
+
noun: 'local',
|
|
18
|
+
verb: 'data',
|
|
19
|
+
args: [],
|
|
20
|
+
flags: { synthetic: 'boolean', rows: 'string', seed: 'string' },
|
|
21
|
+
summary: "Generate sample data for the project's Blueprint (datasets/*.csv), with consistent links",
|
|
22
|
+
examples: ['frontera local data --synthetic', 'frontera local data --synthetic --rows 500 --seed 42'],
|
|
23
|
+
offline: true,
|
|
24
|
+
internal: true,
|
|
25
|
+
},
|
|
26
|
+
async run(ctx) {
|
|
27
|
+
if (!flagBool(ctx, 'synthetic')) throw new UsageError('say what to make', 'run `frontera local data --synthetic`')
|
|
28
|
+
const rows = Number(flagString(ctx, 'rows') ?? 200)
|
|
29
|
+
const seed = Number(flagString(ctx, 'seed') ?? 7)
|
|
30
|
+
if (!Number.isInteger(rows) || rows < 1 || rows > 100_000) throw new UsageError('--rows takes a whole number from 1 to 100000', 'e.g. `frontera local data --synthetic --rows 500`')
|
|
31
|
+
if (!Number.isInteger(seed)) throw new UsageError('--seed takes a whole number', 'e.g. `frontera local data --synthetic --seed 42`')
|
|
32
|
+
const project = localProject(ctx.cwd)
|
|
33
|
+
if (!existsSync(join(project.root, 'blueprint', 'object-types'))) {
|
|
34
|
+
throw new UsageError('this project has no Blueprint object types', 'pull them with `frontera local sync pull --kinds blueprint`, or add blueprint/object-types/<Name>.yaml')
|
|
35
|
+
}
|
|
36
|
+
// A scenario file to edit, the first time, so a coding agent finds it.
|
|
37
|
+
const scenarioFile = join(project.root, 'data', 'generate.ts')
|
|
38
|
+
let wroteScenario = false
|
|
39
|
+
if (!existsSync(scenarioFile)) {
|
|
40
|
+
mkdirSync(join(project.root, 'data'), { recursive: true })
|
|
41
|
+
writeFileSync(scenarioFile, SCENARIO_TEMPLATE)
|
|
42
|
+
wroteScenario = true
|
|
43
|
+
}
|
|
44
|
+
const backed = addMissingBackings(project.root)
|
|
45
|
+
const scenario = await loadScenario(project.root, seed)
|
|
46
|
+
const made = generateSyntheticData(project.root, { rows, seed, scenario: scenario ?? undefined })
|
|
47
|
+
const total = made.reduce((n, m) => n + m.rows, 0)
|
|
48
|
+
return {
|
|
49
|
+
data: { datasets: made, backings: backed, scenario: 'data/generate.ts', wroteScenario, rows: total, seed },
|
|
50
|
+
text: [
|
|
51
|
+
`Wrote ${total} sample rows for ${made.length} object types (seed ${seed}):`,
|
|
52
|
+
...made.map((m) => ` ${m.objectType.padEnd(24)} ${String(m.rows).padStart(6)} ${m.csvPath}`),
|
|
53
|
+
...(backed.length ? ['', `Gave ${backed.length} object type${backed.length === 1 ? '' : 's'} a dataset to read: ${backed.join(', ')}`] : []),
|
|
54
|
+
'',
|
|
55
|
+
wroteScenario ? 'Adjust them in data/generate.ts (rows per type, values per property).' : 'Adjusted by data/generate.ts.',
|
|
56
|
+
'`frontera local dev` loads them into the runtime.',
|
|
57
|
+
].join('\n'),
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -3,7 +3,10 @@ import { existsSync, lstatSync, readFileSync, rmSync, watch, writeFileSync } fro
|
|
|
3
3
|
import { join, sep } from 'node:path'
|
|
4
4
|
|
|
5
5
|
import { CliError } from '../../errors'
|
|
6
|
-
import { contentHash, loadHashes, saveHashes } from '../../local/local-applied'
|
|
6
|
+
import { adaptationOf, contentHash, loadBuild, loadHashes, oneLine, pruneBuild, recordBuild, recordSettingsDifferences, saveHashes, type BuildEntry } from '../../local/local-applied'
|
|
7
|
+
import { deployActions, ensureActionDraft, localActionCalls, prepareActions, retireActions, staleActions, unpublishedActions, type ActionBuild } from '../../local/local-actions'
|
|
8
|
+
import { agentIdentity, syncAgentName } from '../../local/local-agent-name'
|
|
9
|
+
import { classify, itemNeeds, type BuiltInKind } from '../../local/local-portability'
|
|
7
10
|
import { localProfileName } from '../../local/local-control'
|
|
8
11
|
import {
|
|
9
12
|
allTargets,
|
|
@@ -17,11 +20,29 @@ import {
|
|
|
17
20
|
type SyncProfiles,
|
|
18
21
|
type SyncTarget,
|
|
19
22
|
} from '../../local/local-dev-sync'
|
|
23
|
+
import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout, editableSets, localDirectory, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild } from '../../local/local-dev-references'
|
|
24
|
+
import { applyObjectSets, namesObjectSet, objectSetCalls } from '../../local/local-object-sets'
|
|
25
|
+
import { applyTheme, themeCalls } from '../../local/local-themes'
|
|
26
|
+
import { ensureMediaSets } from '../../local/local-media-sets'
|
|
27
|
+
import { applyChatApp } from '../../local/local-chat-apps'
|
|
28
|
+
import { applyLessons } from '../../local/local-lessons'
|
|
29
|
+
import { applyStandIn, platformOnlyNames, readStandIn, standInNames } from '../../local/local-stand-ins'
|
|
30
|
+
import { projectProvides } from '../../local/local-adapt'
|
|
20
31
|
import { applyOperatingConfig } from '../../local/local-operating'
|
|
32
|
+
import { mirrorOrgSettings, settingsCalls } from '../../local/local-org-settings'
|
|
33
|
+
import { readSnapshot } from '../../local/local-sync-snapshot'
|
|
34
|
+
import { functionSlugOf, mirrorFunctionState } from '../../local/local-function-state'
|
|
35
|
+
import { functionSecretNames, missingSecretLines, readProjectDoc, readProjectText, workflowSecretNames } from '../../local/local-secrets'
|
|
36
|
+
import { decideStepNotes, mirrorWorkflowState } from '../../local/local-workflow-state'
|
|
37
|
+
import type { Directory } from '../../local/local-sync-refs'
|
|
21
38
|
import { localProject, type LocalProject } from '../../local/local-project'
|
|
22
39
|
import { isAlive, readState, recordApplied } from '../../local/local-state'
|
|
23
40
|
import { applyErrorText } from '../../local/local-apply-error'
|
|
24
41
|
import { flagBool, type Command, type CommandContext } from '../types'
|
|
42
|
+
import { isSharedFunctionFile } from '../../local/local-function-shared'
|
|
43
|
+
import { applyApp } from '../../local/local-apps'
|
|
44
|
+
import { applyBrand, brandUpload } from '../../local/local-brand'
|
|
45
|
+
import { applyLibrary } from '../../local/local-library'
|
|
25
46
|
|
|
26
47
|
/**
|
|
27
48
|
* `frontera local dev`: the inner loop. Applies the whole project to the
|
|
@@ -37,6 +58,15 @@ const DEBOUNCE_MS = 300
|
|
|
37
58
|
interface RunResult {
|
|
38
59
|
ok: boolean
|
|
39
60
|
output: string
|
|
61
|
+
/** Not applied because it names these (`workflow/x`, `action/y`), which the runtime does not have yet. */
|
|
62
|
+
missing?: string[]
|
|
63
|
+
unnamedActions?: number
|
|
64
|
+
/** The Blueprint: how the runtime holds each object type and governed Action. */
|
|
65
|
+
blueprint?: { objects: Record<string, ObjectBuild>; actions: Record<string, ActionBuild>; objectSets?: string[] }
|
|
66
|
+
/** Saved object sets made again here under a new id: what names them is applied again. */
|
|
67
|
+
objectSetsAgain?: string[]
|
|
68
|
+
/** Governed Actions authored again under new ids: what names them is applied again. */
|
|
69
|
+
authoredAgain?: string[]
|
|
40
70
|
}
|
|
41
71
|
|
|
42
72
|
/** The commands running now, so stopping `local dev` stops them too. */
|
|
@@ -99,13 +129,82 @@ interface ApplyOptions {
|
|
|
99
129
|
|
|
100
130
|
async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptions): Promise<RunResult> {
|
|
101
131
|
const { profiles } = opts
|
|
132
|
+
const creds = readState(project)?.credentials
|
|
133
|
+
// Names in the file (`function/refund-eligibility`) become this runtime's ids in a resolved copy.
|
|
134
|
+
let directory: Promise<Directory> | null = null
|
|
135
|
+
const lookup = () => (directory ??= creds ? localDirectory(opts.serviceUrl, creds) : Promise.resolve({}))
|
|
136
|
+
const resolved = await resolveTarget(project, target, lookup, creds?.workspaceId, creds?.organizationId)
|
|
137
|
+
// The project's own file, before a resolved copy takes its place.
|
|
138
|
+
const original = target as Extract<SyncTarget, { file: string }>
|
|
139
|
+
if (!resolved.ok) return { ok: false, output: resolved.output, missing: resolved.missing, unnamedActions: resolved.unnamedActions }
|
|
140
|
+
let notes = resolved.output
|
|
141
|
+
const withNotes = (r: RunResult): RunResult => (notes ? { ...r, output: `${notes}\n${r.output}`.trim() } : r)
|
|
102
142
|
if (target.kind === 'operating') {
|
|
103
143
|
// Read fresh: the owner session is renewed on every runtime start.
|
|
104
|
-
const creds = readState(project)?.credentials
|
|
105
144
|
if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
106
|
-
return applyOperatingConfig({ serviceUrl: opts.serviceUrl, creds, slug: target.slug, file: join(project.root, target.file) })
|
|
145
|
+
return withNotes(await applyOperatingConfig({ serviceUrl: opts.serviceUrl, creds, slug: target.slug, file: join(project.root, target.file), document: resolved.doc }))
|
|
107
146
|
}
|
|
108
|
-
|
|
147
|
+
if (target.kind === 'bindings') {
|
|
148
|
+
if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
149
|
+
return applyBindings(project, target, opts.serviceUrl, creds)
|
|
150
|
+
}
|
|
151
|
+
if (target.kind === 'knowledge') return applyKnowledge(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined)
|
|
152
|
+
if (target.kind === 'theme') {
|
|
153
|
+
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
154
|
+
return applyTheme(project.root, target.name, themeCalls(opts.serviceUrl, creds))
|
|
155
|
+
}
|
|
156
|
+
if (target.kind === 'chat-app') {
|
|
157
|
+
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
158
|
+
// The owner's session, as the platform's Chat App editor writes; the roster's Agents by this runtime's ids.
|
|
159
|
+
return applyChatApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), lookup, projectProvides(project, creds.organizationId))
|
|
160
|
+
}
|
|
161
|
+
if (target.kind === 'app') {
|
|
162
|
+
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
163
|
+
return applyApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), projectProvides(project, creds.organizationId))
|
|
164
|
+
}
|
|
165
|
+
if (target.kind === 'brand') {
|
|
166
|
+
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
167
|
+
// The owner's session, as an administrator sets the brand in Settings.
|
|
168
|
+
return applyBrand(project.root, creds.organizationId, themeCalls(opts.serviceUrl, creds), brandUpload(opts.serviceUrl, creds))
|
|
169
|
+
}
|
|
170
|
+
if (target.kind === 'library') {
|
|
171
|
+
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
172
|
+
return applyLibrary(project.root, themeCalls(opts.serviceUrl, creds))
|
|
173
|
+
}
|
|
174
|
+
if (target.kind === 'lessons') {
|
|
175
|
+
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
176
|
+
return applyLessons(project.root, target.slug, themeCalls(opts.serviceUrl, creds))
|
|
177
|
+
}
|
|
178
|
+
if (target.kind === 'plugin' && (target.file.endsWith('.stand-in.json') || target.file.endsWith('.platform.json'))) return applyStandIn(project, target.name, profiles.workspace, (args) => runCli(project, args, opts.auth))
|
|
179
|
+
if (target.kind === 'plugin') return applyPlugin(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined)
|
|
180
|
+
if (resolved.file) target = { ...target, file: resolved.file } as SyncTarget
|
|
181
|
+
let command = commandFor(target, profiles)
|
|
182
|
+
// Where the applied Blueprint lives: the runtime's copy when it needed one.
|
|
183
|
+
let blueprintRoot = project.root
|
|
184
|
+
const blueprint = target.kind === 'blueprint' ? resolveBlueprint(project) : null
|
|
185
|
+
// Governed Actions are deployed on this runtime only (D3): calls to it, with the project's own keys.
|
|
186
|
+
const actionCalls = blueprint?.actions.length && creds && opts.publish ? localActionCalls(opts.serviceUrl, creds) : null
|
|
187
|
+
if (blueprint) {
|
|
188
|
+
// The Blueprint as this runtime can hold it; see `resolveBlueprint`.
|
|
189
|
+
if (blueprint.dir) {
|
|
190
|
+
command = [...command, '--dir', join(project.root, blueprint.dir)]
|
|
191
|
+
blueprintRoot = join(project.root, blueprint.dir, '..')
|
|
192
|
+
}
|
|
193
|
+
notes = [notes, ...blueprint.warnings].filter(Boolean).join('\n')
|
|
194
|
+
// A media-backed type over a set's catalog binds to an empty Media Set of that name, made first.
|
|
195
|
+
if (blueprint.mediaSets) {
|
|
196
|
+
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
197
|
+
const why = await ensureMediaSets(blueprint.mediaSets, themeCalls(opts.serviceUrl, creds))
|
|
198
|
+
if (why) return { ok: false, output: `error [BLUEPRINT_MEDIA]: ${why}` }
|
|
199
|
+
}
|
|
200
|
+
if (actionCalls) {
|
|
201
|
+
// A draft on the first schema cannot hold Actions.
|
|
202
|
+
const draft = await ensureActionDraft(actionCalls)
|
|
203
|
+
if (!draft.ok) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: ${draft.note}` }
|
|
204
|
+
if (draft.note) notes = [notes, ` warning: ${draft.note}`].filter(Boolean).join('\n')
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
let result = withNotes(await runCli(project, command, opts.auth))
|
|
109
208
|
const fallback = fallbackFor(target, profiles, project.root)
|
|
110
209
|
if (!result.ok && fallback?.when.test(result.output)) {
|
|
111
210
|
// An empty fallback means there was nothing more to do.
|
|
@@ -116,14 +215,158 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
116
215
|
}
|
|
117
216
|
}
|
|
118
217
|
if (!result.ok) return result
|
|
119
|
-
|
|
218
|
+
if (target.kind === 'workflow' && creds) {
|
|
219
|
+
// Live in the workspace: live here too, with its triggers as they are there where that is safe.
|
|
220
|
+
const lines = [
|
|
221
|
+
...(await mirrorWorkflowState(project, target.slug, original.file, opts.serviceUrl, creds)),
|
|
222
|
+
...decideStepNotes(project, original.file, creds),
|
|
223
|
+
// The secrets its HTTP steps and webhook name: placeholders until set on this computer.
|
|
224
|
+
...(await missingSecretLines(workflowSecretNames(readProjectDoc(project, original.file)), `workflow ${target.slug}`, opts.serviceUrl, creds)),
|
|
225
|
+
]
|
|
226
|
+
if (lines.length) result = { ...result, output: `${result.output}\n${lines.join('\n')}`.trim() }
|
|
227
|
+
}
|
|
228
|
+
if (target.kind === 'function' && creds) {
|
|
229
|
+
// Switched off in the workspace: off here too; and the secrets it is granted, until set here.
|
|
230
|
+
const source = readProjectText(project, original.file)
|
|
231
|
+
const lines = [
|
|
232
|
+
...(await mirrorFunctionState(project, target.key, opts.serviceUrl, creds)),
|
|
233
|
+
...(await missingSecretLines(functionSecretNames(source), `Function ${functionSlugOf(target.key) ?? original.file}`, opts.serviceUrl, creds)),
|
|
234
|
+
]
|
|
235
|
+
if (lines.length) result = { ...result, output: `${result.output}\n${lines.join('\n')}`.trim() }
|
|
236
|
+
}
|
|
237
|
+
if (target.kind === 'agent' && creds) {
|
|
238
|
+
// Agents stay drafts here, so the row keeps the name it was created with unless set from the file.
|
|
239
|
+
const named = await syncAgentName({ slug: target.slug, identity: agentIdentity(project.root, target.file), serviceUrl: opts.serviceUrl, creds })
|
|
240
|
+
if (named) result = { ...result, output: `${result.output}\n${named}`.trim() }
|
|
241
|
+
}
|
|
242
|
+
let authoredAgain: string[] = []
|
|
243
|
+
if (actionCalls && blueprint) {
|
|
244
|
+
const names = blueprint.actions.map((a) => a.apiName)
|
|
245
|
+
// A decision pins the Action's contract, its object type's fields included, and a first
|
|
246
|
+
// apply can leave the draft short of the files: apply once more before deciding.
|
|
247
|
+
if ((await unpublishedActions(actionCalls, names)).length) {
|
|
248
|
+
const again = await runCli(project, command, opts.auth)
|
|
249
|
+
if (!again.ok) return again
|
|
250
|
+
}
|
|
251
|
+
// What each Action may write (`backing.editable`): `blueprint apply` leaves backings to `bind`.
|
|
252
|
+
const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
|
|
253
|
+
const subjects = new Set(blueprint.actions.flatMap((a) => (a.fit.subject ? [a.fit.subject] : [])))
|
|
254
|
+
for (const [type, props] of editableSets(appliedDir, subjects)) {
|
|
255
|
+
const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
|
|
256
|
+
if (!set.ok && !/no change|already|unchanged/i.test(set.output)) notes = [notes, ` warning: ${type}'s editable fields (${props.join(', ')}) could not be declared: ${oneLine(applyErrorText(set.output))}`].filter(Boolean).join('\n')
|
|
257
|
+
}
|
|
258
|
+
// An Action whose contract moved after its decision here blocks the publish, and a decision
|
|
259
|
+
// cannot follow a new contract. On this runtime it is authored again under a new id: out of
|
|
260
|
+
// the draft (`--prune` over a copy without it), then back from the files.
|
|
261
|
+
const { stale, consumed } = await staleActions(actionCalls, names)
|
|
262
|
+
authoredAgain = stale.filter((n) => names.includes(n))
|
|
263
|
+
// A release may not drop a contract a live deployment runs: that deployment is retired first.
|
|
264
|
+
const live = await retireActions(actionCalls, [...new Set([...authoredAgain, ...consumed])])
|
|
265
|
+
if (live.size) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: governed Action ${[...live].map(([name, why]) => `${name}: ${why}`).join('; ')}` }
|
|
266
|
+
// Retired here, so authored again: what names it is applied again too.
|
|
267
|
+
for (const name of consumed) if (!authoredAgain.includes(name)) authoredAgain.push(name)
|
|
268
|
+
if (stale.length) {
|
|
269
|
+
const dir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
|
|
270
|
+
const without = await runCli(project, ['blueprint', 'apply', '--yes', '--prune', '--dir', blueprintWithout(project, dir, stale), '--profile', profiles.organization], opts.auth)
|
|
271
|
+
if (!without.ok) return without
|
|
272
|
+
const back = await runCli(project, command, opts.auth)
|
|
273
|
+
if (!back.ok) return back
|
|
274
|
+
notes = [notes, ` warning: governed Action ${stale.join(', ')} changed since it was deployed here, so it was authored again on this runtime`].filter(Boolean).join('\n')
|
|
275
|
+
result = withNotes(back)
|
|
276
|
+
}
|
|
277
|
+
// Publishing refuses an Action with no deployment decision: each gets "ships switched off" first.
|
|
278
|
+
const refused = await prepareActions(actionCalls, names, new Set(authoredAgain))
|
|
279
|
+
if (refused.size) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: governed Action ${[...refused].map(([name, why]) => `${name}: ${why}`).join('; ')}` }
|
|
280
|
+
}
|
|
281
|
+
const objectTypes = target.kind === 'blueprint' ? blueprintObjectTypes(blueprintRoot) : []
|
|
120
282
|
for (const step of followUpsFor(target, profiles, { publish: opts.publish, workspaceId: opts.workspaceId, objectTypes })) {
|
|
121
283
|
const done = await runCli(project, step.args, opts.auth)
|
|
122
284
|
if (!done.ok && !step.alreadyDone?.test(done.output)) return done
|
|
123
285
|
}
|
|
286
|
+
if (blueprint) {
|
|
287
|
+
// After the publish: what writes only the Blueprint's records runs here; the rest stays switched off.
|
|
288
|
+
const actions: Record<string, ActionBuild> = actionCalls ? await deployActions(actionCalls, blueprint.actions) : {}
|
|
289
|
+
for (const [name, why] of Object.entries(blueprint.leftOffActions)) actions[name] = { status: 'left-off', reason: why }
|
|
290
|
+
for (const [name, a] of Object.entries(actions)) {
|
|
291
|
+
if (a.status === 'runs') result = { ...result, output: `${result.output}\n governed Action ${name} runs on this runtime` }
|
|
292
|
+
else if (a.status !== 'left-off') result = { ...result, output: `${result.output}\n warning: governed Action ${name} ${a.status === 'failed' ? 'didn’t deploy' : 'is switched off here'}: ${a.reason}` }
|
|
293
|
+
}
|
|
294
|
+
// Saved object sets, once the object types they read are published here.
|
|
295
|
+
let objectSets: string[] = []
|
|
296
|
+
let objectSetsAgain: string[] = []
|
|
297
|
+
if (creds && opts.publish) {
|
|
298
|
+
const leftOff = new Set(Object.entries(blueprint.objects).flatMap(([name, o]) => (o.status === 'left-off' ? [name] : [])))
|
|
299
|
+
const sets = await applyObjectSets(project.root, objectSetCalls(opts.serviceUrl, creds), leftOff)
|
|
300
|
+
if (sets.lines.length || sets.warnings.length) result = { ...result, output: [result.output, ...sets.lines, ...sets.warnings].join('\n').trim() }
|
|
301
|
+
objectSets = sets.warnings.map(adaptationOf)
|
|
302
|
+
objectSetsAgain = sets.recreated
|
|
303
|
+
}
|
|
304
|
+
result = {
|
|
305
|
+
...result,
|
|
306
|
+
blueprint: { objects: blueprint.objects, actions, ...(objectSets.length ? { objectSets } : {}) },
|
|
307
|
+
...(authoredAgain.length ? { authoredAgain } : {}),
|
|
308
|
+
...(objectSetsAgain.length ? { objectSetsAgain } : {}),
|
|
309
|
+
}
|
|
310
|
+
}
|
|
124
311
|
return result
|
|
125
312
|
}
|
|
126
313
|
|
|
314
|
+
/**
|
|
315
|
+
* What a target was applied with, in words: the warnings the apply printed
|
|
316
|
+
* (a Plugin it went without, a model swapped) and what it needs that the
|
|
317
|
+
* runtime gives it differently (no computer; a governed Action switched off
|
|
318
|
+
* here, as the last Blueprint build left it).
|
|
319
|
+
*/
|
|
320
|
+
function adaptationsFor(project: LocalProject, target: SyncTarget, output: string, organizationId: string): { adaptations: string[]; stubbed: boolean } {
|
|
321
|
+
const fromOutput = output.split('\n').filter((l) => /^\s*warning: /.test(l)).map(adaptationOf)
|
|
322
|
+
if (target.kind === 'blueprint' || target.kind === 'dataset') return { adaptations: [...new Set(fromOutput)], stubbed: false }
|
|
323
|
+
let files: Record<string, string> = {}
|
|
324
|
+
try {
|
|
325
|
+
if ('file' in target) files = { [target.file]: readFileSync(join(project.root, target.file), 'utf8') }
|
|
326
|
+
} catch {
|
|
327
|
+
// Read for its needs only; a file that vanished has none.
|
|
328
|
+
}
|
|
329
|
+
const deployed = loadBuild(project, organizationId).blueprint?.actions ?? {}
|
|
330
|
+
const actions = new Map(Object.entries(deployed).map(([name, a]) => [name, { runsLocally: a.status === 'runs', reason: a.reason, subject: null }] as const))
|
|
331
|
+
const kind = target.kind === 'operating' || target.kind === 'bindings' || target.kind === 'lessons' ? 'agent' : target.kind
|
|
332
|
+
// A built-in connection the project has a stand-in for is Stubbed: its tools answer "not connected".
|
|
333
|
+
// The Documentation plugin's stand-in serves the runtime's documentation, and the workspace's
|
|
334
|
+
// analytics stays on the platform: neither is Stubbed.
|
|
335
|
+
const standIns = standInNames(project.root)
|
|
336
|
+
const platformOnly = platformOnlyNames(project.root)
|
|
337
|
+
const titles = new Map([...standIns].map((n) => [n, readStandIn(project.root, n)?.connection ?? n] as const))
|
|
338
|
+
const builtIns = new Map<string, BuiltInKind>([
|
|
339
|
+
...[...standIns].flatMap((n) => (readStandIn(project.root, n)?.serves === 'documentation' ? [[n, 'documentation'] as const] : [])),
|
|
340
|
+
...[...platformOnly].map((n) => [n, 'analytics'] as const),
|
|
341
|
+
])
|
|
342
|
+
const portability = classify(itemNeeds(kind, files, new Set([...standIns, ...platformOnly]), builtIns), { actions, titles })
|
|
343
|
+
const judged = portability.adaptations.map((a) => a.charAt(0).toUpperCase() + a.slice(1))
|
|
344
|
+
const stubbed = portability.class === 'stubbed' || (target.kind === 'plugin' && target.file.endsWith('.stand-in.json') && readStandIn(project.root, target.name)?.serves !== 'documentation')
|
|
345
|
+
return { adaptations: [...new Set([...judged, ...fromOutput])], stubbed }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The Blueprint's own adaptations, one line each: what is left off, and the Actions that do not run here. */
|
|
349
|
+
function blueprintAdaptations(b: NonNullable<RunResult['blueprint']>): string[] {
|
|
350
|
+
const out: string[] = []
|
|
351
|
+
// "Receipt’s files stay on the platform…", "FieldJob location is left off…".
|
|
352
|
+
for (const [name, o] of Object.entries(b.objects)) for (const a of o.adaptations ?? []) out.push(a.startsWith('its ') ? `${name}’s ${a.slice('its '.length)}` : `${name} ${a}`)
|
|
353
|
+
for (const [name, a] of Object.entries(b.actions)) {
|
|
354
|
+
if (a.status === 'switched-off') out.push(`Governed Action ${name} is switched off here: ${a.reason}`)
|
|
355
|
+
if (a.status === 'failed') out.push(`Governed Action ${name} didn’t deploy: ${a.reason}`)
|
|
356
|
+
if (a.status === 'left-off') out.push(`Governed Action ${name} is left off: ${a.reason}`)
|
|
357
|
+
}
|
|
358
|
+
out.push(...(b.objectSets ?? []))
|
|
359
|
+
return out
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The bindings an Agent could not get because the Function, workflow or
|
|
364
|
+
* object type (its Context and data) is not on the runtime yet.
|
|
365
|
+
*/
|
|
366
|
+
export function missingFromBindings(output: string): string[] {
|
|
367
|
+
return [...new Set([...output.matchAll(/^\s+((?:function|workflow|objectType)\/[A-Za-z0-9._-]+): (?:404|.*(?:not found|no live version|does not exist|not on the runtime yet))/gim)].map((m) => m[1]!))]
|
|
368
|
+
}
|
|
369
|
+
|
|
127
370
|
async function dev(ctx: CommandContext) {
|
|
128
371
|
const project = localProject(ctx.cwd)
|
|
129
372
|
const state = readState(project)
|
|
@@ -148,21 +391,74 @@ async function dev(ctx: CommandContext) {
|
|
|
148
391
|
|
|
149
392
|
const organizationId = state.credentials!.organizationId
|
|
150
393
|
const applied = flagBool(ctx, 'reapply') ? new Map<string, string | null>() : loadHashes(project, organizationId)
|
|
394
|
+
|
|
395
|
+
// The organization's settings as the last fetch read them: the timezone is taken, every
|
|
396
|
+
// other difference is said here and in the build summary.
|
|
397
|
+
// Read again when a fetch records new settings (`.frontera/sync/remote.json`), so the build summary follows the workspace.
|
|
398
|
+
let settingsSeen: string | null = null
|
|
399
|
+
const mirrorSettings = async () => {
|
|
400
|
+
const synced = readSnapshot(project.root)?.settings
|
|
401
|
+
if (!synced || !creds.session) return
|
|
402
|
+
const seen = JSON.stringify(synced)
|
|
403
|
+
if (seen === settingsSeen) return
|
|
404
|
+
settingsSeen = seen
|
|
405
|
+
const mirrored = await mirrorOrgSettings(synced, settingsCalls(serviceUrl, creds)).catch(() => null)
|
|
406
|
+
if (mirrored) {
|
|
407
|
+
for (const line of mirrored.lines) ctx.output.note(line)
|
|
408
|
+
for (const d of mirrored.differences) ctx.output.note(` differs from the workspace: ${d}`)
|
|
409
|
+
recordSettingsDifferences(project, organizationId, mirrored.differences)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
await mirrorSettings()
|
|
151
413
|
let unchanged = 0
|
|
152
414
|
const pending = new Map<string, SyncTarget>()
|
|
153
415
|
// Files refused while something they depend on was missing; tried again after the next success.
|
|
154
416
|
const failed = new Map<string, SyncTarget>()
|
|
417
|
+
// Workflows applied again for an Action authored again: their live version held the old id.
|
|
418
|
+
const promoteAfter = new Set<string>()
|
|
419
|
+
// Agents whose row name was checked against their file in this session.
|
|
420
|
+
const named = new Set<string>()
|
|
155
421
|
let failures = 0
|
|
422
|
+
// Whether anything applied since the failed targets were last tried.
|
|
423
|
+
let progressed = false
|
|
156
424
|
let busy = false
|
|
157
425
|
let stopping = false
|
|
158
426
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
159
427
|
|
|
428
|
+
/** What one apply came to, for the build record Forge and Studio read (design §9). */
|
|
429
|
+
const buildEntry = (target: SyncTarget, hash: string | null, result: RunResult): BuildEntry => {
|
|
430
|
+
const base = { what: describeTarget(target), hash, at: new Date().toISOString() }
|
|
431
|
+
if (result.ok) {
|
|
432
|
+
// A Blueprint with a media-backed type over an empty Media Set is Stubbed, as an item with a stand-in is.
|
|
433
|
+
const { adaptations, stubbed } = result.blueprint ? { adaptations: [...new Set([...blueprintAdaptations(result.blueprint)])], stubbed: Object.values(result.blueprint.objects).some((o) => o.status === 'stubbed') } : adaptationsFor(project, target, result.output, organizationId)
|
|
434
|
+
return {
|
|
435
|
+
...base,
|
|
436
|
+
status: stubbed ? 'stubbed' : adaptations.length ? 'adapted' : 'built',
|
|
437
|
+
...(adaptations.length ? { adaptations } : {}),
|
|
438
|
+
...(result.blueprint ? { objects: result.blueprint.objects, actions: result.blueprint.actions } : {}),
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const missing = result.missing?.length ? result.missing : target.kind === 'bindings' ? missingFromBindings(result.output) : []
|
|
442
|
+
if (missing.length || result.unnamedActions) return { ...base, status: 'blocked', needs: missing, ...(result.unnamedActions ? { unnamedActions: result.unnamedActions } : {}) }
|
|
443
|
+
return { ...base, status: 'failed', error: oneLine(applyErrorText(result.output)) }
|
|
444
|
+
}
|
|
445
|
+
|
|
160
446
|
const runOne = async (target: SyncTarget) => {
|
|
161
447
|
if (stopping) return
|
|
162
448
|
if (target.kind !== 'blueprint' && !existsSync(join(project.root, target.file))) return
|
|
163
449
|
const hash = contentHash(project, target)
|
|
164
450
|
if (hash && applied.get(target.key) === hash) {
|
|
165
451
|
unchanged += 1
|
|
452
|
+
// An Agent applied before names were set from its file still reads its slug: set it once.
|
|
453
|
+
if (target.kind === 'agent' && !named.has(target.slug)) {
|
|
454
|
+
named.add(target.slug)
|
|
455
|
+
const line = await syncAgentName({ slug: target.slug, identity: agentIdentity(project.root, target.file), serviceUrl, creds })
|
|
456
|
+
if (line) ctx.output.note(line)
|
|
457
|
+
// The runtime changed: say so as an apply does, so Studio's lists and the Agent's page refetch.
|
|
458
|
+
if (line && !/warning:/.test(line)) recordApplied(project, { at: new Date().toISOString(), what: describeTarget(target), ok: true })
|
|
459
|
+
}
|
|
460
|
+
// Applied before builds were recorded: it is built as it is.
|
|
461
|
+
if (!loadBuild(project, organizationId)[target.key]) recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'built', hash, at: new Date().toISOString() })
|
|
166
462
|
return
|
|
167
463
|
}
|
|
168
464
|
const started = Date.now()
|
|
@@ -170,6 +466,7 @@ async function dev(ctx: CommandContext) {
|
|
|
170
466
|
if (stopping) return
|
|
171
467
|
const ms = Date.now() - started
|
|
172
468
|
recordApplied(project, { at: new Date().toISOString(), what: describeTarget(target), ok: result.ok })
|
|
469
|
+
recordBuild(project, organizationId, target.key, buildEntry(target, hash, result))
|
|
173
470
|
if (result.ok) {
|
|
174
471
|
// A draft-only Blueprint is not what a plain `local dev` would leave, so it is not recorded as applied.
|
|
175
472
|
if (target.kind === 'blueprint' && !options.publish) applied.delete(target.key)
|
|
@@ -177,28 +474,68 @@ async function dev(ctx: CommandContext) {
|
|
|
177
474
|
saveHashes(project, organizationId, applied)
|
|
178
475
|
failed.delete(target.key)
|
|
179
476
|
ctx.output.note(` ✓ ${describeTarget(target)} applied (${ms} ms)`)
|
|
180
|
-
|
|
181
|
-
|
|
477
|
+
if (target.kind === 'workflow' && promoteAfter.delete(target.key)) {
|
|
478
|
+
const promoted = await promoteWorkflow(serviceUrl, creds, target.slug)
|
|
479
|
+
if (promoted) ctx.output.note(` promoted workflow ${target.slug} v${promoted}: its live version ran the Action's old id`)
|
|
480
|
+
}
|
|
481
|
+
// What it was applied without, or with instead (a missing model), said once here.
|
|
482
|
+
for (const line of result.output.split('\n')) if (/^\s+warning: /.test(line)) ctx.output.note(` ${line.trim()}`)
|
|
483
|
+
progressed = true
|
|
484
|
+
// Workflows and Agents hold the runtime's id of each Action they run: a new id means applying them again.
|
|
485
|
+
if (result.authoredAgain?.length || result.objectSetsAgain?.length) {
|
|
486
|
+
const names = result.authoredAgain ?? []
|
|
487
|
+
for (const t of allTargets(project.root)) {
|
|
488
|
+
if (t.kind !== 'workflow' && t.kind !== 'agent' && t.kind !== 'operating' && t.kind !== 'bindings') continue
|
|
489
|
+
let text = ''
|
|
490
|
+
try {
|
|
491
|
+
text = readFileSync(join(project.root, t.file), 'utf8')
|
|
492
|
+
} catch {
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
if (names.some((n) => text.includes(`action/${n}`) || text.includes(`action:${n}`)) || namesObjectSet(text, result.objectSetsAgain ?? [])) {
|
|
496
|
+
applied.delete(t.key)
|
|
497
|
+
pending.set(t.key, t)
|
|
498
|
+
if (t.kind === 'workflow') promoteAfter.add(t.key)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
182
502
|
} else {
|
|
183
|
-
failures += 1
|
|
184
503
|
failed.set(target.key, target)
|
|
185
|
-
|
|
504
|
+
// A retry that fails the same way is not said (or counted) again in the same round of changes.
|
|
505
|
+
const text = applyErrorText(result.output)
|
|
506
|
+
if (said.get(target.key) !== text) {
|
|
507
|
+
failures += 1
|
|
508
|
+
said.set(target.key, text)
|
|
509
|
+
ctx.output.note(` ✕ ${describeTarget(target)}: ${text}`)
|
|
510
|
+
}
|
|
186
511
|
}
|
|
187
512
|
}
|
|
188
513
|
|
|
514
|
+
/** What each failure last said in this drain, so a retry failing the same way is said once. */
|
|
515
|
+
const said = new Map<string, string>()
|
|
516
|
+
|
|
189
517
|
/** Apply what is queued, dependencies first, one at a time. Never throws: a bad file must not end the loop. */
|
|
190
518
|
const drain = async () => {
|
|
191
519
|
if (busy) return
|
|
192
520
|
busy = true
|
|
521
|
+
said.clear()
|
|
193
522
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
523
|
+
for (;;) {
|
|
524
|
+
while (pending.size && !stopping) {
|
|
525
|
+
const next = [...pending.values()].sort(byApplyOrder)[0]!
|
|
526
|
+
pending.delete(next.key)
|
|
527
|
+
try {
|
|
528
|
+
await runOne(next)
|
|
529
|
+
} catch (err) {
|
|
530
|
+
ctx.output.note(` ✕ ${describeTarget(next)}: ${(err as Error).message}`)
|
|
531
|
+
}
|
|
201
532
|
}
|
|
533
|
+
// What failed is tried again once the queue is empty, and only while a round applied
|
|
534
|
+
// something it may have been waiting for; otherwise it waits for its own file to change.
|
|
535
|
+
if (stopping || !progressed || !failed.size) break
|
|
536
|
+
progressed = false
|
|
537
|
+
for (const [key, retry] of failed) pending.set(key, retry)
|
|
538
|
+
failed.clear()
|
|
202
539
|
}
|
|
203
540
|
} finally {
|
|
204
541
|
busy = false
|
|
@@ -212,6 +549,8 @@ async function dev(ctx: CommandContext) {
|
|
|
212
549
|
|
|
213
550
|
/** What a changed path means: one file, every Function (a helper or the SDK changed), or a moved folder. */
|
|
214
551
|
const queuePath = (rel: string) => {
|
|
552
|
+
// Forge's state and the runtime's resolved copies are written under `.frontera/`: never a reason to apply.
|
|
553
|
+
if (rel.split(sep).some((p) => p === '.frontera' || p === 'node_modules' || p === '.git')) return
|
|
215
554
|
const absolute = join(project.root, rel)
|
|
216
555
|
const direct = targetFor(project.root, absolute)
|
|
217
556
|
if (direct) {
|
|
@@ -226,7 +565,8 @@ async function dev(ctx: CommandContext) {
|
|
|
226
565
|
return false
|
|
227
566
|
}
|
|
228
567
|
})()
|
|
229
|
-
|
|
568
|
+
// A helper in functions/, the SDK, or a shared file a Function imports from outside functions/.
|
|
569
|
+
const helper = ((top === 'functions' || top === 'vendor') && /\.ts$/.test(rel) && !rel.endsWith('.test.ts')) || isSharedFunctionFile(rel.split(sep).join('/'))
|
|
230
570
|
if (!isDir && !helper) return
|
|
231
571
|
for (const target of allTargets(project.root)) {
|
|
232
572
|
const path = target.kind === 'blueprint' ? 'blueprint' : target.file
|
|
@@ -252,11 +592,14 @@ async function dev(ctx: CommandContext) {
|
|
|
252
592
|
? null
|
|
253
593
|
: watch(project.root, { recursive: true }, (_event, filename) => {
|
|
254
594
|
if (!filename || stopping) return
|
|
595
|
+
if (filename.toString() === join('.frontera', 'sync', 'remote.json')) void mirrorSettings()
|
|
255
596
|
queuePath(filename.toString())
|
|
256
597
|
if (!busy) schedule()
|
|
257
598
|
})
|
|
258
599
|
|
|
259
600
|
const targets = allTargets(project.root)
|
|
601
|
+
// Outcomes of files that are gone are forgotten, so the home lists what the project holds.
|
|
602
|
+
pruneBuild(project, organizationId, new Set(targets.map((t) => t.key)))
|
|
260
603
|
ctx.output.note(targets.length ? `Applying ${targets.length} item${targets.length === 1 ? '' : 's'} to the local runtime` : 'Nothing to apply yet')
|
|
261
604
|
for (const target of targets) pending.set(target.key, target)
|
|
262
605
|
await drain()
|
|
@@ -268,7 +611,7 @@ async function dev(ctx: CommandContext) {
|
|
|
268
611
|
return { data: { applied: done, unchanged }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}` }
|
|
269
612
|
}
|
|
270
613
|
|
|
271
|
-
ctx.output.note('Watching datasets/, blueprint/, functions/, workflows/ and
|
|
614
|
+
ctx.output.note('Watching datasets/, blueprint/, knowledge/, skills/, mcp/, themes/, functions/, workflows/, agents/, chat-apps/, apps/, brand/ and library.json (Ctrl+C to stop)')
|
|
272
615
|
if (pending.size) schedule()
|
|
273
616
|
|
|
274
617
|
await new Promise<void>((resolve) => {
|
|
@@ -26,12 +26,14 @@ import { DEFAULT_REGISTRY, runSupervisor } from '../../local/local-supervisor'
|
|
|
26
26
|
import { findCaseFiles, runCases } from '../../local/local-test-runner'
|
|
27
27
|
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
28
28
|
import { localCheck } from './check'
|
|
29
|
+
import { localData } from './data'
|
|
29
30
|
import { localDev } from './dev'
|
|
30
31
|
import { localDocsMcp } from './docs-mcp'
|
|
31
32
|
import { localInit } from './init'
|
|
32
33
|
import { localKeyList, localKeyRemove, localKeySet } from './key'
|
|
33
34
|
import { localModelAdd, localModelDefault, localModelRemove, localModelsAvailable, localProviderAdd, localProviderRemove, localProviders } from './providers'
|
|
34
35
|
import { localSchema } from './schema'
|
|
36
|
+
import { localSync } from './sync'
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* `frontera local …`: Frontera's runtimes on this machine, for internal FDEs.
|
|
@@ -388,8 +390,12 @@ async function test(ctx: CommandContext) {
|
|
|
388
390
|
if (!state || state.phase !== 'ready' || !state.credentials?.session) {
|
|
389
391
|
throw new CliError('the local runtime is not running', { code: 'LOCAL_NOT_RUNNING', hint: 'run `frontera local start` first' })
|
|
390
392
|
}
|
|
391
|
-
|
|
393
|
+
// `--regression`: only the Agents' regression datasets, as the workspace chose them.
|
|
394
|
+
const files = findCaseFiles(project.root, flagString(ctx, 'agent')).filter((f) => !flagBool(ctx, 'regression') || f.regression)
|
|
392
395
|
const total = files.reduce((n, f) => n + f.cases.length, 0)
|
|
396
|
+
if (total === 0 && flagBool(ctx, 'regression')) {
|
|
397
|
+
return { data: { passed: 0, failed: 0, results: [] }, text: 'No regression datasets. A pull marks the dataset each Agent is tested on before publishing in the workspace (`regression: true`).' }
|
|
398
|
+
}
|
|
393
399
|
if (total === 0) {
|
|
394
400
|
return {
|
|
395
401
|
data: { passed: 0, failed: 0, results: [] },
|
|
@@ -419,9 +425,9 @@ export const localTest: Command = {
|
|
|
419
425
|
noun: 'local',
|
|
420
426
|
verb: 'test',
|
|
421
427
|
args: [],
|
|
422
|
-
flags: { agent: 'string', live: 'boolean' },
|
|
428
|
+
flags: { agent: 'string', live: 'boolean', regression: 'boolean' },
|
|
423
429
|
summary: 'Run the project\'s saved Agent test cases against the local runtime',
|
|
424
|
-
examples: ['frontera local test', 'frontera local test --agent claims-triage', 'frontera local test --live'],
|
|
430
|
+
examples: ['frontera local test', 'frontera local test --agent claims-triage', 'frontera local test --live', 'frontera local test --regression'],
|
|
425
431
|
offline: true,
|
|
426
432
|
internal: true,
|
|
427
433
|
},
|
|
@@ -473,5 +479,7 @@ export const localCommands: readonly Command[] = [
|
|
|
473
479
|
localModelDefault,
|
|
474
480
|
localDocsMcp,
|
|
475
481
|
localSchema,
|
|
482
|
+
localData,
|
|
483
|
+
localSync,
|
|
476
484
|
localDaemon,
|
|
477
485
|
]
|