@frontera-sdk/cli 1.52.39 → 1.52.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -7,6 +7,7 @@ import { allTargets, describeTarget, datasetDocument, isIgnoredName, type SyncTa
|
|
|
7
7
|
import type { LocalProject } from './local-project'
|
|
8
8
|
import { standInTools } from './local-stand-ins'
|
|
9
9
|
import { importedFiles } from './local-function-shared'
|
|
10
|
+
import { NOT_A_DIFFERENCE_HERE } from './local-org-settings'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* What `frontera local dev` has applied to this runtime, by content hash. It
|
|
@@ -90,7 +91,7 @@ export function contentHash(project: LocalProject, target: SyncTarget): string |
|
|
|
90
91
|
// shared files it imports from outside functions/.
|
|
91
92
|
if (target.kind === 'function') {
|
|
92
93
|
hash.update(treeHash(join(project.root, 'functions')) ?? '').update(treeHash(join(project.root, 'vendor')) ?? '')
|
|
93
|
-
for (const path of importedFiles(project.root, target.file).filter((p) => !p.startsWith('functions/')).sort()) hash.update(path).update('\0').update(fileDigest(join(project.root, path)) ?? '')
|
|
94
|
+
for (const path of importedFiles(project.root, target.file).filter((p) => !p.startsWith('functions/')).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) hash.update(path).update('\0').update(fileDigest(join(project.root, path)) ?? '')
|
|
94
95
|
}
|
|
95
96
|
// Context and data the pull could not read is assumed on the project's object types: a new one changes it.
|
|
96
97
|
if (target.kind === 'bindings' && /"blueprintUnread"\s*:\s*true/.test(readFileSync(join(project.root, target.file), 'utf8'))) {
|
|
@@ -127,8 +128,64 @@ export interface SolutionItem {
|
|
|
127
128
|
name: string
|
|
128
129
|
/** Path in the solution (the Blueprint is its folder). */
|
|
129
130
|
path: string
|
|
130
|
-
/**
|
|
131
|
-
|
|
131
|
+
/**
|
|
132
|
+
* `applied`: the runtime has this content; `changed`: edited since; `new`: never applied here;
|
|
133
|
+
* `failed`: the runtime refused this content; `partial`: it applied, but the runtime refused
|
|
134
|
+
* some of what it holds (a governed Action, a metric): `detail` names them.
|
|
135
|
+
*/
|
|
136
|
+
state: 'applied' | 'changed' | 'new' | 'failed' | 'partial'
|
|
137
|
+
/** failed, partial: what the runtime refused, in words; a held item (`waiting`): what it waits for. */
|
|
138
|
+
detail?: string
|
|
139
|
+
/** failed: the error's code, when the apply gave one. */
|
|
140
|
+
code?: string
|
|
141
|
+
/** failed: what fixes it. Running `local dev` again on the same files does not. */
|
|
142
|
+
fix?: string
|
|
143
|
+
/** new or changed, but the last apply of exactly these files was held back for another item (`detail`). */
|
|
144
|
+
waiting?: boolean
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What fixes a Function whose imports the pull couldn't bring: the Function's owner, or the files here. */
|
|
148
|
+
export const IMPORT_MISSING_FIX = 'the pull couldn’t bring the files it imports (its source in the workspace doesn’t hold them): whoever owns the Function deploys it again from its source with a current CLI, then you pull it; or add the files here'
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What fixes a knowledge base the runtime could not read because its
|
|
152
|
+
* organization has no embedding model that runs: not its file. The model is
|
|
153
|
+
* the runtime's: a start sets one from this computer's keys (the keyless
|
|
154
|
+
* local embeddings when there is none, or the provider refuses it).
|
|
155
|
+
*/
|
|
156
|
+
export const EMBEDDING_MISSING_FIX = 'not its file: the runtime has no embedding model that runs. `frontera local status` says why under Setting and Embeddings; a key the provider refuses is replaced with `frontera local key-set <provider>`, and `frontera local stop` then `frontera local start` sets a model (the keyless local embeddings when no key works); the next `frontera local dev` reads it again'
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What fixes an item whose apply failed for these very files: the hint the
|
|
160
|
+
* apply gave, what a known failure needs, else fixing the file. Applying the
|
|
161
|
+
* same files again does not.
|
|
162
|
+
*/
|
|
163
|
+
export function failureFix(entry: Pick<BuildEntry, 'error' | 'code' | 'hint'>, file: string): string {
|
|
164
|
+
if (entry.code === 'FUNCTION_IMPORT_MISSING' || /^imports .+, which (?:isn[’']t|aren[’']t) in the project/i.test(entry.error ?? '')) return IMPORT_MISSING_FIX
|
|
165
|
+
if (/set this organization['’]s embedding model|embedding model .*is not available/i.test(entry.error ?? '')) return EMBEDDING_MISSING_FIX
|
|
166
|
+
if (entry.hint) return entry.hint
|
|
167
|
+
return `change ${file} so the runtime takes it; \`frontera local dev\` applies it once the file changes`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The code an apply's error carries (`error [FUNCTION_IMPORT_MISSING]: …`), and its first `hint:` line. */
|
|
171
|
+
export function failureCodeAndHint(output: string): { code?: string; hint?: string } {
|
|
172
|
+
const code = /error \[([A-Z][A-Z0-9_]*)\]/.exec(output)?.[1]
|
|
173
|
+
const hint = /^\s*hint:\s*(.+)$/m.exec(output)?.[1]?.trim()
|
|
174
|
+
return { ...(code ? { code } : {}), ...(hint ? { hint } : {}) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* What the runtime refused inside a Blueprint that applied, by reference
|
|
179
|
+
* (`action/holdAmenityBooking`, `metric/occupancyRate`, `objectType/Photo`):
|
|
180
|
+
* each one a failure `status`, `local check` and `local dev` count alike.
|
|
181
|
+
*/
|
|
182
|
+
export function refusedBlueprintItems(entry: Pick<BuildEntry, 'actions' | 'parts' | 'objects'> | undefined): string[] {
|
|
183
|
+
if (!entry) return []
|
|
184
|
+
return [
|
|
185
|
+
...Object.entries(entry.objects ?? {}).flatMap(([name, o]) => (o.refused ? [`objectType/${name}`] : [])),
|
|
186
|
+
...Object.entries(entry.parts ?? {}).flatMap(([ref, p]) => (p.refused ? [ref] : [])),
|
|
187
|
+
...Object.entries(entry.actions ?? {}).flatMap(([name, a]) => ((a.status === 'left-off' && a.refused) || a.status === 'failed' ? [`action/${name}`] : [])),
|
|
188
|
+
]
|
|
132
189
|
}
|
|
133
190
|
|
|
134
191
|
/**
|
|
@@ -136,25 +193,35 @@ export interface SolutionItem {
|
|
|
136
193
|
* dev` recorded after its last successful publish, against the files now. Null
|
|
137
194
|
* when the project has no Blueprint.
|
|
138
195
|
*/
|
|
139
|
-
export function blueprintApplyState(project: LocalProject, organizationId: string | null):
|
|
196
|
+
export function blueprintApplyState(project: LocalProject, organizationId: string | null): 'applied' | 'changed' | 'new' | null {
|
|
140
197
|
const now = treeHash(join(project.root, 'blueprint'))
|
|
141
198
|
if (!now) return null
|
|
142
199
|
const was = organizationId ? loadHashes(project, organizationId).get('blueprint') : undefined
|
|
143
200
|
return was === undefined ? 'new' : was === now ? 'applied' : 'changed'
|
|
144
201
|
}
|
|
145
202
|
|
|
146
|
-
/** Each file `local dev` applies, and whether the runtime has its current content. */
|
|
147
|
-
export function solutionStatus(project: LocalProject, organizationId: string | null): SolutionItem[] {
|
|
203
|
+
/** Each file `local dev` applies, and whether the runtime has its current content; `only` keeps the targets it accepts. */
|
|
204
|
+
export function solutionStatus(project: LocalProject, organizationId: string | null, only?: (target: SyncTarget) => boolean): SolutionItem[] {
|
|
148
205
|
const applied = organizationId ? loadHashes(project, organizationId) : new Map<string, string | null>()
|
|
149
|
-
|
|
206
|
+
const build = loadBuild(project, organizationId)
|
|
207
|
+
return allTargets(project.root).filter((target) => !only || only(target)).map((target) => {
|
|
150
208
|
const was = applied.get(target.key)
|
|
151
209
|
const now = contentHash(project, target)
|
|
152
|
-
|
|
210
|
+
const item: SolutionItem = {
|
|
153
211
|
kind: target.kind,
|
|
154
212
|
name: describeTarget(target),
|
|
155
213
|
path: target.kind === 'blueprint' ? 'blueprint/' : target.file,
|
|
156
214
|
state: was === undefined ? 'new' : was === now ? 'applied' : 'changed',
|
|
157
215
|
}
|
|
216
|
+
// The last build is for these files: what the runtime refused is said, not "Applied".
|
|
217
|
+
const entry = build[target.key]
|
|
218
|
+
if (!entry || entry.hash !== now) return item
|
|
219
|
+
if (entry.status === 'failed') return { ...item, state: 'failed', detail: entry.error ?? 'the runtime refused it', ...(entry.code ? { code: entry.code } : {}), fix: failureFix(entry, item.path) }
|
|
220
|
+
// Held back for another item: it applies once that one does, not by running `local dev` again.
|
|
221
|
+
if (entry.status === 'blocked' && item.state !== 'applied') return { ...item, waiting: true, detail: entry.error ?? (entry.needs?.length ? `waits for ${entry.needs.join(', ')}` : 'held back') }
|
|
222
|
+
const refused = target.kind === 'blueprint' ? refusedBlueprintItems(entry) : []
|
|
223
|
+
if (refused.length && item.state === 'applied') return { ...item, state: 'partial', detail: `the runtime refused ${refused.length === 1 ? 'one item' : `${refused.length} items`} in it: ${refused.join(', ')}` }
|
|
224
|
+
return item
|
|
158
225
|
})
|
|
159
226
|
}
|
|
160
227
|
|
|
@@ -188,13 +255,44 @@ export interface BuildEntry {
|
|
|
188
255
|
unnamedActions?: number
|
|
189
256
|
/** failed: the runtime's error, one line. */
|
|
190
257
|
error?: string
|
|
258
|
+
/** failed: the error's code (`FUNCTION_IMPORT_MISSING`), when it had one. */
|
|
259
|
+
code?: string
|
|
260
|
+
/** failed: what the apply said fixes it (its `hint:` line), when it said. */
|
|
261
|
+
hint?: string
|
|
191
262
|
/** The content hash the outcome is for. */
|
|
192
263
|
hash: string | null
|
|
264
|
+
/**
|
|
265
|
+
* A knowledge base: how the runtime's embeddings stood against the
|
|
266
|
+
* workspace's when it was applied (`embeddingsDiffer`; `same` when they
|
|
267
|
+
* match), which decides the threshold it searches with. Another now
|
|
268
|
+
* applies it again, unchanged files or not.
|
|
269
|
+
*/
|
|
270
|
+
embeddings?: 'same' | 'workspace' | 'platform-default' | 'keyless'
|
|
271
|
+
/**
|
|
272
|
+
* A workflow: the version its file was pushed as (the push that made this
|
|
273
|
+
* entry). Tests run exactly this version, live or draft: the newest draft
|
|
274
|
+
* can be an older push once a later one was promoted.
|
|
275
|
+
*/
|
|
276
|
+
version?: number
|
|
277
|
+
/**
|
|
278
|
+
* A workflow: the version the last promotion `local dev` made made live here
|
|
279
|
+
* (this push's, or an earlier one's while no later push was promoted). What an
|
|
280
|
+
* Agent's "Promoted workflow … so … can start it" says, read now.
|
|
281
|
+
*/
|
|
282
|
+
live?: number
|
|
193
283
|
at: string
|
|
194
284
|
/** The Blueprint: each object type as the runtime holds it. */
|
|
195
|
-
objects?: Record<string, { status: 'built' | 'adapted' | 'stubbed' | 'left-off'; adaptations?: string[] }>
|
|
285
|
+
objects?: Record<string, { status: 'built' | 'adapted' | 'stubbed' | 'left-off'; adaptations?: string[]; refused?: string }>
|
|
196
286
|
/** The Blueprint: each governed Action, deployed (`runs`) or not, with why. */
|
|
197
|
-
actions?: Record<string, { status: 'runs' | 'switched-off' | 'failed' | 'left-off'; reason?: string }>
|
|
287
|
+
actions?: Record<string, { status: 'runs' | 'switched-off' | 'failed' | 'left-off'; reason?: string; findings?: string[]; refused?: boolean; inherited?: string[] }>
|
|
288
|
+
/**
|
|
289
|
+
* The Blueprint: each link type, metric, semantic type and shared field left off the
|
|
290
|
+
* runtime, by its row's reference (`metric/workOrdersRaised`), with why; `refused` when
|
|
291
|
+
* the runtime refused that file itself.
|
|
292
|
+
*/
|
|
293
|
+
parts?: Record<string, { reason: string; refused: boolean }>
|
|
294
|
+
/** The Blueprint: each saved object set's id on the runtime, by its file's reference (`open-work-orders`). */
|
|
295
|
+
objectSetIds?: Record<string, string>
|
|
198
296
|
}
|
|
199
297
|
|
|
200
298
|
interface BuildFile {
|
|
@@ -217,7 +315,9 @@ function readBuildFile(project: LocalProject, organizationId: string): Partial<B
|
|
|
217
315
|
|
|
218
316
|
/** The settings the runtime holds differently from the workspace, as `local dev` last compared them. */
|
|
219
317
|
export function loadSettingsDifferences(project: LocalProject, organizationId: string | null): string[] {
|
|
220
|
-
|
|
318
|
+
const recorded = (organizationId ? readBuildFile(project, organizationId)?.settings?.differences : undefined) ?? []
|
|
319
|
+
// A flag recorded by an older CLI that changes nothing here (`NOT_A_DIFFERENCE_HERE`) is not said.
|
|
320
|
+
return recorded.filter((d) => ![...NOT_A_DIFFERENCE_HERE].some((key) => d.startsWith(`Feature ${key} `)))
|
|
221
321
|
}
|
|
222
322
|
|
|
223
323
|
/** Record where the runtime's settings differ from the workspace's. Never throws. */
|
|
@@ -261,6 +361,12 @@ export function recordBuild(project: LocalProject, organizationId: string, key:
|
|
|
261
361
|
}
|
|
262
362
|
}
|
|
263
363
|
|
|
364
|
+
/** The version `local dev` last pushed a workflow's file as, when the record has one. */
|
|
365
|
+
export function pushedWorkflowVersion(project: LocalProject, organizationId: string | null, slug: string): number | undefined {
|
|
366
|
+
const entry = loadBuild(project, organizationId)[`workflow:${slug}`]
|
|
367
|
+
return entry && (entry.status === 'built' || entry.status === 'adapted' || entry.status === 'stubbed') && typeof entry.version === 'number' ? entry.version : undefined
|
|
368
|
+
}
|
|
369
|
+
|
|
264
370
|
/** Forget the outcomes of targets the project no longer has. */
|
|
265
371
|
export function pruneBuild(project: LocalProject, organizationId: string, keep: ReadonlySet<string>): void {
|
|
266
372
|
const items = loadBuild(project, organizationId)
|
|
@@ -335,7 +441,26 @@ export function adaptationOf(warning: string): string {
|
|
|
335
441
|
return text.charAt(0).toUpperCase() + text.slice(1)
|
|
336
442
|
}
|
|
337
443
|
|
|
338
|
-
|
|
444
|
+
/**
|
|
445
|
+
* How an item stands here: its recorded build, `not-applied` when no apply
|
|
446
|
+
* is for its files as they are, and `needs-setup` for one that applied but
|
|
447
|
+
* names a secret the runtime has no value for (`Needs a value for X`): it
|
|
448
|
+
* is on the runtime, and fails when used until the value is set, so it is
|
|
449
|
+
* counted with what to fix, not with what runs adapted.
|
|
450
|
+
*/
|
|
451
|
+
export type ItemBuildState = BuildStatus | 'not-applied' | 'needs-setup'
|
|
452
|
+
|
|
453
|
+
/** An adaptation saying the item names a secret with no value here. */
|
|
454
|
+
export const NEEDS_VALUE = /^Needs a value for ([A-Z][A-Z0-9_]*)\b/
|
|
455
|
+
|
|
456
|
+
/** Adapted or stubbed with a secret missing: `needs-setup`, its secrets said first. */
|
|
457
|
+
export function withSetupNeeded(state: ItemBuildState, adaptations: readonly string[]): { state: ItemBuildState; reason: string | null } | null {
|
|
458
|
+
if (state !== 'adapted' && state !== 'stubbed' && state !== 'needs-setup') return null
|
|
459
|
+
const needs = adaptations.filter((a) => NEEDS_VALUE.test(a))
|
|
460
|
+
if (!needs.length) return state === 'needs-setup' ? { state: 'adapted', reason: adaptations.join('; ') || null } : null
|
|
461
|
+
const rest = adaptations.filter((a) => !NEEDS_VALUE.test(a))
|
|
462
|
+
return { state: 'needs-setup', reason: [...needs, ...rest].join('; ') }
|
|
463
|
+
}
|
|
339
464
|
|
|
340
465
|
export interface ItemBuild {
|
|
341
466
|
state: ItemBuildState
|
|
@@ -344,10 +469,18 @@ export interface ItemBuild {
|
|
|
344
469
|
/** The one line that says why, when there is one: "Needs workflow telco-fault-triage". */
|
|
345
470
|
reason: string | null
|
|
346
471
|
adaptations: string[]
|
|
472
|
+
/** failed: what fixes it (applying the same files again does not). */
|
|
473
|
+
fix?: string
|
|
474
|
+
/**
|
|
475
|
+
* Said beside its state without changing it: what the item's own files get
|
|
476
|
+
* wrong the same way on the platform (an Agent's instructions naming
|
|
477
|
+
* governed Actions its grants never offer it), each with its fix.
|
|
478
|
+
*/
|
|
479
|
+
warnings?: string[]
|
|
347
480
|
}
|
|
348
481
|
|
|
349
|
-
const BUILD_RANK: Record<ItemBuildState, number> = { built: 0, adapted: 1, stubbed: 2, 'not-applied':
|
|
350
|
-
const BUILD_LABEL: Record<ItemBuildState, string> = { built: 'Built', adapted: 'Adapted', stubbed: 'Stubbed', 'not-applied': 'Not applied yet', blocked: 'Blocked', failed: 'Failed' }
|
|
482
|
+
const BUILD_RANK: Record<ItemBuildState, number> = { built: 0, adapted: 1, stubbed: 2, 'needs-setup': 3, 'not-applied': 4, blocked: 5, failed: 6 }
|
|
483
|
+
export const BUILD_LABEL: Record<ItemBuildState, string> = { built: 'Built', adapted: 'Adapted', stubbed: 'Stubbed', 'needs-setup': 'Needs setup', 'not-applied': 'Not applied yet', blocked: 'Blocked', failed: 'Failed' }
|
|
351
484
|
|
|
352
485
|
function lower(text: string): string {
|
|
353
486
|
return text ? text.charAt(0).toLowerCase() + text.slice(1) : text
|
|
@@ -362,7 +495,7 @@ function lower(text: string): string {
|
|
|
362
495
|
* Blueprint".
|
|
363
496
|
*/
|
|
364
497
|
export function buildView(project: LocalProject, organizationId: string | null): Map<string, ItemBuild> {
|
|
365
|
-
const build = loadBuild(project, organizationId)
|
|
498
|
+
const build = withCurrentPromotions(loadBuild(project, organizationId))
|
|
366
499
|
const applied = organizationId ? loadHashes(project, organizationId) : new Map<string, string | null>()
|
|
367
500
|
const targets = allTargets(project.root)
|
|
368
501
|
const byRef = new Map<string, SyncTarget[]>()
|
|
@@ -370,12 +503,16 @@ export function buildView(project: LocalProject, organizationId: string | null):
|
|
|
370
503
|
const hashes = new Map(targets.map((t) => [t.key, contentHash(project, t)]))
|
|
371
504
|
const blueprint = build.blueprint
|
|
372
505
|
|
|
373
|
-
const stateOf = (t: SyncTarget): { state: ItemBuildState; entry?: BuildEntry } => {
|
|
506
|
+
const stateOf = (t: SyncTarget): { state: ItemBuildState; entry?: BuildEntry; changedSince?: boolean } => {
|
|
374
507
|
const entry = build[t.key]
|
|
375
508
|
const now = hashes.get(t.key) ?? null
|
|
376
509
|
if (entry && entry.hash === now) return { state: entry.status, entry }
|
|
377
510
|
// Applied before the build was recorded: it is built.
|
|
378
511
|
if (!entry && now && applied.get(t.key) === now) return { state: 'built' }
|
|
512
|
+
// Failed at its last apply, and what it builds from changed since (its file, or for a Function the
|
|
513
|
+
// vendored SDK or a file beside it): still a failure until an apply says otherwise, not "not applied
|
|
514
|
+
// yet", so a refreshed SDK or a new Function next to it doesn't make the failure drop out of the count.
|
|
515
|
+
if (entry?.status === 'failed' && now) return { state: 'failed', entry, changedSince: true }
|
|
379
516
|
return { state: 'not-applied' }
|
|
380
517
|
}
|
|
381
518
|
|
|
@@ -392,9 +529,10 @@ export function buildView(project: LocalProject, organizationId: string | null):
|
|
|
392
529
|
if (ref.startsWith('objectType/')) {
|
|
393
530
|
const obj = blueprint?.objects?.[ref.slice('objectType/'.length)]
|
|
394
531
|
// The Blueprint not built (failed, waiting, or never applied) holds every object type back.
|
|
395
|
-
if (blueprint && blueprint.status !== 'built' && blueprint.status !== 'adapted') return `${words}: the Blueprint ${blueprint.status === 'failed' ? 'didn’t build' : 'isn’t built yet'}`
|
|
532
|
+
if (blueprint && blueprint.status !== 'built' && blueprint.status !== 'adapted' && blueprint.status !== 'stubbed') return `${words}: the Blueprint ${blueprint.status === 'failed' ? 'didn’t build' : 'isn’t built yet'}`
|
|
396
533
|
if (!blueprint && existsSync(join(project.root, 'blueprint'))) return `${words}: the Blueprint isn’t built yet`
|
|
397
534
|
if (!obj) return `${words}, which isn’t in the project’s Blueprint`
|
|
535
|
+
if (obj.refused) return `${words}, which the local runtime refused: ${obj.refused}`
|
|
398
536
|
return obj.status === 'left-off' ? `${words}, which the local runtime leaves off` : words
|
|
399
537
|
}
|
|
400
538
|
const held = byRef.get(ref)
|
|
@@ -411,6 +549,8 @@ export function buildView(project: LocalProject, organizationId: string | null):
|
|
|
411
549
|
if (entry.status === 'blocked') {
|
|
412
550
|
const needs = entry.needs ?? []
|
|
413
551
|
if (!needs.length) {
|
|
552
|
+
// Held back for its own reason (the apply watchdog): its words, not a missing dependency.
|
|
553
|
+
if (entry.error) return entry.error
|
|
414
554
|
const n = entry.unnamedActions ?? 0
|
|
415
555
|
return `Runs ${n === 1 ? 'a governed Action' : `${n} governed Actions`} named only by the platform’s id: add an organization key and pull it again`
|
|
416
556
|
}
|
|
@@ -425,30 +565,79 @@ export function buildView(project: LocalProject, organizationId: string | null):
|
|
|
425
565
|
const found = stateOf(t)
|
|
426
566
|
const { entry } = found
|
|
427
567
|
// Adapted only by what the runtime is missing (no runner has polled yet): it is built as its files say, with a note.
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
568
|
+
const environmentOnly = found.state === 'adapted' && !!entry?.adaptations?.length && entry.adaptations.every(isEnvironmentNote)
|
|
569
|
+
// A secret with no value here: it applied, and fails when used until the value is set.
|
|
570
|
+
const setup = environmentOnly ? null : withSetupNeeded(found.state, entry?.adaptations ?? [])
|
|
571
|
+
const state: ItemBuildState = environmentOnly ? 'built' : (setup?.state ?? found.state)
|
|
572
|
+
// "No runner has polled for 229s" was the poll's age when it applied, not the runner now: not repeated as
|
|
573
|
+
// the item's reason (a restored database brings back an old poll time). "Never polled" still is.
|
|
574
|
+
const own = environmentOnly ? entry!.adaptations!.filter((a) => !isPollAgeNote(a)).join('; ') || null : setup ? setup.reason : entry ? reasonOf(entry, 0) || null : null
|
|
575
|
+
const reason = found.changedSince ? `${own ?? 'The runtime refused it'} (at its last apply; its files changed since, and \`frontera local dev\` applies it again)` : own
|
|
576
|
+
out.set(t.key, { state, label: BUILD_LABEL[state], reason, adaptations: state === 'adapted' || state === 'stubbed' || state === 'needs-setup' ? (entry?.adaptations ?? []) : [], ...(state === 'failed' && entry ? { fix: failureFix(entry, t.kind === 'blueprint' ? 'blueprint/' : t.file) } : {}) })
|
|
431
577
|
}
|
|
432
578
|
return out
|
|
433
579
|
}
|
|
434
580
|
|
|
581
|
+
/** "Promoted workflow intake v2 so desk can start it": the adaptation an Agent's tools record when its binding promoted a workflow. */
|
|
582
|
+
const PROMOTED = /^Promoted workflow (\S+) v(\d+) so (.+) can start it$/
|
|
583
|
+
|
|
584
|
+
/** `promoted workflow intake v3…` in an apply's output → 3, for that workflow. */
|
|
585
|
+
export function promotedVersionOf(output: string, slug: string): number | undefined {
|
|
586
|
+
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
587
|
+
const all = [...output.matchAll(new RegExp(`promoted workflow ${escaped} v(\\d+)\\b`, 'gi'))]
|
|
588
|
+
return all.length ? Number(all[all.length - 1]![1]) : undefined
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Each "Promoted workflow X vN so Y can start it" with the version live now:
|
|
593
|
+
* the latest promotion `local dev` recorded for X (its push's, `live`), not the
|
|
594
|
+
* one the Agent's binding made when it was applied. A record from before `live`
|
|
595
|
+
* was kept reads the workflow's pushed version, when it was pushed after the
|
|
596
|
+
* binding: once a binding promoted it, every later push is promoted too.
|
|
597
|
+
*/
|
|
598
|
+
export function withCurrentPromotions(build: Record<string, BuildEntry>): Record<string, BuildEntry> {
|
|
599
|
+
const now = (text: string, at: string): string => {
|
|
600
|
+
const m = PROMOTED.exec(text)
|
|
601
|
+
if (!m) return text
|
|
602
|
+
const workflow = build[`workflow:${m[1]}`]
|
|
603
|
+
const recorded = workflow?.live ?? (workflow?.version !== undefined && workflow.at > at ? workflow.version : undefined)
|
|
604
|
+
const latest = Math.max(Number(m[2]), recorded ?? 0)
|
|
605
|
+
return latest === Number(m[2]) ? text : `Promoted workflow ${m[1]} v${latest} so ${m[3]} can start it`
|
|
606
|
+
}
|
|
607
|
+
let changed = false
|
|
608
|
+
const out: Record<string, BuildEntry> = {}
|
|
609
|
+
for (const [key, entry] of Object.entries(build)) {
|
|
610
|
+
const adaptations = entry.adaptations?.map((a) => now(a, entry.at))
|
|
611
|
+
if (adaptations && adaptations.some((a, i) => a !== entry.adaptations![i])) {
|
|
612
|
+
changed = true
|
|
613
|
+
out[key] = { ...entry, adaptations }
|
|
614
|
+
} else out[key] = entry
|
|
615
|
+
}
|
|
616
|
+
return changed ? out : build
|
|
617
|
+
}
|
|
618
|
+
|
|
435
619
|
/** A warning about the runtime rather than the item: it says nothing about how the item runs differently here. */
|
|
436
620
|
export function isEnvironmentNote(text: string): boolean {
|
|
437
621
|
return /no automation runner has (ever )?polled/i.test(text)
|
|
438
622
|
}
|
|
439
623
|
|
|
624
|
+
/** The deploy's "no automation runner has polled for 229s": how old the last poll was at that moment. */
|
|
625
|
+
export function isPollAgeNote(text: string): boolean {
|
|
626
|
+
return /no automation runner has polled for \d+\s?s\b/i.test(text)
|
|
627
|
+
}
|
|
628
|
+
|
|
440
629
|
/** Several targets as one item (an Agent: its file, its behavior settings and its tools): the worst one speaks. */
|
|
441
630
|
export function combineBuilds(builds: Array<ItemBuild | undefined>): ItemBuild {
|
|
442
631
|
const present = builds.filter((b): b is ItemBuild => !!b)
|
|
443
632
|
if (!present.length) return { state: 'not-applied', label: BUILD_LABEL['not-applied'], reason: null, adaptations: [] }
|
|
444
633
|
const worst = [...present].sort((a, b) => BUILD_RANK[b.state] - BUILD_RANK[a.state])[0]!
|
|
445
634
|
const adaptations = [...new Set(present.flatMap((b) => b.adaptations))]
|
|
446
|
-
return { ...worst, adaptations, reason: worst.reason ?? (worst.state === 'adapted' || worst.state === 'stubbed' ? adaptations.join('; ') || null : null) }
|
|
635
|
+
return { ...worst, adaptations, reason: worst.reason ?? (worst.state === 'adapted' || worst.state === 'stubbed' || worst.state === 'needs-setup' ? adaptations.join('; ') || null : null) }
|
|
447
636
|
}
|
|
448
637
|
|
|
449
638
|
/** How many items the home lists are in each state, for "49 built · 2 adapted · 3 blocked". */
|
|
450
639
|
export function buildCounts(states: ItemBuildState[]): Record<ItemBuildState, number> {
|
|
451
|
-
const counts: Record<ItemBuildState, number> = { built: 0, adapted: 0, stubbed: 0, 'not-applied': 0, blocked: 0, failed: 0 }
|
|
640
|
+
const counts: Record<ItemBuildState, number> = { built: 0, adapted: 0, stubbed: 0, 'needs-setup': 0, 'not-applied': 0, blocked: 0, failed: 0 }
|
|
452
641
|
for (const s of states) counts[s] += 1
|
|
453
642
|
return counts
|
|
454
643
|
}
|
|
@@ -459,6 +648,7 @@ export function buildSummaryLine(counts: Record<ItemBuildState, number>): string
|
|
|
459
648
|
counts.built ? `${counts.built} built` : '',
|
|
460
649
|
counts.adapted ? `${counts.adapted} adapted` : '',
|
|
461
650
|
counts.stubbed ? `${counts.stubbed} stubbed` : '',
|
|
651
|
+
counts['needs-setup'] ? `${counts['needs-setup']} need${counts['needs-setup'] === 1 ? 's' : ''} setup` : '',
|
|
462
652
|
counts.blocked ? `${counts.blocked} blocked` : '',
|
|
463
653
|
counts.failed ? `${counts.failed} failed` : '',
|
|
464
654
|
counts['not-applied'] ? `${counts['not-applied']} not applied yet` : '',
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
5
|
+
import type { LocalProject } from './local-project'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Each apply `local dev` ran, newest last: when it started and finished,
|
|
9
|
+
* what started it and what came of it. Forge's Activity lists each as its
|
|
10
|
+
* own event, so an apply nine minutes after the first says when it ran and
|
|
11
|
+
* why ("files changed: agents/desk/agent.json") instead of reusing the
|
|
12
|
+
* first one's time. A round that found nothing changed is not an apply and
|
|
13
|
+
* is not kept.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ApplyRecord {
|
|
17
|
+
/** When its first item started applying. */
|
|
18
|
+
since: string
|
|
19
|
+
finished: string
|
|
20
|
+
/** What started it: "runtime started", "files changed: …", "the runtime is answering again". */
|
|
21
|
+
trigger: string
|
|
22
|
+
/** Items it applied, and items that failed, in this round only. */
|
|
23
|
+
applied: number
|
|
24
|
+
failed: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** How many applies are kept: Activity shows the recent ones. */
|
|
28
|
+
const KEEP = 30
|
|
29
|
+
|
|
30
|
+
function historyPath(stateDir: string): string {
|
|
31
|
+
return join(stateDir, 'applies.json')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The applies kept, oldest first; none when the file is missing or unreadable. */
|
|
35
|
+
export function readApplies(stateDir: string): ApplyRecord[] {
|
|
36
|
+
try {
|
|
37
|
+
const list = JSON.parse(readFileSync(historyPath(stateDir), 'utf8')) as unknown
|
|
38
|
+
return Array.isArray(list) ? list.filter((r): r is ApplyRecord => !!r && typeof (r as ApplyRecord).since === 'string' && typeof (r as ApplyRecord).trigger === 'string') : []
|
|
39
|
+
} catch {
|
|
40
|
+
return []
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Keep one finished apply. Never throws: a missed record must never fail an apply. */
|
|
45
|
+
export function recordApply(project: LocalProject, record: ApplyRecord): void {
|
|
46
|
+
try {
|
|
47
|
+
const list = [...readApplies(project.stateDir), record].slice(-KEEP)
|
|
48
|
+
writeFileAtomic(historyPath(project.stateDir), `${JSON.stringify(list, null, 2)}\n`, 0o644)
|
|
49
|
+
} catch {
|
|
50
|
+
// The apply itself is what matters.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** "files changed: agents/desk/agent.json and workflows/intake.yaml", or "… and 4 more". */
|
|
55
|
+
export function filesChangedTrigger(paths: Iterable<string>): string {
|
|
56
|
+
const list = [...new Set([...paths].map((p) => p.split(sep).join('/')))].sort()
|
|
57
|
+
if (!list.length) return 'files changed'
|
|
58
|
+
const shown = list.slice(0, 2)
|
|
59
|
+
const rest = list.length - shown.length
|
|
60
|
+
return `files changed: ${rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(' and ')}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Folders whose files never change what the runtime runs: the CLI's and
|
|
65
|
+
* Forge's own state (`.frontera/`, screenshots under `.frontera/evidence/`
|
|
66
|
+
* included), the coding agents' settings (`.claude/`, `.codex/`, `.cursor/`,
|
|
67
|
+
* `.agents/`), Git, packages, and the project's notes (`docs/`). A change
|
|
68
|
+
* under one is never a reason to apply, so the watcher doesn't even wake up.
|
|
69
|
+
*/
|
|
70
|
+
const NEVER_APPLIED = new Set(['.frontera', '.claude', '.codex', '.cursor', '.agents', '.git', 'node_modules', 'docs', 'dist', 'fixtures'])
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a changed path (relative to the project root) can change what the
|
|
74
|
+
* runtime runs: not under a folder above, not a hidden file or folder, and
|
|
75
|
+
* not a root file the runtime never reads (README.md, AGENTS.md, .mcp.json).
|
|
76
|
+
* `library.json` is the one root file that applies.
|
|
77
|
+
*/
|
|
78
|
+
export function canChangeRuntime(rel: string): boolean {
|
|
79
|
+
const parts = rel.split(sep).flatMap((p) => p.split('/')).filter(Boolean)
|
|
80
|
+
if (!parts.length) return false
|
|
81
|
+
if (parts.some((p) => NEVER_APPLIED.has(p) || p.startsWith('.'))) return false
|
|
82
|
+
if (parts.length === 1) return parts[0] === 'library.json' || !/\.\w+$/.test(parts[0]!)
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { normalize } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { refusedBlueprintItems, type BuildEntry } from './local-applied'
|
|
4
|
+
import { lockRefOf, type Unchanged } from './local-check-inherited'
|
|
5
|
+
import { RUNTIME_DOWN_ERROR } from './local-runtime-health'
|
|
6
|
+
|
|
7
|
+
/** One item that did not apply, for `local dev --once --json`: the item and why. */
|
|
8
|
+
export interface ApplyFailure {
|
|
9
|
+
item: string
|
|
10
|
+
error: string
|
|
11
|
+
/** The item as `local status` names it (`function/staff-availability`, `action/holdBooking`), when known. */
|
|
12
|
+
ref?: string
|
|
13
|
+
/** The project file that was applied, when the failure is a target's. */
|
|
14
|
+
file?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What `local dev --once` fails with, or null when everything applied: each
|
|
19
|
+
* target that failed, and each item the runtime refused inside a Blueprint
|
|
20
|
+
* that applied (a governed Action, a metric, a link type). Those are the
|
|
21
|
+
* failures `local status` and `local check` count, so the three say one
|
|
22
|
+
* number. `blueprint` is the Blueprint's build entry for the files as they
|
|
23
|
+
* are now; a Blueprint that failed as a whole is one failure, not its parts.
|
|
24
|
+
*/
|
|
25
|
+
export function applyFailures(failing: ReadonlyMap<string, ApplyFailure>, targetCount: number, blueprint: BuildEntry | undefined, refOf?: (key: string) => { ref: string; file?: string } | undefined): { message: string; failed: ApplyFailure[] } | null {
|
|
26
|
+
const refused = failing.has('blueprint') || !blueprint ? [] : refusedBlueprintItems(blueprint)
|
|
27
|
+
const named = (key: string, f: ApplyFailure): ApplyFailure => {
|
|
28
|
+
const at = refOf?.(key)
|
|
29
|
+
return at ? { ...f, ref: at.ref, ...(at.file ? { file: at.file } : {}) } : f
|
|
30
|
+
}
|
|
31
|
+
const failed = [...[...failing].map(([key, f]) => named(key, f)), ...refused.map((ref) => ({ item: ref, error: refusedReason(blueprint!, ref), ...(refOf ? { ref } : {}) }))]
|
|
32
|
+
if (!failed.length) return null
|
|
33
|
+
const message = refused.length
|
|
34
|
+
? `${failed.length} item${failed.length === 1 ? '' : 's'} didn’t apply: ${failing.size ? `${failing.size} of ${targetCount} failed, and ` : ''}the runtime refused ${refused.length} in the Blueprint (${refused.join(', ')})`
|
|
35
|
+
: `${failing.size} of ${targetCount} failed to apply`
|
|
36
|
+
return { message, failed }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function refusedReason(entry: BuildEntry, ref: string): string {
|
|
40
|
+
const [kind, name] = [ref.slice(0, ref.indexOf('/')), ref.slice(ref.indexOf('/') + 1)]
|
|
41
|
+
const why = kind === 'action' ? entry.actions?.[name]?.reason : kind === 'objectType' ? entry.objects?.[name]?.refused : entry.parts?.[ref]?.reason
|
|
42
|
+
return (why ?? 'the local runtime refused it').replace(/^(?!the local runtime refused it)/, 'the local runtime refused it: ')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The failures of an apply, split as `local check` splits them: inherited
|
|
47
|
+
* when the item is still byte for byte what the last pull wrote (its
|
|
48
|
+
* `frontera.lock` entry), and so is the file that was applied, so the change
|
|
49
|
+
* did not make it; made otherwise (an item changed or added here, a file
|
|
50
|
+
* added to a pulled item, the Blueprint as a whole, a failure with no `ref`). A failure that is the runtime's (it stopped answering, an item
|
|
51
|
+
* re-applied in a loop) is never inherited: it is not the workspace's.
|
|
52
|
+
* `strict` counts every failure as made.
|
|
53
|
+
*/
|
|
54
|
+
export function splitApplyFailures(failed: readonly ApplyFailure[], unchanged: Unchanged, opts: { strict?: boolean } = {}): { made: ApplyFailure[]; inherited: ApplyFailure[] } {
|
|
55
|
+
const made: ApplyFailure[] = []
|
|
56
|
+
const inherited: ApplyFailure[] = []
|
|
57
|
+
for (const f of failed) {
|
|
58
|
+
const runtimes = f.error === RUNTIME_DOWN_ERROR || /applied \d+ times with no change to its files/.test(f.error)
|
|
59
|
+
const asPulled = !!f.ref && unchanged.refs.has(lockRefOf(f.ref)) && (!f.file || unchanged.files.has(normalize(f.file)))
|
|
60
|
+
if (!opts.strict && !runtimes && asPulled) inherited.push(f)
|
|
61
|
+
else made.push(f)
|
|
62
|
+
}
|
|
63
|
+
return { made, inherited }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The one line an apply whose every failure is inherited ends with. */
|
|
67
|
+
export function inheritedApplyLine(count: number): string {
|
|
68
|
+
return `${count} inherited failure${count === 1 ? '' : 's'} in items unchanged since the pull: they fail as they do in the workspace, and nothing changed or added here failed (\`frontera local check --strict\` fails on them)`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The items `local dev --once --json` listed as inherited failures, by
|
|
73
|
+
* reference: from its result when it exited 0, or from its error's details
|
|
74
|
+
* when something changed or added failed too. Empty when it listed none.
|
|
75
|
+
*/
|
|
76
|
+
export function inheritedRefsOf(data: unknown): Set<string> {
|
|
77
|
+
const record = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
78
|
+
const from = record(data) ? (Array.isArray(data.inheritedFailures) ? data.inheritedFailures : record(data.details) && Array.isArray(data.details.inheritedFailures) ? data.details.inheritedFailures : []) : []
|
|
79
|
+
return new Set(from.flatMap((f) => (record(f) && typeof f.ref === 'string' ? [f.ref] : [])))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What one apply came to, in a line, for a caller that reads `ok` (the MCP `apply`). */
|
|
83
|
+
export function applySummaryLine(ok: boolean, made: number, inherited: number): string {
|
|
84
|
+
if (!ok) return made ? `${made} item${made === 1 ? '' : 's'} changed or added here didn’t apply${inherited ? `, and ${inheritedApplyLine(inherited).replace(/: they fail.*$/, '')}` : ''}` : 'the apply failed: see log'
|
|
85
|
+
return inherited ? `Everything changed or added here applied; ${inheritedApplyLine(inherited)}` : 'Everything applied'
|
|
86
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A guard on `local dev` re-applying an item whose files did not change.
|
|
3
|
+
*
|
|
4
|
+
* An unchanged item is applied again only for a reason that settles after one
|
|
5
|
+
* apply: its draft was published, a governed Action it deploys isn't running,
|
|
6
|
+
* a dataset it reads got a new revision, an Action it names was authored
|
|
7
|
+
* again. A reason that never settles is a bug, and it re-applies the item
|
|
8
|
+
* forever (a refused Action did, every few seconds, until b57501e3c). So an
|
|
9
|
+
* item applied `limit` times with the same content hash, no file event for
|
|
10
|
+
* it, within one drain or with less than `gapMs` between the applies, is not
|
|
11
|
+
* applied again until its files change.
|
|
12
|
+
*
|
|
13
|
+
* Only applies that succeeded count. A failed item is retried only after
|
|
14
|
+
* something else applied, so failures are bounded by the successes this
|
|
15
|
+
* guards; counting them would stop an item that is waiting, legitimately, for
|
|
16
|
+
* a slow dependency.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const REAPPLY_LIMIT = 3
|
|
20
|
+
/** The longest gap between two applies that still counts them as one run of re-applies. */
|
|
21
|
+
export const REAPPLY_GAP_MS = 20_000
|
|
22
|
+
|
|
23
|
+
interface Streak {
|
|
24
|
+
hash: string | null
|
|
25
|
+
/** Successful applies of this content since the streak began. */
|
|
26
|
+
applied: number
|
|
27
|
+
lastAt: number
|
|
28
|
+
drain: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type WatchdogVerdict = 'apply' | 'stop' | 'stopped'
|
|
32
|
+
|
|
33
|
+
export class ApplyWatchdog {
|
|
34
|
+
private readonly streaks = new Map<string, Streak>()
|
|
35
|
+
/** Items stopped, by the content hash they were stopped at. */
|
|
36
|
+
private readonly stopped = new Map<string, string | null>()
|
|
37
|
+
private readonly touched = new Set<string>()
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly limit = REAPPLY_LIMIT,
|
|
41
|
+
private readonly gapMs = REAPPLY_GAP_MS,
|
|
42
|
+
private readonly now: () => number = Date.now,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
/** A file event for the item: whatever it re-applied before starts over. */
|
|
46
|
+
fileEvent(key: string): void {
|
|
47
|
+
this.touched.add(key)
|
|
48
|
+
this.stopped.delete(key)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Before applying `key` at `hash` in drain number `drain`: `apply` to go
|
|
53
|
+
* ahead, `stop` the first time it is held back (say it), `stopped` after.
|
|
54
|
+
*/
|
|
55
|
+
before(key: string, hash: string | null, drain: number): WatchdogVerdict {
|
|
56
|
+
const at = this.now()
|
|
57
|
+
if (this.stopped.has(key)) {
|
|
58
|
+
if (this.stopped.get(key) === hash) return 'stopped'
|
|
59
|
+
this.stopped.delete(key)
|
|
60
|
+
}
|
|
61
|
+
const fresh = this.touched.delete(key)
|
|
62
|
+
let streak = this.streaks.get(key)
|
|
63
|
+
if (!streak || fresh || streak.hash !== hash || (streak.drain !== drain && at - streak.lastAt > this.gapMs)) {
|
|
64
|
+
streak = { hash, applied: 0, lastAt: at, drain }
|
|
65
|
+
this.streaks.set(key, streak)
|
|
66
|
+
}
|
|
67
|
+
if (streak.applied >= this.limit) {
|
|
68
|
+
this.stopped.set(key, hash)
|
|
69
|
+
this.streaks.delete(key)
|
|
70
|
+
return 'stop'
|
|
71
|
+
}
|
|
72
|
+
streak.lastAt = at
|
|
73
|
+
streak.drain = drain
|
|
74
|
+
return 'apply'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** After the apply `before` let through. */
|
|
78
|
+
after(key: string, ok: boolean): void {
|
|
79
|
+
const streak = this.streaks.get(key)
|
|
80
|
+
if (!streak) return
|
|
81
|
+
if (ok) streak.applied += 1
|
|
82
|
+
streak.lastAt = this.now()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** How many times an item was applied before it was stopped: for the line that says so. */
|
|
86
|
+
get applyLimit(): number {
|
|
87
|
+
return this.limit
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The line `local dev` says, and records, when it stops re-applying an item. */
|
|
92
|
+
export function stoppedReapplyingLine(what: string, times: number): string {
|
|
93
|
+
const name = what.charAt(0).toUpperCase() + what.slice(1)
|
|
94
|
+
return `${name} applied ${times} times with no change to its files: stopped re-applying it; this is a bug, please report it with \`frontera local logs\``
|
|
95
|
+
}
|