@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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { POSTGRES_DB, POSTGRES_USER } from './local-env'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The workspace secrets carried across a database restore.
|
|
7
|
+
*
|
|
8
|
+
* A restore (`frontera local snapshot restore`, `local check`'s rollback)
|
|
9
|
+
* throws away what was written since the baseline: test data. Workspace
|
|
10
|
+
* secrets are configuration, set by the person (`frontera secret set`), not
|
|
11
|
+
* by a test, so a restore keeps them as they are now unless asked to put the
|
|
12
|
+
* baseline's back (`--exact`).
|
|
13
|
+
*
|
|
14
|
+
* The rows are copied as stored, never decrypted: a value is `enc:v2`
|
|
15
|
+
* ciphertext under its workspace's data key version, and that key is a
|
|
16
|
+
* wrapped row in `workspace_deks` that only the service can unwrap. So the
|
|
17
|
+
* key versions the restored database lacks come with the secrets, and the
|
|
18
|
+
* value decrypts there exactly as before. A value under a key version the
|
|
19
|
+
* restored database holds as a different key would not decrypt: it is left
|
|
20
|
+
* as the baseline holds it and named, to be set again. Values never leave
|
|
21
|
+
* the database container as plain text, and nothing here is written to disk.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type QueryFn = (container: string, cmd: string[], input?: string) => { ok: boolean; out: string }
|
|
25
|
+
|
|
26
|
+
/** What the current database holds, read before it is dropped: its workspaces, their secrets and data keys, as JSON. */
|
|
27
|
+
export interface SecretsHeld {
|
|
28
|
+
json: string
|
|
29
|
+
count: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SecretsCarried {
|
|
33
|
+
ok: boolean
|
|
34
|
+
/** How many secrets are as they were before the restore. */
|
|
35
|
+
kept: number
|
|
36
|
+
/** Secrets whose value would not decrypt in the restored database, by name: set them again. */
|
|
37
|
+
notKept: string[]
|
|
38
|
+
/** Why nothing was carried, when it failed. */
|
|
39
|
+
problem?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const psqlArgs = (extra: string[]) => ['psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-v', 'ON_ERROR_STOP=1', '-q', '-At', ...extra]
|
|
43
|
+
|
|
44
|
+
export const READ_SECRETS_SQL = `SELECT json_build_object(
|
|
45
|
+
'workspaces', coalesce((SELECT json_agg(id) FROM workspaces), '[]'::json),
|
|
46
|
+
'secrets', coalesce((SELECT json_agg(s) FROM workspace_secrets s), '[]'::json),
|
|
47
|
+
'deks', coalesce((SELECT json_agg(d) FROM workspace_deks d), '[]'::json))`
|
|
48
|
+
|
|
49
|
+
/** The secrets and data keys as the database holds them now, or why they could not be read. */
|
|
50
|
+
export function readSecrets(query: QueryFn, container: string): SecretsHeld | { problem: string } {
|
|
51
|
+
const res = query(container, psqlArgs(['-c', READ_SECRETS_SQL.replace(/\s+/g, ' ')]))
|
|
52
|
+
if (!res.ok) return { problem: 'the current secrets could not be read' }
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(res.out) as { secrets?: unknown[] }
|
|
55
|
+
return { json: res.out, count: Array.isArray(parsed.secrets) ? parsed.secrets.length : 0 }
|
|
56
|
+
} catch {
|
|
57
|
+
return { problem: 'the current secrets could not be read' }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The SQL that puts the held secrets into the restored database, in one
|
|
63
|
+
* transaction: the data key versions it lacks are added, each workspace's
|
|
64
|
+
* secrets replaced by the held ones (a secret deleted since the baseline
|
|
65
|
+
* stays deleted), except those under a key version it holds differently.
|
|
66
|
+
* Answers one JSON line: how many were kept, and the names that weren't.
|
|
67
|
+
*/
|
|
68
|
+
export function carrySql(json: string, tag = `carry_${randomBytes(6).toString('hex')}`): string {
|
|
69
|
+
if (json.includes(`$${tag}$`)) throw new Error('the held secrets contain the quoting tag')
|
|
70
|
+
const q = `$${tag}$`
|
|
71
|
+
return [
|
|
72
|
+
'BEGIN;',
|
|
73
|
+
`CREATE TEMP TABLE carry ON COMMIT DROP AS SELECT ${q}${json}${q}::json AS j;`,
|
|
74
|
+
// Only workspaces both databases have: one made after the baseline is gone with it.
|
|
75
|
+
`CREATE TEMP TABLE carry_ws ON COMMIT DROP AS SELECT (value #>> '{}')::uuid AS id FROM json_array_elements((SELECT j->'workspaces' FROM carry)) WHERE (value #>> '{}')::uuid IN (SELECT id FROM workspaces);`,
|
|
76
|
+
`CREATE TEMP TABLE carry_deks ON COMMIT DROP AS SELECT * FROM json_populate_recordset(NULL::workspace_deks, (SELECT j->'deks' FROM carry)) WHERE workspace_id IN (SELECT id FROM carry_ws);`,
|
|
77
|
+
`CREATE TEMP TABLE carry_secrets ON COMMIT DROP AS SELECT * FROM json_populate_recordset(NULL::workspace_secrets, (SELECT j->'secrets' FROM carry)) WHERE workspace_id IN (SELECT id FROM carry_ws);`,
|
|
78
|
+
// A key version the restored database holds as another key: a value under it would not decrypt there.
|
|
79
|
+
`CREATE TEMP TABLE carry_skip ON COMMIT DROP AS SELECT s.workspace_id, s.name FROM carry_secrets s JOIN carry_deks c ON c.workspace_id = s.workspace_id AND s.encrypted_value LIKE 'enc:v2:dek.' || c.version || ':%' JOIN workspace_deks d ON d.workspace_id = c.workspace_id AND d.version = c.version AND d.wrapped_dek <> c.wrapped_dek;`,
|
|
80
|
+
`INSERT INTO workspace_deks SELECT c.* FROM carry_deks c WHERE NOT EXISTS (SELECT 1 FROM workspace_deks d WHERE d.workspace_id = c.workspace_id AND d.version = c.version) ON CONFLICT DO NOTHING;`,
|
|
81
|
+
`DELETE FROM workspace_secrets t WHERE t.workspace_id IN (SELECT id FROM carry_ws) AND NOT EXISTS (SELECT 1 FROM carry_skip k WHERE k.workspace_id = t.workspace_id AND k.name = t.name);`,
|
|
82
|
+
`INSERT INTO workspace_secrets SELECT s.* FROM carry_secrets s WHERE NOT EXISTS (SELECT 1 FROM carry_skip k WHERE k.workspace_id = s.workspace_id AND k.name = s.name) ON CONFLICT DO NOTHING;`,
|
|
83
|
+
`SELECT json_build_object('kept', (SELECT count(*) FROM workspace_secrets t JOIN carry_secrets s ON s.id = t.id AND s.encrypted_value = t.encrypted_value), 'notKept', coalesce((SELECT json_agg(name ORDER BY name) FROM carry_skip), '[]'::json));`,
|
|
84
|
+
'COMMIT;',
|
|
85
|
+
'',
|
|
86
|
+
].join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Put the held secrets into the restored database (`carrySql`), and say what became of them. */
|
|
90
|
+
export function carrySecrets(query: QueryFn, container: string, held: SecretsHeld): SecretsCarried {
|
|
91
|
+
let sql: string
|
|
92
|
+
try {
|
|
93
|
+
sql = carrySql(held.json)
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, kept: 0, notKept: [], problem: (err as Error).message }
|
|
96
|
+
}
|
|
97
|
+
const res = query(container, psqlArgs(['-f', '-']), sql)
|
|
98
|
+
if (!res.ok) return { ok: false, kept: 0, notKept: [], problem: 'the restored database refused them' }
|
|
99
|
+
try {
|
|
100
|
+
const line = res.out.split('\n').map((l) => l.trim()).filter(Boolean).at(-1) ?? '{}'
|
|
101
|
+
const said = JSON.parse(line) as { kept?: number; notKept?: string[] }
|
|
102
|
+
return { ok: true, kept: Number(said.kept ?? 0), notKept: Array.isArray(said.notKept) ? said.notKept.filter((n) => typeof n === 'string') : [] }
|
|
103
|
+
} catch {
|
|
104
|
+
// Committed, but its answer could not be read: what the runtime lists says the rest.
|
|
105
|
+
return { ok: true, kept: held.count, notKept: [] }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -115,27 +115,57 @@ export function needsValueLine(name: string, who: string): string {
|
|
|
115
115
|
/** The build's lines for the secrets `names` that the local runtime has no value for. */
|
|
116
116
|
export async function missingSecretLines(names: string[], who: string, serviceUrl: string, creds: LocalCredentials): Promise<string[]> {
|
|
117
117
|
if (!names.length) return []
|
|
118
|
-
const
|
|
119
|
-
if (!
|
|
120
|
-
return names.filter((n) => !
|
|
118
|
+
const presence = await localSecretPresence(serviceUrl, creds)
|
|
119
|
+
if (!presence) return []
|
|
120
|
+
return names.filter((n) => !hasSecretValue(presence, n)).map((n) => needsValueLine(n, who))
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
123
|
+
/**
|
|
124
|
+
* What the local runtime holds of its secrets, never their values: each name
|
|
125
|
+
* the secrets list route lists, and those of them it says have no value
|
|
126
|
+
* (`hasValue: false` on a row; a runtime that does not say counts a listed
|
|
127
|
+
* secret as having one).
|
|
128
|
+
*/
|
|
129
|
+
export interface SecretPresence {
|
|
130
|
+
names: Set<string>
|
|
131
|
+
withoutValue: Set<string>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether the runtime has a value for `name`: listed, and not said to be without one. */
|
|
135
|
+
export function hasSecretValue(presence: SecretPresence, name: string): boolean {
|
|
136
|
+
return presence.names.has(name) && !presence.withoutValue.has(name)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The secrets list route's answer, read as `SecretPresence`; `null` when it could not say. */
|
|
140
|
+
export async function localSecretPresence(serviceUrl: string, creds: LocalCredentials, deps: { fetch?: (url: string | URL, init?: RequestInit) => Promise<Response> } = {}): Promise<SecretPresence | null> {
|
|
125
141
|
if (!creds.workspaceKey || !creds.workspaceId) return null
|
|
126
142
|
try {
|
|
127
|
-
const res = await fetch(new URL(`/v1/workspaces/${encodeURIComponent(creds.workspaceId)}/secrets`, serviceUrl), {
|
|
143
|
+
const res = await (deps.fetch ?? fetch)(new URL(`/v1/workspaces/${encodeURIComponent(creds.workspaceId)}/secrets`, serviceUrl), {
|
|
128
144
|
headers: { authorization: `Bearer ${creds.workspaceKey}` },
|
|
145
|
+
signal: AbortSignal.timeout(5000),
|
|
129
146
|
})
|
|
130
147
|
if (!res.ok) return null
|
|
131
|
-
const body = (await res.json()) as { data?: Array<{ name?: string }> } | Array<{ name?: string }>
|
|
148
|
+
const body = (await res.json()) as { data?: Array<{ name?: string; hasValue?: unknown }> } | Array<{ name?: string; hasValue?: unknown }>
|
|
132
149
|
const rows = Array.isArray(body) ? body : (body.data ?? [])
|
|
133
|
-
|
|
150
|
+
const names = new Set<string>()
|
|
151
|
+
const withoutValue = new Set<string>()
|
|
152
|
+
for (const r of rows) {
|
|
153
|
+
if (typeof r.name !== 'string') continue
|
|
154
|
+
names.add(r.name)
|
|
155
|
+
if (r.hasValue === false) withoutValue.add(r.name)
|
|
156
|
+
}
|
|
157
|
+
return { names, withoutValue }
|
|
134
158
|
} catch {
|
|
135
159
|
return null
|
|
136
160
|
}
|
|
137
161
|
}
|
|
138
162
|
|
|
163
|
+
/** The secret names the local runtime holds a value for (never the values); `null` when it could not say. */
|
|
164
|
+
export async function localSecretNames(serviceUrl: string, creds: LocalCredentials): Promise<Set<string> | null> {
|
|
165
|
+
const presence = await localSecretPresence(serviceUrl, creds)
|
|
166
|
+
return presence ? new Set([...presence.names].filter((n) => !presence.withoutValue.has(n))) : null
|
|
167
|
+
}
|
|
168
|
+
|
|
139
169
|
/**
|
|
140
170
|
* A Plugin file as a push sends it: every header, key-like setting and
|
|
141
171
|
* environment value empty, as the pull writes them. The platform keeps its
|
package/src/local/local-seed.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appendFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
import { findProvider, getKey,
|
|
3
|
+
import { findProvider, getKey, LOCAL_PROVIDERS, localEmbeddingsReachable } from './local-keys'
|
|
4
4
|
import type { LocalCredentials } from './local-state'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -174,11 +174,35 @@ const EMBEDDING: Array<{ provider: string; model: string }> = [
|
|
|
174
174
|
{ provider: 'openrouter', model: 'openai/text-embedding-3-small' },
|
|
175
175
|
]
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The runtime's embedding choice, when it has one its credentials can run
|
|
179
|
+
* (`/v1/config/embedding` lists only credentialed providers): one chosen
|
|
180
|
+
* before, by `local dev` to match the workspace or by its owner, is kept.
|
|
181
|
+
*/
|
|
182
|
+
async function credentialedEmbedding(serviceUrl: string, token: string, orgId: string): Promise<{ provider: string; model: string } | null> {
|
|
183
|
+
try {
|
|
184
|
+
const res = await call<{ data?: EmbeddingView } & EmbeddingView>(serviceUrl, '/v1/config/embedding', { token, orgId })
|
|
185
|
+
if (res.status >= 300) return null
|
|
186
|
+
const view = res.body?.data ?? res.body
|
|
187
|
+
const { provider, model } = view ?? {}
|
|
188
|
+
if (typeof provider !== 'string' || !provider || typeof model !== 'string' || !model) return null
|
|
189
|
+
return Array.isArray(view?.providers) && view.providers.some((p) => p?.id === provider) ? { provider, model } : null
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface EmbeddingView {
|
|
196
|
+
provider?: string | null
|
|
197
|
+
model?: string | null
|
|
198
|
+
providers?: Array<{ id?: string }>
|
|
199
|
+
}
|
|
200
|
+
|
|
177
201
|
/**
|
|
178
202
|
* Copy the FDE's stored keys into the local organization's credentials, and
|
|
179
|
-
* pick an embedding model when one is possible
|
|
180
|
-
* the status line; failures are logged,
|
|
181
|
-
* key still starts, and says so.
|
|
203
|
+
* pick an embedding model when one is possible and none that runs is chosen
|
|
204
|
+
* yet. Returns what was applied, for the status line; failures are logged,
|
|
205
|
+
* never fatal — a runtime without a model key still starts, and says so.
|
|
182
206
|
*/
|
|
183
207
|
export async function applyModelKeys(args: { serviceUrl: string; password: string; organizationId: string; logFile: string }): Promise<string[]> {
|
|
184
208
|
const applied: string[] = []
|
|
@@ -202,33 +226,56 @@ export async function applyModelKeys(args: { serviceUrl: string; password: strin
|
|
|
202
226
|
if (res.status >= 200 && res.status < 300) applied.push(p.id)
|
|
203
227
|
else log(args.logFile, `storing the ${findProvider(p.id)?.label} key answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
|
|
204
228
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
orgId: args.organizationId,
|
|
213
|
-
body: { fields: { apiKey: 'local-embeddings' } },
|
|
214
|
-
})
|
|
215
|
-
if (res.status >= 200 && res.status < 300) {
|
|
216
|
-
embedding = EMBEDDING[0]
|
|
217
|
-
applied.push('local-embeddings')
|
|
218
|
-
} else log(args.logFile, `storing the local embeddings credential answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
|
|
229
|
+
// A choice that runs with this runtime's keys stays: the workspace's model (`mirrorOrgSettings`) or the owner's.
|
|
230
|
+
// The local embeddings' stand-in OpenAI credential is not a key: a real key's model replaces it when the provider takes it.
|
|
231
|
+
const kept = await credentialedEmbedding(args.serviceUrl, token, args.organizationId)
|
|
232
|
+
const standIn = !!kept && isLocalEmbeddings(kept)
|
|
233
|
+
if (kept && !standIn) {
|
|
234
|
+
log(args.logFile, `kept the embedding model ${kept.provider} / ${kept.model}`)
|
|
235
|
+
return applied
|
|
219
236
|
}
|
|
220
|
-
|
|
221
|
-
const res = await call(args.serviceUrl, '/v1/config/embedding', {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
237
|
+
const setEmbedding = async (embedding: { provider: string; model: string }) => {
|
|
238
|
+
const res = await call(args.serviceUrl, '/v1/config/embedding', { method: 'PUT', token, orgId: args.organizationId, body: embedding })
|
|
239
|
+
if (res.status < 300) return true
|
|
240
|
+
log(args.logFile, `setting the embedding model ${embedding.provider} / ${embedding.model} answered ${res.status}: ${JSON.stringify(res.body).slice(0, 300)}`)
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
for (const embedding of EMBEDDING.filter((e) => applied.includes(e.provider))) {
|
|
244
|
+
if (await setEmbedding(embedding)) return applied
|
|
245
|
+
}
|
|
246
|
+
if (standIn) {
|
|
247
|
+
applied.push('local-embeddings')
|
|
248
|
+
return applied
|
|
249
|
+
}
|
|
250
|
+
// No key, or the provider refused the one stored (the line above says its answer): the keyless local
|
|
251
|
+
// embeddings, so knowledge bases still ingest and search here. They need the service's OpenAI client,
|
|
252
|
+
// which points at them while this computer has no OpenAI key (see serviceEnv).
|
|
253
|
+
if (!localEmbeddingsReachable()) return applied
|
|
254
|
+
const res = await call(args.serviceUrl, '/v1/admin/provider-credentials/openai', {
|
|
255
|
+
method: 'PUT',
|
|
256
|
+
token,
|
|
257
|
+
orgId: args.organizationId,
|
|
258
|
+
body: { fields: { apiKey: LOCAL_EMBEDDINGS_CREDENTIAL } },
|
|
259
|
+
})
|
|
260
|
+
if (res.status >= 300) {
|
|
261
|
+
log(args.logFile, `storing the local embeddings credential answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
|
|
262
|
+
return applied
|
|
263
|
+
}
|
|
264
|
+
if (await setEmbedding(EMBEDDING[0]!)) {
|
|
265
|
+
applied.push('local-embeddings')
|
|
266
|
+
if (applied.some((p) => p === 'openrouter')) log(args.logFile, 'embedding with the keyless local embeddings: the stored key’s embedding model was refused (see the line above)')
|
|
228
267
|
}
|
|
229
268
|
return applied
|
|
230
269
|
}
|
|
231
270
|
|
|
271
|
+
/** The placeholder OpenAI credential that sends the service's OpenAI client to the keyless local embeddings. */
|
|
272
|
+
const LOCAL_EMBEDDINGS_CREDENTIAL = 'local-embeddings'
|
|
273
|
+
|
|
274
|
+
/** Whether a choice is the keyless local embeddings: OpenAI's model while this computer holds no OpenAI key. */
|
|
275
|
+
export function isLocalEmbeddings(embedding: { provider: string | null; model: string | null }): boolean {
|
|
276
|
+
return embedding.provider === 'openai' && localEmbeddingsReachable()
|
|
277
|
+
}
|
|
278
|
+
|
|
232
279
|
/**
|
|
233
280
|
* Runtimes started before the echo model was removed still list it: the
|
|
234
281
|
* `local` custom provider, its `local-model` catalog entry, and that model as
|
|
@@ -273,6 +320,35 @@ export const PROVIDER_MODELS: Record<string, Array<{ slug: string; modelId: stri
|
|
|
273
320
|
{ slug: 'gemini-3-8-flash', modelId: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
|
|
274
321
|
{ slug: 'gpt-5-6-terra', modelId: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra' },
|
|
275
322
|
],
|
|
323
|
+
// Direct from Anthropic, beside OpenRouter's: a stored Anthropic key runs Claude without OpenRouter's
|
|
324
|
+
// credit. Their slugs name the provider, so they never take the OpenRouter models' slugs.
|
|
325
|
+
anthropic: [
|
|
326
|
+
{ slug: 'anthropic-claude-haiku-5-5', modelId: 'claude-haiku-5-5', name: 'Claude Haiku 5.5 (Anthropic)' },
|
|
327
|
+
{ slug: 'anthropic-claude-sonnet-5-5', modelId: 'claude-sonnet-5-5', name: 'Claude Sonnet 5.5 (Anthropic)' },
|
|
328
|
+
],
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** A model the local catalog offers directly from a provider (not through OpenRouter), and that provider. */
|
|
332
|
+
export interface DirectModel {
|
|
333
|
+
slug: string
|
|
334
|
+
name: string
|
|
335
|
+
provider: string
|
|
336
|
+
providerLabel: string
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The first model offered directly by a provider whose key the runtime holds
|
|
341
|
+
* (`providers`: the ids the runtime applied keys for, never a key), other than
|
|
342
|
+
* the provider that refused (`refusedBy`); null when there is none. What a
|
|
343
|
+
* credit refusal suggests switching to.
|
|
344
|
+
*/
|
|
345
|
+
export function directModelFor(providers: readonly string[] | undefined, refusedBy?: string | null): DirectModel | null {
|
|
346
|
+
for (const [provider, models] of Object.entries(PROVIDER_MODELS)) {
|
|
347
|
+
if (provider === 'openrouter' || provider === refusedBy || !providers?.includes(provider) || !models.length) continue
|
|
348
|
+
const m = models[0]!
|
|
349
|
+
return { slug: m.slug, name: m.name.replace(/\s*\([^)]*\)$/, ''), provider, providerLabel: findProvider(provider)?.label ?? provider }
|
|
350
|
+
}
|
|
351
|
+
return null
|
|
276
352
|
}
|
|
277
353
|
|
|
278
354
|
/**
|
|
@@ -34,7 +34,7 @@ import { rows } from './local-sync-remote'
|
|
|
34
34
|
* - **Documentation** (Frontera's documentation for Agents): its stand-in
|
|
35
35
|
* `serves: "documentation"`, answering the plugin's tools from the
|
|
36
36
|
* documentation `local start` copied for the runtime's release (the same
|
|
37
|
-
* copy `frontera
|
|
37
|
+
* copy `frontera mcp` serves the project's coding agents). The
|
|
38
38
|
* Agent's documentation tools work here.
|
|
39
39
|
* - **Analytics**: the workspace's analytics tools read its analytics
|
|
40
40
|
* project, which a local runtime does not have (design §8). The pull
|
package/src/local/local-state.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
+
import { ignoreInFronteraDir } from '../project-context'
|
|
4
5
|
import { writeFileAtomic } from '../fs-atomic'
|
|
5
6
|
import type { LocalProject } from './local-project'
|
|
6
7
|
|
|
@@ -88,8 +89,8 @@ export function ensureStateDir(project: LocalProject): void {
|
|
|
88
89
|
} catch {
|
|
89
90
|
// Best effort; a filesystem without modes still works.
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
// The runtime's state, and which profile this machine bound the directory to, stay out of git.
|
|
93
|
+
ignoreInFronteraDir(project.root, ['local/'])
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
export function readState(project: LocalProject): LocalState | null {
|
|
@@ -163,3 +164,43 @@ export function readApplied(project: LocalProject): LastApplied | null {
|
|
|
163
164
|
return null
|
|
164
165
|
}
|
|
165
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An apply under way: `local dev` writes it as it starts applying what is
|
|
170
|
+
* queued and removes it when the queue is done, so Forge and Studio say
|
|
171
|
+
* "Applying…" rather than show a half-made count as if it were the result.
|
|
172
|
+
*/
|
|
173
|
+
export interface ApplyingNow {
|
|
174
|
+
/** When this round of applies began. */
|
|
175
|
+
since: string
|
|
176
|
+
/** How many items were queued when it began. */
|
|
177
|
+
queued: number
|
|
178
|
+
/** The `local dev` applying them: a record whose process is gone is stale. */
|
|
179
|
+
pid: number
|
|
180
|
+
/** What started it, in words: "runtime started", "files changed: agents/desk/agent.json". */
|
|
181
|
+
trigger?: string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function applyingPath(project: LocalProject): string {
|
|
185
|
+
return join(project.stateDir, 'applying.json')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Say an apply is under way (`entry`), or that it is done (`null`). Never throws. */
|
|
189
|
+
export function recordApplying(project: LocalProject, entry: ApplyingNow | null): void {
|
|
190
|
+
try {
|
|
191
|
+
if (entry) writeFileAtomic(applyingPath(project), `${JSON.stringify(entry)}\n`, 0o644)
|
|
192
|
+
else rmSync(applyingPath(project), { force: true })
|
|
193
|
+
} catch {
|
|
194
|
+
// A missed hint must never fail an apply.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The apply under way, or null: none, or its `local dev` is gone. */
|
|
199
|
+
export function readApplying(stateDir: string): ApplyingNow | null {
|
|
200
|
+
try {
|
|
201
|
+
const entry = JSON.parse(readFileSync(join(stateDir, 'applying.json'), 'utf8')) as ApplyingNow
|
|
202
|
+
return entry && typeof entry.since === 'string' && isAlive(entry.pid) ? entry : null
|
|
203
|
+
} catch {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -5,11 +5,15 @@ import { extname, join, normalize } from 'node:path'
|
|
|
5
5
|
import type { LocalProject } from './local-project'
|
|
6
6
|
import { AgentFileError, saveAgentEdit, type AgentFileEdit } from './local-agent-files'
|
|
7
7
|
import { solutionStatus } from './local-applied'
|
|
8
|
+
import { appCodeStates } from './local-apps'
|
|
8
9
|
import { projectBlueprintOutline } from './local-blueprint-outline'
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { currentBuildRows, currentBuildSummary } from './local-build-inputs'
|
|
11
|
+
import { appStatusRow, itemBuild } from './local-build-summary'
|
|
12
|
+
import { KnowledgeDocumentError, KNOWLEDGE_UPLOAD_EXTENSIONS, MAX_KNOWLEDGE_UPLOAD_BYTES, checkUpload, documentInFolder, knowledgeFolderOf, projectDocuments, removeKnowledgeDocument, removeRuntimeCopies, runtimeDocuments, saveKnowledgeDocument, type ServiceCall } from './local-knowledge-uploads'
|
|
13
|
+
import { isAlive, logPath, readApplied, readState, type ComponentName } from './local-state'
|
|
14
|
+
import { appendTestCase, decisionOfApprovals, MIXED_DECISIONS_LINE, noteRedactedVariables, redactedVariablesLine, redactedVariablesOfTimeline, renderTestCase, variablesOfTimeline } from './local-test-cases'
|
|
12
15
|
import { findCaseFiles } from './local-test-runner'
|
|
16
|
+
import { workflowTestListing } from './local-workflow-tests'
|
|
13
17
|
import { createTestRuns } from './local-test-runs'
|
|
14
18
|
import { docsCopyPath } from './local-docs'
|
|
15
19
|
import { standInMcp } from './local-stand-ins'
|
|
@@ -62,6 +66,16 @@ export function newSessionToken(): string {
|
|
|
62
66
|
return randomBytes(24).toString('base64url')
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/** An Agent's display name from `agents/<slug>/agent.json` (`config.name`), or null when it gives none. */
|
|
70
|
+
export function agentFileName(root: string, slug: string): string | null {
|
|
71
|
+
try {
|
|
72
|
+
const name = (JSON.parse(readFileSync(join(root, 'agents', slug, 'agent.json'), 'utf8')) as { config?: { name?: unknown } }).config?.name
|
|
73
|
+
return typeof name === 'string' && name.trim() ? name.trim() : null
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
function sameToken(a: string, b: string): boolean {
|
|
66
80
|
const ab = Buffer.from(a)
|
|
67
81
|
const bb = Buffer.from(b)
|
|
@@ -95,7 +109,7 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
95
109
|
const json = (data: unknown, status = 200) =>
|
|
96
110
|
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json; charset=utf-8' } })
|
|
97
111
|
|
|
98
|
-
const localRoute = (url: URL): Response => {
|
|
112
|
+
const localRoute = (url: URL): Response | Promise<Response> => {
|
|
99
113
|
if (url.pathname === '/__local/state') {
|
|
100
114
|
const state = readState(project)
|
|
101
115
|
if (!state) return json({ error: 'no local runtime state' }, 404)
|
|
@@ -125,6 +139,36 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
125
139
|
if (!/^[a-z-]+\/[A-Za-z0-9][\w./-]{0,200}$/.test(ref)) return json({ error: 'name an item, as agent/<slug>' }, 400)
|
|
126
140
|
return json({ build: itemBuild(project.root, ref, project.id) })
|
|
127
141
|
}
|
|
142
|
+
// The build in a few numbers, counted as `local status` counts it (the runtime's Action deployments and
|
|
143
|
+
// secrets read the same way): Studio's footer says its failures, the number `local status` prints.
|
|
144
|
+
if (url.pathname === '/__local/build-summary') {
|
|
145
|
+
return currentBuildSummary(project.root, project.id, readState(project)).then((summary) =>
|
|
146
|
+
json({ summary: summary ? { problems: summary.problems, counts: summary.counts, headline: summary.headline } : null }),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
// The build's rows of one kind (`?kind=function`), each with its state, reason and fix, as `local status` lists
|
|
150
|
+
// them: Studio's lists mark the ones that didn't apply or need setup, with the same words.
|
|
151
|
+
if (url.pathname === '/__local/builds') {
|
|
152
|
+
const kind = url.searchParams.get('kind') ?? ''
|
|
153
|
+
if (!/^[a-zA-Z-]{1,40}$/.test(kind)) return json({ error: 'name a kind, as function' }, 400)
|
|
154
|
+
return currentBuildRows(project.root, project.id, readState(project)).then((rows) =>
|
|
155
|
+
json({
|
|
156
|
+
rows: (rows ?? [])
|
|
157
|
+
.filter((r) => r.ref.startsWith(`${kind}/`))
|
|
158
|
+
.map((r) => ({ ref: r.ref, state: r.state, label: r.label, reason: r.reason, fix: r.fix ?? null, ...(r.warnings?.length ? { warnings: r.warnings } : {}) })),
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
// Where each Custom App's code is (its folder, not pulled, or none yet), with its one status as `local status` says
|
|
163
|
+
// it (deployed to this runtime, built, not built, no code): Studio's Apps page says it, and how to work on it.
|
|
164
|
+
if (url.pathname === '/__local/apps') {
|
|
165
|
+
return json({
|
|
166
|
+
apps: appCodeStates(project.root).map((code) => {
|
|
167
|
+
const row = appStatusRow(project.root, code)
|
|
168
|
+
return { ...code, status: row.appStatus, label: row.label, fix: row.fix ?? null }
|
|
169
|
+
}),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
128
172
|
// The Blueprint's definitions as the files state them, so they show before anything is applied.
|
|
129
173
|
if (url.pathname === '/__local/blueprint') {
|
|
130
174
|
return json({ objectTypes: projectBlueprintOutline(project.root) })
|
|
@@ -132,11 +176,17 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
132
176
|
// The solution's saved cases (`agents/<slug>/evals/*.yaml`), read fresh each time.
|
|
133
177
|
if (url.pathname === '/__local/test-cases') {
|
|
134
178
|
try {
|
|
135
|
-
|
|
179
|
+
// Each Agent by the name its agent.json gives it, as the home and Agents say it, not its folder.
|
|
180
|
+
return json({ files: findCaseFiles(project.root).map((f) => ({ ...f, agentName: agentFileName(project.root, f.agent) })) })
|
|
136
181
|
} catch (err) {
|
|
137
182
|
return json({ error: `a case file could not be read: ${(err as Error).message}` }, 422)
|
|
138
183
|
}
|
|
139
184
|
}
|
|
185
|
+
// The workflows' test files (`workflows/tests/*.yaml`): `frontera local test` runs them; Evaluations lists them.
|
|
186
|
+
if (url.pathname === '/__local/workflow-tests') {
|
|
187
|
+
// Each case by name, with its last run (`frontera local test` keeps it), so Evaluations lists them.
|
|
188
|
+
return json({ files: workflowTestListing(project.root, project.stateDir) })
|
|
189
|
+
}
|
|
140
190
|
if (url.pathname === '/__local/test-runs') return json({ runs: testRuns.list() })
|
|
141
191
|
const runMatch = /^\/__local\/test-runs\/([0-9a-f-]+)$/.exec(url.pathname)
|
|
142
192
|
if (runMatch) {
|
|
@@ -171,10 +221,122 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
171
221
|
const agent = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}`)) as { agentId?: string }
|
|
172
222
|
const messages = (await get(`/v1/sessions/${encodeURIComponent(body.conversationId)}/messages`)) as unknown
|
|
173
223
|
const list = Array.isArray(messages) ? messages : ((messages as { messages?: unknown[] })?.messages ?? [])
|
|
174
|
-
|
|
224
|
+
// The variables the conversation started with, when its Test setup recorded them: the case replays as the same customer.
|
|
225
|
+
const session = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}/try/conversations/${encodeURIComponent(body.conversationId)}/session`).catch(() => null)) as { timeline?: Array<{ payload?: Record<string, unknown> | null }> } | null
|
|
226
|
+
const variables = variablesOfTimeline(session?.timeline ?? [])
|
|
227
|
+
// A sensitive variable is named in the setup, never its value: the case says to fill it in by hand.
|
|
228
|
+
const redacted = redactedVariablesOfTimeline(session?.timeline ?? [])
|
|
229
|
+
// The tool approvals it stopped on, as the person decided them: the case answers them the same way.
|
|
230
|
+
const cards = (await get(`/v1/chat/approvals?conversationId=${encodeURIComponent(body.conversationId)}`).catch(() => [])) as unknown
|
|
231
|
+
const { decision, mixed } = decisionOfApprovals(Array.isArray(cards) ? cards : [])
|
|
232
|
+
const yaml = noteRedactedVariables(renderTestCase({ conversationId: body.conversationId, messages: list as never[], savedAt: new Date().toISOString(), ...(variables ? { variables } : {}), ...(decision ? { decision } : {}) }), redacted)
|
|
175
233
|
const path = appendTestCase(project.root, agent.agentId ?? body.agentId, yaml)
|
|
176
|
-
|
|
234
|
+
const notes = [...(redacted.length ? [redactedVariablesLine(redacted, path)] : []), ...(mixed ? [MIXED_DECISIONS_LINE] : [])]
|
|
235
|
+
return json({ path, ...(decision ? { decision } : {}), ...(redacted.length ? { redactedVariables: redacted } : {}), ...(notes.length ? { note: notes.join(' ') } : {}) })
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return json({ error: (err as Error).message }, 502)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The local service as the seeded owner, for the routes that act on the runtime themselves; null before it is seeded. */
|
|
242
|
+
const serviceCall = (): ServiceCall | null => {
|
|
243
|
+
const creds = readState(project)?.credentials
|
|
244
|
+
const bearer = creds?.session ?? creds?.workspaceKey
|
|
245
|
+
if (!creds || !bearer) return null
|
|
246
|
+
return async (method, path) => {
|
|
247
|
+
const res = await fetch(new URL(path, opts.serviceUrl), { method, headers: { authorization: `Bearer ${bearer}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId } })
|
|
248
|
+
const text = await res.text()
|
|
249
|
+
if (!res.ok) throw new Error(`the runtime answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`)
|
|
250
|
+
return text ? (JSON.parse(text) as unknown) : null
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Whether a `local dev` watcher is applying this project's saved files (Forge keeps one running). */
|
|
255
|
+
const watching = (): boolean => {
|
|
256
|
+
try {
|
|
257
|
+
return isAlive(Number(readFileSync(join(project.stateDir, 'dev.pid'), 'utf8').trim()))
|
|
258
|
+
} catch {
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A knowledge base's documents as the project's files (`local-knowledge-uploads.ts`):
|
|
265
|
+
* GET /__local/knowledge?base=<name>&id=<knowledge id> its folder, its documents and whether the runtime has each, its apply state
|
|
266
|
+
* POST /__local/knowledge/documents?base=&id=[&replace=1] save one document (multipart `file`) into its folder
|
|
267
|
+
* DELETE /__local/knowledge/documents?base=&id=&sourceId= take a document off the runtime and out of the folder
|
|
268
|
+
*/
|
|
269
|
+
const knowledgeRoute = async (req: Request, url: URL): Promise<Response> => {
|
|
270
|
+
const base = (url.searchParams.get('base') ?? '').trim()
|
|
271
|
+
const id = url.searchParams.get('id') ?? ''
|
|
272
|
+
if (!base || base.length > 200) return json({ error: 'name the knowledge base, as base=<name>' }, 400)
|
|
273
|
+
if (id && !/^[\w-]{1,80}$/.test(id)) return json({ error: 'id is not a knowledge base id' }, 400)
|
|
274
|
+
const folder = knowledgeFolderOf(project.root, base)
|
|
275
|
+
try {
|
|
276
|
+
if (url.pathname === '/__local/knowledge' && req.method === 'GET') {
|
|
277
|
+
const accepted = { extensions: KNOWLEDGE_UPLOAD_EXTENSIONS, maxBytes: MAX_KNOWLEDGE_UPLOAD_BYTES }
|
|
278
|
+
if (!folder) return json({ folder: null, documents: [], apply: null, watching: watching(), accepted })
|
|
279
|
+
const call = id ? serviceCall() : null
|
|
280
|
+
const onRuntime = call ? await runtimeDocuments(call, id).then((docs) => new Set(docs.map((d) => d.path)), () => null) : null
|
|
281
|
+
const status = solutionStatus(project, readState(project)?.credentials?.organizationId ?? null, (t) => t.kind === 'knowledge' && t.name === folder)[0]
|
|
282
|
+
return json({
|
|
283
|
+
folder: `knowledge/${folder}/`,
|
|
284
|
+
documents: projectDocuments(project.root, folder, onRuntime),
|
|
285
|
+
apply: status ? { state: status.state, ...(status.detail ? { detail: status.detail } : {}), ...(status.fix ? { fix: status.fix } : {}), ...(status.waiting ? { waiting: true } : {}) } : null,
|
|
286
|
+
watching: watching(),
|
|
287
|
+
accepted,
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
if (url.pathname !== '/__local/knowledge/documents') return json({ error: 'not found' }, 404)
|
|
291
|
+
if (req.method === 'POST') {
|
|
292
|
+
if (!folder) return json({ error: `${base} has no folder in the project (knowledge/<name>/knowledge.json), so a document can't be saved to it here` }, 404)
|
|
293
|
+
// Refused before the body is read: the form's boundary and fields add little beyond the file.
|
|
294
|
+
if (Number(req.headers.get('content-length') ?? 0) > MAX_KNOWLEDGE_UPLOAD_BYTES + 64 * 1024) {
|
|
295
|
+
return json({ error: `the document is larger than ${MAX_KNOWLEDGE_UPLOAD_BYTES / 1024 / 1024} MB, the most a knowledge base takes` }, 413)
|
|
296
|
+
}
|
|
297
|
+
const form = await req.formData().catch(() => null)
|
|
298
|
+
const file = form?.get('file')
|
|
299
|
+
if (!(file instanceof Blob)) return json({ error: 'send the document as the multipart field "file"' }, 400)
|
|
300
|
+
const name = checkUpload((file as File).name ?? '', file.size)
|
|
301
|
+
const replace = url.searchParams.get('replace') === '1'
|
|
302
|
+
// Replacing: the runtime's copy goes first, so the apply uploads the new content (it only adds what the runtime lacks).
|
|
303
|
+
if (replace && id && documentInFolder(project.root, folder, name)) {
|
|
304
|
+
const call = serviceCall()
|
|
305
|
+
if (call) {
|
|
306
|
+
try {
|
|
307
|
+
await removeRuntimeCopies(call, id, name)
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return json({ error: `the runtime's copy of ${name} couldn't be taken off (${(err as Error).message}), so knowledge/${folder}/${name} is left as it was` }, 502)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const saved = saveKnowledgeDocument(project.root, folder, name, new Uint8Array(await file.arrayBuffer()), { replace })
|
|
314
|
+
return json({ ...saved, watching: watching() })
|
|
315
|
+
}
|
|
316
|
+
if (req.method === 'DELETE') {
|
|
317
|
+
const sourceId = url.searchParams.get('sourceId') ?? ''
|
|
318
|
+
if (!id || !/^[\w-]{1,80}$/.test(sourceId)) return json({ error: 'name the document, as id=<knowledge id>&sourceId=<document id>' }, 400)
|
|
319
|
+
const call = serviceCall()
|
|
320
|
+
if (!call) return json({ error: 'the runtime is not seeded yet' }, 409)
|
|
321
|
+
const source = (await runtimeDocuments(call, id)).find((d) => d.id === sourceId)
|
|
322
|
+
if (!source) return json({ error: 'the runtime has no such document in this knowledge base' }, 404)
|
|
323
|
+
await call('DELETE', `/v1/workspace-knowledge/${encodeURIComponent(id)}/sources/${encodeURIComponent(sourceId)}`)
|
|
324
|
+
// Its file too, so the next apply does not upload it again; a base with no folder had only the runtime's copy.
|
|
325
|
+
// A runtime document of a kind or name the folder can't hold (a spreadsheet uploaded on the runtime) has no file to remove.
|
|
326
|
+
const removed = (() => {
|
|
327
|
+
if (!folder) return { path: null, removed: false }
|
|
328
|
+
try {
|
|
329
|
+
return removeKnowledgeDocument(project.root, folder, source.path)
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (err instanceof KnowledgeDocumentError && err.status !== 409) return { path: null, removed: false }
|
|
332
|
+
throw err
|
|
333
|
+
}
|
|
334
|
+
})()
|
|
335
|
+
return json({ path: removed.path, removedFile: removed.removed })
|
|
336
|
+
}
|
|
337
|
+
return json({ error: 'not found' }, 404)
|
|
177
338
|
} catch (err) {
|
|
339
|
+
if (err instanceof KnowledgeDocumentError) return json({ error: err.message, ...err.extra }, err.status)
|
|
178
340
|
return json({ error: (err as Error).message }, 502)
|
|
179
341
|
}
|
|
180
342
|
}
|
|
@@ -316,6 +478,7 @@ export function startStudioServer(opts: StudioServerOptions) {
|
|
|
316
478
|
const started = testRuns.start({ agent: typeof body.agent === 'string' ? body.agent : undefined, live: body.live === true })
|
|
317
479
|
return 'run' in started ? json(started.run, 202) : json({ error: started.error }, started.status)
|
|
318
480
|
}
|
|
481
|
+
if (url.pathname === '/__local/knowledge' || url.pathname === '/__local/knowledge/documents') return knowledgeRoute(req, url)
|
|
319
482
|
if (url.pathname.startsWith('/__local/')) return localRoute(url)
|
|
320
483
|
// `/api/…` is the service; `/api` alone is Studio's API page.
|
|
321
484
|
if (url.pathname.startsWith('/api/')) return proxy(req, url)
|