Files
kandev/docs/specs/go-dev-launcher/spec.md
T
2026-08-13 12:11:48 +01:00

228 lines
12 KiB
Markdown

---
status: draft
created: 2026-08-07
---
# Go dev launcher and minimal Node surface
## Why
`make dev` is the last Kandev entrypoint that runs through the TypeScript launcher.
`make start` (Makefile:277) and every `make service-*` target already exec the native Go
binary at `apps/backend/bin/kandev`; only `make dev` still shells out to
`pnpm -C cli dev -- dev` (Makefile:195), which runs `tsx src/cli.ts`.
That single call site keeps roughly 3,000 lines of TypeScript alive under
`apps/cli/src/**` — `dev.ts`, plus `run.ts`, `start.ts`, `service/`, and `supervisor/`,
which are already dead code because their Go equivalents (`launcher.runInstalled`,
`launcher.runStart`, `launcher.runService`, `launcher/supervisor.go`) shipped and took
over the Makefile targets. None of that TypeScript is published: `apps/cli/package.json`
declares `files: ["bin/cli.js", "bin/native-shim.js"]`, so the npm `kandev` package is
already a four-line Node shim that execs the Go binary. The `src/` tree exists solely to
serve `make dev`.
The cost of keeping it is not just lines. The dev launch chain today is
`bash → make → pnpm → node → make → sh → kandev`, and that chain is why
`apps/backend/cmd/winjob` exists at all: MSYS bash, native Win32 processes, and Node
disagree on signal propagation, so Ctrl-C leaks processes on Windows unless the backend
is wrapped in a Job Object (`apps/cli/src/dev.ts:169-199`). The Go launcher already
solves this generically with `CREATE_NEW_PROCESS_GROUP` plus `taskkill /F /T`
(`launcher/process_group_windows.go`), so moving `dev` to Go removes the winjob
dependency from the dev path rather than reimplementing it.
Behavior also drifts between the two implementations. PR #2394 ("harden launcher port
ownership") had to land the same health-token and explicit-port-preflight fix twice —
once in `apps/cli/src/{args,health,ports,run,shared,dev}.ts` and once in
`apps/backend/internal/launcher/{env,health,ports,run,start}.go`. Every launcher change
pays that double-implementation tax until `dev` moves.
## What
### `kandev dev` becomes a native launcher command
The Go launcher gains a third command alongside `run` and `start`. It accepts the same
surface the TypeScript `dev` accepted:
| Input | Behavior |
| --- | --- |
| `dev`, `--dev` | Select dev mode. |
| `--port`, `--backend-port`, `KANDEV_BACKEND_PORT`, `KANDEV_PORT` | Backend port. Existing preflight applies: an explicitly requested occupied port is a hard error naming the port and its source. |
| `--web-internal-port`, `KANDEV_WEB_PORT` | Internal Vite port. Rejected outside dev mode. |
| `--web-port` | Rejected with the existing "use `--web-internal-port` for dev mode" message. |
| `--verbose`, `--debug`, `--headless`, `--no-browser` | Unchanged meaning. |
Omitted ports keep the current preferred-then-random selection (`38429` backend,
`37429` web, `39429` agentctl), and the web and agentctl ports must not collide with the
backend port or with each other.
### Dev state stays isolated from production
Dev mode roots Kandev at `<repoRoot>/.kandev-dev` so `make dev` never mutates the user's
`~/.kandev` state and so `make clean-db` (which removes `.kandev-dev/`) matches what
`make dev` writes. The repo root is discovered by walking up from the working directory
for a parent containing both `apps/backend` and `apps/web`; failing to find one is a
usage error, not a silent fallback.
`KANDEV_DATABASE_PATH` resolution keeps its current three-way rule:
- Inside a Kandev task workspace — signalled by `KANDEV_TASK_ID` being set, or by the
repo root living under `~/.kandev/tasks/` — any inherited `KANDEV_DATABASE_PATH` is
assumed to be leaked from the parent backend and is cleared, so the backend derives
its DB from `KANDEV_HOME_DIR`. The launcher prints that it detected a task workspace.
- Otherwise an explicit `KANDEV_DATABASE_PATH` is honored as an escape hatch.
- Otherwise the DB is `<repoRoot>/.kandev-dev/data/kandev.db`.
Dev mode always sets `KANDEV_DEBUG_DEV_MODE=true`, which is the profile selector; the
concrete dev defaults stay in `profiles.yaml`. The launcher must not restate them.
### Production databases are backed up before dev touches them
When the resolved DB path is not under a `.kandev-dev` segment, the launcher snapshots it
to `~/.kandev/data/backups/dev-prod-db-<timestamp>.db` before starting the backend,
retaining the five newest such snapshots and leaving other backup families
(`kandev-*`, `manual-*`) untouched. This is what makes `make dev-prod-db` safe.
A backup failure aborts startup with a non-zero exit. The guard exists precisely to
protect production state before dev mode touches it; continuing past a failed backup
would remove the guarantee that justified it.
### The Vite dev server is a supervised child
The launcher starts the frontend with `pnpm -C apps --filter @kandev/web dev`, passing
`PORT`, `HOSTNAME=127.0.0.1`, and `KANDEV_API_BASE_URL`, setting `KANDEV_DEBUG=true` and
`VITE_KANDEV_DEBUG=true`, and explicitly removing any inherited `VITE_KANDEV_API_PORT` so
a host `.env`, Docker env, or CI variable cannot reintroduce cross-origin browser API
calls. On Windows, `pnpm` is invoked through `cmd.exe /c` because Node-style `.cmd` shims
cannot be spawned directly.
Browser traffic still enters through the Go backend, which reverse-proxies to the Vite
port via `KANDEV_WEB_INTERNAL_URL`. The launcher waits for the backend health endpoint
(dev timeout: 600s, versus 45s for release) and then for the Vite URL to answer, then
opens the backend URL. The Vite readiness probe accepts any HTTP response and does not
require the launcher health token — Vite is not a Kandev process and does not emit one.
If either child exits, the launcher shuts the whole tree down and exits with the child's
code, matching current behavior.
### Startup output advertises network addresses
The startup banner for `dev`, `start`, and `run` prints the localhost backend URL and
then one `network:` URL for each unique non-loopback, non-link-local host address on the machine. This
includes addresses from local-network and Tailscale interfaces so that a user running
Kandev on a remote VM can identify an address to open from another machine.
The banner lists IPv4 addresses before IPv6 addresses, omits link-local addresses, and
wraps IPv6 hosts in brackets. Network-address discovery is best effort: if the host
cannot enumerate its interfaces, the launcher still starts and prints the localhost
URL. When `KANDEV_SERVER_HOST` restricts the backend to loopback or specific addresses,
the banner omits unreachable interface addresses and only advertises matching binds.
### Backend restart from the UI still rebuilds
The restart supervisor (ADR 0019) writes a launch manifest and listens on a control
socket so Settings can restart the backend. In dev that restart must rebuild from source,
which is what makes the affordance useful after editing Go code. The dev launcher
therefore keeps `make -C apps/backend dev` as the backend child command rather than
re-execing itself.
That creates a constraint the TypeScript launcher never had: the launcher and the backend
are the same binary (`bin/kandev`, dispatched by the `__backend` argument), so a rebuild
would overwrite the running launcher's own executable — which fails outright on Windows.
`make dev` therefore execs a copy of the freshly built binary from a distinct path, and
the copy supervises `make -C apps/backend dev`. The copy is a build artifact, not a
second shipped binary.
### `make dev` stops depending on Node and winjob
```makefile
dev: doctor
@$(MAKE) -C $(BACKEND_DIR) build-kandev
@cp bin/kandev → bin/kandev-launcher
@exec $(BACKEND_DIR)/bin/kandev-launcher dev $(DEV_FLAGS)
```
`build-kandev` is the only root prebuild: the copied supervisor binary does not need
agentctl helpers before it starts the supervised child. The child's `make -C apps/backend
dev` runs `dev: build-dev`, which builds the native agentctl and, on hosts other than
Linux/amd64, one `linux/amd64` helper, plus mock-agent, acpdbg, and winjob. Full
four-platform helper builds remain under `build-runtime` for release and service bundles.
`build-winjob` leaves the dev path: the Go launcher's process-group handling replaces it,
and the root `dev` target no longer invokes the winjob build step. `cmd/winjob` itself
stays in the tree for any other consumer. The lean `dev: build-dev` target still builds
winjob once because the backend's Windows process handling remains part of the local
development binary set.
### `apps/cli` shrinks to the published shim
After `dev` lands in Go, `apps/cli/src/**` has no consumer and is deleted in full,
together with `tsconfig.json` and `vitest.config.ts`. What remains is exactly what npm
publishes plus its test:
```text
apps/cli/
├── package.json
└── bin/
├── cli.js # 4-line exec of the native binary
├── native-shim.js # platform-package resolution
└── native-shim.test.mjs
```
`native-shim.test.mjs` moves from Vitest to the Node built-in test runner, so `apps/cli`
needs no `devDependencies` at all and `make test-cli` becomes `node --test`. The package
keeps its `name`, `version`, `bin`, `files`, and `optionalDependencies` untouched: the
npm release flow (`scripts/release/publish-npm.sh`, `.github/workflows/release.yml`)
reads and bumps `apps/cli/package.json` and must keep working unchanged.
Node remains required for exactly three things, and this spec does not attempt to remove
them: building and serving the web app (Vite), the published npm shim, and repo tooling
(prettier, commitlint, Playwright). That is the floor.
## Non-goals
- Removing Node from the web build or from `apps/web`. Vite is the dev server.
- Changing what the npm `kandev` package ships or how it resolves `@kdlbs/runtime-*`.
- Removing `apps/backend/cmd/winjob` from the repository.
- Changing `make start`, `make service-*`, or the desktop/Tauri launch path, all of
which already use the Go binary.
- Changing E2E backend spawning, which already execs `KANDEV_BIN __backend` directly
(`apps/web/e2e/fixtures/backend.ts:287`).
## Failure modes
| Condition | Expected behavior |
| --- | --- |
| Repo root not found from CWD | Exit 2 with a message telling the user to run from the repo. |
| Explicit backend or web port occupied | Exit 1 naming the port and its source (flag vs. env). No browser is opened. |
| Production DB backup fails | Exit 1 before the backend starts; the message names the DB path. |
| Backend fails health within 600s | Print the captured backend output, shut down the tree, exit 1. |
| Vite never becomes ready | Shut down the tree, exit 1. |
| Either child exits | Shut down the tree; exit with that child's code, or 0 if it was signalled. |
| `pnpm` missing | Surface the spawn error rather than hanging on the readiness probe. |
| Ctrl-C | Whole tree terminates, including on Windows, without winjob. |
| Network interface enumeration fails | Continue startup and omit `network:` lines; keep the localhost URL. |
## Persistence guarantees
- `.kandev-dev/` is the only state `make dev` writes, unless the user explicitly sets
`KANDEV_DATABASE_PATH` outside a task workspace.
- `dev-prod-db-*.db` snapshots are retained five deep in `~/.kandev/data/backups/` and
never prune other backup families.
- The supervisor manifest and control socket live under
`<KANDEV_HOME_DIR>/supervisor/` at mode `0600`, unchanged from `start`.
## Scenarios
- **GIVEN** a host with non-loopback LAN and Tailscale IPv4 addresses, **WHEN** the
`dev`, `start`, or `run` launcher prints its startup banner, **THEN** it prints one
`network:` URL for each address using the backend port.
- **GIVEN** a host with duplicate addresses, loopback addresses, or link-local
addresses, **WHEN** the startup banner is printed, **THEN** duplicate, loopback, and
link-local addresses are omitted.
- **GIVEN** a host with an IPv6 address, **WHEN** the startup banner is printed, **THEN**
the address is rendered as `http://[<address>]:<port>`.
- **GIVEN** interface enumeration returns an error, **WHEN** the launcher starts,
**THEN** it continues startup and prints the localhost URL without a `network:` line.
- **GIVEN** `KANDEV_SERVER_HOST` is a loopback or specific bind address, **WHEN** the
startup banner is printed, **THEN** it omits unrelated network addresses.