@frontera-sdk/cli 1.52.22 → 1.52.24
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/package.json +4 -4
- package/src/api/dataset-api.ts +8 -4
- package/src/automation-template.ts +3 -0
- package/src/blueprint/tree.ts +27 -0
- package/src/commands/local/check.ts +64 -0
- package/src/commands/local/dev.ts +301 -0
- package/src/commands/local/docs-mcp.ts +36 -0
- package/src/commands/local/index-commands.ts +477 -0
- package/src/commands/local/init.ts +78 -0
- package/src/commands/local/key.ts +108 -0
- package/src/commands/local/providers.ts +245 -0
- package/src/commands/local/schema.ts +46 -0
- package/src/commands/registry.ts +6 -4
- package/src/commands/types.ts +7 -0
- package/src/flag-help.ts +18 -0
- package/src/harness.ts +1 -1
- package/src/local/local-agent-files.ts +122 -0
- package/src/local/local-applied.ts +135 -0
- package/src/local/local-apply-error.ts +35 -0
- package/src/local/local-control.ts +143 -0
- package/src/local/local-dev-sync.ts +256 -0
- package/src/local/local-docker.ts +146 -0
- package/src/local/local-docs-search.ts +126 -0
- package/src/local/local-docs.ts +241 -0
- package/src/local/local-embeddings.ts +79 -0
- package/src/local/local-env.ts +225 -0
- package/src/local/local-keys.ts +115 -0
- package/src/local/local-operating.ts +82 -0
- package/src/local/local-ports.ts +111 -0
- package/src/local/local-process.ts +106 -0
- package/src/local/local-project.ts +54 -0
- package/src/local/local-providers.ts +268 -0
- package/src/local/local-scaffold.ts +404 -0
- package/src/local/local-schemas.ts +301 -0
- package/src/local/local-seed.ts +332 -0
- package/src/local/local-state.ts +165 -0
- package/src/local/local-studio-assets.ts +68 -0
- package/src/local/local-studio-server.ts +304 -0
- package/src/local/local-supervisor.ts +561 -0
- package/src/local/local-test-cases.ts +58 -0
- package/src/local/local-test-runner.ts +191 -0
- package/src/local/local-test-runs.ts +75 -0
- package/src/local/local-traces.ts +322 -0
- package/src/main.ts +1 -1
- package/src/packaging.ts +5 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process'
|
|
2
|
+
import { appendFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
containerState,
|
|
7
|
+
copyFromContainer,
|
|
8
|
+
execInContainer,
|
|
9
|
+
followLogs,
|
|
10
|
+
imagePresent,
|
|
11
|
+
pullImage,
|
|
12
|
+
removeContainer,
|
|
13
|
+
runContainer,
|
|
14
|
+
startContainer,
|
|
15
|
+
stopContainer,
|
|
16
|
+
} from './local-docker'
|
|
17
|
+
import {
|
|
18
|
+
CATALOG_DB,
|
|
19
|
+
databaseUrl,
|
|
20
|
+
fromContainer,
|
|
21
|
+
RUNNER_OUTBOUND,
|
|
22
|
+
SERVICE_OUTBOUND,
|
|
23
|
+
HOST_FROM_CONTAINER,
|
|
24
|
+
loadOrCreateSecrets,
|
|
25
|
+
POSTGRES_DB,
|
|
26
|
+
POSTGRES_USER,
|
|
27
|
+
readConfig,
|
|
28
|
+
runnerEnv,
|
|
29
|
+
serviceEnv,
|
|
30
|
+
type LocalConfig,
|
|
31
|
+
type LocalSecrets,
|
|
32
|
+
} from './local-env'
|
|
33
|
+
import { keylessEmbeddings, providerEnv } from './local-keys'
|
|
34
|
+
import { applyProviderSetup, readProviderSetup, rememberSlugs, writeProviderSetup } from './local-providers'
|
|
35
|
+
import type { PortBlock } from './local-ports'
|
|
36
|
+
import { startProcess, stopProcess, waitForHttp } from './local-process'
|
|
37
|
+
import type { LocalProject } from './local-project'
|
|
38
|
+
import { startLocalEmbeddings } from './local-embeddings'
|
|
39
|
+
import { LOCAL_TRACE_KEYS, startLocalTraces } from './local-traces'
|
|
40
|
+
import { copyDocs, customerDocsBundle, IMAGE_DOCS_PATH } from './local-docs'
|
|
41
|
+
import { keepSchemas, type SchemaSource } from './local-schemas'
|
|
42
|
+
import { applyModelKeys, ensureBlueprint, ensureOrgKey, ensureProviderModels, retireLocalModel, seedLocalRuntime, signIn } from './local-seed'
|
|
43
|
+
import {
|
|
44
|
+
logPath,
|
|
45
|
+
readState,
|
|
46
|
+
updateComponent,
|
|
47
|
+
writeState,
|
|
48
|
+
type ComponentName,
|
|
49
|
+
type ComponentState,
|
|
50
|
+
type LocalState,
|
|
51
|
+
} from './local-state'
|
|
52
|
+
import { newSessionToken, startStudioServer } from './local-studio-server'
|
|
53
|
+
import { buildStudio, studioAssetsDir, studioBuildStale } from './local-studio-assets'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The local runtime's supervisor: one detached process per solution directory.
|
|
57
|
+
*
|
|
58
|
+
* It starts the components in dependency order, waits for each to be healthy
|
|
59
|
+
* before starting the next, seeds the runtime once, serves Studio, and on
|
|
60
|
+
* SIGTERM stops everything it started. Containers are stopped, not removed
|
|
61
|
+
* with their volumes, so `start` after `stop` comes back with the same data —
|
|
62
|
+
* the way `supabase stop` keeps volumes unless asked not to.
|
|
63
|
+
*
|
|
64
|
+
* Images are pinned: Postgres with pgvector and the Inngest dev server at the
|
|
65
|
+
* versions the platform's own compose files and chart use.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** The registry the release workflow publishes the platform images to. */
|
|
69
|
+
export const DEFAULT_REGISTRY = 'ghcr.io/sebati-ai'
|
|
70
|
+
|
|
71
|
+
export const IMAGES = {
|
|
72
|
+
postgres: 'pgvector/pgvector:pg17',
|
|
73
|
+
cache: 'redis:7-alpine',
|
|
74
|
+
storage: 'rustfs/rustfs:1.0.0',
|
|
75
|
+
jobs: 'inngest/inngest:v1.45.1',
|
|
76
|
+
service: (version: string, registry = DEFAULT_REGISTRY) => `${registry}/agents-service:${version}`,
|
|
77
|
+
runner: (version: string, registry = DEFAULT_REGISTRY) => `${registry}/agents-automation-runner:${version}`,
|
|
78
|
+
} as const
|
|
79
|
+
|
|
80
|
+
export const containerName = (project: LocalProject, component: ComponentName) =>
|
|
81
|
+
`frontera-${project.id}-${component}`
|
|
82
|
+
|
|
83
|
+
export const volumeName = (project: LocalProject) => `frontera-${project.id}-db`
|
|
84
|
+
|
|
85
|
+
interface Running {
|
|
86
|
+
children: Map<ComponentName, ChildProcess>
|
|
87
|
+
containers: Set<string>
|
|
88
|
+
studio?: ReturnType<typeof startStudioServer>
|
|
89
|
+
embeddings?: ReturnType<typeof startLocalEmbeddings>
|
|
90
|
+
traces?: ReturnType<typeof startLocalTraces>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function runSupervisor(project: LocalProject, ports: PortBlock, sessionToken = newSessionToken()): Promise<void> {
|
|
94
|
+
const config = readConfig(project)
|
|
95
|
+
if (!config) throw new Error('no local runtime config; run `frontera local start`')
|
|
96
|
+
const secrets = loadOrCreateSecrets(project)
|
|
97
|
+
const previous = readState(project)
|
|
98
|
+
const enabled = (c: ComponentName) => !config.exclude.includes(c)
|
|
99
|
+
|
|
100
|
+
let state: LocalState = {
|
|
101
|
+
version: 1,
|
|
102
|
+
projectId: project.id,
|
|
103
|
+
root: project.root,
|
|
104
|
+
mode: config.mode,
|
|
105
|
+
platformVersion: config.platformVersion,
|
|
106
|
+
supervisorPid: process.pid,
|
|
107
|
+
phase: 'starting',
|
|
108
|
+
startedAt: new Date().toISOString(),
|
|
109
|
+
updatedAt: new Date().toISOString(),
|
|
110
|
+
ports,
|
|
111
|
+
components: (['postgres', 'storage', 'service', 'jobs', 'runner', 'studio'] as ComponentName[]).map(
|
|
112
|
+
(name): ComponentState => ({
|
|
113
|
+
name,
|
|
114
|
+
status: enabled(name) ? 'starting' : 'off',
|
|
115
|
+
kind: name === 'studio' ? 'supervisor' : name === 'postgres' || name === 'jobs' || name === 'storage' ? 'container' : config.mode === 'source' ? 'process' : 'container',
|
|
116
|
+
port: ports[name],
|
|
117
|
+
detail: enabled(name) ? undefined : 'excluded',
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
credentials: previous?.credentials,
|
|
121
|
+
}
|
|
122
|
+
const save = () => writeState(project, state)
|
|
123
|
+
const set = (name: ComponentName, patch: Partial<ComponentState>) => {
|
|
124
|
+
const current = state.components.find((c) => c.name === name)!
|
|
125
|
+
state = updateComponent(state, { ...current, ...patch })
|
|
126
|
+
save()
|
|
127
|
+
}
|
|
128
|
+
save()
|
|
129
|
+
|
|
130
|
+
const running: Running = { children: new Map(), containers: new Set() }
|
|
131
|
+
let stopping = false
|
|
132
|
+
|
|
133
|
+
const shutdown = async (code: number) => {
|
|
134
|
+
if (stopping) return
|
|
135
|
+
stopping = true
|
|
136
|
+
state = { ...state, phase: 'stopping' }
|
|
137
|
+
save()
|
|
138
|
+
running.studio?.stop(true)
|
|
139
|
+
running.embeddings?.stop(true)
|
|
140
|
+
running.traces?.server.stop(true)
|
|
141
|
+
for (const [name, child] of [...running.children].reverse()) {
|
|
142
|
+
await stopProcess(child.pid)
|
|
143
|
+
set(name, { status: 'stopped', pid: undefined })
|
|
144
|
+
}
|
|
145
|
+
for (const name of [...running.containers].reverse()) stopContainer(name)
|
|
146
|
+
for (const c of state.components) if (c.status === 'running' || c.status === 'starting') set(c.name, { status: 'stopped' })
|
|
147
|
+
state = { ...state, phase: 'stopped', supervisorPid: undefined }
|
|
148
|
+
save()
|
|
149
|
+
process.exit(code)
|
|
150
|
+
}
|
|
151
|
+
process.on('SIGTERM', () => void shutdown(0))
|
|
152
|
+
process.on('SIGINT', () => void shutdown(0))
|
|
153
|
+
|
|
154
|
+
const fail = async (name: ComponentName, detail: string) => {
|
|
155
|
+
set(name, { status: 'failed', detail })
|
|
156
|
+
state = { ...state, phase: 'failed', error: `${name}: ${detail}` }
|
|
157
|
+
save()
|
|
158
|
+
await shutdown(1)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// 1. Postgres
|
|
163
|
+
await startPostgres(project, ports, secrets, running, set)
|
|
164
|
+
// Object storage before anything that uploads to it.
|
|
165
|
+
if (enabled('storage')) await startStorage(project, ports, secrets, running, set)
|
|
166
|
+
// 2. Job engine (before the service, so the service's registration lands)
|
|
167
|
+
if (enabled('jobs')) await startJobs(project, ports, running, set)
|
|
168
|
+
// Keyless embeddings answer before the service needs them.
|
|
169
|
+
running.embeddings = startLocalEmbeddings({
|
|
170
|
+
port: ports.embeddings,
|
|
171
|
+
log: (line) => {
|
|
172
|
+
try {
|
|
173
|
+
appendFileSync(logPath(project, 'studio'), `[local-embeddings] ${line}\n`)
|
|
174
|
+
} catch {
|
|
175
|
+
// Logging never fails a request.
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
// Local traces receive the service's spans and answer Studio's trace reads.
|
|
180
|
+
running.traces = startLocalTraces({
|
|
181
|
+
port: ports.traces,
|
|
182
|
+
file: join(project.stateDir, 'traces.jsonl'),
|
|
183
|
+
log: (line) => {
|
|
184
|
+
try {
|
|
185
|
+
appendFileSync(logPath(project, 'studio'), `[local-traces] ${line}\n`)
|
|
186
|
+
} catch {
|
|
187
|
+
// Logging never fails a request.
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
// 3. Service
|
|
192
|
+
await startService(project, ports, secrets, config, running, set, enabled('runner'), enabled('jobs'), enabled('storage'))
|
|
193
|
+
// The service has migrated; the catalog it reads is seeded per environment.
|
|
194
|
+
seedPluginCatalog(project)
|
|
195
|
+
// 4. Function runner
|
|
196
|
+
if (enabled('runner')) await startRunner(project, ports, secrets, config, running, set, enabled('storage'))
|
|
197
|
+
// 5. Seed once
|
|
198
|
+
if (!state.credentials) {
|
|
199
|
+
state = { ...state, phase: 'seeding' }
|
|
200
|
+
save()
|
|
201
|
+
const credentials = await seedLocalRuntime({
|
|
202
|
+
serviceUrl: `http://127.0.0.1:${ports.service}`,
|
|
203
|
+
password: secrets.ownerPassword,
|
|
204
|
+
databaseUrl: databaseUrl(secrets, ports),
|
|
205
|
+
logFile: logPath(project, 'service'),
|
|
206
|
+
})
|
|
207
|
+
state = { ...state, credentials }
|
|
208
|
+
save()
|
|
209
|
+
}
|
|
210
|
+
// Trace reads are per workspace: the local workspace reads local traces.
|
|
211
|
+
seedTraceKeys(project, state.credentials!.workspaceId)
|
|
212
|
+
// Every start: a fresh owner session for Studio, and the FDE's current model keys.
|
|
213
|
+
const serviceUrl = `http://127.0.0.1:${ports.service}`
|
|
214
|
+
const session = await signIn(serviceUrl, secrets.ownerPassword)
|
|
215
|
+
state = { ...state, credentials: await ensureOrgKey(serviceUrl, session, state.credentials!, logPath(project, 'service')) }
|
|
216
|
+
const modelProviders = await applyModelKeys({
|
|
217
|
+
serviceUrl,
|
|
218
|
+
password: secrets.ownerPassword,
|
|
219
|
+
organizationId: state.credentials!.organizationId,
|
|
220
|
+
logFile: logPath(project, 'service'),
|
|
221
|
+
})
|
|
222
|
+
await ensureProviderModels({
|
|
223
|
+
serviceUrl,
|
|
224
|
+
password: secrets.ownerPassword,
|
|
225
|
+
organizationId: state.credentials!.organizationId,
|
|
226
|
+
providers: modelProviders,
|
|
227
|
+
logFile: logPath(project, 'service'),
|
|
228
|
+
})
|
|
229
|
+
await retireLocalModel({
|
|
230
|
+
serviceUrl,
|
|
231
|
+
password: secrets.ownerPassword,
|
|
232
|
+
organizationId: state.credentials!.organizationId,
|
|
233
|
+
workspaceId: state.credentials!.workspaceId,
|
|
234
|
+
logFile: logPath(project, 'service'),
|
|
235
|
+
})
|
|
236
|
+
// The FDE's own providers, models and defaults (`frontera local providers`).
|
|
237
|
+
const setup = readProviderSetup()
|
|
238
|
+
const provided = await applyProviderSetup(
|
|
239
|
+
{
|
|
240
|
+
serviceUrl,
|
|
241
|
+
password: secrets.ownerPassword,
|
|
242
|
+
organizationId: state.credentials!.organizationId,
|
|
243
|
+
workspaceId: state.credentials!.workspaceId,
|
|
244
|
+
containerHost: config.mode === 'image' ? HOST_FROM_CONTAINER : undefined,
|
|
245
|
+
},
|
|
246
|
+
setup,
|
|
247
|
+
)
|
|
248
|
+
for (const error of provided.errors) appendFileSync(logPath(project, 'service'), `[local-providers] ${error}\n`)
|
|
249
|
+
modelProviders.push(...provided.custom.map((slug) => `custom:${slug}`))
|
|
250
|
+
const remembered = rememberSlugs(setup, provided)
|
|
251
|
+
if (JSON.stringify(remembered) !== JSON.stringify(setup)) writeProviderSetup(remembered)
|
|
252
|
+
await ensureBlueprint({
|
|
253
|
+
serviceUrl,
|
|
254
|
+
password: secrets.ownerPassword,
|
|
255
|
+
organizationId: state.credentials!.organizationId,
|
|
256
|
+
logFile: logPath(project, 'service'),
|
|
257
|
+
})
|
|
258
|
+
state = { ...state, credentials: { ...state.credentials!, session, modelProviders } }
|
|
259
|
+
save()
|
|
260
|
+
// The formats of the project's files, for the release the service runs.
|
|
261
|
+
await keepFileSchemas(project, { serviceUrl, token: state.credentials!.workspaceKey ?? session }, logPath(project, 'service'))
|
|
262
|
+
// 6. Studio. From a checkout, rebuild it first when its sources changed
|
|
263
|
+
// since the last build, so a pulled change is never served stale.
|
|
264
|
+
if (studioBuildStale(config)) {
|
|
265
|
+
set('studio', { status: 'starting' })
|
|
266
|
+
const studioLog = logPath(project, 'studio')
|
|
267
|
+
appendFileSync(studioLog, `[studio] sources changed since the last build; building Studio\n`)
|
|
268
|
+
const built = await buildStudio(config.platformSource!, studioLog)
|
|
269
|
+
if (!built) appendFileSync(studioLog, `[studio] the build failed; serving the previous build, if any\n`)
|
|
270
|
+
}
|
|
271
|
+
running.studio = startStudioServer({
|
|
272
|
+
project,
|
|
273
|
+
port: ports.studio,
|
|
274
|
+
serviceUrl: `http://127.0.0.1:${ports.service}`,
|
|
275
|
+
assetsDir: () => studioAssetsDir(config),
|
|
276
|
+
sessionToken,
|
|
277
|
+
})
|
|
278
|
+
set('studio', { status: 'running', startedAt: new Date().toISOString(), pid: process.pid })
|
|
279
|
+
state = { ...state, phase: 'ready', error: undefined }
|
|
280
|
+
save()
|
|
281
|
+
} catch (err) {
|
|
282
|
+
const failing = state.components.find((c) => c.status === 'starting')?.name ?? 'service'
|
|
283
|
+
await fail(failing, (err as Error).message)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Watch native children: a crash is reported, not hidden.
|
|
288
|
+
for (const [name, child] of running.children) {
|
|
289
|
+
child.on('exit', (code) => {
|
|
290
|
+
if (stopping) return
|
|
291
|
+
set(name, { status: 'failed', pid: undefined, detail: `exited with code ${code ?? 'signal'}; see \`frontera local logs ${name}\`` })
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type SetFn = (name: ComponentName, patch: Partial<ComponentState>) => void
|
|
297
|
+
|
|
298
|
+
async function ensureImage(image: string, project: LocalProject, component: ComponentName): Promise<void> {
|
|
299
|
+
if (imagePresent(image)) return
|
|
300
|
+
await pullImage(image, logPath(project, component))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The Plugin catalog is seeded per environment, and a fresh database has none,
|
|
305
|
+
* so a local MCP server could not be installed. One generic "MCP server" entry,
|
|
306
|
+
* the same values the service's own tests seed; kept if it already exists.
|
|
307
|
+
*/
|
|
308
|
+
const MCP_CATALOG_SQL = `INSERT INTO app_catalog
|
|
309
|
+
(kind, name, display_name, description, auth_type, config_schema, multi_install, category, transport, auth_scheme,
|
|
310
|
+
supported_account_modes, oauth_metadata, binding_interaction, trigger_types, inbound_secrets)
|
|
311
|
+
VALUES
|
|
312
|
+
('mcp', 'MCP', 'MCP Server', 'External Model Context Protocol server.', 'custom', '{"type":"object","properties":{}}', true,
|
|
313
|
+
'custom', 'mcp_http', 'api_key', '["agent_owned"]', NULL, 'none', '[]', '[]')
|
|
314
|
+
ON CONFLICT (kind) DO NOTHING;`
|
|
315
|
+
|
|
316
|
+
function seedTraceKeys(project: LocalProject, workspaceId: string): void {
|
|
317
|
+
if (!/^[0-9a-f-]{36}$/i.test(workspaceId)) return
|
|
318
|
+
const sql = `UPDATE workspaces SET langfuse_public_key = '${LOCAL_TRACE_KEYS.publicKey}', langfuse_secret_key = '${LOCAL_TRACE_KEYS.secretKey}' WHERE id = '${workspaceId}' AND langfuse_public_key IS DISTINCT FROM '${LOCAL_TRACE_KEYS.publicKey}'`
|
|
319
|
+
execInContainer(containerName(project, 'postgres'), ['psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-c', sql])
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function seedPluginCatalog(project: LocalProject): void {
|
|
323
|
+
execInContainer(containerName(project, 'postgres'), ['psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-c', MCP_CATALOG_SQL])
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function startPostgres(project: LocalProject, ports: PortBlock, secrets: LocalSecrets, running: Running, set: SetFn) {
|
|
327
|
+
const name = containerName(project, 'postgres')
|
|
328
|
+
await ensureImage(IMAGES.postgres, project, 'postgres')
|
|
329
|
+
const current = containerState(name)
|
|
330
|
+
if (current === 'exited') startContainer(name)
|
|
331
|
+
else if (current !== 'running') {
|
|
332
|
+
if (current !== 'missing') removeContainer(name)
|
|
333
|
+
runContainer({
|
|
334
|
+
name,
|
|
335
|
+
image: IMAGES.postgres,
|
|
336
|
+
projectId: project.id,
|
|
337
|
+
ports: [{ host: ports.postgres, container: 5432 }],
|
|
338
|
+
env: { POSTGRES_USER, POSTGRES_PASSWORD: secrets.postgresPassword, POSTGRES_DB },
|
|
339
|
+
volumes: [{ name: volumeName(project), path: '/var/lib/postgresql/data' }],
|
|
340
|
+
memory: '768m',
|
|
341
|
+
cpus: '1.5',
|
|
342
|
+
// Postgres defaults are sized for a server; a laptop runtime needs far less.
|
|
343
|
+
args: ['postgres', '-c', 'shared_buffers=128MB', '-c', 'max_connections=60', '-c', 'fsync=off', '-c', 'synchronous_commit=off'],
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
running.containers.add(name)
|
|
347
|
+
followLogs(name, logPath(project, 'postgres'))
|
|
348
|
+
|
|
349
|
+
// The service's agent-config cache. Without it the service works but retries
|
|
350
|
+
// a connection on every request; a 32 MB Redis is cheaper than the noise.
|
|
351
|
+
const cache = `frontera-${project.id}-cache`
|
|
352
|
+
await ensureImage(IMAGES.cache, project, 'postgres')
|
|
353
|
+
if (containerState(cache) === 'exited') startContainer(cache)
|
|
354
|
+
else if (containerState(cache) !== 'running') {
|
|
355
|
+
removeContainer(cache)
|
|
356
|
+
runContainer({
|
|
357
|
+
name: cache,
|
|
358
|
+
image: IMAGES.cache,
|
|
359
|
+
projectId: project.id,
|
|
360
|
+
ports: [{ host: ports.cache, container: 6379 }],
|
|
361
|
+
memory: '64m',
|
|
362
|
+
cpus: '0.5',
|
|
363
|
+
args: ['redis-server', '--save', '', '--appendonly', 'no', '--maxmemory', '32mb'],
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
running.containers.add(cache)
|
|
367
|
+
|
|
368
|
+
const deadline = Date.now() + 60_000
|
|
369
|
+
while (Date.now() < deadline) {
|
|
370
|
+
if (execInContainer(name, ['pg_isready', '-U', POSTGRES_USER, '-d', POSTGRES_DB])) {
|
|
371
|
+
// The vector extension the platform's migrations expect.
|
|
372
|
+
execInContainer(name, ['psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-c', 'CREATE EXTENSION IF NOT EXISTS vector;'])
|
|
373
|
+
// The managed datasets' catalog database; "already exists" on later starts is expected.
|
|
374
|
+
execInContainer(name, ['psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-c', `CREATE DATABASE ${CATALOG_DB};`])
|
|
375
|
+
set('postgres', { status: 'running', container: name, startedAt: new Date().toISOString() })
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
await Bun.sleep(500)
|
|
379
|
+
}
|
|
380
|
+
throw new Error('Postgres did not become ready within 60 s')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function startStorage(project: LocalProject, ports: PortBlock, secrets: LocalSecrets, running: Running, set: SetFn) {
|
|
384
|
+
const name = containerName(project, 'storage')
|
|
385
|
+
await ensureImage(IMAGES.storage, project, 'storage')
|
|
386
|
+
const current = containerState(name)
|
|
387
|
+
if (current === 'exited') startContainer(name)
|
|
388
|
+
else if (current !== 'running') {
|
|
389
|
+
if (current !== 'missing') removeContainer(name)
|
|
390
|
+
runContainer({
|
|
391
|
+
name,
|
|
392
|
+
image: IMAGES.storage,
|
|
393
|
+
projectId: project.id,
|
|
394
|
+
ports: [{ host: ports.storage, container: 9000 }],
|
|
395
|
+
env: { RUSTFS_ACCESS_KEY: secrets.storageAccessKey, RUSTFS_SECRET_KEY: secrets.storageSecretKey },
|
|
396
|
+
volumes: [{ name: `frontera-${project.id}-storage`, path: '/data' }],
|
|
397
|
+
memory: '256m',
|
|
398
|
+
cpus: '0.5',
|
|
399
|
+
args: ['server', '/data'],
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
running.containers.add(name)
|
|
403
|
+
followLogs(name, logPath(project, 'storage'))
|
|
404
|
+
// RustFS answers its S3 API with 403 to an anonymous request once it is up.
|
|
405
|
+
const deadline = Date.now() + 45_000
|
|
406
|
+
while (Date.now() < deadline) {
|
|
407
|
+
try {
|
|
408
|
+
const res = await fetch(`http://127.0.0.1:${ports.storage}/`, { signal: AbortSignal.timeout(2000) })
|
|
409
|
+
if (res.status > 0) {
|
|
410
|
+
set('storage', { status: 'running', container: name, startedAt: new Date().toISOString() })
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
// Not up yet.
|
|
415
|
+
}
|
|
416
|
+
await Bun.sleep(500)
|
|
417
|
+
}
|
|
418
|
+
throw new Error('object storage did not answer within 45 s')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function startJobs(project: LocalProject, ports: PortBlock, running: Running, set: SetFn) {
|
|
422
|
+
const name = containerName(project, 'jobs')
|
|
423
|
+
await ensureImage(IMAGES.jobs, project, 'jobs')
|
|
424
|
+
if (containerState(name) !== 'missing') removeContainer(name)
|
|
425
|
+
runContainer({
|
|
426
|
+
name,
|
|
427
|
+
image: IMAGES.jobs,
|
|
428
|
+
projectId: project.id,
|
|
429
|
+
ports: [{ host: ports.jobs, container: 8288 }],
|
|
430
|
+
flags: ['--add-host', `${HOST_FROM_CONTAINER}:host-gateway`],
|
|
431
|
+
memory: '384m',
|
|
432
|
+
cpus: '1',
|
|
433
|
+
args: ['inngest', 'dev', '--no-discovery', '-u', `http://${HOST_FROM_CONTAINER}:${ports.service}/api/inngest`],
|
|
434
|
+
})
|
|
435
|
+
running.containers.add(name)
|
|
436
|
+
followLogs(name, logPath(project, 'jobs'))
|
|
437
|
+
if (!(await waitForHttp(`http://127.0.0.1:${ports.jobs}/health`, 45_000)) && !(await waitForHttp(`http://127.0.0.1:${ports.jobs}/`, 5_000))) {
|
|
438
|
+
throw new Error('the job engine did not answer within 50 s')
|
|
439
|
+
}
|
|
440
|
+
set('jobs', { status: 'running', container: name, startedAt: new Date().toISOString() })
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function serviceDir(config: LocalConfig): string {
|
|
444
|
+
return join(config.platformSource!, 'packages', 'service')
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function startService(
|
|
448
|
+
project: LocalProject,
|
|
449
|
+
ports: PortBlock,
|
|
450
|
+
secrets: LocalSecrets,
|
|
451
|
+
config: LocalConfig,
|
|
452
|
+
running: Running,
|
|
453
|
+
set: SetFn,
|
|
454
|
+
runnerEnabled: boolean,
|
|
455
|
+
jobsEnabled: boolean,
|
|
456
|
+
storageEnabled: boolean,
|
|
457
|
+
) {
|
|
458
|
+
const env = serviceEnv({
|
|
459
|
+
secrets,
|
|
460
|
+
ports,
|
|
461
|
+
providerEnv: providerEnv(),
|
|
462
|
+
runnerEnabled,
|
|
463
|
+
jobsEnabled,
|
|
464
|
+
storageEnabled,
|
|
465
|
+
keylessEmbeddings: keylessEmbeddings(),
|
|
466
|
+
documentationBundle: config.mode === 'source' ? customerDocsBundle(config.platformSource) : null,
|
|
467
|
+
})
|
|
468
|
+
const log = logPath(project, 'service')
|
|
469
|
+
if (config.mode === 'source') {
|
|
470
|
+
const cwd = serviceDir(config)
|
|
471
|
+
if (!existsSync(join(cwd, 'src', 'index.ts'))) throw new Error(`no service source at ${cwd}`)
|
|
472
|
+
// Migrations first, synchronously, so a broken migration is the error shown.
|
|
473
|
+
const migrate = startProcess({ name: 'migrate', command: 'bun', args: ['scripts/migrate.ts'], cwd, env, logFile: log })
|
|
474
|
+
const code: number = await new Promise((r) => migrate.on('exit', (c) => r(c ?? 1)))
|
|
475
|
+
if (code !== 0) throw new Error(`migrations failed (exit ${code}); see \`frontera local logs service\``)
|
|
476
|
+
const child = startProcess({ name: 'service', command: 'bun', args: ['src/index.ts'], cwd, env, logFile: log })
|
|
477
|
+
running.children.set('service', child)
|
|
478
|
+
const ok = await waitForHttp(`http://127.0.0.1:${ports.service}/health`, 120_000, () => child.exitCode === null)
|
|
479
|
+
if (!ok) throw new Error('the service did not become healthy; see `frontera local logs service`')
|
|
480
|
+
set('service', { status: 'running', pid: child.pid, startedAt: new Date().toISOString() })
|
|
481
|
+
keepDocs(project, { bundle: customerDocsBundle(config.platformSource) }, log)
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
const name = containerName(project, 'service')
|
|
485
|
+
const image = IMAGES.service(config.platformVersion, config.registry)
|
|
486
|
+
await ensureImage(image, project, 'service')
|
|
487
|
+
if (containerState(name) !== 'missing') removeContainer(name)
|
|
488
|
+
const containerEnv = fromContainer(env, SERVICE_OUTBOUND)
|
|
489
|
+
runContainer({
|
|
490
|
+
name,
|
|
491
|
+
image,
|
|
492
|
+
projectId: project.id,
|
|
493
|
+
ports: [{ host: ports.service, container: ports.service }],
|
|
494
|
+
env: containerEnv,
|
|
495
|
+
flags: ['--add-host', `${HOST_FROM_CONTAINER}:host-gateway`],
|
|
496
|
+
memory: '1536m',
|
|
497
|
+
cpus: '2',
|
|
498
|
+
})
|
|
499
|
+
running.containers.add(name)
|
|
500
|
+
followLogs(name, log)
|
|
501
|
+
const ok = await waitForHttp(`http://127.0.0.1:${ports.service}/health`, 180_000)
|
|
502
|
+
if (!ok) throw new Error('the service did not become healthy; see `frontera local logs service`')
|
|
503
|
+
set('service', { status: 'running', container: name, startedAt: new Date().toISOString() })
|
|
504
|
+
keepDocs(project, { fromContainer: (dest) => copyFromContainer(name, IMAGE_DOCS_PATH, dest) }, log)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The release's documentation, for the solution's coding agents (`local docs-mcp`). */
|
|
508
|
+
function keepDocs(project: LocalProject, from: { bundle?: string | null; fromContainer?: (dest: string) => boolean }, log: string): void {
|
|
509
|
+
if (!from.bundle && !from.fromContainer) {
|
|
510
|
+
appendFileSync(log, '[docs] no customer documentation in the platform checkout; run `bun run docs:build` there\n')
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
const problem = copyDocs({ project, ...from })
|
|
514
|
+
if (problem) appendFileSync(log, `[docs] the documentation for coding agents was not copied: ${problem}\n`)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The formats of the project's files, for its coding agents and editors (`local schema`). Logged, never a reason to fail start. */
|
|
518
|
+
async function keepFileSchemas(project: LocalProject, source: SchemaSource, log: string): Promise<void> {
|
|
519
|
+
for (const problem of await keepSchemas(project, source)) appendFileSync(log, `[schemas] not kept: ${problem}\n`)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function startRunner(
|
|
523
|
+
project: LocalProject,
|
|
524
|
+
ports: PortBlock,
|
|
525
|
+
secrets: LocalSecrets,
|
|
526
|
+
config: LocalConfig,
|
|
527
|
+
running: Running,
|
|
528
|
+
set: SetFn,
|
|
529
|
+
storageEnabled: boolean,
|
|
530
|
+
) {
|
|
531
|
+
const env = runnerEnv({ secrets, ports, storageEnabled })
|
|
532
|
+
const log = logPath(project, 'runner')
|
|
533
|
+
if (config.mode === 'source') {
|
|
534
|
+
const cwd = config.platformSource!
|
|
535
|
+
const child = startProcess({ name: 'runner', command: 'bun', args: ['packages/automation-runner/src/index.ts'], cwd, env, logFile: log })
|
|
536
|
+
running.children.set('runner', child)
|
|
537
|
+
const ok = await waitForHttp(`http://127.0.0.1:${ports.runner}/healthz`, 60_000, () => child.exitCode === null)
|
|
538
|
+
if (!ok) throw new Error('the Function runner did not become healthy; see `frontera local logs runner`')
|
|
539
|
+
set('runner', { status: 'running', pid: child.pid, startedAt: new Date().toISOString() })
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
const name = containerName(project, 'runner')
|
|
543
|
+
const image = IMAGES.runner(config.platformVersion, config.registry)
|
|
544
|
+
await ensureImage(image, project, 'runner')
|
|
545
|
+
if (containerState(name) !== 'missing') removeContainer(name)
|
|
546
|
+
runContainer({
|
|
547
|
+
name,
|
|
548
|
+
image,
|
|
549
|
+
projectId: project.id,
|
|
550
|
+
ports: [{ host: ports.runner, container: ports.runner }],
|
|
551
|
+
env: fromContainer(env, RUNNER_OUTBOUND),
|
|
552
|
+
flags: ['--add-host', `${HOST_FROM_CONTAINER}:host-gateway`],
|
|
553
|
+
memory: '768m',
|
|
554
|
+
cpus: '1',
|
|
555
|
+
})
|
|
556
|
+
running.containers.add(name)
|
|
557
|
+
followLogs(name, log)
|
|
558
|
+
const ok = await waitForHttp(`http://127.0.0.1:${ports.runner}/healthz`, 90_000)
|
|
559
|
+
if (!ok) throw new Error('the Function runner did not become healthy; see `frontera local logs runner`')
|
|
560
|
+
set('runner', { status: 'running', container: name, startedAt: new Date().toISOString() })
|
|
561
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Write a conversation into the solution as a regression case.
|
|
6
|
+
*
|
|
7
|
+
* The file is `agents/<slug>/evals/regression.yaml` under the solution root,
|
|
8
|
+
* the layout the solution delivery design uses for an Agent's evaluations. A
|
|
9
|
+
* case is appended, never rewritten, so an FDE's hand edits survive; the
|
|
10
|
+
* expected outcome is the Agent's last reply, which the FDE refines in the
|
|
11
|
+
* file before committing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface ChatMessage {
|
|
15
|
+
role?: string
|
|
16
|
+
content?: unknown
|
|
17
|
+
parts?: Array<{ type?: string; text?: string }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function textOf(message: ChatMessage): string {
|
|
21
|
+
if (typeof message.content === 'string') return message.content
|
|
22
|
+
return (message.parts ?? [])
|
|
23
|
+
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
24
|
+
.map((p) => p.text)
|
|
25
|
+
.join('\n')
|
|
26
|
+
.trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A YAML block scalar, indented, that survives any text. */
|
|
30
|
+
function block(text: string, indent: string): string {
|
|
31
|
+
return `|-\n${text.split('\n').map((line) => `${indent}${line}`).join('\n')}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderTestCase(args: { conversationId: string; messages: ChatMessage[]; savedAt: string }): string {
|
|
35
|
+
const turns = args.messages.filter((m) => (m.role === 'user' || m.role === 'assistant') && textOf(m))
|
|
36
|
+
const lastReply = [...turns].reverse().find((m) => m.role === 'assistant')
|
|
37
|
+
const inputs = turns.slice(0, lastReply ? turns.lastIndexOf(lastReply) : turns.length)
|
|
38
|
+
const lines = [
|
|
39
|
+
`- name: conversation-${args.conversationId.slice(0, 8)}`,
|
|
40
|
+
` savedFrom: studio ${args.savedAt}`,
|
|
41
|
+
' conversation:',
|
|
42
|
+
...inputs.map((m) => ` - ${m.role}: ${block(textOf(m), ' ')}`),
|
|
43
|
+
` expected: ${block(lastReply ? textOf(lastReply) : '', ' ')}`,
|
|
44
|
+
'',
|
|
45
|
+
]
|
|
46
|
+
return lines.join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function appendTestCase(root: string, agentSlug: string, yaml: string): string {
|
|
50
|
+
const safe = agentSlug.replace(/[^a-z0-9._-]/gi, '-')
|
|
51
|
+
const file = join(root, 'agents', safe, 'evals', 'regression.yaml')
|
|
52
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
53
|
+
if (!existsSync(file)) {
|
|
54
|
+
appendFileSync(file, `# Regression cases for ${agentSlug}. Each case: the conversation, then what a good reply does.\ncases:\n`)
|
|
55
|
+
}
|
|
56
|
+
appendFileSync(file, yaml.replace(/^/gm, ' ').replace(/ +$/gm, ''))
|
|
57
|
+
return relative(root, file)
|
|
58
|
+
}
|