Files
onyx/tools/ods/internal/devstack/launch.go
roshan a8033533d1 refactor(ods): unify launchers and protect shared infrastructure (#16246)
## Description

Make ODS reuse shared development infrastructure by default, with
`--isolated` for separate datasets.

- Use the same service specifications and launchers for `stack`, `web
dev`, and `backend` commands. Remove the duplicate `stack
--frontend-only` option.
- Enforce project ownership across checkouts and resolve database
commands to the actual managed database.
- Stop services before pending migrations, preserve them when already at
head, and reject manual migrations beneath managed services.
- Harden startup cancellation, shutdown grace, descendant cleanup,
terminal input, and frontend API validation. Preserve Windows foreground
commands.
- Consolidate environment helpers, protect symlinked environment files,
propagate restore errors, and update help and documentation.

This PR contains only ODS and its development-stack skill. It does not
include the plugins implementation.

## How Has This Been Tested?

- Targeted command and regression tests using fake tools and disposable
Git repositories.
- Race tests for runtime targeting, ownership, migration ordering,
process cleanup, and terminal input.
- `go vet ./...`, pre-commit checks, native builds, and Windows
cross-builds.
- The full Go suite was run. Its remaining failures are the existing
macOS devcontainer SSH-mount fixture expectations in
`TestDevcontainerCommands` and `TestRunDevRebuild`.

No real database migrations, restores, or development-stack restarts
ran. Native Windows execution remains untested.

## Additional Options

- [ ] [Optional] Please cherry-pick this PR to the latest release
version.
- [x] [Optional] Override Linear Check

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
ODS now shares development infrastructure across worktrees by default,
with `--isolated` for separate datasets. `stack`, `web dev`, and
`backend` use the same service specs and launchers, and the duplicate
`stack --frontend-only` option is removed.

- Direct launches run the same dependency-prepare step as stack startup,
so `backend` and `web dev` work with fresh or stale Python environments.
- A stopped preview discards its former project state before retargeting
to another API owner.
- Enforces project ownership across checkouts and resolves database
commands to the managed database. New tests reject conflicting targets
before any mutation.
- Stops services before pending migrations, preserves them when already
at head, and rejects manual migrations beneath managed services.
Migration probe failures fail closed.
- Hardens startup cancellation, shutdown grace, and descendant cleanup;
ODS keeps the terminal foreground group so Ctrl-C still honors shutdown
timeouts, and tmux pane identifiers no longer change service identity.
- Database passwords reach tools through the child environment only,
never in process arguments; Windows foreground commands are preserved.
- Consolidates environment helpers, protects symlinked env files,
propagates restore errors, and refreshes help and documentation. Test
commits exercise the new migration-target and preview-launch paths.

<sup>Written for commit 14500cb051.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/onyx-dot-app/onyx/pull/16246?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="View guided diff"
src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a>
<a
href="https://www.cubic.dev/action/auto-fix/pr/onyx-dot-app/onyx/16246?returnTo=https%3A%2F%2Fgithub.com%2Fonyx-dot-app%2Fonyx%2Fpull%2F16246&source=description"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"><img
alt="Turn on auto-fix"
src="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added the global `--isolated` option for separate development stacks;
shared infrastructure is the default, with clearer project ownership and
status information.
* Added `ods web dev` previews that can use a healthy managed API on
macOS and Linux, plus foreground startup on Windows.
* Expanded command guidance for development workflows, database
targeting, and dump formats.
* **Bug Fixes**
* Pending migrations now stop managed application services first;
healthy services remain running when no migrations are needed.
* Database operations verify project ownership and target selection
before making changes. Restore errors now fail the command, SQL restores
stop on errors, and temporary files are cleaned up after failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-10-19 20:07:02 +00:00

347 lines
9.8 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 := ResolveService(root, launch, Slugify(c.Name), ports, env)
if err != nil {
return nil, err
}
specs = append(specs, sp)
}
return specs, nil
}
// ResolveService builds the same service specification for stack and direct commands.
func ResolveService(root string, launch *Launch, slug string, ports map[string]int, env Env) (Spec, error) {
for _, configuration := range launch.Configurations {
if Slugify(configuration.Name) == slug {
return Translate(root, configuration, ports, env)
}
}
return Spec{}, fmt.Errorf("launch.json has no service %q", slug)
}
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
}