mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-23 11:53:08 -05:00
## 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 -->
347 lines
9.8 KiB
Go
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
|
|
}
|