@frontera-sdk/cli 1.52.23 → 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.
Files changed (45) hide show
  1. package/package.json +4 -4
  2. package/src/api/dataset-api.ts +8 -4
  3. package/src/automation-template.ts +3 -0
  4. package/src/blueprint/tree.ts +27 -0
  5. package/src/commands/local/check.ts +64 -0
  6. package/src/commands/local/dev.ts +301 -0
  7. package/src/commands/local/docs-mcp.ts +36 -0
  8. package/src/commands/local/index-commands.ts +477 -0
  9. package/src/commands/local/init.ts +78 -0
  10. package/src/commands/local/key.ts +108 -0
  11. package/src/commands/local/providers.ts +245 -0
  12. package/src/commands/local/schema.ts +46 -0
  13. package/src/commands/registry.ts +6 -4
  14. package/src/commands/types.ts +7 -0
  15. package/src/flag-help.ts +18 -0
  16. package/src/harness.ts +1 -1
  17. package/src/local/local-agent-files.ts +122 -0
  18. package/src/local/local-applied.ts +135 -0
  19. package/src/local/local-apply-error.ts +35 -0
  20. package/src/local/local-control.ts +143 -0
  21. package/src/local/local-dev-sync.ts +256 -0
  22. package/src/local/local-docker.ts +146 -0
  23. package/src/local/local-docs-search.ts +126 -0
  24. package/src/local/local-docs.ts +241 -0
  25. package/src/local/local-embeddings.ts +79 -0
  26. package/src/local/local-env.ts +225 -0
  27. package/src/local/local-keys.ts +115 -0
  28. package/src/local/local-operating.ts +82 -0
  29. package/src/local/local-ports.ts +111 -0
  30. package/src/local/local-process.ts +106 -0
  31. package/src/local/local-project.ts +54 -0
  32. package/src/local/local-providers.ts +268 -0
  33. package/src/local/local-scaffold.ts +404 -0
  34. package/src/local/local-schemas.ts +301 -0
  35. package/src/local/local-seed.ts +332 -0
  36. package/src/local/local-state.ts +165 -0
  37. package/src/local/local-studio-assets.ts +68 -0
  38. package/src/local/local-studio-server.ts +304 -0
  39. package/src/local/local-supervisor.ts +561 -0
  40. package/src/local/local-test-cases.ts +58 -0
  41. package/src/local/local-test-runner.ts +191 -0
  42. package/src/local/local-test-runs.ts +75 -0
  43. package/src/local/local-traces.ts +322 -0
  44. package/src/main.ts +1 -1
  45. 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
+ }