Split: backend runtime/session updates + frontend shell refactor (#222)
* feat(frontend): rebuild Electron desktop UI as a React + Vite renderer
Replaces the skeleton Electron frontend with a full React 19 + TypeScript
renderer (Vite, electron-forge, contextBridge preload), plus the backend
additions it needs.
Renderer:
- TanStack Query + EventTransport (CDC SSE on /api/v1/events)
- TanStack Router file-system routing (hash history for the file:// origin)
- Tailwind + shadcn/ui, react-resizable-panels, Zustand UI state
- @xterm/xterm per-session PTY over /mux WebSocket + WebGL addon
- openapi-typescript + openapi-fetch types off openapi.yaml
- electron-forge packaging + update-electron-app auto-updater
- Vitest + RTL · Playwright
Backend:
- cors.go — allowlist-only CORS, handles Private Network Access preflight
for app:// renderer -> loopback daemon
- session.TerminalHandleID exposed in domain + OpenAPI spec
- project.Path added to OpenAPI spec, service, store, and tests
DESIGN.md documents the emdash-matched dark UI (tokens, blue accent, status
glyph spec, orchestrator-led layout).
Co-authored-by: Ashish Huddar <ashish.hudar@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: b1e334c1e54a
* feat(terminal): port yyork's terminal rendering architecture
XtermTerminal becomes a self-contained, dependency-free renderer component
(yyork's xterm-terminal.tsx pattern):
- Nothing writes into the buffer at mount — status/empty-state is DOM chrome.
Fixes the startup crash (xterm Viewport.syncScrollArea reading renderer
dimensions on a zero-sized panel).
- Multi-trigger fit (rAF + 50/250ms settle + fonts.ready + ResizeObserver):
FitAddon must re-measure after monospace font metrics settle or it
over-counts columns. xterm only fires onResize on real grid changes, so
repeated fits don't spam the PTY.
- Unicode11 width (agent CLIs print emoji/wide glyphs), WCAG-AA minimum
contrast, WebGL→canvas renderer fallback, full ANSI-16 palette per DESIGN.md.
TerminalPane keeps ONE terminal instance across session switches — the
attachment effect re-points the mux and RIS-resets the screen instead of
remounting (a keyed remount drops the warm GPU surface mid-switch).
useTerminalSession: resize debounced 100ms trailing (one SIGWINCH per pane
drag, not dozens); the "Attaching…" writeln is gone (banner chrome covers it).
Test infra: vitest never loaded vite.renderer.config.ts after the forge split
(it only auto-discovers vite.config.*) so the whole suite ran without jsdom.
Point the test script at the config and type it via vitest/config. Fix
pre-existing type errors (notarytool field, maker-zip config, named
updateElectronApp import). 97/97 passing, typecheck clean.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: eb0f94fcf914
* fix(spawn): don't send base branch; surface real API errors
Two bugs found spawning a worker from the modal:
1. "Based on: main" sent branch:"main" in the POST, but git can't add a
second worktree on a branch already checked out (main lives in the repo
root) — the daemon returns 409 BRANCH_CHECKED_OUT_ELSEWHERE. The base
branch must be OMITTED so the daemon mints a fresh ao/<sessionId> off the
project default. Only a non-default branch (resume an existing session
branch) is sent through.
2. The daemon's error body is {error,code,message,requestId}; App.createTask
did String(error) on it → the modal showed "[object Object]". Add
apiErrorMessage() to unpack message/error from the structured body, with
Error/string fallbacks.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: d3e718d393cd
* feat(terminal): Nerd Font glyph support via --font-mono (yyork pattern)
The terminal now resolves its fontFamily from the --font-mono CSS token
(styles.css @theme), which leads with the Nerd Font family stack
(JetBrainsMono Nerd Font Mono first). Agent TUIs get powerline separators
and file-type icons; box-drawing stays renderer-rasterized.
Mirrors yyork exactly: no font is bundled — the stack names system-installed
Nerd Fonts and the browser picks the first present, falling back to plain
monospace (no icon glyphs) when none are installed.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 28120fa527a7
* chore(frontend): set up shadcn/ui foundation
Prep for the route-parity port: build new screens from shadcn primitives.
- components.json: Tailwind v4, css=styles.css, cssVariables, lucide, "@/" aliases
- "@/" -> src/renderer alias in tsconfig (paths) + vite (resolve.alias)
- fill the shadcn token gaps in @theme (card-foreground, input, destructive,
destructive-foreground) mapped to existing emdash tokens so `shadcn add`
components render on-brand without touching the design system
- add Card primitive (first use: Phase 1 board)
Did NOT run `shadcn init` (it would overwrite styles.css and wipe the emdash
tokens); the @theme already maps shadcn semantic names onto emdash raw tokens.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 9b9c8391e52c
* refactor(renderer): persistent _shell layout + per-route pages (projects vocab)
Phase 0 of the route-parity port. Replaces the single state-driven <App> with
real TanStack Router pages behind a persistent _shell layout, the foundation
every ported screen builds on.
- _shell.tsx: pathless layout owning the Sidebar + shared state (workspace
query, daemon status, spawn modal, create project/task, theme, shortcuts);
child routes render into <Outlet>. The daemon-status effect runs once here.
- Router owns selection: ui-store sheds view/selectedSession/selectedWorkspace
(now route params); keeps only theme/sidebar/workbenchTab. Sidebar/SideRail
navigate via router and read active state from useParams.
- Routes (projects vocabulary): / -> SessionsBoard (new board home, replacing
the orchestrator-terminal home), /projects/$projectId -> scoped board,
/projects/$projectId/sessions/$sessionId and /sessions/$sessionId ->
SessionView (Topbar + terminal + git rail).
- Terminal persistence: it lives on the session route, so session->session is
a param change (TanStack keeps the route mounted -> mux re-points, no
remount); leaving for the board unmounts it and the server ring replays on
return.
- shell-context.ts hands daemonStatus/openSpawn/create* to route content.
Removed the monolithic App.tsx (+ App.test.tsx, whose create/spawn coverage
moves to route/hook-level tests in Phase 5) and the old workspaces.* routes.
shadcn Card used for the board cards. typecheck clean, 91 tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: d10b22f0d841
* feat(board): attention-zone kanban home
Phase 1: SessionsBoard becomes the real kanban, porting agent-orchestrator's
getAttentionLevel state machine (packages/web/src/lib/types.ts) as a pure
function rebound to reverbcode's SessionStatus.
- attentionZone() buckets a session into urgency-ordered zones — merge (one
click to clear, leftmost) → action (needs-you: needs_input/ci_failed/
changes_requested, the collapsed respond+review) → pending (waiting on
reviewer/CI) → working → done (archive).
- Board renders a horizontal column per non-empty zone; cards navigate into
the session route. shadcn Card for cards. Styled to DESIGN.md (emdash
hairlines, status dots, accent), not agent-orchestrator's tokens.
- 13 zone-mapping unit tests. typecheck clean, 104 tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: d315acacf784
* feat(settings): project settings form (Phase 2)
/projects/$projectId/settings — a settings page on reverbcode's own
ProjectConfig shape (not agent-orchestrator's agent/runtime/tracker/scm,
which the Go daemon doesn't have). Reuses agent-orchestrator's form structure:
read-only identity card + editable config.
- Reads GET /api/v1/projects/{id} (config + identity), saves via
PUT /api/v1/projects/{id}/config. The PUT replaces the whole config, so the
form merges edited fields over what loaded (keeps env/symlinks/postCreate
it doesn't expose).
- Editable: defaultBranch, sessionPrefix, default worker/orchestrator agent,
model override. React Query for load + mutation with inline save state.
- shadcn select + label added; settings gear in the project board header.
typecheck clean, 104 tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 0633ebde603b
* feat(session): PR inspector in the session rail (Phase 2)
Ports agent-orchestrator's SessionInspector onto reverbcode's SessionPRFacts
(GET /api/v1/sessions/{id}/pr -> {prs: SessionPRFacts[]}). Mounts above the
git rail on the session route; renders nothing when the session has no PR.
- Shows PR number + state badge, and CI / mergeability / review facts with
tone derived from the fact string (pass/fail/pending), plus an unresolved
review-comments flag.
- React Query, fetched only when the session has a PR.
Completes Phase 2 (project board reuse + settings + inspector).
typecheck clean, 104 tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: c75dadfd45b8
* feat(prs): pull-requests board (Phase 3)
/prs — a PR board ported from agent-orchestrator's PullRequestsPage. The Go
daemon has no PR-list endpoint, so rows are derived from session PR fields
(every session carries pullRequest), sorted open/draft above merged/closed.
- Per-row Merge (POST /prs/{number}/merge) and Resolve comments
(POST /prs/{number}/resolve-comments) mutations with inline result; clicking
a row opens the session (whose inspector has the full CI/review facts).
- shadcn Table; "Pull requests" + "Review" nav added to the sidebar footer.
- /review + /reviews routes added as placeholders (the reviews board needs a
daemon backend — Phase 4); /reviews redirects to /review.
typecheck clean, 104 tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 3526d847c568
* feat(reviews): code-review API + dashboard (Phase 4)
The long pole: the Go daemon had no reviews surface, so /review needed a
backend. Adds one, mirroring agent-orchestrator's reviews feature.
Backend:
- internal/service/review: in-memory reviews Manager (Run + Finding types,
List/Execute/Send). Execution is not yet wired to a real review agent —
Execute records a pending run so the surface is live; agent-backed findings
+ persistence are a follow-up (documented in the package).
- ReviewsController (GET /reviews, POST /reviews/execute, POST /reviews/{id}/send),
wired through api.go + daemon.go (constructed, not nil — actually serves).
- genspec: reviewOperations() + tag + schemaNames; openapi.yaml + schema.ts
regenerated. apispec parity/drift tests pass, go build + go test green.
Frontend:
- ReviewDashboard reads GET /reviews, lists runs with status + findings, lets
you pick a worker and Run review (execute) and Send a run. Replaces the
placeholder /review route. shadcn Card/Badge/Select.
Verified live: GET /reviews -> 200, POST /reviews/execute -> created run.
typecheck clean, 104 frontend tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: ce16c62dfdb0
* chore(renderer): Phase 5 polish — route prefetch + restored spawn coverage
- Route loader: _shell prefetches the workspace list via
queryClient.ensureQueryData (parent loader runs before children), pairing
with defaultPreload: "intent" so a hovered nav target is warm on click.
workspaceQueryOptions exported so the loader and hook share one cache.
- Restored the spawn coverage dropped with App.test.tsx as a focused
SpawnWorkerModal test: the base-branch-omission regression guard (the 409
fix) + the empty-prompt gate.
Full parity surface green: 106 frontend tests, typecheck clean, backend
build + vet clean, all daemon endpoints 200.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 284ae668ffea
* feat(board): match agent-orchestrator's board verbatim
Per explicit request to mirror agent-orchestrator's app exactly (overriding
DESIGN.md/emdash for this screen). Rebuilds SessionsBoard from its actual
source (Dashboard + AttentionZone + SessionCard + mc-board.css), using its
exact tokens and values:
- 4 equal-width columns (grid 1fr), left->right flow: Working -> Needs you ->
In review -> Ready to merge (SIMPLE_KANBAN_LEVELS), always rendered; "done"
archived to a separate strip, not a column.
- Per-column vertical glow gradient (status-tinted top fading at 130px) +
glow dots + uppercase tinted column titles; #0a0b0d base, #15171b cards.
- Topbar: project crumb + Coding/Reviews tabs + breathing "N working" pill +
bell + blue "New worker" primary. "Board" subhead + subtitle.
- Card: status badge (dot + label) · mono id, 2-line title, mono branch line,
hairline-topped PR footer ("no PR yet").
typecheck clean, 106 tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 3569e49ba7d6
* feat(theme): clone agent-orchestrator's dark palette globally
Per the verbatim-clone directive (supersedes DESIGN.md/emdash). Remaps the
:root tokens to agent-orchestrator's exact values — #0a0b0d base, #15171b
card, #f4f5f7/#9ba1aa/#646a73 text, hairline white-alpha borders, #4d8dff
accent, orange/amber/green/red status — so every screen's base shifts at once.
Adds --color-working (orange) for the working status.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: d70096d7f30d
* feat(renderer): clone agent-orchestrator ProjectSidebar verbatim
Rebuild the sidebar to match agent-orchestrator's ProjectSidebar: #08090b
rail, "Reverb / Code" brand with dimmed separator + collapse button,
uppercase PROJECTS label, project disclosure rows (rotating chevron +
hover-revealed New worker action + session count), nested session rows
with a 6px breathing working-dot and mono session id, and a single
Settings menu footer (Pull requests / Reviews / Search / Project
settings) plus a daemon-health dot.
Adds a shadcn dropdown-menu primitive (radix-ui unified package, matching
the existing select/label convention) for the footer menu.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: c4e1827b6142
* feat(renderer): clone agent-orchestrator session topbar
Restyle the session header to match agent-orchestrator's SessionDetailHeader:
a "Kanban" back-to-board button + hairline divider, a stacked identity
(project / title over a mono branch line with a git-branch icon), and a
StatusBadge --pill (tinted bordered pill with a 6px dot that breathes while
the agent is working). Wire onOpenBoard from SessionView to navigate back to
the project board (or home).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: dcd3e4880af5
* feat(renderer): unify board/review/PR/settings chrome verbatim
Extract the mc-board dashboard header (project crumb · Coding/Reviews tabs ·
"N working" breathing pill · bell · settings · New worker) and the 21px
subhead into a shared DashboardTopbar/DashboardSubhead, then apply it to the
review, PR, and settings screens so every dashboard surface shares one stable
agent-orchestrator top strip. SessionsBoard now consumes the shared chrome
instead of its inline copy; review/PR/settings drop their minimal h-11 headers
for the crumb+tabs+subhead treatment on the #0a0b0d base.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 2801a9c5c0ba
* docs: record agent-orchestrator-verbatim design direction
Per explicit user decision (2026-06-10), the renderer clones the
agent-orchestrator web app verbatim, superseding the older "match emdash"
direction. Add a prominent banner at the top of DESIGN.md (reference files,
live palette, the cloned surfaces, shadcn-primitive guidance), mark the
Aesthetic Direction section as superseded, and retarget CLAUDE.md's QA rule so
future review flags divergence from agent-orchestrator instead of emdash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: fe028f97d5d5
* feat(renderer): clone agent-orchestrator shell and inspector
Finish the agent-orchestrator-style renderer pass with shadcn sidebar chrome, titlebar navigation, resizable session inspector, orchestrator spawn affordances, and matching design tokens.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: format with prettier [skip ci]
* fix: repair UI PR CI drift
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(terminal): per-client zellij attach replaces shared PTY + replay ring
Each WebSocket client that opens a pane now gets its own `zellij attach`
PTY (attachment.go) instead of sharing one PTY whose output was replayed
from a bounded byte ring. Zellij answers every fresh attach with its full
init handshake (alt screen, SGR mouse tracking, bracketed paste) and a
faithful repaint — the ring replay lost exactly that handshake, leaving
late subscribers without mouse reporting (dead wheel scroll). The cost is
one zellij client process per open pane per connection, which the zellij
server is built for (yyork ships the same model).
ring.go and session.go (fan-out, replay buffer) are deleted; manager.go
now tracks per-client attachments with liveness gating, and pty_unix.go
answers every resize frame with an explicit SIGWINCH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(renderer): re-assert settled terminal resize; align docs with per-client attach
After each debounced resize settles, send one follow-up resize frame with
the same grid (RESIZE_REASSERT_MS). xterm only fires onResize on actual
grid changes, so a resize update the zellij client loses (raced mid-attach
or coalesced during a drag) would otherwise desync the session layout from
the pane until the next real change. The backend answers every resize
frame with an explicit SIGWINCH, so the re-assert is a no-op when already
in sync.
Comments in the terminal hook/components now describe the per-client
attach model (fresh server-side `zellij attach` per open, no replay ring).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(renderer): full-width shell topbar; retire per-view topbars and review dashboard
The shell now owns a single full-width ShellTopbar (status pill, history
arrows, notifications, kanban/inspector toggles) with the sidebar pinned
below it, replacing the per-view Topbar/DashboardTopbar pair; board pages
get a lightweight DashboardSubhead. The standalone review dashboard and
its /review(s) routes are removed — review state lives on the PR board.
Approved divergence from the AO reference (full-height sidebar) recorded
in DESIGN.md; macOS traffic lights re-centered on the 56px header row.
Also hardens the session view around rrp v4:
- inspector defaultSize re-derived per panel mount (orchestrator → worker
navigation kept SessionView mounted while the panel remounted), and the
imperative expand/collapse effect no longer races panel registration
- onResize writes gated on data-separator="active" so flex-grow
transition frames can't bounce the store (dead-looking toggle button)
- findProjectOrchestrator skips terminated orchestrators so the topbar
offers Spawn instead of attaching to a dead zellij session
- inspector resize handle gets a visible 1px divider at rest
- playwright specs for history arrows + inspector toggle; test-results/
gitignored
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: document Electron app dev quick start
Add an "Electron app (dev)" section: npm install + npm run dev under
frontend/, with the explicit heads-up that the app does not start the
daemon — it attaches over loopback to a daemon started via `ao start`
(plus npm run dev:web for renderer-only work in a browser).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(fork): ignore local agent session dirs
Fork-only ignore entries (.entire/.claude/.gstack) — must not be included
in upstream PRs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: format with prettier [skip ci]
* feat: align backend session lifecycle with workspace runtime updates
* refactor: replace spawn modal with shell-native worker controls
* chore: add shared daemon launch helper and docs updates
* chore: format with prettier [skip ci]
---------
Co-authored-by: Ashish Huddar <ashish.hudar@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
3e64c15142
commit
f7df36bb8b
|
|
@ -234,9 +234,15 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
|
|||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
cmd = make([]string, 0, 5)
|
||||
cmd = make([]string, 0, 7)
|
||||
cmd = append(cmd, binary)
|
||||
appendPermissionFlags(&cmd, cfg.Permissions)
|
||||
if cfg.SystemPrompt != "" {
|
||||
// --resume rebuilds the system prompt from the current flags (it is
|
||||
// not stored in the transcript), so standing instructions must be
|
||||
// re-appended or a restored orchestrator loses its role.
|
||||
cmd = append(cmd, "--append-system-prompt", cfg.SystemPrompt)
|
||||
}
|
||||
cmd = append(cmd, "--resume", sessionID)
|
||||
return cmd, true, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -386,6 +386,26 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandReappendsSystemPrompt(t *testing.T) {
|
||||
// --resume rebuilds the system prompt from flags, so standing instructions
|
||||
// (e.g. the orchestrator role) must be re-appended on restore.
|
||||
cmd, ok, err := (&Plugin{resolvedBinary: "claude"}).GetRestoreCommand(context.Background(), ports.RestoreConfig{
|
||||
Permissions: ports.PermissionModeBypassPermissions,
|
||||
SystemPrompt: "You are an orchestrator.",
|
||||
Session: ports.SessionRef{
|
||||
ID: "sess-r",
|
||||
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "claude-native-1"},
|
||||
},
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("restore = (ok=%v, err=%v), want ok", ok, err)
|
||||
}
|
||||
want := []string{"claude", "--permission-mode", "bypassPermissions", "--append-system-prompt", "You are an orchestrator.", "--resume", "claude-native-1"}
|
||||
if !reflect.DeepEqual(cmd, want) {
|
||||
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRestoreCommandFallsBackToDerivedUUID(t *testing.T) {
|
||||
// No agentSessionId captured (pre-hook session) → derive deterministically
|
||||
// from the AO session id, the explicit fallback.
|
||||
|
|
|
|||
|
|
@ -61,7 +61,11 @@ func deleteSessionArgs(id string) []string {
|
|||
}
|
||||
|
||||
func attachArgs(id string) []string {
|
||||
return []string{"attach", id}
|
||||
return []string{
|
||||
"attach", id,
|
||||
"options",
|
||||
"--pane-frames", "false",
|
||||
}
|
||||
}
|
||||
|
||||
func handleIDValue(sessionID, paneID string) string {
|
||||
|
|
@ -96,7 +100,7 @@ func shellLaunchSpecFor(shellPath string) shellLaunchSpec {
|
|||
func layoutString(workspacePath, shellPath string, shellArgs []string, shellCommand string) string {
|
||||
return "layout {\n" +
|
||||
" cwd " + kdlQuote(workspacePath) + "\n" +
|
||||
" pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " {\n" +
|
||||
" pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " borderless=true {\n" +
|
||||
" args " + kdlJoin(shellArgs) + " " + kdlQuote(shellCommand) + "\n" +
|
||||
" }\n" +
|
||||
"}\n"
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 5 * time.Second
|
||||
minMajor = 0
|
||||
minMinor = 44
|
||||
minPatch = 3
|
||||
defaultTimeout = 5 * time.Second
|
||||
defaultZellijTerm = "xterm-256color"
|
||||
defaultZellijColor = "truecolor"
|
||||
minMajor = 0
|
||||
minMinor = 44
|
||||
minPatch = 3
|
||||
)
|
||||
|
||||
var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
|
|
@ -79,9 +81,7 @@ type execRunner struct{}
|
|||
|
||||
func (execRunner) Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
cmd.Env = zellijCommandEnv(os.Environ(), env)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +134,13 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
|
|||
if err := r.ensureSupportedVersion(ctx); err != nil {
|
||||
return ports.RuntimeHandle{}, err
|
||||
}
|
||||
// Zellij keeps exited sessions in a resurrection cache. A previous partial
|
||||
// spawn can therefore make `attach --create-background` fail with "Session
|
||||
// already exists" even though AO has no usable runtime handle. Clear any
|
||||
// same-name runtime state before creating the new AO-owned session.
|
||||
if err := r.Destroy(ctx, ports.RuntimeHandle{ID: id}); err != nil {
|
||||
return ports.RuntimeHandle{}, err
|
||||
}
|
||||
|
||||
layoutPath, err := r.writeLayout(cfg)
|
||||
if err != nil {
|
||||
|
|
@ -243,9 +250,6 @@ func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) {
|
|||
}
|
||||
args := append([]string{}, r.baseArgs()...)
|
||||
args = append(args, attachArgs(id)...)
|
||||
if r.socketDir == "" {
|
||||
return append([]string{r.binary}, args...), nil
|
||||
}
|
||||
return attachCommandWithEnv(r.binary, r.socketDir, args...), nil
|
||||
}
|
||||
|
||||
|
|
@ -326,21 +330,33 @@ func (r *Runtime) baseArgs() []string {
|
|||
}
|
||||
|
||||
func (r *Runtime) env() []string {
|
||||
env := zellijColorEnv(nil)
|
||||
if r.socketDir == "" {
|
||||
return nil
|
||||
return env
|
||||
}
|
||||
return []string{"ZELLIJ_SOCKET_DIR=" + r.socketDir}
|
||||
return append(env, "ZELLIJ_SOCKET_DIR="+r.socketDir)
|
||||
}
|
||||
|
||||
func attachCommandWithEnv(binary, socketDir string, args ...string) []string {
|
||||
if socketDir == "" {
|
||||
return append([]string{binary}, args...)
|
||||
env := zellijColorEnv(nil)
|
||||
if socketDir != "" {
|
||||
env = append(env, "ZELLIJ_SOCKET_DIR="+socketDir)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
command := strings.Builder{}
|
||||
command.WriteString("$env:ZELLIJ_SOCKET_DIR = ")
|
||||
command.WriteString(psQuote(socketDir))
|
||||
command.WriteString("; & ")
|
||||
command.WriteString("Remove-Item Env:NO_COLOR -ErrorAction SilentlyContinue; ")
|
||||
for _, pair := range env {
|
||||
key, value, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
command.WriteString("$env:")
|
||||
command.WriteString(key)
|
||||
command.WriteString(" = ")
|
||||
command.WriteString(psQuote(value))
|
||||
command.WriteString("; ")
|
||||
}
|
||||
command.WriteString("& ")
|
||||
command.WriteString(psQuote(binary))
|
||||
for _, arg := range args {
|
||||
command.WriteByte(' ')
|
||||
|
|
@ -348,7 +364,55 @@ func attachCommandWithEnv(binary, socketDir string, args ...string) []string {
|
|||
}
|
||||
return []string{"powershell.exe", "-NoLogo", "-NoProfile", "-Command", command.String()}
|
||||
}
|
||||
return append([]string{"env", "ZELLIJ_SOCKET_DIR=" + socketDir, binary}, args...)
|
||||
argv := []string{"env", "-u", "NO_COLOR"}
|
||||
argv = append(argv, env...)
|
||||
argv = append(argv, binary)
|
||||
return append(argv, args...)
|
||||
}
|
||||
|
||||
func zellijCommandEnv(base, overrides []string) []string {
|
||||
env := zellijColorEnv(append([]string(nil), base...))
|
||||
for _, pair := range overrides {
|
||||
env = upsertEnv(env, pair)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func zellijColorEnv(env []string) []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return env
|
||||
}
|
||||
env = removeEnv(env, "NO_COLOR")
|
||||
env = upsertEnv(env, "TERM="+defaultZellijTerm)
|
||||
env = upsertEnv(env, "COLORTERM="+defaultZellijColor)
|
||||
return env
|
||||
}
|
||||
|
||||
func upsertEnv(env []string, pair string) []string {
|
||||
key, _, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
return env
|
||||
}
|
||||
prefix := key + "="
|
||||
for i, current := range env {
|
||||
if strings.HasPrefix(current, prefix) {
|
||||
env[i] = pair
|
||||
return env
|
||||
}
|
||||
}
|
||||
return append(env, pair)
|
||||
}
|
||||
|
||||
func removeEnv(env []string, key string) []string {
|
||||
prefix := key + "="
|
||||
out := env[:0]
|
||||
for _, current := range env {
|
||||
if strings.HasPrefix(current, prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, current)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func zellijSessionName(id domain.SessionID) (string, error) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,66 @@ func TestNewDefaultsToPortableShell(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestZellijCommandEnvNormalizesBrowserTerminalColors(t *testing.T) {
|
||||
got := zellijCommandEnv(
|
||||
[]string{"NO_COLOR=1", "TERM=dumb", "COLORTERM=", "KEEP=yes"},
|
||||
[]string{"ZELLIJ_SOCKET_DIR=/tmp/zj"},
|
||||
)
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
if !contains(got, "NO_COLOR=1") {
|
||||
t.Fatalf("windows env = %#v, want NO_COLOR preserved", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if containsKey(got, "NO_COLOR") {
|
||||
t.Fatalf("NO_COLOR survived env normalization: %#v", got)
|
||||
}
|
||||
for _, want := range []string{"KEEP=yes", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"} {
|
||||
if !contains(got, want) {
|
||||
t.Fatalf("env missing %q in %#v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func expectedZellijEnv(socketDir string) []string {
|
||||
env := []string{}
|
||||
if runtime.GOOS != "windows" {
|
||||
env = append(env, "TERM=xterm-256color", "COLORTERM=truecolor")
|
||||
}
|
||||
if socketDir != "" {
|
||||
env = append(env, "ZELLIJ_SOCKET_DIR="+socketDir)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func expectedAttachEnvPrefix() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor"}
|
||||
}
|
||||
|
||||
func contains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsKey(values []string, key string) bool {
|
||||
prefix := key + "="
|
||||
for _, value := range values {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCommandBuilders(t *testing.T) {
|
||||
if got, want := versionArgs(), []string{"--version"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("versionArgs = %#v, want %#v", got, want)
|
||||
|
|
@ -48,6 +108,9 @@ func TestCommandBuilders(t *testing.T) {
|
|||
if got, want := deleteSessionArgs("sess-1"), []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("deleteSessionArgs = %#v, want %#v", got, want)
|
||||
}
|
||||
if got, want := attachArgs("sess-1"), []string{"attach", "sess-1", "options", "--pane-frames", "false"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("attachArgs = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) {
|
||||
|
|
@ -141,7 +204,7 @@ func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) {
|
|||
|
||||
for _, want := range []string{
|
||||
`cwd "/tmp/ws"`,
|
||||
`pane command="/bin/zsh" name="agent"`,
|
||||
`pane command="/bin/zsh" name="agent" borderless=true`,
|
||||
"export AO_SESSION_ID='sess-1';",
|
||||
"export ODD='can'\\\\''t';",
|
||||
"export PATH='/custom/bin:/usr/bin';",
|
||||
|
|
@ -168,7 +231,7 @@ func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) {
|
|||
}}, `C:\Program Files\PowerShell\7\pwsh.exe`)
|
||||
|
||||
for _, want := range []string{
|
||||
`pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent"`,
|
||||
`pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent" borderless=true`,
|
||||
`args "-NoLogo" "-NoProfile" "-NoExit" "-Command"`,
|
||||
"$env:AO_SESSION_ID = 'sess-1';",
|
||||
"$env:PATH = ",
|
||||
|
|
@ -192,7 +255,7 @@ func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) {
|
|||
}}, `C:\Windows\System32\cmd.exe`)
|
||||
|
||||
for _, want := range []string{
|
||||
`pane command="C:\\Windows\\System32\\cmd.exe" name="agent"`,
|
||||
`pane command="C:\\Windows\\System32\\cmd.exe" name="agent" borderless=true`,
|
||||
`args "/D" "/S" "/K"`,
|
||||
`AO_SESSION_ID=sess-1`,
|
||||
`\"echo\" \"ready\"`,
|
||||
|
|
@ -218,7 +281,7 @@ func TestCreateRejectsInvalidEnvKeys(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCreateStartsSessionAndDiscoversPane(t *testing.T) {
|
||||
fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}}
|
||||
fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}}
|
||||
r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"})
|
||||
r.runner = fr
|
||||
|
||||
|
|
@ -234,23 +297,76 @@ func TestCreateStartsSessionAndDiscoversPane(t *testing.T) {
|
|||
if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3"}) {
|
||||
t.Fatalf("handle = %+v, want zellij handle", handle)
|
||||
}
|
||||
if len(fr.calls) != 3 {
|
||||
t.Fatalf("calls = %d, want 3", len(fr.calls))
|
||||
if len(fr.calls) != 4 {
|
||||
t.Fatalf("calls = %d, want 4", len(fr.calls))
|
||||
}
|
||||
if got, want := fr.calls[0].args, []string{"--config-dir", "/tmp/cfg", "--version"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("version args = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := fr.calls[1].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) {
|
||||
if got, want := fr.calls[1].args, []string{"--config-dir", "/tmp/cfg", "delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("delete args = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := fr.calls[2].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) {
|
||||
t.Fatalf("create args prefix = %#v", got)
|
||||
}
|
||||
if got := fr.calls[2].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) {
|
||||
if got := fr.calls[3].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) {
|
||||
t.Fatalf("list panes args = %#v", got)
|
||||
}
|
||||
if got, want := fr.calls[0].env, []string{"ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := fr.calls[0].env, expectedZellijEnv("/tmp/zj"); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("env = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) {
|
||||
fr := &fakeRunner{outputs: [][]byte{
|
||||
[]byte("zellij 0.44.3"),
|
||||
nil,
|
||||
nil,
|
||||
[]byte(`[{"id":1,"is_plugin":false,"title":"agent"}]`),
|
||||
}}
|
||||
r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"})
|
||||
r.runner = fr
|
||||
|
||||
if _, err := r.Create(context.Background(), ports.RuntimeConfig{
|
||||
SessionID: "sess-1",
|
||||
WorkspacePath: "/tmp/ws",
|
||||
Argv: []string{"echo", "ready"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
if len(fr.calls) != 4 {
|
||||
t.Fatalf("calls = %d, want 4", len(fr.calls))
|
||||
}
|
||||
if got, want := fr.calls[1].args, []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("delete args = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := fr.calls[2].args[:3]; !reflect.DeepEqual(got, []string{"attach", "--create-background", "sess-1"}) {
|
||||
t.Fatalf("create args prefix = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCommandDisablesPaneFrames(t *testing.T) {
|
||||
r := New(Options{})
|
||||
args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"})
|
||||
if err != nil {
|
||||
t.Fatalf("AttachCommand: %v", err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
joined := strings.Join(args, " ")
|
||||
for _, want := range []string{"--pane-frames", "false"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("windows attach command missing %q: %#v", want, args)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
want := append(expectedAttachEnvPrefix(), "zellij", "attach", "sess-1", "options", "--pane-frames", "false")
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("AttachCommand = %#v, want %#v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCommandUsesSocketDir(t *testing.T) {
|
||||
r := New(Options{SocketDir: "/tmp/zj"})
|
||||
args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"})
|
||||
|
|
@ -266,9 +382,12 @@ func TestAttachCommandUsesSocketDir(t *testing.T) {
|
|||
}
|
||||
return
|
||||
}
|
||||
if got, want := args[:3], []string{"env", "ZELLIJ_SOCKET_DIR=/tmp/zj", r.binary}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := args[:6], []string{"env", "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor", "ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("attach prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
if got, want := args[6], r.binary; got != want {
|
||||
t.Fatalf("attach binary = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAgentPaneRetriesTransientErrors(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,11 @@ func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (port
|
|||
if err != nil {
|
||||
return ports.WorkspaceInfo{}, err
|
||||
}
|
||||
if info, ok, err := w.existingWorktree(ctx, repo, path, cfg); err != nil {
|
||||
return ports.WorkspaceInfo{}, err
|
||||
} else if ok {
|
||||
return info, nil
|
||||
}
|
||||
if err := w.addWorktree(ctx, repo, path, cfg.Branch, cfg.BaseBranch); err != nil {
|
||||
return ports.WorkspaceInfo{}, err
|
||||
}
|
||||
|
|
@ -218,6 +223,21 @@ func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (por
|
|||
return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
|
||||
}
|
||||
|
||||
func (w *Workspace) existingWorktree(ctx context.Context, repo, path string, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, bool, error) {
|
||||
records, err := w.listRecords(ctx, repo)
|
||||
if err != nil {
|
||||
return ports.WorkspaceInfo{}, false, err
|
||||
}
|
||||
if rec, ok := findWorktree(records, path); ok {
|
||||
branch := rec.Branch
|
||||
if branch == "" {
|
||||
branch = cfg.Branch
|
||||
}
|
||||
return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, true, nil
|
||||
}
|
||||
return ports.WorkspaceInfo{}, false, nil
|
||||
}
|
||||
|
||||
func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBranch string) error {
|
||||
// Refuse early if the branch is already checked out in another worktree:
|
||||
// `git worktree add` will fail, but its stderr leaks through as an opaque
|
||||
|
|
|
|||
|
|
@ -174,6 +174,43 @@ func TestOrchestratorManagedPath(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestCreateReusesRegisteredWorktreeAtExpectedPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repo := t.TempDir()
|
||||
ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}})
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
path := filepath.Join(ws.managedRoot, "proj", "orchestrator", "proj-orchestrator")
|
||||
cfg := ports.WorkspaceConfig{
|
||||
ProjectID: "proj",
|
||||
SessionID: "proj-1",
|
||||
Kind: domain.KindOrchestrator,
|
||||
SessionPrefix: "proj",
|
||||
Branch: "ao/proj-orchestrator",
|
||||
}
|
||||
ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
|
||||
joined := strings.Join(args, " ")
|
||||
switch {
|
||||
case strings.Contains(joined, "check-ref-format"):
|
||||
return nil, nil
|
||||
case strings.Contains(joined, "worktree list --porcelain"):
|
||||
return []byte("worktree " + path + "\nbranch refs/heads/ao/proj-orchestrator\n"), nil
|
||||
default:
|
||||
t.Fatalf("unexpected git invocation: %v", args)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
info, err := ws.Create(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if info.Path != path || info.Branch != "ao/proj-orchestrator" {
|
||||
t.Fatalf("info = %#v, want path %q branch ao/proj-orchestrator", info, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConfigRejectsPathEscapingIDs covers review item RB: filepath.Join
|
||||
// in managedPath cleans `..` segments before validateManagedPath sees them, so a
|
||||
// session id of "../other" would stay inside managedRoot while jumping projects.
|
||||
|
|
|
|||
|
|
@ -183,28 +183,37 @@ func parsePositiveDuration(name, raw string) (time.Duration, error) {
|
|||
}
|
||||
|
||||
// resolveRunFilePath picks where running.json lives. An explicit AO_RUN_FILE
|
||||
// wins; otherwise it sits under the per-user config directory so multiple repos
|
||||
// share one supervisor handshake location.
|
||||
// wins; otherwise it sits under the per-user config directory so the CLI and
|
||||
// Electron supervisor share one handshake location.
|
||||
func resolveRunFilePath() (string, error) {
|
||||
if p, ok := os.LookupEnv("AO_RUN_FILE"); ok && p != "" {
|
||||
return p, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
stateDir, err := defaultStateDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve state dir: %w", err)
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".ao", "running.json"), nil
|
||||
return filepath.Join(stateDir, "running.json"), nil
|
||||
}
|
||||
|
||||
// resolveDataDir picks where durable state (the SQLite DB) lives. An explicit
|
||||
// AO_DATA_DIR wins; otherwise it defaults to ~/.ao/data/.
|
||||
// AO_DATA_DIR wins; otherwise it defaults under the same per-user config
|
||||
// directory as the run-file.
|
||||
func resolveDataDir() (string, error) {
|
||||
if p, ok := os.LookupEnv("AO_DATA_DIR"); ok && p != "" {
|
||||
return p, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
stateDir, err := defaultStateDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(stateDir, "data"), nil
|
||||
}
|
||||
|
||||
func defaultStateDir() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve state dir: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".ao", "data"), nil
|
||||
return filepath.Join(configDir, "agent-orchestrator"), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -10,7 +10,7 @@ import (
|
|||
func TestLoadDefaults(t *testing.T) {
|
||||
// Clear every recognised var so we observe pure defaults regardless of the
|
||||
// surrounding environment.
|
||||
for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE", "AO_DATA_DIR"} {
|
||||
for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE", "AO_DATA_DIR", "AO_AGENT", "AO_ALLOWED_ORIGINS"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
|
||||
|
|
@ -33,14 +33,20 @@ func TestLoadDefaults(t *testing.T) {
|
|||
if cfg.RunFilePath == "" {
|
||||
t.Error("RunFilePath is empty, want a resolved default path")
|
||||
}
|
||||
if !strings.HasSuffix(cfg.RunFilePath, filepath.Join(".ao", "running.json")) {
|
||||
t.Errorf("RunFilePath = %q, want .ao/running.json suffix", cfg.RunFilePath)
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir: %v", err)
|
||||
}
|
||||
wantRunFilePath := filepath.Join(configDir, "agent-orchestrator", "running.json")
|
||||
if cfg.RunFilePath != wantRunFilePath {
|
||||
t.Errorf("RunFilePath = %q, want %q", cfg.RunFilePath, wantRunFilePath)
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
t.Error("DataDir is empty, want a resolved default path")
|
||||
}
|
||||
if !strings.HasSuffix(cfg.DataDir, filepath.Join(".ao", "data")) {
|
||||
t.Errorf("DataDir = %q, want .ao/data suffix", cfg.DataDir)
|
||||
wantDataDir := filepath.Join(configDir, "agent-orchestrator", "data")
|
||||
if cfg.DataDir != wantDataDir {
|
||||
t.Errorf("DataDir = %q, want %q", cfg.DataDir, wantDataDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func Run() error {
|
|||
}
|
||||
|
||||
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
|
||||
Projects: projectsvc.New(store),
|
||||
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc}),
|
||||
Sessions: sessionSvc,
|
||||
Reviews: reviewsvc.NewInMemory(),
|
||||
CDC: store,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ func (c ProjectConfig) Validate() error {
|
|||
if err := c.AgentConfig.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNameComponent("sessionPrefix", c.SessionPrefix); err != nil {
|
||||
return err
|
||||
}
|
||||
for role, ro := range map[string]RoleOverride{"worker": c.Worker, "orchestrator": c.Orchestrator} {
|
||||
if ro.Harness != "" && !ro.Harness.IsKnown() {
|
||||
return fmt.Errorf("%s.agent: unknown harness %q", role, ro.Harness)
|
||||
|
|
@ -94,6 +97,17 @@ func (c ProjectConfig) Validate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateNameComponent(name, value string) error {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.ContainsAny(trimmed, `/\`) || trimmed == "." || trimmed == ".." {
|
||||
return fmt.Errorf("%s: must not contain path separators or traversal components", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRepoRelative refuses paths that would let a project config escape
|
||||
// its repo root: absolute paths and any ".." segment (before or after Clean).
|
||||
// The same guard runs at spawn time as defense-in-depth, but enforcing it here
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ func TestProjectConfigValidate(t *testing.T) {
|
|||
{"empty ok", ProjectConfig{}, false},
|
||||
{"good agent config", ProjectConfig{AgentConfig: AgentConfig{Model: "m", Permissions: PermissionModeAuto}}, false},
|
||||
{"bad permission", ProjectConfig{AgentConfig: AgentConfig{Permissions: "yolo"}}, true},
|
||||
{"good session prefix", ProjectConfig{SessionPrefix: "ao"}, false},
|
||||
{"session prefix with slash", ProjectConfig{SessionPrefix: "ao/project"}, true},
|
||||
{"session prefix with backslash", ProjectConfig{SessionPrefix: `ao\project`}, true},
|
||||
{"session prefix traversal component", ProjectConfig{SessionPrefix: ".."}, true},
|
||||
{"good role override", ProjectConfig{Worker: RoleOverride{Harness: HarnessCodex}}, false},
|
||||
{"unknown role harness", ProjectConfig{Orchestrator: RoleOverride{Harness: "nope"}}, true},
|
||||
{"bad role agent config", ProjectConfig{Worker: RoleOverride{AgentConfig: AgentConfig{Permissions: "nope"}}}, true},
|
||||
|
|
|
|||
|
|
@ -123,6 +123,11 @@ type RestoreConfig struct {
|
|||
Config AgentConfig
|
||||
Permissions PermissionMode
|
||||
Session SessionRef
|
||||
// SystemPrompt carries the session's standing instructions (e.g. the
|
||||
// orchestrator role). Agent CLIs rebuild their system prompt from flags on
|
||||
// resume — it is not part of the transcript — so adapters whose CLI has a
|
||||
// system-prompt flag should re-apply this in their resume command.
|
||||
SystemPrompt string
|
||||
}
|
||||
|
||||
// SessionRef identifies an AO session whose agent-owned metadata may be read.
|
||||
|
|
|
|||
|
|
@ -36,9 +36,16 @@ type Manager interface {
|
|||
Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error)
|
||||
}
|
||||
|
||||
// SessionTeardowner is the narrow session-service surface project removal
|
||||
// needs: stop live project sessions and reclaim managed terminal workspaces.
|
||||
type SessionTeardowner interface {
|
||||
TeardownProject(ctx context.Context, project domain.ProjectID) error
|
||||
}
|
||||
|
||||
// Service implements project registration and lookup use-cases for controllers.
|
||||
type Service struct {
|
||||
store Store
|
||||
store Store
|
||||
sessions SessionTeardowner
|
||||
// addMu serialises the whole body of Add. Workspace registration performs
|
||||
// filesystem mutations (git init, .gitignore writes, commits) that are not
|
||||
// covered by the store's own writeMu, so path/id conflict checks plus the
|
||||
|
|
@ -48,9 +55,20 @@ type Service struct {
|
|||
|
||||
var _ Manager = (*Service)(nil)
|
||||
|
||||
// Deps captures optional collaborators for project use-cases.
|
||||
type Deps struct {
|
||||
Store Store
|
||||
Sessions SessionTeardowner
|
||||
}
|
||||
|
||||
// New returns a project service backed by the given durable store.
|
||||
func New(store Store) *Service {
|
||||
return &Service{store: store}
|
||||
return NewWithDeps(Deps{Store: store})
|
||||
}
|
||||
|
||||
// NewWithDeps returns a project service with optional teardown dependencies.
|
||||
func NewWithDeps(d Deps) *Service {
|
||||
return &Service{store: d.Store, sessions: d.Sessions}
|
||||
}
|
||||
|
||||
// List returns every active registered project.
|
||||
|
|
@ -218,12 +236,26 @@ func resolveGitOriginURL(path string) string {
|
|||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// Remove archives a project registration.
|
||||
// Remove stops live project sessions, reclaims safe managed workspaces, then
|
||||
// archives the project registration. The original repository path and durable
|
||||
// session/history rows are preserved.
|
||||
func (m *Service) Remove(ctx context.Context, id domain.ProjectID) (RemoveResult, error) {
|
||||
if err := validateProjectID(id); err != nil {
|
||||
return RemoveResult{}, err
|
||||
}
|
||||
ok, err := m.store.ArchiveProject(ctx, string(id), time.Now())
|
||||
row, ok, err := m.store.GetProject(ctx, string(id))
|
||||
if err != nil {
|
||||
return RemoveResult{}, apierr.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project")
|
||||
}
|
||||
if !ok || !row.ArchivedAt.IsZero() {
|
||||
return RemoveResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project")
|
||||
}
|
||||
if m.sessions != nil {
|
||||
if err := m.sessions.TeardownProject(ctx, id); err != nil {
|
||||
return RemoveResult{}, err
|
||||
}
|
||||
}
|
||||
ok, err = m.store.ArchiveProject(ctx, string(id), time.Now())
|
||||
if err != nil {
|
||||
return RemoveResult{}, apierr.Internal("PROJECT_REMOVE_FAILED", "Failed to remove project")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,16 @@ func wantCode(t *testing.T, err error, code string) {
|
|||
}
|
||||
}
|
||||
|
||||
type fakeProjectTeardowner struct {
|
||||
projects []domain.ProjectID
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeProjectTeardowner) TeardownProject(_ context.Context, project domain.ProjectID) error {
|
||||
f.projects = append(f.projects, project)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestManager_AddListGetRemove(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := newManager(t)
|
||||
|
|
@ -99,6 +109,50 @@ func TestManager_AddListGetRemove(t *testing.T) {
|
|||
wantCode(t, err, "PROJECT_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestManager_RemoveTeardownsBeforeArchive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := sqlite.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
teardown := &fakeProjectTeardowner{}
|
||||
m := project.NewWithDeps(project.Deps{Store: store, Sessions: teardown})
|
||||
|
||||
if _, err := m.Add(ctx, project.AddInput{Path: gitRepo(t), ProjectID: ptr("ao")}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if _, err := m.Remove(ctx, "ao"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
if len(teardown.projects) != 1 || teardown.projects[0] != "ao" {
|
||||
t.Fatalf("teardown projects = %#v, want [ao]", teardown.projects)
|
||||
}
|
||||
_, err = m.Get(ctx, "ao")
|
||||
wantCode(t, err, "PROJECT_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestManager_RemoveDoesNotArchiveWhenTeardownFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := sqlite.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
boom := errors.New("teardown failed")
|
||||
m := project.NewWithDeps(project.Deps{Store: store, Sessions: &fakeProjectTeardowner{err: boom}})
|
||||
|
||||
if _, err := m.Add(ctx, project.AddInput{Path: gitRepo(t), ProjectID: ptr("ao")}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if _, err := m.Remove(ctx, "ao"); !errors.Is(err, boom) {
|
||||
t.Fatalf("Remove err = %v, want teardown failure", err)
|
||||
}
|
||||
if got, err := m.Get(ctx, "ao"); err != nil || got.Project == nil || got.Project.ID != "ao" {
|
||||
t.Fatalf("project after failed remove = %#v, %v; want still active", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_DefaultsWhenUnconfigured(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := newManager(t)
|
||||
|
|
|
|||
|
|
@ -241,6 +241,26 @@ func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) (Cleanu
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// TeardownProject stops every live session in a project, then asks the session
|
||||
// manager to reclaim terminal workspaces. Dirty worktrees are preserved by Kill
|
||||
// and Cleanup; callers only see hard teardown failures.
|
||||
func (s *Service) TeardownProject(ctx context.Context, project domain.ProjectID) error {
|
||||
recs, err := s.listRecords(ctx, project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rec := range recs {
|
||||
if rec.IsTerminated {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Kill(ctx, rec.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = s.Cleanup(ctx, project)
|
||||
return err
|
||||
}
|
||||
|
||||
// List returns sessions as enriched display models after applying API filters.
|
||||
func (s *Service) List(ctx context.Context, filter ListFilter) ([]domain.Session, error) {
|
||||
recs, err := s.listRecords(ctx, filter.ProjectID)
|
||||
|
|
|
|||
|
|
@ -127,9 +127,12 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) {
|
|||
// fakeCommander records Kill/Spawn calls so a test can assert the
|
||||
// clean-orchestrator ordering without wiring a real session engine.
|
||||
type fakeCommander struct {
|
||||
killed []domain.SessionID
|
||||
spawned bool
|
||||
killsAtSpawn int
|
||||
killed []domain.SessionID
|
||||
cleanupProjects []domain.ProjectID
|
||||
killErr error
|
||||
cleanupErr error
|
||||
spawned bool
|
||||
killsAtSpawn int
|
||||
}
|
||||
|
||||
func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) {
|
||||
|
|
@ -141,11 +144,18 @@ func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.Sessi
|
|||
return domain.SessionRecord{}, nil
|
||||
}
|
||||
func (f *fakeCommander) Kill(_ context.Context, id domain.SessionID) (bool, error) {
|
||||
if f.killErr != nil {
|
||||
return false, f.killErr
|
||||
}
|
||||
f.killed = append(f.killed, id)
|
||||
return true, nil
|
||||
}
|
||||
func (f *fakeCommander) Send(context.Context, domain.SessionID, string) error { return nil }
|
||||
func (f *fakeCommander) Cleanup(context.Context, domain.ProjectID) (sessionmanager.CleanupResult, error) {
|
||||
func (f *fakeCommander) Cleanup(_ context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) {
|
||||
f.cleanupProjects = append(f.cleanupProjects, project)
|
||||
if f.cleanupErr != nil {
|
||||
return sessionmanager.CleanupResult{}, f.cleanupErr
|
||||
}
|
||||
return sessionmanager.CleanupResult{
|
||||
Cleaned: []domain.SessionID{"mer-1"},
|
||||
Skipped: []sessionmanager.CleanupSkip{{SessionID: "mer-2", Reason: "workspace has uncommitted changes"}},
|
||||
|
|
@ -171,6 +181,41 @@ func TestCleanupMapsManagerResult(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTeardownProjectKillsActiveSessionsThenCleansProject(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"}
|
||||
st.sessions["mer-2"] = domain.SessionRecord{ID: "mer-2", ProjectID: "mer", IsTerminated: true}
|
||||
st.sessions["other-1"] = domain.SessionRecord{ID: "other-1", ProjectID: "other"}
|
||||
fc := &fakeCommander{}
|
||||
svc := &Service{manager: fc, store: st}
|
||||
|
||||
if err := svc.TeardownProject(context.Background(), "mer"); err != nil {
|
||||
t.Fatalf("TeardownProject: %v", err)
|
||||
}
|
||||
if len(fc.killed) != 1 || fc.killed[0] != "mer-1" {
|
||||
t.Fatalf("killed = %#v, want only mer-1", fc.killed)
|
||||
}
|
||||
if len(fc.cleanupProjects) != 1 || fc.cleanupProjects[0] != "mer" {
|
||||
t.Fatalf("cleanup projects = %#v, want [mer]", fc.cleanupProjects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeardownProjectStopsOnKillError(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"}
|
||||
boom := errors.New("boom")
|
||||
fc := &fakeCommander{killErr: boom}
|
||||
svc := &Service{manager: fc, store: st}
|
||||
|
||||
err := svc.TeardownProject(context.Background(), "mer")
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("TeardownProject err = %v, want boom", err)
|
||||
}
|
||||
if len(fc.cleanupProjects) != 0 {
|
||||
t.Fatalf("cleanup projects = %#v, want none after kill failure", fc.cleanupProjects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
|
||||
|
|
|
|||
|
|
@ -176,10 +176,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
|
|||
|
||||
branch := cfg.Branch
|
||||
if branch == "" {
|
||||
// A fresh, unique branch per session: gitworktree can't add a worktree on
|
||||
// a branch already checked out elsewhere (e.g. main), so default to one
|
||||
// derived from the assigned session id.
|
||||
branch = "ao/" + string(id)
|
||||
branch = defaultSessionBranch(id, cfg.Kind, sessionPrefix(project))
|
||||
}
|
||||
ws, err := m.workspace.Create(ctx, ports.WorkspaceConfig{
|
||||
ProjectID: cfg.ProjectID,
|
||||
|
|
@ -456,9 +453,15 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
|
|||
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
|
||||
}
|
||||
// The system prompt is derived, not persisted: recompute it so a restored
|
||||
// session keeps its standing instructions across the relaunch.
|
||||
systemPrompt, err := m.buildSystemPrompt(ctx, rec.Kind, rec.ProjectID)
|
||||
if err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: system prompt: %w", id, err)
|
||||
}
|
||||
// Restore re-applies the project's resolved agent config so a configured
|
||||
// model/permissions carry across a restore, matching fresh spawn.
|
||||
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, effectiveAgentConfig(rec.Kind, project.Config))
|
||||
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, effectiveAgentConfig(rec.Kind, project.Config))
|
||||
if err != nil {
|
||||
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
|
||||
}
|
||||
|
|
@ -577,32 +580,62 @@ func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord {
|
|||
}
|
||||
}
|
||||
|
||||
func defaultSessionBranch(id domain.SessionID, kind domain.SessionKind, prefix string) string {
|
||||
if kind == domain.KindOrchestrator {
|
||||
return "ao/" + prefix + "-orchestrator"
|
||||
}
|
||||
// A fresh, unique branch per worker session: gitworktree can't add a worktree
|
||||
// on a branch already checked out elsewhere (e.g. main), so default to one
|
||||
// derived from the assigned session id.
|
||||
return "ao/" + string(id)
|
||||
}
|
||||
|
||||
func buildPrompt(cfg ports.SpawnConfig) string {
|
||||
return cfg.Prompt
|
||||
}
|
||||
|
||||
// orchestratorKickoffPrompt is the default first turn for an orchestrator
|
||||
// spawned without an explicit prompt. The role definition rides the system
|
||||
// prompt, and an interactive agent launched with only a system prompt sits at
|
||||
// an empty input box; this gives it a turn to act on.
|
||||
const orchestratorKickoffPrompt = "Get oriented: review the current repo state and any active worker sessions, then report your status and wait for direction."
|
||||
|
||||
// buildSpawnTexts returns the user-facing prompt and the system prompt to
|
||||
// deliver separately to the agent. Orchestrator role instructions and worker
|
||||
// coordination hints are placed in the system prompt so they are treated as
|
||||
// standing instructions rather than part of the human's task request.
|
||||
func (m *Manager) buildSpawnTexts(ctx context.Context, cfg ports.SpawnConfig) (prompt, systemPrompt string, err error) {
|
||||
prompt = buildPrompt(cfg)
|
||||
|
||||
switch cfg.Kind {
|
||||
case domain.KindOrchestrator:
|
||||
systemPrompt = orchestratorPrompt(cfg.ProjectID)
|
||||
case domain.KindWorker:
|
||||
orchestratorID, ok, lookupErr := m.activeOrchestratorSessionID(ctx, cfg.ProjectID)
|
||||
if lookupErr != nil {
|
||||
return "", "", lookupErr
|
||||
}
|
||||
if ok {
|
||||
systemPrompt = workerOrchestratorPrompt(orchestratorID)
|
||||
}
|
||||
systemPrompt, err = m.buildSystemPrompt(ctx, cfg.Kind, cfg.ProjectID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if cfg.Kind == domain.KindOrchestrator && prompt == "" {
|
||||
prompt = orchestratorKickoffPrompt
|
||||
}
|
||||
return prompt, systemPrompt, nil
|
||||
}
|
||||
|
||||
// buildSystemPrompt derives the standing instructions for a session of the
|
||||
// given kind from current store state. Restore recomputes them through here
|
||||
// rather than persisting them, so a restored worker points at the orchestrator
|
||||
// that is active now, not the one from its original spawn.
|
||||
func (m *Manager) buildSystemPrompt(ctx context.Context, kind domain.SessionKind, projectID domain.ProjectID) (string, error) {
|
||||
switch kind {
|
||||
case domain.KindOrchestrator:
|
||||
return orchestratorPrompt(projectID), nil
|
||||
case domain.KindWorker:
|
||||
orchestratorID, ok, err := m.activeOrchestratorSessionID(ctx, projectID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ok {
|
||||
return workerOrchestratorPrompt(orchestratorID), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) {
|
||||
recs, err := m.store.ListSessions(ctx, project)
|
||||
if err != nil {
|
||||
|
|
@ -819,7 +852,7 @@ func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id do
|
|||
// restoreArgv builds the argv to relaunch a torn-down session: the agent's
|
||||
// native resume command when it can continue the session, else a fresh launch.
|
||||
// The agent signals via ok=false (e.g. no native session id captured yet).
|
||||
func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata, agentConfig ports.AgentConfig) ([]string, error) {
|
||||
func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string, meta domain.SessionMetadata, systemPrompt string, agentConfig ports.AgentConfig) ([]string, error) {
|
||||
ref := ports.SessionRef{
|
||||
ID: string(id),
|
||||
WorkspacePath: workspacePath,
|
||||
|
|
@ -836,6 +869,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
|
|||
SessionID: string(id),
|
||||
WorkspacePath: workspacePath,
|
||||
Prompt: meta.Prompt,
|
||||
SystemPrompt: systemPrompt,
|
||||
Config: agentConfig,
|
||||
Permissions: agentConfig.Permissions,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -634,6 +634,79 @@ func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
|
|||
if strings.Contains(agent.lastLaunch.Prompt, "You are the human-facing coordinator") {
|
||||
t.Fatalf("coordinator role must not be in the user prompt:\n%s", agent.lastLaunch.Prompt)
|
||||
}
|
||||
|
||||
// A promptless orchestrator still needs a first turn: with the role in the
|
||||
// system prompt only, an interactive agent would idle at an empty input box.
|
||||
if agent.lastLaunch.Prompt != orchestratorKickoffPrompt {
|
||||
t.Fatalf("prompt = %q, want kick-off prompt", agent.lastLaunch.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestore_OrchestratorRederivesSystemPrompt: the system prompt is derived,
|
||||
// not persisted, so a restored orchestrator must get its role instructions
|
||||
// recomputed and handed to the agent's native resume command.
|
||||
func TestRestore_OrchestratorRederivesSystemPrompt(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.sessions["mer-1"] = domain.SessionRecord{
|
||||
ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true,
|
||||
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"},
|
||||
}
|
||||
agent := &recordingAgent{}
|
||||
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
||||
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
||||
|
||||
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(agent.lastRestore.SystemPrompt, "You are the human-facing coordinator for project mer") {
|
||||
t.Fatalf("restore system prompt missing coordinator role:\n%s", agent.lastRestore.SystemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestore_FallbackLaunchCarriesSystemPrompt: when the agent has no native
|
||||
// session to resume, the fresh-launch fallback must carry the re-derived
|
||||
// system prompt alongside the persisted task prompt.
|
||||
func TestRestore_FallbackLaunchCarriesSystemPrompt(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.sessions["mer-1"] = domain.SessionRecord{
|
||||
ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true,
|
||||
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", Prompt: "kick off"},
|
||||
}
|
||||
agent := &recordingAgent{}
|
||||
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
||||
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
||||
|
||||
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(agent.lastLaunch.SystemPrompt, "You are the human-facing coordinator for project mer") {
|
||||
t.Fatalf("fallback launch system prompt missing coordinator role:\n%s", agent.lastLaunch.SystemPrompt)
|
||||
}
|
||||
if agent.lastLaunch.Prompt != "kick off" {
|
||||
t.Fatalf("fallback launch prompt = %q, want persisted task prompt", agent.lastLaunch.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestore_WorkerPointsAtCurrentOrchestrator: a restored worker's
|
||||
// coordination hint must reference the orchestrator active at restore time,
|
||||
// not the one from its original spawn.
|
||||
func TestRestore_WorkerPointsAtCurrentOrchestrator(t *testing.T) {
|
||||
st := newFakeStore()
|
||||
st.sessions["mer-9"] = domain.SessionRecord{ID: "mer-9", ProjectID: "mer", Kind: domain.KindOrchestrator}
|
||||
st.sessions["mer-1"] = domain.SessionRecord{
|
||||
ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker, IsTerminated: true,
|
||||
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"},
|
||||
}
|
||||
agent := &recordingAgent{}
|
||||
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
||||
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
||||
|
||||
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(agent.lastRestore.SystemPrompt, `ao send --session mer-9`) {
|
||||
t.Fatalf("restore system prompt missing current orchestrator contact:\n%s", agent.lastRestore.SystemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestore_RefusesIncompleteHandle covers Bug 2: a terminated row whose
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const config: ForgeConfig = {
|
|||
name: "Agent Orchestrator",
|
||||
executableName: "agent-orchestrator",
|
||||
appCategoryType: "public.app-category.developer-tools",
|
||||
extraResource: "daemon",
|
||||
// macOS signing + notarization — set CSC_LINK/CSC_KEY_PASSWORD and
|
||||
// APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID in CI.
|
||||
// See frontend/docs/desktop-release.md.
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@
|
|||
"url": "https://github.com/aoagents/agent-orchestrator"
|
||||
},
|
||||
"scripts": {
|
||||
"build:daemon": "node ./scripts/build-daemon.mjs",
|
||||
"dev": "electron-forge start",
|
||||
"dev:web": "VITE_NO_ELECTRON=1 vite --config vite.renderer.config.ts",
|
||||
"prepackage": "npm run build:daemon",
|
||||
"package": "electron-forge package",
|
||||
"premake": "npm run build:daemon",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"publish": "npm run build:daemon && electron-forge publish",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --config vite.renderer.config.ts",
|
||||
"test:e2e": "playwright test",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { rmSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
||||
const frontendRoot = resolve(scriptsDir, "..");
|
||||
const repoRoot = resolve(frontendRoot, "..");
|
||||
const backendRoot = join(repoRoot, "backend");
|
||||
const outDir = join(frontendRoot, "daemon");
|
||||
const outPath = join(outDir, process.platform === "win32" ? "ao.exe" : "ao");
|
||||
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const result = spawnSync("go", ["build", "-o", outPath, "./cmd/ao"], {
|
||||
cwd: backendRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(`failed to start go build: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell, type OpenDialogOptions } from "electron";
|
||||
import { updateElectronApp } from "update-electron-app";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveDaemonLaunch } from "./shared/daemon-launch";
|
||||
import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery";
|
||||
import type { DaemonStatus } from "./shared/daemon-status";
|
||||
|
||||
|
|
@ -149,8 +151,14 @@ function startDaemon(): DaemonStatus {
|
|||
return daemonStatus;
|
||||
}
|
||||
|
||||
const command = process.env.AO_DAEMON_COMMAND;
|
||||
if (!command) {
|
||||
const launch = resolveDaemonLaunch(
|
||||
process.env,
|
||||
app.isPackaged,
|
||||
process.resourcesPath,
|
||||
app.getAppPath(),
|
||||
process.platform,
|
||||
);
|
||||
if (!launch) {
|
||||
setDaemonStatus({
|
||||
state: "stopped",
|
||||
message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.",
|
||||
|
|
@ -158,6 +166,14 @@ function startDaemon(): DaemonStatus {
|
|||
return daemonStatus;
|
||||
}
|
||||
|
||||
if (launch.source === "bundled" && !existsSync(launch.command)) {
|
||||
setDaemonStatus({
|
||||
state: "error",
|
||||
message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`,
|
||||
});
|
||||
return daemonStatus;
|
||||
}
|
||||
|
||||
setDaemonStatus({ state: "starting" });
|
||||
|
||||
// Capture the spawned handle locally so the async lifecycle listeners act only
|
||||
|
|
@ -168,10 +184,10 @@ function startDaemon(): DaemonStatus {
|
|||
// runs the command through /bin/sh, a plain kill() would only signal the shell
|
||||
// wrapper and orphan the real daemon (which keeps holding the port). Killing
|
||||
// the whole group via killDaemon() reaches the daemon and any PTY children.
|
||||
const child = spawn(command, [], {
|
||||
cwd: app.getAppPath(),
|
||||
const child = spawn(launch.command, launch.args, {
|
||||
cwd: launch.cwd,
|
||||
env: process.env,
|
||||
shell: true,
|
||||
shell: launch.shell,
|
||||
detached: true,
|
||||
});
|
||||
daemonProcess = child;
|
||||
|
|
@ -315,6 +331,7 @@ function initAutoUpdates(): void {
|
|||
app.whenReady().then(() => {
|
||||
registerRendererProtocol();
|
||||
createWindow();
|
||||
startDaemon();
|
||||
initAutoUpdates();
|
||||
|
||||
app.on("activate", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getMock, putMock } = vi.hoisted(() => ({
|
||||
getMock: vi.fn(),
|
||||
putMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/api-client", () => ({
|
||||
apiClient: {
|
||||
GET: getMock,
|
||||
PUT: putMock,
|
||||
},
|
||||
apiErrorMessage: (error: unknown) => {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
return String((error as { message: unknown }).message);
|
||||
}
|
||||
return "Request failed";
|
||||
},
|
||||
}));
|
||||
|
||||
import { ProjectSettingsForm } from "./ProjectSettingsForm";
|
||||
|
||||
function renderSettings(projectId = "proj-1") {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProjectSettingsForm projectId={projectId} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
async function chooseOption(trigger: HTMLElement, optionName: string) {
|
||||
await userEvent.click(trigger);
|
||||
await userEvent.click(await screen.findByRole("option", { name: optionName }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getMock.mockReset();
|
||||
putMock.mockReset();
|
||||
putMock.mockResolvedValue({ data: { project: {} }, error: undefined });
|
||||
});
|
||||
|
||||
describe("ProjectSettingsForm", () => {
|
||||
it("loads the current project settings and saves the exposed fields without dropping hidden config", async () => {
|
||||
getMock.mockResolvedValue({
|
||||
data: {
|
||||
status: "ok",
|
||||
project: {
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
kind: "single_repo",
|
||||
path: "/repo/project-one",
|
||||
repo: "git@github.com:acme/project-one.git",
|
||||
defaultBranch: "main",
|
||||
config: {
|
||||
defaultBranch: "develop",
|
||||
sessionPrefix: "po",
|
||||
env: { FOO: "bar" },
|
||||
symlinks: [".env"],
|
||||
postCreate: ["npm install"],
|
||||
worker: {
|
||||
agent: "codex",
|
||||
agentConfig: { model: "worker-model" },
|
||||
},
|
||||
orchestrator: { agent: "claude-code" },
|
||||
agentConfig: {
|
||||
model: "claude-opus-4-5",
|
||||
permissions: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText("git@github.com:acme/project-one.git")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Default branch")).toHaveValue("develop");
|
||||
expect(screen.getByLabelText("Session prefix")).toHaveValue("po");
|
||||
expect(screen.getByLabelText("Model override")).toHaveValue("claude-opus-4-5");
|
||||
|
||||
const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" });
|
||||
const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" });
|
||||
const permissionMode = screen.getByRole("combobox", { name: "Permission mode" });
|
||||
expect(workerAgent).toHaveTextContent("codex");
|
||||
expect(orchestratorAgent).toHaveTextContent("claude-code");
|
||||
expect(permissionMode).toHaveTextContent("Auto");
|
||||
|
||||
await userEvent.clear(screen.getByLabelText("Default branch"));
|
||||
await userEvent.type(screen.getByLabelText("Default branch"), "release");
|
||||
await userEvent.clear(screen.getByLabelText("Session prefix"));
|
||||
await userEvent.type(screen.getByLabelText("Session prefix"), "rel");
|
||||
await userEvent.clear(screen.getByLabelText("Model override"));
|
||||
await userEvent.type(screen.getByLabelText("Model override"), "gpt-5-codex");
|
||||
await chooseOption(workerAgent, "opencode");
|
||||
await chooseOption(orchestratorAgent, "goose");
|
||||
await chooseOption(permissionMode, "Bypass permissions");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1));
|
||||
expect(putMock).toHaveBeenCalledWith("/api/v1/projects/{id}/config", {
|
||||
params: { path: { id: "proj-1" } },
|
||||
body: {
|
||||
config: {
|
||||
defaultBranch: "release",
|
||||
sessionPrefix: "rel",
|
||||
env: { FOO: "bar" },
|
||||
symlinks: [".env"],
|
||||
postCreate: ["npm install"],
|
||||
worker: {
|
||||
agent: "opencode",
|
||||
agentConfig: { model: "worker-model" },
|
||||
},
|
||||
orchestrator: { agent: "goose" },
|
||||
agentConfig: {
|
||||
model: "gpt-5-codex",
|
||||
permissions: "bypass-permissions",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await screen.findByText("Saved.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the daemon validation message when save fails", async () => {
|
||||
getMock.mockResolvedValue({
|
||||
data: {
|
||||
status: "ok",
|
||||
project: {
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
kind: "single_repo",
|
||||
path: "/repo/project-one",
|
||||
repo: "",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
putMock.mockResolvedValue({
|
||||
data: undefined,
|
||||
error: { message: "invalid permissions" },
|
||||
});
|
||||
|
||||
renderSettings();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Save changes" }));
|
||||
|
||||
expect(await screen.findByText("invalid permissions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -12,8 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|||
type Project = components["schemas"]["Project"];
|
||||
type ProjectConfig = components["schemas"]["ProjectConfig"];
|
||||
|
||||
// Agents the daemon registers (see SpawnWorkerModal). Empty = "use the daemon
|
||||
// default". Kept short — the spawn modal owns the full list.
|
||||
// Agents the daemon registers. Empty = "use the daemon default".
|
||||
const AGENT_OPTIONS = ["claude-code", "codex", "opencode", "amp", "goose", "kiro"] as const;
|
||||
|
||||
const PERMISSION_MODE_OPTIONS = [
|
||||
|
|
@ -139,13 +138,13 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
placeholder="main"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Session branch prefix" htmlFor="sessionPrefix">
|
||||
<Field label="Session prefix" htmlFor="sessionPrefix">
|
||||
<input
|
||||
id="sessionPrefix"
|
||||
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-[13px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
|
||||
value={form.sessionPrefix}
|
||||
onChange={(e) => setForm((f) => ({ ...f, sessionPrefix: e.target.value }))}
|
||||
placeholder="ao/"
|
||||
placeholder="ao"
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
|
|
@ -156,11 +155,16 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
<CardTitle className="text-[13px]">Agents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Field label="Default worker agent">
|
||||
<AgentSelect value={form.workerAgent} onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))} />
|
||||
</Field>
|
||||
<Field label="Default orchestrator agent">
|
||||
<Field label="Default worker agent" htmlFor="workerAgent">
|
||||
<AgentSelect
|
||||
id="workerAgent"
|
||||
value={form.workerAgent}
|
||||
onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Default orchestrator agent" htmlFor="orchestratorAgent">
|
||||
<AgentSelect
|
||||
id="orchestratorAgent"
|
||||
value={form.orchestratorAgent}
|
||||
onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))}
|
||||
/>
|
||||
|
|
@ -174,8 +178,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
placeholder="(agent default)"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Permission mode">
|
||||
<Field label="Permission mode" htmlFor="permissionMode">
|
||||
<PermissionModeSelect
|
||||
id="permissionMode"
|
||||
value={form.permissions}
|
||||
onChange={(v) => setForm((f) => ({ ...f, permissions: v }))}
|
||||
/>
|
||||
|
|
@ -200,10 +205,18 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
|
|||
);
|
||||
}
|
||||
|
||||
function PermissionModeSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
function PermissionModeSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
||||
<SelectTrigger className="h-8 w-full text-[13px]">
|
||||
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -218,11 +231,11 @@ function PermissionModeSelect({ value, onChange }: { value: string; onChange: (v
|
|||
);
|
||||
}
|
||||
|
||||
function AgentSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
function AgentSelect({ id, value, onChange }: { id: string; value: string; onChange: (value: string) => void }) {
|
||||
// "" sentinel → daemon default; Select can't hold an empty value, so map it.
|
||||
return (
|
||||
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
||||
<SelectTrigger className="h-8 w-full text-[13px]">
|
||||
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
|||
const workspaceQuery = useWorkspaceQuery();
|
||||
const all = workspaceQuery.data ?? [];
|
||||
const workspaces = projectId ? all.filter((w) => w.id === projectId) : all;
|
||||
// Project board leads with the project's name; the cross-project board
|
||||
// (home) keeps the generic title.
|
||||
const boardTitle = (projectId && workspaces[0]?.name) || "Board";
|
||||
const sessions = workspaces.flatMap((w) => workerSessions(w.sessions));
|
||||
|
||||
const byZone = new Map<AttentionZone, WorkspaceSession[]>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import type { WorkspaceSummary } from "../types/workspace";
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({ navigateMock: vi.fn() }));
|
||||
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
useParams: () => ({}),
|
||||
useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) =>
|
||||
select({ location: { pathname: "/" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const workspace: WorkspaceSummary = {
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
path: "/repo/project-one",
|
||||
sessions: [],
|
||||
};
|
||||
|
||||
function renderSidebar(onRemoveProject = vi.fn().mockResolvedValue(undefined)) {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<Sidebar
|
||||
daemonStatus={{ state: "running" }}
|
||||
onCreateProject={vi.fn()}
|
||||
onRemoveProject={onRemoveProject}
|
||||
workspaces={[workspace]}
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
return onRemoveProject;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
vi.spyOn(window, "alert").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("confirms project removal before calling the remove handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemoveProject = renderSidebar();
|
||||
|
||||
await user.click(screen.getByLabelText("Project actions for Project One"));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Remove project" }));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
"Remove project Project One? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.",
|
||||
);
|
||||
await waitFor(() => expect(onRemoveProject).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("does not remove the project when confirmation is cancelled", async () => {
|
||||
vi.mocked(window.confirm).mockReturnValue(false);
|
||||
const user = userEvent.setup();
|
||||
const onRemoveProject = renderSidebar();
|
||||
|
||||
await user.click(screen.getByLabelText("Project actions for Project One"));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Remove project" }));
|
||||
|
||||
expect(onRemoveProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the worker count in every state that reveals project actions", () => {
|
||||
renderSidebar();
|
||||
|
||||
const projectRow = screen.getByText("Project One").closest("button");
|
||||
const count = screen.getByText("0");
|
||||
|
||||
if (!projectRow) throw new Error("Project row button not found");
|
||||
expect(projectRow).toHaveClass("group-hover/menu-item:pr-[34px]");
|
||||
expect(projectRow).toHaveClass("group-focus-within/menu-item:pr-[34px]");
|
||||
expect(projectRow).toHaveClass("group-has-data-[state=open]/menu-item:pr-[34px]");
|
||||
expect(count).toHaveClass("group-hover/menu-item:opacity-0");
|
||||
expect(count).toHaveClass("group-focus-within/menu-item:opacity-0");
|
||||
expect(count).toHaveClass("group-has-data-[state=open]/menu-item:opacity-0");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,16 @@
|
|||
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
|
||||
import { ChevronRight, GitPullRequest, Moon, Plus, Search, Settings, Sun, Waypoints } from "lucide-react";
|
||||
import {
|
||||
ChevronRight,
|
||||
GitPullRequest,
|
||||
Moon,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
Trash2,
|
||||
Waypoints,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
attentionZone,
|
||||
|
|
@ -97,6 +108,7 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
|
|||
const selection = useSelection();
|
||||
const eventsConnection = useEventsConnection();
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const toggleTheme = useUiStore((s) => s.toggleTheme);
|
||||
// Disclosure state: projects are expanded by default; a project id present in
|
||||
|
|
@ -202,6 +214,7 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
|
|||
onToggle={() => toggleCollapsed(workspace.id)}
|
||||
/>
|
||||
))}
|
||||
{isCollapsed && <CreateProjectListItem onCreateProject={onCreateProject} />}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
|
|
@ -211,8 +224,8 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
|
|||
{/* Footer (project-sidebar__footer) — single Settings menu. Divergence
|
||||
(user-requested 2026-06-10): the trigger stretches the full row width
|
||||
(flex-1) with a uniform 7px footer inset on all sides (reference uses
|
||||
12px top, 0 bottom, content-hugging button). The icon rail swaps it
|
||||
for the old rail footer: New project (+ expand toggle off macOS). */}
|
||||
12px top, 0 bottom, content-hugging button). The icon rail keeps the
|
||||
icon-only settings action plus expand toggle (off macOS). */}
|
||||
<SidebarFooter className="mt-auto gap-0 border-t border-border p-[7px] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5 group-data-[collapsible=icon]:pb-0 group-data-[collapsible=icon]:pt-2">
|
||||
<div className="relative flex w-full items-center group-data-[collapsible=icon]:hidden">
|
||||
<DropdownMenu>
|
||||
|
|
@ -273,7 +286,47 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
|
|||
</Tooltip>
|
||||
</div>
|
||||
<div className="hidden flex-col items-center gap-1 pb-3.5 group-data-[collapsible=icon]:flex">
|
||||
<CreateProjectButton onCreateProject={onCreateProject} />
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Settings"
|
||||
className="grid size-9 place-items-center rounded-lg text-passive transition-colors hover:bg-interactive-hover hover:text-foreground [&_svg]:size-4"
|
||||
type="button"
|
||||
>
|
||||
<Settings aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" className="min-w-0" side="top">
|
||||
<DropdownMenuItem onSelect={toggleTheme}>
|
||||
{theme === "dark" ? <Sun aria-hidden="true" /> : <Moon aria-hidden="true" />}
|
||||
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={selection.goPrs}>
|
||||
<GitPullRequest aria-hidden="true" />
|
||||
Pull requests
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>
|
||||
<Search aria-hidden="true" />
|
||||
Search
|
||||
<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{selection.activeProjectId && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => selection.goSettings(selection.activeProjectId!)}>
|
||||
<Settings aria-hidden="true" />
|
||||
Project settings
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isMac && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -324,6 +377,25 @@ function ProjectItem({
|
|||
}
|
||||
};
|
||||
|
||||
const removeProject = async () => {
|
||||
setRemoveError(null);
|
||||
const confirmed = window.confirm(
|
||||
`Remove project ${workspace.name}? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await onRemoveProject();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Could not remove project";
|
||||
setRemoveError(message);
|
||||
window.alert(message);
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
|
||||
{/* project-sidebar__proj-row */}
|
||||
|
|
@ -439,3 +511,45 @@ function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreatePr
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isChoosingPath, setIsChoosingPath] = useState(false);
|
||||
|
||||
const choosePath = async () => {
|
||||
setError(null);
|
||||
setIsChoosingPath(true);
|
||||
try {
|
||||
const selectedPath = await aoBridge.app.chooseDirectory();
|
||||
if (selectedPath) await onCreateProject({ path: selectedPath });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not add project");
|
||||
} finally {
|
||||
setIsChoosingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="New project"
|
||||
className="grid h-9 w-full place-items-center rounded-[5px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
|
||||
disabled={isChoosingPath}
|
||||
onClick={choosePath}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isChoosingPath ? "Opening…" : "New project"}</TooltipContent>
|
||||
</Tooltip>
|
||||
{error && (
|
||||
<span className="sr-only" role="status">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import { SpawnWorkerModal } from "./SpawnWorkerModal";
|
||||
import type { WorkspaceSummary } from "../types/workspace";
|
||||
|
||||
const workspaces: WorkspaceSummary[] = [{ id: "proj-1", name: "my-app", path: "/p", type: "main", sessions: [] }];
|
||||
|
||||
function renderModal(onCreateTask = vi.fn().mockResolvedValue(undefined), onOpenChange = () => undefined) {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SpawnWorkerModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
workspaces={workspaces}
|
||||
defaultProjectId="proj-1"
|
||||
onCreateTask={onCreateTask}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return onCreateTask;
|
||||
}
|
||||
|
||||
describe("SpawnWorkerModal", () => {
|
||||
// Regression: "Based on main" must NOT send branch:"main" — git refuses a
|
||||
// second worktree on a checked-out branch, so the daemon 409s. Omitting it
|
||||
// lets the daemon mint a fresh ao/<sessionId>.
|
||||
it("omits the base branch from the spawn payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateTask = renderModal();
|
||||
|
||||
await user.type(await screen.findByLabelText("Prompt"), "do the thing");
|
||||
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
|
||||
|
||||
expect(onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: "proj-1", prompt: "do the thing", branch: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a non-empty prompt before it can spawn", async () => {
|
||||
const onCreateTask = renderModal();
|
||||
expect(screen.getByRole("button", { name: /Spawn worker/ })).toBeDisabled();
|
||||
expect(onCreateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Regression: a failed spawn (e.g. 409 BRANCH_CHECKED_OUT_ELSEWHERE) must
|
||||
// keep the modal open with the daemon's message inline and the input intact,
|
||||
// disable submit only while in flight, and allow re-submitting.
|
||||
it("keeps the modal open and shows the daemon error when the spawn fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
let rejectSpawn!: (reason: Error) => void;
|
||||
const onCreateTask = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_, reject) => {
|
||||
rejectSpawn = reject;
|
||||
}),
|
||||
);
|
||||
renderModal(onCreateTask, onOpenChange);
|
||||
|
||||
await user.type(await screen.findByLabelText("Prompt"), "do the thing");
|
||||
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
|
||||
expect(screen.getByRole("button", { name: /Spawn worker/ })).toBeDisabled();
|
||||
|
||||
rejectSpawn(new Error("branch already checked out at ~/Projects/skills"));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("branch already checked out at ~/Projects/skills");
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Prompt")).toHaveValue("do the thing");
|
||||
|
||||
onCreateTask.mockResolvedValueOnce(undefined);
|
||||
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||
expect(onCreateTask).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { ChevronDown, CornerDownLeft, X } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import type { AgentProvider, WorkspaceSummary } from "../types/workspace";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const agentOptions: { value: AgentProvider; label: string }[] = [
|
||||
{ value: "claude-code", label: "claude-code" },
|
||||
{ value: "codex", label: "codex" },
|
||||
{ value: "opencode", label: "opencode" },
|
||||
{ value: "amp", label: "amp" },
|
||||
{ value: "goose", label: "goose" },
|
||||
{ value: "kiro", label: "kiro" },
|
||||
{ value: "kimi", label: "kimi" },
|
||||
{ value: "crush", label: "crush" },
|
||||
{ value: "vibe", label: "vibe" },
|
||||
];
|
||||
|
||||
const basedOnTabs = ["Branch", "Issue", "Pull Request"] as const;
|
||||
// The project's default branch — selecting it in "Based on" means "new session
|
||||
// branch off the default", not "check out the default branch itself".
|
||||
const BASE_BRANCH = "main";
|
||||
type BasedOn = (typeof basedOnTabs)[number];
|
||||
|
||||
const NAME_RULE = /^[a-z0-9-]+$/;
|
||||
|
||||
type SpawnWorkerModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaces: WorkspaceSummary[];
|
||||
defaultProjectId?: string;
|
||||
onCreateTask: (input: {
|
||||
projectId: string;
|
||||
prompt: string;
|
||||
branch?: string;
|
||||
harness?: AgentProvider;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
export function SpawnWorkerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaces,
|
||||
defaultProjectId,
|
||||
onCreateTask,
|
||||
}: SpawnWorkerModalProps) {
|
||||
const fallbackProjectId = defaultProjectId ?? workspaces[0]?.id ?? "";
|
||||
const [name, setName] = useState("");
|
||||
const [projectId, setProjectId] = useState(fallbackProjectId);
|
||||
const [agent, setAgent] = useState<AgentProvider>("claude-code");
|
||||
const [basedOn, setBasedOn] = useState<BasedOn>("Branch");
|
||||
const [branch, setBranch] = useState(BASE_BRANCH);
|
||||
const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset to the launching project each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setProjectId(fallbackProjectId);
|
||||
setError(null);
|
||||
}
|
||||
}, [open, fallbackProjectId]);
|
||||
|
||||
const selectedWorkspace = workspaces.find((workspace) => workspace.id === projectId) ?? workspaces[0];
|
||||
const branchOptions = Array.from(
|
||||
new Set([BASE_BRANCH, ...(selectedWorkspace?.sessions.map((session) => session.branch).filter(Boolean) ?? [])]),
|
||||
);
|
||||
const nameValid = name === "" || NAME_RULE.test(name);
|
||||
const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting;
|
||||
|
||||
const submit = async (event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// The API's `branch` field means "check out this exact branch in the
|
||||
// session worktree" — valid for resuming an existing session branch, but
|
||||
// never for the base branch itself (git refuses a second worktree on a
|
||||
// checked-out branch; daemon manager.go). "Based on main" therefore
|
||||
// OMITS branch so the daemon mints a fresh ao/<sessionId> off the
|
||||
// project's default branch.
|
||||
const trimmedBranch = branch.trim();
|
||||
await onCreateTask({
|
||||
projectId,
|
||||
prompt: prompt.trim(),
|
||||
branch:
|
||||
basedOn === "Branch" && trimmedBranch !== "" && trimmedBranch !== BASE_BRANCH ? trimmedBranch : undefined,
|
||||
harness: agent,
|
||||
});
|
||||
setName("");
|
||||
setPrompt("");
|
||||
setBranch(BASE_BRANCH);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not spawn worker");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/50 data-[state=open]:animate-overlay-in" />
|
||||
<Dialog.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed left-1/2 top-1/2 z-50 w-[512px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-xl bg-background text-foreground shadow-[0_24px_70px_rgb(0_0_0_/_0.55)] ring-1 ring-foreground/10 focus-visible:outline-none data-[state=open]:animate-modal-in"
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-[18px] py-[15px]">
|
||||
<Dialog.Title className="font-mono text-[11px] uppercase tracking-[0.14em] text-passive">
|
||||
New worker
|
||||
</Dialog.Title>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
className="ml-auto inline-flex text-passive transition-colors hover:text-foreground"
|
||||
>
|
||||
<X className="h-[15px] w-[15px]" aria-hidden="true" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-[15px] p-[18px]" onSubmit={submit}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
aria-label="Worker name"
|
||||
autoFocus
|
||||
className="w-full border-none bg-transparent p-0 text-lg font-medium text-foreground outline-none placeholder:text-passive"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="worker-name"
|
||||
value={name}
|
||||
/>
|
||||
<span className={cn("text-[11.5px]", nameValid ? "text-passive" : "text-error")}>
|
||||
Worker names allow letters, numbers, and hyphens.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SelectRow
|
||||
label="Project"
|
||||
aria-label="Project"
|
||||
onChange={setProjectId}
|
||||
value={projectId}
|
||||
options={workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name }))}
|
||||
/>
|
||||
<SelectRow
|
||||
label="Agent"
|
||||
aria-label="Agent"
|
||||
onChange={(value) => setAgent(value as AgentProvider)}
|
||||
value={agent}
|
||||
options={agentOptions}
|
||||
/>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center gap-2 border-b border-border px-2.5 py-1.5">
|
||||
<span className="text-[13px] text-muted-foreground">Based on</span>
|
||||
<div className="ml-auto inline-flex overflow-hidden rounded-md border border-border">
|
||||
{basedOnTabs.map((option) => (
|
||||
<button
|
||||
className={cn(
|
||||
"px-2.5 py-0.5 text-[11.5px] transition-colors",
|
||||
basedOn === option
|
||||
? "bg-raised text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
key={option}
|
||||
onClick={() => setBasedOn(option)}
|
||||
type="button"
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2.5">
|
||||
{basedOn === "Branch" ? (
|
||||
<SelectControl
|
||||
aria-label="Based on branch"
|
||||
className="flex w-full"
|
||||
onChange={setBranch}
|
||||
value={branch}
|
||||
options={branchOptions.map((option) => ({ value: option, label: option }))}
|
||||
/>
|
||||
) : (
|
||||
<p className="px-1 py-1.5 text-[12.5px] text-passive">
|
||||
{basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-1">
|
||||
{(["Prompt", "Workspace"] as const).map((option) => (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-[12.5px] transition-colors",
|
||||
tab === option ? "bg-raised text-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
key={option}
|
||||
onClick={() => setTab(option)}
|
||||
type="button"
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "Prompt" ? (
|
||||
<textarea
|
||||
aria-label="Prompt"
|
||||
className="min-h-[74px] w-full resize-none rounded-md border border-border bg-transparent px-2.5 py-2 text-[13px] text-foreground outline-none placeholder:text-passive focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-weak"
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="What should this worker do?"
|
||||
value={prompt}
|
||||
/>
|
||||
) : (
|
||||
<p className="rounded-md border border-border px-2.5 py-2 font-mono text-[12px] text-passive">
|
||||
~/.rc/wt/{selectedWorkspace?.name ?? "project"}/{name || "worker-name"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-[12px] text-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="-mx-[18px] -mb-[18px] flex justify-end border-t border-border bg-surface px-3.5 py-3">
|
||||
<Button disabled={!canSubmit} type="submit" variant="primary">
|
||||
Spawn worker
|
||||
<span className="ml-0.5 inline-flex items-center gap-0.5 rounded border border-accent-foreground/35 px-1 font-mono text-[10px] opacity-70">
|
||||
<CornerDownLeft className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectRow({
|
||||
label,
|
||||
"aria-label": ariaLabel,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
"aria-label": string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] text-muted-foreground">{label}</span>
|
||||
<div className="ml-auto">
|
||||
<SelectControl aria-label={ariaLabel} onChange={onChange} options={options} value={value} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectControl({
|
||||
"aria-label": ariaLabel,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
"aria-label": string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("relative inline-flex h-7 items-center rounded-md border border-border", className)}>
|
||||
<select
|
||||
aria-label={ariaLabel}
|
||||
className="h-full w-full cursor-pointer appearance-none bg-transparent pl-2.5 pr-7 text-[13px] text-foreground outline-none"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 h-[13px] w-[13px] text-passive" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { ChevronRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return <ol data-slot="breadcrumb-list" className={cn("flex min-w-0 items-baseline gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="breadcrumb-item" className={cn("inline-flex min-w-0 items-baseline", className)} {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-current="page"
|
||||
data-slot="breadcrumb-page"
|
||||
className={cn("truncate font-bold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
aria-hidden="true"
|
||||
data-slot="breadcrumb-separator"
|
||||
className={cn("inline-flex shrink-0 items-center text-passive [&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator };
|
||||
|
|
@ -6,7 +6,10 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn("flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", className)}
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border border-border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function SelectTrigger({
|
|||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-border-strong focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -54,7 +54,7 @@ function SelectContent({
|
|||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
import { createContext, useContext } from "react";
|
||||
import type { useDaemonStatus } from "../hooks/useDaemonStatus";
|
||||
import type { AgentProvider } from "../types/workspace";
|
||||
|
||||
// Shared state the persistent _shell layout owns and route content reads. The
|
||||
// daemon status effect (IPC poll + event transport) must run exactly once, so
|
||||
// it lives in the shell and is handed down here rather than re-run per route.
|
||||
export type ShellContextValue = {
|
||||
daemonStatus: ReturnType<typeof useDaemonStatus>;
|
||||
/** Open the spawn-worker modal, optionally pre-selecting a project. */
|
||||
openSpawn: (projectId?: string) => void;
|
||||
createProject: (input: { path: string }) => Promise<void>;
|
||||
createTask: (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => Promise<void>;
|
||||
};
|
||||
|
||||
const ShellContext = createContext<ShellContextValue | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, Outlet, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ShellTopbar } from "../components/ShellTopbar";
|
||||
import { Sidebar } from "../components/Sidebar";
|
||||
import { SidebarProvider } from "../components/ui/sidebar";
|
||||
import { SpawnWorkerModal } from "../components/SpawnWorkerModal";
|
||||
import { TitlebarNav } from "../components/TitlebarNav";
|
||||
import { useDaemonStatus } from "../hooks/useDaemonStatus";
|
||||
import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery";
|
||||
import { apiClient, apiErrorMessage } from "../lib/api-client";
|
||||
import { ShellProvider } from "../lib/shell-context";
|
||||
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
|
||||
import { toAgentProvider, toSessionStatus, type AgentProvider, type WorkspaceSummary } from "../types/workspace";
|
||||
import type { WorkspaceSummary } from "../types/workspace";
|
||||
|
||||
export const Route = createFileRoute("/_shell")({
|
||||
// Prefetch the workspace list for the whole shell (parent loaders run before
|
||||
|
|
@ -35,18 +34,12 @@ function errorMessage(error: unknown) {
|
|||
// instead of Zustand. The daemon-status effect runs here exactly once.
|
||||
function ShellLayout() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams({ strict: false }) as { projectId?: string };
|
||||
const queryClient = useQueryClient();
|
||||
const workspaceQuery = useWorkspaceQuery();
|
||||
const workspaces = workspaceQuery.data ?? [];
|
||||
const daemonStatus = useDaemonStatus(queryClient);
|
||||
const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore();
|
||||
const [spawnOpen, setSpawnOpen] = useState(false);
|
||||
const [spawnProjectId, setSpawnProjectId] = useState<string | undefined>(undefined);
|
||||
|
||||
const openSpawn = useCallback((projectId?: string) => {
|
||||
setSpawnProjectId(projectId);
|
||||
setSpawnOpen(true);
|
||||
}, []);
|
||||
|
||||
const updateWorkspaces = useCallback(
|
||||
(updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
|
||||
|
|
@ -74,49 +67,18 @@ function ShellLayout() {
|
|||
[navigate, updateWorkspaces],
|
||||
);
|
||||
|
||||
const createTask = useCallback(
|
||||
async (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => {
|
||||
const { data, error } = await apiClient.POST("/api/v1/sessions", {
|
||||
body: {
|
||||
projectId: input.projectId,
|
||||
kind: "worker",
|
||||
harness: input.harness,
|
||||
prompt: input.prompt,
|
||||
branch: input.branch || undefined,
|
||||
},
|
||||
});
|
||||
if (error || !data?.session) throw new Error(error ? apiErrorMessage(error) : "No session returned");
|
||||
const removeProject = useCallback(
|
||||
async (projectId: string) => {
|
||||
const { error } = await apiClient.DELETE("/api/v1/projects/{id}", { params: { path: { id: projectId } } });
|
||||
if (error) throw new Error(apiErrorMessage(error));
|
||||
|
||||
const session = data.session;
|
||||
updateWorkspaces((current) =>
|
||||
current.map((item) =>
|
||||
item.id === input.projectId
|
||||
? {
|
||||
...item,
|
||||
sessions: [
|
||||
{
|
||||
id: session.id,
|
||||
terminalHandleId: session.terminalHandleId,
|
||||
workspaceId: item.id,
|
||||
workspaceName: item.name,
|
||||
title: input.prompt,
|
||||
provider: toAgentProvider(session.harness),
|
||||
branch: input.branch ?? "",
|
||||
status: toSessionStatus(session.status, session.isTerminated),
|
||||
updatedAt: "now",
|
||||
},
|
||||
...item.sessions.filter((existing) => existing.id !== session.id),
|
||||
],
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
void navigate({
|
||||
to: "/projects/$projectId/sessions/$sessionId",
|
||||
params: { projectId: input.projectId, sessionId: session.id },
|
||||
});
|
||||
updateWorkspaces((current) => current.filter((item) => item.id !== projectId));
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
|
||||
if (params.projectId === projectId) {
|
||||
void navigate({ to: "/" });
|
||||
}
|
||||
},
|
||||
[navigate, updateWorkspaces],
|
||||
[navigate, params.projectId, queryClient, updateWorkspaces],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ select {
|
|||
|
||||
.resize-handle:hover::after,
|
||||
body.is-resizing-x .resize-handle::after {
|
||||
background: var(--color-accent);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
body.is-resizing-x {
|
||||
|
|
@ -484,7 +484,6 @@ body.is-resizing-x [data-slot="sidebar-container"] {
|
|||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.session-inspector__tabs {
|
||||
|
|
@ -573,7 +572,7 @@ body.is-resizing-x [data-slot="sidebar-container"] {
|
|||
.session-inspector__resize-handle:hover::after,
|
||||
.session-inspector__resize-handle:focus-visible::after,
|
||||
.session-inspector__resize-handle[data-separator="active"]::after {
|
||||
background: var(--accent);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* Collapse/expand animation for the inspector panel: rrp v4 drives panel
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ Object.defineProperty(window, "localStorage", {
|
|||
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
Element.prototype.hasPointerCapture = (() => false) as typeof Element.prototype.hasPointerCapture;
|
||||
Element.prototype.setPointerCapture = (() => undefined) as typeof Element.prototype.setPointerCapture;
|
||||
Element.prototype.releasePointerCapture = (() => undefined) as typeof Element.prototype.releasePointerCapture;
|
||||
Element.prototype.scrollIntoView = (() => undefined) as typeof Element.prototype.scrollIntoView;
|
||||
|
||||
window.ao = {
|
||||
app: {
|
||||
getVersion: async () => "0.0.0-test",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDaemonLaunch } from "./daemon-launch";
|
||||
|
||||
describe("resolveDaemonLaunch", () => {
|
||||
it("uses AO_DAEMON_COMMAND when configured", () => {
|
||||
expect(resolveDaemonLaunch({ AO_DAEMON_COMMAND: "/tmp/ao daemon" }, false, "/resources", "/app", "darwin")).toEqual(
|
||||
{
|
||||
command: "/tmp/ao daemon",
|
||||
args: [],
|
||||
cwd: "/app",
|
||||
shell: true,
|
||||
source: "configured",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the backend daemon from source in dev without an explicit command", () => {
|
||||
expect(resolveDaemonLaunch({}, false, "/resources", "/repo/frontend", "darwin")).toEqual({
|
||||
command: "go",
|
||||
args: ["run", "./cmd/ao", "daemon"],
|
||||
cwd: "/repo/frontend/../backend",
|
||||
shell: false,
|
||||
source: "dev",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the bundled daemon binary for packaged macOS/Linux builds", () => {
|
||||
expect(
|
||||
resolveDaemonLaunch({}, true, "/Applications/Agent Orchestrator.app/Contents/Resources", "/app", "darwin"),
|
||||
).toEqual({
|
||||
command: "/Applications/Agent Orchestrator.app/Contents/Resources/daemon/ao",
|
||||
args: ["daemon"],
|
||||
cwd: "/Applications/Agent Orchestrator.app/Contents/Resources",
|
||||
shell: false,
|
||||
source: "bundled",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the bundled daemon exe for packaged Windows builds", () => {
|
||||
expect(
|
||||
resolveDaemonLaunch(
|
||||
{},
|
||||
true,
|
||||
"C:\\Program Files\\AO\\resources",
|
||||
"C:\\Program Files\\AO\\resources\\app.asar",
|
||||
"win32",
|
||||
),
|
||||
).toEqual({
|
||||
command: "C:\\Program Files\\AO\\resources/daemon/ao.exe",
|
||||
args: ["daemon"],
|
||||
cwd: "C:\\Program Files\\AO\\resources",
|
||||
shell: false,
|
||||
source: "bundled",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
export type DaemonLaunchSpec = {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
shell: boolean;
|
||||
source: "configured" | "bundled" | "dev";
|
||||
};
|
||||
|
||||
function joinPath(...segments: string[]): string {
|
||||
return segments.map((segment) => segment.replace(/[/\\]+$/, "")).join("/");
|
||||
}
|
||||
|
||||
export function bundledDaemonBinaryName(platform: NodeJS.Platform): string {
|
||||
return platform === "win32" ? "ao.exe" : "ao";
|
||||
}
|
||||
|
||||
export function resolveDaemonLaunch(
|
||||
env: Record<string, string | undefined>,
|
||||
isPackaged: boolean,
|
||||
resourcesPath: string,
|
||||
appPath: string,
|
||||
platform: NodeJS.Platform,
|
||||
): DaemonLaunchSpec | null {
|
||||
const configuredCommand = env.AO_DAEMON_COMMAND?.trim();
|
||||
if (configuredCommand) {
|
||||
return {
|
||||
command: configuredCommand,
|
||||
args: [],
|
||||
cwd: appPath,
|
||||
shell: true,
|
||||
source: "configured",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPackaged) {
|
||||
return {
|
||||
command: "go",
|
||||
args: ["run", "./cmd/ao", "daemon"],
|
||||
cwd: joinPath(appPath, "..", "backend"),
|
||||
shell: false,
|
||||
source: "dev",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: joinPath(resourcesPath, "daemon", bundledDaemonBinaryName(platform)),
|
||||
args: ["daemon"],
|
||||
cwd: resourcesPath,
|
||||
shell: false,
|
||||
source: "bundled",
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue