@frontera-sdk/cli 1.52.39 → 1.52.40
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 +48 -4
- 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/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 +331 -31
- 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 +875 -88
- 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 +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- 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/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -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 +211 -21
- 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 +180 -7
- 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 +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- 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-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- 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 +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- 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 +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- 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-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- 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/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 +24 -21
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { type ChildProcess, spawn } from 'node:child_process'
|
|
2
|
-
import { existsSync, lstatSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs'
|
|
3
|
-
import { join, sep } from 'node:path'
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join, posix, sep } from 'node:path'
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import { parse as parseYaml } from 'yaml'
|
|
6
|
+
|
|
7
|
+
import { CliError, UsageError } from '../../errors'
|
|
8
|
+
import { ensureProjectBunfig } from '../../local/local-scaffold'
|
|
9
|
+
import { llmParameterNotes } from '../../local/local-model-parameters'
|
|
10
|
+
import { findOrphans, orphanCalls, restoreIfArchived, retireDeleted, reviveFunction, runtimeItems } from '../../local/local-orphans'
|
|
11
|
+
import { adaptationOf, contentHash, refOfTarget, failureCodeAndHint, isEnvironmentNote, loadBuild, loadHashes, oneLine, promotedVersionOf, pruneBuild, recordBuild, recordSettingsDifferences, saveHashes, type BuildEntry } from '../../local/local-applied'
|
|
12
|
+
import { INHERITED_FROM_PULL } from '../../blueprint/action-rules'
|
|
13
|
+
import { actionsNotRunning, deployActions, ensureActionDraft, liveActionStates, localActionCalls, prepareActions, retireActions, staleActions, unpublishedActions, type ActionBuild } from '../../local/local-actions'
|
|
14
|
+
import { agentIdentity, hasAgentDraft, syncAgentName } from '../../local/local-agent-name'
|
|
15
|
+
import { agentFileErrors, agentFileWarnings, approvalBindingNotes, boundTools, isApprovalNote, procedureToolProblems } from '../../local/local-agent-checks'
|
|
9
16
|
import { classify, itemNeeds, type BuiltInKind } from '../../local/local-portability'
|
|
10
|
-
import { localProfileName } from '../../local/local-control'
|
|
17
|
+
import { localProfileName, rebindCopiedProject } from '../../local/local-control'
|
|
11
18
|
import {
|
|
12
19
|
allTargets,
|
|
13
20
|
blueprintObjectTypes,
|
|
@@ -17,32 +24,52 @@ import {
|
|
|
17
24
|
describeTarget,
|
|
18
25
|
followUpsFor,
|
|
19
26
|
targetFor,
|
|
27
|
+
unappliedFiles,
|
|
28
|
+
unappliedReason,
|
|
20
29
|
type SyncProfiles,
|
|
21
30
|
type SyncTarget,
|
|
22
31
|
} from '../../local/local-dev-sync'
|
|
23
|
-
import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout, editableSets, localDirectory, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild } from '../../local/local-dev-references'
|
|
32
|
+
import { applyBindings, applyKnowledge, applyPlugin, blueprintWithout, editableSets, embeddingsDiffer, localDirectory, mediaCatalogDatasetReason, pointMediaTypesAt, promoteWorkflow, resolveBlueprint, resolveTarget, type ObjectBuild, type ResolvedBlueprint } from '../../local/local-dev-references'
|
|
24
33
|
import { applyObjectSets, namesObjectSet, objectSetCalls } from '../../local/local-object-sets'
|
|
25
34
|
import { applyTheme, themeCalls } from '../../local/local-themes'
|
|
26
|
-
import { ensureMediaSets } from '../../local/local-media-sets'
|
|
27
|
-
import {
|
|
35
|
+
import { ensureMediaSets, mediaUpload } from '../../local/local-media-sets'
|
|
36
|
+
import { fillPlaceholderMedia } from '../../local/local-media-placeholders'
|
|
37
|
+
import { applyChatApp, keyCalls } from '../../local/local-chat-apps'
|
|
28
38
|
import { applyLessons } from '../../local/local-lessons'
|
|
29
39
|
import { applyStandIn, platformOnlyNames, readStandIn, standInNames } from '../../local/local-stand-ins'
|
|
30
40
|
import { projectProvides } from '../../local/local-adapt'
|
|
31
41
|
import { applyOperatingConfig } from '../../local/local-operating'
|
|
42
|
+
import { localEmbeddingsReachable } from '../../local/local-keys'
|
|
32
43
|
import { mirrorOrgSettings, settingsCalls } from '../../local/local-org-settings'
|
|
33
|
-
import { readSnapshot } from '../../local/local-sync-snapshot'
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
44
|
+
import { directoryOf, readSnapshot } from '../../local/local-sync-snapshot'
|
|
45
|
+
import { BUNDLE_ID_MAP_ENV, bundleIdMap, functionCodeFiles, hardCodedIdLine, hardCodedObjectTypeIds, platformObjectTypeIds } from '../../local/local-function-ids'
|
|
46
|
+
import { checkRunnable, functionSlugOf, mirrorFunctionState } from '../../local/local-function-state'
|
|
47
|
+
import { functionSecretNames, localSecretPresence, missingSecretLines, readProjectDoc, readProjectText, workflowSecretNames } from '../../local/local-secrets'
|
|
48
|
+
import { seedPersonas } from '../../local/local-personas'
|
|
49
|
+
import { PROJECT_SECRETS_FILE, projectSecretLines } from '../../local/local-project-secrets'
|
|
50
|
+
import { decideStepNotes, keepLiveCurrent, mirrorWorkflowState } from '../../local/local-workflow-state'
|
|
37
51
|
import type { Directory } from '../../local/local-sync-refs'
|
|
38
52
|
import { localProject, type LocalProject } from '../../local/local-project'
|
|
39
|
-
import { isAlive, readState, recordApplied } from '../../local/local-state'
|
|
53
|
+
import { isAlive, readState, recordApplied, recordApplying, type LocalCredentials } from '../../local/local-state'
|
|
40
54
|
import { applyErrorText } from '../../local/local-apply-error'
|
|
55
|
+
import { applyFailures, inheritedApplyLine, splitApplyFailures } from '../../local/local-apply-summary'
|
|
56
|
+
import { unchangedSincePull } from '../../local/local-check-inherited'
|
|
57
|
+
import { readWorkspaceGrants } from '../../local/local-workspace-grants'
|
|
58
|
+
import { ApplyWatchdog, stoppedReapplyingLine } from '../../local/local-apply-watchdog'
|
|
59
|
+
import { hasGeneratedSampleData, pulledSampleData, pulledSampleDataNote, remakeLines, remakeReason, remakeSampleData } from '../../local/local-sample-remake'
|
|
60
|
+
import { appsAwayByRestore } from '../../local/local-app-restore'
|
|
61
|
+
import { BASELINE_RECORD, readBaseline } from '../../local/local-db-snapshot'
|
|
62
|
+
import { canChangeRuntime, filesChangedTrigger, recordApply } from '../../local/local-apply-history'
|
|
63
|
+
import { failedForRuntime, RUNTIME_DOWN_ERROR, RUNTIME_STOPPED_LINE, runtimeAnswering } from '../../local/local-runtime-health'
|
|
64
|
+
import { noticeLines, projectNotices } from '../../local/local-project-notices'
|
|
65
|
+
import { refreshVendoredSdk, vendoredSdkState } from '../../local/local-vendored-sdk'
|
|
41
66
|
import { flagBool, type Command, type CommandContext } from '../types'
|
|
42
67
|
import { isSharedFunctionFile } from '../../local/local-function-shared'
|
|
43
68
|
import { applyApp } from '../../local/local-apps'
|
|
44
69
|
import { applyBrand, brandUpload } from '../../local/local-brand'
|
|
45
70
|
import { applyLibrary } from '../../local/local-library'
|
|
71
|
+
import { projectFileChecks } from '../../local/local-file-checks'
|
|
72
|
+
import { actionFailureLines, dedupeLines, hasIndexedPaths, nameIndexedPaths, refusedArtifact, refusedByRules, sourceSystemHint } from '../../local/local-blueprint-errors'
|
|
46
73
|
|
|
47
74
|
/**
|
|
48
75
|
* `frontera local dev`: the inner loop. Applies the whole project to the
|
|
@@ -55,6 +82,9 @@ import { applyLibrary } from '../../local/local-library'
|
|
|
55
82
|
|
|
56
83
|
const DEBOUNCE_MS = 300
|
|
57
84
|
|
|
85
|
+
/** How many Blueprint artifacts one apply may leave off for the runtime's refusals before it says the Blueprint failed. */
|
|
86
|
+
const MAX_REFUSED = 12
|
|
87
|
+
|
|
58
88
|
interface RunResult {
|
|
59
89
|
ok: boolean
|
|
60
90
|
output: string
|
|
@@ -62,11 +92,19 @@ interface RunResult {
|
|
|
62
92
|
missing?: string[]
|
|
63
93
|
unnamedActions?: number
|
|
64
94
|
/** The Blueprint: how the runtime holds each object type and governed Action. */
|
|
65
|
-
blueprint?: { objects: Record<string, ObjectBuild>; actions: Record<string, ActionBuild>; objectSets?: string[] }
|
|
95
|
+
blueprint?: { objects: Record<string, ObjectBuild>; actions: Record<string, ActionBuild>; objectSets?: string[]; objectSetIds?: Record<string, string>; parts?: Record<string, { reason: string; refused: boolean }> }
|
|
66
96
|
/** Saved object sets made again here under a new id: what names them is applied again. */
|
|
67
97
|
objectSetsAgain?: string[]
|
|
68
98
|
/** Governed Actions authored again under new ids: what names them is applied again. */
|
|
69
99
|
authoredAgain?: string[]
|
|
100
|
+
/** The failure in full, one line each, when every line is a thing to fix (each Action that didn't deploy, each failed check). */
|
|
101
|
+
failureLines?: string[]
|
|
102
|
+
/** A dataset got a new revision: the Blueprint is applied again so its object types read it. */
|
|
103
|
+
datasetRevised?: boolean
|
|
104
|
+
/** A workflow: the version the push stored its file as. */
|
|
105
|
+
version?: number
|
|
106
|
+
/** Output `warning:` lines that are the platform's findings on the files (an Agent's behavior settings): they hold wherever it runs. */
|
|
107
|
+
validationWarnings?: string[]
|
|
70
108
|
}
|
|
71
109
|
|
|
72
110
|
/** The commands running now, so stopping `local dev` stops them too. */
|
|
@@ -97,7 +135,7 @@ function withLocalAuth(args: string[], auth: LocalAuth | undefined): { args: str
|
|
|
97
135
|
}
|
|
98
136
|
|
|
99
137
|
/** Run this CLI once more, at low priority, in the solution folder. */
|
|
100
|
-
function runCli(project: LocalProject, command: string[], auth?: LocalAuth): Promise<RunResult> {
|
|
138
|
+
function runCli(project: LocalProject, command: string[], auth?: LocalAuth, extraEnv: Record<string, string> = {}): Promise<RunResult> {
|
|
101
139
|
const compiled = Bun.main.startsWith('/$bunfs') || Bun.main.startsWith('B:\\~BUN')
|
|
102
140
|
const self = compiled ? [process.execPath] : [process.execPath, Bun.main]
|
|
103
141
|
const { args, env } = withLocalAuth(command, auth)
|
|
@@ -105,7 +143,7 @@ function runCli(project: LocalProject, command: string[], auth?: LocalAuth): Pro
|
|
|
105
143
|
const child = spawn('nice', ['-n', '10', ...self, ...args], {
|
|
106
144
|
cwd: project.root,
|
|
107
145
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
108
|
-
env: { ...process.env, ...env, NO_COLOR: '1' },
|
|
146
|
+
env: { ...process.env, ...env, ...extraEnv, NO_COLOR: '1' },
|
|
109
147
|
})
|
|
110
148
|
children.add(child)
|
|
111
149
|
let output = ''
|
|
@@ -125,6 +163,8 @@ interface ApplyOptions {
|
|
|
125
163
|
publish: boolean
|
|
126
164
|
workspaceId: string
|
|
127
165
|
serviceUrl: string
|
|
166
|
+
/** A line said now, while an apply waits (a Function registering). */
|
|
167
|
+
note?: (line: string) => void
|
|
128
168
|
}
|
|
129
169
|
|
|
130
170
|
async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptions): Promise<RunResult> {
|
|
@@ -133,12 +173,25 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
133
173
|
// Names in the file (`function/refund-eligibility`) become this runtime's ids in a resolved copy.
|
|
134
174
|
let directory: Promise<Directory> | null = null
|
|
135
175
|
const lookup = () => (directory ??= creds ? localDirectory(opts.serviceUrl, creds) : Promise.resolve({}))
|
|
176
|
+
// What the Agent's files say that would apply in silence and fail in a chat (`local-agent-checks.ts`).
|
|
177
|
+
const agent = agentChecks(project, target)
|
|
178
|
+
// A Chat App, knowledge base or skill against its format: a key the platform drops is said, a wrong shape refused.
|
|
179
|
+
const formats = projectFileChecks(project.root, target as { kind: string; file?: string })
|
|
180
|
+
const checked = { errors: [...agent.errors, ...formats.errors], warnings: [...agent.warnings, ...formats.warnings] }
|
|
181
|
+
if (checked.errors.length) return { ok: false, output: [`error [USAGE]: ${describeTarget(target)} can't be applied as written`, ...checked.errors.map((e) => ` ${e}`), ...checked.warnings].join('\n') }
|
|
136
182
|
const resolved = await resolveTarget(project, target, lookup, creds?.workspaceId, creds?.organizationId)
|
|
137
183
|
// The project's own file, before a resolved copy takes its place.
|
|
138
184
|
const original = target as Extract<SyncTarget, { file: string }>
|
|
139
185
|
if (!resolved.ok) return { ok: false, output: resolved.output, missing: resolved.missing, unnamedActions: resolved.unnamedActions }
|
|
140
|
-
let notes = resolved.output
|
|
186
|
+
let notes = [...checked.warnings, resolved.output].filter(Boolean).join('\n')
|
|
141
187
|
const withNotes = (r: RunResult): RunResult => (notes ? { ...r, output: `${notes}\n${r.output}`.trim() } : r)
|
|
188
|
+
// An Agent or workflow archived here (its file was deleted and is back, or it was archived by hand): restored first,
|
|
189
|
+
// so the file reaches an item in service (`local-orphans.ts`).
|
|
190
|
+
if ((target.kind === 'agent' || target.kind === 'workflow') && creds) {
|
|
191
|
+
const call = orphanCalls(opts.serviceUrl, creds)
|
|
192
|
+
const line = call ? await restoreIfArchived(project, creds.organizationId, call, target.kind, target.slug) : null
|
|
193
|
+
if (line) notes = [notes, line].filter(Boolean).join('\n')
|
|
194
|
+
}
|
|
142
195
|
if (target.kind === 'operating') {
|
|
143
196
|
// Read fresh: the owner session is renewed on every runtime start.
|
|
144
197
|
if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
@@ -146,9 +199,9 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
146
199
|
}
|
|
147
200
|
if (target.kind === 'bindings') {
|
|
148
201
|
if (!creds) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
149
|
-
return applyBindings(project, target, opts.serviceUrl, creds)
|
|
202
|
+
return withNotes(await applyBindings(project, target, opts.serviceUrl, creds))
|
|
150
203
|
}
|
|
151
|
-
if (target.kind === 'knowledge') return applyKnowledge(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined)
|
|
204
|
+
if (target.kind === 'knowledge') return withNotes(await applyKnowledge(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined))
|
|
152
205
|
if (target.kind === 'theme') {
|
|
153
206
|
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
154
207
|
return applyTheme(project.root, target.name, themeCalls(opts.serviceUrl, creds))
|
|
@@ -156,7 +209,8 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
156
209
|
if (target.kind === 'chat-app') {
|
|
157
210
|
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
158
211
|
// The owner's session, as the platform's Chat App editor writes; the roster's Agents by this runtime's ids.
|
|
159
|
-
|
|
212
|
+
// The workspace key's draft too, so `frontera chat-app get` (which reads the key's own draft) shows what was applied.
|
|
213
|
+
return withNotes(await applyChatApp(project.root, target.name, themeCalls(opts.serviceUrl, creds), lookup, projectProvides(project, creds.organizationId), creds.workspaceKey ? keyCalls(opts.serviceUrl, creds) : undefined))
|
|
160
214
|
}
|
|
161
215
|
if (target.kind === 'app') {
|
|
162
216
|
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
@@ -177,49 +231,135 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
177
231
|
}
|
|
178
232
|
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
233
|
if (target.kind === 'plugin') return applyPlugin(project, target, profiles, (args) => runCli(project, args, opts.auth), creds ? { serviceUrl: opts.serviceUrl, creds } : undefined)
|
|
234
|
+
// A Media Set's catalog: a sample CSV can't publish into it, so the file is skipped, said (sample data makes none).
|
|
235
|
+
if (target.kind === 'dataset') {
|
|
236
|
+
const media = mediaCatalogDatasetReason(project.root, target.apiName)
|
|
237
|
+
if (media) return { ok: true, output: ` warning: dataset ${target.apiName} is skipped here: ${media}` }
|
|
238
|
+
}
|
|
180
239
|
if (resolved.file) target = { ...target, file: resolved.file } as SyncTarget
|
|
181
|
-
|
|
240
|
+
// A Function whose code hard-codes the workspace's object type ids (a pulled one): said, and its
|
|
241
|
+
// bundle here built with this runtime's ids in their place; the files keep the workspace's.
|
|
242
|
+
let bundleEnv: Record<string, string> = {}
|
|
243
|
+
if (target.kind === 'function') {
|
|
244
|
+
const hits = hardCodedObjectTypeIds(functionCodeFiles(project.root, target.file, target.key.endsWith('/')), platformObjectTypeIds(directoryOf(readSnapshot(project.root))))
|
|
245
|
+
if (hits.length) {
|
|
246
|
+
const map = bundleIdMap(hits, creds ? ((await lookup()) as Directory).objectType : undefined)
|
|
247
|
+
if (Object.keys(map).length) bundleEnv = { [BUNDLE_ID_MAP_ENV]: JSON.stringify(map) }
|
|
248
|
+
notes = [notes, ...hits.map((h) => ` warning: ${hardCodedIdLine(h, !!map[h.id.toLowerCase()])}`)].filter(Boolean).join('\n')
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const plain = commandFor(target, profiles)
|
|
252
|
+
let command = plain
|
|
182
253
|
// Where the applied Blueprint lives: the runtime's copy when it needed one.
|
|
183
254
|
let blueprintRoot = project.root
|
|
184
|
-
|
|
255
|
+
let blueprint: ResolvedBlueprint | null = null
|
|
185
256
|
// Governed Actions are deployed on this runtime only (D3): calls to it, with the project's own keys.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
257
|
+
let actionCalls: ReturnType<typeof localActionCalls> | null = null
|
|
258
|
+
// What the runtime refused of the Blueprint, one artifact at a time: each is left off and the
|
|
259
|
+
// rest applied again, so one type the runtime cannot hold never keeps the others off it.
|
|
260
|
+
const refused = new Map<string, string>()
|
|
261
|
+
const notesBefore = notes
|
|
262
|
+
let result: RunResult
|
|
263
|
+
for (;;) {
|
|
264
|
+
command = plain
|
|
265
|
+
blueprintRoot = project.root
|
|
266
|
+
notes = notesBefore
|
|
267
|
+
blueprint = target.kind === 'blueprint' ? resolveBlueprint(project, refused) : null
|
|
268
|
+
actionCalls = blueprint?.actions.length && creds && opts.publish ? localActionCalls(opts.serviceUrl, creds) : null
|
|
269
|
+
if (blueprint) {
|
|
270
|
+
// The Blueprint as this runtime can hold it; see `resolveBlueprint`.
|
|
271
|
+
if (blueprint.dir) {
|
|
272
|
+
command = [...command, '--dir', join(project.root, blueprint.dir)]
|
|
273
|
+
blueprintRoot = join(project.root, blueprint.dir, '..')
|
|
274
|
+
}
|
|
275
|
+
notes = [notes, ...blueprint.warnings].filter(Boolean).join('\n')
|
|
276
|
+
// A media-backed type over a set's catalog binds to an empty Media Set of that name, made first,
|
|
277
|
+
// and reads the dataset that set's catalog is here.
|
|
278
|
+
if (blueprint.mediaSets) {
|
|
279
|
+
if (!creds?.session) return { ok: false, output: 'error [LOCAL_NOT_SEEDED]: the runtime is not seeded yet' }
|
|
280
|
+
// This is the project's own runtime: each stand-in set is granted to its workspace.
|
|
281
|
+
const media = await ensureMediaSets(blueprint.mediaSets, themeCalls(opts.serviceUrl, creds), creds.workspaceId)
|
|
282
|
+
if (media.why) return { ok: false, output: `error [BLUEPRINT_MEDIA]: ${media.why}` }
|
|
283
|
+
if (media.warnings.length) notes = [notes, ...media.warnings.map((w) => ` warning: ${w}`)].filter(Boolean).join('\n')
|
|
284
|
+
pointMediaTypesAt(project, blueprint, media.catalogs)
|
|
285
|
+
// An empty stand-in set gets a few generated placeholder files, so the type's rows, links and file reads can be tried.
|
|
286
|
+
if (blueprint.mediaTypes) {
|
|
287
|
+
const placed = await fillPlaceholderMedia(project.root, blueprint.mediaTypes, themeCalls(opts.serviceUrl, creds), mediaUpload(opts.serviceUrl, creds))
|
|
288
|
+
if (placed.length) notes = [notes, ...placed].filter(Boolean).join('\n')
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (actionCalls) {
|
|
292
|
+
// A draft on the first schema cannot hold Actions.
|
|
293
|
+
const draft = await ensureActionDraft(actionCalls)
|
|
294
|
+
if (!draft.ok) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: ${draft.note}` }
|
|
295
|
+
if (draft.note) notes = [notes, ` warning: ${draft.note}`].filter(Boolean).join('\n')
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
result = withNotes(await runCli(project, command, opts.auth, bundleEnv))
|
|
299
|
+
if (blueprint && !result.ok && refused.size < MAX_REFUSED) {
|
|
300
|
+
const stop = refusedArtifact(result.output)
|
|
301
|
+
if (stop && !refused.has(stop.key)) {
|
|
302
|
+
refused.set(stop.key, stop.reason)
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
// Refused before any write, for a rule an Action or metric file breaks: each such file is left off.
|
|
306
|
+
const broken = refusedByRules(result.output).filter((r) => !refused.has(r.key))
|
|
307
|
+
if (broken.length) {
|
|
308
|
+
for (const r of broken) refused.set(r.key, r.reason)
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
break
|
|
313
|
+
}
|
|
208
314
|
const fallback = fallbackFor(target, profiles, project.root)
|
|
209
315
|
if (!result.ok && fallback?.when.test(result.output)) {
|
|
210
316
|
// An empty fallback means there was nothing more to do.
|
|
211
317
|
result = { ok: true, output: '' }
|
|
212
318
|
for (const args of fallback.then) {
|
|
213
319
|
result = await runCli(project, args, opts.auth)
|
|
214
|
-
if (!result.ok)
|
|
320
|
+
if (!result.ok) break
|
|
321
|
+
}
|
|
322
|
+
// What the resolved copy was applied with (a model swapped, a Plugin it went without) is said
|
|
323
|
+
// when the item is made here too, not only when it is applied again.
|
|
324
|
+
result = withNotes(result)
|
|
325
|
+
// A dataset whose definition changed (a column added, one now nullable): the CSV no longer
|
|
326
|
+
// fits its revision, so the definition becomes the dataset's next revision.
|
|
327
|
+
if (!result.ok && target.kind === 'dataset' && /requires a new revision/i.test(result.output)) {
|
|
328
|
+
const revised = await runCli(project, ['dataset', 'revise', '-f', target.file, '--profile', profiles.organization], opts.auth)
|
|
329
|
+
if (!revised.ok) return { ...revised, output: [result.output.trim(), revised.output.trim()].join('\n') }
|
|
330
|
+
// Each object type reading it is bound to the new revision (a binding is pinned to the revision it was made on).
|
|
331
|
+
const rebound = await rebindReaders(project, target.apiName, profiles, (args) => runCli(project, args, opts.auth))
|
|
332
|
+
if (!rebound.ok) return rebound
|
|
333
|
+
return { ok: true, output: [` cut a new revision of dataset ${target.apiName}: its CSV has empty values the last one refused`, rebound.output].filter(Boolean).join('\n'), datasetRevised: true }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Plugins this runtime doesn't have (a newer platform's): the Agent is applied without them, said once.
|
|
337
|
+
if (!result.ok && target.kind === 'agent') {
|
|
338
|
+
const without = withoutPlugins(project, target.file, result.output)
|
|
339
|
+
if (without) {
|
|
340
|
+
notes = [notes, ` warning: ${original.file} names the Plugin${without.names.length === 1 ? '' : 's'} ${without.names.join(', ')}, which this runtime doesn’t have; the Agent is applied without ${without.names.length === 1 ? 'it' : 'them'}`].filter(Boolean).join('\n')
|
|
341
|
+
target = { ...target, file: without.file }
|
|
342
|
+
result = withNotes(await runCli(project, commandFor(target, profiles), opts.auth))
|
|
215
343
|
}
|
|
216
344
|
}
|
|
345
|
+
// A Function whose build cannot find a file it imports from beside its folder: said as that, first.
|
|
346
|
+
if (!result.ok && target.kind === 'function') result = { ...result, output: missingImportsOutput(project, original.file, result.output) }
|
|
217
347
|
if (!result.ok) return result
|
|
348
|
+
if (target.kind === 'workflow') {
|
|
349
|
+
// The version this push stored the file as: what its tests run, and what a promotion makes live.
|
|
350
|
+
const version = pushedVersionOf(result.output)
|
|
351
|
+
if (version) result = { ...result, version }
|
|
352
|
+
}
|
|
218
353
|
if (target.kind === 'workflow' && creds) {
|
|
219
354
|
// Live in the workspace: live here too, with its triggers as they are there where that is safe.
|
|
355
|
+
const mirrored = await mirrorWorkflowState(project, target.slug, original.file, opts.serviceUrl, creds, result.version)
|
|
220
356
|
const lines = [
|
|
221
|
-
...
|
|
357
|
+
...mirrored,
|
|
358
|
+
// Not live in the workspace, but live here (an Agent's binding promoted it): live follows the file.
|
|
359
|
+
...(mirrored.some((l) => /promoted workflow/.test(l)) ? [] : await keepLiveCurrent(opts.serviceUrl, creds, target.slug, result.version)),
|
|
222
360
|
...decideStepNotes(project, original.file, creds),
|
|
361
|
+
// An llm node's `temperature` its model refuses: said now, not when the first run fails at it.
|
|
362
|
+
...llmParameterNotes(project.root, target.file, ((await lookup()) as Directory).model),
|
|
223
363
|
// The secrets its HTTP steps and webhook name: placeholders until set on this computer.
|
|
224
364
|
...(await missingSecretLines(workflowSecretNames(readProjectDoc(project, original.file)), `workflow ${target.slug}`, opts.serviceUrl, creds)),
|
|
225
365
|
]
|
|
@@ -228,8 +368,19 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
228
368
|
if (target.kind === 'function' && creds) {
|
|
229
369
|
// Switched off in the workspace: off here too; and the secrets it is granted, until set here.
|
|
230
370
|
const source = readProjectText(project, original.file)
|
|
371
|
+
// Switched off here when its file was deleted, and the file is back: switched on, before the workspace's state is mirrored.
|
|
372
|
+
const call = orphanCalls(opts.serviceUrl, creds)
|
|
373
|
+
const revived = call && functionSlugOf(target.key) ? await reviveFunction(project, creds.organizationId, call, functionSlugOf(target.key)!) : null
|
|
374
|
+
const mirrored = await mirrorFunctionState(project, target.key, opts.serviceUrl, creds)
|
|
375
|
+
// Not "applied" until the job engine can run it: a workflow started right after would not find it.
|
|
376
|
+
const runnable = await checkRunnable(opts.serviceUrl, creds, functionSlugOf(target.key) ?? original.file, { note: opts.note, ...runnerAt(project) })
|
|
377
|
+
// A runner registered it, so one is alive: the deploy's "no runner has polled for Ns" (a poll time a
|
|
378
|
+
// restored database or a wait between polls makes old) is not true here, and is not kept.
|
|
379
|
+
if (runnable.runnable === true) result = { ...result, output: result.output.split('\n').filter((l) => !isEnvironmentNote(l)).join('\n') }
|
|
231
380
|
const lines = [
|
|
232
|
-
...(
|
|
381
|
+
...(revived ? [revived] : []),
|
|
382
|
+
...mirrored,
|
|
383
|
+
...runnable.lines,
|
|
233
384
|
...(await missingSecretLines(functionSecretNames(source), `Function ${functionSlugOf(target.key) ?? original.file}`, opts.serviceUrl, creds)),
|
|
234
385
|
]
|
|
235
386
|
if (lines.length) result = { ...result, output: `${result.output}\n${lines.join('\n')}`.trim() }
|
|
@@ -240,6 +391,8 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
240
391
|
if (named) result = { ...result, output: `${result.output}\n${named}`.trim() }
|
|
241
392
|
}
|
|
242
393
|
let authoredAgain: string[] = []
|
|
394
|
+
// Object types whose editable fields were declared for their Actions below.
|
|
395
|
+
const editableDone = new Set<string>()
|
|
243
396
|
if (actionCalls && blueprint) {
|
|
244
397
|
const names = blueprint.actions.map((a) => a.apiName)
|
|
245
398
|
// A decision pins the Action's contract, its object type's fields included, and a first
|
|
@@ -252,6 +405,7 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
252
405
|
const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
|
|
253
406
|
const subjects = new Set(blueprint.actions.flatMap((a) => (a.fit.subject ? [a.fit.subject] : [])))
|
|
254
407
|
for (const [type, props] of editableSets(appliedDir, subjects)) {
|
|
408
|
+
editableDone.add(type)
|
|
255
409
|
const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
|
|
256
410
|
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
411
|
}
|
|
@@ -278,47 +432,251 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
|
|
|
278
432
|
const refused = await prepareActions(actionCalls, names, new Set(authoredAgain))
|
|
279
433
|
if (refused.size) return { ok: false, output: `error [BLUEPRINT_ACTIONS]: governed Action ${[...refused].map(([name, why]) => `${name}: ${why}`).join('; ')}` }
|
|
280
434
|
}
|
|
435
|
+
// Every other type's `backing.editable` too, before the publish: an editable type's rows carry
|
|
436
|
+
// their record version, which an Action on it (one to come, or an App's) submits.
|
|
437
|
+
if (blueprint && opts.publish) {
|
|
438
|
+
const appliedDir = blueprint.dir ? join(project.root, blueprint.dir) : join(project.root, 'blueprint')
|
|
439
|
+
for (const [type, props] of editableSets(appliedDir, null)) {
|
|
440
|
+
if (editableDone.has(type)) continue
|
|
441
|
+
const set = await runCli(project, ['blueprint', 'editable', type, '--set', props.join(','), '--profile', profiles.organization], opts.auth)
|
|
442
|
+
if (!set.ok && !/no change|already|unchanged/i.test(set.output)) result = { ...result, output: `${result.output}\n warning: ${type}'s editable fields (${props.join(', ')}) could not be declared: ${oneLine(applyErrorText(set.output))}` }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
281
445
|
const objectTypes = target.kind === 'blueprint' ? blueprintObjectTypes(blueprintRoot) : []
|
|
282
|
-
|
|
446
|
+
// The workspace's grants beyond read (`blueprint/workspace-grants.yaml`); a file that is wrong fails the Blueprint, said by line.
|
|
447
|
+
const workspaceGrants = target.kind === 'blueprint' ? readWorkspaceGrants(project.root) : null
|
|
448
|
+
if (workspaceGrants?.problems.length) return { ok: false, output: workspaceGrants.problems.map((p) => `error [WORKSPACE_GRANTS]: ${p}`).join('\n') }
|
|
449
|
+
const unknownTypes = (workspaceGrants?.grants ?? []).filter((g) => !objectTypes.includes(g.objectType)).map((g) => g.objectType)
|
|
450
|
+
if (unknownTypes.length) result = { ...result, output: `${result.output}\n warning: blueprint/workspace-grants.yaml names ${unknownTypes.join(', ')}, which the project's Blueprint doesn't hold here` }
|
|
451
|
+
for (const step of followUpsFor(target, profiles, { publish: opts.publish, workspaceId: opts.workspaceId, objectTypes, grants: workspaceGrants?.grants ?? [] })) {
|
|
283
452
|
const done = await runCli(project, step.args, opts.auth)
|
|
284
453
|
if (!done.ok && !step.alreadyDone?.test(done.output)) return done
|
|
285
454
|
}
|
|
286
455
|
if (blueprint) {
|
|
287
456
|
// After the publish: what writes only the Blueprint's records runs here; the rest stays switched off.
|
|
288
457
|
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 }
|
|
458
|
+
for (const [name, why] of Object.entries(blueprint.leftOffActions)) actions[name] = { status: 'left-off', reason: why, ...(blueprint.refusedActions?.includes(name) ? { refused: true } : {}) }
|
|
459
|
+
// Deployed as the workspace runs it, with what its owner fixes there: the apply said it (a `warning:`
|
|
460
|
+
// line naming the file), and it is kept on its build for `local status` and `local check`.
|
|
461
|
+
for (const [name, lines] of Object.entries(blueprint.inheritedWarnings ?? {})) {
|
|
462
|
+
const a = actions[name]
|
|
463
|
+
if (a && a.status !== 'left-off') actions[name] = { ...a, inherited: lines }
|
|
464
|
+
}
|
|
290
465
|
for (const [name, a] of Object.entries(actions)) {
|
|
291
466
|
if (a.status === 'runs') result = { ...result, output: `${result.output}\n governed Action ${name} runs on this runtime` }
|
|
292
|
-
|
|
467
|
+
// A failed deploy is not a warning: it fails the Blueprint below, so nothing later meets it as ACTION_FORBIDDEN first.
|
|
468
|
+
else if (a.status === 'switched-off') result = { ...result, output: `${result.output}\n warning: governed Action ${name} is switched off here: ${a.reason}` }
|
|
293
469
|
}
|
|
294
470
|
// Saved object sets, once the object types they read are published here.
|
|
295
471
|
let objectSets: string[] = []
|
|
296
472
|
let objectSetsAgain: string[] = []
|
|
473
|
+
let objectSetIds: Record<string, string> = {}
|
|
297
474
|
if (creds && opts.publish) {
|
|
298
475
|
const leftOff = new Set(Object.entries(blueprint.objects).flatMap(([name, o]) => (o.status === 'left-off' ? [name] : [])))
|
|
299
476
|
const sets = await applyObjectSets(project.root, objectSetCalls(opts.serviceUrl, creds), leftOff)
|
|
300
477
|
if (sets.lines.length || sets.warnings.length) result = { ...result, output: [result.output, ...sets.lines, ...sets.warnings].join('\n').trim() }
|
|
301
478
|
objectSets = sets.warnings.map(adaptationOf)
|
|
302
479
|
objectSetsAgain = sets.recreated
|
|
480
|
+
objectSetIds = sets.ids
|
|
303
481
|
}
|
|
304
482
|
result = {
|
|
305
483
|
...result,
|
|
306
|
-
blueprint: { objects: blueprint.objects, actions, ...(objectSets.length ? { objectSets } : {}) },
|
|
484
|
+
blueprint: { objects: blueprint.objects, actions, ...(objectSets.length ? { objectSets } : {}), ...(Object.keys(objectSetIds).length ? { objectSetIds } : {}), ...(blueprint.parts ? { parts: blueprint.parts } : {}) },
|
|
307
485
|
...(authoredAgain.length ? { authoredAgain } : {}),
|
|
308
486
|
...(objectSetsAgain.length ? { objectSetsAgain } : {}),
|
|
309
487
|
}
|
|
488
|
+
// A governed Action that should run here and did not deploy fails the Blueprint: the object
|
|
489
|
+
// types are published, but what names the Action would only meet ACTION_FORBIDDEN later.
|
|
490
|
+
const undeployed = actionFailureLines(actions, project.root)
|
|
491
|
+
if (undeployed.length) {
|
|
492
|
+
const n = Object.values(actions).filter((a) => a.status === 'failed').length
|
|
493
|
+
const failureLines = [`error [LOCAL_ACTION_NOT_DEPLOYED]: ${n} governed Action${n === 1 ? '' : 's'} didn’t deploy on the local runtime`, ...undeployed]
|
|
494
|
+
result = { ...result, ok: false, failureLines, output: [...failureLines, result.output].join('\n').trim() }
|
|
495
|
+
}
|
|
310
496
|
}
|
|
311
497
|
return result
|
|
312
498
|
}
|
|
313
499
|
|
|
500
|
+
/**
|
|
501
|
+
* A Function's build output, led by what it imports that the project lacks:
|
|
502
|
+
* a relative import the bundler could not resolve, named by its project path
|
|
503
|
+
* (`functions/shared/eligibility`). A pull writes such a file only when the
|
|
504
|
+
* Function's source in the workspace holds it; one deployed before deploys
|
|
505
|
+
* kept them does not, so the next step is said. The output as it is when no
|
|
506
|
+
* import is unresolved.
|
|
507
|
+
*/
|
|
508
|
+
export function missingImportsOutput(project: LocalProject, entry: string, output: string): string {
|
|
509
|
+
const specs = [...new Set([...output.matchAll(/Could not resolve: "(\.{1,2}\/[^"]+)"/g)].map((m) => m[1]!))]
|
|
510
|
+
if (!specs.length) return output
|
|
511
|
+
const from = entry.split(sep).join('/')
|
|
512
|
+
const at = from.startsWith('.frontera/local/resolved/') ? from.slice('.frontera/local/resolved/'.length) : from
|
|
513
|
+
const paths = specs.map((s) => posix.normalize(posix.join(posix.dirname(at), s))).filter((p) => !existsSync(join(project.root, p)) && !['.ts', '.tsx', '.js', '/index.ts'].some((ext) => existsSync(join(project.root, `${p}${ext}`))))
|
|
514
|
+
if (!paths.length) return output
|
|
515
|
+
const named = paths.length === 1 ? paths[0]! : `${paths.slice(0, -1).join(', ')} and ${paths[paths.length - 1]}`
|
|
516
|
+
return [
|
|
517
|
+
`error [FUNCTION_IMPORT_MISSING]: imports ${named}, which ${paths.length === 1 ? 'isn’t' : 'aren’t'} in the project (from ${at})`,
|
|
518
|
+
` hint: a pull brings what a Function imports from beside its folder when its source in the workspace holds it. If it doesn’t (deployed before deploys kept those files), deploy the Function again from its source with a current CLI and pull it, or add ${paths.length === 1 ? 'the file' : 'the files'} here`,
|
|
519
|
+
output,
|
|
520
|
+
].join('\n')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* An Agent's file without the Plugins the runtime refused (`invalid_plugins:
|
|
525
|
+
* decision`), as a copy under `.frontera/local/resolved/`: the names and the
|
|
526
|
+
* copy's path, or null when the refusal is not that, or names none the file has.
|
|
527
|
+
*/
|
|
528
|
+
export function withoutPlugins(project: LocalProject, file: string, output: string): { names: string[]; file: string } | null {
|
|
529
|
+
const m = /invalid_plugins: ([^\n]+)/.exec(output)
|
|
530
|
+
if (!m) return null
|
|
531
|
+
const refused = new Set(m[1]!.split(',').map((n) => n.trim()).filter(Boolean))
|
|
532
|
+
let doc: { config?: { plugins?: unknown } }
|
|
533
|
+
try {
|
|
534
|
+
doc = JSON.parse(readFileSync(join(project.root, file), 'utf8'))
|
|
535
|
+
} catch {
|
|
536
|
+
return null
|
|
537
|
+
}
|
|
538
|
+
const plugins = Array.isArray(doc.config?.plugins) ? (doc.config!.plugins as unknown[]) : []
|
|
539
|
+
const names = plugins.filter((p): p is string => typeof p === 'string' && refused.has(p))
|
|
540
|
+
if (!names.length) return null
|
|
541
|
+
doc.config!.plugins = plugins.filter((p) => !(typeof p === 'string' && refused.has(p)))
|
|
542
|
+
const copy = file.startsWith(join('.frontera', 'local', 'resolved')) ? file : join('.frontera', 'local', 'resolved', file)
|
|
543
|
+
mkdirSync(join(project.root, copy, '..'), { recursive: true })
|
|
544
|
+
writeFileSync(join(project.root, copy), `${JSON.stringify(doc, null, 2)}\n`)
|
|
545
|
+
return { names, file: copy }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** A warning the workflow service's validation gave about a step (`node "ask": …`) or the output (`output: …`), as `workflow push` prints it. */
|
|
549
|
+
export function isValidationWarning(line: string): boolean {
|
|
550
|
+
return /^\s*warning: (node "[^"]+"|output):/.test(line)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The lines of an apply's output `local dev` says: each warning, and each
|
|
555
|
+
* promotion of a workflow. Indented or not: an output put together from
|
|
556
|
+
* several commands is trimmed, which takes the indent off its first line (a
|
|
557
|
+
* push's first validation warning, before its "pushed draft" line).
|
|
558
|
+
*/
|
|
559
|
+
export function saidLines(output: string): string[] {
|
|
560
|
+
// `note:` says what an apply did that is not an adaptation (documents being chunked again).
|
|
561
|
+
return output.split('\n').filter((line) => /^\s*(warning: |note: |promoted workflow )/.test(line)).map((line) => ` ${line.trim()}`)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** `hello: pushed draft v3` → 3: the version a `workflow push` stored. */
|
|
565
|
+
export function pushedVersionOf(output: string): number | undefined {
|
|
566
|
+
const m = /:\s+pushed draft v(\d+)\b/.exec(output)
|
|
567
|
+
return m ? Number(m[1]) : undefined
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Bind every object type that reads a dataset to the dataset's current
|
|
572
|
+
* revision, with the mapping in its file: after `dataset revise`, the types
|
|
573
|
+
* still read the revision their binding was made on. The binding's tree write
|
|
574
|
+
* goes to a folder that holds no object types, so the project's files stay
|
|
575
|
+
* as they are.
|
|
576
|
+
*/
|
|
577
|
+
async function rebindReaders(project: LocalProject, dataset: string, profiles: SyncProfiles, run: (args: string[]) => Promise<RunResult>): Promise<RunResult> {
|
|
578
|
+
const dir = join(project.root, 'blueprint', 'object-types')
|
|
579
|
+
const scratch = join(project.root, '.frontera', 'local', 'resolved', 'rebind')
|
|
580
|
+
const lines: string[] = []
|
|
581
|
+
let names: string[] = []
|
|
582
|
+
try {
|
|
583
|
+
names = readdirSync(dir).filter((f) => /\.(ya?ml|json)$/.test(f) && !f.startsWith('.'))
|
|
584
|
+
} catch {
|
|
585
|
+
return { ok: true, output: '' }
|
|
586
|
+
}
|
|
587
|
+
for (const file of names.sort()) {
|
|
588
|
+
let doc: { apiName?: unknown; backing?: { dataset?: unknown; mapping?: unknown } } | null
|
|
589
|
+
try {
|
|
590
|
+
doc = parseYaml(readFileSync(join(dir, file), 'utf8')) as typeof doc
|
|
591
|
+
} catch {
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
594
|
+
if (typeof doc?.apiName !== 'string' || doc.backing?.dataset !== dataset || !doc.backing.mapping || typeof doc.backing.mapping !== 'object') continue
|
|
595
|
+
mkdirSync(scratch, { recursive: true })
|
|
596
|
+
const mappingFile = join(scratch, `${doc.apiName}.json`)
|
|
597
|
+
writeFileSync(mappingFile, `${JSON.stringify({ mapping: doc.backing.mapping }, null, 2)}\n`)
|
|
598
|
+
const bound = await run(['blueprint', 'bind', doc.apiName, '--dataset', dataset, '--file', mappingFile, '--accept-contract-change', '--dir', join(scratch, 'tree'), '--profile', profiles.organization])
|
|
599
|
+
if (!bound.ok) return { ...bound, output: `error [BLUEPRINT_REBIND]: ${doc.apiName} could not be bound to the new revision of ${dataset}\n${bound.output.trim()}` }
|
|
600
|
+
lines.push(` bound ${doc.apiName} to the new revision of ${dataset}`)
|
|
601
|
+
}
|
|
602
|
+
return { ok: true, output: lines.join('\n') }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** The local runner's loopback address, to ask it to sync a deploy at once. */
|
|
606
|
+
function runnerAt(project: LocalProject): { runnerUrl?: string } {
|
|
607
|
+
const port = readState(project)?.ports.runner
|
|
608
|
+
return port ? { runnerUrl: `http://127.0.0.1:${port}` } : {}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** The governed Actions the project's Blueprint deploys here that the runtime does not run, read live. */
|
|
612
|
+
async function unchangedActionsDown(project: LocalProject, serviceUrl: string, creds: LocalCredentials, organizationId: string): Promise<Array<{ apiName: string; why: string }>> {
|
|
613
|
+
// An Action the runtime refused from these same files stays off however often the Blueprint is
|
|
614
|
+
// applied again: only a change to the files can bring it up, so it is not a reason to apply.
|
|
615
|
+
const recorded = loadBuild(project, organizationId).blueprint
|
|
616
|
+
const sameFiles = recorded?.hash !== undefined && recorded.hash === contentHash(project, { kind: 'blueprint', key: 'blueprint' })
|
|
617
|
+
const refusedHere = new Set(sameFiles ? Object.entries(recorded?.actions ?? {}).filter(([, a]) => a.status === 'left-off' || a.status === 'failed').map(([name]) => name) : [])
|
|
618
|
+
const expected = resolveBlueprint(project).actions.filter((a) => a.fit.runsLocally && !refusedHere.has(a.apiName)).map((a) => a.apiName)
|
|
619
|
+
if (!expected.length) return []
|
|
620
|
+
try {
|
|
621
|
+
return actionsNotRunning(expected, await liveActionStates(localActionCalls(serviceUrl, creds)))
|
|
622
|
+
} catch {
|
|
623
|
+
// Not readable now: the files decide, as before.
|
|
624
|
+
return []
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The checks on an Agent's own files before they are applied: errors refuse the file, warnings travel with it. */
|
|
629
|
+
function agentChecks(project: LocalProject, target: SyncTarget): { errors: string[]; warnings: string[] } {
|
|
630
|
+
if (target.kind !== 'agent' && target.kind !== 'operating' && target.kind !== 'bindings') return { errors: [], warnings: [] }
|
|
631
|
+
let doc: unknown
|
|
632
|
+
try {
|
|
633
|
+
doc = JSON.parse(readFileSync(join(project.root, target.file), 'utf8'))
|
|
634
|
+
} catch {
|
|
635
|
+
// Unreadable: the apply says so with the file's own error.
|
|
636
|
+
return { errors: [], warnings: [] }
|
|
637
|
+
}
|
|
638
|
+
if (target.kind === 'agent') return { errors: agentFileErrors(doc, target.slug), warnings: agentFileWarnings(doc, target.slug) }
|
|
639
|
+
if (target.kind === 'bindings') return { errors: [], warnings: approvalBindingNotes(project.root, target.slug, doc) }
|
|
640
|
+
return procedureToolProblems(doc, target.slug, boundTools(project.root, target.slug))
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* A failed Blueprint's output against the project's files: each indexed path
|
|
645
|
+
* (`actions.1.concurrency.strategy`) named by its file, read from the draft the
|
|
646
|
+
* runtime holds, and each issue said once.
|
|
647
|
+
*/
|
|
648
|
+
async function blueprintFailureOutput(project: LocalProject, output: string, serviceUrl: string): Promise<string> {
|
|
649
|
+
let text = output
|
|
650
|
+
const creds = readState(project)?.credentials
|
|
651
|
+
if (creds && hasIndexedPaths(text)) {
|
|
652
|
+
try {
|
|
653
|
+
const lifecycle = await localActionCalls(serviceUrl, creds)('org', 'GET', '/v1/blueprint/lifecycle')
|
|
654
|
+
const body = lifecycle.body as { data?: { draft?: { definition?: unknown } }; draft?: { definition?: unknown } } | null
|
|
655
|
+
const definition = body?.data?.draft?.definition ?? body?.draft?.definition
|
|
656
|
+
if (lifecycle.status < 300 && definition) text = nameIndexedPaths(text, definition, project.root)
|
|
657
|
+
} catch {
|
|
658
|
+
// Unnamed paths are still the runtime's words.
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const lines = dedupeLines(text.split('\n'))
|
|
662
|
+
// Source systems have no file: say the one name an Action on the Blueprint's records gives.
|
|
663
|
+
const hint = sourceSystemHint(text)
|
|
664
|
+
if (hint && !text.includes(hint)) lines.push(`hint: ${hint}`)
|
|
665
|
+
return lines.join('\n')
|
|
666
|
+
}
|
|
667
|
+
|
|
314
668
|
/**
|
|
315
669
|
* What a target was applied with, in words: the warnings the apply printed
|
|
316
670
|
* (a Plugin it went without, a model swapped) and what it needs that the
|
|
317
671
|
* runtime gives it differently (no computer; a governed Action switched off
|
|
318
672
|
* here, as the last Blueprint build left it).
|
|
319
673
|
*/
|
|
320
|
-
function adaptationsFor(project: LocalProject, target: SyncTarget, output: string, organizationId: string): { adaptations: string[]; stubbed: boolean } {
|
|
321
|
-
|
|
674
|
+
function adaptationsFor(project: LocalProject, target: SyncTarget, output: string, organizationId: string, validationWarnings: string[] = []): { adaptations: string[]; stubbed: boolean } {
|
|
675
|
+
// A workflow's validation warnings (`node "ask": …`, from the push) and the platform's findings on
|
|
676
|
+
// an Agent's behavior settings are said, but they hold wherever it runs: not adaptations to this runtime.
|
|
677
|
+
const validation = new Set(validationWarnings.map((l) => l.trim()))
|
|
678
|
+
// An Action's warning inherited from the workspace holds there too: the Action's, not an adaptation here.
|
|
679
|
+
const fromOutput = output.split('\n').filter((l) => /^\s*warning: /.test(l) && !(target.kind === 'workflow' && isValidationWarning(l)) && !validation.has(l.trim()) && !isApprovalNote(l) && !l.includes(INHERITED_FROM_PULL)).map(adaptationOf)
|
|
322
680
|
if (target.kind === 'blueprint' || target.kind === 'dataset') return { adaptations: [...new Set(fromOutput)], stubbed: false }
|
|
323
681
|
let files: Record<string, string> = {}
|
|
324
682
|
try {
|
|
@@ -340,11 +698,20 @@ function adaptationsFor(project: LocalProject, target: SyncTarget, output: strin
|
|
|
340
698
|
...[...platformOnly].map((n) => [n, 'analytics'] as const),
|
|
341
699
|
])
|
|
342
700
|
const portability = classify(itemNeeds(kind, files, new Set([...standIns, ...platformOnly]), builtIns), { actions, titles })
|
|
343
|
-
|
|
701
|
+
// A Chat App that published here (its Agents were published) is no longer the owner's draft.
|
|
702
|
+
const published = target.kind === 'chat-app' && /^\s+published Chat App /m.test(output)
|
|
703
|
+
const judged = portability.adaptations
|
|
704
|
+
.map((a) => (a.charAt(0).toUpperCase() + a.slice(1)).replace('/apps/<slug>', target.kind === 'chat-app' ? `/apps/${target.name}` : '/apps/<slug>'))
|
|
705
|
+
.filter((a) => !(published && /^Saved as the owner’s draft/.test(a)))
|
|
344
706
|
const stubbed = portability.class === 'stubbed' || (target.kind === 'plugin' && target.file.endsWith('.stand-in.json') && readStandIn(project.root, target.name)?.serves !== 'documentation')
|
|
345
707
|
return { adaptations: [...new Set([...judged, ...fromOutput])], stubbed }
|
|
346
708
|
}
|
|
347
709
|
|
|
710
|
+
/** What the build record keeps of a Blueprint apply: each type, Action and left-off part, and the saved sets' ids. */
|
|
711
|
+
function blueprintRecord(b: NonNullable<RunResult['blueprint']>): Pick<BuildEntry, 'objects' | 'actions' | 'objectSetIds' | 'parts'> {
|
|
712
|
+
return { objects: b.objects, actions: b.actions, ...(b.objectSetIds ? { objectSetIds: b.objectSetIds } : {}), ...(b.parts ? { parts: b.parts } : {}) }
|
|
713
|
+
}
|
|
714
|
+
|
|
348
715
|
/** The Blueprint's own adaptations, one line each: what is left off, and the Actions that do not run here. */
|
|
349
716
|
function blueprintAdaptations(b: NonNullable<RunResult['blueprint']>): string[] {
|
|
350
717
|
const out: string[] = []
|
|
@@ -355,10 +722,34 @@ function blueprintAdaptations(b: NonNullable<RunResult['blueprint']>): string[]
|
|
|
355
722
|
if (a.status === 'failed') out.push(`Governed Action ${name} didn’t deploy: ${a.reason}`)
|
|
356
723
|
if (a.status === 'left-off') out.push(`Governed Action ${name} is left off: ${a.reason}`)
|
|
357
724
|
}
|
|
725
|
+
for (const [ref, p] of Object.entries(b.parts ?? {})) out.push(`${ref.replace('/', ' ').replace(/^linkType /, 'Link type ').replace(/^metric /, 'Metric ').replace(/^semanticType /, 'Semantic type ').replace(/^sharedField /, 'Shared field ')} is left off: ${p.reason}`)
|
|
358
726
|
out.push(...(b.objectSets ?? []))
|
|
359
727
|
return out
|
|
360
728
|
}
|
|
361
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Sample data an older generator made (`remakeReason`), made again before the
|
|
732
|
+
* first apply, so each dataset applies once, as made now. The lines to say;
|
|
733
|
+
* none when there is nothing to remake, or `--keep-sample-data`. A pulled
|
|
734
|
+
* project's sample data that the pull wrote (`pulledSampleData`) is not made
|
|
735
|
+
* again unasked: a tracked file a first apply changes is a change nobody
|
|
736
|
+
* made. One line says newer rules exist, with the command that makes it
|
|
737
|
+
* again (`--remake-sample-data`, `asked`).
|
|
738
|
+
*/
|
|
739
|
+
export async function remakeStaleSampleData(root: string, keep: boolean, asked = false): Promise<string[]> {
|
|
740
|
+
if (keep) return []
|
|
741
|
+
const why = remakeReason(root, false)
|
|
742
|
+
if (!why) return []
|
|
743
|
+
if (!asked && pulledSampleData(root)) return hasGeneratedSampleData(root) ? [pulledSampleDataNote(root)] : []
|
|
744
|
+
try {
|
|
745
|
+
const remade = await remakeSampleData(root, why)
|
|
746
|
+
return remade ? remakeLines(remade) : []
|
|
747
|
+
} catch (err) {
|
|
748
|
+
// The datasets apply as they are; the remake is said, never a reason to stop.
|
|
749
|
+
return [` warning: sample data wasn’t made again (${why}): ${(err as Error).message}`]
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
362
753
|
/**
|
|
363
754
|
* The bindings an Agent could not get because the Function, workflow or
|
|
364
755
|
* object type (its Context and data) is not on the runtime yet.
|
|
@@ -367,12 +758,22 @@ export function missingFromBindings(output: string): string[] {
|
|
|
367
758
|
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
759
|
}
|
|
369
760
|
|
|
761
|
+
/** How the runtime's embeddings stood against the workspace's, in words, for the line that applies a knowledge base again. */
|
|
762
|
+
function embeddingWords(e: NonNullable<BuildEntry['embeddings']>): string {
|
|
763
|
+
return e === 'keyless' ? 'the keyless local embeddings' : e === 'same' ? 'the workspace’s model' : e === 'workspace' ? 'another model than the workspace’s' : 'another model than the platform’s default'
|
|
764
|
+
}
|
|
765
|
+
|
|
370
766
|
async function dev(ctx: CommandContext) {
|
|
767
|
+
// `--strict` decides what one apply exits with; a watcher has no exit to decide.
|
|
768
|
+
if (flagBool(ctx, 'strict') && !flagBool(ctx, 'once')) throw new UsageError('--strict needs --once', 'frontera local dev --once --strict')
|
|
371
769
|
const project = localProject(ctx.cwd)
|
|
372
770
|
const state = readState(project)
|
|
373
771
|
if (!state || state.phase !== 'ready' || !state.supervisorPid || !isAlive(state.supervisorPid)) {
|
|
374
772
|
throw new CliError('the local runtime is not running', { code: 'LOCAL_NOT_RUNNING', hint: 'run `frontera local start` first' })
|
|
375
773
|
}
|
|
774
|
+
// A copy of a project carries the binding it was made under: bound here to this runtime instead (`copiedLocalBinding`).
|
|
775
|
+
const rebound = rebindCopiedProject(project)
|
|
776
|
+
if (rebound) ctx.output.note(rebound)
|
|
376
777
|
// The profile was registered when the runtime started; applying needs only the keys in the project's state.
|
|
377
778
|
const workspace = localProfileName(project)
|
|
378
779
|
const profiles: SyncProfiles = { workspace, organization: `${workspace}-org`.slice(0, 64) }
|
|
@@ -387,6 +788,7 @@ async function dev(ctx: CommandContext) {
|
|
|
387
788
|
publish: !flagBool(ctx, 'no-publish'),
|
|
388
789
|
workspaceId: state.credentials!.workspaceId,
|
|
389
790
|
serviceUrl,
|
|
791
|
+
note: (line) => ctx.output.note(line),
|
|
390
792
|
}
|
|
391
793
|
|
|
392
794
|
const organizationId = state.credentials!.organizationId
|
|
@@ -402,7 +804,7 @@ async function dev(ctx: CommandContext) {
|
|
|
402
804
|
const seen = JSON.stringify(synced)
|
|
403
805
|
if (seen === settingsSeen) return
|
|
404
806
|
settingsSeen = seen
|
|
405
|
-
const mirrored = await mirrorOrgSettings(synced, settingsCalls(serviceUrl, creds)).catch(() => null)
|
|
807
|
+
const mirrored = await mirrorOrgSettings(synced, settingsCalls(serviceUrl, creds), { localEmbeddings: localEmbeddingsReachable() }).catch(() => null)
|
|
406
808
|
if (mirrored) {
|
|
407
809
|
for (const line of mirrored.lines) ctx.output.note(line)
|
|
408
810
|
for (const d of mirrored.differences) ctx.output.note(` differs from the workspace: ${d}`)
|
|
@@ -418,36 +820,82 @@ async function dev(ctx: CommandContext) {
|
|
|
418
820
|
const promoteAfter = new Set<string>()
|
|
419
821
|
// Agents whose row name was checked against their file in this session.
|
|
420
822
|
const named = new Set<string>()
|
|
421
|
-
|
|
823
|
+
// What is failing now, by target, for `--once --json`: the item and its error.
|
|
824
|
+
const failing = new Map<string, { item: string; error: string }>()
|
|
825
|
+
// What each item's last apply warned, by target, for `--once --json` (and the MCP `apply`): the item and its lines.
|
|
826
|
+
const warned = new Map<string, { item: string; warnings: string[] }>()
|
|
422
827
|
// Whether anything applied since the failed targets were last tried.
|
|
423
828
|
let progressed = false
|
|
424
829
|
let busy = false
|
|
425
830
|
let stopping = false
|
|
426
831
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
832
|
+
// An item applied again and again with no change to its files is a bug: stopped, and said (`ApplyWatchdog`).
|
|
833
|
+
const watchdog = new ApplyWatchdog()
|
|
834
|
+
let drains = 0
|
|
835
|
+
// Items that failed because the runtime wasn't answering: applied again once it answers.
|
|
836
|
+
const waiting = new Map<string, SyncTarget>()
|
|
837
|
+
// Set by an apply that found the runtime down: the drain stops, and what is queued waits for it.
|
|
838
|
+
let runtimeDown = false
|
|
839
|
+
// What starts the next round of applies, for Activity: the files queued since the last one, or a reason of its own.
|
|
840
|
+
const changedFiles = new Set<string>()
|
|
841
|
+
let nextTrigger: string | null = null
|
|
842
|
+
/**
|
|
843
|
+
* The round under way, once it applies something: a round that finds every
|
|
844
|
+
* queued item unchanged applies nothing, so it never says "Applying" and is
|
|
845
|
+
* never recorded as an apply.
|
|
846
|
+
*/
|
|
847
|
+
let round: { since: string | null; trigger: string; applied: number; failed: number } = { since: null, trigger: '', applied: 0, failed: 0 }
|
|
848
|
+
const markApplying = () => {
|
|
849
|
+
if (round.since) return
|
|
850
|
+
round.since = new Date().toISOString()
|
|
851
|
+
// Forge and Studio say "Applying…" until the queue is done, not a half-made count as the result.
|
|
852
|
+
recordApplying(project, { since: round.since, queued: pending.size + 1, pid: process.pid, trigger: round.trigger })
|
|
853
|
+
}
|
|
427
854
|
|
|
428
855
|
/** What one apply came to, for the build record Forge and Studio read (design §9). */
|
|
429
856
|
const buildEntry = (target: SyncTarget, hash: string | null, result: RunResult): BuildEntry => {
|
|
430
|
-
|
|
857
|
+
// A workflow: the version its last promotion here made live, this apply's or the one before.
|
|
858
|
+
const live = target.kind === 'workflow' ? (result.ok ? promotedVersionOf(result.output, target.slug) : undefined) ?? loadBuild(project, organizationId)[target.key]?.live : undefined
|
|
859
|
+
const base = { what: describeTarget(target), hash, at: new Date().toISOString(), ...(result.ok && result.version ? { version: result.version } : {}), ...(live ? { live } : {}) }
|
|
431
860
|
if (result.ok) {
|
|
432
861
|
// 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)
|
|
862
|
+
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, result.validationWarnings)
|
|
434
863
|
return {
|
|
435
864
|
...base,
|
|
436
865
|
status: stubbed ? 'stubbed' : adaptations.length ? 'adapted' : 'built',
|
|
437
866
|
...(adaptations.length ? { adaptations } : {}),
|
|
438
|
-
...(result.blueprint ?
|
|
867
|
+
...(result.blueprint ? blueprintRecord(result.blueprint) : {}),
|
|
439
868
|
}
|
|
440
869
|
}
|
|
441
870
|
const missing = result.missing?.length ? result.missing : target.kind === 'bindings' ? missingFromBindings(result.output) : []
|
|
442
871
|
if (missing.length || result.unnamedActions) return { ...base, status: 'blocked', needs: missing, ...(result.unnamedActions ? { unnamedActions: result.unnamedActions } : {}) }
|
|
443
|
-
|
|
872
|
+
// A Blueprint published with an Action that didn't deploy keeps how each type and Action came out.
|
|
873
|
+
return { ...base, status: 'failed', error: oneLine(applyErrorText(result.output)), ...failureCodeAndHint(result.output), ...(result.blueprint ? blueprintRecord(result.blueprint) : {}) }
|
|
444
874
|
}
|
|
445
875
|
|
|
446
876
|
const runOne = async (target: SyncTarget) => {
|
|
447
877
|
if (stopping) return
|
|
448
878
|
if (target.kind !== 'blueprint' && !existsSync(join(project.root, target.file))) return
|
|
449
879
|
const hash = contentHash(project, target)
|
|
450
|
-
|
|
880
|
+
// Unchanged files, but the runtime lost the Agent's draft (it was published): the draft is
|
|
881
|
+
// what its test cases and Studio's Draft chat run, so it is staged again from the files.
|
|
882
|
+
const draftGone = hash && applied.get(target.key) === hash && target.kind === 'agent' && (await hasAgentDraft({ slug: target.slug, serviceUrl, creds })) === false
|
|
883
|
+
if (draftGone) ctx.output.note(` ${describeTarget(target)} has no draft on the runtime (it was published): staging it again from ${target.file}`)
|
|
884
|
+
// Unchanged files, but a governed Action that should run here does not (a failed apply left it
|
|
885
|
+
// switched off, or its deployment no longer matches its contract): the Blueprint is applied again.
|
|
886
|
+
const actionsDown = hash && applied.get(target.key) === hash && target.kind === 'blueprint' && options.publish ? await unchangedActionsDown(project, serviceUrl, creds, organizationId) : []
|
|
887
|
+
for (const a of actionsDown) ctx.output.note(` governed Action ${a.apiName} doesn’t run on the runtime (${a.why}): applying the Blueprint again`)
|
|
888
|
+
// Unchanged, but applied before the build recorded which version the file was pushed as:
|
|
889
|
+
// pushed once more, so its tests run exactly that version (never a stale draft).
|
|
890
|
+
const recorded = loadBuild(project, organizationId)[target.key]
|
|
891
|
+
const versionUnknown = hash && applied.get(target.key) === hash && target.kind === 'workflow' && (!recorded || (recorded.status !== 'failed' && recorded.status !== 'blocked' && recorded.version === undefined))
|
|
892
|
+
if (versionUnknown) ctx.output.note(` ${describeTarget(target)}: pushing ${target.file} again to record the version it runs as`)
|
|
893
|
+
// A knowledge base's threshold follows the runtime's embeddings (`knowledgeSettings`): when they changed since
|
|
894
|
+
// it was applied (a key set, the keyless local embeddings in its place), it is applied again with unchanged files.
|
|
895
|
+
const embeddings = target.kind === 'knowledge' ? ((await embeddingsDiffer(project.root, { serviceUrl, creds }).catch((): false => false)) || ('same' as const)) : undefined
|
|
896
|
+
const embeddingsMoved = !!embeddings && !!hash && applied.get(target.key) === hash && (recorded?.embeddings ?? 'same') !== embeddings
|
|
897
|
+
if (embeddingsMoved) ctx.output.note(` ${describeTarget(target)}: the runtime’s embeddings changed since it was applied (${embeddingWords(recorded?.embeddings ?? 'same')} then, ${embeddingWords(embeddings!)} now): applying it again so its threshold follows`)
|
|
898
|
+
if (hash && applied.get(target.key) === hash && !draftGone && !actionsDown.length && !versionUnknown && !embeddingsMoved) {
|
|
451
899
|
unchanged += 1
|
|
452
900
|
// An Agent applied before names were set from its file still reads its slug: set it once.
|
|
453
901
|
if (target.kind === 'agent' && !named.has(target.slug)) {
|
|
@@ -461,26 +909,89 @@ async function dev(ctx: CommandContext) {
|
|
|
461
909
|
if (!loadBuild(project, organizationId)[target.key]) recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'built', hash, at: new Date().toISOString() })
|
|
462
910
|
return
|
|
463
911
|
}
|
|
912
|
+
const verdict = watchdog.before(target.key, hash, drains)
|
|
913
|
+
if (verdict !== 'apply') {
|
|
914
|
+
if (verdict === 'stop') {
|
|
915
|
+
const line = stoppedReapplyingLine(describeTarget(target), watchdog.applyLimit)
|
|
916
|
+
ctx.output.note(` ✕ ${line}`)
|
|
917
|
+
recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'blocked', needs: [], error: line, hash, at: new Date().toISOString() })
|
|
918
|
+
failing.set(target.key, { item: describeTarget(target), error: line })
|
|
919
|
+
}
|
|
920
|
+
return
|
|
921
|
+
}
|
|
464
922
|
const started = Date.now()
|
|
465
|
-
|
|
923
|
+
markApplying()
|
|
924
|
+
let result = await apply(project, target, options)
|
|
925
|
+
watchdog.after(target.key, result.ok)
|
|
926
|
+
if (result.ok) round.applied += 1
|
|
927
|
+
else round.failed += 1
|
|
466
928
|
if (stopping) return
|
|
929
|
+
// A failure while the runtime wasn't answering (stopped, restarting, its database going down
|
|
930
|
+
// or being restored) is not the files': said as that, and applied again when it answers.
|
|
931
|
+
if (!result.ok && (await failedForRuntime(project, result.output))) {
|
|
932
|
+
runtimeDown = true
|
|
933
|
+
if (applied.delete(target.key)) saveHashes(project, organizationId, applied)
|
|
934
|
+
waiting.set(target.key, target)
|
|
935
|
+
failing.set(target.key, { item: describeTarget(target), error: RUNTIME_DOWN_ERROR })
|
|
936
|
+
recordBuild(project, organizationId, target.key, { what: describeTarget(target), status: 'failed', error: RUNTIME_DOWN_ERROR, hash, at: new Date().toISOString() })
|
|
937
|
+
ctx.output.note(` ✕ ${describeTarget(target)}: ${RUNTIME_STOPPED_LINE}`)
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
if (!result.ok && target.kind === 'blueprint') result = { ...result, output: await blueprintFailureOutput(project, result.output, serviceUrl) }
|
|
467
941
|
const ms = Date.now() - started
|
|
468
942
|
recordApplied(project, { at: new Date().toISOString(), what: describeTarget(target), ok: result.ok })
|
|
469
|
-
recordBuild(project, organizationId, target.key, buildEntry(target, hash, result))
|
|
943
|
+
recordBuild(project, organizationId, target.key, { ...buildEntry(target, hash, result), ...(embeddings ? { embeddings } : {}) })
|
|
470
944
|
if (result.ok) {
|
|
471
945
|
// A draft-only Blueprint is not what a plain `local dev` would leave, so it is not recorded as applied.
|
|
472
946
|
if (target.kind === 'blueprint' && !options.publish) applied.delete(target.key)
|
|
473
947
|
else applied.set(target.key, hash)
|
|
474
948
|
saveHashes(project, organizationId, applied)
|
|
475
949
|
failed.delete(target.key)
|
|
950
|
+
failing.delete(target.key)
|
|
476
951
|
ctx.output.note(` ✓ ${describeTarget(target)} applied (${ms} ms)`)
|
|
952
|
+
if (target.kind === 'blueprint') personasDue = true
|
|
477
953
|
if (target.kind === 'workflow' && promoteAfter.delete(target.key)) {
|
|
478
|
-
const promoted = await promoteWorkflow(serviceUrl, creds, target.slug)
|
|
479
|
-
if (promoted)
|
|
954
|
+
const promoted = await promoteWorkflow(serviceUrl, creds, target.slug, result.version)
|
|
955
|
+
if (promoted) {
|
|
956
|
+
ctx.output.note(` promoted workflow ${target.slug} v${promoted}: its live version ran the Action's old id`)
|
|
957
|
+
const entry = loadBuild(project, organizationId)[target.key]
|
|
958
|
+
if (entry) recordBuild(project, organizationId, target.key, { ...entry, live: promoted })
|
|
959
|
+
}
|
|
480
960
|
}
|
|
481
961
|
// What it was applied without, or with instead (a missing model), said once here.
|
|
482
|
-
|
|
962
|
+
// A promotion is said too: what Agents and webhooks start changed.
|
|
963
|
+
const warnings = saidLines(result.output).filter((line) => /^\s*warning: /.test(line)).map((line) => line.trim().replace(/^warning: /, ''))
|
|
964
|
+
if (warnings.length) warned.set(target.key, { item: describeTarget(target), warnings })
|
|
965
|
+
else warned.delete(target.key)
|
|
966
|
+
for (const line of saidLines(result.output)) {
|
|
967
|
+
ctx.output.note(line)
|
|
968
|
+
const secret = /Needs a value for ([A-Z][A-Z0-9_]*)/.exec(line)?.[1]
|
|
969
|
+
if (secret) secretsSaid.add(secret)
|
|
970
|
+
}
|
|
971
|
+
if (result.datasetRevised) {
|
|
972
|
+
ctx.output.note(result.output.trim().replace(/^/, ' '))
|
|
973
|
+
// The object types reading it move to the new revision when the Blueprint is applied again.
|
|
974
|
+
applied.delete('blueprint')
|
|
975
|
+
pending.set('blueprint', { kind: 'blueprint', key: 'blueprint' })
|
|
976
|
+
}
|
|
483
977
|
progressed = true
|
|
978
|
+
// An Agent applied without this Function while it did not build gets it now.
|
|
979
|
+
const built = target.kind === 'function' ? functionSlugOf(target.key) : null
|
|
980
|
+
if (built) {
|
|
981
|
+
const record = loadBuild(project, organizationId)
|
|
982
|
+
for (const t of allTargets(project.root)) {
|
|
983
|
+
if (t.kind !== 'bindings' || record[t.key]?.status === 'built' || pending.has(t.key)) continue
|
|
984
|
+
let text = ''
|
|
985
|
+
try {
|
|
986
|
+
text = readFileSync(join(project.root, t.file), 'utf8')
|
|
987
|
+
} catch {
|
|
988
|
+
continue
|
|
989
|
+
}
|
|
990
|
+
if (!text.includes(`function/${built}"`)) continue
|
|
991
|
+
applied.delete(t.key)
|
|
992
|
+
pending.set(t.key, t)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
484
995
|
// Workflows and Agents hold the runtime's id of each Action they run: a new id means applying them again.
|
|
485
996
|
if (result.authoredAgain?.length || result.objectSetsAgain?.length) {
|
|
486
997
|
const names = result.authoredAgain ?? []
|
|
@@ -500,17 +1011,65 @@ async function dev(ctx: CommandContext) {
|
|
|
500
1011
|
}
|
|
501
1012
|
}
|
|
502
1013
|
} else {
|
|
1014
|
+
// A failed apply may have changed the runtime (an Action retired, switched off): the file is not
|
|
1015
|
+
// "unchanged" afterwards, even when it is restored to what last applied.
|
|
1016
|
+
if (applied.delete(target.key)) saveHashes(project, organizationId, applied)
|
|
503
1017
|
failed.set(target.key, target)
|
|
504
|
-
// A retry that fails the same way is not said
|
|
505
|
-
|
|
1018
|
+
// A retry that fails the same way is not said again in the same round of changes.
|
|
1019
|
+
// A governed Action's checks are each a line to fix: shown whole, not cut to the first seven lines.
|
|
1020
|
+
const text = result.failureLines ? result.failureLines.map((l, i) => (i ? ` ${l}` : l)).join('\n') : applyErrorText(result.output)
|
|
1021
|
+
failing.set(target.key, { item: describeTarget(target), error: result.failureLines?.join('\n') ?? applyErrorText(result.output).replace(/\n\s+/g, '\n') })
|
|
506
1022
|
if (said.get(target.key) !== text) {
|
|
507
|
-
failures += 1
|
|
508
1023
|
said.set(target.key, text)
|
|
509
1024
|
ctx.output.note(` ✕ ${describeTarget(target)}: ${text}`)
|
|
510
1025
|
}
|
|
511
1026
|
}
|
|
512
1027
|
}
|
|
513
1028
|
|
|
1029
|
+
/** Whether the local personas are to be seeded after this drain: at the start, and after each Blueprint apply. */
|
|
1030
|
+
let personasDue = true
|
|
1031
|
+
/** Whether the project's secrets are to be said after this drain: after a database restore. */
|
|
1032
|
+
let secretsDue = false
|
|
1033
|
+
/** Whether to deploy again the Custom Apps a database restore took away (`local-app-restore.ts`): at the start, and after a restore. */
|
|
1034
|
+
let appsDue = true
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* A Custom App deployed after the baseline a restore went back to is not on
|
|
1038
|
+
* the runtime: deployed again from its folder (`frontera app deploy
|
|
1039
|
+
* --build`), as it was. A failure keeps the mark: status says so, with the fix.
|
|
1040
|
+
*/
|
|
1041
|
+
const redeployRestoredApps = async () => {
|
|
1042
|
+
for (const app of appsAwayByRestore(project.root)) {
|
|
1043
|
+
if (stopping) return
|
|
1044
|
+
ctx.output.note(` App ${app.name}'s deployment ${app.deployment} went with the database restore: deploying it again (\`frontera app deploy --build\` in ${app.dir})`)
|
|
1045
|
+
const result = await runCli(project, ['app', 'deploy', '--build', '--dir', join(project.root, app.dir), '--profile', profiles.workspace], options.auth)
|
|
1046
|
+
ctx.output.note(result.ok ? ` ✓ App ${app.name} deployed again` : ` ✕ App ${app.name} wasn't deployed again: ${oneLine(applyErrorText(result.output))} — \`frontera app deploy\` in ${app.dir}`)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Secrets an apply in this round already said need a value: not said twice. */
|
|
1051
|
+
const secretsSaid = new Set<string>()
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Each secret `secrets.json` declares or a workflow or Function names that
|
|
1055
|
+
* the runtime has no value for (names and whether a value is set only; values
|
|
1056
|
+
* are never read or written): said on every start and after a database
|
|
1057
|
+
* restore, for unchanged items too, since a restore can leave a secret
|
|
1058
|
+
* listed without its value.
|
|
1059
|
+
*/
|
|
1060
|
+
const sayProjectSecrets = async () => {
|
|
1061
|
+
const presence = await localSecretPresence(serviceUrl, creds)
|
|
1062
|
+
for (const line of projectSecretLines(project.root, presence)) {
|
|
1063
|
+
const secret = /Needs a value for ([A-Z][A-Z0-9_]*)/.exec(line)?.[1]
|
|
1064
|
+
if (secret && secretsSaid.has(secret)) continue
|
|
1065
|
+
ctx.output.note(` ${line.trim()}`)
|
|
1066
|
+
}
|
|
1067
|
+
secretsSaid.clear()
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Files already said not to be applied, so a save does not say it again. */
|
|
1071
|
+
const unappliedSaid = new Set<string>()
|
|
1072
|
+
|
|
514
1073
|
/** What each failure last said in this drain, so a retry failing the same way is said once. */
|
|
515
1074
|
const said = new Map<string, string>()
|
|
516
1075
|
|
|
@@ -518,18 +1077,48 @@ async function dev(ctx: CommandContext) {
|
|
|
518
1077
|
const drain = async () => {
|
|
519
1078
|
if (busy) return
|
|
520
1079
|
busy = true
|
|
1080
|
+
// Why this round runs; "Applying" is said only once an item actually applies (`markApplying`).
|
|
1081
|
+
round = { since: null, trigger: nextTrigger ?? filesChangedTrigger(changedFiles), applied: 0, failed: 0 }
|
|
1082
|
+
nextTrigger = null
|
|
1083
|
+
changedFiles.clear()
|
|
521
1084
|
said.clear()
|
|
1085
|
+
drains += 1
|
|
1086
|
+
runtimeDown = false
|
|
1087
|
+
let resumed = 0
|
|
522
1088
|
try {
|
|
523
1089
|
for (;;) {
|
|
524
|
-
while (pending.size && !stopping) {
|
|
1090
|
+
while (pending.size && !stopping && !runtimeDown) {
|
|
525
1091
|
const next = [...pending.values()].sort(byApplyOrder)[0]!
|
|
526
1092
|
pending.delete(next.key)
|
|
527
1093
|
try {
|
|
528
1094
|
await runOne(next)
|
|
529
1095
|
} catch (err) {
|
|
530
|
-
|
|
1096
|
+
// A call before the apply (a lookup of the runtime's ids) that couldn't reach the runtime.
|
|
1097
|
+
if (!stopping && !(await runtimeAnswering(project))) {
|
|
1098
|
+
runtimeDown = true
|
|
1099
|
+
waiting.set(next.key, next)
|
|
1100
|
+
failing.set(next.key, { item: describeTarget(next), error: RUNTIME_DOWN_ERROR })
|
|
1101
|
+
ctx.output.note(` ✕ ${describeTarget(next)}: ${RUNTIME_STOPPED_LINE}`)
|
|
1102
|
+
} else ctx.output.note(` ✕ ${describeTarget(next)}: ${(err as Error).message}`)
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (runtimeDown) {
|
|
1106
|
+
// The rest waits for the runtime too, rather than each failing the same way.
|
|
1107
|
+
for (const [key, rest] of pending) {
|
|
1108
|
+
waiting.set(key, rest)
|
|
1109
|
+
failing.set(key, { item: describeTarget(rest), error: RUNTIME_DOWN_ERROR })
|
|
531
1110
|
}
|
|
1111
|
+
pending.clear()
|
|
532
1112
|
}
|
|
1113
|
+
// What failed while the runtime was down is applied again once it answers (at most twice a drain;
|
|
1114
|
+
// after that, a watching `local dev` asks again every few seconds).
|
|
1115
|
+
if (!stopping && waiting.size && resumed < 2 && (await runtimeAnswering(project))) {
|
|
1116
|
+
resumed += 1
|
|
1117
|
+
runtimeDown = false
|
|
1118
|
+
resumeWaiting()
|
|
1119
|
+
continue
|
|
1120
|
+
}
|
|
1121
|
+
if (runtimeDown) break
|
|
533
1122
|
// What failed is tried again once the queue is empty, and only while a round applied
|
|
534
1123
|
// something it may have been waiting for; otherwise it waits for its own file to change.
|
|
535
1124
|
if (stopping || !progressed || !failed.size) break
|
|
@@ -537,8 +1126,29 @@ async function dev(ctx: CommandContext) {
|
|
|
537
1126
|
for (const [key, retry] of failed) pending.set(key, retry)
|
|
538
1127
|
failed.clear()
|
|
539
1128
|
}
|
|
1129
|
+
// Once the Actions are on the runtime: a local persona per approval step, and a submitter.
|
|
1130
|
+
if (personasDue && !stopping && !runtimeDown && !failing.has('blueprint')) {
|
|
1131
|
+
personasDue = false
|
|
1132
|
+
for (const line of await seedPersonas(project.root, serviceUrl, creds)) ctx.output.note(line)
|
|
1133
|
+
}
|
|
1134
|
+
// After a restore's applies: the secrets that lost their value with it, unchanged items' included.
|
|
1135
|
+
if (secretsDue && !stopping && !runtimeDown) {
|
|
1136
|
+
secretsDue = false
|
|
1137
|
+
await sayProjectSecrets()
|
|
1138
|
+
}
|
|
1139
|
+
// And the Custom Apps it took away, once what they read is applied again.
|
|
1140
|
+
if (appsDue && !stopping && !runtimeDown) {
|
|
1141
|
+
appsDue = false
|
|
1142
|
+
await redeployRestoredApps()
|
|
1143
|
+
}
|
|
540
1144
|
} finally {
|
|
541
1145
|
busy = false
|
|
1146
|
+
if (round.since) {
|
|
1147
|
+
recordApply(project, { since: round.since, finished: new Date().toISOString(), trigger: round.trigger, applied: round.applied, failed: round.failed })
|
|
1148
|
+
recordApplying(project, null)
|
|
1149
|
+
}
|
|
1150
|
+
// A file saved after the queue emptied, before this round ended: its own round.
|
|
1151
|
+
if (pending.size && !stopping && !runtimeDown) schedule()
|
|
542
1152
|
}
|
|
543
1153
|
}
|
|
544
1154
|
|
|
@@ -547,16 +1157,88 @@ async function dev(ctx: CommandContext) {
|
|
|
547
1157
|
timer = setTimeout(() => void drain(), DEBOUNCE_MS)
|
|
548
1158
|
}
|
|
549
1159
|
|
|
550
|
-
/** What
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
1160
|
+
/** What waited for the runtime, queued again: said once. */
|
|
1161
|
+
const resumeWaiting = () => {
|
|
1162
|
+
if (!waiting.size) return
|
|
1163
|
+
ctx.output.note(` the local runtime is answering again: applying the ${waiting.size === 1 ? 'item' : `${waiting.size} items`} it missed`)
|
|
1164
|
+
nextTrigger ??= 'the runtime is answering again'
|
|
1165
|
+
for (const [key, target] of waiting) {
|
|
1166
|
+
pending.set(key, target)
|
|
1167
|
+
failing.delete(key)
|
|
1168
|
+
}
|
|
1169
|
+
waiting.clear()
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** A target queued by a file event (`rel`, the changed path): whatever it re-applied before starts over (`ApplyWatchdog`). */
|
|
1173
|
+
const queueFromFile = (target: SyncTarget, rel: string) => {
|
|
1174
|
+
pending.set(target.key, target)
|
|
1175
|
+
changedFiles.add(rel)
|
|
1176
|
+
watchdog.fileEvent(target.key)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* The database was restored to its baseline (`frontera local snapshot restore`, `local check`):
|
|
1181
|
+
* the record of what is applied was put back with it, so this watcher takes it too, and applies
|
|
1182
|
+
* again what it had applied since.
|
|
1183
|
+
*/
|
|
1184
|
+
let restoreSeen = readBaseline(project, organizationId)?.restoredAt
|
|
1185
|
+
const afterRestore = () => {
|
|
1186
|
+
const record = readBaseline(project, organizationId)
|
|
1187
|
+
if (!record?.restoredAt || record.restoredAt === restoreSeen) return
|
|
1188
|
+
restoreSeen = record.restoredAt
|
|
1189
|
+
const now = loadHashes(project, organizationId)
|
|
1190
|
+
const changed = new Set([...applied.keys(), ...now.keys()].filter((k) => applied.get(k) !== now.get(k)))
|
|
1191
|
+
applied.clear()
|
|
1192
|
+
for (const [k, v] of now) applied.set(k, v)
|
|
1193
|
+
// A Custom App deployed after the baseline went with it: deployed again after this round.
|
|
1194
|
+
appsDue = true
|
|
1195
|
+
// A restore can leave a secret listed without its value, whether or not anything applies again.
|
|
1196
|
+
if (!changed.size) {
|
|
1197
|
+
if (appsAwayByRestore(project.root).length) {
|
|
1198
|
+
secretsDue = true
|
|
1199
|
+
nextTrigger = 'the database was restored to its baseline'
|
|
1200
|
+
if (!busy) schedule()
|
|
1201
|
+
return
|
|
1202
|
+
}
|
|
1203
|
+
if (!busy) void sayProjectSecrets()
|
|
1204
|
+
else secretsDue = true
|
|
1205
|
+
return
|
|
1206
|
+
}
|
|
1207
|
+
secretsDue = true
|
|
1208
|
+
nextTrigger = 'the database was restored to its baseline'
|
|
1209
|
+
for (const t of allTargets(project.root)) if (changed.has(t.key)) pending.set(t.key, t)
|
|
1210
|
+
ctx.output.note(` the database was restored to its baseline: applying ${changed.size === 1 ? 'one item' : `${changed.size} items`} applied since then again`)
|
|
1211
|
+
if (!busy) schedule()
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* What a changed path means: one file, every Function (a helper or the SDK changed), or a moved folder.
|
|
1216
|
+
* Whether it queued anything: a path that queues nothing never starts a round.
|
|
1217
|
+
*/
|
|
1218
|
+
const queuePath = (rel: string): boolean => {
|
|
1219
|
+
// Forge's state, the coding agents' settings, screenshots and notes: never a reason to apply (`canChangeRuntime`).
|
|
1220
|
+
if (!canChangeRuntime(rel)) return false
|
|
554
1221
|
const absolute = join(project.root, rel)
|
|
555
1222
|
const direct = targetFor(project.root, absolute)
|
|
556
1223
|
if (direct) {
|
|
557
|
-
|
|
558
|
-
return
|
|
1224
|
+
queueFromFile(direct, rel)
|
|
1225
|
+
if (direct.kind !== 'blueprint') return true
|
|
1226
|
+
}
|
|
1227
|
+
// A saved file that matches no kind is said once, with where it belongs.
|
|
1228
|
+
if (!unappliedSaid.has(rel) && existsSync(absolute)) {
|
|
1229
|
+
const why = (() => {
|
|
1230
|
+
try {
|
|
1231
|
+
return lstatSync(absolute).isFile() ? unappliedReason(project.root, rel) : null
|
|
1232
|
+
} catch {
|
|
1233
|
+
return null
|
|
1234
|
+
}
|
|
1235
|
+
})()
|
|
1236
|
+
if (why) {
|
|
1237
|
+
unappliedSaid.add(rel)
|
|
1238
|
+
ctx.output.note(` warning: ${rel} isn't applied: ${why}`)
|
|
1239
|
+
}
|
|
559
1240
|
}
|
|
1241
|
+
if (direct) return true
|
|
560
1242
|
const top = rel.split(sep)[0]
|
|
561
1243
|
const isDir = (() => {
|
|
562
1244
|
try {
|
|
@@ -567,13 +1249,53 @@ async function dev(ctx: CommandContext) {
|
|
|
567
1249
|
})()
|
|
568
1250
|
// A helper in functions/, the SDK, or a shared file a Function imports from outside functions/.
|
|
569
1251
|
const helper = ((top === 'functions' || top === 'vendor') && /\.ts$/.test(rel) && !rel.endsWith('.test.ts')) || isSharedFunctionFile(rel.split(sep).join('/'))
|
|
570
|
-
if (!isDir && !helper) return
|
|
1252
|
+
if (!isDir && !helper) return false
|
|
1253
|
+
let queued = false
|
|
571
1254
|
for (const target of allTargets(project.root)) {
|
|
572
1255
|
const path = target.kind === 'blueprint' ? 'blueprint' : target.file
|
|
573
|
-
if (helper ? target.kind === 'function' : path === rel || path.startsWith(rel + sep))
|
|
1256
|
+
if (helper ? target.kind === 'function' : path === rel || path.startsWith(rel + sep)) {
|
|
1257
|
+
queueFromFile(target, rel)
|
|
1258
|
+
queued = true
|
|
1259
|
+
}
|
|
574
1260
|
}
|
|
1261
|
+
return queued
|
|
575
1262
|
}
|
|
576
1263
|
|
|
1264
|
+
/**
|
|
1265
|
+
* What the runtime holds that the files no longer do (`local-orphans.ts`): an Agent, workflow or
|
|
1266
|
+
* Function the project applied whose file is gone is taken out of service, reversibly, and said.
|
|
1267
|
+
* The runtime is asked only when an applied file is missing, so a save never costs a listing.
|
|
1268
|
+
*/
|
|
1269
|
+
let sweeping: Promise<void> | null = null
|
|
1270
|
+
const sweepOrphans = (): Promise<void> =>
|
|
1271
|
+
(sweeping ??= (async () => {
|
|
1272
|
+
try {
|
|
1273
|
+
const known = loadHashes(project, organizationId)
|
|
1274
|
+
const present = new Set(allTargets(project.root).map((t) => t.key))
|
|
1275
|
+
if (![...known.keys()].some((k) => /^(agent|workflow|function):/.test(k) && !present.has(k))) return
|
|
1276
|
+
const call = orphanCalls(serviceUrl, creds)
|
|
1277
|
+
if (!call) return
|
|
1278
|
+
const orphans = findOrphans(project.root, await runtimeItems(call, creds.workspaceId), new Set(known.keys())).filter((o) => o.deleted)
|
|
1279
|
+
if (!orphans.length) return
|
|
1280
|
+
const { lines, forget } = await retireDeleted(project, organizationId, call, orphans)
|
|
1281
|
+
for (const line of lines) ctx.output.note(line)
|
|
1282
|
+
if (forget.length) {
|
|
1283
|
+
// Forgotten as applied, so the file, if it comes back unchanged, is applied again.
|
|
1284
|
+
for (const key of forget) {
|
|
1285
|
+
applied.delete(key)
|
|
1286
|
+
known.delete(key)
|
|
1287
|
+
}
|
|
1288
|
+
saveHashes(project, organizationId, known)
|
|
1289
|
+
// The runtime changed: Studio's lists and Forge's home read it again.
|
|
1290
|
+
recordApplied(project, { at: new Date().toISOString(), what: orphans.map((o) => `${o.kind} ${o.name}`).join(', '), ok: true })
|
|
1291
|
+
}
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
ctx.output.note(` warning: what the runtime holds without a file couldn’t be checked: ${(err as Error).message}`)
|
|
1294
|
+
} finally {
|
|
1295
|
+
sweeping = null
|
|
1296
|
+
}
|
|
1297
|
+
})())
|
|
1298
|
+
|
|
577
1299
|
// Watch from the start, so a save during the first sync is not missed.
|
|
578
1300
|
const once = flagBool(ctx, 'once')
|
|
579
1301
|
// One watcher per project (Forge keeps one running; a second would apply every change twice). `--once` is always allowed.
|
|
@@ -588,37 +1310,100 @@ async function dev(ctx: CommandContext) {
|
|
|
588
1310
|
}
|
|
589
1311
|
writeFileSync(pidFile, String(process.pid))
|
|
590
1312
|
}
|
|
1313
|
+
// Before the watcher and the first apply: the datasets it rewrites apply once, as made now.
|
|
1314
|
+
const sampleLines = await remakeStaleSampleData(project.root, flagBool(ctx, 'keep-sample-data'), flagBool(ctx, 'remake-sample-data'))
|
|
1315
|
+
for (const line of sampleLines) ctx.output.note(line)
|
|
591
1316
|
const watcher = once
|
|
592
1317
|
? null
|
|
593
1318
|
: watch(project.root, { recursive: true }, (_event, filename) => {
|
|
594
1319
|
if (!filename || stopping) return
|
|
595
1320
|
if (filename.toString() === join('.frontera', 'sync', 'remote.json')) void mirrorSettings()
|
|
596
|
-
|
|
597
|
-
if (
|
|
1321
|
+
if (filename.toString() === PROJECT_SECRETS_FILE) void sayProjectSecrets()
|
|
1322
|
+
if (filename.toString() === BASELINE_RECORD) afterRestore()
|
|
1323
|
+
// An Agent's folder, a workflow's or a Function's file deleted: what the runtime holds follows.
|
|
1324
|
+
if (/^(agents|workflows|functions)([\\/]|$)/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) void sweepOrphans()
|
|
1325
|
+
// Only a change that queued an item starts a round: the watcher's own writes under `.frontera/`,
|
|
1326
|
+
// a screenshot, a coding agent's settings or a note in docs/ never do.
|
|
1327
|
+
if (queuePath(filename.toString()) && !busy) schedule()
|
|
598
1328
|
})
|
|
599
1329
|
|
|
600
1330
|
const targets = allTargets(project.root)
|
|
601
1331
|
// Outcomes of files that are gone are forgotten, so the home lists what the project holds.
|
|
602
1332
|
pruneBuild(project, organizationId, new Set(targets.map((t) => t.key)))
|
|
1333
|
+
// An Agent, workflow or Function whose file was deleted is taken out of service here, as the files now say.
|
|
1334
|
+
await sweepOrphans()
|
|
603
1335
|
ctx.output.note(targets.length ? `Applying ${targets.length} item${targets.length === 1 ? '' : 's'} to the local runtime` : 'Nothing to apply yet')
|
|
1336
|
+
// Files in the applied folders that match no kind: said, not skipped in silence.
|
|
1337
|
+
for (const line of unappliedFiles(project.root)) {
|
|
1338
|
+
unappliedSaid.add(line.split(': ')[0]!)
|
|
1339
|
+
ctx.output.note(` warning: ${line.replace(/^([^:]+): /, "$1 isn't applied: ")}`)
|
|
1340
|
+
}
|
|
1341
|
+
// The vendored SDK's files the CLI wrote and nobody edited follow this CLI's copy; the rest are said below.
|
|
1342
|
+
const sdk = vendoredSdkState(project.root)
|
|
1343
|
+
// Never back to an older copy: one a newer CLI wrote is kept, and said below with the advice to upgrade.
|
|
1344
|
+
if (!sdk.unknown && !sdk.newer && (sdk.outdated.length || sdk.missing.length)) {
|
|
1345
|
+
const done = refreshVendoredSdk(project.root)
|
|
1346
|
+
ctx.output.note(` vendor/frontera/: ${done.replaced.length + done.added.length} file${done.replaced.length + done.added.length === 1 ? '' : 's'} of the Functions SDK brought to this CLI's copy (unedited since the CLI wrote them)`)
|
|
1347
|
+
}
|
|
1348
|
+
// An older project's root `bun test` sweeps the Apps' tests too: a bunfig.toml keeps it to functions/, written only when there is none.
|
|
1349
|
+
if (ensureProjectBunfig(project.root)) ctx.output.note(" bunfig.toml: written, so `bun test` here runs only the Functions' unit tests (functions/); an App's tests run in its folder")
|
|
1350
|
+
// What the project's tests and the runtime can't show: the pull's notes, sample data an older generator made, an older vendored SDK.
|
|
1351
|
+
// The sample data line above already said what there is to say of it.
|
|
1352
|
+
for (const line of noticeLines({ ...projectNotices(project.root), ...(sampleLines.length ? { sampleData: null } : {}) })) ctx.output.note(` warning: ${line}`)
|
|
604
1353
|
for (const target of targets) pending.set(target.key, target)
|
|
1354
|
+
// The first round: the runtime just started (Forge starts this watcher with it), or `local dev` was run by hand.
|
|
1355
|
+
const startedAt = Date.parse(state.startedAt ?? '')
|
|
1356
|
+
nextTrigger = once ? 'frontera local dev --once' : Number.isFinite(startedAt) && Date.now() - startedAt < 5 * 60_000 ? 'runtime started' : 'frontera local dev started'
|
|
605
1357
|
await drain()
|
|
606
1358
|
if (unchanged) ctx.output.note(` ${unchanged} unchanged since the last apply (--reapply applies them again)`)
|
|
1359
|
+
await sayProjectSecrets()
|
|
607
1360
|
|
|
608
1361
|
if (once) {
|
|
609
|
-
|
|
1362
|
+
// What still fails once every retry ran: a target that failed and then applied is not a failure.
|
|
1363
|
+
// And what the runtime refused inside a Blueprint that applied (a governed Action, a metric): failures
|
|
1364
|
+
// here as in `local status` and `local check`, not only warnings, so all three say one number.
|
|
1365
|
+
const blueprintEntry = loadBuild(project, organizationId).blueprint
|
|
1366
|
+
const current = blueprintEntry && blueprintEntry.hash === contentHash(project, { kind: 'blueprint', key: 'blueprint' }) ? blueprintEntry : undefined
|
|
1367
|
+
const byKey = new Map(targets.map((t) => [t.key, t]))
|
|
1368
|
+
const failures = applyFailures(failing, targets.length, current, (key) => {
|
|
1369
|
+
const target = byKey.get(key)
|
|
1370
|
+
return target ? { ref: refOfTarget(target), ...('file' in target ? { file: target.file } : {}) } : undefined
|
|
1371
|
+
})
|
|
1372
|
+
// Split as `local check` splits it: a failure only in items still as the pull wrote them is the workspace's, not this change's.
|
|
1373
|
+
const { made, inherited } = failures ? splitApplyFailures(failures.failed, unchangedSincePull(project.root), { strict: flagBool(ctx, 'strict') }) : { made: [], inherited: [] }
|
|
1374
|
+
const inheritedField = inherited.length ? { inheritedFailures: inherited } : {}
|
|
1375
|
+
if (failures && made.length) {
|
|
1376
|
+
const message = inherited.length ? `${failures.message}: ${made.length} changed or added here, ${inherited.length} inherited from the pull` : failures.message
|
|
1377
|
+
throw new CliError(message, { code: 'LOCAL_APPLY_FAILED', hint: 'fix the files above and run again; `frontera local status` lists each item that didn’t apply', details: { failed: made, ...inheritedField } })
|
|
1378
|
+
}
|
|
610
1379
|
const done = targets.length - unchanged
|
|
611
|
-
|
|
1380
|
+
if (inherited.length) {
|
|
1381
|
+
const summary = inheritedApplyLine(inherited.length)
|
|
1382
|
+
ctx.output.note(` ${summary}:`)
|
|
1383
|
+
for (const f of inherited) ctx.output.note(` ${f.ref ?? f.item}: ${oneLine(f.error)}`)
|
|
1384
|
+
return { data: { applied: done, unchanged, failed: 0, ...inheritedField, summary, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}; ${summary}` }
|
|
1385
|
+
}
|
|
1386
|
+
return { data: { applied: done, unchanged, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}` }
|
|
612
1387
|
}
|
|
613
1388
|
|
|
614
1389
|
ctx.output.note('Watching datasets/, blueprint/, knowledge/, skills/, mcp/, themes/, functions/, workflows/, agents/, chat-apps/, apps/, brand/ and library.json (Ctrl+C to stop)')
|
|
615
1390
|
if (pending.size) schedule()
|
|
1391
|
+
// What failed while the runtime was down is applied once it answers again, not when its file next changes.
|
|
1392
|
+
const poller = setInterval(() => {
|
|
1393
|
+
if (!waiting.size || busy || stopping) return
|
|
1394
|
+
void runtimeAnswering(project).then((up) => {
|
|
1395
|
+
if (!up || busy || stopping || !waiting.size) return
|
|
1396
|
+
resumeWaiting()
|
|
1397
|
+
schedule()
|
|
1398
|
+
})
|
|
1399
|
+
}, 5_000)
|
|
616
1400
|
|
|
617
1401
|
await new Promise<void>((resolve) => {
|
|
618
1402
|
const stop = () => {
|
|
619
1403
|
stopping = true
|
|
620
1404
|
rmSync(pidFile, { force: true })
|
|
621
1405
|
if (timer) clearTimeout(timer)
|
|
1406
|
+
clearInterval(poller)
|
|
622
1407
|
watcher?.close()
|
|
623
1408
|
stopChildren()
|
|
624
1409
|
resolve()
|
|
@@ -634,11 +1419,13 @@ export const localDev: Command = {
|
|
|
634
1419
|
noun: 'local',
|
|
635
1420
|
verb: 'dev',
|
|
636
1421
|
args: [],
|
|
637
|
-
flags: { once: 'boolean', 'no-publish': 'boolean', reapply: 'boolean' },
|
|
1422
|
+
flags: { once: 'boolean', 'no-publish': 'boolean', reapply: 'boolean', 'keep-sample-data': 'boolean', 'remake-sample-data': 'boolean', strict: 'boolean' },
|
|
638
1423
|
summary: 'Apply the project to the local runtime, then apply each saved file',
|
|
639
|
-
|
|
1424
|
+
details: [
|
|
1425
|
+
'With --once it exits 0 when everything applied, and also when every item that failed is still as the last pull wrote it: those failures are inherited from the workspace, listed apart (`inheritedFailures` with --json), as `frontera local check` lists them. Anything changed or added here that failed exits 1. --strict counts the inherited failures too.',
|
|
1426
|
+
],
|
|
1427
|
+
examples: ['frontera local dev', 'frontera local dev --once', 'frontera local dev --no-publish', 'frontera local dev --once --reapply', 'frontera local dev --once --strict'],
|
|
640
1428
|
offline: true,
|
|
641
|
-
internal: true,
|
|
642
1429
|
},
|
|
643
1430
|
run: dev,
|
|
644
1431
|
}
|