@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.
- 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,165 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
5
|
+
import type { LocalProject } from './local-project'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The one document that says what a local runtime is doing.
|
|
9
|
+
*
|
|
10
|
+
* The supervisor owns it and rewrites it on every transition; every other
|
|
11
|
+
* command only reads it. That single-writer rule is what lets `status`,
|
|
12
|
+
* `stop` and Studio agree without talking to each other: they all look at the
|
|
13
|
+
* same file, and the supervisor's pid in it says whether anyone is home.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type ComponentName = 'postgres' | 'storage' | 'service' | 'jobs' | 'runner' | 'studio'
|
|
17
|
+
|
|
18
|
+
export type ComponentStatus = 'off' | 'starting' | 'running' | 'stopped' | 'failed'
|
|
19
|
+
|
|
20
|
+
export interface ComponentState {
|
|
21
|
+
name: ComponentName
|
|
22
|
+
status: ComponentStatus
|
|
23
|
+
/** How it runs: a container, a native process from source, or the supervisor itself. */
|
|
24
|
+
kind: 'container' | 'process' | 'supervisor'
|
|
25
|
+
port?: number
|
|
26
|
+
pid?: number
|
|
27
|
+
container?: string
|
|
28
|
+
/** Why it is off or failed, in words a person can act on. */
|
|
29
|
+
detail?: string
|
|
30
|
+
startedAt?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LocalCredentials {
|
|
34
|
+
email: string
|
|
35
|
+
organizationId: string
|
|
36
|
+
workspaceId: string
|
|
37
|
+
workspaceSlug?: string
|
|
38
|
+
/** `sk-org-…`: the CLI's organization profile (Blueprint, datasets) uses it. */
|
|
39
|
+
orgKey: string
|
|
40
|
+
/**
|
|
41
|
+
* `owner` when `orgKey` carries every capability the owner may delegate.
|
|
42
|
+
* Runtimes seeded before that have a narrower key, replaced on next start.
|
|
43
|
+
*/
|
|
44
|
+
orgKeyGrant?: 'owner'
|
|
45
|
+
/** `sk-ws-…` scoped to the seeded workspace. */
|
|
46
|
+
workspaceKey?: string
|
|
47
|
+
/** The owner's session, refreshed on every start; Studio's API proxy acts as this person. */
|
|
48
|
+
session?: string
|
|
49
|
+
/** Providers whose keys were stored on the organization at the last start. */
|
|
50
|
+
modelProviders?: string[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LocalState {
|
|
54
|
+
version: 1
|
|
55
|
+
projectId: string
|
|
56
|
+
root: string
|
|
57
|
+
/** `source` runs the platform from a checkout; `image` runs release images. */
|
|
58
|
+
mode: 'source' | 'image'
|
|
59
|
+
platformVersion: string
|
|
60
|
+
supervisorPid?: number
|
|
61
|
+
phase: 'starting' | 'seeding' | 'ready' | 'stopping' | 'stopped' | 'failed'
|
|
62
|
+
error?: string
|
|
63
|
+
startedAt: string
|
|
64
|
+
updatedAt: string
|
|
65
|
+
ports: Record<'postgres' | 'service' | 'jobs' | 'runner' | 'studio' | 'cache' | 'embeddings' | 'storage' | 'traces', number>
|
|
66
|
+
components: ComponentState[]
|
|
67
|
+
credentials?: LocalCredentials
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function statePath(project: LocalProject): string {
|
|
71
|
+
return join(project.stateDir, 'state.json')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function logsDir(project: LocalProject): string {
|
|
75
|
+
return join(project.stateDir, 'logs')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function logPath(project: LocalProject, component: ComponentName): string {
|
|
79
|
+
return join(logsDir(project), `${component}.log`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ensureStateDir(project: LocalProject): void {
|
|
83
|
+
mkdirSync(logsDir(project), { recursive: true })
|
|
84
|
+
// The state dir holds a password and keys for a disposable local runtime.
|
|
85
|
+
// Not production secrets, but nobody else on the machine needs to read them.
|
|
86
|
+
try {
|
|
87
|
+
chmodSync(project.stateDir, 0o700)
|
|
88
|
+
} catch {
|
|
89
|
+
// Best effort; a filesystem without modes still works.
|
|
90
|
+
}
|
|
91
|
+
const ignore = join(project.root, '.frontera', '.gitignore')
|
|
92
|
+
if (!existsSync(ignore)) writeFileAtomic(ignore, 'local/\n', 0o644)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readState(project: LocalProject): LocalState | null {
|
|
96
|
+
const path = statePath(project)
|
|
97
|
+
if (!existsSync(path)) return null
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(readFileSync(path, 'utf8')) as LocalState
|
|
100
|
+
} catch {
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function writeState(project: LocalProject, state: LocalState): void {
|
|
106
|
+
ensureStateDir(project)
|
|
107
|
+
writeFileAtomic(statePath(project), `${JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2)}\n`, 0o600)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function updateComponent(state: LocalState, next: ComponentState): LocalState {
|
|
111
|
+
const others = state.components.filter((c) => c.name !== next.name)
|
|
112
|
+
return { ...state, components: [...others, next].sort(byOrder) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ORDER: ComponentName[] = ['postgres', 'storage', 'service', 'jobs', 'runner', 'studio']
|
|
116
|
+
|
|
117
|
+
function byOrder(a: ComponentState, b: ComponentState): number {
|
|
118
|
+
return ORDER.indexOf(a.name) - ORDER.indexOf(b.name)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Whether a pid names a live process. Signal 0 checks without delivering anything. */
|
|
122
|
+
export function isAlive(pid: number | undefined): boolean {
|
|
123
|
+
if (!pid) return false
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0)
|
|
126
|
+
return true
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function clearState(project: LocalProject): void {
|
|
133
|
+
rmSync(statePath(project), { force: true })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The last thing `frontera local dev` applied. Its own file rather than a
|
|
138
|
+
* field in `state.json`, which only the supervisor writes; Studio reads it with
|
|
139
|
+
* the state and refreshes its pages when it changes.
|
|
140
|
+
*/
|
|
141
|
+
export interface LastApplied {
|
|
142
|
+
at: string
|
|
143
|
+
what: string
|
|
144
|
+
ok: boolean
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function appliedPath(project: LocalProject): string {
|
|
148
|
+
return join(project.stateDir, 'last-applied.json')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function recordApplied(project: LocalProject, entry: LastApplied): void {
|
|
152
|
+
try {
|
|
153
|
+
writeFileAtomic(appliedPath(project), `${JSON.stringify(entry)}\n`, 0o600)
|
|
154
|
+
} catch {
|
|
155
|
+
// A missed refresh hint must never fail an apply.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function readApplied(project: LocalProject): LastApplied | null {
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(readFileSync(appliedPath(project), 'utf8')) as LastApplied
|
|
162
|
+
} catch {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, openSync, readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { LocalConfig } from './local-env'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the built Studio app is.
|
|
9
|
+
*
|
|
10
|
+
* In order: an explicit override, the CLI's own vendored copy (a release
|
|
11
|
+
* build places it next to the CLI), then the platform checkout the runtime
|
|
12
|
+
* runs from. `null` makes the server answer with how to build it.
|
|
13
|
+
*/
|
|
14
|
+
export function studioAssetsDir(config: LocalConfig | null): string | null {
|
|
15
|
+
const candidates = [
|
|
16
|
+
process.env.FRONTERA_STUDIO_DIST,
|
|
17
|
+
resolve(import.meta.dir, '..', 'vendor', 'studio'),
|
|
18
|
+
config?.platformSource ? join(config.platformSource, 'packages', 'studio', 'dist') : undefined,
|
|
19
|
+
resolve(import.meta.dir, '..', '..', '..', 'studio', 'dist'),
|
|
20
|
+
]
|
|
21
|
+
for (const dir of candidates) {
|
|
22
|
+
if (dir && existsSync(join(dir, 'index.html'))) return dir
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The packages a Studio build bundles from a platform checkout. */
|
|
28
|
+
const STUDIO_SOURCES = ['packages/studio/src', 'packages/studio/index.html', 'packages/chat/src', 'packages/blueprint/src', 'packages/platform-kit/src']
|
|
29
|
+
|
|
30
|
+
function newestChange(path: string): number {
|
|
31
|
+
if (!existsSync(path)) return 0
|
|
32
|
+
const stat = statSync(path)
|
|
33
|
+
if (!stat.isDirectory()) return stat.mtimeMs
|
|
34
|
+
let newest = stat.mtimeMs
|
|
35
|
+
for (const entry of readdirSync(path, { recursive: true, withFileTypes: true })) {
|
|
36
|
+
if (!entry.isFile()) continue
|
|
37
|
+
const mtime = statSync(join(entry.parentPath, entry.name)).mtimeMs
|
|
38
|
+
if (mtime > newest) newest = mtime
|
|
39
|
+
}
|
|
40
|
+
return newest
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* True when the checkout's Studio build is missing or older than its sources,
|
|
45
|
+
* so a runtime started from the checkout would serve a stale Studio. Only for
|
|
46
|
+
* the checkout's own build: an override or a vendored copy is used as it is.
|
|
47
|
+
*/
|
|
48
|
+
export function studioBuildStale(config: LocalConfig): boolean {
|
|
49
|
+
if (config.mode !== 'source' || !config.platformSource) return false
|
|
50
|
+
if (process.env.FRONTERA_STUDIO_DIST || existsSync(resolve(import.meta.dir, '..', 'vendor', 'studio', 'index.html'))) return false
|
|
51
|
+
const built = join(config.platformSource, 'packages', 'studio', 'dist', 'index.html')
|
|
52
|
+
if (!existsSync(built)) return true
|
|
53
|
+
const builtAt = statSync(built).mtimeMs
|
|
54
|
+
return STUDIO_SOURCES.some((source) => newestChange(join(config.platformSource!, source)) > builtAt)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build Studio in the checkout at low priority, logging to `logFile`; resolves whether it built. */
|
|
58
|
+
export function buildStudio(platformSource: string, logFile: string): Promise<boolean> {
|
|
59
|
+
return new Promise((done) => {
|
|
60
|
+
const fd = openSync(logFile, 'a')
|
|
61
|
+
const child = spawn('nice', ['-n', '10', 'bun', 'run', 'build'], {
|
|
62
|
+
cwd: join(platformSource, 'packages', 'studio'),
|
|
63
|
+
stdio: ['ignore', fd, fd],
|
|
64
|
+
})
|
|
65
|
+
child.on('error', () => done(false))
|
|
66
|
+
child.on('exit', (code) => done(code === 0))
|
|
67
|
+
})
|
|
68
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { extname, join, normalize } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { LocalProject } from './local-project'
|
|
6
|
+
import { AgentFileError, saveAgentEdit, type AgentFileEdit } from './local-agent-files'
|
|
7
|
+
import { solutionStatus } from './local-applied'
|
|
8
|
+
import { logPath, readApplied, readState, type ComponentName } from './local-state'
|
|
9
|
+
import { appendTestCase, renderTestCase } from './local-test-cases'
|
|
10
|
+
import { findCaseFiles } from './local-test-runner'
|
|
11
|
+
import { createTestRuns } from './local-test-runs'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Frontera Studio's server: the static app, the API, and the runtime's own
|
|
15
|
+
* status — one origin, served by the supervisor, as `mastra dev` serves Studio
|
|
16
|
+
* beside its API.
|
|
17
|
+
*
|
|
18
|
+
* Three rules shape it:
|
|
19
|
+
* - It binds to 127.0.0.1 only, and every request needs the session cookie
|
|
20
|
+
* that the printed `?token=` link sets. "No auth configured means public" is
|
|
21
|
+
* the pattern we are deliberately not copying.
|
|
22
|
+
* - The browser never sees a credential. `/api/*` is proxied to the local
|
|
23
|
+
* service with the seeded owner's session added here, so the API explorer
|
|
24
|
+
* and every Studio view call the real API exactly as the platform does.
|
|
25
|
+
* - The runtime's own data (`/__local/*`) is read from the supervisor's state
|
|
26
|
+
* file and log files, never from the service, so Studio still explains what
|
|
27
|
+
* is wrong when the service is the thing that is down.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const COOKIE = 'frontera_studio'
|
|
31
|
+
|
|
32
|
+
const MIME: Record<string, string> = {
|
|
33
|
+
'.html': 'text/html; charset=utf-8',
|
|
34
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
35
|
+
'.css': 'text/css; charset=utf-8',
|
|
36
|
+
'.json': 'application/json; charset=utf-8',
|
|
37
|
+
'.svg': 'image/svg+xml',
|
|
38
|
+
'.png': 'image/png',
|
|
39
|
+
'.woff2': 'font/woff2',
|
|
40
|
+
'.woff': 'font/woff',
|
|
41
|
+
'.ico': 'image/x-icon',
|
|
42
|
+
'.map': 'application/json; charset=utf-8',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StudioServerOptions {
|
|
46
|
+
project: LocalProject
|
|
47
|
+
port: number
|
|
48
|
+
serviceUrl: string
|
|
49
|
+
/** Where the built Studio app is (`index.html` + assets); asked per request, so a rebuild needs no restart. */
|
|
50
|
+
assetsDir: () => string | null
|
|
51
|
+
/** The session secret printed in the Studio link. */
|
|
52
|
+
sessionToken: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function newSessionToken(): string {
|
|
56
|
+
return randomBytes(24).toString('base64url')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sameToken(a: string, b: string): boolean {
|
|
60
|
+
const ab = Buffer.from(a)
|
|
61
|
+
const bb = Buffer.from(b)
|
|
62
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cookieToken(req: Request): string | null {
|
|
66
|
+
const header = req.headers.get('cookie') ?? ''
|
|
67
|
+
for (const part of header.split(';')) {
|
|
68
|
+
const [k, ...v] = part.trim().split('=')
|
|
69
|
+
if (k === COOKIE) return v.join('=')
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LOG_COMPONENTS: ComponentName[] = ['postgres', 'storage', 'service', 'jobs', 'runner', 'studio']
|
|
75
|
+
|
|
76
|
+
function tail(path: string, lines: number): string[] {
|
|
77
|
+
if (!existsSync(path)) return []
|
|
78
|
+
const size = statSync(path).size
|
|
79
|
+
// Read at most the last 512 KiB: a long-running log must not be loaded whole.
|
|
80
|
+
const start = Math.max(0, size - 512 * 1024)
|
|
81
|
+
const buf = readFileSync(path).subarray(start)
|
|
82
|
+
return buf.toString('utf8').split('\n').filter(Boolean).slice(-lines)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function startStudioServer(opts: StudioServerOptions) {
|
|
86
|
+
const { project } = opts
|
|
87
|
+
const testRuns = createTestRuns(project, opts.serviceUrl)
|
|
88
|
+
|
|
89
|
+
const json = (data: unknown, status = 200) =>
|
|
90
|
+
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json; charset=utf-8' } })
|
|
91
|
+
|
|
92
|
+
const localRoute = (url: URL): Response => {
|
|
93
|
+
if (url.pathname === '/__local/state') {
|
|
94
|
+
const state = readState(project)
|
|
95
|
+
if (!state) return json({ error: 'no local runtime state' }, 404)
|
|
96
|
+
const { credentials, ...rest } = state
|
|
97
|
+
return json({
|
|
98
|
+
...rest,
|
|
99
|
+
serviceUrl: opts.serviceUrl,
|
|
100
|
+
lastApplied: readApplied(project),
|
|
101
|
+
credentials: credentials
|
|
102
|
+
? {
|
|
103
|
+
email: credentials.email,
|
|
104
|
+
organizationId: credentials.organizationId,
|
|
105
|
+
workspaceId: credentials.workspaceId,
|
|
106
|
+
workspaceSlug: credentials.workspaceSlug,
|
|
107
|
+
modelProviders: credentials.modelProviders ?? [],
|
|
108
|
+
}
|
|
109
|
+
: null,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
// Each file `local dev` applies, and whether the runtime has its current content.
|
|
113
|
+
if (url.pathname === '/__local/solution') {
|
|
114
|
+
return json({ items: solutionStatus(project, readState(project)?.credentials?.organizationId ?? null) })
|
|
115
|
+
}
|
|
116
|
+
// The solution's saved cases (`agents/<slug>/evals/*.yaml`), read fresh each time.
|
|
117
|
+
if (url.pathname === '/__local/test-cases') {
|
|
118
|
+
try {
|
|
119
|
+
return json({ files: findCaseFiles(project.root) })
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return json({ error: `a case file could not be read: ${(err as Error).message}` }, 422)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (url.pathname === '/__local/test-runs') return json({ runs: testRuns.list() })
|
|
125
|
+
const runMatch = /^\/__local\/test-runs\/([0-9a-f-]+)$/.exec(url.pathname)
|
|
126
|
+
if (runMatch) {
|
|
127
|
+
const run = testRuns.get(runMatch[1]!)
|
|
128
|
+
return run ? json(run) : json({ error: 'no such test run' }, 404)
|
|
129
|
+
}
|
|
130
|
+
const logMatch = /^\/__local\/logs\/([a-z]+)$/.exec(url.pathname)
|
|
131
|
+
if (logMatch) {
|
|
132
|
+
const component = logMatch[1] as ComponentName
|
|
133
|
+
if (!LOG_COMPONENTS.includes(component)) return json({ error: 'unknown component' }, 404)
|
|
134
|
+
const lines = Math.min(Number(url.searchParams.get('lines') ?? 300) || 300, 2000)
|
|
135
|
+
return json({ component, lines: tail(logPath(project, component), lines) })
|
|
136
|
+
}
|
|
137
|
+
return json({ error: 'not found' }, 404)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Save a conversation into the solution as a regression case (`agents/<slug>/evals/regression.yaml`). */
|
|
141
|
+
const saveTestCase = async (req: Request): Promise<Response> => {
|
|
142
|
+
const state = readState(project)
|
|
143
|
+
const creds = state?.credentials
|
|
144
|
+
if (!creds?.session) return json({ error: 'the runtime is not seeded yet' }, 409)
|
|
145
|
+
const body = (await req.json().catch(() => null)) as { agentId?: string; conversationId?: string } | null
|
|
146
|
+
if (!body?.agentId || !body.conversationId) return json({ error: 'agentId and conversationId are required' }, 400)
|
|
147
|
+
const headers = { authorization: `Bearer ${creds.session}`, 'x-org-id': creds.organizationId, 'x-workspace-id': creds.workspaceId }
|
|
148
|
+
const get = async (path: string) => {
|
|
149
|
+
const res = await fetch(new URL(path, opts.serviceUrl), { headers })
|
|
150
|
+
if (!res.ok) throw new Error(`${path} answered ${res.status}`)
|
|
151
|
+
const parsed = (await res.json()) as { data?: unknown }
|
|
152
|
+
return parsed.data
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const agent = (await get(`/v1/config/agents/${encodeURIComponent(body.agentId)}`)) as { agentId?: string }
|
|
156
|
+
const messages = (await get(`/v1/sessions/${encodeURIComponent(body.conversationId)}/messages`)) as unknown
|
|
157
|
+
const list = Array.isArray(messages) ? messages : ((messages as { messages?: unknown[] })?.messages ?? [])
|
|
158
|
+
const yaml = renderTestCase({ conversationId: body.conversationId, messages: list as never[], savedAt: new Date().toISOString() })
|
|
159
|
+
const path = appendTestCase(project.root, agent.agentId ?? body.agentId, yaml)
|
|
160
|
+
return json({ path })
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return json({ error: (err as Error).message }, 502)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The service's API reference on Studio's origin. The OpenAPI document's
|
|
168
|
+
* server is rewritten to `/api`, so the reference's "Send request" runs
|
|
169
|
+
* through the proxy as the local owner instead of asking for a key.
|
|
170
|
+
*/
|
|
171
|
+
const apiReference = async (url: URL): Promise<Response> => {
|
|
172
|
+
const target = new URL(url.pathname + url.search, opts.serviceUrl)
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch(target)
|
|
175
|
+
if (url.pathname === '/docs/json' && res.ok) {
|
|
176
|
+
const spec = (await res.json()) as Record<string, unknown>
|
|
177
|
+
spec.servers = [{ url: `${url.origin}/api`, description: 'Local runtime, signed in as the local owner' }]
|
|
178
|
+
return json(spec)
|
|
179
|
+
}
|
|
180
|
+
const out = new Headers(res.headers)
|
|
181
|
+
out.delete('content-encoding')
|
|
182
|
+
out.delete('content-length')
|
|
183
|
+
return new Response(res.body, { status: res.status, headers: out })
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return json({ error: 'the local service did not answer', detail: (err as Error).message }, 502)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const proxy = async (req: Request, url: URL): Promise<Response> => {
|
|
190
|
+
const state = readState(project)
|
|
191
|
+
// The shared packages give Better Auth's client `<origin>/api` as its base,
|
|
192
|
+
// which it reads as the auth base path; on a deployment those endpoints
|
|
193
|
+
// live under `/api/auth/`. Every other service route is `/v1/…`, `/api/…`,
|
|
194
|
+
// `/health` or `/docs`, so anything else is an auth endpoint.
|
|
195
|
+
let path = url.pathname.replace(/^\/api/, '') || '/'
|
|
196
|
+
if (!/^\/(v1|api|health|docs)(\/|$)/.test(path)) path = `/api/auth${path}`
|
|
197
|
+
const target = new URL(path + url.search, opts.serviceUrl)
|
|
198
|
+
const headers = new Headers(req.headers)
|
|
199
|
+
headers.delete('cookie')
|
|
200
|
+
headers.delete('host')
|
|
201
|
+
headers.delete('origin')
|
|
202
|
+
headers.delete('referer')
|
|
203
|
+
// Studio acts as the seeded owner: a session reaches every route a person
|
|
204
|
+
// can use, including the session-only ones an API key is refused on.
|
|
205
|
+
const bearer = state?.credentials?.session ?? state?.credentials?.workspaceKey
|
|
206
|
+
if (bearer && !headers.has('authorization')) headers.set('authorization', `Bearer ${bearer}`)
|
|
207
|
+
if (state?.credentials?.organizationId && !headers.has('x-org-id')) headers.set('x-org-id', state.credentials.organizationId)
|
|
208
|
+
if (state?.credentials?.workspaceId && !headers.has('x-workspace-id')) {
|
|
209
|
+
headers.set('x-workspace-id', state.credentials.workspaceId)
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const res = await fetch(target, {
|
|
213
|
+
method: req.method,
|
|
214
|
+
headers,
|
|
215
|
+
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req.body,
|
|
216
|
+
redirect: 'manual',
|
|
217
|
+
duplex: 'half',
|
|
218
|
+
})
|
|
219
|
+
const out = new Headers(res.headers)
|
|
220
|
+
out.delete('content-encoding')
|
|
221
|
+
out.delete('content-length')
|
|
222
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: out })
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return json({ error: 'the local service did not answer', detail: (err as Error).message }, 502)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const asset = (url: URL): Response => {
|
|
229
|
+
const assetsDir = opts.assetsDir()
|
|
230
|
+
if (!assetsDir) {
|
|
231
|
+
return new Response(
|
|
232
|
+
'<!doctype html><title>Frontera Studio</title><body style="font-family:system-ui;padding:40px">' +
|
|
233
|
+
'<h1>Studio is not built</h1><p>Run <code>bun run --cwd packages/studio build</code> in the platform repository, then reload.</p>',
|
|
234
|
+
{ status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
const rel = normalize(decodeURIComponent(url.pathname)).replace(/^(\.\.[/\\])+/, '')
|
|
238
|
+
let file = join(assetsDir, rel)
|
|
239
|
+
if (!file.startsWith(assetsDir) || !existsSync(file) || statSync(file).isDirectory()) {
|
|
240
|
+
file = join(assetsDir, 'index.html')
|
|
241
|
+
}
|
|
242
|
+
const type = MIME[extname(file)] ?? 'application/octet-stream'
|
|
243
|
+
const cache = file.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable'
|
|
244
|
+
return new Response(Bun.file(file), { headers: { 'content-type': type, 'cache-control': cache } })
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return Bun.serve({
|
|
248
|
+
hostname: '127.0.0.1',
|
|
249
|
+
port: opts.port,
|
|
250
|
+
idleTimeout: 120,
|
|
251
|
+
async fetch(req) {
|
|
252
|
+
const url = new URL(req.url)
|
|
253
|
+
|
|
254
|
+
const offered = url.searchParams.get('token')
|
|
255
|
+
if (offered) {
|
|
256
|
+
if (!sameToken(offered, opts.sessionToken)) return new Response('Invalid Studio link', { status: 401 })
|
|
257
|
+
url.searchParams.delete('token')
|
|
258
|
+
return new Response(null, {
|
|
259
|
+
status: 302,
|
|
260
|
+
headers: {
|
|
261
|
+
location: url.pathname + (url.search || ''),
|
|
262
|
+
'set-cookie': `${COOKIE}=${opts.sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (url.pathname === '/__local/health') return json({ ok: true })
|
|
268
|
+
|
|
269
|
+
const cookie = cookieToken(req)
|
|
270
|
+
if (!cookie || !sameToken(cookie, opts.sessionToken)) {
|
|
271
|
+
return new Response(
|
|
272
|
+
'<!doctype html><title>Frontera Studio</title><body style="font-family:system-ui;padding:40px">' +
|
|
273
|
+
'<h1>Open Studio from the CLI</h1><p>Run <code>frontera local ui</code>. It prints a link that signs this browser in.</p>',
|
|
274
|
+
{ status: 401, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (url.pathname === '/docs' || url.pathname.startsWith('/docs/')) return apiReference(url)
|
|
279
|
+
if (url.pathname === '/__local/test-cases' && req.method === 'POST') return saveTestCase(req)
|
|
280
|
+
const editMatch = /^\/__local\/agents\/([^/]+)\/file$/.exec(url.pathname)
|
|
281
|
+
if (editMatch && req.method === 'PUT') {
|
|
282
|
+
const organizationId = readState(project)?.credentials?.organizationId
|
|
283
|
+
if (!organizationId) return json({ error: 'the runtime is not seeded yet' }, 409)
|
|
284
|
+
const edit = (await req.json().catch(() => null)) as AgentFileEdit | null
|
|
285
|
+
if (!edit || typeof edit !== 'object') return json({ error: 'the edit must be a JSON object' }, 400)
|
|
286
|
+
try {
|
|
287
|
+
return json(await saveAgentEdit(project, organizationId, decodeURIComponent(editMatch[1]!), edit))
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err instanceof AgentFileError) return json({ error: err.message }, err.status)
|
|
290
|
+
return json({ error: (err as Error).message }, 500)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (url.pathname === '/__local/test-runs' && req.method === 'POST') {
|
|
294
|
+
const body = (await req.json().catch(() => ({}))) as { agent?: string; live?: boolean }
|
|
295
|
+
const started = testRuns.start({ agent: typeof body.agent === 'string' ? body.agent : undefined, live: body.live === true })
|
|
296
|
+
return 'run' in started ? json(started.run, 202) : json({ error: started.error }, started.status)
|
|
297
|
+
}
|
|
298
|
+
if (url.pathname.startsWith('/__local/')) return localRoute(url)
|
|
299
|
+
// `/api/…` is the service; `/api` alone is Studio's API page.
|
|
300
|
+
if (url.pathname.startsWith('/api/')) return proxy(req, url)
|
|
301
|
+
return asset(url)
|
|
302
|
+
},
|
|
303
|
+
})
|
|
304
|
+
}
|