Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Frontend pnpm workspace (build output + router cache)
frontend/node_modules/
frontend/**/dist/
frontend/**/.tanstack/

# Embedded web UI build output. The broad `dist/` rule above also matches this
# directory, so it is re-included first, its build artifacts ignored, then the
# committed placeholder shell + .gitkeep sentinel are restored. Keep this block
# below `dist/` — gitignore is order-sensitive and cannot un-ignore a file whose
# parent directory stays excluded.
!/backend/internal/webui/dist/
/backend/internal/webui/dist/*
!/backend/internal/webui/dist/.gitkeep
!/backend/internal/webui/dist/index.html

# Go
.go/
bin/
Expand Down
32 changes: 25 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Operational guidance for coding agents working in this repository. Keep changes

## Repo layout

- `backend/` — Go rewrite of Agent Orchestrator: Cobra `ao` CLI, loopback HTTP daemon, services, SQLite storage, lifecycle/reaper, runtime/workspace/agent/tracker adapters, terminal mux, and tests.
- `frontend/` — placeholder Electron + TypeScript shell. Treat it as a thin supervisor/UI surface; do not move daemon logic into it.
- `backend/` — Go rewrite of Agent Orchestrator: Cobra `ao` CLI, loopback HTTP daemon, services, SQLite storage, lifecycle/reaper, runtime/workspace/agent/tracker adapters, terminal mux, and tests. The built web UI is embedded at `backend/internal/webui` (`//go:embed dist`) and served as the daemon's catch-all SPA fallback.
- `frontend/` — pnpm workspace (`packageManager: pnpm@9.15.4`) for the React web UI rewrite. Packages: `apps/web` (the main SPA — Vite + React 19 + TanStack Router/Query + Tailwind v4), `apps/landing` (Vite landing scaffold), `packages/core` (pure TS, no React — types + API client), `packages/runtime` (React contexts/hooks, depends on `core`). `frontend/src/` is the legacy reference UI retained only for the `api-drift` (`src/api/schema.ts`) and `react-doctor` (`src/landing`) CI jobs — do not extend it; new UI lands under `apps/`/`packages/`. Treat the frontend as a thin UI surface; do not move daemon logic into it.
- `docs/` — current architecture/status notes. Start here before changing lifecycle, CLI, agents, storage, or daemon behavior.
- `test/` — external smoke/e2e assets, including the CLI fresh-install container check.
- `.github/workflows/` — CI definitions. Mirror these commands locally when possible.
Expand All @@ -15,13 +15,29 @@ Operational guidance for coding agents working in this repository. Keep changes
From the repo root unless noted:

```bash
npm run lint # backend go test ./... + golangci-lint v2.12.2
npm run frontend:typecheck # frontend TypeScript check
npm run lint # lint:backend (go test + golangci-lint v2.12.2) then lint:web (frontend eslint)
npm run lint:backend # backend go test ./... + golangci-lint v2.12.2 only
npm run frontend:typecheck # frontend workspace TypeScript check (delegates to pnpm)
npm run sqlc # regenerate backend/internal/storage/sqlite/gen from queries/schema
npm run api # regenerate OpenAPI spec + frontend TS types (see API contract changes below)
npx @redwoodjs/agent-ci run --all # local workflow validation; requires Docker socket
```

### Package managers: npm at the root, pnpm inside `frontend/`

The repo root stays on **npm** (`package-lock.json`, the `api`/`sqlc` toolchain).
The `frontend/` workspace is **pnpm only** (`pnpm@9.15.4`) — never run `npm` or
`yarn` inside `frontend/`. Root scripts are thin delegators into the workspace:

```bash
npm run web:dev # pnpm -C frontend --filter @aoagents/ao-web dev (boots the SPA)
npm run web:build # build the ao-web SPA
npm run web:test # ao-web unit tests (vitest)
npm run build:frontend # build ao-web + copy dist into backend/internal/webui/dist for go:embed
npm run lint:web # eslint across the frontend workspace
npm run format # prettier --write across the frontend workspace
```

Backend-specific checks:

```bash
Expand All @@ -33,12 +49,14 @@ go vet ./...
go run ./cmd/ao start
```

Frontend-specific checks:
Frontend-specific checks (pnpm only — run from `frontend/`):

```bash
cd frontend
npm run typecheck
npm run build
pnpm install # restore the workspace (writes pnpm-lock.yaml)
pnpm typecheck # tsc --noEmit across every workspace package
pnpm build # build all apps
pnpm --filter @aoagents/ao-web dev # boot the SPA dev server
```

## Where to look first
Expand Down
16 changes: 16 additions & 0 deletions backend/internal/httpd/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
"github.com/aoagents/agent-orchestrator/backend/internal/webui"
)

// ControlDeps carries the daemon-control hooks the router exposes, such as the
Expand Down Expand Up @@ -56,10 +57,25 @@ func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal
mountTerminalMux(r, termMgr, log)
mountControl(r, control)
NewAPI(cfg, deps).Register(r)
// The embedded web UI mounts last: it is the catch-all fallback, so every
// API, health, control, and terminal route claims its path first. Only
// paths that match none of them fall through to the static SPA shell. API
// 404s stay JSON because they resolve inside the /api/v1 subrouter and
// never reach this root-level wildcard.
mountWebUI(r)

return r
}

// mountWebUI registers the embedded single-page app as the root catch-all.
// It only handles GET/HEAD; other verbs on unmatched paths keep falling through
// to the JSON method/route handlers rather than being answered with the shell.
func mountWebUI(r chi.Router) {
h := webui.Handler()
r.Get("/*", h.ServeHTTP)
r.Head("/*", h.ServeHTTP)
}

// mountHealth registers the liveness and readiness probes the Electron
// supervisor polls before letting the renderer connect.
func mountHealth(r chi.Router) {
Expand Down
Empty file.
10 changes: 10 additions & 0 deletions backend/internal/webui/dist/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Agent Orchestrator</title>
</head>
<body>
frontend not built — run pnpm web:build
</body>
</html>
57 changes: 57 additions & 0 deletions backend/internal/webui/webui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Package webui serves the built single-page web UI from the daemon. The
// compiled Vite assets are embedded at build time so the daemon ships as a
// single binary with no runtime dependency on the frontend toolchain.
//
// The bundle is produced by `npm run build:frontend`, which runs the ao-web
// Vite build and copies frontend/apps/web/dist/* into dist/ here. When the
// frontend has not been built, dist/ holds only a placeholder index.html (plus
// .gitkeep) so the //go:embed directive still compiles.
package webui

import (
"embed"
"io/fs"
"net/http"
"strings"
)

//go:embed dist
var embedded embed.FS

// Handler returns an http.Handler that serves the embedded SPA.
//
// Static files resolve directly. Any path that does not map to an embedded file
// falls back to index.html so client-side (TanStack Router) deep links survive
// a hard refresh. The one exception is /assets/*: a miss there is a genuinely
// absent build artifact, so it returns 404 rather than masking the error by
// serving the HTML shell with a 200.
func Handler() http.Handler {
dist, err := fs.Sub(embedded, "dist")
if err != nil {
// dist is a compile-time embed; a failure here is a programming error,
// not a runtime condition, so failing loudly is correct.
panic(err)
}
fileServer := http.FileServer(http.FS(dist))

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/")
if name == "" {
name = "index.html"
}

if _, statErr := fs.Stat(dist, name); statErr != nil {
// Missing asset references are real 404s, not SPA routes.
if strings.HasPrefix(r.URL.Path, "/assets/") {
http.NotFound(w, r)
return
}
// Unknown non-asset path: serve the SPA shell so the client router
// can resolve the deep link. Rewrite onto a clone to avoid mutating
// the caller's request.
r = r.Clone(r.Context())
r.URL.Path = "/"
Comment on lines +52 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 After rewriting the path to "/", r.URL.RawPath is not cleared. If the original request carried a percent-encoded path (e.g. /foo%2Fbar), RawPath retains that value while Path is "/". http.URL.EscapedPath() — which some middleware and redirect logic calls — would then return the stale encoded value instead of "/". Setting RawPath = "" restores consistency so EscapedPath() falls back to Path.

Suggested change
r = r.Clone(r.Context())
r.URL.Path = "/"
r = r.Clone(r.Context())
r.URL.Path = "/"
r.URL.RawPath = ""

}
fileServer.ServeHTTP(w, r)
})
}
52 changes: 52 additions & 0 deletions backend/internal/webui/webui_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package webui

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

// An unknown, non-asset path must fall back to the embedded index.html (the SPA
// shell) so client-side router deep links survive a hard refresh.
func TestHandler_ServesIndexForUnknownPath(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/sessions/abc/deep/link", nil)

Handler().ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("unknown path: got status %d, want %d", rec.Code, http.StatusOK)
}
if body := rec.Body.String(); !strings.Contains(body, "frontend not built") {
t.Fatalf("unknown path: body did not serve the index shell, got %q", body)
}
}

// A missing asset is a genuinely absent build artifact, not a router route, so
// it must 404 rather than be masked by the HTML shell.
func TestHandler_NotFoundForMissingAsset(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/assets/missing.js", nil)

Handler().ServeHTTP(rec, req)

if rec.Code != http.StatusNotFound {
t.Fatalf("missing asset: got status %d, want %d", rec.Code, http.StatusNotFound)
}
}

// The root path serves the embedded index.html directly.
func TestHandler_ServesIndexForRoot(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)

Handler().ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("root: got status %d, want %d", rec.Code, http.StatusOK)
}
if body := rec.Body.String(); !strings.Contains(body, "frontend not built") {
t.Fatalf("root: body did not serve the index shell, got %q", body)
}
}
6 changes: 6 additions & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/dist/
**/node_modules/
**/.tanstack/
pnpm-lock.yaml
# Legacy reference UI — tooled separately (see eslint.config.js).
src/
6 changes: 6 additions & 0 deletions frontend/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100
}
12 changes: 12 additions & 0 deletions frontend/apps/landing/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Orchestrator — Landing</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
16 changes: 16 additions & 0 deletions frontend/apps/landing/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@aoagents/ao-landing",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "6.0.3",
"vite": "8.0.16"
}
}
6 changes: 6 additions & 0 deletions frontend/apps/landing/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Empty Vite landing scaffold (phase 0). Real content is ported from the
// existing Next.js landing in a later session.
const app = document.querySelector<HTMLDivElement>("#app");
if (app) {
app.textContent = "Agent Orchestrator landing — scaffold placeholder";
}
1 change: 1 addition & 0 deletions frontend/apps/landing/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
8 changes: 8 additions & 0 deletions frontend/apps/landing/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
10 changes: 10 additions & 0 deletions frontend/apps/landing/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";

// Empty Vite scaffold for the landing app. The existing Next.js landing under
// frontend/src/landing is intentionally NOT moved here this session (it is
// tied to the react-doctor CI job); its port to this app lands later.
export default defineConfig({
server: {
port: 5174,
},
});
12 changes: 12 additions & 0 deletions frontend/apps/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Orchestrator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
32 changes: 32 additions & 0 deletions frontend/apps/web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@aoagents/ao-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@aoagents/core": "workspace:*",
"@aoagents/runtime": "workspace:*",
"@tanstack/react-query": "5.101.0",
"@tanstack/react-router": "1.170.15",
"@xterm/xterm": "6.0.0",
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"tailwindcss": "4.3.0",
"typescript": "6.0.3",
"vite": "8.0.16",
"vitest": "4.1.8"
}
}
13 changes: 13 additions & 0 deletions frontend/apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Placeholder route component for phase 0. Real UI (layout, panes, terminal
// mux) lands in later phases — this only proves the Vite + React + Router +
// Query + Tailwind toolchain boots and renders.
export function ScaffoldingLive() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-2 font-sans">
<h1 className="text-2xl font-semibold">scaffolding live</h1>
<p className="text-sm opacity-70">
Agent Orchestrator web UI — phase 0 foundation. No components yet.
</p>
</main>
);
}
17 changes: 17 additions & 0 deletions frontend/apps/web/src/__tests__/router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";

import { router } from "../router";

// Phase 0 smoke test: the toolchain (Vite + Vitest + TanStack Router) is wired
// and the code-based route tree constructs without error. Behavioral UI tests
// arrive with the first real components.
describe("ao-web scaffolding", () => {
it("constructs the TanStack Router instance", () => {
expect(router).toBeDefined();
expect(router.routeTree).toBeDefined();
});

it("registers the index route on the tree", () => {
expect(router.routeTree.children).toBeDefined();
});
});
1 change: 1 addition & 0 deletions frontend/apps/web/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "tailwindcss";
Loading
Loading