@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,5 +1,8 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
|
|
1
3
|
import { parse as parseYaml } from 'yaml'
|
|
2
4
|
|
|
5
|
+
import { actionSemanticConflicts, type SemanticFacts } from '../blueprint/action-semantic-fit'
|
|
3
6
|
import { unnamedObjectBindings } from './local-objects'
|
|
4
7
|
import { governedActionIds, referencesIn } from './local-sync-refs'
|
|
5
8
|
|
|
@@ -38,7 +41,7 @@ export interface RuntimeCapabilities {
|
|
|
38
41
|
media: boolean
|
|
39
42
|
/** Built-in connections signed in with OAuth or a service account (Google Sheets, BigQuery…). */
|
|
40
43
|
connections: boolean
|
|
41
|
-
/** Calculated properties
|
|
44
|
+
/** Calculated properties other than a location (which `blueprint apply` adds); none has a route from a file yet. */
|
|
42
45
|
calculatedFields: boolean
|
|
43
46
|
/** Governed Actions: `local` deploys those that write the Blueprint's own records (D3). */
|
|
44
47
|
governedActions: 'local' | 'none'
|
|
@@ -78,6 +81,26 @@ export type Need =
|
|
|
78
81
|
| { kind: 'app-code'; external?: boolean }
|
|
79
82
|
/** An Agent's object tools whose object definition the pull couldn't name (an older platform). */
|
|
80
83
|
| { kind: 'objects-unnamed'; count: number }
|
|
84
|
+
/**
|
|
85
|
+
* A Function's code imports files the pull couldn't bring (its source in the
|
|
86
|
+
* workspace doesn't hold them), by project path without extension: it can't
|
|
87
|
+
* build here, as `local dev` says with FUNCTION_IMPORT_MISSING.
|
|
88
|
+
*/
|
|
89
|
+
| { kind: 'imports-missing'; paths: string[] }
|
|
90
|
+
/**
|
|
91
|
+
* A governed Action the local runtime refuses as written (the Action draft
|
|
92
|
+
* route's checks, such as an input value the set property's semantic type
|
|
93
|
+
* refuses): `local dev` tries it and it fails to apply, with that reason.
|
|
94
|
+
*/
|
|
95
|
+
| { kind: 'action-refused'; reasons: string[] }
|
|
96
|
+
/**
|
|
97
|
+
* A governed Action that offers a value the semantic type of the property it
|
|
98
|
+
* writes refuses, as the workspace runs it: it deploys here as it is there,
|
|
99
|
+
* the conflict said as a warning inherited from the workspace, and a request
|
|
100
|
+
* with such a value is refused when it is submitted, here as there. (An
|
|
101
|
+
* index from before this read the same Action as `action-refused`.)
|
|
102
|
+
*/
|
|
103
|
+
| { kind: 'action-inherited'; reasons: string[] }
|
|
81
104
|
|
|
82
105
|
export interface Portability {
|
|
83
106
|
class: PortabilityClass
|
|
@@ -108,8 +131,17 @@ export function judge(need: Need, ctx: Context = {}): { class: PortabilityClass;
|
|
|
108
131
|
const fit = ctx.actions?.get(need.name)
|
|
109
132
|
// Unknown here: what the build says about it decides; at fetch it comes down with the Blueprint.
|
|
110
133
|
if (!fit || fit.runsLocally) return null
|
|
134
|
+
if (fit.refused) return { class: 'adapted', text: `goes without governed Action ${need.name} here: the local runtime refuses it as written` }
|
|
111
135
|
return { class: 'adapted', text: `governed Action ${need.name} is switched off here: ${fit.reason}` }
|
|
112
136
|
}
|
|
137
|
+
case 'imports-missing':
|
|
138
|
+
return { class: 'platform-only', text: `won’t build here: it imports ${listed(need.paths)}, which its source in the workspace doesn’t hold, so the pull couldn’t bring ${need.paths.length === 1 ? 'it' : 'them'}` }
|
|
139
|
+
case 'action-refused':
|
|
140
|
+
// Not left off: `local dev` still deploys it, and the runtime refuses it, so the build says it failed.
|
|
141
|
+
return { class: 'platform-only', text: `the local runtime refuses it as written: ${need.reasons.join(' ')}` }
|
|
142
|
+
case 'action-inherited':
|
|
143
|
+
// It runs here as it does in the workspace: a warning, not an adaptation.
|
|
144
|
+
return { class: 'portable', text: `runs here as the workspace runs it, with a warning inherited from the workspace: ${need.reasons.join(' ')} The Blueprint’s author fixes the Action’s contract in the workspace` }
|
|
113
145
|
case 'action-unnamed':
|
|
114
146
|
return { class: 'platform-only', text: `runs ${need.count === 1 ? 'a governed Action' : `${need.count} governed Actions`} this key can’t name: add an organization key to bring ${need.count === 1 ? 'it' : 'them'}` }
|
|
115
147
|
case 'connection':
|
|
@@ -123,19 +155,19 @@ export function judge(need: Need, ctx: Context = {}): { class: PortabilityClass;
|
|
|
123
155
|
case 'media':
|
|
124
156
|
return caps.media ? null : { class: 'platform-only', text: 'reads the platform’s media files, so the local runtime leaves it off' }
|
|
125
157
|
case 'media-stub':
|
|
126
|
-
return caps.media ? null : { class: 'stubbed', text: `its files stay on the platform:
|
|
158
|
+
return caps.media ? null : { class: 'stubbed', text: `its files stay on the platform: a stand-in Media Set, ${need.set}, holds placeholder files for them here` }
|
|
127
159
|
case 'calculated':
|
|
128
160
|
return caps.calculatedFields
|
|
129
161
|
? null
|
|
130
|
-
: { class: 'adapted', text: `${need.fields.join(', ')} ${need.fields.length === 1 ? 'is' : 'are'} left off: calculated
|
|
162
|
+
: { class: 'adapted', text: `${need.fields.join(', ')} ${need.fields.length === 1 ? 'is' : 'are'} left off: only a location is a calculated field a file can add` }
|
|
131
163
|
case 'markings':
|
|
132
164
|
return { class: 'adapted', text: `reads every row: its markings come from the platform’s dataset, and ${need.dataset} has none` }
|
|
133
165
|
case 'external-effect':
|
|
134
166
|
return { class: 'stubbed', text: 'switched off here: it changes a system outside the local runtime' }
|
|
135
167
|
case 'app-draft':
|
|
136
|
-
return { class: 'adapted', text: 'saved as the owner’s draft here
|
|
168
|
+
return { class: 'adapted', text: 'saved as the owner’s draft here: Studio opens it at /apps/<slug> as its draft preview, its Agents running their drafts. It publishes once its Agents are published (a Chat App publishes only published Agents)' }
|
|
137
169
|
case 'app-code':
|
|
138
|
-
return { class: 'adapted', text: `saved here with the Functions it may start
|
|
170
|
+
return { class: 'adapted', text: `saved here with the Functions it may start; its code is its own project in apps/<slug>/, which a pull brings unless --no-app-code, where \`frontera app dev\` runs it against this runtime and \`frontera app deploy\` deploys it here${need.external ? '; it is hosted on its own domain, whose sign-in stays on the platform' : ''}` }
|
|
139
171
|
case 'objects-unnamed':
|
|
140
172
|
return { class: 'adapted', text: `goes without ${need.count === 1 ? 'one of its object tools' : `${need.count} of its object tools`} here: the pull couldn’t read ${need.count === 1 ? 'its object definition' : 'their object definitions'} on the platform` }
|
|
141
173
|
case 'context-unread':
|
|
@@ -143,6 +175,39 @@ export function judge(need: Need, ctx: Context = {}): { class: PortabilityClass;
|
|
|
143
175
|
}
|
|
144
176
|
}
|
|
145
177
|
|
|
178
|
+
const listed = (paths: string[]) => (paths.length > 1 ? `${paths.slice(0, -1).join(', ')} and ${paths[paths.length - 1]}` : paths.join(''))
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Why an item won't run locally at all, as a pull's summary says it: the
|
|
182
|
+
* reason, what fixes it, and whose fix it is. Empty when it runs, even adapted.
|
|
183
|
+
*/
|
|
184
|
+
export interface Blocker {
|
|
185
|
+
reason: string
|
|
186
|
+
fix: string
|
|
187
|
+
/** Who makes the fix: the item's owner in the workspace, or whoever works in this project. */
|
|
188
|
+
owner: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function blockers(needs: readonly Need[]): Blocker[] {
|
|
192
|
+
return needs.flatMap((n): Blocker[] => {
|
|
193
|
+
if (n.kind === 'imports-missing') {
|
|
194
|
+
return [{
|
|
195
|
+
reason: `it imports ${listed(n.paths)}, which its source in the workspace doesn’t hold, so it can’t build here`,
|
|
196
|
+
fix: `deploy it again in the workspace from its full source with a current CLI, then pull it; or add ${n.paths.length === 1 ? 'the file' : 'the files'} here if you have the source`,
|
|
197
|
+
owner: 'whoever deploys this Function in the workspace',
|
|
198
|
+
}]
|
|
199
|
+
}
|
|
200
|
+
if (n.kind === 'action-refused') {
|
|
201
|
+
return [{
|
|
202
|
+
reason: `the local runtime refuses it as written: ${n.reasons.join(' ')}`,
|
|
203
|
+
fix: 'change the Action (or the semantic type it writes) so the values agree, in the workspace’s Blueprint, then pull it',
|
|
204
|
+
owner: 'the Blueprint’s author in the workspace',
|
|
205
|
+
}]
|
|
206
|
+
}
|
|
207
|
+
return []
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
146
211
|
/** The class an item gets from its needs: the most limiting one wins. */
|
|
147
212
|
export function classify(needs: Need[], ctx: Context = {}): Portability {
|
|
148
213
|
let cls: PortabilityClass = 'portable'
|
|
@@ -172,7 +237,7 @@ export interface ObjectTypeFit {
|
|
|
172
237
|
leftOff: boolean
|
|
173
238
|
/** It reads a Media Set's catalog: the runtime holds it over an empty Media Set of this API name. */
|
|
174
239
|
mediaStub?: string | null
|
|
175
|
-
/** Calculated properties, by apiName: the runtime holds the type without them. */
|
|
240
|
+
/** Calculated properties other than a location, by apiName: the runtime holds the type without them. */
|
|
176
241
|
calculated: string[]
|
|
177
242
|
/** Its rows are read by markings from the platform's dataset: here it reads every row. */
|
|
178
243
|
readsEveryRow: boolean
|
|
@@ -206,7 +271,8 @@ export function objectTypeFit(doc: Doc, hasLocalDataset: (dataset: string) => bo
|
|
|
206
271
|
const set = mediaCatalogSet(doc)
|
|
207
272
|
return set ? { leftOff: false, mediaStub: set, calculated: [], readsEveryRow: false } : { leftOff: true, calculated: [], readsEveryRow: false }
|
|
208
273
|
}
|
|
209
|
-
|
|
274
|
+
// A location (from latitude and longitude) is added by `blueprint apply`; another calculation has no route from a file.
|
|
275
|
+
const calculated = properties.filter((p) => p.calculation && (p.calculation as { kind?: unknown }).kind !== 'location').map((p) => String(p.apiName ?? ''))
|
|
210
276
|
const dataset = doc.backing?.dataset
|
|
211
277
|
const readsEveryRow = doc.rowAccess === 'markings' && !doc.markingsColumn && typeof dataset === 'string' && hasLocalDataset(dataset)
|
|
212
278
|
return { leftOff: false, calculated, readsEveryRow }
|
|
@@ -232,8 +298,16 @@ export interface ActionFit {
|
|
|
232
298
|
runsLocally: boolean
|
|
233
299
|
/** Why not, in words. */
|
|
234
300
|
reason?: string
|
|
301
|
+
/** The local runtime refuses the file as written (not merely switches it off). */
|
|
302
|
+
refused?: boolean
|
|
235
303
|
/** The object type it acts on. */
|
|
236
304
|
subject: string | null
|
|
305
|
+
/**
|
|
306
|
+
* The values it offers that the semantic types it writes refuse, as the
|
|
307
|
+
* workspace has them (`changed: false`): it deploys as the workspace runs it,
|
|
308
|
+
* and these are said as warnings inherited from the workspace.
|
|
309
|
+
*/
|
|
310
|
+
inheritedWarnings?: string[]
|
|
237
311
|
}
|
|
238
312
|
|
|
239
313
|
const ONTOLOGY_EDITS = new Set(['create', 'update', 'delete'])
|
|
@@ -246,18 +320,55 @@ const ONTOLOGY_EDITS = new Set(['create', 'update', 'delete'])
|
|
|
246
320
|
* command, or an edit with nothing to set would be bound to a connector on the
|
|
247
321
|
* platform, which calls a real system: here it is published switched off.
|
|
248
322
|
*/
|
|
249
|
-
export function actionFit(doc: Doc): ActionFit {
|
|
323
|
+
export function actionFit(doc: Doc, semantic?: SemanticFacts, opts: { changed?: boolean } = {}): ActionFit {
|
|
250
324
|
const subject = typeof doc.subject?.objectType === 'string' ? doc.subject.objectType : null
|
|
325
|
+
// A value the semantic type it writes refuses (`actionSemanticConflicts`). The person's own change
|
|
326
|
+
// is refused before anything else, as the platform refuses a write that adds one: the runtime holds
|
|
327
|
+
// no such Action. The workspace's Action (as pulled) deploys as the workspace runs it, with a warning.
|
|
328
|
+
const conflicts = semantic ? actionSemanticConflicts(doc, semantic) : []
|
|
329
|
+
if (conflicts.length && opts.changed) return { runsLocally: false, refused: true, reason: `the local runtime refuses it: ${conflicts.map((c) => c.message).join(' ')}`, subject }
|
|
330
|
+
const inherited = conflicts.length ? { inheritedWarnings: conflicts.map((c) => c.message) } : {}
|
|
251
331
|
const effect = typeof doc.effect?.kind === 'string' ? doc.effect.kind : null
|
|
252
|
-
if (!effect || !ONTOLOGY_EDITS.has(effect)) return { runsLocally: false, reason: 'it changes a system outside the local runtime', subject }
|
|
332
|
+
if (!effect || !ONTOLOGY_EDITS.has(effect)) return { runsLocally: false, reason: 'it changes a system outside the local runtime', subject, ...inherited }
|
|
253
333
|
const changes = (Array.isArray(doc.businessOutcomes) ? doc.businessOutcomes : []).flatMap((o: Doc) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : []))
|
|
254
334
|
const sets = changes.some((c: Doc) => c?.kind === 'property_set' && c?.value?.kind === 'input')
|
|
255
|
-
if (effect !== 'delete' && !sets) return { runsLocally: false, reason: 'it sets no field from its inputs, so there is nothing to write locally', subject }
|
|
256
|
-
return { runsLocally: true, subject }
|
|
335
|
+
if (effect !== 'delete' && !sets) return { runsLocally: false, reason: 'it sets no field from its inputs, so there is nothing to write locally', subject, ...inherited }
|
|
336
|
+
return { runsLocally: true, subject, ...inherited }
|
|
257
337
|
}
|
|
258
338
|
|
|
259
|
-
|
|
260
|
-
|
|
339
|
+
/** What a pulled Action needs here: it is the workspace's as pulled, so a semantic-type conflict is inherited. */
|
|
340
|
+
export function actionNeeds(doc: Doc, semantic?: SemanticFacts): Need[] {
|
|
341
|
+
const conflicts = semantic ? actionSemanticConflicts(doc, semantic) : []
|
|
342
|
+
const inherited: Need[] = conflicts.length ? [{ kind: 'action-inherited', reasons: conflicts.map((c) => c.message) }] : []
|
|
343
|
+
return [...inherited, ...(actionFit(doc).runsLocally ? [] : [{ kind: 'external-effect' } as const])]
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Functions ────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
const CODE_FILE = /\.(ts|tsx|mts|js|mjs)$/
|
|
349
|
+
const RELATIVE_SPEC = /(?:from|import)\s*\(?\s*['"](\.{1,2}\/[^'"]+)['"]/g
|
|
350
|
+
const RESOLVE = ['', '.ts', '.tsx', '.mts', '.js', '.mjs', '.json', '/index.ts', '/index.js']
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The project files a Function's code imports that neither it nor any other
|
|
354
|
+
* pulled item holds, by project path without extension: what `local dev`'s
|
|
355
|
+
* bundler would fail to resolve (FUNCTION_IMPORT_MISSING). Tests are left
|
|
356
|
+
* out (a test's import fails the unit step, not the build), and so is the
|
|
357
|
+
* project's `vendor/` SDK, which every project has.
|
|
358
|
+
*/
|
|
359
|
+
export function missingFunctionImports(files: Record<string, string>, has: (path: string) => boolean): string[] {
|
|
360
|
+
const missing = new Set<string>()
|
|
361
|
+
for (const [path, text] of Object.entries(files)) {
|
|
362
|
+
if (!CODE_FILE.test(path) || /\.test\.[a-z]+$/.test(path) || path.endsWith('.d.ts')) continue
|
|
363
|
+
for (const m of text.matchAll(RELATIVE_SPEC)) {
|
|
364
|
+
const base = posix.normalize(posix.join(posix.dirname(path), m[1]!))
|
|
365
|
+
if (base.startsWith('vendor/') || base.split('/').includes('node_modules')) continue
|
|
366
|
+
const stem = base.replace(/\.(js|mjs)$/, '')
|
|
367
|
+
const found = base.startsWith('../') ? false : [...RESOLVE.map((e) => `${base}${e}`), ...RESOLVE.map((e) => `${stem}${e}`)].some((c) => files[c] !== undefined || has(c))
|
|
368
|
+
if (!found) missing.add(stem.replace(/\.(ts|tsx|mts)$/, ''))
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return [...missing].sort()
|
|
261
372
|
}
|
|
262
373
|
|
|
263
374
|
// ── Agents and workflows ─────────────────────────────────────────────────────
|
|
@@ -330,17 +441,31 @@ export function itemNeeds(kind: string, files: Record<string, string>, connectio
|
|
|
330
441
|
export function portabilityOf(
|
|
331
442
|
kind: string,
|
|
332
443
|
files: Record<string, string>,
|
|
333
|
-
opts: {
|
|
444
|
+
opts: {
|
|
445
|
+
connections?: ReadonlySet<string>
|
|
446
|
+
builtIns?: ReadonlyMap<string, BuiltInKind>
|
|
447
|
+
hasLocalDataset?: (dataset: string) => boolean
|
|
448
|
+
context?: Context
|
|
449
|
+
/** The pulled Blueprint's semantic types, shared fields and object types: an Action is checked against them. */
|
|
450
|
+
semantic?: SemanticFacts
|
|
451
|
+
/** Whether the pull (or the project) holds a file, by project path: a Function's imports are checked against it. */
|
|
452
|
+
hasFile?: (path: string) => boolean
|
|
453
|
+
} = {},
|
|
334
454
|
): Portability {
|
|
335
455
|
if (kind === 'blueprint') {
|
|
336
456
|
const needs = Object.entries(files).flatMap(([path, text]): Need[] => {
|
|
337
457
|
const doc = parse(path, text) as Doc | null
|
|
338
458
|
if (!doc || typeof doc !== 'object') return []
|
|
339
459
|
if (path.startsWith('blueprint/object-types/')) return objectTypeNeeds(doc, opts.hasLocalDataset ?? (() => true))
|
|
340
|
-
if (path.startsWith('blueprint/actions/')) return actionNeeds(doc)
|
|
460
|
+
if (path.startsWith('blueprint/actions/')) return actionNeeds(doc, opts.semantic)
|
|
341
461
|
return []
|
|
342
462
|
})
|
|
343
463
|
return classify(needs, opts.context)
|
|
344
464
|
}
|
|
345
|
-
|
|
465
|
+
const needs = itemNeeds(kind, files, opts.connections, opts.builtIns)
|
|
466
|
+
if (kind === 'function') {
|
|
467
|
+
const paths = missingFunctionImports(files, opts.hasFile ?? (() => false))
|
|
468
|
+
if (paths.length) needs.unshift({ kind: 'imports-missing', paths })
|
|
469
|
+
}
|
|
470
|
+
return classify(needs, opts.context)
|
|
346
471
|
}
|
package/src/local/local-ports.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { createServer } from 'node:net'
|
|
4
|
-
import { join } from 'node:path'
|
|
4
|
+
import { join, sep } from 'node:path'
|
|
5
5
|
|
|
6
6
|
import { writeFileAtomic } from '../fs-atomic'
|
|
7
7
|
import { dataDir } from '../paths'
|
|
8
|
+
import { removeRuntimeResources } from './local-docker'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Sticky ports, one block per local runtime.
|
|
@@ -14,6 +15,9 @@ import { dataDir } from '../paths'
|
|
|
14
15
|
* across restarts while two projects can run side by side. Same here: each
|
|
15
16
|
* runtime claims a block of ten consecutive ports starting at 54100 and keeps
|
|
16
17
|
* it, recorded in `~/.local/share/frontera/local-ports.json`.
|
|
18
|
+
*
|
|
19
|
+
* A block is freed by `frontera local remove`, and, when a new runtime needs
|
|
20
|
+
* one, for every project whose folder is gone.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
export type PortName = 'postgres' | 'service' | 'jobs' | 'runner' | 'studio' | 'cache' | 'embeddings' | 'storage' | 'traces'
|
|
@@ -58,20 +62,108 @@ function blockFrom(start: number): PortBlock {
|
|
|
58
62
|
}
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
/**
|
|
62
|
-
export
|
|
65
|
+
/** A block freed because its project folder is gone. */
|
|
66
|
+
export interface ReclaimedBlock {
|
|
67
|
+
/** The runtime's id: the block's key, and what its containers and volumes are named by. */
|
|
68
|
+
key: string
|
|
69
|
+
root: string
|
|
70
|
+
start: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PortClaim {
|
|
74
|
+
ports: PortBlock
|
|
75
|
+
/** Blocks freed to make this claim; empty when the project already had one. */
|
|
76
|
+
reclaimed: ReclaimedBlock[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mount points a folder can vanish under without being deleted: an external
|
|
81
|
+
* or network drive that is not mounted right now. A project under one whose
|
|
82
|
+
* mount point is missing is left alone, so unplugging a drive never deletes a
|
|
83
|
+
* runtime's data.
|
|
84
|
+
*/
|
|
85
|
+
function onAbsentMount(root: string): boolean {
|
|
86
|
+
const parts = root.split(sep).filter(Boolean)
|
|
87
|
+
const mountDepth = parts[0] === 'Volumes' || parts[0] === 'mnt' ? 2
|
|
88
|
+
: parts[0] === 'media' ? 3
|
|
89
|
+
: parts[0] === 'run' && parts[1] === 'media' ? 4
|
|
90
|
+
: 0
|
|
91
|
+
if (!mountDepth || parts.length < mountDepth) return false
|
|
92
|
+
return !existsSync(sep + parts.slice(0, mountDepth).join(sep))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether a block's project folder is gone, so the block and its runtime's data may go too. */
|
|
96
|
+
function isStale(entry: { root?: unknown }): entry is { start: number; root: string } {
|
|
97
|
+
return typeof entry.root === 'string' && entry.root.length > 0 && !existsSync(entry.root) && !onAbsentMount(entry.root)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function removeQuietly(key: string): void {
|
|
101
|
+
try {
|
|
102
|
+
removeRuntimeResources(key)
|
|
103
|
+
} catch {
|
|
104
|
+
// Without a container runtime there is nothing of it to remove; the block is free either way.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The block this project already owns, or a fresh one nobody else has claimed.
|
|
110
|
+
*
|
|
111
|
+
* A fresh claim first frees every block whose project folder no longer
|
|
112
|
+
* exists, deleting that runtime's containers and volumes with it
|
|
113
|
+
* (`cleanup`): they are named by the block's key, and nothing can reach them
|
|
114
|
+
* once the folder is gone.
|
|
115
|
+
*/
|
|
116
|
+
export function claimPortBlock(
|
|
117
|
+
projectId: string,
|
|
118
|
+
root: string,
|
|
119
|
+
cleanup: (key: string) => void = removeQuietly,
|
|
120
|
+
): PortClaim {
|
|
63
121
|
const registry = readRegistry()
|
|
64
122
|
const existing = registry.blocks[projectId]
|
|
65
|
-
if (existing) return blockFrom(existing.start)
|
|
123
|
+
if (existing) return { ports: blockFrom(existing.start), reclaimed: [] }
|
|
124
|
+
const reclaimed: ReclaimedBlock[] = []
|
|
125
|
+
for (const [key, entry] of Object.entries(registry.blocks)) {
|
|
126
|
+
if (!isStale(entry)) continue
|
|
127
|
+
delete registry.blocks[key]
|
|
128
|
+
reclaimed.push({ key, root: entry.root, start: entry.start })
|
|
129
|
+
}
|
|
130
|
+
// The registry first: a cleanup that fails part-way never leaves a block claimed by nothing.
|
|
131
|
+
const write = () => writeFileAtomic(registryPath(), `${JSON.stringify(registry, null, 2)}\n`, 0o644)
|
|
132
|
+
if (reclaimed.length) write()
|
|
133
|
+
for (const block of reclaimed) cleanup(block.key)
|
|
66
134
|
const taken = new Set(Object.values(registry.blocks).map((b) => b.start))
|
|
67
135
|
for (let i = 0; i < MAX_BLOCKS; i++) {
|
|
68
136
|
const start = BASE + i * BLOCK
|
|
69
137
|
if (taken.has(start)) continue
|
|
70
138
|
registry.blocks[projectId] = { start, root }
|
|
71
|
-
|
|
72
|
-
return blockFrom(start)
|
|
139
|
+
write()
|
|
140
|
+
return { ports: blockFrom(start), reclaimed }
|
|
73
141
|
}
|
|
74
|
-
throw new Error(
|
|
142
|
+
throw new Error(
|
|
143
|
+
`no free local port block left: all ${MAX_BLOCKS} belong to projects whose folders still exist; `
|
|
144
|
+
+ 'run `frontera local remove` in a project you no longer need',
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `claimPortBlock`, for a caller with no one to tell what was freed. */
|
|
149
|
+
export function claimPorts(projectId: string, root: string): PortBlock {
|
|
150
|
+
return claimPortBlock(projectId, root).ports
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The one line that says which blocks a claim freed, or null when it freed none. */
|
|
154
|
+
export function reclaimedNote(reclaimed: readonly ReclaimedBlock[]): string | null {
|
|
155
|
+
if (!reclaimed.length) return null
|
|
156
|
+
const names = reclaimed.map((b) => b.key)
|
|
157
|
+
const shown = names.length > 3 ? `${names.slice(0, 3).join(', ')} and ${names.length - 3} more` : names.join(', ')
|
|
158
|
+
return reclaimed.length === 1
|
|
159
|
+
? `Freed the local ports of ${shown}, whose project folder ${reclaimed[0]!.root} is gone, and deleted its containers and volumes.`
|
|
160
|
+
: `Freed the local ports of ${reclaimed.length} runtimes whose project folders are gone (${shown}), and deleted their containers and volumes.`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The block a project owns, or null. */
|
|
164
|
+
export function portBlockOf(projectId: string): PortBlock | null {
|
|
165
|
+
const entry = readRegistry().blocks[projectId]
|
|
166
|
+
return entry ? blockFrom(entry.start) : null
|
|
75
167
|
}
|
|
76
168
|
|
|
77
169
|
export function releasePorts(projectId: string): void {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { agentActionGapLine, agentActionGaps } from './local-agent-action-grants'
|
|
5
|
+
import { agentLeanPlugins, agentLeanPluginsLine } from './local-agent-lean-plugins'
|
|
6
|
+
import { emptyWorkflowLine, emptyWorkflows, workspaceWorkflows } from './local-empty-workflows'
|
|
7
|
+
import { earlierPullShadows, sdkShadowLine } from './local-app-code'
|
|
8
|
+
import { vendoredShadows } from './local-app-sdk'
|
|
9
|
+
import { staleSampleDataLine } from './local-sample-data'
|
|
10
|
+
import { sampleDatasetNames } from './local-synthetic-data'
|
|
11
|
+
import { readSnapshot, type RemoteSnapshot } from './local-sync-snapshot'
|
|
12
|
+
import { vendoredSdkLine, vendoredSdkState } from './local-vendored-sdk'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a coding agent needs to know about a project before it trusts what
|
|
16
|
+
* the local runtime shows, said by `local dev` and `local status` (and the
|
|
17
|
+
* MCP status tool) rather than left in generated files: what a pull could not
|
|
18
|
+
* bring and why, sample data an older generator made, a vendored SDK that is
|
|
19
|
+
* not this CLI's, Apps whose code stays on the platform or won't install as
|
|
20
|
+
* pulled, and Agents told to submit governed Actions their grants never offer.
|
|
21
|
+
*/
|
|
22
|
+
export interface ProjectNotices {
|
|
23
|
+
/** From the pull's index (`.frontera/sync/remote.json`): one line each. */
|
|
24
|
+
pull: string[]
|
|
25
|
+
/**
|
|
26
|
+
* Each pulled Agent's own notes from the pull's index (no behavior settings
|
|
27
|
+
* on the platform, a connection that stays there), one line per note with
|
|
28
|
+
* the Agents it is about. Said by `local status`, not as a warning: what
|
|
29
|
+
* came, not something to fix.
|
|
30
|
+
*/
|
|
31
|
+
agentNotes?: string[]
|
|
32
|
+
/** Each App the folder describes without its code, with how to get the code. */
|
|
33
|
+
apps: string[]
|
|
34
|
+
sampleData: string | null
|
|
35
|
+
sdk: string | null
|
|
36
|
+
/** Each Agent whose instructions or procedures name governed Actions it can't submit (`agentActionGaps`), or whose Plugins aren't offered (`agentLeanPlugins`), with the fix. */
|
|
37
|
+
agents: string[]
|
|
38
|
+
/** Each workflow with no steps: it applies, but does nothing (`emptyWorkflows`). */
|
|
39
|
+
workflows: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function projectNotices(root: string): ProjectNotices {
|
|
43
|
+
const snap = readSnapshot(root)
|
|
44
|
+
return {
|
|
45
|
+
pull: snap ? pullNoteLines(root, snap) : [],
|
|
46
|
+
agentNotes: snap ? agentPullNoteLines(root, snap) : [],
|
|
47
|
+
apps: snap ? appCodeLines(root, snap) : [],
|
|
48
|
+
sampleData: staleSampleDataLine(root, sampleDatasetNames(root)),
|
|
49
|
+
sdk: vendoredSdkLine(vendoredSdkState(root)),
|
|
50
|
+
agents: [...agentActionGaps(root).map(agentActionGapLine), ...agentLeanPlugins(root).map(agentLeanPluginsLine)],
|
|
51
|
+
workflows: emptyWorkflows(root).map((slug, _i, all) => emptyWorkflowLine(slug, all.length ? workspaceWorkflows(root) : null)),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The notices as lines, each with what it is about first; empty when there are none. */
|
|
56
|
+
export function noticeLines(n: ProjectNotices): string[] {
|
|
57
|
+
return [...n.pull, ...n.apps, ...(n.sampleData ? [n.sampleData] : []), ...(n.sdk ? [n.sdk] : []), ...n.agents, ...(n.workflows ?? [])]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const BEHAVIOR_UNREAD = /behavior settings couldn’t be read on the platform(?: \((.*?)\))?, so no operating\.json was pulled/
|
|
61
|
+
const CONTEXT_UNREAD = /Context and data couldn’t be read on the platform: ([^.]*)/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The pull's notes an agent has to act on or work around: Agents whose
|
|
65
|
+
* behavior settings or Context could not be read (and why), and what the
|
|
66
|
+
* whole pull left out. The rest of the index's notes describe what came, and
|
|
67
|
+
* `local dev` says it as it applies each item.
|
|
68
|
+
*/
|
|
69
|
+
export function pullNoteLines(root: string, snap: RemoteSnapshot): string[] {
|
|
70
|
+
const lines: string[] = []
|
|
71
|
+
const agentsHere = (ref: string) => existsSync(join(root, 'agents', ref.slice('agent/'.length), 'agent.json'))
|
|
72
|
+
const unread = new Map<string, string[]>()
|
|
73
|
+
for (const [ref, item] of Object.entries(snap.items)) {
|
|
74
|
+
if (!ref.startsWith('agent/') || !agentsHere(ref)) continue
|
|
75
|
+
for (const note of item.notes ?? []) {
|
|
76
|
+
const behavior = BEHAVIOR_UNREAD.exec(note)
|
|
77
|
+
if (behavior) {
|
|
78
|
+
const why = behavior[1] ?? 'the platform didn’t say why'
|
|
79
|
+
unread.set(why, [...(unread.get(why) ?? []), ref.slice('agent/'.length)])
|
|
80
|
+
}
|
|
81
|
+
const context = CONTEXT_UNREAD.exec(note)
|
|
82
|
+
if (context) lines.push(`${ref}: its Context and data couldn’t be read on the platform (${context[1]!.trim()}), so here it may read every object type the project holds, more than it may there`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const [why, slugs] of unread) {
|
|
86
|
+
const own = slugs.filter((s) => existsSync(join(root, 'agents', s, 'operating.json')))
|
|
87
|
+
lines.push(
|
|
88
|
+
`${slugs.length === 1 ? `agent/${slugs[0]}: its` : `${slugs.length} Agents (${slugs.join(', ')}): their`} behavior settings weren’t pulled (${why}), so the project has no operating.json from the workspace`
|
|
89
|
+
+ `${own.length ? `; ${own.map((s) => `agents/${s}/operating.json`).join(', ')} ${own.length === 1 ? 'was' : 'were'} written here and may differ from the workspace’s` : ''}`
|
|
90
|
+
+ `. Nothing here can show the workspace’s settings: \`frontera local sync pull ${slugs.map((s) => `agent/${s}`).join(' ')}\` reads them again (a platform from this release on lets the project’s workspace key read them)`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
for (const note of snap.notes ?? []) lines.push(`the pull: ${shortList(pullNoteText(note, snap.workspace, root))}`)
|
|
94
|
+
return lines
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Each pulled Agent's notes in the pull index that `pullNoteLines` doesn't
|
|
99
|
+
* already say (behavior settings or Context that couldn't be read), one
|
|
100
|
+
* line per note: "agent/vera: has no behavior settings in its snapshot on
|
|
101
|
+
* the platform (operating.json): …", or "agent/leo, agent/nova and
|
|
102
|
+
* agent/vera: each has …" when several Agents share it. Only Agents whose
|
|
103
|
+
* folder is in the project.
|
|
104
|
+
*/
|
|
105
|
+
export function agentPullNoteLines(root: string, snap: RemoteSnapshot): string[] {
|
|
106
|
+
const byNote = new Map<string, string[]>()
|
|
107
|
+
for (const [ref, item] of Object.entries(snap.items).sort(([a], [b]) => a.localeCompare(b))) {
|
|
108
|
+
if (!ref.startsWith('agent/') || !existsSync(join(root, 'agents', ref.slice('agent/'.length), 'agent.json'))) continue
|
|
109
|
+
for (const note of item.notes ?? []) {
|
|
110
|
+
if (BEHAVIOR_UNREAD.test(note) || CONTEXT_UNREAD.test(note)) continue
|
|
111
|
+
byNote.set(note, [...(byNote.get(note) ?? []), ref])
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// "Has no …" reads on after the Agent's name in lower case; a name or an acronym first keeps its case.
|
|
115
|
+
const lower = (note: string) => (/^[A-Z][a-z]/.test(note) ? `${note[0]!.toLowerCase()}${note.slice(1)}` : note)
|
|
116
|
+
return [...byNote].map(([note, refs]) => {
|
|
117
|
+
// "No behavior settings on the platform", for an Agent given an operating.json here since: it no longer runs on the defaults here.
|
|
118
|
+
const own = /\boperating\.json\b/.test(note) ? refs.map((r) => `agents/${r.slice('agent/'.length)}/operating.json`).filter((f) => existsSync(join(root, f))) : []
|
|
119
|
+
const since = own.length ? `; since then ${andList(own)} ${own.length === 1 ? 'was' : 'were'} written here, so ${own.length === 1 ? 'that Agent runs' : 'those Agents run'} on ${own.length === 1 ? 'it' : 'them'} here, not on the defaults` : ''
|
|
120
|
+
return `${refs.length === 1 ? `${refs[0]}: ${lower(note)}` : `${andList(refs)}: each ${lower(note)}`}${since}`
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** "a, b and c". */
|
|
125
|
+
const andList = (items: string[]) => (items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`)
|
|
126
|
+
|
|
127
|
+
/** A pull index note that governed Actions stayed out, as the CLI and older CLIs wrote it. */
|
|
128
|
+
const UNGRANTED = /^(\d+) governed Actions? acts? on object types (?:this|the) workspace isn’t granted, so (?:it stays|they stay) out: (.+)$/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The Agents and Apps in the project whose files name any of these governed
|
|
132
|
+
* Actions (an Agent's instructions or procedures, an App's description), as
|
|
133
|
+
* "Agent desk" and "App ops-console": what would miss them here.
|
|
134
|
+
*/
|
|
135
|
+
function namedBy(root: string, actions: string[]): string[] {
|
|
136
|
+
const escape = (name: string) => name.replace(/[.*+?^$()|[\]\\{}]/g, '\\$&')
|
|
137
|
+
const pattern = new RegExp(`\\b(?:${actions.map(escape).join('|')})\\b`)
|
|
138
|
+
const named = (text: string) => !!text && pattern.test(text)
|
|
139
|
+
const read = (file: string) => {
|
|
140
|
+
try {
|
|
141
|
+
return readFileSync(file, 'utf8')
|
|
142
|
+
} catch {
|
|
143
|
+
return ''
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const out: string[] = []
|
|
147
|
+
for (const slug of folderNames(join(root, 'agents'))) {
|
|
148
|
+
if (['agent.json', 'operating.json'].some((f) => named(read(join(root, 'agents', slug, f))))) out.push(`Agent ${slug}`)
|
|
149
|
+
}
|
|
150
|
+
for (const f of folderNames(join(root, 'apps')).filter((n) => n.endsWith('.json'))) {
|
|
151
|
+
if (named(read(join(root, 'apps', f)))) out.push(`App ${f.replace(/\.json$/, '')}`)
|
|
152
|
+
}
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function folderNames(dir: string): string[] {
|
|
157
|
+
try {
|
|
158
|
+
return readdirSync(dir).filter((n) => !n.startsWith('.')).sort()
|
|
159
|
+
} catch {
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A note of the pull's index, naming the workspace it means: the one the
|
|
166
|
+
* project was pulled from, which a copy linked to no workspace (or to
|
|
167
|
+
* another) can't call "this workspace". Notes from an older CLI said "this".
|
|
168
|
+
* The note that governed Actions stayed out says it plainly and with its next
|
|
169
|
+
* step: which of the project's Agents and Apps name them (read from `root`),
|
|
170
|
+
* and that a workspace admin grants the object types before a pull brings
|
|
171
|
+
* them. Its list of names stays last, for `shortList`.
|
|
172
|
+
*/
|
|
173
|
+
export function pullNoteText(note: string, workspace: string | null | undefined, root?: string): string {
|
|
174
|
+
const ungranted = UNGRANTED.exec(note)
|
|
175
|
+
if (ungranted) {
|
|
176
|
+
const n = Number(ungranted[1])
|
|
177
|
+
const names = ungranted[2]!.split(', ')
|
|
178
|
+
const one = n === 1
|
|
179
|
+
const where = workspace ? `this copy’s workspace (${workspace.slice(0, 8)})` : 'this copy’s workspace'
|
|
180
|
+
const users = root ? namedBy(root, names) : []
|
|
181
|
+
const who = users.length
|
|
182
|
+
? `${andList(users)} name${users.length === 1 ? 's' : ''} ${one ? 'it' : 'some of them'} and can’t submit ${one ? 'it' : 'those'} here`
|
|
183
|
+
: `No Agent or App here names ${one ? 'it' : 'them'}`
|
|
184
|
+
return `${n} governed Action${one ? ' acts' : 's act'} on object types ${where} isn’t granted, so ${one ? 'it wasn’t' : 'they weren’t'} pulled. ${who}; to bring ${one ? 'it' : 'them'}, a workspace admin grants those object types to the workspace, then pull again. ${one ? 'It is' : 'They are'}: ${names.join(', ')}`
|
|
185
|
+
}
|
|
186
|
+
if (!workspace) return note
|
|
187
|
+
return note.replace(/\b(?:this|the) workspace(?= is(?:n’t)? granted)/g, `the workspace it was pulled from (${workspace.slice(0, 8)})`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A note's list of names cut to the first few: "…stay out: a, b, c, d, … (75)". */
|
|
191
|
+
export function shortList(note: string): string {
|
|
192
|
+
const at = note.lastIndexOf(': ')
|
|
193
|
+
if (at < 0) return note
|
|
194
|
+
const names = note.slice(at + 2).split(', ')
|
|
195
|
+
return names.length > 5 ? `${note.slice(0, at + 2)}${names.slice(0, 4).join(', ')}, … (${names.length})` : note
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Each App the project describes (`apps/<slug>.json`, as a pull writes it)
|
|
200
|
+
* without its code in `apps/<slug>/` (a pull with `--no-app-code`, or one
|
|
201
|
+
* whose source the platform didn't hold): the file is what the App is and the
|
|
202
|
+
* Functions it may start. Said with the version the workspace deployed and
|
|
203
|
+
* the pull that brings that code. A pulled App whose code is here but whose
|
|
204
|
+
* package.json and bun.lock disagree is said too, with the command that
|
|
205
|
+
* settles it, before a render or a check finds it.
|
|
206
|
+
*/
|
|
207
|
+
export function appCodeLines(root: string, snap: RemoteSnapshot): string[] {
|
|
208
|
+
return Object.entries(snap.items).flatMap(([ref, item]) => {
|
|
209
|
+
if (!ref.startsWith('app/')) return []
|
|
210
|
+
const slug = ref.slice('app/'.length)
|
|
211
|
+
const file = join(root, 'apps', `${slug}.json`)
|
|
212
|
+
if (!existsSync(file)) return []
|
|
213
|
+
// Its code is here: said only when it won't install as it is (package.json and bun.lock disagree).
|
|
214
|
+
if (existsSync(join(root, 'apps', slug))) {
|
|
215
|
+
const shadows = vendoredShadows(join(root, 'apps', slug))
|
|
216
|
+
// An earlier pull's folder, unchanged since: the pull that brings the deployed source again is the first fix.
|
|
217
|
+
const repull = shadows.length && earlierPullShadows(root, slug).length ? { repull: ref } : {}
|
|
218
|
+
return shadows.length ? [`${ref}: ${sdkShadowLine(`apps/${slug}`, shadows, repull)}`] : []
|
|
219
|
+
}
|
|
220
|
+
let version: string | undefined
|
|
221
|
+
try {
|
|
222
|
+
const doc = JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }
|
|
223
|
+
version = typeof doc.version === 'string' && doc.version ? doc.version : undefined
|
|
224
|
+
} catch {
|
|
225
|
+
version = undefined
|
|
226
|
+
}
|
|
227
|
+
version ??= item.version?.replace(/^v/, '')
|
|
228
|
+
return [
|
|
229
|
+
version
|
|
230
|
+
? `${ref}: apps/${slug}.json is the App’s description only; its code (version ${version}) isn’t in the project (pulled with --no-app-code or by an earlier CLI, or the platform held no source for it). \`frontera local sync pull ${ref}\` brings it into apps/${slug}/, where \`frontera local dev\` builds it like any App folder`
|
|
231
|
+
: `${ref}: apps/${slug}.json is the App’s description only, and it was never deployed in the workspace, so the platform holds no code for it: \`frontera app init\` starts one`,
|
|
232
|
+
]
|
|
233
|
+
})
|
|
234
|
+
}
|