mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-22 11:22:49 -05:00
337 lines
9.4 KiB
Go
337 lines
9.4 KiB
Go
package devstack
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
)
|
|
|
|
// DefaultCompound is the launch.json compound that lists every service.
|
|
const DefaultCompound = "Run All Onyx Services"
|
|
|
|
// Profiles name the subsets of the compound that a stack runs.
|
|
const (
|
|
ProfileMinimal = "minimal"
|
|
ProfileDefault = "default"
|
|
ProfileFull = "full"
|
|
)
|
|
|
|
// Profiles lists the valid profile names.
|
|
var Profiles = []string{ProfileMinimal, ProfileDefault, ProfileFull}
|
|
|
|
// profileServices lists the compound entries a profile runs. Full runs the
|
|
// whole compound. "Model Server" is added only with LocalModelServer.
|
|
var profileServices = map[string][]string{
|
|
ProfileMinimal: {"Web Server", "API Server"},
|
|
ProfileDefault: {
|
|
"Web Server", "API Server",
|
|
"Celery primary", "Celery light", "Celery beat",
|
|
"Celery docfetching", "Celery docprocessing", "Celery user_file_processing",
|
|
},
|
|
}
|
|
|
|
// slackMetricsPort is the fixed port the Slack bot's metrics server binds.
|
|
const slackMetricsPort = 8000
|
|
|
|
// LaunchConfig is one entry of launch.json's "configurations".
|
|
type LaunchConfig struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Request string `json:"request"`
|
|
Module string `json:"module"`
|
|
Program string `json:"program"`
|
|
Args []string `json:"args"`
|
|
Cwd string `json:"cwd"`
|
|
EnvFile string `json:"envFile"`
|
|
Env map[string]string `json:"env"`
|
|
RuntimeExecutable string `json:"runtimeExecutable"`
|
|
RuntimeArgs []string `json:"runtimeArgs"`
|
|
}
|
|
|
|
// Launch is the parsed launch.json.
|
|
type Launch struct {
|
|
Compounds []struct {
|
|
Name string `json:"name"`
|
|
Configurations []string `json:"configurations"`
|
|
} `json:"compounds"`
|
|
Configurations []LaunchConfig `json:"configurations"`
|
|
}
|
|
|
|
// LoadLaunch parses .vscode/launch.json, which may hold comments and
|
|
// trailing commas.
|
|
func LoadLaunch(path string) (*Launch, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var launch Launch
|
|
if err := json.Unmarshal(StripJSONC(data), &launch); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", path, err)
|
|
}
|
|
return &launch, nil
|
|
}
|
|
|
|
// StripJSONC removes // and /* */ comments and trailing commas outside of
|
|
// strings, so the result parses as JSON.
|
|
func StripJSONC(data []byte) []byte {
|
|
out := make([]byte, 0, len(data))
|
|
var inString, escaped bool
|
|
for i := 0; i < len(data); i++ {
|
|
c := data[i]
|
|
if inString {
|
|
out = append(out, c)
|
|
switch {
|
|
case escaped:
|
|
escaped = false
|
|
case c == '\\':
|
|
escaped = true
|
|
case c == '"':
|
|
inString = false
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case c == '"':
|
|
inString = true
|
|
out = append(out, c)
|
|
case c == '/' && i+1 < len(data) && data[i+1] == '/':
|
|
for i < len(data) && data[i] != '\n' {
|
|
i++
|
|
}
|
|
if i < len(data) {
|
|
out = append(out, '\n')
|
|
}
|
|
case c == '/' && i+1 < len(data) && data[i+1] == '*':
|
|
i += 2
|
|
for i+1 < len(data) && (data[i] != '*' || data[i+1] != '/') {
|
|
i++
|
|
}
|
|
i++
|
|
case c == ',':
|
|
// A comma followed only by whitespace and a closer is dropped.
|
|
j := i + 1
|
|
for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\n' || data[j] == '\r') {
|
|
j++
|
|
}
|
|
if j < len(data) && (data[j] == '}' || data[j] == ']') {
|
|
continue
|
|
}
|
|
out = append(out, c)
|
|
default:
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Compound returns the configurations a compound lists, in order.
|
|
func (l *Launch) Compound(name string) ([]LaunchConfig, error) {
|
|
byName := map[string]LaunchConfig{}
|
|
for _, c := range l.Configurations {
|
|
byName[c.Name] = c
|
|
}
|
|
for _, comp := range l.Compounds {
|
|
if comp.Name != name {
|
|
continue
|
|
}
|
|
var configs []LaunchConfig
|
|
for _, n := range comp.Configurations {
|
|
c, ok := byName[n]
|
|
if !ok {
|
|
return nil, fmt.Errorf("compound %q names missing configuration %q", name, n)
|
|
}
|
|
configs = append(configs, c)
|
|
}
|
|
return configs, nil
|
|
}
|
|
return nil, fmt.Errorf("launch.json has no compound %q", name)
|
|
}
|
|
|
|
// LaunchPlan selects the compound entries for the profile and translates
|
|
// them into specs. ports holds the leased app ports (web, api, mcp, model).
|
|
func LaunchPlan(root string, launch *Launch, ports map[string]int, env Env, opts Options) ([]Spec, error) {
|
|
configs, err := launch.Compound(DefaultCompound)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allowed, limited := profileServices[opts.Profile]
|
|
if !limited && opts.Profile != ProfileFull {
|
|
return nil, fmt.Errorf("unknown profile %q (valid: %s)", opts.Profile, strings.Join(Profiles, ", "))
|
|
}
|
|
allowedSet := map[string]bool{}
|
|
for _, name := range allowed {
|
|
allowedSet[name] = true
|
|
}
|
|
var specs []Spec
|
|
for _, c := range configs {
|
|
if c.Name == "Model Server" {
|
|
if !opts.LocalModelServer {
|
|
continue
|
|
}
|
|
} else if limited && !allowedSet[c.Name] {
|
|
continue
|
|
}
|
|
sp, err := Translate(root, c, ports, env)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
specs = append(specs, sp)
|
|
}
|
|
return specs, nil
|
|
}
|
|
|
|
var workspaceVar = regexp.MustCompile(`\$\{workspace(?:Folder|Root)\}`)
|
|
|
|
func expandWorkspace(value, root string) (string, error) {
|
|
expanded := workspaceVar.ReplaceAllString(value, root)
|
|
if strings.Contains(expanded, "${") {
|
|
return "", fmt.Errorf("unsupported launch.json variable in %q", value)
|
|
}
|
|
return expanded, nil
|
|
}
|
|
|
|
// Translate turns one launch configuration into a runnable spec. The
|
|
// environment is the shell, then the configuration's envFile, then its env,
|
|
// then the stack overrides. A --port argument of the api, mcp and model
|
|
// servers is replaced by the leased port.
|
|
func Translate(root string, c LaunchConfig, ports map[string]int, env Env) (Spec, error) {
|
|
if c.Request != "launch" {
|
|
return Spec{}, fmt.Errorf("%s: request %q is not launch", c.Name, c.Request)
|
|
}
|
|
slug := Slugify(c.Name)
|
|
var fileEnv []string
|
|
if c.EnvFile != "" {
|
|
path, err := expandWorkspace(c.EnvFile, root)
|
|
if err != nil {
|
|
return Spec{}, err
|
|
}
|
|
if fileEnv, err = LoadEnvFile(path); err != nil {
|
|
return Spec{}, err
|
|
}
|
|
}
|
|
launchEnv := map[string]string{}
|
|
for k, v := range c.Env {
|
|
expanded, err := expandWorkspace(v, root)
|
|
if err != nil {
|
|
return Spec{}, err
|
|
}
|
|
launchEnv[k] = expanded
|
|
}
|
|
|
|
dir := root
|
|
if c.Cwd != "" {
|
|
var err error
|
|
if dir, err = expandWorkspace(c.Cwd, root); err != nil {
|
|
return Spec{}, err
|
|
}
|
|
}
|
|
|
|
sp := Spec{Name: c.Name, Slug: slug, Dir: dir, Env: env.With(fileEnv, mapToEnv(launchEnv))}
|
|
if port := ports[slug+"-metrics"]; port > 0 {
|
|
sp.Env = MergeEnv(sp.Env, []string{"PROMETHEUS_METRICS_PORT=" + fmt.Sprint(port)})
|
|
}
|
|
switch slug {
|
|
case "web":
|
|
sp.Port = ports["web"]
|
|
sp.Health = fmt.Sprintf("http://localhost:%d/api/auth/type", sp.Port)
|
|
case "api":
|
|
sp.Port = ports["api"]
|
|
sp.Health = fmt.Sprintf("http://localhost:%d/health", sp.Port)
|
|
case "mcp":
|
|
sp.Port = ports["mcp"]
|
|
case "model":
|
|
sp.Port = ports["model"]
|
|
sp.Health = fmt.Sprintf("http://localhost:%d/api/health", sp.Port)
|
|
case "slack-bot":
|
|
sp.Optional = true
|
|
sp.FixedPort = slackMetricsPort
|
|
}
|
|
|
|
args := make([]string, 0, len(c.Args))
|
|
for i := 0; i < len(c.Args); i++ {
|
|
a, err := expandWorkspace(c.Args[i], root)
|
|
if err != nil {
|
|
return Spec{}, err
|
|
}
|
|
if a == "--port" && sp.Port > 0 && i+1 < len(c.Args) {
|
|
args = append(args, a, fmt.Sprint(sp.Port))
|
|
i++
|
|
continue
|
|
}
|
|
args = append(args, a)
|
|
}
|
|
|
|
switch c.Type {
|
|
case "debugpy":
|
|
python := PythonExecutable(root)
|
|
switch {
|
|
case c.Module != "":
|
|
sp.Command = append([]string{python, "-m", c.Module}, args...)
|
|
case c.Program != "":
|
|
program, err := expandWorkspace(c.Program, root)
|
|
if err != nil {
|
|
return Spec{}, err
|
|
}
|
|
sp.Command = append([]string{python, program}, args...)
|
|
default:
|
|
return Spec{}, fmt.Errorf("%s: no module or program", c.Name)
|
|
}
|
|
case "node":
|
|
if c.RuntimeExecutable == "" {
|
|
return Spec{}, fmt.Errorf("%s: no runtimeExecutable", c.Name)
|
|
}
|
|
sp.Command = []string{c.RuntimeExecutable}
|
|
for _, a := range c.RuntimeArgs {
|
|
expanded, err := expandWorkspace(a, root)
|
|
if err != nil {
|
|
return Spec{}, err
|
|
}
|
|
sp.Command = append(sp.Command, expanded)
|
|
}
|
|
sp.Command = append(sp.Command, args...)
|
|
default:
|
|
return Spec{}, fmt.Errorf("%s: unsupported launch type %q", c.Name, c.Type)
|
|
}
|
|
return sp, nil
|
|
}
|
|
|
|
// PythonExecutable is the interpreter of the worktree's uv-managed venv.
|
|
func PythonExecutable(root string) string {
|
|
if runtime.GOOS == "windows" {
|
|
return filepath.Join(root, ".venv", "Scripts", "python.exe")
|
|
}
|
|
return filepath.Join(root, ".venv", "bin", "python")
|
|
}
|
|
|
|
// AppPortRequests lists the application ports a profile needs.
|
|
func AppPortRequests(opts Options) []PortRequest {
|
|
reqs := []PortRequest{{Name: "web", Preferred: 3000}, {Name: "api", Preferred: 8080}}
|
|
if opts.Profile == ProfileFull {
|
|
reqs = append(reqs, PortRequest{Name: "mcp", Preferred: 8090})
|
|
}
|
|
if opts.LocalModelServer {
|
|
reqs = append(reqs, PortRequest{Name: "model", Preferred: 9000})
|
|
}
|
|
for _, worker := range []struct {
|
|
name string
|
|
port int
|
|
full bool
|
|
}{
|
|
{"Celery primary", 9097, false},
|
|
{"Celery light", 9095, false},
|
|
{"Celery docfetching", 9092, false},
|
|
{"Celery docprocessing", 9093, false},
|
|
{"Celery heavy", 9094, true},
|
|
{"Celery scheduled_tasks", 9098, true},
|
|
} {
|
|
if opts.Profile == ProfileFull || opts.Profile == ProfileDefault && !worker.full {
|
|
reqs = append(reqs, PortRequest{Name: Slugify(worker.name) + "-metrics", Preferred: worker.port})
|
|
}
|
|
}
|
|
return reqs
|
|
}
|