@frontera-sdk/cli 1.52.39 → 1.52.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parse as parseYaml } from 'yaml'
|
|
5
|
+
|
|
6
|
+
import { allTargets } from './local-dev-sync'
|
|
7
|
+
import { readSnapshot } from './local-sync-snapshot'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Workflows with no steps. One applies (the runtime takes an empty workflow
|
|
11
|
+
* as written) and every surface would mark it Applied, but a run does
|
|
12
|
+
* nothing: a placeholder made in Studio and never filled in, or a pull of
|
|
13
|
+
* one. Said as a warning beside its state, with what fixes it, by
|
|
14
|
+
* `local status`, `local dev`, Forge's home and Activity alike.
|
|
15
|
+
*/
|
|
16
|
+
export function emptyWorkflows(root: string): string[] {
|
|
17
|
+
const slugs = new Set<string>()
|
|
18
|
+
for (const target of allTargets(root)) {
|
|
19
|
+
if (target.kind !== 'workflow') continue
|
|
20
|
+
try {
|
|
21
|
+
const text = readFileSync(join(root, target.file), 'utf8')
|
|
22
|
+
const doc = (target.file.endsWith('.json') ? JSON.parse(text) : parseYaml(text)) as { nodes?: unknown; steps?: unknown } | null
|
|
23
|
+
if (!doc || typeof doc !== 'object') continue
|
|
24
|
+
const steps = doc.nodes ?? doc.steps
|
|
25
|
+
if (steps === undefined || steps === null || (Array.isArray(steps) && steps.length === 0)) slugs.add(target.slug)
|
|
26
|
+
} catch {
|
|
27
|
+
// A file that doesn't parse fails its apply, which says why.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [...slugs].sort()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The workflows of the workspace this project was pulled from, as its pull
|
|
35
|
+
* index (`.frontera/sync/remote.json`) knows them at the last fetch: how
|
|
36
|
+
* many it has (archived ones aside) and which are not in the project. Null
|
|
37
|
+
* when there is no index, or it never read workflows: then nothing is said.
|
|
38
|
+
*/
|
|
39
|
+
export interface WorkspaceWorkflows {
|
|
40
|
+
total: number
|
|
41
|
+
pulled: number
|
|
42
|
+
/** How many workflows the project holds now, pulled or made here: the count its home lists. */
|
|
43
|
+
here: number
|
|
44
|
+
/** Listed by the index without their file here, by slug; with no definition to file (`empty`) or not pulled. */
|
|
45
|
+
notHere: Array<{ slug: string; why: 'not-pulled' | 'no-definition' }>
|
|
46
|
+
fetchedAt: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function workspaceWorkflows(root: string): WorkspaceWorkflows | null {
|
|
50
|
+
const snap = readSnapshot(root)
|
|
51
|
+
if (!snap || !snap.kinds?.includes('workflow')) return null
|
|
52
|
+
const notHere: WorkspaceWorkflows['notHere'] = []
|
|
53
|
+
let pulled = 0
|
|
54
|
+
for (const [ref, item] of Object.entries(snap.items)) {
|
|
55
|
+
if (item.kind !== 'workflow') continue
|
|
56
|
+
if (item.files.length && item.files.every((f) => existsSync(join(root, f)))) pulled++
|
|
57
|
+
else notHere.push({ slug: ref.replace(/^workflow\//, ''), why: 'not-pulled' })
|
|
58
|
+
}
|
|
59
|
+
for (const ref of Object.keys(snap.skipped ?? {})) if (ref.startsWith('workflow/') && !snap.items[ref]) notHere.push({ slug: ref.slice('workflow/'.length), why: 'no-definition' })
|
|
60
|
+
const here = Math.max(pulled, new Set(allTargets(root).filter((t) => t.kind === 'workflow').map((t) => t.slug)).size)
|
|
61
|
+
return { total: pulled + notHere.length, pulled, here, notHere: notHere.sort((a, b) => a.slug.localeCompare(b.slug)), fetchedAt: snap.fetchedAt }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Where the project's workflows came from, counted from the project's side so
|
|
66
|
+
* the number matches the home's ("Workflows 2"): "1 of this project's 2
|
|
67
|
+
* workflows came from the workspace it was pulled from (at the last fetch,
|
|
68
|
+
* …); the other was made here, and the workspace has no other workflow to
|
|
69
|
+
* pull." Run 8 read "has 1 workflow …; 1 is in this project" as a wrong count
|
|
70
|
+
* beside a home listing 2.
|
|
71
|
+
*/
|
|
72
|
+
export function workspaceWorkflowsWords(w: WorkspaceWorkflows): string {
|
|
73
|
+
const fetched = `(at the last fetch, ${w.fetchedAt.slice(0, 10)})`
|
|
74
|
+
const made = w.here - w.pulled
|
|
75
|
+
const from = 'came from the workspace it was pulled from'
|
|
76
|
+
const lead =
|
|
77
|
+
w.pulled === w.here
|
|
78
|
+
? `${w.here === 1 ? 'This project’s workflow' : `All ${w.here} of this project’s workflows`} ${from} ${fetched}`
|
|
79
|
+
: `${w.pulled === 0 ? 'None' : w.pulled} of this project’s ${w.here} workflows ${from} ${fetched}; ${made === 1 ? 'the other was' : `the other ${made} were`} made here`
|
|
80
|
+
if (!w.notHere.length) return `${lead}, and the workspace has no other workflow to pull.`
|
|
81
|
+
const notPulled = w.notHere.filter((n) => n.why === 'not-pulled').map((n) => n.slug)
|
|
82
|
+
const empty = w.notHere.filter((n) => n.why === 'no-definition').map((n) => n.slug)
|
|
83
|
+
return `${lead}. ${[
|
|
84
|
+
notPulled.length ? `Not pulled from the workspace: ${notPulled.join(', ')} (\`frontera local sync pull ${notPulled.map((s) => `workflow/${s}`).join(' ')}\` brings ${notPulled.length === 1 ? 'it' : 'them'})` : '',
|
|
85
|
+
empty.length ? `${notPulled.length ? 'listed' : 'Listed'} there without a definition to file: ${empty.join(', ')}` : '',
|
|
86
|
+
].filter(Boolean).join('; ')}.`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The warning for one, as a row's warning line: "workflow/x: … Fix: …", with the workspace's other workflows when the pull index knows them. */
|
|
90
|
+
export function emptyWorkflowLine(slug: string, workspace: WorkspaceWorkflows | null = null): string {
|
|
91
|
+
return `workflow/${slug}: has no steps, so it does nothing when it runs. Fix: add its steps (Studio's Workflows page, or nodes: in its file under workflows/), or delete the file if it is a placeholder${workspace ? `. ${workspaceWorkflowsWords(workspace)}` : ''}`
|
|
92
|
+
}
|
package/src/local/local-env.ts
CHANGED
|
@@ -135,7 +135,7 @@ export function serviceEnv(args: {
|
|
|
135
135
|
runnerEnabled: boolean
|
|
136
136
|
jobsEnabled: boolean
|
|
137
137
|
storageEnabled: boolean
|
|
138
|
-
/**
|
|
138
|
+
/** Point the service's OpenAI client at the keyless local embeddings (no OpenAI key stored). */
|
|
139
139
|
keylessEmbeddings?: boolean
|
|
140
140
|
/** The customer documentation publication the service serves to coding agents. */
|
|
141
141
|
documentationBundle?: string | null
|
|
@@ -145,6 +145,8 @@ export function serviceEnv(args: {
|
|
|
145
145
|
const studio = `http://127.0.0.1:${ports.studio}`
|
|
146
146
|
const env: Record<string, string> = {
|
|
147
147
|
NODE_ENV: 'development',
|
|
148
|
+
// A local runtime: the service admits exact loopback origins (a Custom App's dev server on any port).
|
|
149
|
+
FRONTERA_LOCAL_RUNTIME: '1',
|
|
148
150
|
PORT: String(ports.service),
|
|
149
151
|
DATABASE_URL: databaseUrl(secrets, ports),
|
|
150
152
|
AGENT_CONFIG_REDIS_URL: `redis://127.0.0.1:${ports.cache}`,
|
|
@@ -164,9 +166,22 @@ export function serviceEnv(args: {
|
|
|
164
166
|
FUNCTIONS_ALLOW_LOCAL_HTTP: '1',
|
|
165
167
|
// Every local Agent is a draft: a Chat App's draft preview offers them, each run on its draft.
|
|
166
168
|
CHAT_APP_PREVIEW_DRAFT_AGENTS: '1',
|
|
169
|
+
// App previews load from this machine (`*.localhost` is loopback), never a public domain a
|
|
170
|
+
// checkout's own .env.local names; the empty values switch the public address zones off.
|
|
171
|
+
APPS_WILDCARD_DOMAIN: `http://apps.localhost:${ports.service}`,
|
|
172
|
+
APPS_ADDRESS_ZONE: '',
|
|
173
|
+
CHAT_APPS_ADDRESS_ZONE: '',
|
|
174
|
+
SITES_WILDCARD_DOMAIN: '',
|
|
167
175
|
// So are the MCP servers an FDE is building: the operator's exemption from
|
|
168
176
|
// the private-address rule, and on a laptop the FDE is the operator.
|
|
169
177
|
MCP_ALLOWED_PRIVATE_HOSTS: `127.0.0.1,localhost,${HOST_FROM_CONTAINER}`,
|
|
178
|
+
// No Agent computer: a checkout's .env can name a Daytona account (the service loads it from
|
|
179
|
+
// its directory), and Agents here would run bash, files and a browser on that remote machine
|
|
180
|
+
// while `local status` says they have none. Empty values win over the file; a release image
|
|
181
|
+
// has none either.
|
|
182
|
+
DAYTONA_API_KEY: '',
|
|
183
|
+
DAYTONA_API_URL: '',
|
|
184
|
+
DAYTONA_TARGET: '',
|
|
170
185
|
// Traces go to the runtime's own local traces, read back by Studio.
|
|
171
186
|
LANGFUSE_BASE_URL: `http://127.0.0.1:${ports.traces}`,
|
|
172
187
|
LANGFUSE_PUBLIC_KEY: LOCAL_TRACE_KEYS.publicKey,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import matter from 'gray-matter'
|
|
5
|
+
|
|
6
|
+
import { checkAgainstSchema, type SchemaProblem } from './local-schema-check'
|
|
7
|
+
import { cliSchema, type SchemaKind } from './local-schemas'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A Chat App file, a knowledge base's `knowledge.json` and a skill's
|
|
11
|
+
* `SKILL.md` front matter, checked against `frontera local schema <kind>`
|
|
12
|
+
* before `local dev` applies them.
|
|
13
|
+
*
|
|
14
|
+
* The platform keeps only the keys it knows and drops the rest without a word
|
|
15
|
+
* (a Chat App's `experience` block applied "✓" and was gone), so a key the
|
|
16
|
+
* format does not have is a warning that says it was left out. A value of the
|
|
17
|
+
* wrong shape (`roster.agents.0.agent` where `agentConfigId` belongs) is an
|
|
18
|
+
* error with its path, refused before the call rather than after it as a bare
|
|
19
|
+
* "Invalid configuration".
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface FileChecks {
|
|
23
|
+
errors: string[]
|
|
24
|
+
warnings: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const UNKNOWN_KEY = /^`([^`]+)` is not a key this file takes/
|
|
28
|
+
|
|
29
|
+
/** Split schema problems: an unknown key is left out (a warning); anything else is refused. */
|
|
30
|
+
export function splitProblems(file: string, problems: SchemaProblem[], what: string): FileChecks {
|
|
31
|
+
const errors: string[] = []
|
|
32
|
+
const warnings: string[] = []
|
|
33
|
+
for (const p of problems) {
|
|
34
|
+
const unknown = UNKNOWN_KEY.exec(p.message)
|
|
35
|
+
if (unknown) {
|
|
36
|
+
const takes = /\(it takes (.*)\)$/.exec(p.message)?.[1]
|
|
37
|
+
warnings.push(` warning: ${file} ${p.path}: ${what} has no \`${unknown[1]}\`, so it is left out${takes ? ` (it takes ${takes})` : ''}`)
|
|
38
|
+
} else errors.push(`${file} ${p.path || '(the file)'}: ${p.message}`)
|
|
39
|
+
}
|
|
40
|
+
return { errors, warnings }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function check(kind: SchemaKind, file: string, doc: unknown, what: string): FileChecks {
|
|
44
|
+
const schema = cliSchema(kind)
|
|
45
|
+
if (!schema) return { errors: [], warnings: [] }
|
|
46
|
+
return splitProblems(file, checkAgainstSchema(doc, schema), what)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A Chat App document (`chat-apps/<slug>.json`). */
|
|
50
|
+
export function chatAppFileChecks(file: string, doc: unknown): FileChecks {
|
|
51
|
+
return check('chat-app', file, doc, 'a Chat App')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A knowledge base's file. Unlike a Chat App's, nothing in it is refused
|
|
56
|
+
* here: `local dev` sends only the settings it knows, so a wrong value is a
|
|
57
|
+
* warning and the platform's answer decides.
|
|
58
|
+
*/
|
|
59
|
+
export function knowledgeFileChecks(file: string, doc: unknown): FileChecks {
|
|
60
|
+
const { errors, warnings } = check('knowledge', file, doc, 'knowledge.json')
|
|
61
|
+
return { errors: [], warnings: [...warnings, ...errors.map((e) => ` warning: ${e}`)] }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A skill's front matter. A missing name refuses the push (it is the skill's identity); the rest are warnings. */
|
|
65
|
+
export function skillFileChecks(file: string, text: string): FileChecks {
|
|
66
|
+
let data: Record<string, unknown>
|
|
67
|
+
try {
|
|
68
|
+
data = matter(text).data as Record<string, unknown>
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { errors: [`${file}: its front matter is not valid YAML: ${(err as Error).message}`], warnings: [] }
|
|
71
|
+
}
|
|
72
|
+
const schema = cliSchema('skill')!
|
|
73
|
+
const known = Object.keys((schema.properties ?? {}) as Record<string, unknown>)
|
|
74
|
+
const warnings = Object.keys(data)
|
|
75
|
+
.filter((k) => !known.includes(k))
|
|
76
|
+
.map((k) => ` warning: ${file}: the front matter has no \`${k}\`, so it is ignored (it takes ${known.map((n) => `\`${n}\``).join(', ')})`)
|
|
77
|
+
const errors: string[] = []
|
|
78
|
+
for (const p of checkAgainstSchema(data, schema)) {
|
|
79
|
+
if (p.message.includes('`name` is required')) errors.push(`${file}: its front matter needs \`name\`: the skill's identity, what agent.json binds as skill/<name>`)
|
|
80
|
+
else warnings.push(` warning: ${file} ${p.path || 'front matter'}: ${p.message}`)
|
|
81
|
+
}
|
|
82
|
+
return { errors, warnings }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The checks for one target's file, read from the project; nothing for a kind these do not cover. */
|
|
86
|
+
export function projectFileChecks(root: string, target: { kind: string; file?: string }): FileChecks {
|
|
87
|
+
const none = { errors: [], warnings: [] }
|
|
88
|
+
if (!target.file || !['chat-app', 'knowledge', 'skill'].includes(target.kind)) return none
|
|
89
|
+
let text: string
|
|
90
|
+
try {
|
|
91
|
+
text = readFileSync(join(root, target.file), 'utf8')
|
|
92
|
+
} catch {
|
|
93
|
+
return none
|
|
94
|
+
}
|
|
95
|
+
if (target.kind === 'skill') return skillFileChecks(target.file, text)
|
|
96
|
+
let doc: unknown
|
|
97
|
+
try {
|
|
98
|
+
doc = JSON.parse(text)
|
|
99
|
+
} catch {
|
|
100
|
+
// Unreadable: the apply says so with the file's own error.
|
|
101
|
+
return none
|
|
102
|
+
}
|
|
103
|
+
return target.kind === 'chat-app' ? chatAppFileChecks(target.file, doc) : knowledgeFileChecks(target.file, doc)
|
|
104
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { Directory } from './local-sync-refs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Object type ids a pulled Function's code hard-codes (pull D-3).
|
|
8
|
+
*
|
|
9
|
+
* A Function written against the workspace sometimes names an object type by
|
|
10
|
+
* the platform's id (`const SERVICE_REQUEST = 'b17f…'`), most often for an
|
|
11
|
+
* Action's `subjectRef`. The local runtime gives every object type its own
|
|
12
|
+
* id, so that code names a type the runtime doesn't have: an edit submitted
|
|
13
|
+
* there is refused, or worse, read as the wrong record. The pull knows the
|
|
14
|
+
* workspace's ids (the snapshot's directory), so it can say where.
|
|
15
|
+
*
|
|
16
|
+
* Two things are done with them:
|
|
17
|
+
*
|
|
18
|
+
* - **Said**, at pull and at each `local dev` apply: `<file>:<line>
|
|
19
|
+
* hard-codes <Type>’s platform id`, with the fix (read the id from the
|
|
20
|
+
* query result: `subjectOf`, or the result's `objectTypeId`).
|
|
21
|
+
* - **Adapted**, in the bundle `local dev` deploys and nowhere else: each
|
|
22
|
+
* quoted platform id becomes the local runtime's id for the same type
|
|
23
|
+
* (`bundleIdMap`, applied by `function deploy` when `local dev` passes the
|
|
24
|
+
* map in FRONTERA_BUNDLE_ID_MAP). The files keep the platform's ids, so
|
|
25
|
+
* what is pushed or deployed to the workspace is what was pulled.
|
|
26
|
+
*
|
|
27
|
+
* Only an exact match of a whole UUID the workspace gave an object type is
|
|
28
|
+
* touched: nothing else in the code can be that string by accident.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
|
32
|
+
|
|
33
|
+
/** One hard-coded platform id: the project file, its 1-based line, the id and the object type it names. */
|
|
34
|
+
export interface HardCodedId {
|
|
35
|
+
file: string
|
|
36
|
+
line: number
|
|
37
|
+
id: string
|
|
38
|
+
objectType: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The workspace's object type ids, id → apiName, from a pull's directory. */
|
|
42
|
+
export function platformObjectTypeIds(directory: Directory | undefined): Map<string, string> {
|
|
43
|
+
const out = new Map<string, string>()
|
|
44
|
+
for (const [name, id] of directory?.objectType ?? []) out.set(id.toLowerCase(), name)
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isCode = (path: string) => /\.(ts|tsx|mts|js|mjs)$/.test(path) && !/\.test\.[a-z]+$/.test(path) && !path.endsWith('.d.ts')
|
|
49
|
+
|
|
50
|
+
/** Each platform object type id the files' code (not their tests) hard-codes, in file and line order. */
|
|
51
|
+
export function hardCodedObjectTypeIds(files: Record<string, string>, platformIds: ReadonlyMap<string, string>): HardCodedId[] {
|
|
52
|
+
if (!platformIds.size) return []
|
|
53
|
+
const out: HardCodedId[] = []
|
|
54
|
+
for (const [file, text] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) {
|
|
55
|
+
if (!isCode(file)) continue
|
|
56
|
+
text.split('\n').forEach((line, i) => {
|
|
57
|
+
for (const m of line.matchAll(UUID)) {
|
|
58
|
+
const objectType = platformIds.get(m[0].toLowerCase())
|
|
59
|
+
if (objectType) out.push({ file, line: i + 1, id: m[0], objectType })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One sentence per hard-coded id, as the pull and `local dev` say it. */
|
|
67
|
+
export function hardCodedIdLine(hit: HardCodedId, adapted = false): string {
|
|
68
|
+
return `${hit.file}:${hit.line} hard-codes ${hit.objectType}’s platform id${adapted ? '; the bundle applied here uses this runtime’s id for it instead (the file is unchanged)' : ', which the local runtime doesn’t have'}. Take the id from the query result instead (\`subjectOf(result, row, id)\`, or \`result.objectTypeId\`), so the code holds on any runtime`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A Function's code files by project path: its file, or every file in its folder (`functions/<slug>/`). */
|
|
72
|
+
export function functionCodeFiles(root: string, file: string, folder: boolean): Record<string, string> {
|
|
73
|
+
const out: Record<string, string> = {}
|
|
74
|
+
const read = (rel: string) => {
|
|
75
|
+
try {
|
|
76
|
+
out[rel] = readFileSync(join(root, rel), 'utf8')
|
|
77
|
+
} catch {
|
|
78
|
+
// Gone since it was listed: nothing to say about it.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!folder) {
|
|
82
|
+
read(file)
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
const parts = file.split(/[\\/]/)
|
|
86
|
+
const dir = join(root, parts[0]!, parts[1]!)
|
|
87
|
+
const walk = (at: string) => {
|
|
88
|
+
for (const name of readdirSync(at)) {
|
|
89
|
+
if (name === 'node_modules' || name.startsWith('.')) continue
|
|
90
|
+
const path = join(at, name)
|
|
91
|
+
if (statSync(path).isDirectory()) walk(path)
|
|
92
|
+
else if (isCode(name)) read(relative(root, path).split(sep).join('/'))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (existsSync(dir)) walk(dir)
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Platform id → this runtime's id, for each object type both know by the
|
|
101
|
+
* same apiName and whose ids differ: what `function deploy` puts in the bundle
|
|
102
|
+
* in place of the hard-coded one. Lowercase keys.
|
|
103
|
+
*/
|
|
104
|
+
export function bundleIdMap(hits: readonly HardCodedId[], localIds: ReadonlyMap<string, string> | undefined): Record<string, string> {
|
|
105
|
+
const out: Record<string, string> = {}
|
|
106
|
+
for (const h of hits) {
|
|
107
|
+
const local = localIds?.get(h.objectType)
|
|
108
|
+
if (local && local.toLowerCase() !== h.id.toLowerCase()) out[h.id.toLowerCase()] = local
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The environment variable `local dev` hands `function deploy` the map in. */
|
|
114
|
+
export const BUNDLE_ID_MAP_ENV = 'FRONTERA_BUNDLE_ID_MAP'
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The built code with each quoted platform id (`'…'`, `"…"` or a template
|
|
118
|
+
* literal) replaced by its local one, case kept as the map's value gives it.
|
|
119
|
+
* Unquoted text (a comment) is left as it is.
|
|
120
|
+
*/
|
|
121
|
+
export function mapBundleIds(code: string, map: Readonly<Record<string, string>>): string {
|
|
122
|
+
if (!Object.keys(map).length) return code
|
|
123
|
+
return code.replace(/(['"`])([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\1/g, (whole, quote: string, id: string) => {
|
|
124
|
+
const local = map[id.toLowerCase()]
|
|
125
|
+
return local ? `${quote}${local}${quote}` : whole
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The map from FRONTERA_BUNDLE_ID_MAP, or empty when unset or not a map of ids. */
|
|
130
|
+
export function bundleIdMapFromEnv(env: Record<string, string | undefined> = process.env): Record<string, string> {
|
|
131
|
+
const raw = env[BUNDLE_ID_MAP_ENV]
|
|
132
|
+
if (!raw) return {}
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(raw) as unknown
|
|
135
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
136
|
+
const ok = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-fA-F-]{36}$/.test(v)
|
|
137
|
+
return Object.fromEntries(Object.entries(parsed).filter(([k, v]) => ok(k) && ok(v)).map(([k, v]) => [k.toLowerCase(), v as string]))
|
|
138
|
+
} catch {
|
|
139
|
+
return {}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { mergeRecovered, recoverFromBundle, recoveredFiles, type RecoveredModule } from './function-bundle-recovery'
|
|
4
|
+
import type { RemoteItem } from './local-sync-plan'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A pull's Functions whose source archive lacks a module their code imports,
|
|
8
|
+
* and what it does about them once every Function is read.
|
|
9
|
+
*
|
|
10
|
+
* In order: a copy another Function's source holds (the same file, kept by a
|
|
11
|
+
* current deploy); else the module cut back out of the Function's deployed
|
|
12
|
+
* bundle (`function-bundle-recovery.ts`), one file for every Function that
|
|
13
|
+
* needs it; else the reason, with today's remedy (deploy again from its source
|
|
14
|
+
* with a current CLI). A platform without the bundle route gets today's note.
|
|
15
|
+
*/
|
|
16
|
+
export interface MissingImports {
|
|
17
|
+
it: RemoteItem
|
|
18
|
+
slug: string
|
|
19
|
+
version: number
|
|
20
|
+
/** Its source archive, by archive path. */
|
|
21
|
+
files: ReadonlyMap<string, string>
|
|
22
|
+
entry: string
|
|
23
|
+
/** By path from its folder, without extension (`../shared/eligibility`). */
|
|
24
|
+
missing: string[]
|
|
25
|
+
/** Whether a pull may write this project path (without extension). */
|
|
26
|
+
writable: (projectPath: string) => boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const list = (paths: string[]) => (paths.length > 3 ? `${paths.slice(0, 3).join(', ')} and ${paths.length - 3} more` : paths.length > 1 ? `${paths.slice(0, -1).join(', ')} and ${paths[paths.length - 1]}` : paths.join(''))
|
|
30
|
+
|
|
31
|
+
export function missingImportNote(lacking: string[], why?: string): string {
|
|
32
|
+
return `Imports ${list(lacking)}, which its source in the workspace doesn’t hold${why ? ` and its deployed bundle couldn’t give back (${why})` : ''}: it was deployed before a deploy kept the files a Function imports from beside its folder, so it can’t build here until the project has ${lacking.length === 1 ? 'that file' : 'those files'}. Deploy it again from its source with a current CLI, then pull it`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function missingImportWarning(slug: string, lacking: string[], why?: string): string {
|
|
36
|
+
return `Function ${slug}: imports ${list(lacking)}, which its source in the workspace doesn’t hold${why ? ` and its deployed bundle couldn’t give back (${why})` : ''}; deploy it again from its source with a current CLI, then pull it`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A 404 from a platform that has no bundle route, rather than one that refused this version. */
|
|
40
|
+
function olderPlatform(err: unknown): boolean {
|
|
41
|
+
const message = (err as Error)?.message ?? ''
|
|
42
|
+
return /answered 404/.test(message) && (/Route not found/i.test(message) || /answered 404: (NOT_FOUND)?\s*$/.test(message))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function recoverMissingImports(
|
|
46
|
+
pending: MissingImports[],
|
|
47
|
+
items: RemoteItem[],
|
|
48
|
+
getBytes: (path: string) => Promise<Uint8Array>,
|
|
49
|
+
warnings: string[],
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const project = (p: MissingImports, m: string) => posix.normalize(posix.join('functions', p.slug, m))
|
|
52
|
+
// A real copy of the file in another Function's source wins over anything recovered.
|
|
53
|
+
const real = (path: string): { file: string; content: string; by: RemoteItem }[] => {
|
|
54
|
+
for (const ext of ['.ts', '.tsx', '/index.ts', '.js', '/index.js']) {
|
|
55
|
+
const by = items.find((i) => i.kind === 'function' && i.files[`${path}${ext}`] !== undefined && !i.recovered?.includes(`${path}${ext}`))
|
|
56
|
+
if (!by) continue
|
|
57
|
+
const file = `${path}${ext}`
|
|
58
|
+
const types = file.endsWith('.js') ? `${file.slice(0, -'.js'.length)}.d.ts` : null
|
|
59
|
+
return [{ file, content: by.files[file]!, by }, ...(types && by.files[types] !== undefined ? [{ file: types, content: by.files[types]!, by }] : [])]
|
|
60
|
+
}
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What the pull holds as source, from any Function's archive: a recovered module imports it rather than recovering it.
|
|
65
|
+
const elsewhere = new Map<string, string>()
|
|
66
|
+
for (const i of items) {
|
|
67
|
+
if (i.kind !== 'function') continue
|
|
68
|
+
for (const [file, content] of Object.entries(i.files)) {
|
|
69
|
+
if (/\.(ts|tsx|mts)$/.test(file) && !file.endsWith('.d.ts') && !i.recovered?.includes(file)) elsewhere.set(file.replace(/\.[^./]+$/, ''), content)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const recovered = new Map<string, RecoveredModule>()
|
|
73
|
+
const holders = new Map<string, MissingImports[]>()
|
|
74
|
+
const failed = new Map<MissingImports, Record<string, string>>()
|
|
75
|
+
const unread = new Map<MissingImports, string | null>()
|
|
76
|
+
for (const p of pending) {
|
|
77
|
+
const fromOthers: string[] = []
|
|
78
|
+
const remaining = p.missing.filter((m) => {
|
|
79
|
+
const copies = real(project(p, m))
|
|
80
|
+
if (!copies.length) return true
|
|
81
|
+
for (const c of copies) p.it.files[c.file] = c.content
|
|
82
|
+
fromOthers.push(`${copies[0]!.file} (from Function ${copies[0]!.by.name})`)
|
|
83
|
+
return false
|
|
84
|
+
})
|
|
85
|
+
if (fromOthers.length) p.it.notes.push(`Brings ${list(fromOthers)}, which it imports from beside its folder and another Function’s source in the workspace holds`)
|
|
86
|
+
if (!remaining.length) continue
|
|
87
|
+
let bundle: string
|
|
88
|
+
try {
|
|
89
|
+
bundle = new TextDecoder().decode(await getBytes(`/v1/functions/${encodeURIComponent(p.slug)}/versions/${p.version}/bundle`))
|
|
90
|
+
} catch (err) {
|
|
91
|
+
unread.set(p, olderPlatform(err) ? null : `its deployed bundle couldn’t be read: ${(err as Error).message}`)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const result = recoverFromBundle({ slug: p.slug, version: p.version, bundle, folder: posix.join('functions', p.slug), files: p.files, entry: p.entry, missing: remaining, writable: p.writable, elsewhere })
|
|
95
|
+
failed.set(p, result.failed)
|
|
96
|
+
for (const [path, names] of Object.entries(result.testOnly)) {
|
|
97
|
+
p.it.notes.push(`Its tests import ${names.map((n) => `\`${n}\``).join(', ')} from ${path}, which its deployed bundle doesn’t hold (the bundle holds only what the Function runs): those tests need the original source`)
|
|
98
|
+
}
|
|
99
|
+
for (const mod of result.modules) {
|
|
100
|
+
const had = recovered.get(mod.path)
|
|
101
|
+
const merged = had ? mergeRecovered(had, mod) : mod
|
|
102
|
+
if (!merged) {
|
|
103
|
+
result.failed[mod.path] = `the deployed bundles of ${had!.from.join(', ')} and ${p.slug} hold code for it that can’t be one file`
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
recovered.set(mod.path, merged)
|
|
107
|
+
holders.set(mod.path, [...(holders.get(mod.path) ?? []), p])
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// One file for every Function that needs it: the union of what their bundles held.
|
|
112
|
+
const wrote = new Map<MissingImports, string[]>()
|
|
113
|
+
for (const [path, mod] of recovered) {
|
|
114
|
+
const files = recoveredFiles(mod)
|
|
115
|
+
for (const p of holders.get(path) ?? []) {
|
|
116
|
+
Object.assign(p.it.files, files)
|
|
117
|
+
p.it.recovered = [...new Set([...(p.it.recovered ?? []), ...Object.keys(files)])].sort()
|
|
118
|
+
wrote.set(p, [...(wrote.get(p) ?? []), ...Object.keys(files).filter((f) => f.endsWith('.js'))])
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const p of pending) {
|
|
123
|
+
const done = wrote.get(p) ?? []
|
|
124
|
+
if (done.length) {
|
|
125
|
+
p.it.notes.push(`Recovered ${list(done.sort())} from its deployed bundle (v${p.version}): its source in the workspace doesn’t hold ${done.length === 1 ? 'that file' : 'those files'}. ${done.length === 1 ? 'It is' : 'They are'} bundled JavaScript with a .d.ts typing each export \`any\`, so it builds and runs here; the original types are gone, so code that relied on them may not typecheck (\`local check\` counts those errors as inherited while the files are as pulled). Replace ${done.length === 1 ? 'it' : 'them'} with the original source when you have it, or deploy the Function again from its source with a current CLI`)
|
|
126
|
+
}
|
|
127
|
+
if (unread.has(p)) {
|
|
128
|
+
const why = unread.get(p) ?? undefined
|
|
129
|
+
const lacking = p.missing.map((m) => project(p, m)).filter((m) => !Object.keys(p.it.files).some((f) => f.startsWith(`${m}.`) || f.startsWith(`${m}/index.`)))
|
|
130
|
+
if (lacking.length) {
|
|
131
|
+
p.it.notes.push(missingImportNote(lacking, why))
|
|
132
|
+
warnings.push(missingImportWarning(p.slug, lacking, why))
|
|
133
|
+
}
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
for (const [path, why] of Object.entries(failed.get(p) ?? {})) {
|
|
137
|
+
p.it.notes.push(missingImportNote([path], why))
|
|
138
|
+
warnings.push(missingImportWarning(p.slug, [path], why))
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -32,3 +32,90 @@ export async function mirrorFunctionState(project: LocalProject, key: string, se
|
|
|
32
32
|
}
|
|
33
33
|
return state.enabled ? [] : [` switched off Function ${slug}: it is off in the workspace`]
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/** `GET /v1/functions/registry-status`: what the service has deployed, and what a runner has registered with the job engine. */
|
|
37
|
+
interface RegistryStatus {
|
|
38
|
+
generation: number
|
|
39
|
+
registeredGeneration: number | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* After a Function deploy: wait until the job engine can run it. A deploy
|
|
44
|
+
* moves the service's registry generation (the deploy answers it as
|
|
45
|
+
* `registryGeneration`); the Function is runnable once a runner has registered
|
|
46
|
+
* that generation (`registeredGeneration >= registryGeneration`, the check
|
|
47
|
+
* `frontera function run` makes before it sends its event). Until then a
|
|
48
|
+
* workflow or Agent that starts it fails with "could not find function". On
|
|
49
|
+
* the local runtime the runner is asked to sync at once (`POST
|
|
50
|
+
* /internal/reload` on its loopback port) rather than at its next poll.
|
|
51
|
+
* Says "registering…" once through `note` while it waits; returns a
|
|
52
|
+
* `warning:` line when it is still not runnable at the cap (60 s), nothing
|
|
53
|
+
* when it is.
|
|
54
|
+
*/
|
|
55
|
+
export async function waitUntilRunnable(
|
|
56
|
+
serviceUrl: string,
|
|
57
|
+
creds: LocalCredentials,
|
|
58
|
+
slug: string,
|
|
59
|
+
opts: RunnableOptions = {},
|
|
60
|
+
): Promise<string[]> {
|
|
61
|
+
return (await checkRunnable(serviceUrl, creds, slug, opts)).lines
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type RunnableOptions = { note?: (line: string) => void; timeoutMs?: number; pollMs?: number; fetcher?: typeof fetch; now?: () => number; runnerUrl?: string; generation?: number }
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `waitUntilRunnable`, and whether it saw the Function runnable: true when a
|
|
68
|
+
* runner registered its generation (a runner is alive and the job engine can
|
|
69
|
+
* run it), false when it still was not at the cap, null when the runtime
|
|
70
|
+
* could not say.
|
|
71
|
+
*/
|
|
72
|
+
export async function checkRunnable(serviceUrl: string, creds: LocalCredentials, slug: string, opts: RunnableOptions = {}): Promise<{ lines: string[]; runnable: boolean | null }> {
|
|
73
|
+
if (!creds.workspaceKey) return { lines: [], runnable: null }
|
|
74
|
+
const fetcher = opts.fetcher ?? fetch
|
|
75
|
+
const now = opts.now ?? Date.now
|
|
76
|
+
const deadline = now() + (opts.timeoutMs ?? 60_000)
|
|
77
|
+
const read = async (): Promise<RegistryStatus | null> => {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetcher(new URL('/v1/functions/registry-status', serviceUrl), { headers: { authorization: `Bearer ${creds.workspaceKey}`, 'x-workspace-id': creds.workspaceId } })
|
|
80
|
+
if (!res.ok) return null
|
|
81
|
+
const body = (await res.json()) as { data?: RegistryStatus } & Partial<RegistryStatus>
|
|
82
|
+
const status = body.data ?? body
|
|
83
|
+
return typeof status.generation === 'number' ? { generation: status.generation, registeredGeneration: typeof status.registeredGeneration === 'number' ? status.registeredGeneration : null } : null
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// The deploy's own generation when it said one; else what the service has now, which is at least that.
|
|
89
|
+
const caughtUp = (st: RegistryStatus) => st.registeredGeneration !== null && st.registeredGeneration >= (opts.generation ?? st.generation)
|
|
90
|
+
let status = await read()
|
|
91
|
+
// A runtime that cannot say (an older service): nothing to wait on.
|
|
92
|
+
if (!status) return { lines: [], runnable: null }
|
|
93
|
+
if (caughtUp(status)) return { lines: [], runnable: true }
|
|
94
|
+
opts.note?.(` Function ${slug} is registering with the job engine…`)
|
|
95
|
+
if (opts.runnerUrl) {
|
|
96
|
+
try {
|
|
97
|
+
await fetcher(new URL('/internal/reload', opts.runnerUrl), { method: 'POST', signal: AbortSignal.timeout(15_000) })
|
|
98
|
+
} catch {
|
|
99
|
+
// The runner's own poll registers it; the reload only makes that sooner.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const started = now()
|
|
103
|
+
for (;;) {
|
|
104
|
+
const next = await read()
|
|
105
|
+
if (next) status = next
|
|
106
|
+
if (caughtUp(status)) {
|
|
107
|
+
opts.note?.(` Function ${slug} is runnable (${Math.round((now() - started) / 1000)} s)`)
|
|
108
|
+
return { lines: [], runnable: true }
|
|
109
|
+
}
|
|
110
|
+
if (now() >= deadline) break
|
|
111
|
+
await new Promise((r) => setTimeout(r, opts.pollMs ?? 1000))
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
runnable: false,
|
|
115
|
+
lines: [
|
|
116
|
+
status.registeredGeneration === null
|
|
117
|
+
? ` warning: Function ${slug} is deployed, but no runner has registered with the job engine, so nothing can run it yet: see \`frontera local logs runner\``
|
|
118
|
+
: ` warning: Function ${slug} is deployed but still registering with the job engine: a workflow or Agent that starts it now fails with "could not find function"; \`frontera function run ${slug}\` waits for it`,
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/local/local-keys.ts
CHANGED
|
@@ -106,10 +106,12 @@ export function providerEnv(): Record<string, string> {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
* With no OpenAI
|
|
110
|
-
*
|
|
111
|
-
*
|
|
109
|
+
* With no OpenAI key, the service's OpenAI client is pointed at the local
|
|
110
|
+
* keyless embeddings, so the runtime can embed with them: with no OpenAI or
|
|
111
|
+
* OpenRouter key, or when the provider refuses the key stored for it (the
|
|
112
|
+
* organization then gets a placeholder OpenAI credential). A real OpenAI key
|
|
113
|
+
* turns this off, and a key the provider takes replaces their model.
|
|
112
114
|
*/
|
|
113
|
-
export function
|
|
114
|
-
return !getKey('openai')
|
|
115
|
+
export function localEmbeddingsReachable(): boolean {
|
|
116
|
+
return !getKey('openai')
|
|
115
117
|
}
|
|
@@ -179,7 +179,7 @@ export function documentPaths(root: string, kb: string): string[] {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
if (existsSync(top)) walk('', 0)
|
|
182
|
-
return out.sort()
|
|
182
|
+
return out.sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
/** The documents in a knowledge base's folder and its subfolders, by path. */
|