@frontera-sdk/cli 1.52.38 → 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
|
@@ -0,0 +1,133 @@
|
|
|
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 { functionSecretNames, hasSecretValue, type SecretPresence, workflowSecretNames } from './local-secrets'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The secrets a project needs, by name: `secrets.json` at its root.
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* "secrets": [
|
|
14
|
+
* { "name": "INTAKE_WEBHOOK_TOKEN", "description": "The token the intake webhook checks" }
|
|
15
|
+
* ]
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* Names and descriptions only, never values: a value is set on each computer
|
|
19
|
+
* with `frontera secret set NAME` and stays on that runtime. The file makes a
|
|
20
|
+
* project reproducible from its files: after `local reset`, or on another
|
|
21
|
+
* computer, `local dev` and `local check` say which secrets the runtime still
|
|
22
|
+
* needs a value for, and why each one is there. A `value` in the file is
|
|
23
|
+
* refused (said, and ignored), so a value is never written into a commit by
|
|
24
|
+
* mistake.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const PROJECT_SECRETS_FILE = 'secrets.json'
|
|
28
|
+
|
|
29
|
+
export interface ProjectSecret {
|
|
30
|
+
name: string
|
|
31
|
+
description?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProjectSecrets {
|
|
35
|
+
secrets: ProjectSecret[]
|
|
36
|
+
/** What is wrong with the file, one line each: said as warnings. */
|
|
37
|
+
problems: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NAME = /^[A-Z][A-Z0-9_]*$/
|
|
41
|
+
|
|
42
|
+
/** The project's `secrets.json`, or null when it has none. Never throws: a bad file is its problems. */
|
|
43
|
+
export function readProjectSecrets(root: string): ProjectSecrets | null {
|
|
44
|
+
const path = join(root, PROJECT_SECRETS_FILE)
|
|
45
|
+
if (!existsSync(path)) return null
|
|
46
|
+
let doc: unknown
|
|
47
|
+
try {
|
|
48
|
+
doc = JSON.parse(readFileSync(path, 'utf8'))
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { secrets: [], problems: [`${PROJECT_SECRETS_FILE} is not valid JSON: ${(err as Error).message}`] }
|
|
51
|
+
}
|
|
52
|
+
const raw = (doc as { secrets?: unknown } | null)?.secrets
|
|
53
|
+
if (!Array.isArray(raw)) return { secrets: [], problems: [`${PROJECT_SECRETS_FILE} has no \`secrets\` list: { "secrets": [ { "name": "API_TOKEN", "description": "…" } ] }`] }
|
|
54
|
+
const secrets: ProjectSecret[] = []
|
|
55
|
+
const problems: string[] = []
|
|
56
|
+
for (const [i, entry] of raw.entries()) {
|
|
57
|
+
const o = (entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}) as Record<string, unknown>
|
|
58
|
+
const name = typeof o.name === 'string' ? o.name.trim() : ''
|
|
59
|
+
if (!NAME.test(name)) {
|
|
60
|
+
problems.push(`${PROJECT_SECRETS_FILE}: secret ${i + 1} needs a \`name\` in capitals, digits and underscores (API_TOKEN)`)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if ('value' in o) problems.push(`${PROJECT_SECRETS_FILE}: ${name} has a \`value\`, which is ignored and must not be committed: remove it, and set the value with \`frontera secret set ${name}\``)
|
|
64
|
+
if (secrets.some((s) => s.name === name)) continue
|
|
65
|
+
secrets.push({ name, ...(typeof o.description === 'string' && o.description.trim() ? { description: o.description.trim() } : {}) })
|
|
66
|
+
}
|
|
67
|
+
return { secrets, problems }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The secret names the project's Functions and workflows name in their files,
|
|
72
|
+
* each with the first file that names it: a Function's `secret:NAME` grants
|
|
73
|
+
* (a single-file Function, or a folder's entry) and a workflow's `auth`.
|
|
74
|
+
*/
|
|
75
|
+
export function secretsNamedInFiles(root: string): Map<string, string> {
|
|
76
|
+
const out = new Map<string, string>()
|
|
77
|
+
for (const target of allTargets(root)) {
|
|
78
|
+
if (target.kind !== 'function' && target.kind !== 'workflow') continue
|
|
79
|
+
let names: string[] = []
|
|
80
|
+
try {
|
|
81
|
+
const text = readFileSync(join(root, target.file), 'utf8')
|
|
82
|
+
names = target.kind === 'function' ? functionSecretNames(text) : workflowSecretNames(parseYaml(text))
|
|
83
|
+
} catch {
|
|
84
|
+
// Unreadable: it names nothing here.
|
|
85
|
+
}
|
|
86
|
+
for (const name of names) if (!out.has(name)) out.set(name, target.file)
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The warning for a secret the runtime has no value for; a listed one says it lost its value. */
|
|
92
|
+
function needsValue(name: string, who: string, listed: boolean): string {
|
|
93
|
+
return listed
|
|
94
|
+
? ` warning: Needs a value for ${name} (${who}): the runtime lists it, but has no value for it (restoring the database can leave a secret without one); set it again with \`frontera secret set ${name}\` in this folder`
|
|
95
|
+
: ` warning: Needs a value for ${name} (${who}): set it with \`frontera secret set ${name}\` in this folder`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What to say about the secrets the project needs against what the runtime
|
|
100
|
+
* holds (`have`: the names it has a value for, or what its secrets list says;
|
|
101
|
+
* null when it could not say): each secret `secrets.json` declares or a
|
|
102
|
+
* workflow or Function names that has no value here, said for every item on
|
|
103
|
+
* each call (not only for an item an apply changed, so a value lost to a
|
|
104
|
+
* restore is said), each secret a file names that `secrets.json` leaves out,
|
|
105
|
+
* and the file's own problems. `warning:` lines, as `local dev` says them.
|
|
106
|
+
*/
|
|
107
|
+
export function projectSecretLines(root: string, have: ReadonlySet<string> | SecretPresence | null): string[] {
|
|
108
|
+
const declared = readProjectSecrets(root)
|
|
109
|
+
const named = secretsNamedInFiles(root)
|
|
110
|
+
if (!declared && !named.size) return []
|
|
111
|
+
const lines = (declared?.problems ?? []).map((p) => ` warning: ${p}`)
|
|
112
|
+
if (have) {
|
|
113
|
+
const presence: SecretPresence = have instanceof Set ? { names: new Set(have), withoutValue: new Set() } : (have as SecretPresence)
|
|
114
|
+
const said = new Set<string>()
|
|
115
|
+
for (const s of declared?.secrets ?? []) {
|
|
116
|
+
if (hasSecretValue(presence, s.name)) continue
|
|
117
|
+
said.add(s.name)
|
|
118
|
+
lines.push(needsValue(s.name, `${PROJECT_SECRETS_FILE}${s.description ? `: ${s.description}` : ''}`, presence.names.has(s.name)))
|
|
119
|
+
}
|
|
120
|
+
for (const [name, file] of named) {
|
|
121
|
+
if (said.has(name) || hasSecretValue(presence, name)) continue
|
|
122
|
+
said.add(name)
|
|
123
|
+
lines.push(needsValue(name, file, presence.names.has(name)))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (declared) {
|
|
127
|
+
const names = new Set(declared.secrets.map((s) => s.name))
|
|
128
|
+
for (const [name, file] of named) {
|
|
129
|
+
if (!names.has(name)) lines.push(` warning: ${file} names the secret ${name}, which ${PROJECT_SECRETS_FILE} doesn't list: add { "name": "${name}", "description": "…" } so the project says what it needs`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return lines
|
|
133
|
+
}
|
|
@@ -56,3 +56,15 @@ export function localProject(cwd: string): LocalProject {
|
|
|
56
56
|
stateDir: join(root, '.frontera', 'local'),
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The CLI profile of the local runtime this directory owns
|
|
62
|
+
* (`local-<id>`, as `local status` names it), or null when the directory has
|
|
63
|
+
* never started one. The profile name only: whether it is registered on this
|
|
64
|
+
* machine is the caller's to check.
|
|
65
|
+
*/
|
|
66
|
+
export function localRuntimeProfileOf(root: string): string | null {
|
|
67
|
+
const project = localProject(root)
|
|
68
|
+
if (!existsSync(join(project.stateDir, 'state.json'))) return null
|
|
69
|
+
return `local-${project.id}`.slice(0, 64)
|
|
70
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { caseCouldNotRun, notRunFix, withDirectModel, type NotRunKind } from './local-case-not-run'
|
|
2
|
+
import { findProvider } from './local-keys'
|
|
3
|
+
import { directModelFor } from './local-seed'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A model provider's refusal met outside a test case: an `agent_chat` turn,
|
|
7
|
+
* or a Function run whose Agent call the provider refused. Said as the cause
|
|
8
|
+
* (the provider account, named when its words name it) and the fix, the way
|
|
9
|
+
* `test_run` says a case that couldn't run, instead of the provider's raw
|
|
10
|
+
* words. Only the provider's refusals are turned into this: a timeout or a
|
|
11
|
+
* runtime that does not answer keeps its own message.
|
|
12
|
+
*/
|
|
13
|
+
export interface ProviderRefusal {
|
|
14
|
+
cause: 'provider'
|
|
15
|
+
kind: Extract<NotRunKind, 'credits' | 'rate-limit' | 'key' | 'provider-error'>
|
|
16
|
+
/** The refusing provider's id and label, when its words name it. */
|
|
17
|
+
provider?: string
|
|
18
|
+
providerLabel?: string
|
|
19
|
+
/** "the OpenRouter account refused the request: it is out of credit (402)". */
|
|
20
|
+
what: string
|
|
21
|
+
fix: string
|
|
22
|
+
/** A model the runtime offers directly from another provider whose key it holds, for a credit refusal. */
|
|
23
|
+
directModel?: { slug: string; name: string; provider: string }
|
|
24
|
+
/** The provider's own words, for the record. */
|
|
25
|
+
providerSaid: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const WHAT: Record<ProviderRefusal['kind'], (account: string, status: string) => string> = {
|
|
29
|
+
credits: (account, status) => `the ${account} refused the request: it is out of credit (${status || '402'})`,
|
|
30
|
+
'rate-limit': (account, status) => `the ${account} is limiting requests (${status || '429'})`,
|
|
31
|
+
key: (account, status) => `the ${account} refused the key${status ? ` (${status})` : ''}`,
|
|
32
|
+
'provider-error': (account, status) => `the ${account.replace(/ account$/, '')} failed to answer${status ? ` (${status})` : ''}`,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The refusal in `message`, or null when it is not the model provider's.
|
|
37
|
+
* `providers` are the ids the local runtime applied keys for (never a key),
|
|
38
|
+
* so a credit refusal can name a model another held key runs; `again` is
|
|
39
|
+
* what the person does once it is fixed ("send the message again").
|
|
40
|
+
*/
|
|
41
|
+
export function providerRefusal(message: string, opts: { providers?: readonly string[]; again: string }): ProviderRefusal | null {
|
|
42
|
+
const n = caseCouldNotRun(message)
|
|
43
|
+
if (!n || n.cause !== 'provider') return null
|
|
44
|
+
const kind = n.kind as ProviderRefusal['kind']
|
|
45
|
+
const label = n.provider ? (findProvider(n.provider)?.label ?? n.provider) : null
|
|
46
|
+
const status = /\((\d{3})/.exec(n.what)?.[1] ?? ''
|
|
47
|
+
const account = label ? `${label} account` : 'model provider account'
|
|
48
|
+
const direct = kind === 'credits' ? directModelFor(opts.providers, n.provider) : null
|
|
49
|
+
const base = { ...n, fix: notRunFix(n.kind, opts.again) }
|
|
50
|
+
const fix = withDirectModel(base, direct, opts.again).fix
|
|
51
|
+
const named = label ? fix.replace(/^the model provider account is out of credit/, `the ${label} account is out of credit`) : fix
|
|
52
|
+
return {
|
|
53
|
+
cause: 'provider',
|
|
54
|
+
kind,
|
|
55
|
+
...(n.provider ? { provider: n.provider, providerLabel: label! } : {}),
|
|
56
|
+
what: WHAT[kind](account, status),
|
|
57
|
+
fix: named,
|
|
58
|
+
...(direct ? { directModel: { slug: direct.slug, name: direct.name, provider: direct.provider } } : {}),
|
|
59
|
+
providerSaid: message.replace(/\s+/g, ' ').trim().slice(0, 400),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One line for a person: "couldn't run: <what>. Fix: <fix>". */
|
|
64
|
+
export function refusalLine(r: ProviderRefusal): string {
|
|
65
|
+
return `couldn’t run: ${r.what}. Fix: ${r.fix}`
|
|
66
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { caseKey, compareWithLastRun, type LastRun } from './local-test-baseline'
|
|
5
|
+
import type { CaseFile, CaseResult } from './local-test-runner'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Evidence a change request carries (design 2026-10-18 §6): an Agent's gated
|
|
9
|
+
* evals against its last applied run, an App's last render, screenshots
|
|
10
|
+
* from a Forge session. Pure where it can be; the command attaches what
|
|
11
|
+
* these build.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** What the platform's gate reads from an Agent's evals (`change-request-gate.ts`). */
|
|
15
|
+
export interface EvalsDetail {
|
|
16
|
+
gated: number
|
|
17
|
+
passed: number
|
|
18
|
+
failed: number
|
|
19
|
+
newlyFailing: Array<{ name: string; file: string; reason: string | null }>
|
|
20
|
+
/**
|
|
21
|
+
* Cases that couldn't run (the model provider refused them, the runtime did
|
|
22
|
+
* not answer): evidence with any is not attached, since none newly failing
|
|
23
|
+
* would then say nothing about them.
|
|
24
|
+
*/
|
|
25
|
+
couldNotRun: Array<{ name: string; what: string; fix: string }>
|
|
26
|
+
fixed: string[]
|
|
27
|
+
stillFailing: string[]
|
|
28
|
+
firstRun: number
|
|
29
|
+
passRate: number | null
|
|
30
|
+
baselinePassRate: number | null
|
|
31
|
+
efficiency: { latencyMs: number | null; toolCalls: number | null; baseline: { latencyMs: number | null; toolCalls: number | null } | null }
|
|
32
|
+
judged: { ran: boolean; reason: string | null }
|
|
33
|
+
ranAt: string
|
|
34
|
+
/** Where they ran. */
|
|
35
|
+
runtime: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const mean = (values: Array<number | undefined>): number | null => {
|
|
39
|
+
const known = values.filter((v): v is number => typeof v === 'number')
|
|
40
|
+
return known.length ? known.reduce((a, b) => a + b, 0) / known.length : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One Agent's gated cases, compared with its applied baseline (or its last
|
|
45
|
+
* run, when it was never applied from here): what the gate holds Apply on
|
|
46
|
+
* (newly failing cases), the pass rate either side, and efficiency per case,
|
|
47
|
+
* which is reported and never gates.
|
|
48
|
+
*/
|
|
49
|
+
export function evalsDetail(input: { files: CaseFile[]; results: CaseResult[]; baseline: LastRun; runtime: string; now?: Date }): EvalsDetail {
|
|
50
|
+
const { results, baseline } = input
|
|
51
|
+
const compared = compareWithLastRun(results, baseline)
|
|
52
|
+
const before = results.map((r) => baseline[caseKey(r)]).filter((b) => b !== undefined)
|
|
53
|
+
const judgedCases = input.files.some((f) => f.cases.some((c) => c.judged?.length))
|
|
54
|
+
return {
|
|
55
|
+
gated: results.length,
|
|
56
|
+
passed: results.filter((r) => r.passed).length,
|
|
57
|
+
failed: results.filter((r) => !r.passed && !r.notRun).length,
|
|
58
|
+
newlyFailing: compared.newlyFailing.map((r) => ({ name: r.name, file: r.file, reason: r.reason ?? null })),
|
|
59
|
+
couldNotRun: compared.couldNotRun.map((r) => ({ name: r.name, what: r.notRun!.what, fix: r.notRun!.fix })),
|
|
60
|
+
fixed: compared.fixed.map((r) => r.name),
|
|
61
|
+
stillFailing: compared.stillFailing.map((r) => r.name),
|
|
62
|
+
firstRun: compared.firstRun.length,
|
|
63
|
+
passRate: results.length ? results.filter((r) => r.passed).length / results.length : null,
|
|
64
|
+
baselinePassRate: before.length ? before.filter((b) => b!.passed).length / before.length : null,
|
|
65
|
+
efficiency: {
|
|
66
|
+
latencyMs: mean(results.map((r) => r.ms)),
|
|
67
|
+
toolCalls: mean(results.map((r) => r.toolCalls)),
|
|
68
|
+
baseline: before.length ? { latencyMs: mean(before.map((b) => b!.ms)), toolCalls: mean(before.map((b) => b!.toolCalls)) } : null,
|
|
69
|
+
},
|
|
70
|
+
// Judged expectations are graded by the platform's judge model; a local run grades the rest.
|
|
71
|
+
judged: { ran: false, reason: judgedCases ? 'judged expectations are graded on the platform, with its judge model' : null },
|
|
72
|
+
ranAt: (input.now ?? new Date()).toISOString(),
|
|
73
|
+
runtime: input.runtime,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A short line for the person: how the gated cases went. */
|
|
78
|
+
export function evalsLine(title: string, d: EvalsDetail): string {
|
|
79
|
+
if (!d.gated) return `${title}: no gated cases (mark the ones that guard it with gate: true)`
|
|
80
|
+
if (d.couldNotRun.length) return `${title}: ${d.couldNotRun.length} of ${d.gated} gated case${d.gated === 1 ? '' : 's'} couldn’t run: ${[...new Set(d.couldNotRun.map((c) => c.what))].join('; ')}; ${d.couldNotRun[0]!.fix}`
|
|
81
|
+
const rate = d.passRate === null ? '' : `, ${Math.round(d.passRate * 100)}% pass${d.baselinePassRate === null ? '' : ` (was ${Math.round(d.baselinePassRate * 100)}%)`}`
|
|
82
|
+
return `${title}: ${d.gated} gated case${d.gated === 1 ? '' : 's'}, ${d.newlyFailing.length ? `${d.newlyFailing.length} newly failing (${d.newlyFailing.map((c) => c.name).join(', ')})` : 'none newly failing'}${rate}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The newest pictures a session saved (`.frontera/evidence/<session>/`), newest first, at most `limit`. */
|
|
86
|
+
export function sessionScreenshots(root: string, session: string, limit = 6): Array<{ path: string; name: string; bytes: number; at: number }> {
|
|
87
|
+
if (!/^[\w.-]{1,120}$/.test(session)) return []
|
|
88
|
+
const dir = join(root, '.frontera', 'evidence', session)
|
|
89
|
+
if (!existsSync(dir)) return []
|
|
90
|
+
return readdirSync(dir)
|
|
91
|
+
.filter((n) => /\.(png|jpe?g|webp)$/i.test(n))
|
|
92
|
+
.map((n) => {
|
|
93
|
+
const st = statSync(join(dir, n))
|
|
94
|
+
return { path: join(dir, n), name: n, bytes: st.size, at: st.mtimeMs }
|
|
95
|
+
})
|
|
96
|
+
.sort((a, b) => b.at - a.at)
|
|
97
|
+
.slice(0, limit)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** An App's last render, as `app_render` and `frontera app dev` keep it in its folder. */
|
|
101
|
+
export function lastRender(appRoot: string): { detail: Record<string, unknown>; image: Buffer | null } | null {
|
|
102
|
+
const json = join(appRoot, '.frontera', 'last-render.json')
|
|
103
|
+
if (!existsSync(json)) return null
|
|
104
|
+
let detail: Record<string, unknown>
|
|
105
|
+
try {
|
|
106
|
+
detail = JSON.parse(readFileSync(json, 'utf8')) as Record<string, unknown>
|
|
107
|
+
} catch {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
const png = join(appRoot, '.frontera', 'last-render.png')
|
|
111
|
+
return { detail, image: existsSync(png) ? readFileSync(png) : null }
|
|
112
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { portAnswers } from './local-component-health'
|
|
2
|
+
import { snapshotInProgress } from './local-db-snapshot'
|
|
3
|
+
import type { LocalProject } from './local-project'
|
|
4
|
+
import { isAlive, readState } from './local-state'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the local runtime answers, for telling an apply that failed for its
|
|
8
|
+
* files from one that failed because the runtime went away under it (stopped,
|
|
9
|
+
* restarting, Postgres going down, a database snapshot being restored).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** What an item's error says when the runtime wasn't answering. */
|
|
13
|
+
export const RUNTIME_DOWN_ERROR = 'the local runtime isn’t answering (start it with `frontera local start`)'
|
|
14
|
+
/** What `local dev` says when the runtime went down during an apply. */
|
|
15
|
+
export const RUNTIME_STOPPED_LINE = 'runtime stopped mid-apply; the next apply retries it'
|
|
16
|
+
|
|
17
|
+
/** Output a command prints when it could not reach the service at all. */
|
|
18
|
+
const CONNECTION_ERROR = /ECONNREFUSED|ECONNRESET|EPIPE|Unable to connect|fetch failed|socket connection was closed|connection (?:was )?(?:refused|reset|closed)|ConnectionRefused|ConnectionClosed|FailedToOpenSocket|network error/i
|
|
19
|
+
|
|
20
|
+
export function isConnectionError(output: string): boolean {
|
|
21
|
+
return CONNECTION_ERROR.test(output)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HealthDeps {
|
|
25
|
+
fetch?: (url: string, init?: RequestInit) => Promise<Response>
|
|
26
|
+
/** Whether something accepts a connection on a local port (Postgres's). */
|
|
27
|
+
portAnswers?: (port: number) => Promise<boolean>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The runtime answers: its supervisor is up and says it is ready, no database
|
|
32
|
+
* snapshot holds the database, Postgres takes connections, and the service's
|
|
33
|
+
* health check answers. The service's `/health` answers from memory, so a
|
|
34
|
+
* Postgres that went down under it is asked on its own port.
|
|
35
|
+
*/
|
|
36
|
+
export async function runtimeAnswering(project: LocalProject, deps: HealthDeps = {}): Promise<boolean> {
|
|
37
|
+
const state = readState(project)
|
|
38
|
+
if (!state || state.phase !== 'ready' || !state.supervisorPid || !isAlive(state.supervisorPid)) return false
|
|
39
|
+
if (snapshotInProgress(project)) return false
|
|
40
|
+
if (state.ports.postgres && !(await (deps.portAnswers ?? portAnswers)(state.ports.postgres))) return false
|
|
41
|
+
try {
|
|
42
|
+
const res = await (deps.fetch ?? fetch)(`http://127.0.0.1:${state.ports.service}/health`, { signal: AbortSignal.timeout(3000) })
|
|
43
|
+
return res.ok
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether a failed apply failed because the runtime wasn't answering: checked
|
|
51
|
+
* once, now. A runtime that answers again was down for the moment only when
|
|
52
|
+
* the command itself couldn't reach it; a refusal it answered (a 500
|
|
53
|
+
* included) is the item's own failure.
|
|
54
|
+
*/
|
|
55
|
+
export async function failedForRuntime(project: LocalProject, output: string, deps: HealthDeps = {}): Promise<boolean> {
|
|
56
|
+
if (!(await runtimeAnswering(project, deps))) return true
|
|
57
|
+
const first = output.split('\n').find((l) => /^error/i.test(l.trim())) ?? ''
|
|
58
|
+
return /\[INTERNAL_ERROR\]/.test(first) && isConnectionError(first)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DatabaseReadyOptions {
|
|
62
|
+
/** How long to keep asking, in all (30 s). */
|
|
63
|
+
timeoutMs?: number
|
|
64
|
+
/** Rounds in a row in which every request found the database (2). */
|
|
65
|
+
rounds?: number
|
|
66
|
+
/** Requests sent at once in a round, so more than one of the service's pooled connections answers (4). */
|
|
67
|
+
parallel?: number
|
|
68
|
+
/** Pause between rounds (250 ms). */
|
|
69
|
+
intervalMs?: number
|
|
70
|
+
fetch?: (url: string, init?: RequestInit) => Promise<Response>
|
|
71
|
+
sleep?: (ms: number) => Promise<void>
|
|
72
|
+
now?: () => number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether the service reaches its database again after the database was
|
|
77
|
+
* dropped and made again (a snapshot restore), or its connections ended (a
|
|
78
|
+
* save): `/health` answers from memory, so the service's database-backed
|
|
79
|
+
* health route (`/v1/health`, which runs a query) is asked instead, several
|
|
80
|
+
* requests at once and several rounds in a row, until every request says the
|
|
81
|
+
* database is connected. A pooled connection ended under the service fails
|
|
82
|
+
* its next query, so one answer alone is not enough. A runtime from before
|
|
83
|
+
* that route answers 404 there; its `/health` is taken instead.
|
|
84
|
+
*/
|
|
85
|
+
export async function waitForDatabaseReady(serviceUrl: string, options: DatabaseReadyOptions = {}): Promise<boolean> {
|
|
86
|
+
const doFetch = options.fetch ?? fetch
|
|
87
|
+
const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms))
|
|
88
|
+
const now = options.now ?? Date.now
|
|
89
|
+
const rounds = options.rounds ?? 2
|
|
90
|
+
const parallel = Math.max(1, options.parallel ?? 4)
|
|
91
|
+
const interval = options.intervalMs ?? 250
|
|
92
|
+
const deadline = now() + (options.timeoutMs ?? 30_000)
|
|
93
|
+
const base = serviceUrl.replace(/\/+$/, '')
|
|
94
|
+
const ask = async (): Promise<'connected' | 'no-route' | 'not-yet'> => {
|
|
95
|
+
try {
|
|
96
|
+
const res = await doFetch(`${base}/v1/health`, { signal: AbortSignal.timeout(3000) })
|
|
97
|
+
if (res.status === 404) return 'no-route'
|
|
98
|
+
if (!res.ok) return 'not-yet'
|
|
99
|
+
const body = (await res.json().catch(() => null)) as { data?: { database?: unknown } } | null
|
|
100
|
+
return body?.data?.database === 'connected' ? 'connected' : 'not-yet'
|
|
101
|
+
} catch {
|
|
102
|
+
return 'not-yet'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let inARow = 0
|
|
106
|
+
while (now() < deadline) {
|
|
107
|
+
const answers = await Promise.all(Array.from({ length: parallel }, ask))
|
|
108
|
+
if (answers.every((a) => a === 'no-route')) {
|
|
109
|
+
try {
|
|
110
|
+
const res = await doFetch(`${base}/health`, { signal: AbortSignal.timeout(3000) })
|
|
111
|
+
if (res.ok) return true
|
|
112
|
+
} catch {
|
|
113
|
+
// Not up yet.
|
|
114
|
+
}
|
|
115
|
+
inARow = 0
|
|
116
|
+
} else if (answers.every((a) => a === 'connected')) {
|
|
117
|
+
inARow += 1
|
|
118
|
+
if (inARow >= rounds) return true
|
|
119
|
+
} else inARow = 0
|
|
120
|
+
await sleep(interval)
|
|
121
|
+
}
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
5
|
+
import type { LocalProject } from './local-project'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Each time the project's runtime started or stopped, newest last: Forge's
|
|
9
|
+
* Activity lists them beside the applies, so "Applied 290 items: runtime
|
|
10
|
+
* started" has the start it follows, and a stop explains why nothing ran
|
|
11
|
+
* after it. Written by the supervisor; a missed record never fails a start
|
|
12
|
+
* or a stop.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface RuntimeRecord {
|
|
16
|
+
event: 'started' | 'stopped' | 'failed'
|
|
17
|
+
/** When it happened: a start when the runtime was ready, a stop when it had stopped. */
|
|
18
|
+
at: string
|
|
19
|
+
/** A start: when it began starting, so its duration can be said. */
|
|
20
|
+
since?: string
|
|
21
|
+
/** A failed start: which part, and why. */
|
|
22
|
+
detail?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How many are kept: Activity shows the recent ones. */
|
|
26
|
+
const KEEP = 30
|
|
27
|
+
|
|
28
|
+
const historyPath = (stateDir: string) => join(stateDir, 'runtime-history.json')
|
|
29
|
+
|
|
30
|
+
/** The records kept, oldest first; none when the file is missing or unreadable. */
|
|
31
|
+
export function readRuntimeHistory(stateDir: string): RuntimeRecord[] {
|
|
32
|
+
try {
|
|
33
|
+
const list = JSON.parse(readFileSync(historyPath(stateDir), 'utf8')) as unknown
|
|
34
|
+
return Array.isArray(list)
|
|
35
|
+
? list.filter((r): r is RuntimeRecord => !!r && typeof (r as RuntimeRecord).at === 'string' && ['started', 'stopped', 'failed'].includes((r as RuntimeRecord).event))
|
|
36
|
+
: []
|
|
37
|
+
} catch {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keep one record. Never throws. */
|
|
43
|
+
export function recordRuntime(project: Pick<LocalProject, 'stateDir'>, record: RuntimeRecord): void {
|
|
44
|
+
try {
|
|
45
|
+
const list = [...readRuntimeHistory(project.stateDir), record].slice(-KEEP)
|
|
46
|
+
writeFileAtomic(historyPath(project.stateDir), `${JSON.stringify(list, null, 2)}\n`, 0o644)
|
|
47
|
+
} catch {
|
|
48
|
+
// The runtime itself is what matters.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { localProject, type LocalProject } from './local-project'
|
|
2
|
+
import { isAlive, readState, type LocalCredentials, type LocalState } from './local-state'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The local runtime a command reaches, when it reaches one: the project the
|
|
6
|
+
* working directory is in has a ready runtime, and the command's API address
|
|
7
|
+
* is that runtime's service. Commands that behave differently on the local
|
|
8
|
+
* runtime (resolving a project file's names, acting as a seeded persona) ask
|
|
9
|
+
* this, so nothing local-only ever reaches a hosted deployment.
|
|
10
|
+
*/
|
|
11
|
+
export interface LocalRuntimeTarget {
|
|
12
|
+
project: LocalProject
|
|
13
|
+
state: LocalState
|
|
14
|
+
creds: LocalCredentials
|
|
15
|
+
serviceUrl: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `http://localhost:4100/` and `http://127.0.0.1:4100` are the same service. */
|
|
19
|
+
function sameService(a: string, b: string): boolean {
|
|
20
|
+
try {
|
|
21
|
+
const x = new URL(a)
|
|
22
|
+
const y = new URL(b)
|
|
23
|
+
const host = (h: string) => (h === 'localhost' || h === '[::1]' ? '127.0.0.1' : h)
|
|
24
|
+
return x.protocol === y.protocol && host(x.hostname) === host(y.hostname) && x.port === y.port
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function localRuntimeAt(cwd: string, apiUrl: string): LocalRuntimeTarget | null {
|
|
31
|
+
let project: LocalProject
|
|
32
|
+
try {
|
|
33
|
+
project = localProject(cwd)
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
const state = readState(project)
|
|
38
|
+
if (!state?.credentials || state.phase !== 'ready' || !isAlive(state.supervisorPid)) return null
|
|
39
|
+
const serviceUrl = `http://127.0.0.1:${state.ports.service}`
|
|
40
|
+
if (!sameService(serviceUrl, apiUrl)) return null
|
|
41
|
+
return { project, state, creds: state.credentials, serviceUrl }
|
|
42
|
+
}
|