From da45983c4c6ba2a14c48badbbc16251899b50932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 20 Jun 2026 19:40:15 +0100 Subject: [PATCH] feat: add native kandev launcher (#1391) --- .github/workflows/release.yml | 47 +- Dockerfile | 36 +- Formula/kandev.rb | 19 +- Makefile | 31 +- apps/backend/Makefile | 6 +- apps/backend/cmd/kandev/main.go | 1626 +--------------- apps/backend/cmd/kandev/main_test.go | 66 + apps/backend/cmd/preview/build.go | 30 +- apps/backend/cmd/preview/deploy.go | 5 - apps/backend/cmd/preview/sprite_ops.go | 12 +- apps/backend/cmd/preview/sprite_ops_test.go | 4 +- .../backendapp}/adapters.go | 2 +- .../backendapp}/adapters_office.go | 2 +- .../backendapp}/adapters_test.go | 2 +- .../backendapp}/agentctl.go | 2 +- .../kandev => internal/backendapp}/agents.go | 2 +- .../backendapp}/boot_state.go | 2 +- .../backendapp}/boot_state_routes.go | 2 +- .../backendapp}/branch_materializer.go | 2 +- .../backendapp}/branch_materializer_test.go | 2 +- .../kandev => internal/backendapp}/cron.go | 2 +- .../backendapp}/e2e_reset.go | 2 +- .../kandev => internal/backendapp}/gateway.go | 2 +- .../kandev => internal/backendapp}/helpers.go | 2 +- .../backendapp}/helpers_test.go | 2 +- .../backendapp}/initial_agent.go | 2 +- apps/backend/internal/backendapp/main.go | 1664 +++++++++++++++++ .../backendapp}/metrics_provider.go | 2 +- .../backendapp}/metrics_provider_test.go | 2 +- .../backendapp}/middleware.go | 2 +- .../backendapp}/middleware_test.go | 2 +- .../backendapp}/orchestrator.go | 2 +- .../backendapp}/postgres_boot_test.go | 2 +- .../backendapp}/services.go | 2 +- .../kandev => internal/backendapp}/storage.go | 2 +- .../backendapp}/turn_adapters.go | 2 +- .../backendapp}/turn_adapters_test.go | 2 +- .../kandev => internal/backendapp}/types.go | 2 +- .../backendapp}/usage_adapter.go | 2 +- .../backendapp}/worktree.go | 2 +- .../backendapp}/worktree_test.go | 2 +- apps/backend/internal/launcher/bundle.go | 49 + apps/backend/internal/launcher/bundle_test.go | 56 + apps/backend/internal/launcher/cli/help.go | 28 + .../internal/launcher/cli/help_test.go | 19 + apps/backend/internal/launcher/constants.go | 38 + apps/backend/internal/launcher/data_dir.go | 41 + .../internal/launcher/data_dir_test.go | 27 + apps/backend/internal/launcher/doc.go | 5 + apps/backend/internal/launcher/env.go | 32 + apps/backend/internal/launcher/health.go | 50 + apps/backend/internal/launcher/launcher.go | 41 + apps/backend/internal/launcher/options.go | 146 ++ .../backend/internal/launcher/options_test.go | 59 + apps/backend/internal/launcher/ports.go | 91 + apps/backend/internal/launcher/ports_test.go | 13 + apps/backend/internal/launcher/process.go | 151 ++ .../launcher/process_group_default.go | 15 + .../internal/launcher/process_group_unix.go | 16 + .../launcher/process_group_unix_test.go | 17 + .../backend/internal/launcher/process_test.go | 33 + apps/backend/internal/launcher/run.go | 44 + apps/backend/internal/launcher/service.go | 609 ++++++ .../backend/internal/launcher/service_test.go | 324 ++++ apps/backend/internal/launcher/start.go | 133 ++ apps/backend/internal/launcher/start_test.go | 47 + apps/backend/internal/launcher/supervisor.go | 254 +++ .../internal/launcher/supervisor_test.go | 92 + apps/cli/README.md | 8 - apps/cli/README_internal.md | 58 +- apps/cli/bin/cli.js | 4 +- apps/cli/bin/native-shim.js | 94 + apps/cli/bin/native-shim.test.mjs | 65 + apps/cli/package.json | 18 +- apps/cli/src/args.test.ts | 34 +- apps/cli/src/args.ts | 29 +- apps/cli/src/cli.ts | 12 +- apps/cli/src/run.ts | 13 +- apps/cli/src/shared.ts | 27 +- apps/cli/src/start.ts | 7 +- apps/cli/vitest.config.ts | 2 +- apps/pnpm-lock.yaml | 13 +- apps/web/e2e/fixtures/backend.ts | 2 +- .../workflow/workflow-agent-switch.spec.ts | 36 +- docs/cli.md | 45 +- .../0018-runtime-settings-overrides.md | 6 +- docs/run-as-a-service.md | 6 +- docs/specs/INDEX.md | 1 + docs/specs/homebrew-core/spec.md | 8 +- docs/specs/native-kandev-cli/plan.md | 363 ++++ docs/specs/native-kandev-cli/spec.md | 155 ++ docs/test_e2e_web.md | 2 +- e2e.Dockerfile | 7 +- scripts/opencode-code-review | 83 +- scripts/opencode-code-review.test.py | 60 +- scripts/release/kandev.rb | 14 +- scripts/release/package-bundle.sh | 21 +- scripts/release/package-cli.sh | 49 - scripts/release/package-npm-runtime.sh | 8 +- 99 files changed, 5240 insertions(+), 2044 deletions(-) create mode 100644 apps/backend/cmd/kandev/main_test.go rename apps/backend/{cmd/kandev => internal/backendapp}/adapters.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/adapters_office.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/adapters_test.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/agentctl.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/agents.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/boot_state.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/boot_state_routes.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/branch_materializer.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/branch_materializer_test.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/cron.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/e2e_reset.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/gateway.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/helpers.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/helpers_test.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/initial_agent.go (98%) create mode 100644 apps/backend/internal/backendapp/main.go rename apps/backend/{cmd/kandev => internal/backendapp}/metrics_provider.go (98%) rename apps/backend/{cmd/kandev => internal/backendapp}/metrics_provider_test.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/middleware.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/middleware_test.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/orchestrator.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/postgres_boot_test.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/services.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/storage.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/turn_adapters.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/turn_adapters_test.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/types.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/usage_adapter.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/worktree.go (99%) rename apps/backend/{cmd/kandev => internal/backendapp}/worktree_test.go (99%) create mode 100644 apps/backend/internal/launcher/bundle.go create mode 100644 apps/backend/internal/launcher/bundle_test.go create mode 100644 apps/backend/internal/launcher/cli/help.go create mode 100644 apps/backend/internal/launcher/cli/help_test.go create mode 100644 apps/backend/internal/launcher/constants.go create mode 100644 apps/backend/internal/launcher/data_dir.go create mode 100644 apps/backend/internal/launcher/data_dir_test.go create mode 100644 apps/backend/internal/launcher/doc.go create mode 100644 apps/backend/internal/launcher/env.go create mode 100644 apps/backend/internal/launcher/health.go create mode 100644 apps/backend/internal/launcher/launcher.go create mode 100644 apps/backend/internal/launcher/options.go create mode 100644 apps/backend/internal/launcher/options_test.go create mode 100644 apps/backend/internal/launcher/ports.go create mode 100644 apps/backend/internal/launcher/ports_test.go create mode 100644 apps/backend/internal/launcher/process.go create mode 100644 apps/backend/internal/launcher/process_group_default.go create mode 100644 apps/backend/internal/launcher/process_group_unix.go create mode 100644 apps/backend/internal/launcher/process_group_unix_test.go create mode 100644 apps/backend/internal/launcher/process_test.go create mode 100644 apps/backend/internal/launcher/run.go create mode 100644 apps/backend/internal/launcher/service.go create mode 100644 apps/backend/internal/launcher/service_test.go create mode 100644 apps/backend/internal/launcher/start.go create mode 100644 apps/backend/internal/launcher/start_test.go create mode 100644 apps/backend/internal/launcher/supervisor.go create mode 100644 apps/backend/internal/launcher/supervisor_test.go create mode 100644 apps/cli/bin/native-shim.js create mode 100644 apps/cli/bin/native-shim.test.mjs create mode 100644 docs/specs/native-kandev-cli/plan.md create mode 100644 docs/specs/native-kandev-cli/spec.md delete mode 100755 scripts/release/package-cli.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec6d8f0416..e3775925ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,7 @@ name: release # Flow (one click → everything below): # 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z # 2. build-web — build Vite SPA bundle (shared across arches) -# 3. build-cli — build + esbuild-bundle CLI (shared across arches) -# 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms +# 3. build-bundles — assemble native bin/ tarballs for 5 platforms # 5. docker-{amd64,arm64} — native per-arch image builds (no QEMU), consuming # the prebuilt linux bundles from step 4 # 6. docker-manifest — stitch the two arch digests into the user-facing @@ -281,35 +280,9 @@ jobs: path: apps/web/dist include-hidden-files: true - build-cli: - name: Build & bundle CLI - needs: prepare - if: ${{ !inputs.dry_run }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ needs.prepare.outputs.tag }} - - uses: pnpm/action-setup@v5 - with: - version: "9.15.9" - - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: pnpm - cache-dependency-path: apps/pnpm-lock.yaml - - name: Install dependencies - run: pnpm -C apps install --frozen-lockfile - - name: Build and bundle CLI - run: bash scripts/release/package-cli.sh - - uses: actions/upload-artifact@v5 - with: - name: cli-bundle - path: dist/kandev/cli - build-bundles: name: Build platform bundle (${{ matrix.platform }}) - needs: [prepare, build-web, build-cli] + needs: [prepare, build-web] if: ${{ !inputs.dry_run }} strategy: matrix: @@ -352,10 +325,6 @@ jobs: with: name: web-bundle path: apps/backend/internal/webapp/embedded/generated - - uses: actions/download-artifact@v5 - with: - name: cli-bundle - path: dist/kandev/cli - name: Install cross-compilation tools if: matrix.cc @@ -403,18 +372,12 @@ jobs: - name: Package bundle run: | - rm -rf dist/kandev/bin dist/kandev/web + rm -rf dist/kandev mkdir -p dist/kandev/bin EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl-linux-amd64 dist/kandev/bin/ - # cli/ was already placed by the build-cli job (downloaded above). - # actions/upload-artifact + download-artifact zip-roundtrip strips the - # executable bit, so re-apply it. The Homebrew formula's wrapper at - # bin/kandev does `exec libexec/cli/bin/cli.js`, which fails with - # "Permission denied" if this isn't +x. - chmod +x dist/kandev/cli/bin/cli.js cd dist tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256" @@ -429,7 +392,7 @@ jobs: # --------------------------------------------------------------------------- # Docker base image — split per arch onto native runners, then stitched into # a multi-arch manifest list. Each arch consumes the matching linux bundle - # produced by build-bundles, so no Go/Next.js/pnpm work happens inside the + # produced by build-bundles, so no Go/Vite/pnpm work happens inside the # Docker build (the Dockerfile is reduced to apt-get + COPY). # --------------------------------------------------------------------------- docker-amd64: @@ -459,7 +422,7 @@ jobs: set -euo pipefail mkdir -p ctx/bundle # --strip-components=1 drops the top-level `kandev/` dir inside the - # tarball so ctx/bundle/ ends up with bin/, web/, cli/ directly. + # tarball so ctx/bundle/ ends up with bin/ directly. tar -xzf dist/bundle/kandev-linux-x64.tar.gz --strip-components=1 -C ctx/bundle cp docker-entrypoint.sh ctx/ diff --git a/Dockerfile b/Dockerfile index fa73135933..06fd3333d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # Single-stage build that consumes prebuilt artifacts. The release workflow # (.github/workflows/release.yml) extracts the per-arch release bundle # (`kandev-linux-{x64,arm64}.tar.gz`) into the build context, then this file -# just COPYs the binaries + web standalone + CLI into the runtime layout. +# just COPYs the native binaries into the runtime layout. # # Building this file outside CI (manual `docker build .`) will fail because # the `bundle/` directory isn't present in the build context. To build @@ -14,12 +14,7 @@ # Run: # docker run -p 38429:38429 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest -ARG PNPM_VERSION=9.15.9 - -FROM node:24-bookworm-slim - -ARG PNPM_VERSION -LABEL org.opencontainers.image.pnpm-version="${PNPM_VERSION}" +FROM debian:bookworm-slim # Install runtime dependencies. gh is included because the GitHub integration # (PR review, webhooks) shells out to it for auth fallback when GITHUB_TOKEN @@ -41,10 +36,10 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* && \ PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise -# Replace the node base image's default user so we own uid 1000. +# Create the runtime user with uid 1000. # Home is placed under /data so agent CLI auth state (gh, claude, codex, auggie, # copilot, amp, ...) lives on the PV and survives pod restarts and image upgrades. -RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -d /data/home -M kandev +RUN groupadd -r kandev && useradd -r -g kandev -u 1000 -d /data/home -M kandev # Install azure-devops extension under the runtime user's Azure config dir on /data. RUN mkdir -p /data/home/.azure && \ @@ -53,18 +48,13 @@ RUN mkdir -p /data/home/.azure && \ # Create app directory structure matching what `kandev start` expects: # /app/apps/backend/bin/kandev -# /app/apps/web/.next/standalone/web/server.js -RUN mkdir -p /app/apps/backend/bin /app/apps/web/.next/standalone /usr/local/lib/kandev-cli /data/worktrees +RUN mkdir -p /app/apps/backend/bin /data/worktrees # Build context layout (prepared by the release workflow from the extracted # bundle tarball): # bundle/bin/kandev - native Go backend (per-arch) # bundle/bin/agentctl - native agentctl (per-arch) # bundle/bin/agentctl-linux-amd64 - amd64 agentctl helper (always amd64) -# bundle/web/... - Next.js standalone + static + public -# bundle/cli/bin/cli.js - CLI entrypoint (calls cli.bundle.js) -# bundle/cli/dist/cli.bundle.js - self-contained CLI bundle (deps inlined) -# bundle/cli/package.json - package metadata (version, etc.) # # The bundle's `bin/agentctl-linux-amd64` variant is bind-mounted into # Docker-executor sandboxes by the lifecycle manager; ship it next to kandev @@ -72,20 +62,16 @@ RUN mkdir -p /app/apps/backend/bin /app/apps/web/.next/standalone /usr/local/lib COPY bundle/bin/kandev /app/apps/backend/bin/kandev COPY bundle/bin/agentctl-linux-amd64 /app/apps/backend/bin/agentctl-linux-amd64 COPY bundle/bin/agentctl /usr/local/bin/agentctl -COPY bundle/web/ /app/apps/web/.next/standalone/ -COPY bundle/cli/ /usr/local/lib/kandev-cli/ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -# Re-apply executable bits stripped by tar/COPY edge cases, then link the -# CLI launcher onto PATH. The CLI's deps are inlined into dist/cli.bundle.js -# by the release bundle step, so no `npm install` is needed here. +# Re-apply executable bits stripped by tar/COPY edge cases, then link the native +# launcher onto PATH. RUN chmod +x \ /app/apps/backend/bin/kandev \ /app/apps/backend/bin/agentctl-linux-amd64 \ /usr/local/bin/agentctl \ - /usr/local/lib/kandev-cli/bin/cli.js \ /usr/local/bin/docker-entrypoint.sh && \ - ln -s /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev && \ + ln -s /app/apps/backend/bin/kandev /usr/local/bin/kandev && \ chown -R kandev:kandev /app /data # Kandev home directory (DB, worktrees, sessions, repos) @@ -105,10 +91,10 @@ ENV KANDEV_NO_BROWSER=1 \ WORKDIR /app -# Only the backend port is exposed — it reverse-proxies the Next.js frontend -# (which listens on 37429 internally), so users hit a single port. +# Only the backend port is exposed; the Go backend serves API, WebSocket, and +# embedded SPA assets on the same listener. EXPOSE 38429 # tini as PID 1 for signal handling; entrypoint handles privilege drop ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"] -CMD ["kandev", "start", "--backend-port", "38429", "--web-port", "37429", "--verbose"] +CMD ["kandev", "start", "--backend-port", "38429", "--verbose"] diff --git a/Formula/kandev.rb b/Formula/kandev.rb index 1179610e97..e1528969c0 100644 --- a/Formula/kandev.rb +++ b/Formula/kandev.rb @@ -12,9 +12,7 @@ class Kandev < Formula depends_on "go" => :build depends_on "pnpm" => :build - depends_on "node" - uses_from_macos "rsync" => :build uses_from_macos "sqlite" def install @@ -22,8 +20,7 @@ class Kandev < Formula system "pnpm", "-C", "apps", "install", "--frozen-lockfile" system "pnpm", "-C", "apps", "--filter", "@kandev/web", "build" - system "./scripts/release/package-web.sh" - system "./scripts/release/package-cli.sh" + system "make", "sync-embedded-web" bundle = buildpath/"dist/kandev" (bundle/"bin").mkpath @@ -32,6 +29,7 @@ class Kandev < Formula # kandev backend needs cgo for mattn/go-sqlite3. with_env(CGO_ENABLED: "1") do system "go", "build", + "-tags", "fts5", *std_go_args(ldflags: "-s -w -X main.Version=#{version}", output: bundle/"bin/kandev"), "./cmd/kandev" @@ -47,20 +45,9 @@ class Kandev < Formula system "./scripts/release/package-bundle.sh" - # The Next.js standalone tracer pulls platform-tagged native modules - # into the bundle, including musl-libc variants of sharp, @swc/core, - # and lightningcss that brew linkage --test flags on glibc-only - # Linuxbrew. Strip them — rm_r handles both directory trees (the - # current shape) and bare .node files (in case a future bundler - # version inlines them). The exist? guard handles the glob - # returning a parent dir and its musl-named children together — - # removing the parent makes the child paths vanish before we get - # to them. - bundle.glob("web/**/*musl*").each { |p| rm_r(p) if p.exist? } - libexec.install Dir[bundle/"*"] - (bin/"kandev").write_env_script libexec/"cli/bin/cli.js", + (bin/"kandev").write_env_script libexec/"bin/kandev", KANDEV_BUNDLE_DIR: libexec.to_s, KANDEV_VERSION: version.to_s end diff --git a/Makefile b/Makefile index ac3639d3dd..19727f80f3 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ MAGENTA := \033[35m VERBOSE ?= 0 NODE ?= $(shell command -v node 2>/dev/null || echo node) -SERVICE_CLI := $(CURDIR)/dist/kandev/cli/bin/cli.js +SERVICE_LAUNCHER := $(CURDIR)/dist/kandev/bin/kandev SERVICE_BUNDLE_DIR := $(CURDIR)/dist/kandev SERVICE_VERSION := $(shell git rev-parse --short HEAD 2>/dev/null || echo dev) SERVICE_ENV := KANDEV_BUNDLE_DIR="$(SERVICE_BUNDLE_DIR)" KANDEV_VERSION="$(SERVICE_VERSION)" @@ -65,11 +65,11 @@ help: @echo "Development Commands:" @echo " bootstrap Install mise tools, workspace deps, and git hooks" @echo " bootstrap-e2e Bootstrap plus Playwright browser/system deps" - @echo " dev Run backend + web via CLI (auto ports)" + @echo " dev Run backend + web via local CLI (auto ports)" @echo " dev-prod-db Run dev mode against the production db at ~/.kandev" @echo " dev-backend Run backend in development mode (port 38429)" @echo " dev-web Run web app in development mode (port 37429)" - @echo " dev Note: Uses apps/cli launcher (auto ports)" + @echo " dev Note: Uses local development launcher (auto ports)" @echo " doctor Idempotently wire up pre-commit hooks (runs automatically before dev)" @echo "" @echo "Production Commands:" @@ -214,7 +214,7 @@ start: @$(MAKE) -s build-backend-quiet $(call success,Build complete) $(call phase,Starting Server) - @cd $(APPS_DIR) && $(PNPM) -C cli dev -- start $(if $(filter 1 true yes,$(VERBOSE)),--verbose,) $(if $(filter 1 true yes,$(DEBUG)),--debug,) + @$(BACKEND_DIR)/bin/kandev start $(if $(filter 1 true yes,$(VERBOSE)),--verbose,) $(if $(filter 1 true yes,$(DEBUG)),--debug,) .PHONY: start-verbose start-verbose: @@ -231,7 +231,6 @@ start-debug: .PHONY: service-bundle service-bundle: install build $(call phase,Packaging Service Bundle) - @scripts/release/package-cli.sh @test -n "$(SERVICE_BUNDLE_DIR)" || { echo "SERVICE_BUNDLE_DIR is empty; aborting."; exit 1; } @test "$(SERVICE_BUNDLE_DIR)" != "/" || { echo "SERVICE_BUNDLE_DIR must not be /; aborting."; exit 1; } @test -f "$(BACKEND_DIR)/bin/agentctl-linux-amd64" || $(MAKE) -C $(BACKEND_DIR) build-agentctl-linux @@ -243,40 +242,40 @@ service-bundle: install build .PHONY: service-cli-check service-cli-check: - @test -f "$(SERVICE_CLI)" || { echo "Missing $(SERVICE_CLI). Run 'make service-install' first."; exit 1; } + @test -f "$(SERVICE_LAUNCHER)" || { echo "Missing $(SERVICE_LAUNCHER). Run 'make service-install' first."; exit 1; } .PHONY: service-install service-install: service-bundle - @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service install $(SERVICE_INSTALL_FLAGS) + @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service install $(SERVICE_INSTALL_FLAGS) .PHONY: service-install-system service-install-system: service-bundle - @sudo env $(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service install --system $(SERVICE_INSTALL_FLAGS) + @sudo env $(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service install --system $(SERVICE_INSTALL_FLAGS) .PHONY: service-uninstall service-start service-stop service-restart service-status service-logs service-logs-follow service-config service-uninstall: service-cli-check - @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service uninstall + @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service uninstall service-start: service-cli-check - @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service start + @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service start service-stop: service-cli-check - @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service stop + @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service stop service-restart: service-cli-check - @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service restart + @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service restart service-status: service-cli-check - @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service status + @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service status service-logs: service-cli-check - @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service logs + @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service logs service-logs-follow: service-cli-check - @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service logs -f + @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service logs -f service-config: service-cli-check - @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service config + @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service config .PHONY: build-backend build-backend: diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 30b6825718..44a8b4b982 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -126,17 +126,17 @@ clean: ## Run the application run: build @echo "Running $(BINARY_NAME)..." - ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) + ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) __backend ## Run in development mode (with pprof enabled) dev: build @echo "Running $(BINARY_NAME) in development mode..." - @KANDEV_DEBUG_PPROF_ENABLED=true exec ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) + @KANDEV_DEBUG_PPROF_ENABLED=true exec ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) __backend ## Run in debug mode (pprof + debug logging) start-debug: build @echo "Running $(BINARY_NAME) in debug mode (pprof + debug logging)..." - @KANDEV_DEBUG_PPROF_ENABLED=true KANDEV_LOG_LEVEL=debug exec ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) + @KANDEV_DEBUG_PPROF_ENABLED=true KANDEV_LOG_LEVEL=debug exec ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) __backend ## Run tests test: diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 04832f9359..249ef7f73a 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -1,1627 +1,49 @@ -// Package main is the unified entry point for Kandev. -// This single binary runs all services together with shared infrastructure. -// The server exposes WebSocket and HTTP endpoints. package main import ( - "context" - "flag" - "fmt" - "net" - "net/http" "os" - "os/signal" - "path/filepath" - "sync/atomic" - "syscall" - "time" - "github.com/gin-gonic/gin" - "github.com/kandev/kandev/internal/common/httpmw" - "go.uber.org/zap" - - // Common packages - "github.com/kandev/kandev/internal/common/config" - "github.com/kandev/kandev/internal/common/logger" - - // Event bus - "github.com/kandev/kandev/internal/events" - "github.com/kandev/kandev/internal/events/bus" - - // GitHub integration - githubpkg "github.com/kandev/kandev/internal/github" - gitlabpkg "github.com/kandev/kandev/internal/gitlab" - - // JIRA integration - jirapkg "github.com/kandev/kandev/internal/jira" - linearpkg "github.com/kandev/kandev/internal/linear" - sentrypkg "github.com/kandev/kandev/internal/sentry" - slackpkg "github.com/kandev/kandev/internal/slack" - - // Agent infrastructure - "github.com/kandev/kandev/internal/agent/hostutility" - "github.com/kandev/kandev/internal/agent/mcpconfig" - "github.com/kandev/kandev/internal/agent/registry" - agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" - "github.com/kandev/kandev/internal/agent/runtime/lifecycle" - runtimeskill "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill" - agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" - settingsstore "github.com/kandev/kandev/internal/agent/settings/store" - - // WebSocket gateway - gateways "github.com/kandev/kandev/internal/gateway/websocket" - - editorcontroller "github.com/kandev/kandev/internal/editors/controller" - notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" - promptcontroller "github.com/kandev/kandev/internal/prompts/controller" - usercontroller "github.com/kandev/kandev/internal/user/controller" - utilitycontroller "github.com/kandev/kandev/internal/utility/controller" - - // Orchestrator - "github.com/kandev/kandev/internal/office/configloader" - officeservice "github.com/kandev/kandev/internal/office/service" - "github.com/kandev/kandev/internal/orchestrator" - v1 "github.com/kandev/kandev/pkg/api/v1" - - // Office feature packages - office "github.com/kandev/kandev/internal/office" - officeagents "github.com/kandev/kandev/internal/office/agents" - officeapprovals "github.com/kandev/kandev/internal/office/approvals" - officechannels "github.com/kandev/kandev/internal/office/channels" - officeconfig "github.com/kandev/kandev/internal/office/config" - officecosts "github.com/kandev/kandev/internal/office/costs" - officemodelsdev "github.com/kandev/kandev/internal/office/costs/modelsdev" - officedashboard "github.com/kandev/kandev/internal/office/dashboard" - officeinfra "github.com/kandev/kandev/internal/office/infra" - officelabels "github.com/kandev/kandev/internal/office/labels" - officemodels "github.com/kandev/kandev/internal/office/models" - officeonboarding "github.com/kandev/kandev/internal/office/onboarding" - officeprojects "github.com/kandev/kandev/internal/office/projects" - officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" - officeroutines "github.com/kandev/kandev/internal/office/routines" - "github.com/kandev/kandev/internal/office/routing" - officescheduler "github.com/kandev/kandev/internal/office/scheduler" - officeshared "github.com/kandev/kandev/internal/office/shared" - officeskills "github.com/kandev/kandev/internal/office/skills" - officewakeup "github.com/kandev/kandev/internal/office/wakeup" - orchexecutor "github.com/kandev/kandev/internal/orchestrator/executor" - - // Runs queue (Phase 3 of task-model-unification) - runsscheduler "github.com/kandev/kandev/internal/runs/scheduler" - runsservice "github.com/kandev/kandev/internal/runs/service" - - // Workflow engine adapters (Phase 3.2 of task-model-unification) - officeengineadapters "github.com/kandev/kandev/internal/office/engine_adapters" - officeenginedispatcher "github.com/kandev/kandev/internal/office/engine_dispatcher" - workflowadapters "github.com/kandev/kandev/internal/workflow/adapters" - workflowengine "github.com/kandev/kandev/internal/workflow/engine" - - tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" - taskservice "github.com/kandev/kandev/internal/task/service" - workflowservice "github.com/kandev/kandev/internal/workflow/service" - - // Repository cloning - "github.com/kandev/kandev/internal/repoclone" - "github.com/kandev/kandev/internal/runtimeflags" - - // Secrets - "github.com/kandev/kandev/internal/secrets" - - // System pages (status / database / backups / logs / updates / about) - systemsvc "github.com/kandev/kandev/internal/system" - - // Database - "github.com/kandev/kandev/internal/db" - - "github.com/kandev/kandev/internal/common/ports" -) - -// Command-line flags -var ( - flagPort = flag.Int("port", 0, fmt.Sprintf("HTTP server port (default: %d)", ports.Backend)) - flagLogLevel = flag.String("log-level", "", "Log level: debug, info, warn, error") - flagHelp = flag.Bool("help", false, "Show help message") - flagVersion = flag.Bool("version", false, "Show version information") + "github.com/kandev/kandev/internal/backendapp" + "github.com/kandev/kandev/internal/launcher" ) // Build-time variables injected via -ldflags "-X main.Version=... -X main.Commit=... -X main.BuildTime=..." -// (see apps/backend/Makefile). Defaults apply when running un-stamped builds (e.g. `go run`). +// (see apps/backend/Makefile). Defaults apply when running un-stamped builds. var ( Version = "dev" Commit = "unknown" BuildTime = "unknown" ) -// ready is the readiness flag consulted by the GET /health handler. -// Until it flips true, /health returns 503 — so callers polling for -// readiness (Playwright fixtures, container orchestrators, manual -// curl loops) keep waiting instead of racing ahead. main() flips it -// after every route is mounted, the agent registry is seeded, and -// the HTTP listener is accepting connections. -var ready atomic.Bool - -func init() { - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: kandev [options]\n\n") - fmt.Fprintf(os.Stderr, "Kandev is an AI-powered development task orchestrator.\n\n") - fmt.Fprintf(os.Stderr, "Options:\n") - flag.PrintDefaults() - fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " kandev # Start with default settings\n") - fmt.Fprintf(os.Stderr, " kandev -port=18080 -log-level=debug # Custom port and log level\n") - } +type buildInfo struct { + Version string + Commit string + BuildTime string } -// main parses flags and delegates to realMain, using os.Exit with its return code. -// os.Exit is only called from main() before any defers are registered, so that -// deferred cleanup functions inside realMain() always execute. +type backendRunner func(args []string, build backendapp.BuildInfo) int +type launcherRunner func(args []string, build launcher.BuildInfo) int + func main() { - os.Exit(realMain()) + os.Exit(run(os.Args[1:])) } -// realMain contains all startup logic and returns 0 on success or 1 on fatal error. -// Deferred cleanup is registered here so it always executes before realMain returns. -func realMain() int { - flag.Parse() - - if *flagHelp { - flag.Usage() - return 0 - } - - if *flagVersion { - fmt.Printf("kandev version %s (commit %s, built %s)\n", Version, Commit, BuildTime) - return 0 - } - - // 1. Load configuration - cfg, err := config.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) - return 1 - } - - // Apply command-line flag overrides (flags take precedence over config/env) - if *flagPort > 0 { - cfg.Server.Port = *flagPort - } - if *flagLogLevel != "" { - cfg.Logging.Level = *flagLogLevel - } - - // 2. Initialize logger - log, err := logger.NewLogger(logger.LoggingConfig{ - Level: cfg.Logging.Level, - Format: cfg.Logging.Format, - OutputPath: cfg.Logging.OutputPath, - MaxSizeMB: cfg.Logging.MaxSizeMB, - MaxBackups: cfg.Logging.MaxBackups, - MaxAgeDays: cfg.Logging.MaxAgeDays, - Compress: cfg.Logging.Compress, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) - return 1 - } - - cleanups := make([]func() error, 0) - cleanupsRan := false - runCleanups := func() { - if cleanupsRan { - return - } - cleanupsRan = true - for i := len(cleanups) - 1; i >= 0; i-- { - if cleanups[i] == nil { - continue - } - if err := cleanups[i](); err != nil { - log.Warn("cleanup failed", zap.Error(err)) - } - } - } - defer func() { - runCleanups() - _ = log.Close() - }() - logger.SetDefault(log) - - log.Info("Starting Kandev (unified mode)...", - zap.String("db_path", cfg.Database.Path), - ) - - if !run(cfg, log, &cleanups, runCleanups) { - return 1 - } - return 0 +func run(args []string) int { + build := buildInfo{Version: Version, Commit: Commit, BuildTime: BuildTime} + return dispatch(args, build, backendapp.Run, launcher.Run) } -// run initializes all services and runs the server. Returns false on fatal startup error. -func run(cfg *config.Config, log *logger.Logger, cleanups *[]func() error, runCleanups func()) bool { - addCleanup := func(fn func() error) { *cleanups = append(*cleanups, fn) } - - // 3. Create context with cancellation - ctx, cancel := context.WithCancel(context.Background()) - addCleanup(func() error { cancel(); return nil }) - - // 4. Initialize event bus (in-memory for unified mode, or NATS if configured) - eventBusProvider, cleanup, err := events.Provide(cfg, log) - if err != nil { - log.Error("Failed to initialize event bus", zap.Error(err)) - return false - } - addCleanup(cleanup) - eventBus := eventBusProvider.Bus - - return startServices(ctx, cfg, log, addCleanup, eventBus, runCleanups) -} - -func applyStartupRuntimeFlags(ctx context.Context, cfg *config.Config, repos *Repositories, log *logger.Logger) bool { - if repos.RuntimeFlags == nil { - return true - } - svc := runtimeflags.NewService(repos.RuntimeFlags, runtimeflags.OptionsFromConfig(cfg)) - states, err := svc.ListStates(ctx) - if err != nil { - log.Error("Failed to resolve runtime flag overrides", zap.Error(err)) - return false - } - runtimeflags.ApplyStatesToConfig(cfg, states) - return true -} - -// startServices initializes task-level services and all downstream infrastructure. -func startServices( //nolint:cyclop - ctx context.Context, - cfg *config.Config, - log *logger.Logger, - addCleanup func(func() error), - eventBus bus.EventBus, - runCleanups func(), -) bool { - // ============================================ - // TASK SERVICE - // ============================================ - log.Info("Initializing Task Service...") - - dbPool, repos, repoCleanups, err := provideRepositories(cfg, log, Version) - if err != nil { - log.Error("Failed to initialize repositories", zap.Error(err)) - return false - } - for _, c := range repoCleanups { - addCleanup(c) - } - - runtimeFlagDefaults := runtimeflags.OptionsFromConfig(cfg).DefaultValues - if !applyStartupRuntimeFlags(ctx, cfg, repos, log) { - return false - } - - agentRegistry, _, err := registry.Provide(log) - if err != nil { - log.Error("Failed to initialize agent registry", zap.Error(err)) - return false - } - - services, agentSettingsController, err := provideServices(cfg, log, repos, dbPool, eventBus, agentRegistry, Version) - if err != nil { - log.Error("Failed to initialize services", zap.Error(err)) - return false - } - services.RuntimeFlags = runtimeflags.NewService( - repos.RuntimeFlags, - runtimeflags.RuntimeOptionsFromAppliedConfig(runtimeFlagDefaults, cfg), - ) - log.Info("Task Service initialized") - - if err := runInitialAgentSetup(ctx, services.User, agentSettingsController, log); err != nil { - // Agent registry seeding is a hard prerequisite for every - // HTTP surface that lists or operates on agents — including - // the e2e harness, the office onboarding wizard, and the - // task-create dialog. Letting startup continue with an empty - // registry produces silent flakes that look like "no agent - // profile available" with no log trail at the failure site. - // Fail loudly so the cause shows up in the backend log - // instead of cascading into downstream UI confusion. - log.Error("Failed to run initial agent setup — aborting startup", zap.Error(err)) - return false - } - log.Info("ACP messages will be stored as comments") - - // ============================================ - // AGENTCTL LAUNCHER (for standalone mode) - // ============================================ - agentctlResult, err := provideAgentctlLauncher(ctx, cfg, log) - if err != nil { - log.Error("Failed to start agentctl subprocess", zap.Error(err)) - return false - } - var agentctlBinaryPath string - if agentctlResult != nil { - addCleanup(agentctlResult.cleanup) - defer func() { - if r := recover(); r != nil { - log.Error("panic recovered, stopping agentctl", zap.Any("panic", r)) - if stopErr := agentctlResult.cleanup(); stopErr != nil { - log.Error("failed to stop agentctl on panic", zap.Error(stopErr)) - } - panic(r) - } - }() - - // Capture the binary path so initOfficeServices can include it in the - // ServiceOptions when constructing the office service. - agentctlBinaryPath = agentctlResult.binaryPath - } - - return startAgentInfrastructure(ctx, cfg, log, addCleanup, eventBus, dbPool, repos, services, agentSettingsController, agentRegistry, agentctlBinaryPath, runCleanups) -} - -// startAgentInfrastructure initializes the agent lifecycle manager, worktree, orchestrator, -// gateway, and HTTP server. -func startAgentInfrastructure( - ctx context.Context, - cfg *config.Config, - log *logger.Logger, - addCleanup func(func() error), - eventBus bus.EventBus, - dbPool *db.Pool, - repos *Repositories, - services *Services, - agentSettingsController *agentsettingscontroller.Controller, - agentRegistry *registry.Registry, - agentctlBinaryPath string, - runCleanups func(), -) bool { - // ============================================ - // AGENT MANAGER - // ============================================ - lifecycleMgr, err := provideLifecycleManager(ctx, cfg, log, eventBus, repos.AgentSettings, agentRegistry, repos.Secrets) - if err != nil { - log.Error("Failed to initialize agent manager", zap.Error(err)) - return false - } - - // ============================================ - // WORKTREE MANAGER - // ============================================ - log.Info("Initializing Worktree Manager...") - - worktreeMgr, _, worktreeCleanup, err := provideWorktreeManager(dbPool, cfg, log, lifecycleMgr, services.Task) - if err != nil { - log.Error("Failed to initialize worktree manager", zap.Error(err)) - return false - } - services.WorktreeMgr = worktreeMgr - addCleanup(worktreeCleanup) - log.Info("Worktree Manager initialized", - zap.Bool("enabled", cfg.Worktree.Enabled)) - - services.Task.SetBranchMaterializer(newBranchMaterializer(repos.Task, worktreeMgr, lifecycleMgr, log)) - services.Task.SetAgentBaseBranchPusher(lifecycleMgr) - - lifecycleMgr.SetWorkspaceInfoProvider(services.Task) - log.Info("Workspace info provider configured for session recovery") - - // TODO(task-model-unification Phase 2, ADR 0004): wire agentruntime.New(lifecycleMgr) - // once a real consumer (workflow-engine / cron-driven trigger handlers) exists. - // Allocating the facade in Phase 1 without a caller is dead code. - - // Persistence writer for executors_running. This makes the lifecycle manager - // the sole writer of agent_execution_id / container_id / runtime / status — - // the structural fix for the agent-execution-id divergence bug. Must be set - // before any Launch / EnsureWorkspaceExecutionForSession can run. - lifecycleMgr.SetExecutorRunningWriter(repos.Task) - - // Configure quick-chat workspace cleanup - if homeDir := cfg.ResolvedHomeDir(); homeDir != "" { - quickChatDir := filepath.Join(homeDir, "quick-chat") - services.Task.SetQuickChatDir(quickChatDir) - log.Info("Quick-chat workspace cleanup configured", zap.String("quick_chat_dir", quickChatDir)) - } - - // ============================================ - // REPO CLONER - // ============================================ - repoCloner := repoclone.NewCloner(repoclone.Config{ - BasePath: cfg.RepoClone.BasePath, - }, repoclone.DetectGitProtocol(), cfg.ResolvedHomeDir(), log) - log.Info("Repository cloner configured", - zap.String("base_path", cfg.RepoClone.BasePath)) - - // Let the task service treat the cloner's base path as an implicit - // allow-listed root. Without this, deploys that put the clone base - // outside HOME (e.g. KANDEV_REPOCLONE_BASEPATH=/data/repos in a - // container) fail the discoveryRoots() allow-list check and local - // branch listing returns nothing. - services.Task.SetRepoCloneLocation(repoCloner) - - // ============================================ - // ORCHESTRATOR - // ============================================ - log.Info("Initializing Orchestrator...") - - orchestratorSvc, msgCreator, err := provideOrchestrator(cfg, log, dbPool, eventBus, repos.Task, services.Task, services.User, - lifecycleMgr, agentRegistry, services.Workflow, repos.Secrets, repoCloner) - if err != nil { - log.Error("Failed to initialize orchestrator", zap.Error(err)) - return false - } - - // Wire the soft-deleted-profile pre-flight into the watcher dispatch. - // Orphan watchers (their agent profile was soft-deleted by the - // reconciler when its agent type left the registry) self-heal on the - // next poll instead of looping on "profile not found" forever. - orchestratorSvc.SetProfileLookup(&profileLookupAdapter{store: repos.AgentSettings}) - - // Wire the watcher-dependency enumerator into the agent settings - // controller so the profile-delete UI can surface "this will also - // disable N watchers" before the user confirms. - agentSettingsController.SetWatcherDependencyChecker(&watcherDepsAdapter{ - linear: services.Linear, - jira: services.Jira, - github: services.GitHub, - log: log, - }) - - // Wire GitHub service into orchestrator for PR auto-detection on push - if services.GitHub != nil { - orchestratorSvc.SetGitHubService(services.GitHub) - services.GitHub.SetTaskDeleter(&taskDeleterAdapter{svc: services.Task}) - services.GitHub.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task}) - log.Info("GitHub service configured for orchestrator (PR auto-detection enabled)") - - // Start GitHub background poller - ghPoller := githubpkg.NewPoller(services.GitHub, eventBus, log) - ghPoller.SetTaskBranchProvider(orchestratorSvc) - ghPoller.Start(ctx) - addCleanup(func() error { ghPoller.Stop(); return nil }) - log.Info("GitHub poller started") - } - - // Start GitLab background poller + wire the service into the - // orchestrator so review/issue watch events get turned into tasks. - if services.GitLab != nil { - orchestratorSvc.SetGitLabService(services.GitLab) - services.GitLab.SetTaskDeleter(&taskDeleterAdapter{svc: services.Task}) - services.GitLab.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task}) - glPoller := gitlabpkg.NewPoller(services.GitLab, eventBus, log) - glPoller.Start(ctx) - addCleanup(func() error { glPoller.Stop(); return nil }) - log.Info("GitLab poller started") - } - - // Start JIRA poller. Drives two background loops sharing one service: an - // auth-health probe (so the UI can show connect status without polling - // JIRA itself) and an issue-watch loop that runs configured JQL queries - // and emits NewJiraIssueEvent for the orchestrator to turn into tasks. - if services.Jira != nil { - orchestratorSvc.SetJiraService(&jiraServiceAdapter{svc: services.Jira}) - jiraPoller := jirapkg.NewPoller(services.Jira, log) - jiraPoller.Start(ctx) - addCleanup(func() error { jiraPoller.Stop(); return nil }) - } - - // Start Linear poller. Mirrors the Jira shape: auth-health probe plus an - // issue-watch loop that runs configured filters and emits - // NewLinearIssueEvent for the orchestrator to turn into tasks. - if services.Linear != nil { - orchestratorSvc.SetLinearService(&linearServiceAdapter{svc: services.Linear}) - linearPoller := linearpkg.NewPoller(services.Linear, log) - linearPoller.Start(ctx) - addCleanup(func() error { linearPoller.Stop(); return nil }) - } - - // Start Sentry poller: an auth-health probe plus an issue-watch loop that - // runs configured filters and emits NewSentryIssueEvent. The dedup adapter - // lets the orchestrator turn matching Sentry issues into kandev tasks. - if services.Sentry != nil { - orchestratorSvc.SetSentryService(&sentryServiceAdapter{svc: services.Sentry}) - sentryPoller := sentrypkg.NewPoller(services.Sentry, log) - sentryPoller.Start(ctx) - addCleanup(func() error { sentryPoller.Stop(); return nil }) - } - - // Start Slack auth-health poller and the trigger loop. The trigger - // polls each configured workspace every 30s for new `!kandev …` - // messages from the authenticated user and turns them into Kandev - // tasks via taskSvc. - if services.Slack != nil { - slackPoller := slackpkg.NewPoller(services.Slack, log) - slackPoller.Start(ctx) - addCleanup(func() error { slackPoller.Stop(); return nil }) - - slackTrigger := slackpkg.NewTrigger(services.Slack, log) - slackTrigger.Start(ctx) - addCleanup(func() error { slackTrigger.Stop(); return nil }) - } - - // Wire automation service into orchestrator for trigger-based task creation. - // The Automation subsystem is independent of the Office feature flag — it - // has its own cron scheduler, GitHub poller, and webhook handler, and - // creates tasks via the task service directly. - if services.Automation != nil { - orchestratorSvc.SetAutomationService(services.Automation.Service) - services.Automation.Start(ctx) - addCleanup(func() error { services.Automation.Stop(); return nil }) - log.Info("Automation scheduler and evaluator started") - } - - return startGatewayAndServe(ctx, cfg, log, eventBus, dbPool, repos, services, - agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, agentctlBinaryPath, addCleanup, runCleanups) -} - -// startGatewayAndServe sets up the WebSocket gateway, HTTP routes, starts the server, -// and blocks until a shutdown signal. -func startGatewayAndServe( - ctx context.Context, - cfg *config.Config, - log *logger.Logger, - eventBus bus.EventBus, - dbPool *db.Pool, - repos *Repositories, - services *Services, - agentSettingsController *agentsettingscontroller.Controller, - lifecycleMgr *lifecycle.Manager, - agentRegistry *registry.Registry, - orchestratorSvc *orchestrator.Service, - msgCreator *messageCreatorAdapter, - repoCloner *repoclone.Cloner, - agentctlBinaryPath string, - addCleanup func(func() error), - runCleanups func(), -) bool { - // ============================================ - // WEBSOCKET GATEWAY - // ============================================ - log.Info("Initializing WebSocket Gateway...") - gateway, _, notificationCtrl, terminalSvc, err := provideGateway( - ctx, log, eventBus, services.Task, services.User, - orchestratorSvc, lifecycleMgr, agentRegistry, - repos.Notification, repos.Task, repos.Terminal, services.GitHub, - cfg.ResolvedHomeDir(), - ) - if terminalSvc != nil { - services.Terminal = terminalSvc - } - if err != nil { - log.Error("Failed to initialize WebSocket gateway", zap.Error(err)) - return false - } - - gateways.RegisterSessionStreamNotifications(ctx, eventBus, gateway.Hub, log) - gateway.Hub.SetSessionDataProvider(buildSessionDataProvider(repos.Task, lifecycleMgr, log)) - log.Info("Session data provider configured for session subscriptions (git status from snapshots)") - - waitForAgentctlControlHealthy(ctx, cfg, log) - - // ============================================ - // HOST UTILITY MANAGER - // ============================================ - // Long-lived per-agent-type agentctl instances for boot-time capability - // probes, on-demand refresh via settings, and sessionless utility prompts - // (e.g. "enhance prompt" before a task/session exists). - hostControlClient := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log, - agentctlclient.WithControlAuthToken(cfg.Agent.StandaloneAuthToken)) - hostUtilityMgr := hostutility.NewManager(agentRegistry, cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, hostControlClient, log) - hostUtilityMgr.SetAuthToken(cfg.Agent.StandaloneAuthToken) - // Wire the host utility manager into the settings controller so - // /api/v1/agent-models/:agentName reads live capability data. - agentSettingsController.SetHostUtility(hostUtilityMgr) - profileReconciler := agentsettingscontroller.NewProfileReconciler(hostUtilityMgr, agentRegistry, repos.AgentSettings, log) - go func() { - if err := hostUtilityMgr.Start(ctx); err != nil { - log.Warn("host utility manager bootstrap error", zap.Error(err)) - } - // Reconcile profiles against fresh probe results — seeds defaults for - // newly probed agents, heals stale profile models/modes, cleans up - // orphans referencing removed agents. - if err := profileReconciler.Run(ctx); err != nil { - log.Warn("profile reconciler error", zap.Error(err)) - } - }() - addCleanup(func() error { - stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - hostUtilityMgr.Stop(stopCtx) - return nil - }) - - // Wire the Slack agent runner. Slack triage uses the host-utility - // inference path (single-shot ACP subprocess) with the Kandev MCP - // server attached so the agent can call list_workflows_kandev / - // create_task_kandev / etc. mid-prompt. Both deps land here at the - // same time: hostUtilityMgr just bootstrapped above, services.Utility - // was constructed in provideServices. - if services.Slack != nil && services.Utility != nil { - mcpURL := buildKandevMCPURL(cfg.Server.Port) - slackRunner := slackpkg.NewRunner( - services.Utility, - services.User, - slackHostUtilityAdapter{mgr: hostUtilityMgr}, - []slackpkg.MCPDescriptor{{Name: "kandev", URL: mcpURL}}, - log, - ) - services.Slack.SetRunner(slackRunner) - } - - if err := orchestratorSvc.Start(ctx); err != nil { - log.Error("Failed to start orchestrator", zap.Error(err)) - return false - } - log.Info("Orchestrator initialized") - - // ============================================ - // ORCHESTRATE CONFIG LOADER + WAKEUP SCHEDULER - // ============================================ - if ok := initOfficeServices(ctx, cfg, log, repos, services, orchestratorSvc, eventBus, agentctlBinaryPath, addCleanup, lifecycleMgr, agentRegistry); !ok { - return false - } - - // Wire subscription usage provider into the office agents service so the - // /agents/:id/utilization endpoint can fetch live utilization data. - // Skipped when the Office feature flag is off (services.OfficeSvcs is nil). - if services.OfficeSvcs != nil && services.OfficeSvcs.Agents != nil { - usageAdapter := newUsageProviderAdapter(repos.AgentSettings, agentRegistry) - services.OfficeSvcs.Agents.SetUsageProvider(usageAdapter) - } - - services.Task.StartAutoArchiveLoop(ctx) - - // ============================================ - // SYSTEM PAGES - // ============================================ - // Composed before HTTP routes so the registration pass below can mount - // the /api/v1/system/* group; started before the listener so the - // updates poller is alive as soon as we accept connections. - systemSvc := systemsvc.Provide(cfg, log, dbPool, eventBus, systemsvc.BuildInfo{ - Version: Version, - Commit: Commit, - BuildTime: BuildTime, - }, systemsvc.Wiring{ - OrchestratorShutdown: func() { _ = orchestratorSvc.Stop() }, - }) - if systemSvc.Metrics != nil { - systemSvc.Metrics.SetBroadcaster(gateway.Hub.BroadcastToSystemMetrics) - gateway.Hub.SetSystemMetricsInterestTracker(systemSvc.Metrics) - systemSvc.Metrics.SetExecutionProvider(lifecycleMetricProvider{manager: lifecycleMgr}) - } - systemSvc.StartBackground(ctx) - gateways.RegisterSystemNotifications(ctx, eventBus, gateway.Hub, log) - - // ============================================ - // HTTP SERVER - // ============================================ - server := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController, - lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr, addCleanup, repoCloner, systemSvc) - - port := cfg.Server.Port - if port == 0 { - port = ports.Backend - } - go func() { - log.Info("WebSocket server listening", zap.Int("port", port)) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Error("Server listen error", zap.Error(err)) - } - }() - - log.Info("API configured", - zap.String("websocket", "/ws"), - zap.String("health", "/health"), - zap.String("http", "/api/v1"), - ) - - // Flip the readiness flag once the HTTP listener is actually - // accepting connections, not just "spawned". ListenAndServe runs - // in a goroutine and may not have bound the socket yet by the time - // we reach this line. Probe the local listener with a short retry - // loop — once a single connect succeeds, the kernel queue is up - // and any subsequent /health call will land on a wired route. - go waitListenerThenMarkReady(port, log) - - awaitShutdown(server, orchestratorSvc, lifecycleMgr, runCleanups, log) - return true -} - -// waitListenerThenMarkReady probes the local HTTP listener until a -// connect succeeds, then flips the package-level `ready` flag so the -// /health handler stops returning 503. Runs in its own goroutine so -// the caller can proceed into awaitShutdown. -// -// The probe budget is generous (30s) — under heavy parallel-suite -// load the OS scheduler can delay the listen goroutine for a couple -// of seconds. If the budget expires the flag still flips, so the -// backend doesn't permanently advertise "not ready" if probing fails -// for some unrelated reason (e.g. an iptables hiccup on a dev box). -func waitListenerThenMarkReady(port int, log *logger.Logger) { - addr := fmt.Sprintf("127.0.0.1:%d", port) - deadline := time.Now().Add(30 * time.Second) - ticker := time.NewTicker(50 * time.Millisecond) - defer ticker.Stop() - for { - conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) - if err == nil { - _ = conn.Close() - // A successful TCP dial proves the listener is bound. - ready.Store(true) - log.Info("backend ready", zap.Int("port", port)) - return - } - if time.Now().After(deadline) { - log.Warn("backend readiness probe never connected; flipping ready anyway", - zap.Int("port", port)) - ready.Store(true) - return - } - <-ticker.C - } -} - -// initOfficeServices constructs the office service with all dependencies, then -// sets up the reconciler, event subscribers, scheduler, and garbage collector. -// Returns false if a fatal error prevents startup. -func initOfficeServices( - ctx context.Context, - cfg *config.Config, - log *logger.Logger, - repos *Repositories, - services *Services, - orchestratorSvc *orchestrator.Service, - eventBus bus.EventBus, - agentctlBinaryPath string, - addCleanup func(func() error), - lifecycleMgr *lifecycle.Manager, - agentRegistry *registry.Registry, -) bool { - // Feature gate: when features.office is off (the production default), - // skip every Office construction step. Downstream call sites - // (helpers.go route registration, cron scheduler, skill backfill, - // usage-provider wiring) already nil-check services.Office / - // services.OfficeSvcs, so leaving them nil is safe. - // See docs/decisions/0007-runtime-feature-flags.md. - if !cfg.Features.Office { - log.Info("Office feature disabled (features.office=false); skipping initialization") - return true - } - - configBasePath := cfg.ResolvedHomeDir() - cfgLoader, cfgWriter := initOfficeConfigLoader(configBasePath, log, addCleanup) - - apiPort := cfg.Server.Port - if apiPort == 0 { - apiPort = ports.Backend - } - - taskStarter := newOfficeTaskStarter(orchestratorSvc) - - // Construct the office service with all dependencies at once so the - // compiler catches missing fields rather than failing at runtime. - services.Office = officeservice.NewService(officeservice.ServiceOptions{ - Repo: repos.Office, - Logger: log, - CfgLoader: cfgLoader, - CfgWriter: cfgWriter, - WorkspaceCreator: &taskWorkspaceCreatorAdapter{taskSvc: services.Task}, - TaskWorkspace: services.Task, - TaskCreator: &taskCreatorAdapter{taskSvc: services.Task}, - TaskPRs: &taskPRListerAdapter{gh: services.GitHub}, - APIBaseURL: fmt.Sprintf("http://localhost:%d/api/v1", apiPort), - TaskStarter: taskStarter, - TaskCanceller: orchestratorSvc, - AgentctlBinaryPath: agentctlBinaryPath, - EventBus: eventBus, - }) - log.Info("Office service constructed with all dependencies") - - // office-costs Wave B: lazy models.dev pricing lookup. The Client - // allocates no resources at startup — the first non-claude-acp cost - // event triggers a disk read; the first missing cache file triggers - // a background fetch. Workspaces running only claude-acp stay - // untouched because Layer A handles every event before lookup. - modelsdevCachePath := filepath.Join(cfg.ResolvedHomeDir(), "cache", "models-dev.json") - pricingLookup := officemodelsdev.New(officemodelsdev.Config{ - CachePath: modelsdevCachePath, - }, log) - services.Office.SetPricingLookup(pricingLookup) - orchestratorSvc.SetModelInfoLookup(pricingLookup) - services.Office.SetSessionUsageWriter(repos.Task) - - // ADR 0005 Wave E: plug the runtime-tier skill deployer into the - // lifecycle manager. The deployer reads office's skills repo + - // instructions repo via small adapters; office no longer ships - // its own delivery code. - wireRuntimeSkillDeployer(lifecycleMgr, agentRegistry, repos.Office, services.Office, cfg.ResolvedHomeDir(), log) - - // Wire office-owned repositories into the task service for cross-package operations. - services.Task.SetBlockerRepository(repos.Office) - services.Task.SetCommentRepository(repos.Office) - - // Build feature-package services and wire all inter-service dependencies. - services.OfficeSvcs = buildOfficeFeatureServices( - repos.Office, repos.Task, repos.AgentSettings, cfgLoader, cfgWriter, configBasePath, - agentRegistry, log, services, cfg.Office.JWTSigningKey, - ) - wireOfficeSvcsDependencies(services, repos, eventBus, orchestratorSvc, agentRegistry) - - // Reconcile using the new infra package. - reconciler := officeinfra.NewReconciler(repos.Office, log) - reconciler.ReconcileAll(ctx) - log.Info("Office reconciliation complete") - - // System skill sync. Upserts every embedded SKILL.md (the ones written - // to disk by EnsureBundledSkills above) into office_skills as - // is_system = true rows for each known workspace, removing system - // rows that no longer have a matching embed. Per-agent - // desired_skills references are preserved. - syncSystemSkills(ctx, repos.Office, services, log) - // Backfill default skills onto agents that pre-date the system-skill - // rollout. Idempotent: only agents whose `desired_skills` is empty - // receive defaults; curated lists are left alone. - backfillAgentDefaultSkills(ctx, services, log) - - // Register event subscribers and start scheduler - if err := services.Office.RegisterEventSubscribers(eventBus); err != nil { - log.Error("Failed to register office event subscribers", zap.Error(err)) - return false - } - - startOfficeSchedulersAndGC(ctx, cfg, repos, services, eventBus, orchestratorSvc, log) - return true -} - -// wireOfficeSvcsDependencies wires inter-service dependencies into the -// OfficeSvcs feature package. Extracted to keep initOfficeServices within -// funlen limits. -func wireOfficeSvcsDependencies( - services *Services, - repos *Repositories, - eventBus bus.EventBus, - orchestratorSvc *orchestrator.Service, - agentRegistry *registry.Registry, -) { - // Wire the workflow-domain decisions store so approve/request-changes - // route to workflow_step_decisions (ADR 0005 Wave E). - services.OfficeSvcs.Dashboard.SetDecisionStore(repos.Workflow) - // Wire the event bus into the dashboard service for status-change events. - services.OfficeSvcs.Dashboard.SetEventBus(eventBus) - services.OfficeSvcs.Channels.SetEventBus(eventBus) - // Wire the office service as the channel relay's run resolver so - // relayed-comment activity rows get tagged with the originating - // run id (Tasks Touched on the run detail page). - services.OfficeSvcs.Channels.SetRunResolver(services.Office) - // Wire the office service as the retry canceller for task reassignment. - services.OfficeSvcs.Dashboard.SetRetryCanceller(services.Office) - // Wire the office service as the task canceller for status→cancelled hard-cancels. - services.OfficeSvcs.Dashboard.SetTaskCanceller(services.Office) - // Wire the reactivity pipeline so property mutations queue downstream runs. - services.OfficeSvcs.Dashboard.SetReactivityApplier( - officescheduler.NewDashboardReactivityAdapter(services.OfficeSvcs.Scheduler), - ) - // Wire the approval-flow run queuer so decisions trigger - // task_changes_requested / task_ready_to_close runs. - services.OfficeSvcs.Dashboard.SetApprovalReactivityQueuer( - officescheduler.NewDashboardApprovalAdapter(services.OfficeSvcs.Scheduler), - ) - // Wire the office session terminator so participation removal flips the - // (task, agent) session row to COMPLETED. - officeSessionTerm := orchestratorSvc.OfficeSessionTerminator() - services.OfficeSvcs.Dashboard.SetSessionTerminator(officeSessionTerm) - services.OfficeSvcs.Agents.SetSessionTerminator(officeSessionTerm) - // Wire the failure notifier so reassignments auto-dismiss the - // prior (task, agent) inbox entry. - services.OfficeSvcs.Dashboard.SetFailureNotifier(services.Office) - // Wire the failure-tracking inbox source + dismiss handler so the - // inbox surfaces agent_run_failed / agent_paused_after_failures rows. - services.OfficeSvcs.Dashboard.SetFailureInboxSource( - newOfficeFailureInboxAdapter(services.Office), - ) - services.OfficeSvcs.Dashboard.SetMarkFixedHandler(services.Office) - wireOfficeProviderRouting(services, repos, orchestratorSvc, eventBus, agentRegistry) -} - -// wireOfficeProviderRouting builds the routing resolver + TaskStarter -// adapter and wires the scheduler.SchedulerService as the office -// service's routing dispatcher. No-op effect on non-routing launches -// because the resolver short-circuits when no workspace has routing -// enabled. -func wireOfficeProviderRouting( - services *Services, - repos *Repositories, - orchestratorSvc *orchestrator.Service, - eventBus bus.EventBus, - agentRegistry *registry.Registry, -) { - scheduler := services.OfficeSvcs.Scheduler - resolver := routing.NewResolver(&officeRoutingRepoAdapter{repo: repos.Office}, nil) - scheduler.SetResolver(resolver) - scheduler.SetTaskStarter(&schedulerTaskStarterAdapter{orch: orchestratorSvc}) - scheduler.SetEventBus(eventBus) - services.Office.SetRoutingDispatcher(scheduler) - - provider := routing.NewProvider(repos.Office, agentRegistry, resolver, scheduler) - services.OfficeSvcs.Dashboard.SetRoutingProvider(provider) - services.OfficeSvcs.Dashboard.SetRouteAttemptLister(repos.Office) - services.OfficeSvcs.Agents.SetKnownProvidersFn(func() []routing.ProviderID { - return routing.KnownProviders(agentRegistry) - }) -} - -// officeRoutingRepoAdapter satisfies routing.Repo over the office -// sqlite repo. Lives here (not in the routing package) so the routing -// package stays repo-agnostic. -type officeRoutingRepoAdapter struct { - repo *officesqlite.Repository -} - -func (a *officeRoutingRepoAdapter) GetWorkspaceRouting( - ctx context.Context, workspaceID string, -) (*routing.WorkspaceConfig, error) { - return a.repo.GetWorkspaceRouting(ctx, workspaceID) -} - -func (a *officeRoutingRepoAdapter) ListProviderHealth( - ctx context.Context, workspaceID string, -) ([]officemodels.ProviderHealth, error) { - return a.repo.ListProviderHealth(ctx, workspaceID) -} - -// schedulerTaskStarterAdapter satisfies scheduler.TaskStarter against -// the orchestrator service. -type schedulerTaskStarterAdapter struct { - orch *orchestrator.Service -} - -func (a *schedulerTaskStarterAdapter) StartTask( - ctx context.Context, - taskID, agentProfileID, executorID, executorProfileID string, - priority, prompt, workflowStepID string, - planMode bool, attachments []interface{}, -) error { - _, err := a.orch.StartTask(ctx, taskID, agentProfileID, - executorID, executorProfileID, priority, prompt, - workflowStepID, planMode, false, nil) - return err -} - -func (a *schedulerTaskStarterAdapter) StartTaskWithRoute( - ctx context.Context, - taskID, agentProfileID string, - launch officescheduler.LaunchContext, - route officescheduler.RouteOverride, -) error { - return a.orch.StartTaskWithRoute(ctx, taskID, agentProfileID, - orchexecutor.LaunchContext{ - ExecutorID: launch.ExecutorID, - ExecutorProfileID: launch.ExecutorProfileID, - Priority: launch.Priority, - Prompt: launch.Prompt, - WorkflowStepID: launch.WorkflowStepID, - PlanMode: launch.PlanMode, - Attachments: launch.Attachments, - Env: launch.Env, - }, - orchexecutor.RouteOverride{ - ProviderID: route.ProviderID, - Model: route.Model, - Tier: route.Tier, - Mode: route.Mode, - Flags: route.Flags, - Env: route.Env, - }) -} - -// startOfficeSchedulersAndGC wires the runs service, workflow engine dispatcher, -// runs scheduler, cron scheduler, and GC sweep. Extracted to keep -// initOfficeServices within funlen limits. -func startOfficeSchedulersAndGC( - ctx context.Context, - cfg *config.Config, - repos *Repositories, - services *Services, - eventBus bus.EventBus, - orchestratorSvc *orchestrator.Service, - log *logger.Logger, -) { - log.Info("Office scheduler wired to orchestrator StartTask") - orchScheduler := officeservice.NewSchedulerIntegration( - services.Office, runsscheduler.TickIntervalFromEnv(), - ) - // Office task-handoffs prompt enrichment. The HandoffService is - // constructed alongside the HTTP routes (helpers.go); we stash the - // scheduler reference on the Services struct so registerRoutes can - // wire SetTaskContextProvider once both exist. - services.OrchScheduler = orchScheduler - // Wire the runs queue service so office.QueueRun delegates the - // insert + publish + signal to it (Phase 3 of task-model-unification). - runsSvc := runsservice.New( - repos.Office.RunsRepository(), eventBus, log, nil, - ) - services.Office.SetRunsService(runsSvc) - // Phase 4 (ADR-0004): wire the workflow engine's dependencies and a - // dispatcher so office event subscribers route through the engine - // unconditionally. - engineDispatcher := wireWorkflowEngineForOffice( - orchestratorSvc, services.Office, services.Task, services.Workflow, repos, runsSvc, log, - ) - // Start the runs scheduler (tick + signal listener). It drives - // orchScheduler.Tick on both periodic ticks and event-driven signals. - runScheduler := runsscheduler.New( - orchScheduler, runsSvc.SubscribeSignal(), - runsscheduler.TickIntervalFromEnv(), log, - ) - go runScheduler.Start(ctx) - log.Info("Runs scheduler started", - zap.Duration("tick", runsscheduler.TickIntervalFromEnv())) - // Phase 5 (ADR-0004): start the shared cron loop. The routines handler - // degrades to a no-op when routineSvc is nil, so omitting Office's - // scheduler is safe when features.office is off. - var officeRoutines *officeroutines.RoutineService - if services.OfficeSvcs != nil { - officeRoutines = services.OfficeSvcs.Routines - } - startCronScheduler(ctx, repos, engineDispatcher, officeRoutines, log) - // Start GC sweep for orphaned worktrees and containers. - worktreeBase := filepath.Join(cfg.ResolvedHomeDir(), "tasks") - gc := officeinfra.NewGarbageCollector( - repos.Office, - services.WorktreeMgr, // WorktreeInventory — authoritative live-worktrees source - log, worktreeBase, - nil, // dockerClient - pass if Docker available - 3*time.Hour, - ) - go gc.Start(ctx) - log.Info("Office GC sweep started") -} - -// wireWorkflowEngineForOffice composes the Phase 2 (ADR-0004) -// dependencies the workflow engine needs to evaluate office triggers, -// then builds an engine-dispatcher and hands it to the office service. -// -// Engine options wired here: -// - RunQueueAdapter — runs service (Phase 3.1) -// - ParticipantStore — workflow_step_participants -// - DecisionStore — workflow_step_decisions -// - PrimaryAgentResolver — workflow_steps.agent_profile_id -// - CEOAgentResolver — agent_profiles WHERE role='ceo' AND workspace_id != ” -// -// The orchestrator's engine is rebuilt with these options applied, then -// the office service is given a dispatcher pointing at it. The four -// task-scoped event subscribers (comment, blockers_resolved, -// children_completed, approval_resolved) route through the engine -// unconditionally after Phase 4. -func wireWorkflowEngineForOffice( - orchestratorSvc *orchestrator.Service, - officeSvc *officeservice.Service, - taskSvc *taskservice.Service, - workflowSvc *workflowservice.Service, - repos *Repositories, - runsSvc *runsservice.Service, - log *logger.Logger, -) *officeenginedispatcher.Dispatcher { - // Build the workflow-domain adapters. - participants := workflowadapters.NewParticipantAdapter(repos.Workflow) - decisions := workflowadapters.NewDecisionAdapter(repos.Workflow) - primary := workflowadapters.NewPrimaryAgentAdapter(repos.Workflow) - // Office-domain CEO resolver. - ceo := officeengineadapters.NewCEOAgentAdapter(repos.Office) - // Phase 8 delegation adapters: task creator + workflow switcher. - taskCreator := officeengineadapters.NewTaskCreatorAdapter( - repos.Task, &childTaskCreatorAdapter{taskSvc: taskSvc}) - workflowSwitcher := officeengineadapters.NewWorkflowSwitcherAdapter( - &startStepResolverAdapter{svc: workflowSvc}, repos.Task) - // Wire each dependency via its dedicated setter so the orchestrator - // captures it both for engine.With* options and for the Phase 2 / 8 - // callback registry. - orchestratorSvc.SetEngineRunQueue(&runsServiceEngineAdapter{svc: runsSvc}) - orchestratorSvc.SetEngineParticipantStore(participants) - orchestratorSvc.SetEngineDecisionStore(decisions) - orchestratorSvc.SetEngineCEOResolver(ceo) - orchestratorSvc.SetPrimaryAgentResolver(primary) - orchestratorSvc.SetEngineTaskCreator(taskCreator) - orchestratorSvc.SetEngineWorkflowSwitcher(workflowSwitcher) - eng := orchestratorSvc.WorkflowEngine() - if eng == nil { - log.Warn("workflow engine not initialised; office engine dispatcher disabled") - return nil - } - // Build the dispatcher. The session resolver is the task repo, - // which exposes GetActiveTaskSessionByTaskID. - dispatcher := officeenginedispatcher.New(eng, repos.Task, log) - officeSvc.SetWorkflowEngineDispatcher(dispatcher) - log.Info("workflow engine dispatcher wired for office") - return dispatcher -} - -// runsServiceEngineAdapter bridges runs/service.Service.QueueRun (which -// takes runs/service.QueueRunRequest) to engine.RunQueueAdapter (which -// takes engine.QueueRunRequest). The two structs have identical fields -// — they are intentionally duplicated so neither package imports the -// other — so this adapter is a field-by-field copy. -type runsServiceEngineAdapter struct { - svc *runsservice.Service -} - -func (a *runsServiceEngineAdapter) QueueRun(ctx context.Context, req workflowengine.QueueRunRequest) error { - return a.svc.QueueRun(ctx, runsservice.QueueRunRequest{ - AgentProfileID: req.AgentProfileID, - TaskID: req.TaskID, - WorkflowStepID: req.WorkflowStepID, - Reason: req.Reason, - IdempotencyKey: req.IdempotencyKey, - Payload: req.Payload, - }) -} - -// wireRuntimeSkillDeployer plugs the runtime-tier SkillDeployer into the -// lifecycle manager (ADR 0005 Wave E). The deployer bridges office's -// skills repo + instructions repo to the runtime via small adapters in -// internal/office/skills, so kanban and office launches share a single -// skill-deploy code path. A nil officeSvc / repo leaves the manager -// with the Wave A no-op deployer. -func wireRuntimeSkillDeployer( - lifecycleMgr *lifecycle.Manager, - agentRegistry *registry.Registry, - officeRepo *officesqlite.Repository, - officeSvc *officeservice.Service, - basePath string, - log *logger.Logger, -) { - if lifecycleMgr == nil || officeSvc == nil || officeRepo == nil { - return - } - deployer, err := runtimeskill.New(runtimeskill.Config{ - Logger: log, - BasePath: basePath, - SkillReader: officeskills.NewSkillReaderAdapter(officeSvc), - InstructionLister: officeskills.NewInstructionListerAdapter(officeRepo), - ProjectSkillDirResolver: makeProjectSkillDirResolver(agentRegistry), - WorkspaceSlugFn: makeWorkspaceSlugFn(), - }) - if err != nil { - log.Warn("failed to construct runtime skill deployer; launches will skip skill delivery", - zap.Error(err)) - return - } - lifecycleMgr.SetSkillDeployer(lifecycle.NewSkillDeployerAdapter(deployer)) - log.Info("Runtime skill deployer wired into lifecycle manager") -} - -// makeProjectSkillDirResolver returns a runtimeskill.ProjectSkillDirResolver -// backed by the agent registry. The agent type ID equals the agent_id on -// the agent_profiles row after ADR 0005 — we look up the agent and read -// its declared ProjectSkillDir, falling back to the runtime default. -func makeProjectSkillDirResolver(reg *registry.Registry) runtimeskill.ProjectSkillDirResolver { - if reg == nil { - return nil - } - return func(agentTypeID string) string { - ag, ok := reg.Get(agentTypeID) - if !ok { - return "" - } - if rt := ag.Runtime(); rt != nil && rt.ProjectSkillDir != "" { - return rt.ProjectSkillDir - } - return "" - } -} - -// makeUserSkillDirResolver returns a provider user-skill-dir resolver backed by -// the agent registry. Providers that do not declare a user skill dir are omitted -// from discovery. -func makeUserSkillDirResolver(reg *registry.Registry) officeskills.UserSkillDirResolver { - if reg == nil { - return nil - } - return func(provider string) (string, bool) { - ag, ok := reg.Get(provider) - if !ok { - return "", false - } - if rt := ag.Runtime(); rt != nil && rt.UserSkillDir != "" { - return rt.UserSkillDir, true - } - return "", false - } -} - -// makeWorkspaceSlugFn returns a slug-resolver that maps a workspace ID -// to a slug used in on-host runtime paths. The office stack is currently -// single-workspace-per-install, so every ID resolves to the constant -// "default". When multi-workspace lands, this becomes a real lookup -// against officesqlite.Repository (e.g. GetWorkspaceNameByID) — until -// then we deliberately ignore the workspace ID rather than passing a -// repo dependency that has nothing to query. -func makeWorkspaceSlugFn() func(string) string { - return func(string) string { return "default" } -} - -// initOfficeConfigLoader initialises the filesystem config loader, writes -// officeFailureInboxAdapter forwards from the office Service (which -// returns service-package row types) to the dashboard -// FailureInboxSource interface (which expects dashboard-package row -// types). Trivial conversion — dashboard intentionally avoids -// importing the office repo or service so the package boundary stays -// clean. -type officeFailureInboxAdapter struct { - svc *officeservice.Service -} - -func newOfficeFailureInboxAdapter(svc *officeservice.Service) *officeFailureInboxAdapter { - return &officeFailureInboxAdapter{svc: svc} -} - -func (a *officeFailureInboxAdapter) ListFailedRunInboxRows( - ctx context.Context, workspaceID, userID string, -) ([]officedashboard.FailureInboxRow, error) { - rows, err := a.svc.ListFailedRunInboxRows(ctx, workspaceID, userID) - if err != nil { - return nil, err - } - out := make([]officedashboard.FailureInboxRow, 0, len(rows)) - for _, r := range rows { - out = append(out, officedashboard.FailureInboxRow{ - Kind: "agent_run_failed", - ItemID: r.RunID, - AgentProfileID: r.AgentProfileID, - AgentName: r.AgentName, - TaskID: r.TaskID, - ErrorMessage: r.ErrorMessage, - FailedAt: r.FailedAt, +func dispatch(args []string, build buildInfo, backend backendRunner, launch launcherRunner) int { + if len(args) > 0 && args[0] == "__backend" { + return backend(args[1:], backendapp.BuildInfo{ + Version: build.Version, + Commit: build.Commit, + BuildTime: build.BuildTime, }) } - return out, nil -} - -func (a *officeFailureInboxAdapter) ListPausedAgentInboxRows( - ctx context.Context, workspaceID, userID string, -) ([]officedashboard.FailureInboxRow, error) { - rows, err := a.svc.ListPausedAgentInboxRows(ctx, workspaceID, userID) - if err != nil { - return nil, err - } - out := make([]officedashboard.FailureInboxRow, 0, len(rows)) - for _, r := range rows { - out = append(out, officedashboard.FailureInboxRow{ - Kind: "agent_paused_after_failures", - ItemID: r.AgentID, - AgentProfileID: r.AgentID, - AgentName: r.AgentName, - PauseReason: r.PauseReason, - ConsecutiveFailures: r.ConsecutiveFailures, - FailedAt: r.UpdatedAt, - }) - } - return out, nil -} - -// bundled skills, and registers shutdown cleanup for symlinks. -func initOfficeConfigLoader( - basePath string, log *logger.Logger, addCleanup func(func() error), -) (*configloader.ConfigLoader, *configloader.FileWriter) { - cfgLoader := configloader.NewConfigLoader(basePath) - if err := cfgLoader.Load(); err != nil { - log.Error("Failed to load office config from filesystem", zap.Error(err)) - } else { - log.Info("Office config loaded from filesystem", - zap.String("base_path", basePath), - zap.Int("workspaces", len(cfgLoader.GetWorkspaces()))) - } - if err := configloader.EnsureBundledSkills(basePath); err != nil { - log.Error("Failed to write bundled skills", zap.Error(err)) - } else { - slugs, _ := configloader.BundledSkillSlugs() - log.Info("Bundled skills ensured", zap.Strings("slugs", slugs)) - } - return cfgLoader, configloader.NewFileWriter(basePath, cfgLoader) -} - -// syncSystemSkills reconciles the office_skills table against the -// embedded bundled skill set for every known workspace. Pulls the -// workspace list from the task service (workspace ids are shared -// across task + office persistence). Failures are logged but do not -// gate startup — system skills are surfaced lazily by the Skills -// page, which simply shows an empty System group until a later -// retry succeeds. -func syncSystemSkills( - ctx context.Context, - repo *officesqlite.Repository, - services *Services, - log *logger.Logger, -) { - workspaces, err := services.Task.ListWorkspaces(ctx) - if err != nil { - log.Error("system skill sync: list workspaces", zap.Error(err)) - return - } - ids := make([]string, 0, len(workspaces)) - for _, w := range workspaces { - ids = append(ids, w.ID) - } - if _, err := officeskills.SyncSystemSkills(ctx, repo, ids, nil, log); err != nil { - log.Error("system skill sync failed", zap.Error(err)) - } -} - -// backfillAgentDefaultSkills delegates to the agents service so each -// workspace's existing agents inherit the system-skill defaults for -// their role when their desired_skills array is empty. Errors per -// workspace are absorbed inside the service call; startup must not -// fail because of a curated-list edge case. -func backfillAgentDefaultSkills( - ctx context.Context, - services *Services, - log *logger.Logger, -) { - if services.OfficeSvcs == nil || services.OfficeSvcs.Agents == nil { - return - } - workspaces, err := services.Task.ListWorkspaces(ctx) - if err != nil { - log.Error("backfill default skills: list workspaces", zap.Error(err)) - return - } - for _, w := range workspaces { - services.OfficeSvcs.Agents.BackfillDefaultSkillsForWorkspace(ctx, w.ID) - } -} - -// newOfficeTaskStarter wraps orchestratorSvc.StartTaskWithEnv in the -// officeservice.TaskStarterWithEnvFunc adapter. Extracted from -// initOfficeServices to keep that function under the funlen cap. -func newOfficeTaskStarter(orchestratorSvc *orchestrator.Service) officeservice.TaskStarter { - return officeservice.TaskStarterWithEnvFunc( - func(ctx context.Context, taskID, agentProfileID, executorID, - executorProfileID string, priority string, prompt, workflowStepID string, - planMode bool, attachments []v1.MessageAttachment, env map[string]string) error { - _, err := orchestratorSvc.StartTaskWithEnv(ctx, taskID, agentProfileID, - executorID, executorProfileID, priority, prompt, - workflowStepID, planMode, false, attachments, env) - return err - }, - ) -} - -// newAgentAuth wraps officeagents.NewAgentAuth with a dev-mode warning when -// no signing key is configured, so the empty-key fallback can't silently -// invalidate agent tokens on every restart in production. -func newAgentAuth(jwtSigningKey string, log *logger.Logger) *officeagents.AgentAuth { - if jwtSigningKey == "" { - log.Warn("office.jwtSigningKey is empty; generating an ephemeral key. " + - "Agent JWTs will be invalidated on every backend restart. " + - "Set KANDEV_OFFICE_JWTSIGNINGKEY for stable tokens.") - } - return officeagents.NewAgentAuth(jwtSigningKey) -} - -// buildOfficeFeatureServices creates the feature-level office services used by -// the HTTP handler layer (office.RegisterAllRoutes). The monolithic -// services.Office is passed for shared interfaces during the transition period. -func buildOfficeFeatureServices( - repo *officesqlite.Repository, - taskRepo *tasksqlite.Repository, - settingsRepo settingsstore.Repository, - cfgLoader *configloader.ConfigLoader, - cfgWriter *configloader.FileWriter, - homeDir string, - agentRegistry *registry.Registry, - log *logger.Logger, - services *Services, - jwtSigningKey string, -) *office.Services { - activity := officeshared.NewActivityLogger(repo, log) - - agentSvc := officeagents.NewAgentService(repo, log, activity) - agentSvc.SetAuth(newAgentAuth(jwtSigningKey, log)) - if services.Office != nil { - services.Office.SetAgentTokenMinter(agentSvc) - } - skillSvc := officeskills.NewSkillService(repo, log, activity, agentSvc, cfgLoader) - skillSvc.SetUserSkillDirResolver(makeUserSkillDirResolver(agentRegistry)) - projectSvc := officeprojects.NewProjectService(repo, log, activity) - costSvc := officecosts.NewCostService(repo, log, activity, agentSvc, agentSvc) - // Office service delegates budget evaluation (pre-execution + post-event) - // to the costs feature — the only place that owns CRUD for budget policies. - if services.Office != nil { - services.Office.SetBudgetChecker(costSvc) - } - routineSvc := officeroutines.NewRoutineService(repo, log, activity) - // PR 3 of office-heartbeat-rework: wire routines into the wakeup - // dispatcher so the lightweight (taskless) flow enqueues a fresh - // taskless run, and into the task path so the heavy flow creates a - // real task in the routine system workflow. - routineWakeupDispatcher := officewakeup.NewDispatcher(repo, repo, log) - routineWakeupDispatcher.SetRoutineLookup(repo) - routineSvc.SetWakeupEnqueuer(&routineWakeupAdapter{ - repo: repo, - dispatcher: routineWakeupDispatcher, + return launch(args, launcher.BuildInfo{ + Version: build.Version, + Commit: build.Commit, + BuildTime: build.BuildTime, }) - routineSvc.SetWorkflowEnsurer(&workflowEnsurerAdapter{repo: taskRepo}) - routineSvc.SetTaskCreator(&taskCreatorAdapter{taskSvc: services.Task}) - approvalSvc := officeapprovals.NewApprovalService(repo, log, activity, services.Office) - approvalSvc.SetAgentWriter(agentSvc) - channelSvc := officechannels.NewChannelService(repo, log, activity, agentSvc) - configSvc := officeconfig.NewConfigService(repo, cfgLoader, cfgWriter, log, activity) - dashboardSvc := buildOfficeDashboardService( - repo, log, activity, agentSvc, costSvc, - skillSvc, routineSvc, approvalSvc, - cfgLoader, cfgWriter, - ) - documentSvc := taskservice.NewDocumentService(taskRepo, log) - onboardingSvc := officeonboarding.NewOnboardingService( - repo, cfgLoader, cfgWriter, log, - agentSvc, settingsRepo, agentSvc, - &taskWorkspaceCreatorAdapter{taskSvc: services.Task}, - &workflowEnsurerAdapter{repo: taskRepo}, - &taskCreatorAdapter{taskSvc: services.Task}, - services.Office, - &configSyncerAdapter{svc: configSvc}, - ) - onboardingSvc.SetCoordinatorRoutineInstaller(routineSvc) - schedulerSvc := officescheduler.NewSchedulerService(repo, log, services.Office) - labelSvc := officelabels.NewLabelService(repo) - gitMgr := configloader.NewGitManager(cfgLoader.BasePath(), cfgLoader, log) - - return &office.Services{ - Agents: agentSvc, - Skills: skillSvc, - Projects: projectSvc, - Costs: costSvc, - Routines: routineSvc, - Approvals: approvalSvc, - Channels: channelSvc, - Config: configSvc, - Dashboard: dashboardSvc, - Documents: documentSvc, - Labels: labelSvc, - Onboarding: onboardingSvc, - Scheduler: schedulerSvc, - TreeControls: services.Office, - Workspaces: services.Office, - Repo: repo, - GitManager: gitMgr, - KandevHome: homeDir, - } -} - -// buildOfficeDashboardService constructs the dashboard service and wires -// all of its cross-service dependencies (governance, skill/routine listers, -// settings provider, coordinator-routine installer). Extracted from -// buildOfficeFeatureServices to keep the parent under funlen's 80-line cap. -func buildOfficeDashboardService( - repo *officesqlite.Repository, - log *logger.Logger, - activity officeshared.ActivityLogger, - agentSvc *officeagents.AgentService, - costSvc *officecosts.CostService, - skillSvc *officeskills.SkillService, - routineSvc *officeroutines.RoutineService, - approvalSvc *officeapprovals.ApprovalService, - cfgLoader *configloader.ConfigLoader, - cfgWriter *configloader.FileWriter, -) *officedashboard.DashboardService { - dashboardSvc := officedashboard.NewDashboardService(repo, log, activity, agentSvc, costSvc) - dashboardSvc.SetGovernanceStore(repo) - dashboardSvc.SetSkillLister(skillSvc) - dashboardSvc.SetRoutineLister(routineSvc) - agentSvc.SetGovernanceSettings(dashboardSvc) - agentSvc.SetGovernanceApproval(approvalSvc) - // office-heartbeat-as-routine: every coordinator agent (onboarding or - // post-onboarding via the agents API) gets a "Coordinator heartbeat" - // routine on creation. The routines service is the canonical owner; - // both creators delegate to it via a slim interface. - agentSvc.SetCoordinatorRoutineInstaller(routineSvc) - if cfgLoader != nil && cfgWriter != nil { - dashboardSvc.SetSettingsProvider(&workspaceSettingsProviderAdapter{ - loader: cfgLoader, - writer: cfgWriter, - }) - } - return dashboardSvc -} - -// buildHTTPServer creates the HTTP server with all routes registered. -func buildHTTPServer( - cfg *config.Config, - log *logger.Logger, - gateway *gateways.Gateway, - repos *Repositories, - services *Services, - agentSettingsController *agentsettingscontroller.Controller, - lifecycleMgr *lifecycle.Manager, - eventBus bus.EventBus, - orchestratorSvc *orchestrator.Service, - notificationCtrl *notificationcontroller.Controller, - msgCreator *messageCreatorAdapter, - agentRegistry *registry.Registry, - hostUtilityMgr *hostutility.Manager, - addCleanup func(func() error), - repoCloner *repoclone.Cloner, - systemSvc *systemsvc.Service, -) *http.Server { - gin.SetMode(gin.ReleaseMode) - router := gin.New() - router.Use(httpmw.RequestLogger(log, "kandev")) - router.Use(httpmw.OtelTracing("kandev")) - router.Use(gin.Recovery()) - router.Use(corsMiddleware()) - - port := cfg.Server.Port - if port == 0 { - port = ports.Backend - } - - registerRoutes(routeParams{ - router: router, - gateway: gateway, - taskSvc: services.Task, - taskRepo: repos.Task, - officeRepo: repos.Office, - analyticsRepo: repos.Analytics, - orchestratorSvc: orchestratorSvc, - lifecycleMgr: lifecycleMgr, - hostUtilityMgr: hostUtilityMgr, - eventBus: eventBus, - services: services, - systemSvc: systemSvc, - runtimeFlagsSvc: services.RuntimeFlags, - agentSettingsController: agentSettingsController, - agentSettingsRepo: repos.AgentSettings, - agentList: agentRegistry, - agentRegistry: agentRegistry, - userCtrl: usercontroller.NewController(services.User), - notificationCtrl: notificationCtrl, - editorCtrl: editorcontroller.NewController(services.Editor), - promptCtrl: promptcontroller.NewController(services.Prompts), - utilityCtrl: utilitycontroller.NewController(services.Utility), - msgCreator: msgCreator, - secretsSvc: secrets.NewService(repos.Secrets, log), - secretStore: repos.Secrets, - mcpConfigSvc: mcpconfig.NewService(repos.AgentSettings), - addCleanup: addCleanup, - repoCloner: repoCloner, - version: Version, - webInternalURL: cfg.Server.WebInternalURL, - devMode: cfg.Debug.DevMode || cfg.Debug.PprofEnabled, - httpPort: port, - features: cfg.Features, - voice: cfg.Voice, - log: log, - }) - - return &http.Server{ - Addr: fmt.Sprintf(":%d", port), - Handler: router, - ReadTimeout: cfg.Server.ReadTimeoutDuration(), - WriteTimeout: cfg.Server.WriteTimeoutDuration(), - } -} - -// awaitShutdown waits for an OS signal then performs graceful shutdown. -func awaitShutdown( - server *http.Server, - orchestratorSvc *orchestrator.Service, - lifecycleMgr *lifecycle.Manager, - runCleanups func(), - log *logger.Logger, -) { - // ============================================ - // GRACEFUL SHUTDOWN - // ============================================ - quit := make(chan os.Signal, 2) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - sig := <-quit - - // If we get a second signal, exit immediately. - go func() { - second := <-quit - log.Warn("Received second shutdown signal, forcing exit", zap.String("signal", second.String())) - _ = log.Close() - os.Exit(1) - }() - - log.Info("Received shutdown signal", zap.String("signal", sig.String())) - runGracefulShutdown(server, orchestratorSvc, lifecycleMgr, runCleanups, log) } diff --git a/apps/backend/cmd/kandev/main_test.go b/apps/backend/cmd/kandev/main_test.go new file mode 100644 index 0000000000..4e7db98222 --- /dev/null +++ b/apps/backend/cmd/kandev/main_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "testing" + + "github.com/kandev/kandev/internal/backendapp" + "github.com/kandev/kandev/internal/launcher" +) + +func TestDispatchesHiddenBackendMode(t *testing.T) { + backendCalled := false + launcherCalled := false + + code := dispatch([]string{"__backend", "--version"}, buildInfo{Version: "test"}, func(args []string, build backendapp.BuildInfo) int { + backendCalled = true + if len(args) != 1 || args[0] != "--version" { + t.Fatalf("backend args = %v, want [--version]", args) + } + if build.Version != "test" { + t.Fatalf("backend build = %+v", build) + } + return 7 + }, func(args []string, build launcher.BuildInfo) int { + launcherCalled = true + return 0 + }) + + if code != 7 { + t.Fatalf("exit code = %d, want 7", code) + } + if !backendCalled { + t.Fatal("backend runner was not called") + } + if launcherCalled { + t.Fatal("launcher runner was called for hidden backend mode") + } +} + +func TestDispatchDefaultsToLauncherMode(t *testing.T) { + backendCalled := false + launcherCalled := false + + code := dispatch([]string{"--help"}, buildInfo{Commit: "abc"}, func(args []string, build backendapp.BuildInfo) int { + backendCalled = true + return 0 + }, func(args []string, build launcher.BuildInfo) int { + launcherCalled = true + if len(args) != 1 || args[0] != "--help" { + t.Fatalf("launcher args = %v, want [--help]", args) + } + if build.Commit != "abc" { + t.Fatalf("launcher build = %+v", build) + } + return 3 + }) + + if code != 3 { + t.Fatalf("exit code = %d, want 3", code) + } + if backendCalled { + t.Fatal("backend runner was called for public launcher mode") + } + if !launcherCalled { + t.Fatal("launcher runner was not called") + } +} diff --git a/apps/backend/cmd/preview/build.go b/apps/backend/cmd/preview/build.go index 04e51835aa..ea602a0bc1 100644 --- a/apps/backend/cmd/preview/build.go +++ b/apps/backend/cmd/preview/build.go @@ -141,25 +141,11 @@ func buildWeb(ctx context.Context, skipInstall bool) error { return nil } -// buildCLI creates the bundled TypeScript launcher used by release-style -// installs. Preview sprites run through this launcher so their backend is -// managed by the same restart supervisor as normal kandev starts. -func buildCLI(ctx context.Context) error { - cmd := exec.CommandContext(ctx, "bash", "scripts/release/package-cli.sh") - cmd.Dir = repoRootDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("package cli: %w", err) - } - return nil -} - // packageBundle creates a tar.gz matching the Docker container layout: // // app/apps/backend/bin/{kandev,agentctl,mock-agent} // app/apps/web/dist/ (Vite SPA assets) -// usr/local/lib/kandev-cli/ (CLI launcher bundle) +// usr/local/bin/kandev (native launcher symlink) func packageBundle(binDir, tarPath string) error { f, err := os.Create(tarPath) if err != nil { @@ -193,9 +179,8 @@ func packageBundle(binDir, tarPath string) error { return fmt.Errorf("add web dist: %w", err) } - cliDir := filepath.Join(repoRootDir, "dist", "kandev", "cli") - if err := addDirToTar(tw, cliDir, filepath.Join("usr", "local", "lib", "kandev-cli")); err != nil { - return fmt.Errorf("add cli: %w", err) + if err := addSymlinkToTar(tw, filepath.Join("usr", "local", "bin", "kandev"), filepath.Join("/", "app", "apps", "backend", "bin", "kandev")); err != nil { + return fmt.Errorf("add kandev symlink: %w", err) } // Close in order: tar → gzip → file (flush compressed data). @@ -232,6 +217,15 @@ func viteIndexHasEntrypoint(indexHTML string) bool { return false } +func addSymlinkToTar(tw *tar.Writer, name, target string) error { + return tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeSymlink, + Name: name, + Linkname: target, + Mode: 0o755, + }) +} + func addFileToTar(tw *tar.Writer, src, dst string, mode fs.FileMode) error { data, err := os.ReadFile(src) if err != nil { diff --git a/apps/backend/cmd/preview/deploy.go b/apps/backend/cmd/preview/deploy.go index 05f33190fc..90e86bbdf4 100644 --- a/apps/backend/cmd/preview/deploy.go +++ b/apps/backend/cmd/preview/deploy.go @@ -85,11 +85,6 @@ func deployArtifacts(ctx context.Context, binDir, tarPath, spritesToken, spriteN return "", fmt.Errorf("build web: %w", err) } - fmt.Fprintln(os.Stderr, "building cli launcher...") - if err := buildCLI(ctx); err != nil { - return "", fmt.Errorf("build cli: %w", err) - } - fmt.Fprintln(os.Stderr, "packaging bundle...") if err := packageBundle(binDir, tarPath); err != nil { return "", fmt.Errorf("package bundle: %w", err) diff --git a/apps/backend/cmd/preview/sprite_ops.go b/apps/backend/cmd/preview/sprite_ops.go index 10e110022e..d75de62447 100644 --- a/apps/backend/cmd/preview/sprite_ops.go +++ b/apps/backend/cmd/preview/sprite_ops.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/kandev/kandev/internal/common/ports" sprites "github.com/superfly/sprites-go" ) @@ -104,11 +103,10 @@ func buildExtractScript(backendPort int) string { tar -xzf /tmp/kandev-preview.tar.gz -C / chmod +x /app/apps/backend/bin/kandev \ /app/apps/backend/bin/agentctl \ - /app/apps/backend/bin/mock-agent \ - /usr/local/lib/kandev-cli/bin/cli.js + /app/apps/backend/bin/mock-agent ln -sf /app/apps/backend/bin/agentctl /usr/local/bin/agentctl ln -sf /app/apps/backend/bin/mock-agent /usr/local/bin/mock-agent -ln -sf /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev +ln -sf /app/apps/backend/bin/kandev /usr/local/bin/kandev # Reset data directory on each deploy so the DB starts fresh (preview env only). rm -rf /data mkdir -p /data /var/log @@ -122,14 +120,13 @@ mkdir -p /data # Kill any agentctl orphans from previous runs. pkill -f agentctl || true -# Kill any stale web (node) process from older preview deployments. -pkill -f '/app/apps/web/.*/server.js' || true sleep 1 cd /app export KANDEV_HOME_DIR=/data export KANDEV_DOCKER_ENABLED=false export KANDEV_LOG_LEVEL=info +export KANDEV_WEB_DIST_DIR=/app/apps/web/dist # Preview mode: only register the mock agent, suppress real agent discovery. export KANDEV_MOCK_AGENT=only # The preview service runs from /app, so the backend's relative dist probes do @@ -139,12 +136,11 @@ export KANDEV_WEB_DIST_DIR=/app/apps/web/dist # Launch through the CLI so the backend runs under the restart supervisor. exec kandev start \ --backend-port %d \ - --web-internal-port %d \ --verbose \ --headless \ > /var/log/kandev.log 2>&1 STARTSCRIPT -chmod +x /app/start-kandev.sh`, backendPort, ports.Web) +chmod +x /app/start-kandev.sh`, backendPort) } // deployService stops any running kandev service, registers (or updates) its diff --git a/apps/backend/cmd/preview/sprite_ops_test.go b/apps/backend/cmd/preview/sprite_ops_test.go index b2c0177d4e..52dc9f4f82 100644 --- a/apps/backend/cmd/preview/sprite_ops_test.go +++ b/apps/backend/cmd/preview/sprite_ops_test.go @@ -20,8 +20,8 @@ func TestBuildExtractScript(t *testing.T) { if !strings.Contains(script, "KANDEV_WEB_DIST_DIR=/app/apps/web/dist") { t.Errorf("expected KANDEV_WEB_DIST_DIR to point at packaged Vite dist") } - if !strings.Contains(script, "ln -sf /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev") { - t.Errorf("expected kandev cli symlink in script") + if !strings.Contains(script, "ln -sf /app/apps/backend/bin/kandev /usr/local/bin/kandev") { + t.Errorf("expected native kandev symlink in script") } if !strings.Contains(script, "exec kandev start") { t.Errorf("expected script to launch through kandev start") diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/internal/backendapp/adapters.go similarity index 99% rename from apps/backend/cmd/kandev/adapters.go rename to apps/backend/internal/backendapp/adapters.go index 7a414c8cf9..d3c7b11eaa 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/adapters_office.go b/apps/backend/internal/backendapp/adapters_office.go similarity index 99% rename from apps/backend/cmd/kandev/adapters_office.go rename to apps/backend/internal/backendapp/adapters_office.go index 6a6ebebc98..91643e64cc 100644 --- a/apps/backend/cmd/kandev/adapters_office.go +++ b/apps/backend/internal/backendapp/adapters_office.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/adapters_test.go b/apps/backend/internal/backendapp/adapters_test.go similarity index 99% rename from apps/backend/cmd/kandev/adapters_test.go rename to apps/backend/internal/backendapp/adapters_test.go index ed9c7d9235..aabc7e9e66 100644 --- a/apps/backend/cmd/kandev/adapters_test.go +++ b/apps/backend/internal/backendapp/adapters_test.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/agentctl.go b/apps/backend/internal/backendapp/agentctl.go similarity index 99% rename from apps/backend/cmd/kandev/agentctl.go rename to apps/backend/internal/backendapp/agentctl.go index 6c8fe36a34..7a3860fb00 100644 --- a/apps/backend/cmd/kandev/agentctl.go +++ b/apps/backend/internal/backendapp/agentctl.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/internal/backendapp/agents.go similarity index 99% rename from apps/backend/cmd/kandev/agents.go rename to apps/backend/internal/backendapp/agents.go index 5d6185ace8..d02427385c 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/internal/backendapp/agents.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/boot_state.go b/apps/backend/internal/backendapp/boot_state.go similarity index 99% rename from apps/backend/cmd/kandev/boot_state.go rename to apps/backend/internal/backendapp/boot_state.go index aef2e20794..96d2e4e85e 100644 --- a/apps/backend/cmd/kandev/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go similarity index 99% rename from apps/backend/cmd/kandev/boot_state_routes.go rename to apps/backend/internal/backendapp/boot_state_routes.go index 7cfc6ab327..89ff0463a7 100644 --- a/apps/backend/cmd/kandev/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/branch_materializer.go b/apps/backend/internal/backendapp/branch_materializer.go similarity index 99% rename from apps/backend/cmd/kandev/branch_materializer.go rename to apps/backend/internal/backendapp/branch_materializer.go index 200a62bd1e..dd6682f093 100644 --- a/apps/backend/cmd/kandev/branch_materializer.go +++ b/apps/backend/internal/backendapp/branch_materializer.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/branch_materializer_test.go b/apps/backend/internal/backendapp/branch_materializer_test.go similarity index 99% rename from apps/backend/cmd/kandev/branch_materializer_test.go rename to apps/backend/internal/backendapp/branch_materializer_test.go index fb9d208c51..8bc699a611 100644 --- a/apps/backend/cmd/kandev/branch_materializer_test.go +++ b/apps/backend/internal/backendapp/branch_materializer_test.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/cron.go b/apps/backend/internal/backendapp/cron.go similarity index 99% rename from apps/backend/cmd/kandev/cron.go rename to apps/backend/internal/backendapp/cron.go index 04a9c233b1..5c9075fd8f 100644 --- a/apps/backend/cmd/kandev/cron.go +++ b/apps/backend/internal/backendapp/cron.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/e2e_reset.go b/apps/backend/internal/backendapp/e2e_reset.go similarity index 99% rename from apps/backend/cmd/kandev/e2e_reset.go rename to apps/backend/internal/backendapp/e2e_reset.go index e3d09495dc..fbdc7ade9d 100644 --- a/apps/backend/cmd/kandev/e2e_reset.go +++ b/apps/backend/internal/backendapp/e2e_reset.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/internal/backendapp/gateway.go similarity index 99% rename from apps/backend/cmd/kandev/gateway.go rename to apps/backend/internal/backendapp/gateway.go index 1059a7419d..361b199e15 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/internal/backendapp/gateway.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/internal/backendapp/helpers.go similarity index 99% rename from apps/backend/cmd/kandev/helpers.go rename to apps/backend/internal/backendapp/helpers.go index 7b81c1ce69..b5397a77c9 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go similarity index 99% rename from apps/backend/cmd/kandev/helpers_test.go rename to apps/backend/internal/backendapp/helpers_test.go index 01273fc433..2049f36810 100644 --- a/apps/backend/cmd/kandev/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/initial_agent.go b/apps/backend/internal/backendapp/initial_agent.go similarity index 98% rename from apps/backend/cmd/kandev/initial_agent.go rename to apps/backend/internal/backendapp/initial_agent.go index 6f0988844c..73bdfc6e32 100644 --- a/apps/backend/cmd/kandev/initial_agent.go +++ b/apps/backend/internal/backendapp/initial_agent.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go new file mode 100644 index 0000000000..ce93cd3825 --- /dev/null +++ b/apps/backend/internal/backendapp/main.go @@ -0,0 +1,1664 @@ +// Package backendapp runs the Kandev backend server. +// +//revive:disable:file-length-limit +package backendapp + +import ( + "context" + "flag" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "sync/atomic" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/httpmw" + "go.uber.org/zap" + + // Common packages + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + + // Event bus + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + + // GitHub integration + githubpkg "github.com/kandev/kandev/internal/github" + gitlabpkg "github.com/kandev/kandev/internal/gitlab" + + // JIRA integration + jirapkg "github.com/kandev/kandev/internal/jira" + linearpkg "github.com/kandev/kandev/internal/linear" + sentrypkg "github.com/kandev/kandev/internal/sentry" + slackpkg "github.com/kandev/kandev/internal/slack" + + // Agent infrastructure + "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/mcpconfig" + "github.com/kandev/kandev/internal/agent/registry" + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + runtimeskill "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill" + agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + + // WebSocket gateway + gateways "github.com/kandev/kandev/internal/gateway/websocket" + + editorcontroller "github.com/kandev/kandev/internal/editors/controller" + notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" + promptcontroller "github.com/kandev/kandev/internal/prompts/controller" + usercontroller "github.com/kandev/kandev/internal/user/controller" + utilitycontroller "github.com/kandev/kandev/internal/utility/controller" + + // Orchestrator + "github.com/kandev/kandev/internal/office/configloader" + officeservice "github.com/kandev/kandev/internal/office/service" + "github.com/kandev/kandev/internal/orchestrator" + v1 "github.com/kandev/kandev/pkg/api/v1" + + // Office feature packages + office "github.com/kandev/kandev/internal/office" + officeagents "github.com/kandev/kandev/internal/office/agents" + officeapprovals "github.com/kandev/kandev/internal/office/approvals" + officechannels "github.com/kandev/kandev/internal/office/channels" + officeconfig "github.com/kandev/kandev/internal/office/config" + officecosts "github.com/kandev/kandev/internal/office/costs" + officemodelsdev "github.com/kandev/kandev/internal/office/costs/modelsdev" + officedashboard "github.com/kandev/kandev/internal/office/dashboard" + officeinfra "github.com/kandev/kandev/internal/office/infra" + officelabels "github.com/kandev/kandev/internal/office/labels" + officemodels "github.com/kandev/kandev/internal/office/models" + officeonboarding "github.com/kandev/kandev/internal/office/onboarding" + officeprojects "github.com/kandev/kandev/internal/office/projects" + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + officeroutines "github.com/kandev/kandev/internal/office/routines" + "github.com/kandev/kandev/internal/office/routing" + officescheduler "github.com/kandev/kandev/internal/office/scheduler" + officeshared "github.com/kandev/kandev/internal/office/shared" + officeskills "github.com/kandev/kandev/internal/office/skills" + officewakeup "github.com/kandev/kandev/internal/office/wakeup" + orchexecutor "github.com/kandev/kandev/internal/orchestrator/executor" + + // Runs queue (Phase 3 of task-model-unification) + runsscheduler "github.com/kandev/kandev/internal/runs/scheduler" + runsservice "github.com/kandev/kandev/internal/runs/service" + + // Workflow engine adapters (Phase 3.2 of task-model-unification) + officeengineadapters "github.com/kandev/kandev/internal/office/engine_adapters" + officeenginedispatcher "github.com/kandev/kandev/internal/office/engine_dispatcher" + workflowadapters "github.com/kandev/kandev/internal/workflow/adapters" + workflowengine "github.com/kandev/kandev/internal/workflow/engine" + + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" + taskservice "github.com/kandev/kandev/internal/task/service" + workflowservice "github.com/kandev/kandev/internal/workflow/service" + + // Repository cloning + "github.com/kandev/kandev/internal/repoclone" + "github.com/kandev/kandev/internal/runtimeflags" + + // Secrets + "github.com/kandev/kandev/internal/secrets" + + // System pages (status / database / backups / logs / updates / about) + systemsvc "github.com/kandev/kandev/internal/system" + + // Database + "github.com/kandev/kandev/internal/db" + + "github.com/kandev/kandev/internal/common/ports" +) + +// Build-time variables are set by cmd/kandev before Run is called. Defaults +// apply when running un-stamped builds. +var ( + Version = "dev" + Commit = "unknown" + BuildTime = "unknown" +) + +// BuildInfo contains build metadata injected into the top-level command. +type BuildInfo struct { + Version string + Commit string + BuildTime string +} + +type backendFlags struct { + Port int + LogLevel string + Help bool + Version bool +} + +func parseBackendFlags(args []string) (backendFlags, func(), error) { + flags := flag.NewFlagSet("kandev __backend", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + out := backendFlags{} + flags.IntVar(&out.Port, "port", 0, fmt.Sprintf("HTTP server port (default: %d)", ports.Backend)) + flags.StringVar(&out.LogLevel, "log-level", "", "Log level: debug, info, warn, error") + flags.BoolVar(&out.Help, "help", false, "Show help message") + flags.BoolVar(&out.Version, "version", false, "Show version information") + flags.Usage = func() { + _, _ = fmt.Fprintf(flags.Output(), "Usage: kandev __backend [options]\n\n") + _, _ = fmt.Fprintf(flags.Output(), "Kandev backend server. This mode is normally started by the launcher.\n\n") + _, _ = fmt.Fprintf(flags.Output(), "Options:\n") + flags.PrintDefaults() + } + return out, flags.Usage, flags.Parse(args) +} + +// ready is the readiness flag consulted by the GET /health handler. +// Until it flips true, /health returns 503 — so callers polling for +// readiness (Playwright fixtures, container orchestrators, manual +// curl loops) keep waiting instead of racing ahead. main() flips it +// after every route is mounted, the agent registry is seeded, and +// the HTTP listener is accepting connections. +var ready atomic.Bool + +// Run contains all startup logic and returns 0 on success or 1 on fatal error. +// Deferred cleanup is registered here so it always executes before Run returns. +func Run(args []string, build BuildInfo) int { + setBuildInfo(build) + ready.Store(false) + + parsedFlags, usage, err := parseBackendFlags(args) + if err != nil { + return 1 + } + if parsedFlags.Help { + usage() + return 0 + } + if parsedFlags.Version { + fmt.Printf("kandev version %s (commit %s, built %s)\n", Version, Commit, BuildTime) + return 0 + } + + // 1. Load configuration + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) + return 1 + } + + // Apply command-line flag overrides (flags take precedence over config/env) + if parsedFlags.Port > 0 { + cfg.Server.Port = parsedFlags.Port + } + if parsedFlags.LogLevel != "" { + cfg.Logging.Level = parsedFlags.LogLevel + } + + // 2. Initialize logger + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: cfg.Logging.Level, + Format: cfg.Logging.Format, + OutputPath: cfg.Logging.OutputPath, + MaxSizeMB: cfg.Logging.MaxSizeMB, + MaxBackups: cfg.Logging.MaxBackups, + MaxAgeDays: cfg.Logging.MaxAgeDays, + Compress: cfg.Logging.Compress, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) + return 1 + } + + cleanups := make([]func() error, 0) + cleanupsRan := false + runCleanups := func() { + if cleanupsRan { + return + } + cleanupsRan = true + for i := len(cleanups) - 1; i >= 0; i-- { + if cleanups[i] == nil { + continue + } + if err := cleanups[i](); err != nil { + log.Warn("cleanup failed", zap.Error(err)) + } + } + } + defer func() { + runCleanups() + _ = log.Close() + }() + logger.SetDefault(log) + + log.Info("Starting Kandev (unified mode)...", + zap.String("db_path", cfg.Database.Path), + ) + + if !run(cfg, log, &cleanups, runCleanups) { + return 1 + } + return 0 +} + +func setBuildInfo(build BuildInfo) { + if build.Version != "" { + Version = build.Version + } + if build.Commit != "" { + Commit = build.Commit + } + if build.BuildTime != "" { + BuildTime = build.BuildTime + } +} + +// run initializes all services and runs the server. Returns false on fatal startup error. +func run(cfg *config.Config, log *logger.Logger, cleanups *[]func() error, runCleanups func()) bool { + addCleanup := func(fn func() error) { *cleanups = append(*cleanups, fn) } + + // 3. Create context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + addCleanup(func() error { cancel(); return nil }) + + // 4. Initialize event bus (in-memory for unified mode, or NATS if configured) + eventBusProvider, cleanup, err := events.Provide(cfg, log) + if err != nil { + log.Error("Failed to initialize event bus", zap.Error(err)) + return false + } + addCleanup(cleanup) + eventBus := eventBusProvider.Bus + + return startServices(ctx, cfg, log, addCleanup, eventBus, runCleanups) +} + +func applyStartupRuntimeFlags(ctx context.Context, cfg *config.Config, repos *Repositories, log *logger.Logger) bool { + if repos.RuntimeFlags == nil { + return true + } + svc := runtimeflags.NewService(repos.RuntimeFlags, runtimeflags.OptionsFromConfig(cfg)) + states, err := svc.ListStates(ctx) + if err != nil { + log.Error("Failed to resolve runtime flag overrides", zap.Error(err)) + return false + } + runtimeflags.ApplyStatesToConfig(cfg, states) + return true +} + +// startServices initializes task-level services and all downstream infrastructure. +func startServices( //nolint:cyclop + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + addCleanup func(func() error), + eventBus bus.EventBus, + runCleanups func(), +) bool { + // ============================================ + // TASK SERVICE + // ============================================ + log.Info("Initializing Task Service...") + + dbPool, repos, repoCleanups, err := provideRepositories(cfg, log, Version) + if err != nil { + log.Error("Failed to initialize repositories", zap.Error(err)) + return false + } + for _, c := range repoCleanups { + addCleanup(c) + } + + runtimeFlagDefaults := runtimeflags.OptionsFromConfig(cfg).DefaultValues + if !applyStartupRuntimeFlags(ctx, cfg, repos, log) { + return false + } + + agentRegistry, _, err := registry.Provide(log) + if err != nil { + log.Error("Failed to initialize agent registry", zap.Error(err)) + return false + } + + services, agentSettingsController, err := provideServices(cfg, log, repos, dbPool, eventBus, agentRegistry, Version) + if err != nil { + log.Error("Failed to initialize services", zap.Error(err)) + return false + } + services.RuntimeFlags = runtimeflags.NewService( + repos.RuntimeFlags, + runtimeflags.RuntimeOptionsFromAppliedConfig(runtimeFlagDefaults, cfg), + ) + log.Info("Task Service initialized") + + if err := runInitialAgentSetup(ctx, services.User, agentSettingsController, log); err != nil { + // Agent registry seeding is a hard prerequisite for every + // HTTP surface that lists or operates on agents — including + // the e2e harness, the office onboarding wizard, and the + // task-create dialog. Letting startup continue with an empty + // registry produces silent flakes that look like "no agent + // profile available" with no log trail at the failure site. + // Fail loudly so the cause shows up in the backend log + // instead of cascading into downstream UI confusion. + log.Error("Failed to run initial agent setup — aborting startup", zap.Error(err)) + return false + } + log.Info("ACP messages will be stored as comments") + + // ============================================ + // AGENTCTL LAUNCHER (for standalone mode) + // ============================================ + agentctlResult, err := provideAgentctlLauncher(ctx, cfg, log) + if err != nil { + log.Error("Failed to start agentctl subprocess", zap.Error(err)) + return false + } + var agentctlBinaryPath string + if agentctlResult != nil { + addCleanup(agentctlResult.cleanup) + defer func() { + if r := recover(); r != nil { + log.Error("panic recovered, stopping agentctl", zap.Any("panic", r)) + if stopErr := agentctlResult.cleanup(); stopErr != nil { + log.Error("failed to stop agentctl on panic", zap.Error(stopErr)) + } + panic(r) + } + }() + + // Capture the binary path so initOfficeServices can include it in the + // ServiceOptions when constructing the office service. + agentctlBinaryPath = agentctlResult.binaryPath + } + + return startAgentInfrastructure(ctx, cfg, log, addCleanup, eventBus, dbPool, repos, services, agentSettingsController, agentRegistry, agentctlBinaryPath, runCleanups) +} + +// startAgentInfrastructure initializes the agent lifecycle manager, worktree, orchestrator, +// gateway, and HTTP server. +// +//nolint:funlen // Moved legacy backend startup orchestration; split after launcher migration settles. +func startAgentInfrastructure( + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + addCleanup func(func() error), + eventBus bus.EventBus, + dbPool *db.Pool, + repos *Repositories, + services *Services, + agentSettingsController *agentsettingscontroller.Controller, + agentRegistry *registry.Registry, + agentctlBinaryPath string, + runCleanups func(), +) bool { + // ============================================ + // AGENT MANAGER + // ============================================ + lifecycleMgr, err := provideLifecycleManager(ctx, cfg, log, eventBus, repos.AgentSettings, agentRegistry, repos.Secrets) + if err != nil { + log.Error("Failed to initialize agent manager", zap.Error(err)) + return false + } + + // ============================================ + // WORKTREE MANAGER + // ============================================ + log.Info("Initializing Worktree Manager...") + + worktreeMgr, _, worktreeCleanup, err := provideWorktreeManager(dbPool, cfg, log, lifecycleMgr, services.Task) + if err != nil { + log.Error("Failed to initialize worktree manager", zap.Error(err)) + return false + } + services.WorktreeMgr = worktreeMgr + addCleanup(worktreeCleanup) + log.Info("Worktree Manager initialized", + zap.Bool("enabled", cfg.Worktree.Enabled)) + + services.Task.SetBranchMaterializer(newBranchMaterializer(repos.Task, worktreeMgr, lifecycleMgr, log)) + services.Task.SetAgentBaseBranchPusher(lifecycleMgr) + + lifecycleMgr.SetWorkspaceInfoProvider(services.Task) + log.Info("Workspace info provider configured for session recovery") + + // TODO(task-model-unification Phase 2, ADR 0004): wire agentruntime.New(lifecycleMgr) + // once a real consumer (workflow-engine / cron-driven trigger handlers) exists. + // Allocating the facade in Phase 1 without a caller is dead code. + + // Persistence writer for executors_running. This makes the lifecycle manager + // the sole writer of agent_execution_id / container_id / runtime / status — + // the structural fix for the agent-execution-id divergence bug. Must be set + // before any Launch / EnsureWorkspaceExecutionForSession can run. + lifecycleMgr.SetExecutorRunningWriter(repos.Task) + + // Configure quick-chat workspace cleanup + if homeDir := cfg.ResolvedHomeDir(); homeDir != "" { + quickChatDir := filepath.Join(homeDir, "quick-chat") + services.Task.SetQuickChatDir(quickChatDir) + log.Info("Quick-chat workspace cleanup configured", zap.String("quick_chat_dir", quickChatDir)) + } + + // ============================================ + // REPO CLONER + // ============================================ + repoCloner := repoclone.NewCloner(repoclone.Config{ + BasePath: cfg.RepoClone.BasePath, + }, repoclone.DetectGitProtocol(), cfg.ResolvedHomeDir(), log) + log.Info("Repository cloner configured", + zap.String("base_path", cfg.RepoClone.BasePath)) + + // Let the task service treat the cloner's base path as an implicit + // allow-listed root. Without this, deploys that put the clone base + // outside HOME (e.g. KANDEV_REPOCLONE_BASEPATH=/data/repos in a + // container) fail the discoveryRoots() allow-list check and local + // branch listing returns nothing. + services.Task.SetRepoCloneLocation(repoCloner) + + // ============================================ + // ORCHESTRATOR + // ============================================ + log.Info("Initializing Orchestrator...") + + orchestratorSvc, msgCreator, err := provideOrchestrator(cfg, log, dbPool, eventBus, repos.Task, services.Task, services.User, + lifecycleMgr, agentRegistry, services.Workflow, repos.Secrets, repoCloner) + if err != nil { + log.Error("Failed to initialize orchestrator", zap.Error(err)) + return false + } + + // Wire the soft-deleted-profile pre-flight into the watcher dispatch. + // Orphan watchers (their agent profile was soft-deleted by the + // reconciler when its agent type left the registry) self-heal on the + // next poll instead of looping on "profile not found" forever. + orchestratorSvc.SetProfileLookup(&profileLookupAdapter{store: repos.AgentSettings}) + + // Wire the watcher-dependency enumerator into the agent settings + // controller so the profile-delete UI can surface "this will also + // disable N watchers" before the user confirms. + agentSettingsController.SetWatcherDependencyChecker(&watcherDepsAdapter{ + linear: services.Linear, + jira: services.Jira, + github: services.GitHub, + log: log, + }) + + // Wire GitHub service into orchestrator for PR auto-detection on push + if services.GitHub != nil { + orchestratorSvc.SetGitHubService(services.GitHub) + services.GitHub.SetTaskDeleter(&taskDeleterAdapter{svc: services.Task}) + services.GitHub.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task}) + log.Info("GitHub service configured for orchestrator (PR auto-detection enabled)") + + // Start GitHub background poller + ghPoller := githubpkg.NewPoller(services.GitHub, eventBus, log) + ghPoller.SetTaskBranchProvider(orchestratorSvc) + ghPoller.Start(ctx) + addCleanup(func() error { ghPoller.Stop(); return nil }) + log.Info("GitHub poller started") + } + + // Start GitLab background poller + wire the service into the + // orchestrator so review/issue watch events get turned into tasks. + if services.GitLab != nil { + orchestratorSvc.SetGitLabService(services.GitLab) + services.GitLab.SetTaskDeleter(&taskDeleterAdapter{svc: services.Task}) + services.GitLab.SetTaskSessionChecker(&taskSessionCheckerAdapter{repo: repos.Task}) + glPoller := gitlabpkg.NewPoller(services.GitLab, eventBus, log) + glPoller.Start(ctx) + addCleanup(func() error { glPoller.Stop(); return nil }) + log.Info("GitLab poller started") + } + + // Start JIRA poller. Drives two background loops sharing one service: an + // auth-health probe (so the UI can show connect status without polling + // JIRA itself) and an issue-watch loop that runs configured JQL queries + // and emits NewJiraIssueEvent for the orchestrator to turn into tasks. + if services.Jira != nil { + orchestratorSvc.SetJiraService(&jiraServiceAdapter{svc: services.Jira}) + jiraPoller := jirapkg.NewPoller(services.Jira, log) + jiraPoller.Start(ctx) + addCleanup(func() error { jiraPoller.Stop(); return nil }) + } + + // Start Linear poller. Mirrors the Jira shape: auth-health probe plus an + // issue-watch loop that runs configured filters and emits + // NewLinearIssueEvent for the orchestrator to turn into tasks. + if services.Linear != nil { + orchestratorSvc.SetLinearService(&linearServiceAdapter{svc: services.Linear}) + linearPoller := linearpkg.NewPoller(services.Linear, log) + linearPoller.Start(ctx) + addCleanup(func() error { linearPoller.Stop(); return nil }) + } + + // Start Sentry poller: an auth-health probe plus an issue-watch loop that + // runs configured filters and emits NewSentryIssueEvent. The dedup adapter + // lets the orchestrator turn matching Sentry issues into kandev tasks. + if services.Sentry != nil { + orchestratorSvc.SetSentryService(&sentryServiceAdapter{svc: services.Sentry}) + sentryPoller := sentrypkg.NewPoller(services.Sentry, log) + sentryPoller.Start(ctx) + addCleanup(func() error { sentryPoller.Stop(); return nil }) + } + + // Start Slack auth-health poller and the trigger loop. The trigger + // polls each configured workspace every 30s for new `!kandev …` + // messages from the authenticated user and turns them into Kandev + // tasks via taskSvc. + if services.Slack != nil { + slackPoller := slackpkg.NewPoller(services.Slack, log) + slackPoller.Start(ctx) + addCleanup(func() error { slackPoller.Stop(); return nil }) + + slackTrigger := slackpkg.NewTrigger(services.Slack, log) + slackTrigger.Start(ctx) + addCleanup(func() error { slackTrigger.Stop(); return nil }) + } + + // Wire automation service into orchestrator for trigger-based task creation. + // The Automation subsystem is independent of the Office feature flag — it + // has its own cron scheduler, GitHub poller, and webhook handler, and + // creates tasks via the task service directly. + if services.Automation != nil { + orchestratorSvc.SetAutomationService(services.Automation.Service) + services.Automation.Start(ctx) + addCleanup(func() error { services.Automation.Stop(); return nil }) + log.Info("Automation scheduler and evaluator started") + } + + return startGatewayAndServe(ctx, cfg, log, eventBus, dbPool, repos, services, + agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, agentctlBinaryPath, addCleanup, runCleanups) +} + +// startGatewayAndServe sets up the WebSocket gateway, HTTP routes, starts the server, +// and blocks until a shutdown signal. +// +//nolint:funlen // Moved legacy backend startup orchestration; split after launcher migration settles. +func startGatewayAndServe( + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + eventBus bus.EventBus, + dbPool *db.Pool, + repos *Repositories, + services *Services, + agentSettingsController *agentsettingscontroller.Controller, + lifecycleMgr *lifecycle.Manager, + agentRegistry *registry.Registry, + orchestratorSvc *orchestrator.Service, + msgCreator *messageCreatorAdapter, + repoCloner *repoclone.Cloner, + agentctlBinaryPath string, + addCleanup func(func() error), + runCleanups func(), +) bool { + // ============================================ + // WEBSOCKET GATEWAY + // ============================================ + log.Info("Initializing WebSocket Gateway...") + gateway, _, notificationCtrl, terminalSvc, err := provideGateway( + ctx, log, eventBus, services.Task, services.User, + orchestratorSvc, lifecycleMgr, agentRegistry, + repos.Notification, repos.Task, repos.Terminal, services.GitHub, + cfg.ResolvedHomeDir(), + ) + if terminalSvc != nil { + services.Terminal = terminalSvc + } + if err != nil { + log.Error("Failed to initialize WebSocket gateway", zap.Error(err)) + return false + } + + gateways.RegisterSessionStreamNotifications(ctx, eventBus, gateway.Hub, log) + gateway.Hub.SetSessionDataProvider(buildSessionDataProvider(repos.Task, lifecycleMgr, log)) + log.Info("Session data provider configured for session subscriptions (git status from snapshots)") + + waitForAgentctlControlHealthy(ctx, cfg, log) + + // ============================================ + // HOST UTILITY MANAGER + // ============================================ + // Long-lived per-agent-type agentctl instances for boot-time capability + // probes, on-demand refresh via settings, and sessionless utility prompts + // (e.g. "enhance prompt" before a task/session exists). + hostControlClient := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log, + agentctlclient.WithControlAuthToken(cfg.Agent.StandaloneAuthToken)) + hostUtilityMgr := hostutility.NewManager(agentRegistry, cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, hostControlClient, log) + hostUtilityMgr.SetAuthToken(cfg.Agent.StandaloneAuthToken) + // Wire the host utility manager into the settings controller so + // /api/v1/agent-models/:agentName reads live capability data. + agentSettingsController.SetHostUtility(hostUtilityMgr) + profileReconciler := agentsettingscontroller.NewProfileReconciler(hostUtilityMgr, agentRegistry, repos.AgentSettings, log) + go func() { + if err := hostUtilityMgr.Start(ctx); err != nil { + log.Warn("host utility manager bootstrap error", zap.Error(err)) + } + // Reconcile profiles against fresh probe results — seeds defaults for + // newly probed agents, heals stale profile models/modes, cleans up + // orphans referencing removed agents. + if err := profileReconciler.Run(ctx); err != nil { + log.Warn("profile reconciler error", zap.Error(err)) + } + }() + addCleanup(func() error { + stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + hostUtilityMgr.Stop(stopCtx) + return nil + }) + + // Wire the Slack agent runner. Slack triage uses the host-utility + // inference path (single-shot ACP subprocess) with the Kandev MCP + // server attached so the agent can call list_workflows_kandev / + // create_task_kandev / etc. mid-prompt. Both deps land here at the + // same time: hostUtilityMgr just bootstrapped above, services.Utility + // was constructed in provideServices. + if services.Slack != nil && services.Utility != nil { + mcpURL := buildKandevMCPURL(cfg.Server.Port) + slackRunner := slackpkg.NewRunner( + services.Utility, + services.User, + slackHostUtilityAdapter{mgr: hostUtilityMgr}, + []slackpkg.MCPDescriptor{{Name: "kandev", URL: mcpURL}}, + log, + ) + services.Slack.SetRunner(slackRunner) + } + + if err := orchestratorSvc.Start(ctx); err != nil { + log.Error("Failed to start orchestrator", zap.Error(err)) + return false + } + log.Info("Orchestrator initialized") + + // ============================================ + // ORCHESTRATE CONFIG LOADER + WAKEUP SCHEDULER + // ============================================ + if ok := initOfficeServices(ctx, cfg, log, repos, services, orchestratorSvc, eventBus, agentctlBinaryPath, addCleanup, lifecycleMgr, agentRegistry); !ok { + return false + } + + // Wire subscription usage provider into the office agents service so the + // /agents/:id/utilization endpoint can fetch live utilization data. + // Skipped when the Office feature flag is off (services.OfficeSvcs is nil). + if services.OfficeSvcs != nil && services.OfficeSvcs.Agents != nil { + usageAdapter := newUsageProviderAdapter(repos.AgentSettings, agentRegistry) + services.OfficeSvcs.Agents.SetUsageProvider(usageAdapter) + } + + services.Task.StartAutoArchiveLoop(ctx) + + // ============================================ + // SYSTEM PAGES + // ============================================ + // Composed before HTTP routes so the registration pass below can mount + // the /api/v1/system/* group; started before the listener so the + // updates poller is alive as soon as we accept connections. + systemSvc := systemsvc.Provide(cfg, log, dbPool, eventBus, systemsvc.BuildInfo{ + Version: Version, + Commit: Commit, + BuildTime: BuildTime, + }, systemsvc.Wiring{ + OrchestratorShutdown: func() { _ = orchestratorSvc.Stop() }, + }) + if systemSvc.Metrics != nil { + systemSvc.Metrics.SetBroadcaster(gateway.Hub.BroadcastToSystemMetrics) + gateway.Hub.SetSystemMetricsInterestTracker(systemSvc.Metrics) + systemSvc.Metrics.SetExecutionProvider(lifecycleMetricProvider{manager: lifecycleMgr}) + } + systemSvc.StartBackground(ctx) + gateways.RegisterSystemNotifications(ctx, eventBus, gateway.Hub, log) + + // ============================================ + // HTTP SERVER + // ============================================ + server := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController, + lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr, addCleanup, repoCloner, systemSvc) + + port := cfg.Server.Port + if port == 0 { + port = ports.Backend + } + if !startHTTPServer(server, port, log) { + return false + } + + log.Info("API configured", + zap.String("websocket", "/ws"), + zap.String("health", "/health"), + zap.String("http", "/api/v1"), + ) + + // Flip the readiness flag once the HTTP listener is actually + // accepting connections, not just "spawned". Serve runs in a goroutine + // after we bind the socket above; probe the local listener with a short + // retry loop — once a single connect succeeds, the kernel queue is up and + // any subsequent /health call will land on a wired route. + go waitListenerThenMarkReady(port, log) + + awaitShutdown(server, orchestratorSvc, lifecycleMgr, runCleanups, log) + return true +} + +func startHTTPServer(server *http.Server, port int, log *logger.Logger) bool { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + log.Error("Server listen error", zap.Error(err)) + return false + } + + go func() { + log.Info("WebSocket server listening", zap.Int("port", port)) + if err := server.Serve(ln); err != nil && err != http.ErrServerClosed { + log.Error("Server serve error", zap.Error(err)) + } + }() + + return true +} + +// waitListenerThenMarkReady probes the local HTTP listener until a +// connect succeeds, then flips the package-level `ready` flag so the +// /health handler stops returning 503. Runs in its own goroutine so +// the caller can proceed into awaitShutdown. +// +// The probe budget is generous (30s) — under heavy parallel-suite +// load the OS scheduler can delay the listen goroutine for a couple +// of seconds. If the budget expires the flag still flips, so the +// backend doesn't permanently advertise "not ready" if probing fails +// for some unrelated reason (e.g. an iptables hiccup on a dev box). +func waitListenerThenMarkReady(port int, log *logger.Logger) { + addr := fmt.Sprintf("127.0.0.1:%d", port) + deadline := time.Now().Add(30 * time.Second) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err == nil { + _ = conn.Close() + // A successful TCP dial proves the listener is bound. + ready.Store(true) + log.Info("backend ready", zap.Int("port", port)) + return + } + if time.Now().After(deadline) { + log.Warn("backend readiness probe never connected; flipping ready anyway", + zap.Int("port", port)) + ready.Store(true) + return + } + <-ticker.C + } +} + +// initOfficeServices constructs the office service with all dependencies, then +// sets up the reconciler, event subscribers, scheduler, and garbage collector. +// Returns false if a fatal error prevents startup. +func initOfficeServices( + ctx context.Context, + cfg *config.Config, + log *logger.Logger, + repos *Repositories, + services *Services, + orchestratorSvc *orchestrator.Service, + eventBus bus.EventBus, + agentctlBinaryPath string, + addCleanup func(func() error), + lifecycleMgr *lifecycle.Manager, + agentRegistry *registry.Registry, +) bool { + // Feature gate: when features.office is off (the production default), + // skip every Office construction step. Downstream call sites + // (helpers.go route registration, cron scheduler, skill backfill, + // usage-provider wiring) already nil-check services.Office / + // services.OfficeSvcs, so leaving them nil is safe. + // See docs/decisions/0007-runtime-feature-flags.md. + if !cfg.Features.Office { + log.Info("Office feature disabled (features.office=false); skipping initialization") + return true + } + + configBasePath := cfg.ResolvedHomeDir() + cfgLoader, cfgWriter := initOfficeConfigLoader(configBasePath, log, addCleanup) + + apiPort := cfg.Server.Port + if apiPort == 0 { + apiPort = ports.Backend + } + + taskStarter := newOfficeTaskStarter(orchestratorSvc) + + // Construct the office service with all dependencies at once so the + // compiler catches missing fields rather than failing at runtime. + services.Office = officeservice.NewService(officeservice.ServiceOptions{ + Repo: repos.Office, + Logger: log, + CfgLoader: cfgLoader, + CfgWriter: cfgWriter, + WorkspaceCreator: &taskWorkspaceCreatorAdapter{taskSvc: services.Task}, + TaskWorkspace: services.Task, + TaskCreator: &taskCreatorAdapter{taskSvc: services.Task}, + TaskPRs: &taskPRListerAdapter{gh: services.GitHub}, + APIBaseURL: fmt.Sprintf("http://localhost:%d/api/v1", apiPort), + TaskStarter: taskStarter, + TaskCanceller: orchestratorSvc, + AgentctlBinaryPath: agentctlBinaryPath, + EventBus: eventBus, + }) + log.Info("Office service constructed with all dependencies") + + // office-costs Wave B: lazy models.dev pricing lookup. The Client + // allocates no resources at startup — the first non-claude-acp cost + // event triggers a disk read; the first missing cache file triggers + // a background fetch. Workspaces running only claude-acp stay + // untouched because Layer A handles every event before lookup. + modelsdevCachePath := filepath.Join(cfg.ResolvedHomeDir(), "cache", "models-dev.json") + pricingLookup := officemodelsdev.New(officemodelsdev.Config{ + CachePath: modelsdevCachePath, + }, log) + services.Office.SetPricingLookup(pricingLookup) + orchestratorSvc.SetModelInfoLookup(pricingLookup) + services.Office.SetSessionUsageWriter(repos.Task) + + // ADR 0005 Wave E: plug the runtime-tier skill deployer into the + // lifecycle manager. The deployer reads office's skills repo + + // instructions repo via small adapters; office no longer ships + // its own delivery code. + wireRuntimeSkillDeployer(lifecycleMgr, agentRegistry, repos.Office, services.Office, cfg.ResolvedHomeDir(), log) + + // Wire office-owned repositories into the task service for cross-package operations. + services.Task.SetBlockerRepository(repos.Office) + services.Task.SetCommentRepository(repos.Office) + + // Build feature-package services and wire all inter-service dependencies. + services.OfficeSvcs = buildOfficeFeatureServices( + repos.Office, repos.Task, repos.AgentSettings, cfgLoader, cfgWriter, configBasePath, + agentRegistry, log, services, cfg.Office.JWTSigningKey, + ) + wireOfficeSvcsDependencies(services, repos, eventBus, orchestratorSvc, agentRegistry) + + // Reconcile using the new infra package. + reconciler := officeinfra.NewReconciler(repos.Office, log) + reconciler.ReconcileAll(ctx) + log.Info("Office reconciliation complete") + + // System skill sync. Upserts every embedded SKILL.md (the ones written + // to disk by EnsureBundledSkills above) into office_skills as + // is_system = true rows for each known workspace, removing system + // rows that no longer have a matching embed. Per-agent + // desired_skills references are preserved. + syncSystemSkills(ctx, repos.Office, services, log) + // Backfill default skills onto agents that pre-date the system-skill + // rollout. Idempotent: only agents whose `desired_skills` is empty + // receive defaults; curated lists are left alone. + backfillAgentDefaultSkills(ctx, services, log) + + // Register event subscribers and start scheduler + if err := services.Office.RegisterEventSubscribers(eventBus); err != nil { + log.Error("Failed to register office event subscribers", zap.Error(err)) + return false + } + + startOfficeSchedulersAndGC(ctx, cfg, repos, services, eventBus, orchestratorSvc, log) + return true +} + +// wireOfficeSvcsDependencies wires inter-service dependencies into the +// OfficeSvcs feature package. Extracted to keep initOfficeServices within +// funlen limits. +func wireOfficeSvcsDependencies( + services *Services, + repos *Repositories, + eventBus bus.EventBus, + orchestratorSvc *orchestrator.Service, + agentRegistry *registry.Registry, +) { + // Wire the workflow-domain decisions store so approve/request-changes + // route to workflow_step_decisions (ADR 0005 Wave E). + services.OfficeSvcs.Dashboard.SetDecisionStore(repos.Workflow) + // Wire the event bus into the dashboard service for status-change events. + services.OfficeSvcs.Dashboard.SetEventBus(eventBus) + services.OfficeSvcs.Channels.SetEventBus(eventBus) + // Wire the office service as the channel relay's run resolver so + // relayed-comment activity rows get tagged with the originating + // run id (Tasks Touched on the run detail page). + services.OfficeSvcs.Channels.SetRunResolver(services.Office) + // Wire the office service as the retry canceller for task reassignment. + services.OfficeSvcs.Dashboard.SetRetryCanceller(services.Office) + // Wire the office service as the task canceller for status→cancelled hard-cancels. + services.OfficeSvcs.Dashboard.SetTaskCanceller(services.Office) + // Wire the reactivity pipeline so property mutations queue downstream runs. + services.OfficeSvcs.Dashboard.SetReactivityApplier( + officescheduler.NewDashboardReactivityAdapter(services.OfficeSvcs.Scheduler), + ) + // Wire the approval-flow run queuer so decisions trigger + // task_changes_requested / task_ready_to_close runs. + services.OfficeSvcs.Dashboard.SetApprovalReactivityQueuer( + officescheduler.NewDashboardApprovalAdapter(services.OfficeSvcs.Scheduler), + ) + // Wire the office session terminator so participation removal flips the + // (task, agent) session row to COMPLETED. + officeSessionTerm := orchestratorSvc.OfficeSessionTerminator() + services.OfficeSvcs.Dashboard.SetSessionTerminator(officeSessionTerm) + services.OfficeSvcs.Agents.SetSessionTerminator(officeSessionTerm) + // Wire the failure notifier so reassignments auto-dismiss the + // prior (task, agent) inbox entry. + services.OfficeSvcs.Dashboard.SetFailureNotifier(services.Office) + // Wire the failure-tracking inbox source + dismiss handler so the + // inbox surfaces agent_run_failed / agent_paused_after_failures rows. + services.OfficeSvcs.Dashboard.SetFailureInboxSource( + newOfficeFailureInboxAdapter(services.Office), + ) + services.OfficeSvcs.Dashboard.SetMarkFixedHandler(services.Office) + wireOfficeProviderRouting(services, repos, orchestratorSvc, eventBus, agentRegistry) +} + +// wireOfficeProviderRouting builds the routing resolver + TaskStarter +// adapter and wires the scheduler.SchedulerService as the office +// service's routing dispatcher. No-op effect on non-routing launches +// because the resolver short-circuits when no workspace has routing +// enabled. +func wireOfficeProviderRouting( + services *Services, + repos *Repositories, + orchestratorSvc *orchestrator.Service, + eventBus bus.EventBus, + agentRegistry *registry.Registry, +) { + scheduler := services.OfficeSvcs.Scheduler + resolver := routing.NewResolver(&officeRoutingRepoAdapter{repo: repos.Office}, nil) + scheduler.SetResolver(resolver) + scheduler.SetTaskStarter(&schedulerTaskStarterAdapter{orch: orchestratorSvc}) + scheduler.SetEventBus(eventBus) + services.Office.SetRoutingDispatcher(scheduler) + + provider := routing.NewProvider(repos.Office, agentRegistry, resolver, scheduler) + services.OfficeSvcs.Dashboard.SetRoutingProvider(provider) + services.OfficeSvcs.Dashboard.SetRouteAttemptLister(repos.Office) + services.OfficeSvcs.Agents.SetKnownProvidersFn(func() []routing.ProviderID { + return routing.KnownProviders(agentRegistry) + }) +} + +// officeRoutingRepoAdapter satisfies routing.Repo over the office +// sqlite repo. Lives here (not in the routing package) so the routing +// package stays repo-agnostic. +type officeRoutingRepoAdapter struct { + repo *officesqlite.Repository +} + +func (a *officeRoutingRepoAdapter) GetWorkspaceRouting( + ctx context.Context, workspaceID string, +) (*routing.WorkspaceConfig, error) { + return a.repo.GetWorkspaceRouting(ctx, workspaceID) +} + +func (a *officeRoutingRepoAdapter) ListProviderHealth( + ctx context.Context, workspaceID string, +) ([]officemodels.ProviderHealth, error) { + return a.repo.ListProviderHealth(ctx, workspaceID) +} + +// schedulerTaskStarterAdapter satisfies scheduler.TaskStarter against +// the orchestrator service. +type schedulerTaskStarterAdapter struct { + orch *orchestrator.Service +} + +func (a *schedulerTaskStarterAdapter) StartTask( + ctx context.Context, + taskID, agentProfileID, executorID, executorProfileID string, + priority, prompt, workflowStepID string, + planMode bool, attachments []interface{}, +) error { + _, err := a.orch.StartTask(ctx, taskID, agentProfileID, + executorID, executorProfileID, priority, prompt, + workflowStepID, planMode, false, nil) + return err +} + +func (a *schedulerTaskStarterAdapter) StartTaskWithRoute( + ctx context.Context, + taskID, agentProfileID string, + launch officescheduler.LaunchContext, + route officescheduler.RouteOverride, +) error { + return a.orch.StartTaskWithRoute(ctx, taskID, agentProfileID, + orchexecutor.LaunchContext{ + ExecutorID: launch.ExecutorID, + ExecutorProfileID: launch.ExecutorProfileID, + Priority: launch.Priority, + Prompt: launch.Prompt, + WorkflowStepID: launch.WorkflowStepID, + PlanMode: launch.PlanMode, + Attachments: launch.Attachments, + Env: launch.Env, + }, + orchexecutor.RouteOverride{ + ProviderID: route.ProviderID, + Model: route.Model, + Tier: route.Tier, + Mode: route.Mode, + Flags: route.Flags, + Env: route.Env, + }) +} + +// startOfficeSchedulersAndGC wires the runs service, workflow engine dispatcher, +// runs scheduler, cron scheduler, and GC sweep. Extracted to keep +// initOfficeServices within funlen limits. +func startOfficeSchedulersAndGC( + ctx context.Context, + cfg *config.Config, + repos *Repositories, + services *Services, + eventBus bus.EventBus, + orchestratorSvc *orchestrator.Service, + log *logger.Logger, +) { + log.Info("Office scheduler wired to orchestrator StartTask") + orchScheduler := officeservice.NewSchedulerIntegration( + services.Office, runsscheduler.TickIntervalFromEnv(), + ) + // Office task-handoffs prompt enrichment. The HandoffService is + // constructed alongside the HTTP routes (helpers.go); we stash the + // scheduler reference on the Services struct so registerRoutes can + // wire SetTaskContextProvider once both exist. + services.OrchScheduler = orchScheduler + // Wire the runs queue service so office.QueueRun delegates the + // insert + publish + signal to it (Phase 3 of task-model-unification). + runsSvc := runsservice.New( + repos.Office.RunsRepository(), eventBus, log, nil, + ) + services.Office.SetRunsService(runsSvc) + // Phase 4 (ADR-0004): wire the workflow engine's dependencies and a + // dispatcher so office event subscribers route through the engine + // unconditionally. + engineDispatcher := wireWorkflowEngineForOffice( + orchestratorSvc, services.Office, services.Task, services.Workflow, repos, runsSvc, log, + ) + // Start the runs scheduler (tick + signal listener). It drives + // orchScheduler.Tick on both periodic ticks and event-driven signals. + runScheduler := runsscheduler.New( + orchScheduler, runsSvc.SubscribeSignal(), + runsscheduler.TickIntervalFromEnv(), log, + ) + go runScheduler.Start(ctx) + log.Info("Runs scheduler started", + zap.Duration("tick", runsscheduler.TickIntervalFromEnv())) + // Phase 5 (ADR-0004): start the shared cron loop. The routines handler + // degrades to a no-op when routineSvc is nil, so omitting Office's + // scheduler is safe when features.office is off. + var officeRoutines *officeroutines.RoutineService + if services.OfficeSvcs != nil { + officeRoutines = services.OfficeSvcs.Routines + } + startCronScheduler(ctx, repos, engineDispatcher, officeRoutines, log) + // Start GC sweep for orphaned worktrees and containers. + worktreeBase := filepath.Join(cfg.ResolvedHomeDir(), "tasks") + gc := officeinfra.NewGarbageCollector( + repos.Office, + services.WorktreeMgr, // WorktreeInventory — authoritative live-worktrees source + log, worktreeBase, + nil, // dockerClient - pass if Docker available + 3*time.Hour, + ) + go gc.Start(ctx) + log.Info("Office GC sweep started") +} + +// wireWorkflowEngineForOffice composes the Phase 2 (ADR-0004) +// dependencies the workflow engine needs to evaluate office triggers, +// then builds an engine-dispatcher and hands it to the office service. +// +// Engine options wired here: +// - RunQueueAdapter — runs service (Phase 3.1) +// - ParticipantStore — workflow_step_participants +// - DecisionStore — workflow_step_decisions +// - PrimaryAgentResolver — workflow_steps.agent_profile_id +// - CEOAgentResolver — agent_profiles WHERE role='ceo' AND workspace_id != ” +// +// The orchestrator's engine is rebuilt with these options applied, then +// the office service is given a dispatcher pointing at it. The four +// task-scoped event subscribers (comment, blockers_resolved, +// children_completed, approval_resolved) route through the engine +// unconditionally after Phase 4. +func wireWorkflowEngineForOffice( + orchestratorSvc *orchestrator.Service, + officeSvc *officeservice.Service, + taskSvc *taskservice.Service, + workflowSvc *workflowservice.Service, + repos *Repositories, + runsSvc *runsservice.Service, + log *logger.Logger, +) *officeenginedispatcher.Dispatcher { + // Build the workflow-domain adapters. + participants := workflowadapters.NewParticipantAdapter(repos.Workflow) + decisions := workflowadapters.NewDecisionAdapter(repos.Workflow) + primary := workflowadapters.NewPrimaryAgentAdapter(repos.Workflow) + // Office-domain CEO resolver. + ceo := officeengineadapters.NewCEOAgentAdapter(repos.Office) + // Phase 8 delegation adapters: task creator + workflow switcher. + taskCreator := officeengineadapters.NewTaskCreatorAdapter( + repos.Task, &childTaskCreatorAdapter{taskSvc: taskSvc}) + workflowSwitcher := officeengineadapters.NewWorkflowSwitcherAdapter( + &startStepResolverAdapter{svc: workflowSvc}, repos.Task) + // Wire each dependency via its dedicated setter so the orchestrator + // captures it both for engine.With* options and for the Phase 2 / 8 + // callback registry. + orchestratorSvc.SetEngineRunQueue(&runsServiceEngineAdapter{svc: runsSvc}) + orchestratorSvc.SetEngineParticipantStore(participants) + orchestratorSvc.SetEngineDecisionStore(decisions) + orchestratorSvc.SetEngineCEOResolver(ceo) + orchestratorSvc.SetPrimaryAgentResolver(primary) + orchestratorSvc.SetEngineTaskCreator(taskCreator) + orchestratorSvc.SetEngineWorkflowSwitcher(workflowSwitcher) + eng := orchestratorSvc.WorkflowEngine() + if eng == nil { + log.Warn("workflow engine not initialised; office engine dispatcher disabled") + return nil + } + // Build the dispatcher. The session resolver is the task repo, + // which exposes GetActiveTaskSessionByTaskID. + dispatcher := officeenginedispatcher.New(eng, repos.Task, log) + officeSvc.SetWorkflowEngineDispatcher(dispatcher) + log.Info("workflow engine dispatcher wired for office") + return dispatcher +} + +// runsServiceEngineAdapter bridges runs/service.Service.QueueRun (which +// takes runs/service.QueueRunRequest) to engine.RunQueueAdapter (which +// takes engine.QueueRunRequest). The two structs have identical fields +// — they are intentionally duplicated so neither package imports the +// other — so this adapter is a field-by-field copy. +type runsServiceEngineAdapter struct { + svc *runsservice.Service +} + +func (a *runsServiceEngineAdapter) QueueRun(ctx context.Context, req workflowengine.QueueRunRequest) error { + return a.svc.QueueRun(ctx, runsservice.QueueRunRequest{ + AgentProfileID: req.AgentProfileID, + TaskID: req.TaskID, + WorkflowStepID: req.WorkflowStepID, + Reason: req.Reason, + IdempotencyKey: req.IdempotencyKey, + Payload: req.Payload, + }) +} + +// wireRuntimeSkillDeployer plugs the runtime-tier SkillDeployer into the +// lifecycle manager (ADR 0005 Wave E). The deployer bridges office's +// skills repo + instructions repo to the runtime via small adapters in +// internal/office/skills, so kanban and office launches share a single +// skill-deploy code path. A nil officeSvc / repo leaves the manager +// with the Wave A no-op deployer. +func wireRuntimeSkillDeployer( + lifecycleMgr *lifecycle.Manager, + agentRegistry *registry.Registry, + officeRepo *officesqlite.Repository, + officeSvc *officeservice.Service, + basePath string, + log *logger.Logger, +) { + if lifecycleMgr == nil || officeSvc == nil || officeRepo == nil { + return + } + deployer, err := runtimeskill.New(runtimeskill.Config{ + Logger: log, + BasePath: basePath, + SkillReader: officeskills.NewSkillReaderAdapter(officeSvc), + InstructionLister: officeskills.NewInstructionListerAdapter(officeRepo), + ProjectSkillDirResolver: makeProjectSkillDirResolver(agentRegistry), + WorkspaceSlugFn: makeWorkspaceSlugFn(), + }) + if err != nil { + log.Warn("failed to construct runtime skill deployer; launches will skip skill delivery", + zap.Error(err)) + return + } + lifecycleMgr.SetSkillDeployer(lifecycle.NewSkillDeployerAdapter(deployer)) + log.Info("Runtime skill deployer wired into lifecycle manager") +} + +// makeProjectSkillDirResolver returns a runtimeskill.ProjectSkillDirResolver +// backed by the agent registry. The agent type ID equals the agent_id on +// the agent_profiles row after ADR 0005 — we look up the agent and read +// its declared ProjectSkillDir, falling back to the runtime default. +func makeProjectSkillDirResolver(reg *registry.Registry) runtimeskill.ProjectSkillDirResolver { + if reg == nil { + return nil + } + return func(agentTypeID string) string { + ag, ok := reg.Get(agentTypeID) + if !ok { + return "" + } + if rt := ag.Runtime(); rt != nil && rt.ProjectSkillDir != "" { + return rt.ProjectSkillDir + } + return "" + } +} + +// makeUserSkillDirResolver returns a provider user-skill-dir resolver backed by +// the agent registry. Providers that do not declare a user skill dir are omitted +// from discovery. +func makeUserSkillDirResolver(reg *registry.Registry) officeskills.UserSkillDirResolver { + if reg == nil { + return nil + } + return func(provider string) (string, bool) { + ag, ok := reg.Get(provider) + if !ok { + return "", false + } + if rt := ag.Runtime(); rt != nil && rt.UserSkillDir != "" { + return rt.UserSkillDir, true + } + return "", false + } +} + +// makeWorkspaceSlugFn returns a slug-resolver that maps a workspace ID +// to a slug used in on-host runtime paths. The office stack is currently +// single-workspace-per-install, so every ID resolves to the constant +// "default". When multi-workspace lands, this becomes a real lookup +// against officesqlite.Repository (e.g. GetWorkspaceNameByID) — until +// then we deliberately ignore the workspace ID rather than passing a +// repo dependency that has nothing to query. +func makeWorkspaceSlugFn() func(string) string { + return func(string) string { return "default" } +} + +// initOfficeConfigLoader initialises the filesystem config loader, writes +// officeFailureInboxAdapter forwards from the office Service (which +// returns service-package row types) to the dashboard +// FailureInboxSource interface (which expects dashboard-package row +// types). Trivial conversion — dashboard intentionally avoids +// importing the office repo or service so the package boundary stays +// clean. +type officeFailureInboxAdapter struct { + svc *officeservice.Service +} + +func newOfficeFailureInboxAdapter(svc *officeservice.Service) *officeFailureInboxAdapter { + return &officeFailureInboxAdapter{svc: svc} +} + +func (a *officeFailureInboxAdapter) ListFailedRunInboxRows( + ctx context.Context, workspaceID, userID string, +) ([]officedashboard.FailureInboxRow, error) { + rows, err := a.svc.ListFailedRunInboxRows(ctx, workspaceID, userID) + if err != nil { + return nil, err + } + out := make([]officedashboard.FailureInboxRow, 0, len(rows)) + for _, r := range rows { + out = append(out, officedashboard.FailureInboxRow{ + Kind: "agent_run_failed", + ItemID: r.RunID, + AgentProfileID: r.AgentProfileID, + AgentName: r.AgentName, + TaskID: r.TaskID, + ErrorMessage: r.ErrorMessage, + FailedAt: r.FailedAt, + }) + } + return out, nil +} + +func (a *officeFailureInboxAdapter) ListPausedAgentInboxRows( + ctx context.Context, workspaceID, userID string, +) ([]officedashboard.FailureInboxRow, error) { + rows, err := a.svc.ListPausedAgentInboxRows(ctx, workspaceID, userID) + if err != nil { + return nil, err + } + out := make([]officedashboard.FailureInboxRow, 0, len(rows)) + for _, r := range rows { + out = append(out, officedashboard.FailureInboxRow{ + Kind: "agent_paused_after_failures", + ItemID: r.AgentID, + AgentProfileID: r.AgentID, + AgentName: r.AgentName, + PauseReason: r.PauseReason, + ConsecutiveFailures: r.ConsecutiveFailures, + FailedAt: r.UpdatedAt, + }) + } + return out, nil +} + +// bundled skills, and registers shutdown cleanup for symlinks. +func initOfficeConfigLoader( + basePath string, log *logger.Logger, addCleanup func(func() error), +) (*configloader.ConfigLoader, *configloader.FileWriter) { + cfgLoader := configloader.NewConfigLoader(basePath) + if err := cfgLoader.Load(); err != nil { + log.Error("Failed to load office config from filesystem", zap.Error(err)) + } else { + log.Info("Office config loaded from filesystem", + zap.String("base_path", basePath), + zap.Int("workspaces", len(cfgLoader.GetWorkspaces()))) + } + if err := configloader.EnsureBundledSkills(basePath); err != nil { + log.Error("Failed to write bundled skills", zap.Error(err)) + } else { + slugs, _ := configloader.BundledSkillSlugs() + log.Info("Bundled skills ensured", zap.Strings("slugs", slugs)) + } + return cfgLoader, configloader.NewFileWriter(basePath, cfgLoader) +} + +// syncSystemSkills reconciles the office_skills table against the +// embedded bundled skill set for every known workspace. Pulls the +// workspace list from the task service (workspace ids are shared +// across task + office persistence). Failures are logged but do not +// gate startup — system skills are surfaced lazily by the Skills +// page, which simply shows an empty System group until a later +// retry succeeds. +func syncSystemSkills( + ctx context.Context, + repo *officesqlite.Repository, + services *Services, + log *logger.Logger, +) { + workspaces, err := services.Task.ListWorkspaces(ctx) + if err != nil { + log.Error("system skill sync: list workspaces", zap.Error(err)) + return + } + ids := make([]string, 0, len(workspaces)) + for _, w := range workspaces { + ids = append(ids, w.ID) + } + if _, err := officeskills.SyncSystemSkills(ctx, repo, ids, nil, log); err != nil { + log.Error("system skill sync failed", zap.Error(err)) + } +} + +// backfillAgentDefaultSkills delegates to the agents service so each +// workspace's existing agents inherit the system-skill defaults for +// their role when their desired_skills array is empty. Errors per +// workspace are absorbed inside the service call; startup must not +// fail because of a curated-list edge case. +func backfillAgentDefaultSkills( + ctx context.Context, + services *Services, + log *logger.Logger, +) { + if services.OfficeSvcs == nil || services.OfficeSvcs.Agents == nil { + return + } + workspaces, err := services.Task.ListWorkspaces(ctx) + if err != nil { + log.Error("backfill default skills: list workspaces", zap.Error(err)) + return + } + for _, w := range workspaces { + services.OfficeSvcs.Agents.BackfillDefaultSkillsForWorkspace(ctx, w.ID) + } +} + +// newOfficeTaskStarter wraps orchestratorSvc.StartTaskWithEnv in the +// officeservice.TaskStarterWithEnvFunc adapter. Extracted from +// initOfficeServices to keep that function under the funlen cap. +func newOfficeTaskStarter(orchestratorSvc *orchestrator.Service) officeservice.TaskStarter { + return officeservice.TaskStarterWithEnvFunc( + func(ctx context.Context, taskID, agentProfileID, executorID, + executorProfileID string, priority string, prompt, workflowStepID string, + planMode bool, attachments []v1.MessageAttachment, env map[string]string) error { + _, err := orchestratorSvc.StartTaskWithEnv(ctx, taskID, agentProfileID, + executorID, executorProfileID, priority, prompt, + workflowStepID, planMode, false, attachments, env) + return err + }, + ) +} + +// newAgentAuth wraps officeagents.NewAgentAuth with a dev-mode warning when +// no signing key is configured, so the empty-key fallback can't silently +// invalidate agent tokens on every restart in production. +func newAgentAuth(jwtSigningKey string, log *logger.Logger) *officeagents.AgentAuth { + if jwtSigningKey == "" { + log.Warn("office.jwtSigningKey is empty; generating an ephemeral key. " + + "Agent JWTs will be invalidated on every backend restart. " + + "Set KANDEV_OFFICE_JWTSIGNINGKEY for stable tokens.") + } + return officeagents.NewAgentAuth(jwtSigningKey) +} + +// buildOfficeFeatureServices creates the feature-level office services used by +// the HTTP handler layer (office.RegisterAllRoutes). The monolithic +// services.Office is passed for shared interfaces during the transition period. +func buildOfficeFeatureServices( + repo *officesqlite.Repository, + taskRepo *tasksqlite.Repository, + settingsRepo settingsstore.Repository, + cfgLoader *configloader.ConfigLoader, + cfgWriter *configloader.FileWriter, + homeDir string, + agentRegistry *registry.Registry, + log *logger.Logger, + services *Services, + jwtSigningKey string, +) *office.Services { + activity := officeshared.NewActivityLogger(repo, log) + + agentSvc := officeagents.NewAgentService(repo, log, activity) + agentSvc.SetAuth(newAgentAuth(jwtSigningKey, log)) + if services.Office != nil { + services.Office.SetAgentTokenMinter(agentSvc) + } + skillSvc := officeskills.NewSkillService(repo, log, activity, agentSvc, cfgLoader) + skillSvc.SetUserSkillDirResolver(makeUserSkillDirResolver(agentRegistry)) + projectSvc := officeprojects.NewProjectService(repo, log, activity) + costSvc := officecosts.NewCostService(repo, log, activity, agentSvc, agentSvc) + // Office service delegates budget evaluation (pre-execution + post-event) + // to the costs feature — the only place that owns CRUD for budget policies. + if services.Office != nil { + services.Office.SetBudgetChecker(costSvc) + } + routineSvc := officeroutines.NewRoutineService(repo, log, activity) + // PR 3 of office-heartbeat-rework: wire routines into the wakeup + // dispatcher so the lightweight (taskless) flow enqueues a fresh + // taskless run, and into the task path so the heavy flow creates a + // real task in the routine system workflow. + routineWakeupDispatcher := officewakeup.NewDispatcher(repo, repo, log) + routineWakeupDispatcher.SetRoutineLookup(repo) + routineSvc.SetWakeupEnqueuer(&routineWakeupAdapter{ + repo: repo, + dispatcher: routineWakeupDispatcher, + }) + routineSvc.SetWorkflowEnsurer(&workflowEnsurerAdapter{repo: taskRepo}) + routineSvc.SetTaskCreator(&taskCreatorAdapter{taskSvc: services.Task}) + approvalSvc := officeapprovals.NewApprovalService(repo, log, activity, services.Office) + approvalSvc.SetAgentWriter(agentSvc) + channelSvc := officechannels.NewChannelService(repo, log, activity, agentSvc) + configSvc := officeconfig.NewConfigService(repo, cfgLoader, cfgWriter, log, activity) + dashboardSvc := buildOfficeDashboardService( + repo, log, activity, agentSvc, costSvc, + skillSvc, routineSvc, approvalSvc, + cfgLoader, cfgWriter, + ) + documentSvc := taskservice.NewDocumentService(taskRepo, log) + onboardingSvc := officeonboarding.NewOnboardingService( + repo, cfgLoader, cfgWriter, log, + agentSvc, settingsRepo, agentSvc, + &taskWorkspaceCreatorAdapter{taskSvc: services.Task}, + &workflowEnsurerAdapter{repo: taskRepo}, + &taskCreatorAdapter{taskSvc: services.Task}, + services.Office, + &configSyncerAdapter{svc: configSvc}, + ) + onboardingSvc.SetCoordinatorRoutineInstaller(routineSvc) + schedulerSvc := officescheduler.NewSchedulerService(repo, log, services.Office) + labelSvc := officelabels.NewLabelService(repo) + gitMgr := configloader.NewGitManager(cfgLoader.BasePath(), cfgLoader, log) + + return &office.Services{ + Agents: agentSvc, + Skills: skillSvc, + Projects: projectSvc, + Costs: costSvc, + Routines: routineSvc, + Approvals: approvalSvc, + Channels: channelSvc, + Config: configSvc, + Dashboard: dashboardSvc, + Documents: documentSvc, + Labels: labelSvc, + Onboarding: onboardingSvc, + Scheduler: schedulerSvc, + TreeControls: services.Office, + Workspaces: services.Office, + Repo: repo, + GitManager: gitMgr, + KandevHome: homeDir, + } +} + +// buildOfficeDashboardService constructs the dashboard service and wires +// all of its cross-service dependencies (governance, skill/routine listers, +// settings provider, coordinator-routine installer). Extracted from +// buildOfficeFeatureServices to keep the parent under funlen's 80-line cap. +func buildOfficeDashboardService( + repo *officesqlite.Repository, + log *logger.Logger, + activity officeshared.ActivityLogger, + agentSvc *officeagents.AgentService, + costSvc *officecosts.CostService, + skillSvc *officeskills.SkillService, + routineSvc *officeroutines.RoutineService, + approvalSvc *officeapprovals.ApprovalService, + cfgLoader *configloader.ConfigLoader, + cfgWriter *configloader.FileWriter, +) *officedashboard.DashboardService { + dashboardSvc := officedashboard.NewDashboardService(repo, log, activity, agentSvc, costSvc) + dashboardSvc.SetGovernanceStore(repo) + dashboardSvc.SetSkillLister(skillSvc) + dashboardSvc.SetRoutineLister(routineSvc) + agentSvc.SetGovernanceSettings(dashboardSvc) + agentSvc.SetGovernanceApproval(approvalSvc) + // office-heartbeat-as-routine: every coordinator agent (onboarding or + // post-onboarding via the agents API) gets a "Coordinator heartbeat" + // routine on creation. The routines service is the canonical owner; + // both creators delegate to it via a slim interface. + agentSvc.SetCoordinatorRoutineInstaller(routineSvc) + if cfgLoader != nil && cfgWriter != nil { + dashboardSvc.SetSettingsProvider(&workspaceSettingsProviderAdapter{ + loader: cfgLoader, + writer: cfgWriter, + }) + } + return dashboardSvc +} + +// buildHTTPServer creates the HTTP server with all routes registered. +func buildHTTPServer( + cfg *config.Config, + log *logger.Logger, + gateway *gateways.Gateway, + repos *Repositories, + services *Services, + agentSettingsController *agentsettingscontroller.Controller, + lifecycleMgr *lifecycle.Manager, + eventBus bus.EventBus, + orchestratorSvc *orchestrator.Service, + notificationCtrl *notificationcontroller.Controller, + msgCreator *messageCreatorAdapter, + agentRegistry *registry.Registry, + hostUtilityMgr *hostutility.Manager, + addCleanup func(func() error), + repoCloner *repoclone.Cloner, + systemSvc *systemsvc.Service, +) *http.Server { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(httpmw.RequestLogger(log, "kandev")) + router.Use(httpmw.OtelTracing("kandev")) + router.Use(gin.Recovery()) + router.Use(corsMiddleware()) + + port := cfg.Server.Port + if port == 0 { + port = ports.Backend + } + + registerRoutes(routeParams{ + router: router, + gateway: gateway, + taskSvc: services.Task, + taskRepo: repos.Task, + officeRepo: repos.Office, + analyticsRepo: repos.Analytics, + orchestratorSvc: orchestratorSvc, + lifecycleMgr: lifecycleMgr, + hostUtilityMgr: hostUtilityMgr, + eventBus: eventBus, + services: services, + systemSvc: systemSvc, + runtimeFlagsSvc: services.RuntimeFlags, + agentSettingsController: agentSettingsController, + agentSettingsRepo: repos.AgentSettings, + agentList: agentRegistry, + agentRegistry: agentRegistry, + userCtrl: usercontroller.NewController(services.User), + notificationCtrl: notificationCtrl, + editorCtrl: editorcontroller.NewController(services.Editor), + promptCtrl: promptcontroller.NewController(services.Prompts), + utilityCtrl: utilitycontroller.NewController(services.Utility), + msgCreator: msgCreator, + secretsSvc: secrets.NewService(repos.Secrets, log), + secretStore: repos.Secrets, + mcpConfigSvc: mcpconfig.NewService(repos.AgentSettings), + addCleanup: addCleanup, + repoCloner: repoCloner, + version: Version, + webInternalURL: cfg.Server.WebInternalURL, + devMode: cfg.Debug.DevMode || cfg.Debug.PprofEnabled, + httpPort: port, + features: cfg.Features, + voice: cfg.Voice, + log: log, + }) + + return &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: router, + ReadTimeout: cfg.Server.ReadTimeoutDuration(), + WriteTimeout: cfg.Server.WriteTimeoutDuration(), + } +} + +// awaitShutdown waits for an OS signal then performs graceful shutdown. +func awaitShutdown( + server *http.Server, + orchestratorSvc *orchestrator.Service, + lifecycleMgr *lifecycle.Manager, + runCleanups func(), + log *logger.Logger, +) { + // ============================================ + // GRACEFUL SHUTDOWN + // ============================================ + quit := make(chan os.Signal, 2) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + sig := <-quit + + // If we get a second signal, exit immediately. + go func() { + second := <-quit + log.Warn("Received second shutdown signal, forcing exit", zap.String("signal", second.String())) + _ = log.Close() + os.Exit(1) + }() + + log.Info("Received shutdown signal", zap.String("signal", sig.String())) + runGracefulShutdown(server, orchestratorSvc, lifecycleMgr, runCleanups, log) +} diff --git a/apps/backend/cmd/kandev/metrics_provider.go b/apps/backend/internal/backendapp/metrics_provider.go similarity index 98% rename from apps/backend/cmd/kandev/metrics_provider.go rename to apps/backend/internal/backendapp/metrics_provider.go index e15c32da69..6475389001 100644 --- a/apps/backend/cmd/kandev/metrics_provider.go +++ b/apps/backend/internal/backendapp/metrics_provider.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "fmt" diff --git a/apps/backend/cmd/kandev/metrics_provider_test.go b/apps/backend/internal/backendapp/metrics_provider_test.go similarity index 99% rename from apps/backend/cmd/kandev/metrics_provider_test.go rename to apps/backend/internal/backendapp/metrics_provider_test.go index da61db1b5a..2a6ea65428 100644 --- a/apps/backend/cmd/kandev/metrics_provider_test.go +++ b/apps/backend/internal/backendapp/metrics_provider_test.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "reflect" diff --git a/apps/backend/cmd/kandev/middleware.go b/apps/backend/internal/backendapp/middleware.go similarity index 99% rename from apps/backend/cmd/kandev/middleware.go rename to apps/backend/internal/backendapp/middleware.go index a6151bf992..2f5d860a34 100644 --- a/apps/backend/cmd/kandev/middleware.go +++ b/apps/backend/internal/backendapp/middleware.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "net" diff --git a/apps/backend/cmd/kandev/middleware_test.go b/apps/backend/internal/backendapp/middleware_test.go similarity index 99% rename from apps/backend/cmd/kandev/middleware_test.go rename to apps/backend/internal/backendapp/middleware_test.go index de40988bf6..685aab3251 100644 --- a/apps/backend/cmd/kandev/middleware_test.go +++ b/apps/backend/internal/backendapp/middleware_test.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "net/http" diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go similarity index 99% rename from apps/backend/cmd/kandev/orchestrator.go rename to apps/backend/internal/backendapp/orchestrator.go index 7588e99027..cb489b64eb 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/postgres_boot_test.go b/apps/backend/internal/backendapp/postgres_boot_test.go similarity index 99% rename from apps/backend/cmd/kandev/postgres_boot_test.go rename to apps/backend/internal/backendapp/postgres_boot_test.go index fa0fccc20a..b315dc3303 100644 --- a/apps/backend/cmd/kandev/postgres_boot_test.go +++ b/apps/backend/internal/backendapp/postgres_boot_test.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/internal/backendapp/services.go similarity index 99% rename from apps/backend/cmd/kandev/services.go rename to apps/backend/internal/backendapp/services.go index 4e2ffb9567..1e7ccec32a 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/internal/backendapp/storage.go similarity index 99% rename from apps/backend/cmd/kandev/storage.go rename to apps/backend/internal/backendapp/storage.go index cad4098e92..7642f50a85 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/internal/backendapp/storage.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "fmt" diff --git a/apps/backend/cmd/kandev/turn_adapters.go b/apps/backend/internal/backendapp/turn_adapters.go similarity index 99% rename from apps/backend/cmd/kandev/turn_adapters.go rename to apps/backend/internal/backendapp/turn_adapters.go index dc85d2dec4..92e9b16644 100644 --- a/apps/backend/cmd/kandev/turn_adapters.go +++ b/apps/backend/internal/backendapp/turn_adapters.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/turn_adapters_test.go b/apps/backend/internal/backendapp/turn_adapters_test.go similarity index 99% rename from apps/backend/cmd/kandev/turn_adapters_test.go rename to apps/backend/internal/backendapp/turn_adapters_test.go index f39abf0eaa..bbedabf6a4 100644 --- a/apps/backend/cmd/kandev/turn_adapters_test.go +++ b/apps/backend/internal/backendapp/turn_adapters_test.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/internal/backendapp/types.go similarity index 99% rename from apps/backend/cmd/kandev/types.go rename to apps/backend/internal/backendapp/types.go index b7c743fa92..64eb0b8816 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( settingsstore "github.com/kandev/kandev/internal/agent/settings/store" diff --git a/apps/backend/cmd/kandev/usage_adapter.go b/apps/backend/internal/backendapp/usage_adapter.go similarity index 99% rename from apps/backend/cmd/kandev/usage_adapter.go rename to apps/backend/internal/backendapp/usage_adapter.go index 4af6543549..df924cb1e4 100644 --- a/apps/backend/cmd/kandev/usage_adapter.go +++ b/apps/backend/internal/backendapp/usage_adapter.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/worktree.go b/apps/backend/internal/backendapp/worktree.go similarity index 99% rename from apps/backend/cmd/kandev/worktree.go rename to apps/backend/internal/backendapp/worktree.go index 4968f79822..3ca83c0a3d 100644 --- a/apps/backend/cmd/kandev/worktree.go +++ b/apps/backend/internal/backendapp/worktree.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/cmd/kandev/worktree_test.go b/apps/backend/internal/backendapp/worktree_test.go similarity index 99% rename from apps/backend/cmd/kandev/worktree_test.go rename to apps/backend/internal/backendapp/worktree_test.go index c5cce3da7f..85e37814c4 100644 --- a/apps/backend/cmd/kandev/worktree_test.go +++ b/apps/backend/internal/backendapp/worktree_test.go @@ -1,4 +1,4 @@ -package main +package backendapp import ( "context" diff --git a/apps/backend/internal/launcher/bundle.go b/apps/backend/internal/launcher/bundle.go new file mode 100644 index 0000000000..9eaad894ab --- /dev/null +++ b/apps/backend/internal/launcher/bundle.go @@ -0,0 +1,49 @@ +package launcher + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +type runtimeBundle struct { + Dir string + Launcher string + Source string +} + +func resolveRuntimeBundle() (runtimeBundle, error) { + dir := os.Getenv("KANDEV_BUNDLE_DIR") + if dir == "" { + return runtimeBundle{}, fmt.Errorf("no Kandev runtime found; KANDEV_BUNDLE_DIR is not set") + } + return validateRuntimeBundle(dir, "env") +} + +func validateRuntimeBundle(dir, source string) (runtimeBundle, error) { + launcher := filepath.Join(dir, "bin", executableName("kandev")) + if !exists(launcher) { + return runtimeBundle{}, fmt.Errorf("launcher binary not found in bundle at %s", launcher) + } + agentctl := filepath.Join(dir, "bin", executableName("agentctl")) + if !exists(agentctl) { + return runtimeBundle{}, fmt.Errorf("agentctl binary not found in bundle at %s", agentctl) + } + agentctlLinuxAMD64 := filepath.Join(dir, "bin", "agentctl-linux-amd64") + if requiresAgentctlLinuxAMD64(runtime.GOOS, runtime.GOARCH) && !exists(agentctlLinuxAMD64) { + return runtimeBundle{}, fmt.Errorf("agentctl linux/amd64 helper not found in bundle at %s", agentctlLinuxAMD64) + } + return runtimeBundle{Dir: dir, Launcher: launcher, Source: source}, nil +} + +func requiresAgentctlLinuxAMD64(goos, goarch string) bool { + return goos != "linux" || goarch != "amd64" +} + +func executableName(name string) string { + if os.PathSeparator == '\\' { + return name + ".exe" + } + return name +} diff --git a/apps/backend/internal/launcher/bundle_test.go b/apps/backend/internal/launcher/bundle_test.go new file mode 100644 index 0000000000..a6a257bfa9 --- /dev/null +++ b/apps/backend/internal/launcher/bundle_test.go @@ -0,0 +1,56 @@ +package launcher + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestValidateRuntimeBundleAcceptsSingleBinaryLayout(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "bin", "kandev")) + writeFile(t, filepath.Join(dir, "bin", "agentctl")) + if requiresAgentctlLinuxAMD64(runtime.GOOS, runtime.GOARCH) { + writeFile(t, filepath.Join(dir, "bin", "agentctl-linux-amd64")) + } + + bundle, err := validateRuntimeBundle(dir, "test") + if err != nil { + t.Fatal(err) + } + if bundle.Launcher != filepath.Join(dir, "bin", "kandev") { + t.Fatalf("Launcher = %q", bundle.Launcher) + } +} + +func TestValidateRuntimeBundleRejectsMissingLauncher(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "bin", "agentctl")) + + if _, err := validateRuntimeBundle(dir, "test"); err == nil { + t.Fatal("expected error") + } +} + +func TestRequiresAgentctlLinuxAMD64(t *testing.T) { + if requiresAgentctlLinuxAMD64("linux", "amd64") { + t.Fatal("linux/amd64 should use the native agentctl binary") + } + if !requiresAgentctlLinuxAMD64("linux", "arm64") { + t.Fatal("linux/arm64 should require the linux/amd64 helper") + } + if !requiresAgentctlLinuxAMD64("darwin", "arm64") { + t.Fatal("non-linux hosts should require the linux/amd64 helper") + } +} + +func writeFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/apps/backend/internal/launcher/cli/help.go b/apps/backend/internal/launcher/cli/help.go new file mode 100644 index 0000000000..79271cd55e --- /dev/null +++ b/apps/backend/internal/launcher/cli/help.go @@ -0,0 +1,28 @@ +package cli + +const helpText = `kandev launcher + +Usage: + kandev run [--port ] [--verbose] [--debug] + kandev start [--port ] [--verbose] [--debug] + kandev [--port ] [--verbose] [--debug] + kandev service install|uninstall|start|stop|restart|status|logs [--system] + +Options: + start Use local production build. + run Use installed runtime bundle (default). + service Manage kandev as an OS service. + --version, -V Print CLI version and exit. + --port Port for the Go backend. Alias for --backend-port. + --verbose, -v Show info logs from backend. + --debug Show debug logs + agent message dumps. + --headless Skip opening the browser. Used by service units. + --help, -h Show help. + +Advanced: + --backend-port Same as --port. +` + +func Help() string { + return helpText +} diff --git a/apps/backend/internal/launcher/cli/help_test.go b/apps/backend/internal/launcher/cli/help_test.go new file mode 100644 index 0000000000..48cea1b9f7 --- /dev/null +++ b/apps/backend/internal/launcher/cli/help_test.go @@ -0,0 +1,19 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestHelpDoesNotExposeHiddenBackendMode(t *testing.T) { + help := Help() + if !strings.Contains(help, "kandev run") { + t.Fatalf("help does not describe public run command:\n%s", help) + } + if strings.Contains(help, "kandev dev") || strings.Contains(help, "--dev") { + t.Fatalf("help exposes unsupported dev mode:\n%s", help) + } + if strings.Contains(help, "__backend") { + t.Fatalf("help exposes hidden backend mode:\n%s", help) + } +} diff --git a/apps/backend/internal/launcher/constants.go b/apps/backend/internal/launcher/constants.go new file mode 100644 index 0000000000..8e02d7c8d4 --- /dev/null +++ b/apps/backend/internal/launcher/constants.go @@ -0,0 +1,38 @@ +package launcher + +import ( + "os" + "path/filepath" + "strings" +) + +const ( + defaultBackendPort = 38429 + defaultAgentctlPort = 39429 + + healthTimeoutReleaseMS = 45000 + randomPortMin = 10000 + randomPortMax = 60000 +) + +func resolveHomeDir() string { + if v := strings.TrimSpace(os.Getenv("KANDEV_HOME_DIR")); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return ".kandev" + } + return filepath.Join(home, ".kandev") +} + +func resolveDataDir() string { + return filepath.Join(resolveHomeDir(), "data") +} + +func resolveDatabasePath() string { + if v := strings.TrimSpace(os.Getenv("KANDEV_DATABASE_PATH")); v != "" { + return v + } + return filepath.Join(resolveDataDir(), "kandev.db") +} diff --git a/apps/backend/internal/launcher/data_dir.go b/apps/backend/internal/launcher/data_dir.go new file mode 100644 index 0000000000..20b79ce682 --- /dev/null +++ b/apps/backend/internal/launcher/data_dir.go @@ -0,0 +1,41 @@ +package launcher + +import ( + "fmt" + "os" + "path/filepath" +) + +func ensureDataDir() error { + dir := resolveDataDir() + if err := rejectSymlinkComponents(dir); err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + return nil +} + +func rejectSymlinkComponents(target string) error { + abs, err := filepath.Abs(target) + if err != nil { + return err + } + for current := abs; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to use symlinked data path component: %s", current) + } + if err != nil && !os.IsNotExist(err) { + return err + } + parent := filepath.Dir(current) + if parent == current { + return nil + } + } +} diff --git a/apps/backend/internal/launcher/data_dir_test.go b/apps/backend/internal/launcher/data_dir_test.go new file mode 100644 index 0000000000..5f3f1e6b9f --- /dev/null +++ b/apps/backend/internal/launcher/data_dir_test.go @@ -0,0 +1,27 @@ +package launcher + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestRejectSymlinkComponentsRejectsParentSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink permissions vary on Windows") + } + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "link") + if err := os.Symlink(realDir, link); err != nil { + t.Fatal(err) + } + + if err := rejectSymlinkComponents(filepath.Join(link, "data")); err == nil { + t.Fatal("expected symlink component to be rejected") + } +} diff --git a/apps/backend/internal/launcher/doc.go b/apps/backend/internal/launcher/doc.go new file mode 100644 index 0000000000..23b85f0998 --- /dev/null +++ b/apps/backend/internal/launcher/doc.go @@ -0,0 +1,5 @@ +// Package launcher implements the native kandev terminal entrypoint. +// +// Exit codes follow the usual CLI convention: 0 means success, 2 means usage +// or parse errors, and 1 means runtime failures after arguments are accepted. +package launcher diff --git a/apps/backend/internal/launcher/env.go b/apps/backend/internal/launcher/env.go new file mode 100644 index 0000000000..7853005394 --- /dev/null +++ b/apps/backend/internal/launcher/env.go @@ -0,0 +1,32 @@ +package launcher + +import ( + "fmt" + "os" +) + +func backendEnv(ports portConfig, logLevel string, debug bool) []string { + env := os.Environ() + env = upsertEnv(env, "KANDEV_SERVER_PORT", fmt.Sprint(ports.BackendPort)) + env = upsertEnv(env, "KANDEV_AGENT_STANDALONE_PORT", fmt.Sprint(ports.AgentctlPort)) + env = upsertEnv(env, "KANDEV_DATABASE_PATH", resolveDatabasePath()) + if logLevel != "" { + env = upsertEnv(env, "KANDEV_LOG_LEVEL", logLevel) + } + if debug { + env = upsertEnv(env, "KANDEV_DEBUG_AGENT_MESSAGES", "true") + env = upsertEnv(env, "KANDEV_DEBUG_PPROF_ENABLED", "true") + } + return env +} + +func upsertEnv(env []string, key, value string) []string { + prefix := key + "=" + for i, item := range env { + if len(item) >= len(prefix) && item[:len(prefix)] == prefix { + env[i] = prefix + value + return env + } + } + return append(env, prefix+value) +} diff --git a/apps/backend/internal/launcher/health.go b/apps/backend/internal/launcher/health.go new file mode 100644 index 0000000000..24c4074b30 --- /dev/null +++ b/apps/backend/internal/launcher/health.go @@ -0,0 +1,50 @@ +package launcher + +import ( + "fmt" + "net/http" + "os" + "strconv" + "time" +) + +type childState interface { + Exited() (bool, int) +} + +func healthTimeout(defaultMS int) time.Duration { + raw := os.Getenv("KANDEV_HEALTH_TIMEOUT_MS") + if raw == "" { + return time.Duration(defaultMS) * time.Millisecond + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return time.Duration(defaultMS) * time.Millisecond + } + return time.Duration(n) * time.Millisecond +} + +func waitForHealth(baseURL string, proc childState, timeout time.Duration, onFailure func()) error { + deadline := time.Now().Add(timeout) + healthURL := baseURL + "/health" + for time.Now().Before(deadline) { + if exited, code := proc.Exited(); exited { + if onFailure != nil { + onFailure() + } + return fmt.Errorf("backend exited (code %d) before healthcheck passed", code) + } + resp, err := http.Get(healthURL) //nolint:gosec,noctx + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + } + time.Sleep(300 * time.Millisecond) + } + if onFailure != nil { + onFailure() + } + return fmt.Errorf("backend healthcheck timed out after %s at %s", timeout, healthURL) +} diff --git a/apps/backend/internal/launcher/launcher.go b/apps/backend/internal/launcher/launcher.go new file mode 100644 index 0000000000..df20755a19 --- /dev/null +++ b/apps/backend/internal/launcher/launcher.go @@ -0,0 +1,41 @@ +package launcher + +import ( + "fmt" + "os" + + "github.com/kandev/kandev/internal/launcher/cli" +) + +type BuildInfo struct { + Version string + Commit string + BuildTime string +} + +func Run(args []string, build BuildInfo) int { + if len(args) > 0 && args[0] == "service" { + return runService(args[1:], build) + } + opts, err := parseArgs(args) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 2 + } + if opts.ShowVersion { + fmt.Println(build.Version) + return 0 + } + if opts.ShowHelp { + fmt.Print(cli.Help()) + return 0 + } + switch opts.Command { + case CommandStart: + return runStart(opts) + case CommandRun: + return runInstalled(opts) + } + fmt.Fprintf(os.Stderr, "[kandev] native launcher command %q is not implemented yet\n", opts.Command) + return 1 +} diff --git a/apps/backend/internal/launcher/options.go b/apps/backend/internal/launcher/options.go new file mode 100644 index 0000000000..38899ef646 --- /dev/null +++ b/apps/backend/internal/launcher/options.go @@ -0,0 +1,146 @@ +package launcher + +import ( + "fmt" + "strconv" + "strings" +) + +type Command string + +const ( + CommandRun Command = "run" + CommandStart Command = "start" +) + +type Options struct { + Command Command + BackendPort int + Verbose bool + Debug bool + ShowVersion bool + Headless bool + ShowHelp bool +} + +type ParseError struct { + Message string +} + +func (e ParseError) Error() string { + return e.Message +} + +func parseArgs(argv []string) (Options, error) { + opts := Options{Command: CommandRun} + for i := 0; i < len(argv); i++ { + next, err := parseArgAt(argv, i, &opts) + if err != nil { + return opts, err + } + i = next + } + return opts, nil +} + +func parseArgAt(argv []string, i int, opts *Options) (int, error) { + arg := argv[i] + if parseSimpleArg(arg, opts) { + return i, nil + } + if isRuntimeVersionArg(arg) { + return i, ParseError{Message: "--runtime-version is not supported by the native launcher"} + } + if next, ok, err := parsePortArg(argv, i, opts); ok || err != nil { + return next, err + } + return i, ParseError{Message: fmt.Sprintf("unknown argument %q", arg)} +} + +func parseSimpleArg(arg string, opts *Options) bool { + return parseCommandArg(arg, opts) || parseBooleanFlag(arg, opts) +} + +func parseCommandArg(arg string, opts *Options) bool { + switch arg { + case "run": + opts.Command = CommandRun + case string(CommandStart): + opts.Command = CommandStart + default: + return false + } + return true +} + +func parseBooleanFlag(arg string, opts *Options) bool { + switch arg { + case "--help", "-h", "help": + opts.ShowHelp = true + case "--version", "-V": + opts.ShowVersion = true + case "--verbose", "-v": + opts.Verbose = true + case "--debug": + opts.Debug = true + case "--headless", "--no-browser": + opts.Headless = true + default: + return false + } + return true +} + +func isRuntimeVersionArg(arg string) bool { + return arg == "--runtime-version" || strings.HasPrefix(arg, "--runtime-version=") +} + +func parsePortArg(argv []string, i int, opts *Options) (int, bool, error) { + arg := argv[i] + switch { + case arg == "--port" || arg == "--backend-port": + next, err := parseBackendPort(argv, i, opts) + return next, true, err + case strings.HasPrefix(arg, "--port="): + return i, true, parseBackendPortValue("--port", strings.TrimPrefix(arg, "--port="), opts) + case strings.HasPrefix(arg, "--backend-port="): + return i, true, parseBackendPortValue("--backend-port", strings.TrimPrefix(arg, "--backend-port="), opts) + default: + return i, false, nil + } +} + +func parseBackendPort(argv []string, i int, opts *Options) (int, error) { + v, err := takeValue(argv, i, argv[i]) + if err != nil { + return i, err + } + if err := parseBackendPortValue(argv[i], v, opts); err != nil { + return i, err + } + return i + 1, nil +} + +func parseBackendPortValue(flag, value string, opts *Options) error { + port, err := parsePort(value, flag) + if err != nil { + return err + } + opts.BackendPort = port + return nil +} + +func takeValue(argv []string, i int, flag string) (string, error) { + if i+1 >= len(argv) || strings.HasPrefix(argv[i+1], "-") { + return "", ParseError{Message: fmt.Sprintf("%s requires a value", flag)} + } + return argv[i+1], nil +} + +func parsePort(raw, flag string) (int, error) { + n, err := strconv.Atoi(raw) + if raw == "" || err != nil || n < 1 || n > 65535 { + return 0, ParseError{Message: fmt.Sprintf("%s value must be an integer between 1 and 65535, got %q", flag, raw)} + } + return n, nil +} diff --git a/apps/backend/internal/launcher/options_test.go b/apps/backend/internal/launcher/options_test.go new file mode 100644 index 0000000000..dc91fa4488 --- /dev/null +++ b/apps/backend/internal/launcher/options_test.go @@ -0,0 +1,59 @@ +package launcher + +import "testing" + +func TestParseArgsStartPortsAndHeadless(t *testing.T) { + opts, err := parseArgs([]string{"start", "--port", "1234", "--headless"}) + if err != nil { + t.Fatal(err) + } + if opts.Command != CommandStart { + t.Fatalf("Command = %q, want %q", opts.Command, CommandStart) + } + if opts.BackendPort != 1234 || !opts.Headless { + t.Fatalf("parsed options = %+v", opts) + } +} + +func TestParseArgsRejectsInvalidPort(t *testing.T) { + _, err := parseArgs([]string{"--port", "70000"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestParseArgsRejectsRemovedWebPort(t *testing.T) { + _, err := parseArgs([]string{"--web-port", "12345"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestParseArgsRejectsUnsupportedDevMode(t *testing.T) { + for _, argv := range [][]string{{"dev"}, {"--dev"}} { + _, err := parseArgs(argv) + if err == nil { + t.Fatalf("parseArgs(%v) returned nil error", argv) + } + if _, ok := err.(ParseError); !ok { + t.Fatalf("parseArgs(%v) error = %T, want ParseError", argv, err) + } + } +} + +func TestParseArgsRejectsUnsupportedRuntimeVersion(t *testing.T) { + for _, argv := range [][]string{ + {"--runtime-version", "v1.2.3"}, + {"--runtime-version=v1.2.3"}, + {"--runtime-version"}, + {"--runtime-version="}, + } { + _, err := parseArgs(argv) + if err == nil { + t.Fatalf("parseArgs(%v) returned nil error", argv) + } + if err.Error() != "--runtime-version is not supported by the native launcher" { + t.Fatalf("parseArgs(%v) error = %q", argv, err) + } + } +} diff --git a/apps/backend/internal/launcher/ports.go b/apps/backend/internal/launcher/ports.go new file mode 100644 index 0000000000..85529494c1 --- /dev/null +++ b/apps/backend/internal/launcher/ports.go @@ -0,0 +1,91 @@ +package launcher + +import ( + "fmt" + "math/rand" + "net" + "os" + "strconv" + "time" +) + +type portConfig struct { + BackendPort int + AgentctlPort int + BackendURL string +} + +func resolvePorts(opts Options) (int, error) { + backend := opts.BackendPort + if backend == 0 { + if p, err := envPort("KANDEV_BACKEND_PORT"); err != nil { + return 0, err + } else if p != 0 { + backend = p + } else if p, err := envPort("KANDEV_PORT"); err != nil { + return 0, err + } else { + backend = p + } + } + return backend, nil +} + +func envPort(name string) (int, error) { + raw, ok := os.LookupEnv(name) + if !ok { + return 0, nil + } + n, err := strconv.Atoi(raw) + if raw == "" || err != nil || n < 1 || n > 65535 { + return 0, ParseError{Message: fmt.Sprintf("%s must be an integer between 1 and 65535, got %q", name, raw)} + } + return n, nil +} + +func pickPorts(backendPort int) (portConfig, error) { + used := map[int]bool{} + backend := backendPort + if backend == 0 { + p, err := pickAvailablePortExcept(defaultBackendPort, used) + if err != nil { + return portConfig{}, err + } + backend = p + } + used[backend] = true + agentctl, err := pickAvailablePortExcept(defaultAgentctlPort, used) + if err != nil { + return portConfig{}, err + } + return portConfig{ + BackendPort: backend, + AgentctlPort: agentctl, + BackendURL: fmt.Sprintf("http://localhost:%d", backend), + }, nil +} + +func pickAvailablePortExcept(preferred int, used map[int]bool) (int, error) { + if canBind(preferred) { + if !used[preferred] { + return preferred, nil + } + } + r := rand.New(rand.NewSource(time.Now().UnixNano())) + for i := 0; i < 10; i++ { + candidate := randomPortMin + r.Intn(randomPortMax-randomPortMin+1) + if !used[candidate] && canBind(candidate) { + return candidate, nil + } + } + return 0, fmt.Errorf("unable to find a free port") +} + +func canBind(port int) bool { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return false + } + _ = ln.Close() + return true +} diff --git a/apps/backend/internal/launcher/ports_test.go b/apps/backend/internal/launcher/ports_test.go new file mode 100644 index 0000000000..a8b43fb3d5 --- /dev/null +++ b/apps/backend/internal/launcher/ports_test.go @@ -0,0 +1,13 @@ +package launcher + +import "testing" + +func TestPickAvailablePortExceptSkipsUsedPreferredPort(t *testing.T) { + port, err := pickAvailablePortExcept(defaultBackendPort, map[int]bool{defaultBackendPort: true}) + if err != nil { + t.Fatal(err) + } + if port == defaultBackendPort { + t.Fatalf("picked reserved preferred port %d", port) + } +} diff --git a/apps/backend/internal/launcher/process.go b/apps/backend/internal/launcher/process.go new file mode 100644 index 0000000000..5447f18db2 --- /dev/null +++ b/apps/backend/internal/launcher/process.go @@ -0,0 +1,151 @@ +package launcher + +import ( + "fmt" + "os" + "os/exec" + "os/signal" + "sync" + "syscall" +) + +const capturedOutputLimit = 64 * 1024 + +type processSupervisor struct { + mu sync.Mutex + children []*managedProcess +} + +type managedProcess struct { + cmd *exec.Cmd + exitCode int + exited bool + mu sync.Mutex + done chan struct{} +} + +func newSupervisor() *processSupervisor { + return &processSupervisor{} +} + +func (s *processSupervisor) add(proc *managedProcess) { + s.mu.Lock() + defer s.mu.Unlock() + s.children = append(s.children, proc) +} + +func (s *processSupervisor) shutdown(reason string) { + fmt.Printf("[kandev] shutting down (%s)...\n", reason) + s.mu.Lock() + children := append([]*managedProcess(nil), s.children...) + s.mu.Unlock() + for _, child := range children { + child.kill() + } +} + +func (s *processSupervisor) attachSignals() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt, syscall.SIGTERM) + go func() { + sig := <-ch + s.shutdown("signal " + sig.String()) + os.Exit(0) + }() +} + +func startProcess(command string, args []string, cwd string, env []string, quiet bool, label string, supervisor *processSupervisor) (*managedProcess, func(), error) { + cmd := exec.Command(command, args...) + cmd.Dir = cwd + cmd.Env = env + cmd.Stdin = nil + configureManagedProcess(cmd) + stdout := newLimitedBuffer(capturedOutputLimit) + if quiet { + cmd.Stdout = stdout + cmd.Stderr = os.Stderr + } else { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + if err := cmd.Start(); err != nil { + return nil, nil, err + } + proc := &managedProcess{cmd: cmd, done: make(chan struct{})} + supervisor.add(proc) + go func() { + err := cmd.Wait() + code := 0 + if err != nil { + code = 1 + if exitErr, ok := err.(*exec.ExitError); ok { + code = exitErr.ExitCode() + } + } + proc.mu.Lock() + proc.exitCode = code + proc.exited = true + proc.mu.Unlock() + close(proc.done) + if label != "" && code != 0 { + fmt.Fprintf(os.Stderr, "[kandev] %s exited (code=%d)\n", label, code) + } + }() + return proc, func() { + snapshot := stdout.Bytes() + if len(snapshot) == 0 { + return + } + fmt.Fprintln(os.Stderr, "[kandev] --- backend stdout (last captured output) ---") + _, _ = os.Stderr.Write(snapshot) + fmt.Fprintln(os.Stderr, "[kandev] --- end backend stdout ---") + }, nil +} + +type limitedBuffer struct { + mu sync.Mutex + limit int + buf []byte +} + +func newLimitedBuffer(limit int) *limitedBuffer { + return &limitedBuffer{limit: limit} +} + +func (b *limitedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.buf = append(b.buf, p...) + if len(b.buf) > b.limit { + b.buf = append([]byte(nil), b.buf[len(b.buf)-b.limit:]...) + } + return len(p), nil +} + +func (b *limitedBuffer) Bytes() []byte { + b.mu.Lock() + defer b.mu.Unlock() + return append([]byte(nil), b.buf...) +} + +func (p *managedProcess) Exited() (bool, int) { + p.mu.Lock() + defer p.mu.Unlock() + return p.exited, p.exitCode +} + +func (p *managedProcess) kill() { + if p.cmd.Process == nil { + return + } + if err := killManagedProcessGroup(p.cmd.Process.Pid); err != nil { + _ = p.cmd.Process.Kill() + } + <-p.done +} + +func waitForAppExit(supervisor *processSupervisor, backend *restartableBackend) int { + code := <-backend.exitCh + supervisor.shutdown("backend exit") + return code +} diff --git a/apps/backend/internal/launcher/process_group_default.go b/apps/backend/internal/launcher/process_group_default.go new file mode 100644 index 0000000000..330fb48c72 --- /dev/null +++ b/apps/backend/internal/launcher/process_group_default.go @@ -0,0 +1,15 @@ +//go:build !linux && !darwin + +package launcher + +import ( + "errors" + "os/exec" +) + +func configureManagedProcess(_ *exec.Cmd) { +} + +func killManagedProcessGroup(_ int) error { + return errors.New("process groups are not supported on this platform") +} diff --git a/apps/backend/internal/launcher/process_group_unix.go b/apps/backend/internal/launcher/process_group_unix.go new file mode 100644 index 0000000000..5d9c7b1831 --- /dev/null +++ b/apps/backend/internal/launcher/process_group_unix.go @@ -0,0 +1,16 @@ +//go:build linux || darwin + +package launcher + +import ( + "os/exec" + "syscall" +) + +func configureManagedProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +func killManagedProcessGroup(pid int) error { + return syscall.Kill(-pid, syscall.SIGKILL) +} diff --git a/apps/backend/internal/launcher/process_group_unix_test.go b/apps/backend/internal/launcher/process_group_unix_test.go new file mode 100644 index 0000000000..0bd54b22a3 --- /dev/null +++ b/apps/backend/internal/launcher/process_group_unix_test.go @@ -0,0 +1,17 @@ +//go:build linux || darwin + +package launcher + +import ( + "os/exec" + "testing" +) + +func TestConfigureManagedProcessCreatesProcessGroup(t *testing.T) { + cmd := exec.Command("kandev") + configureManagedProcess(cmd) + + if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid { + t.Fatalf("configureManagedProcess should set Setpgid") + } +} diff --git a/apps/backend/internal/launcher/process_test.go b/apps/backend/internal/launcher/process_test.go new file mode 100644 index 0000000000..93947ea6d7 --- /dev/null +++ b/apps/backend/internal/launcher/process_test.go @@ -0,0 +1,33 @@ +package launcher + +import ( + "bytes" + "testing" +) + +func TestLimitedBufferKeepsOnlyTail(t *testing.T) { + buf := newLimitedBuffer(5) + if _, err := buf.Write([]byte("hello")); err != nil { + t.Fatal(err) + } + if _, err := buf.Write([]byte("world")); err != nil { + t.Fatal(err) + } + + if got := string(buf.Bytes()); got != "world" { + t.Fatalf("buffer tail = %q, want world", got) + } +} + +func TestLimitedBufferBytesReturnsSnapshot(t *testing.T) { + buf := newLimitedBuffer(10) + if _, err := buf.Write([]byte("abc")); err != nil { + t.Fatal(err) + } + snapshot := buf.Bytes() + snapshot[0] = 'x' + + if !bytes.Equal(buf.Bytes(), []byte("abc")) { + t.Fatalf("mutating snapshot changed buffer: %q", buf.Bytes()) + } +} diff --git a/apps/backend/internal/launcher/run.go b/apps/backend/internal/launcher/run.go new file mode 100644 index 0000000000..8396dea0b9 --- /dev/null +++ b/apps/backend/internal/launcher/run.go @@ -0,0 +1,44 @@ +package launcher + +import ( + "fmt" + "os" + "path/filepath" +) + +func runInstalled(opts Options) int { + backendPort, err := resolvePorts(opts) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 2 + } + ports, err := pickPorts(backendPort) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + bundle, err := resolveRuntimeBundle() + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := ensureDataDir(); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + + logLevel := resolveLogLevel(opts) + releaseTag := os.Getenv("KANDEV_VERSION") + if releaseTag == "" { + releaseTag = "(" + bundle.Source + ")" + } + return launchManaged(managedAppConfig{ + Header: "release: " + releaseTag, + Mode: "run", + Backend: bundle.Launcher, + BackendCWD: filepath.Dir(bundle.Launcher), + Ports: ports, + LogLevel: logLevel, + Opts: opts, + }) +} diff --git a/apps/backend/internal/launcher/service.go b/apps/backend/internal/launcher/service.go new file mode 100644 index 0000000000..96da2cc8d5 --- /dev/null +++ b/apps/backend/internal/launcher/service.go @@ -0,0 +1,609 @@ +package launcher + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strings" +) + +type serviceArgs struct { + Action string + System bool + Port int + HomeDir string + NoBootStart bool + Follow bool + ShowHelp bool +} + +const ( + actionInstall = "install" + actionUninstall = "uninstall" + actionRestart = "restart" + actionConfig = "config" + actionLogs = "logs" + actionStatus = "status" + actionStop = "stop" + flagHelp = "--help" + goosLinux = "linux" + goosDarwin = "darwin" + managedMarker = "managed by kandev" + serviceUnitName = "kandev.service" +) + +const serviceHelp = `kandev service — install kandev as an OS-managed service + +Usage: + kandev service install [--system] [--port ] [--home-dir ] [--no-boot-start] + kandev service uninstall [--system] + kandev service start|stop|restart|status [--system] + kandev service logs [-f] [--system] + kandev service config [--system] +` + +var ( + executeServiceCommand = defaultRunCommand + servicePrintln = func(message string) { fmt.Println(message) } +) + +func runService(argv []string, build BuildInfo) int { + args, err := parseServiceArgs(argv) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 2 + } + if args.ShowHelp { + fmt.Print(serviceHelp) + return 0 + } + if args.Action == actionConfig { + printServiceConfig(args) + return 0 + } + switch runtime.GOOS { + case goosLinux: + return runLinuxService(args, build) + case goosDarwin: + return runLaunchdService(args, build) + default: + fmt.Fprintf(os.Stderr, "[kandev] service is not supported on %s\n", runtime.GOOS) + return 1 + } +} + +func parseServiceArgs(argv []string) (serviceArgs, error) { + if len(argv) == 0 || argv[0] == flagHelp || argv[0] == "-h" { + return serviceArgs{Action: actionInstall, ShowHelp: true}, nil + } + action := argv[0] + if !validServiceAction(action) { + return serviceArgs{}, ParseError{Message: fmt.Sprintf("unknown service action %q", action)} + } + out := serviceArgs{Action: action} + for i := 1; i < len(argv); i++ { + next, err := parseServiceFlag(argv, i, action, &out) + if err != nil { + return out, err + } + i = next + } + return out, nil +} + +func validServiceAction(action string) bool { + switch action { + case actionInstall, actionUninstall, string(CommandStart), actionStop, actionRestart, actionStatus, actionLogs, actionConfig: + return true + default: + return false + } +} + +func parseServiceFlag(argv []string, i int, action string, out *serviceArgs) (int, error) { + arg := argv[i] + switch { + case arg == flagHelp || arg == "-h": + out.ShowHelp = true + case arg == "--system": + out.System = true + case arg == "--no-boot-start": + out.NoBootStart = true + case arg == "-f" || arg == "--follow": + out.Follow = true + case arg == "--port": + return parseServicePort(argv, i, out) + case strings.HasPrefix(arg, "--port="): + return i, parseServicePortValue(strings.TrimPrefix(arg, "--port="), out) + case arg == "--home-dir": + return parseServiceHomeDir(argv, i, out) + case strings.HasPrefix(arg, "--home-dir="): + out.HomeDir = expandHome(strings.TrimPrefix(arg, "--home-dir=")) + default: + return i, ParseError{Message: fmt.Sprintf("unknown flag %q for kandev service %s", arg, action)} + } + return i, nil +} + +func parseServicePort(argv []string, i int, out *serviceArgs) (int, error) { + v, err := takeValue(argv, i, "--port") + if err != nil { + return i, err + } + if err := parseServicePortValue(v, out); err != nil { + return i, err + } + return i + 1, nil +} + +func parseServicePortValue(value string, out *serviceArgs) error { + port, err := parsePort(value, "--port") + if err != nil { + return err + } + out.Port = port + return nil +} + +func parseServiceHomeDir(argv []string, i int, out *serviceArgs) (int, error) { + v, err := takeValue(argv, i, "--home-dir") + if err != nil { + return i, err + } + out.HomeDir = expandHome(v) + return i + 1, nil +} + +func runLinuxService(args serviceArgs, build BuildInfo) int { + unitPath := linuxUnitPath(args.System) + switch args.Action { + case actionInstall: + return installSystemd(args, build, unitPath) + case actionUninstall: + _ = runCommand("systemctl", append(systemctlScope(args.System), "disable", "--now", serviceUnitName)...) + _ = os.Remove(unitPath) + _ = runCommand("systemctl", append(systemctlScope(args.System), "daemon-reload")...) + return 0 + case string(CommandStart), actionStop, actionRestart, actionStatus: + if err := runCommand("systemctl", append(systemctlScope(args.System), args.Action, serviceUnitName)...); err != nil { + return 1 + } + return 0 + case actionLogs: + if err := runCommand("journalctl", buildJournalArgs(args)...); err != nil { + return 1 + } + return 0 + } + return 1 +} + +func buildJournalArgs(args serviceArgs) []string { + journalArgs := []string{"-n", "200", "--no-pager"} + if args.System { + journalArgs = append([]string{"-u", serviceUnitName}, journalArgs...) + } else { + journalArgs = append([]string{"--user-unit", serviceUnitName}, journalArgs...) + } + if args.Follow { + journalArgs = journalArgs[:len(journalArgs)-1] + journalArgs = append(journalArgs, "-f") + } + return journalArgs +} + +func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int { + self, err := executablePath() + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + homeDir := serviceHomeDir(args) + logDir := filepath.Join(homeDir, "logs") + if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + unit := renderSystemdUnit(nativeServiceUnitInput{ + Executable: self, + HomeDir: homeDir, + LogDir: logDir, + Port: args.Port, + System: args.System, + SystemUser: serviceUser(args.System), + BundleDir: serviceBundleDir(self), + Version: serviceVersion(build.Version), + }) + if err := backupUnmanagedServiceFile(unitPath); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := runCommand("systemctl", append(systemctlScope(args.System), "daemon-reload")...); err != nil { + return 1 + } + if args.NoBootStart { + if err := runCommand("systemctl", append(systemctlScope(args.System), string(CommandStart), serviceUnitName)...); err != nil { + return 1 + } + servicePrintln("[kandev] service installed and started (boot-start disabled)") + return 0 + } + if err := runCommand("systemctl", append(systemctlScope(args.System), "enable", "--now", serviceUnitName)...); err != nil { + return 1 + } + servicePrintln("[kandev] service enabled and started") + return 0 +} + +func runLaunchdService(args serviceArgs, build BuildInfo) int { + plistPath := launchdPlistPath(args.System) + target := "gui/" + fmt.Sprint(os.Getuid()) + "/com.kdlbs.kandev" + domain := "gui/" + fmt.Sprint(os.Getuid()) + if args.System { + target = "system/com.kdlbs.kandev" + domain = "system" + } + switch args.Action { + case actionInstall: + return installLaunchd(args, build, plistPath, target, domain) + case "uninstall": + _ = runCommand("launchctl", "bootout", target) + _ = os.Remove(plistPath) + return 0 + case "start": + if err := runCommand("launchctl", "kickstart", target); err != nil { + return runCommandExit("launchctl", "bootstrap", domain, plistPath) + } + return 0 + case "stop": + _ = runCommand("launchctl", "bootout", target) + return 0 + case actionRestart: + if err := runCommand("launchctl", "kickstart", "-k", target); err != nil { + _ = runCommand("launchctl", "bootout", target) + return runCommandExit("launchctl", "bootstrap", domain, plistPath) + } + return 0 + case "status": + return runCommandExit("launchctl", "print", target) + case "logs": + logDir := filepath.Join(serviceHomeDir(args), "logs") + tailArgs := []string{"-n", "200", filepath.Join(logDir, "service.out"), filepath.Join(logDir, "service.err")} + if args.Follow { + tailArgs = []string{"-f", "-n", "200", filepath.Join(logDir, "service.out"), filepath.Join(logDir, "service.err")} + } + return runCommandExit("tail", tailArgs...) + } + return 1 +} + +func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain string) int { + self, err := executablePath() + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + homeDir := serviceHomeDir(args) + logDir := filepath.Join(homeDir, "logs") + if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := os.MkdirAll(logDir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + plist := renderLaunchdPlist(nativeServiceUnitInput{ + Executable: self, + HomeDir: homeDir, + LogDir: logDir, + Port: args.Port, + System: args.System, + SystemUser: serviceUser(args.System), + NoBootStart: args.NoBootStart, + BundleDir: serviceBundleDir(self), + Version: serviceVersion(build.Version), + }) + if err := backupUnmanagedServiceFile(plistPath); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + _ = runCommand("launchctl", "bootout", target) + if err := runCommand("launchctl", "bootstrap", domain, plistPath); err != nil { + return 1 + } + _ = runCommand("launchctl", "enable", target) + if args.NoBootStart { + if err := runCommand("launchctl", "kickstart", target); err != nil { + return 1 + } + servicePrintln("[kandev] service loaded and started (boot-start disabled)") + return 0 + } + servicePrintln("[kandev] service loaded, enabled, and started") + return 0 +} + +type nativeServiceUnitInput struct { + Executable string + HomeDir string + LogDir string + Port int + System bool + SystemUser string + NoBootStart bool + BundleDir string + Version string +} + +func renderSystemdUnit(input nativeServiceUnitInput) string { + env := []string{ + serviceEnvLine("KANDEV_HOME_DIR", input.HomeDir), + serviceEnvLine("KANDEV_LOG_LEVEL", "info"), + serviceEnvLineAllowSpecifiers("PATH", "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin"), + } + if input.Port != 0 { + env = append(env, serviceEnvLine("KANDEV_SERVER_PORT", fmt.Sprint(input.Port))) + } + if input.BundleDir != "" { + env = append(env, serviceEnvLine("KANDEV_BUNDLE_DIR", input.BundleDir)) + } + if input.Version != "" { + env = append(env, serviceEnvLine("KANDEV_VERSION", input.Version)) + } + userLine := "" + wantedBy := "default.target" + if input.System { + wantedBy = "multi-user.target" + if input.SystemUser != "" { + userLine = "User=" + input.SystemUser + "\n" + } + } + return "# managed by kandev — regenerated by `kandev service install`\n" + + "[Unit]\nDescription=Kandev autonomous agent platform\nAfter=network-online.target\nWants=network-online.target\n\n" + + "[Service]\nType=simple\nExecStart=" + quoteForUnit(input.Executable) + " --headless\n" + + userLine + strings.Join(env, "\n") + "\nRestart=on-failure\nRestartSec=5s\nKillMode=mixed\nTimeoutStopSec=30s\n\n" + + "[Install]\nWantedBy=" + wantedBy + "\n" +} + +func renderLaunchdPlist(input nativeServiceUnitInput) string { + envEntries := [][2]string{{"KANDEV_HOME_DIR", input.HomeDir}, {"KANDEV_LOG_LEVEL", "info"}, {"PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"}} + if input.Port != 0 { + envEntries = append(envEntries, [2]string{"KANDEV_SERVER_PORT", fmt.Sprint(input.Port)}) + } + if input.BundleDir != "" { + envEntries = append(envEntries, [2]string{"KANDEV_BUNDLE_DIR", input.BundleDir}) + } + if input.Version != "" { + envEntries = append(envEntries, [2]string{"KANDEV_VERSION", input.Version}) + } + var envXML strings.Builder + for _, entry := range envEntries { + envXML.WriteString(" " + escapeXML(entry[0]) + "\n " + escapeXML(entry[1]) + "\n") + } + userBlock := "" + if input.System && input.SystemUser != "" { + userBlock = " UserName\n " + escapeXML(input.SystemUser) + "\n" + } + return ` + + + + + Label + com.kdlbs.kandev + ProgramArguments + + ` + escapeXML(input.Executable) + ` + --headless + + EnvironmentVariables + +` + envXML.String() + ` +` + userBlock + ` RunAtLoad + ` + plistBool(!input.NoBootStart) + ` + KeepAlive + + StandardOutPath + ` + escapeXML(filepath.Join(input.LogDir, "service.out")) + ` + StandardErrorPath + ` + escapeXML(filepath.Join(input.LogDir, "service.err")) + ` + WorkingDirectory + ` + escapeXML(input.HomeDir) + ` + + +` +} + +func plistBool(value bool) string { + if value { + return "" + } + return "" +} + +func serviceEnvLine(key, value string) string { + return "Environment=" + quoteForUnit(key+"="+value) +} + +func serviceEnvLineAllowSpecifiers(key, value string) string { + return "Environment=" + quoteForUnitAllowSpecifiers(key+"="+value) +} + +func quoteForUnit(value string) string { + return quoteForUnitValue(value, true) +} + +func quoteForUnitAllowSpecifiers(value string) string { + return quoteForUnitValue(value, false) +} + +func quoteForUnitValue(value string, escapeSystemdSpecifiers bool) string { + chars := " \\\"$" + if escapeSystemdSpecifiers { + chars += "%" + } + if !strings.ContainsAny(value, chars) { + return value + } + replacements := []string{"\\", "\\\\", "$", "$$", "\"", "\\\""} + if escapeSystemdSpecifiers { + replacements = append(replacements, "%", "%%") + } + escaped := strings.NewReplacer(replacements...).Replace(value) + return `"` + escaped + `"` +} + +func serviceBundleDir(executable string) string { + if v := os.Getenv("KANDEV_BUNDLE_DIR"); v != "" { + return v + } + return filepath.Dir(filepath.Dir(executable)) +} + +func serviceVersion(buildVersion string) string { + if v := os.Getenv("KANDEV_VERSION"); v != "" { + return v + } + return buildVersion +} + +func backupUnmanagedServiceFile(path string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if info.IsDir() { + return fmt.Errorf("%s is a directory", path) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(data), managedMarker) { + return nil + } + backupPath := path + ".bak" + if err := os.WriteFile(backupPath, data, info.Mode().Perm()); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "[kandev] WARNING: existing unmanaged service file backed up to %s\n", backupPath) + return nil +} + +func escapeXML(value string) string { + replacer := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") + return replacer.Replace(value) +} + +func linuxUnitPath(system bool) string { + if system { + return "/etc/systemd/system/kandev.service" + } + return filepath.Join(mustHomeDir(), ".config", "systemd", "user", serviceUnitName) +} + +func launchdPlistPath(system bool) string { + if system { + return "/Library/LaunchDaemons/com.kdlbs.kandev.plist" + } + return filepath.Join(mustHomeDir(), "Library", "LaunchAgents", "com.kdlbs.kandev.plist") +} + +func systemctlScope(system bool) []string { + if system { + return nil + } + return []string{"--user"} +} + +func serviceHomeDir(args serviceArgs) string { + if args.HomeDir != "" { + return args.HomeDir + } + if args.System { + return "/var/lib/kandev" + } + return resolveHomeDir() +} + +func serviceUser(system bool) string { + if !system { + return "" + } + if sudo := os.Getenv("SUDO_USER"); sudo != "" && sudo != "root" { + return sudo + } + u, err := user.Current() + if err != nil { + return "" + } + return u.Username +} + +func printServiceConfig(args serviceArgs) { + fmt.Println("manager:", runtime.GOOS) + fmt.Println("mode:", map[bool]string{true: "system", false: "user"}[args.System]) + fmt.Println("home:", serviceHomeDir(args)) + if runtime.GOOS == goosLinux { + fmt.Println("unit:", linuxUnitPath(args.System)) + } + if runtime.GOOS == goosDarwin { + fmt.Println("plist:", launchdPlistPath(args.System)) + } +} + +func expandHome(path string) string { + if path == "~" { + return mustHomeDir() + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(mustHomeDir(), strings.TrimPrefix(path, "~/")) + } + abs, err := filepath.Abs(path) + if err != nil { + return path + } + return abs +} + +func mustHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "." + } + return home +} + +func runCommand(name string, args ...string) error { + return executeServiceCommand(name, args...) +} + +func defaultRunCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} + +func runCommandExit(name string, args ...string) int { + if err := runCommand(name, args...); err != nil { + return 1 + } + return 0 +} diff --git a/apps/backend/internal/launcher/service_test.go b/apps/backend/internal/launcher/service_test.go new file mode 100644 index 0000000000..0d699eecda --- /dev/null +++ b/apps/backend/internal/launcher/service_test.go @@ -0,0 +1,324 @@ +package launcher + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestRenderSystemdUnitExecsNativeKandev(t *testing.T) { + unit := renderSystemdUnit(nativeServiceUnitInput{ + Executable: "/opt/kandev/bin/kandev", + HomeDir: "/home/alice/.kandev", + LogDir: "/home/alice/.kandev/logs", + Port: 1234, + }) + if !strings.Contains(unit, "ExecStart=/opt/kandev/bin/kandev --headless") { + t.Fatalf("unit does not exec native kandev:\n%s", unit) + } + if strings.Contains(unit, "cli.js") || strings.Contains(unit, "node ") { + t.Fatalf("unit contains Node CLI launcher:\n%s", unit) + } + if !strings.Contains(unit, "Environment=KANDEV_SERVER_PORT=1234") { + t.Fatalf("unit missing port env:\n%s", unit) + } +} + +func TestRenderSystemdUnitIncludesBundleMetadata(t *testing.T) { + unit := renderSystemdUnit(nativeServiceUnitInput{ + Executable: "/opt/kandev/bin/kandev", + HomeDir: "/home/alice/.kandev", + LogDir: "/home/alice/.kandev/logs", + BundleDir: "/opt/kandev", + Version: "1.2.3", + }) + + if !strings.Contains(unit, "Environment=KANDEV_BUNDLE_DIR=/opt/kandev") { + t.Fatalf("unit missing bundle dir env:\n%s", unit) + } + if !strings.Contains(unit, "Environment=KANDEV_VERSION=1.2.3") { + t.Fatalf("unit missing version env:\n%s", unit) + } +} + +func TestRenderLaunchdPlistExecsNativeKandev(t *testing.T) { + plist := renderLaunchdPlist(nativeServiceUnitInput{ + Executable: "/opt/kandev/bin/kandev", + HomeDir: "/Users/alice/.kandev", + LogDir: "/Users/alice/.kandev/logs", + }) + if !strings.Contains(plist, "/opt/kandev/bin/kandev") { + t.Fatalf("plist does not exec native kandev:\n%s", plist) + } + if strings.Contains(plist, "cli.js") { + t.Fatalf("plist contains Node CLI launcher:\n%s", plist) + } + if !strings.Contains(plist, "RunAtLoad\n ") { + t.Fatalf("plist should start at load by default:\n%s", plist) + } +} + +func TestRenderLaunchdPlistIncludesBundleMetadata(t *testing.T) { + plist := renderLaunchdPlist(nativeServiceUnitInput{ + Executable: "/opt/kandev/bin/kandev", + HomeDir: "/Users/alice/.kandev", + LogDir: "/Users/alice/.kandev/logs", + BundleDir: "/opt/kandev", + Version: "1.2.3", + }) + + if !strings.Contains(plist, "KANDEV_BUNDLE_DIR\n /opt/kandev") { + t.Fatalf("plist missing bundle dir env:\n%s", plist) + } + if !strings.Contains(plist, "KANDEV_VERSION\n 1.2.3") { + t.Fatalf("plist missing version env:\n%s", plist) + } +} + +func TestRenderLaunchdPlistCanDisableBootStart(t *testing.T) { + plist := renderLaunchdPlist(nativeServiceUnitInput{ + Executable: "/opt/kandev/bin/kandev", + HomeDir: "/Users/alice/.kandev", + LogDir: "/Users/alice/.kandev/logs", + NoBootStart: true, + }) + + if !strings.Contains(plist, "RunAtLoad\n ") { + t.Fatalf("plist should not start at load with no boot start:\n%s", plist) + } +} + +func TestInstallSystemdMessagesDistinguishBootStart(t *testing.T) { + tests := []struct { + name string + noBootStart bool + wantMessage string + wantEnableNow bool + wantStartAction bool + }{ + { + name: "default install", + wantMessage: "[kandev] service enabled and started", + wantEnableNow: true, + }, + { + name: "no boot start", + noBootStart: true, + wantMessage: "[kandev] service installed and started (boot-start disabled)", + wantStartAction: true, + }, + } + + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + }) + + executablePath = func() (string, error) { + return "/opt/kandev/bin/kandev", nil + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var commands []string + var messages []string + executeServiceCommand = func(name string, args ...string) error { + commands = append(commands, name+" "+strings.Join(args, " ")) + return nil + } + servicePrintln = func(message string) { + messages = append(messages, message) + } + + tmp := t.TempDir() + code := installSystemd( + serviceArgs{ + Action: actionInstall, + HomeDir: filepath.Join(tmp, "home"), + NoBootStart: tt.noBootStart, + }, + BuildInfo{Version: "test"}, + filepath.Join(tmp, "kandev.service"), + ) + if code != 0 { + t.Fatalf("installSystemd() = %d, want 0", code) + } + if !slices.Equal(messages, []string{tt.wantMessage}) { + t.Fatalf("messages = %v, want %q", messages, tt.wantMessage) + } + gotEnableNow := slices.Contains(commands, "systemctl --user enable --now kandev.service") + if gotEnableNow != tt.wantEnableNow { + t.Fatalf("enable --now command presence = %v, want %v; commands=%v", gotEnableNow, tt.wantEnableNow, commands) + } + gotStartAction := slices.Contains(commands, "systemctl --user start kandev.service") + if gotStartAction != tt.wantStartAction { + t.Fatalf("start command presence = %v, want %v; commands=%v", gotStartAction, tt.wantStartAction, commands) + } + }) + } +} + +func TestInstallLaunchdMessagesDistinguishBootStart(t *testing.T) { + tests := []struct { + name string + noBootStart bool + wantMessage string + wantKickstart bool + }{ + { + name: "default install", + wantMessage: "[kandev] service loaded, enabled, and started", + wantKickstart: false, + }, + { + name: "no boot start", + noBootStart: true, + wantMessage: "[kandev] service loaded and started (boot-start disabled)", + wantKickstart: true, + }, + } + + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + }) + + executablePath = func() (string, error) { + return "/opt/kandev/bin/kandev", nil + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var commands []string + var messages []string + executeServiceCommand = func(name string, args ...string) error { + commands = append(commands, name+" "+strings.Join(args, " ")) + return nil + } + servicePrintln = func(message string) { + messages = append(messages, message) + } + + tmp := t.TempDir() + code := installLaunchd( + serviceArgs{ + Action: actionInstall, + HomeDir: filepath.Join(tmp, "home"), + NoBootStart: tt.noBootStart, + }, + BuildInfo{Version: "test"}, + filepath.Join(tmp, "com.kdlbs.kandev.plist"), + "gui/501/com.kdlbs.kandev", + "gui/501", + ) + if code != 0 { + t.Fatalf("installLaunchd() = %d, want 0", code) + } + if !slices.Equal(messages, []string{tt.wantMessage}) { + t.Fatalf("messages = %v, want %q", messages, tt.wantMessage) + } + gotKickstart := slices.Contains(commands, "launchctl kickstart gui/501/com.kdlbs.kandev") + if gotKickstart != tt.wantKickstart { + t.Fatalf("kickstart command presence = %v, want %v; commands=%v", gotKickstart, tt.wantKickstart, commands) + } + }) + } +} + +func TestBuildJournalArgs(t *testing.T) { + tests := []struct { + name string + args serviceArgs + want []string + }{ + { + name: "user logs", + args: serviceArgs{Action: actionLogs}, + want: []string{"--user-unit", "kandev.service", "-n", "200", "--no-pager"}, + }, + { + name: "user logs follow keeps line count", + args: serviceArgs{Action: actionLogs, Follow: true}, + want: []string{"--user-unit", "kandev.service", "-n", "200", "-f"}, + }, + { + name: "system logs", + args: serviceArgs{Action: actionLogs, System: true}, + want: []string{"-u", "kandev.service", "-n", "200", "--no-pager"}, + }, + { + name: "system logs follow keeps line count", + args: serviceArgs{Action: actionLogs, System: true, Follow: true}, + want: []string{"-u", "kandev.service", "-n", "200", "-f"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildJournalArgs(tt.args) + if !slices.Equal(got, tt.want) { + t.Fatalf("buildJournalArgs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestQuoteForUnitEscapesSystemdSpecials(t *testing.T) { + got := serviceEnvLine("KANDEV_HOME_DIR", `/tmp/$USER/kandev%data`) + want := `Environment="KANDEV_HOME_DIR=/tmp/$$USER/kandev%%data"` + if got != want { + t.Fatalf("serviceEnvLine() = %q, want %q", got, want) + } +} + +func TestServiceEnvLineAllowSpecifiersPreservesSystemdSpecifiers(t *testing.T) { + got := serviceEnvLineAllowSpecifiers("PATH", `%h/.local/bin:$PATH`) + want := `Environment="PATH=%h/.local/bin:$$PATH"` + if got != want { + t.Fatalf("serviceEnvLineAllowSpecifiers() = %q, want %q", got, want) + } +} + +func TestBackupUnmanagedServiceFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "kandev.service") + original := "custom unit" + if err := os.WriteFile(path, []byte(original), 0o640); err != nil { + t.Fatal(err) + } + + if err := backupUnmanagedServiceFile(path); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path + ".bak") + if err != nil { + t.Fatal(err) + } + if string(data) != original { + t.Fatalf("backup = %q, want %q", data, original) + } +} + +func TestBackupUnmanagedServiceFileSkipsManagedFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "kandev.service") + if err := os.WriteFile(path, []byte("# managed by kandev\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := backupUnmanagedServiceFile(path); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) { + t.Fatalf("managed file should not create backup, stat err=%v", err) + } +} diff --git a/apps/backend/internal/launcher/start.go b/apps/backend/internal/launcher/start.go new file mode 100644 index 0000000000..6fc0188a8f --- /dev/null +++ b/apps/backend/internal/launcher/start.go @@ -0,0 +1,133 @@ +package launcher + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +var ( + executablePath = os.Executable + launchManaged = runManagedApp +) + +func runStart(opts Options) int { + backendPort, err := resolvePorts(opts) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 2 + } + ports, err := pickPorts(backendPort) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := ensureDataDir(); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + + logLevel := resolveLogLevel(opts) + + self, err := executablePath() + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + return launchManaged(managedAppConfig{ + Header: "start mode: using local build", + Mode: "start", + Backend: self, + BackendCWD: filepath.Dir(self), + Ports: ports, + LogLevel: logLevel, + Opts: opts, + }) +} + +type managedAppConfig struct { + Header string + Mode string + Backend string + BackendCWD string + Ports portConfig + LogLevel string + Opts Options +} + +func resolveLogLevel(opts Options) string { + if logLevel := os.Getenv("KANDEV_LOG_LEVEL"); logLevel != "" { + return logLevel + } + switch { + case opts.Debug: + return "debug" + case opts.Verbose: + return "info" + default: + return "warn" + } +} + +func runManagedApp(cfg managedAppConfig) int { + logStartup(cfg.Header, cfg.Ports, resolveDatabasePath(), cfg.LogLevel) + + supervisor := newSupervisor() + supervisor.attachSignals() + showOutput := cfg.Opts.Verbose || cfg.Opts.Debug + backend, dumpLogs, err := launchRestartableBackend(cfg.Backend, []string{"__backend"}, cfg.BackendCWD, backendEnv(cfg.Ports, cfg.LogLevel, cfg.Opts.Debug), !showOutput, cfg.Ports, cfg.Mode, supervisor) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + fmt.Println("[kandev] starting backend...") + if err := waitForHealth(cfg.Ports.BackendURL, backend, healthTimeout(healthTimeoutReleaseMS), dumpLogs); err != nil { + supervisor.shutdown("backend health failure") + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + fmt.Printf("[kandev] backend ready at %s\n", cfg.Ports.BackendURL) + + if cfg.Opts.Headless { + fmt.Printf("[kandev] ready (headless) at %s\n", cfg.Ports.BackendURL) + return waitForAppExit(supervisor, backend) + } + fmt.Println("[kandev] open: " + cfg.Ports.BackendURL) + openBrowser(cfg.Ports.BackendURL) + return waitForAppExit(supervisor, backend) +} + +func logStartup(header string, ports portConfig, dbPath, logLevel string) { + fmt.Println("[kandev] " + header) + fmt.Println("[kandev] url:", ports.BackendURL) + fmt.Println("[kandev] mcp:", ports.BackendURL+"/mcp") + if dbPath != "" { + fmt.Println("[kandev] db:", dbPath) + } + if logLevel != "" { + fmt.Println("[kandev] log level:", logLevel) + } +} + +func openBrowser(url string) { + if os.Getenv("KANDEV_NO_BROWSER") == "1" { + return + } + var cmd *exec.Cmd + switch { + case os.Getenv("OS") == "Windows_NT": + cmd = exec.Command("cmd.exe", "/c", "start", "", url) + case runtime.GOOS == "darwin": + cmd = exec.Command("open", url) + default: + cmd = exec.Command("xdg-open", url) + } + _ = cmd.Start() +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/apps/backend/internal/launcher/start_test.go b/apps/backend/internal/launcher/start_test.go new file mode 100644 index 0000000000..007765997b --- /dev/null +++ b/apps/backend/internal/launcher/start_test.go @@ -0,0 +1,47 @@ +package launcher + +import ( + "path/filepath" + "testing" +) + +func TestRunStartUsesSelfExecutableAndBackendCWD(t *testing.T) { + oldExecutablePath := executablePath + oldLaunchManaged := launchManaged + t.Cleanup(func() { + executablePath = oldExecutablePath + launchManaged = oldLaunchManaged + }) + + exe := filepath.Join(t.TempDir(), "bin", "kandev") + executablePath = func() (string, error) { + return exe, nil + } + + var got managedAppConfig + launchManaged = func(cfg managedAppConfig) int { + got = cfg + return 42 + } + t.Setenv("KANDEV_HOME_DIR", t.TempDir()) + + code := runStart(Options{Command: CommandStart, BackendPort: 48123, Headless: true}) + if code != 42 { + t.Fatalf("runStart() = %d, want 42", code) + } + if got.Backend != exe { + t.Fatalf("Backend = %q, want %q", got.Backend, exe) + } + if got.BackendCWD != filepath.Dir(exe) { + t.Fatalf("BackendCWD = %q, want %q", got.BackendCWD, filepath.Dir(exe)) + } + if got.Mode != "start" { + t.Fatalf("Mode = %q, want start", got.Mode) + } + if got.Ports.BackendPort != 48123 { + t.Fatalf("BackendPort = %d, want 48123", got.Ports.BackendPort) + } + if !got.Opts.Headless { + t.Fatal("expected Headless option to be preserved") + } +} diff --git a/apps/backend/internal/launcher/supervisor.go b/apps/backend/internal/launcher/supervisor.go new file mode 100644 index 0000000000..6507cef801 --- /dev/null +++ b/apps/backend/internal/launcher/supervisor.go @@ -0,0 +1,254 @@ +package launcher + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "time" +) + +type launchManifest struct { + Version int `json:"version"` + BackendExecutable string `json:"backend_executable"` + Argv []string `json:"argv"` + CWD string `json:"cwd"` + Env map[string]string `json:"env"` + HomeDir string `json:"home_dir"` + Port int `json:"port"` + Mode string `json:"mode"` + CreatedAt string `json:"created_at"` +} + +type restartableBackend struct { + mu sync.Mutex + restartMu sync.Mutex + command string + args []string + cwd string + env []string + quiet bool + supervisor *processSupervisor + current *managedProcess + exitCh chan int + dumpLogs func() +} + +func launchRestartableBackend(command string, args []string, cwd string, env []string, quiet bool, ports portConfig, mode string, supervisor *processSupervisor) (*restartableBackend, func(), error) { + homeDir := resolveHomeDir() + supervisorEnv, socket, manifestPath, err := prepareSupervisorEnv(env, homeDir) + if err != nil { + return nil, nil, err + } + manifest := buildManifest(command, args, cwd, supervisorEnv, homeDir, ports.BackendPort, mode) + if err := writeManifest(manifest, manifestPath); err != nil { + return nil, nil, err + } + backend := &restartableBackend{ + command: command, + args: args, + cwd: cwd, + env: supervisorEnv, + quiet: quiet, + supervisor: supervisor, + exitCh: make(chan int, 1), + } + if err := backend.start(); err != nil { + return nil, nil, err + } + if err := startControlServer(socket, backend.restart); err != nil { + backend.mu.Lock() + current := backend.current + backend.current = nil + backend.mu.Unlock() + if current != nil { + current.kill() + } + return nil, nil, err + } + return backend, func() { + backend.mu.Lock() + dump := backend.dumpLogs + backend.mu.Unlock() + if dump != nil { + dump() + } + }, nil +} + +func (b *restartableBackend) start() error { + proc, dump, err := startProcess(b.command, b.args, b.cwd, b.env, b.quiet, "backend", b.supervisor) + if err != nil { + return err + } + b.mu.Lock() + b.current = proc + b.dumpLogs = dump + b.mu.Unlock() + go func() { + <-proc.done + _, code := proc.Exited() + b.mu.Lock() + current := b.current == proc + b.mu.Unlock() + if current { + select { + case b.exitCh <- code: + default: + } + } + }() + return nil +} + +func (b *restartableBackend) restart() { + b.restartMu.Lock() + defer b.restartMu.Unlock() + + b.mu.Lock() + current := b.current + b.current = nil + b.mu.Unlock() + if current != nil { + current.kill() + } + if err := b.start(); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] backend restart failed: "+err.Error()) + b.notifyExit(1) + } +} + +func (b *restartableBackend) notifyExit(code int) { + select { + case b.exitCh <- code: + default: + } +} + +func (b *restartableBackend) Exited() (bool, int) { + b.mu.Lock() + current := b.current + b.mu.Unlock() + if current == nil { + return false, 0 + } + return current.Exited() +} + +func prepareSupervisorEnv(env []string, homeDir string) ([]string, string, string, error) { + dir := filepath.Join(homeDir, "supervisor") + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, "", "", err + } + _ = os.Chmod(dir, 0o700) + socket := filepath.Join(dir, "control.sock") + manifest := filepath.Join(dir, "launch.json") + env = upsertEnv(env, "KANDEV_SUPERVISOR_SOCKET", socket) + env = upsertEnv(env, "KANDEV_SUPERVISOR_MANIFEST", manifest) + env = upsertEnv(env, "KANDEV_RESTART_ADAPTER", "supervisor") + return env, socket, manifest, nil +} + +func buildManifest(command string, args []string, cwd string, env []string, homeDir string, port int, mode string) launchManifest { + return launchManifest{ + Version: 1, + BackendExecutable: command, + Argv: append([]string(nil), args...), + CWD: cwd, + Env: allowedSupervisorEnv(env), + HomeDir: homeDir, + Port: port, + Mode: mode, + CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), + } +} + +func allowedSupervisorEnv(env []string) map[string]string { + allow := map[string]bool{ + "KANDEV_HOME_DIR": true, + "KANDEV_DATABASE_PATH": true, + "KANDEV_SERVER_PORT": true, + "KANDEV_WEB_INTERNAL_URL": true, + "KANDEV_AGENT_STANDALONE_PORT": true, + "KANDEV_LOG_LEVEL": true, + "KANDEV_DEBUG_DEV_MODE": true, + "KANDEV_DEBUG_AGENT_MESSAGES": true, + "KANDEV_DEBUG_PPROF_ENABLED": true, + "KANDEV_E2E_MOCK": true, + "KANDEV_MOCK_AGENT": true, + "KANDEV_MOCK_GITHUB": true, + "KANDEV_MOCK_JIRA": true, + "KANDEV_MOCK_LINEAR": true, + "KANDEV_SUPERVISOR_SOCKET": true, + "KANDEV_SUPERVISOR_MANIFEST": true, + "KANDEV_RESTART_ADAPTER": true, + } + out := map[string]string{} + for _, item := range env { + for key := range allow { + prefix := key + "=" + if len(item) >= len(prefix) && item[:len(prefix)] == prefix { + out[key] = item[len(prefix):] + break + } + } + } + return out +} + +func writeManifest(manifest launchManifest, targetPath string) error { + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(targetPath, data, 0o600) +} + +func startControlServer(socket string, onRestart func()) error { + ln, err := listenControlSocket(socket) + if err != nil { + return err + } + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleControlConn(conn, onRestart) + } + }() + return nil +} + +func listenControlSocket(socket string) (net.Listener, error) { + _ = os.Remove(socket) + ln, err := net.Listen("unix", socket) + if err != nil { + return nil, err + } + if err := os.Chmod(socket, 0o600); err != nil { + _ = ln.Close() + return nil, err + } + return ln, nil +} + +func handleControlConn(conn net.Conn, onRestart func()) { + defer func() { _ = conn.Close() }() + line, err := bufio.NewReader(conn).ReadBytes('\n') + if err != nil { + return + } + var req map[string]string + resp := map[string]interface{}{"accepted": false, "message": "unsupported action"} + if json.Unmarshal(line, &req) == nil && req["action"] == "restart" { + go onRestart() + resp = map[string]interface{}{"accepted": true, "message": "Restart accepted"} + } + _ = json.NewEncoder(conn).Encode(resp) +} diff --git a/apps/backend/internal/launcher/supervisor_test.go b/apps/backend/internal/launcher/supervisor_test.go new file mode 100644 index 0000000000..8777866b33 --- /dev/null +++ b/apps/backend/internal/launcher/supervisor_test.go @@ -0,0 +1,92 @@ +package launcher + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestBuildManifestUsesSameBinaryBackendMode(t *testing.T) { + env := []string{ + "KANDEV_SERVER_PORT=1234", + "UNRELATED=value", + } + manifest := buildManifest("/opt/kandev/bin/kandev", []string{"__backend"}, "/opt/kandev/bin", env, "/tmp/home", 1234, "run") + + if manifest.BackendExecutable != "/opt/kandev/bin/kandev" { + t.Fatalf("BackendExecutable = %q", manifest.BackendExecutable) + } + if len(manifest.Argv) != 1 || manifest.Argv[0] != "__backend" { + t.Fatalf("Argv = %v, want [__backend]", manifest.Argv) + } + if _, ok := manifest.Env["UNRELATED"]; ok { + t.Fatalf("manifest contains unrelated env: %+v", manifest.Env) + } + if manifest.Env["KANDEV_SERVER_PORT"] != "1234" { + t.Fatalf("KANDEV_SERVER_PORT = %q", manifest.Env["KANDEV_SERVER_PORT"]) + } +} + +func TestPrepareSupervisorEnvWritesExpectedPaths(t *testing.T) { + home := t.TempDir() + env, socket, manifest, err := prepareSupervisorEnv(nil, home) + if err != nil { + t.Fatal(err) + } + if socket != filepath.Join(home, "supervisor", "control.sock") { + t.Fatalf("socket = %q", socket) + } + if manifest != filepath.Join(home, "supervisor", "launch.json") { + t.Fatalf("manifest = %q", manifest) + } + got := allowedSupervisorEnv(env) + if got["KANDEV_RESTART_ADAPTER"] != "supervisor" { + t.Fatalf("restart adapter env = %+v", got) + } +} + +func TestListenControlSocketUsesOwnerOnlyMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not supported on windows") + } + dir, err := os.MkdirTemp("", "kandev-sock-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + socket := filepath.Join(dir, "control.sock") + ln, err := listenControlSocket(socket) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + + info, err := os.Stat(socket) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("socket mode = %#o, want 0600", got) + } +} + +func TestRestartFailureNotifiesLauncherExit(t *testing.T) { + backend := &restartableBackend{ + command: filepath.Join(t.TempDir(), "missing-kandev"), + supervisor: newSupervisor(), + exitCh: make(chan int, 1), + } + + backend.restart() + + select { + case code := <-backend.exitCh: + if code == 0 { + t.Fatal("restart failure reported successful exit") + } + case <-time.After(500 * time.Millisecond): + t.Fatal("restart failure did not notify launcher exit") + } +} diff --git a/apps/cli/README.md b/apps/cli/README.md index 81d2e8536a..48f171378d 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -27,14 +27,6 @@ The package manager owns the runtime version. `kandev@X.Y.Z` ships with the matc - **Update via npm/npx**: `npx kandev@latest` or `npm install -g kandev@latest` - **Print CLI version**: `kandev --version` -### Advanced: pin a specific runtime tag - -`--runtime-version ` downloads a specific GitHub release runtime instead of using the installed one. For debugging compatibility issues only: - -```bash -kandev --runtime-version v0.16.0 -``` - ## Run as a Service Install kandev as a systemd (Linux) or launchd (macOS) service so it auto-starts and stays running across reboots: diff --git a/apps/cli/README_internal.md b/apps/cli/README_internal.md index 8614481e9b..796f338c1b 100644 --- a/apps/cli/README_internal.md +++ b/apps/cli/README_internal.md @@ -5,19 +5,16 @@ ```mermaid flowchart TD A["kandev"] --> B["CLI parses arguments"] - B --> C{"Command?"} + B --> C{"Install channel?"} - C -->|run / default| D["resolveRuntime()"] - C -->|dev| F["Use local repo"] - C -->|start| G["Use local build"] + C -->|Homebrew / manual| H["bin/kandev native launcher"] + C -->|npm / npx| D["bin/cli.js native shim"] D --> D1{"KANDEV_BUNDLE_DIR set?"} D1 -->|yes| D2["Use env bundle (Homebrew)"] D1 -->|no| D3["require.resolve('@kdlbs/runtime-{platform}')"] D3 -->|hit| D4["Use installed npm bundle"] - D3 -->|miss| D5{"--runtime-version given?"} - D5 -->|yes| D6["Download from GitHub Releases (cache)"] - D5 -->|no| D7["Throw: 'No runtime found'"] + D3 -->|miss| D5["Throw: 'No runtime found'"] D2 --> E["Start Go backend serving static SPA"] D4 --> E @@ -34,9 +31,9 @@ This package provides the `kandev` CLI launcher. The runtime bundle (Go backend, Two install channels share the same release artifacts: - **npm/npx**: `kandev@X.Y.Z` declares `optionalDependencies` for `@kdlbs/runtime-{platform}@X.Y.Z`. npm 7+ filters by `os`/`cpu` and installs only the matching one. -- **Homebrew**: `kdlbs/homebrew-kandev` formula downloads the GitHub release tarball into the Cellar and sets `KANDEV_BUNDLE_DIR`. +- **Homebrew**: `kdlbs/homebrew-kandev` formula downloads the GitHub release tarball into the Cellar and installs `bin/kandev` as the public command. -Both channels resolve to the same runtime bundle layout (`bin/kandev`, `bin/agentctl`, `web/index.html` plus assets). The CLI launcher (`runtime.ts`) abstracts over them. +Both channels resolve to a native runtime bundle. Homebrew/manual bundles contain `bin/kandev` and `bin/agentctl`; npm runtime packages also include the platform-matched binaries. The public command remains `kandev`; the hidden backend mode is `kandev __backend`. ## Artifact shapes @@ -45,21 +42,14 @@ The GitHub release bundle and the npm runtime package are **different shapes** b ``` # GitHub release bundle (used by Homebrew + manual installs) kandev/ -├── bin/{kandev,agentctl} -├── web/index.html -├── web/assets/ -└── cli/ - ├── bin/cli.js # #!/usr/bin/env node + chmod +x - └── dist/cli.bundle.js # esbuild single-file bundle (deps inlined) +└── bin/{kandev,agentctl,agentctl-linux-amd64} # npm runtime package (@kdlbs/runtime-{platform}) @kdlbs/runtime-{platform}/ -├── bin/{kandev,agentctl} -├── web/index.html -└── web/assets/ +└── bin/{kandev,agentctl,agentctl-linux-amd64} ``` -The `cli/` directory is only in the GitHub bundle (Homebrew needs the launcher). For npm installs, the main `kandev` package already provides the CLI; the runtime package only ships the binaries + web assets. +For npm installs, the main `kandev` package provides only a tiny Node bin shim that execs `bin/kandev` from the platform runtime package. ## Commands @@ -75,7 +65,7 @@ The `cli/` directory is only in the GitHub bundle (Homebrew needs the launcher). | -------------------------- | ---------------------------------------------------------- | | `--version`, `-V` | Print CLI version and exit | | `--port`, `--backend-port` | Backend port | -| `--web-internal-port` | Override internal dev web port | +| `--web-internal-port` | Override internal Vite dev web port | | `--verbose`, `-v` | Show info logs | | `--debug` | Show debug logs + agent message dumps | | `--runtime-version ` | **Advanced/debug**: download a specific GitHub runtime tag | @@ -93,23 +83,22 @@ npx kandev@latest # always pulls latest There is no `KANDEV_NO_UPDATE_PROMPT` or `KANDEV_SKIP_UPDATE` env var anymore — they're gone with the self-updater. -## Bundling for Homebrew +## npm shim -The Homebrew bundle uses `dist/cli.bundle.js`, an esbuild single-file output that inlines `tree-kill`, `tar`, and other Node deps. Required because the Cellar has no `node_modules`. The npm package uses the unbundled `dist/cli.js` and resolves deps via npm. +The npm package has no runtime dependencies beyond its platform optional runtime package. `bin/cli.js` resolves `KANDEV_BUNDLE_DIR` first, then the matching `@kdlbs/runtime-*` package, and execs the native `bin/kandev` binary with the original arguments. ```bash -pnpm -C apps/cli build # produces dist/cli.js -pnpm -C apps/cli bundle # produces dist/cli.bundle.js (~212KB) +pnpm -C apps/cli build # syntax-checks the shim +pnpm -C apps/cli test # tests runtime resolution ``` Smoke test the bundle in a clean directory: ```bash -mkdir /tmp/smoke && cd /tmp/smoke -node /path/to/apps/cli/dist/cli.bundle.js --help +KANDEV_BUNDLE_DIR=/path/to/dist/kandev node /path/to/apps/cli/bin/cli.js --help ``` -If `Cannot find module 'tree-kill'` appears, the bundle is broken. +If the runtime package or bundle is missing, the shim prints an actionable runtime-resolution error. ## Local Development @@ -144,15 +133,12 @@ Versioning: ## Environment Overrides -| Variable | Description | -| ------------------------------------- | --------------------------------------------------------------------- | -| `KANDEV_BUNDLE_DIR` | Force runtime bundle location (set by Homebrew wrapper, tests) | -| `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port | -| `KANDEV_WEB_PORT` | Internal Next.js port | -| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | -| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner (default: `kdlbs`) for `--runtime-version` | -| `KANDEV_GITHUB_REPO` | Override GitHub repo name (default: `kandev`) for `--runtime-version` | -| `KANDEV_GITHUB_TOKEN` | Optional GitHub token for rate limits | +| Variable | Description | +| ------------------------------------- | -------------------------------------------------------------- | +| `KANDEV_BUNDLE_DIR` | Force runtime bundle location (set by Homebrew wrapper, tests) | +| `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port | +| `KANDEV_WEB_PORT` | Internal Vite dev web port override | +| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | ## Supported Platforms diff --git a/apps/cli/bin/cli.js b/apps/cli/bin/cli.js index b47d6e1e66..5772e34d44 100755 --- a/apps/cli/bin/cli.js +++ b/apps/cli/bin/cli.js @@ -1,3 +1,5 @@ #!/usr/bin/env node -require("../dist/cli.js"); +const { run } = require("./native-shim"); + +process.exitCode = run(process.argv.slice(2)); diff --git a/apps/cli/bin/native-shim.js b/apps/cli/bin/native-shim.js new file mode 100644 index 0000000000..c8644b0b8c --- /dev/null +++ b/apps/cli/bin/native-shim.js @@ -0,0 +1,94 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const PLATFORM_PACKAGES = { + "linux-x64": "@kdlbs/runtime-linux-x64", + "linux-arm64": "@kdlbs/runtime-linux-arm64", + "darwin-x64": "@kdlbs/runtime-darwin-x64", + "darwin-arm64": "@kdlbs/runtime-darwin-arm64", + "win32-x64": "@kdlbs/runtime-win32-x64", +}; + +function platformPackage(platform = process.platform, arch = process.arch) { + const packageName = PLATFORM_PACKAGES[`${platform}-${arch}`]; + if (!packageName) { + throw new Error(`Unsupported Kandev platform: ${platform}-${arch}`); + } + return packageName; +} + +function binaryName(name, platform = process.platform) { + return platform === "win32" ? `${name}.exe` : name; +} + +function resolveRuntime(env = process.env, resolver = require.resolve) { + if (env.KANDEV_BUNDLE_DIR) { + return validateRuntime(env.KANDEV_BUNDLE_DIR); + } + + const packageName = platformPackage(); + let packageJSON; + try { + packageJSON = resolver(`${packageName}/package.json`); + } catch (error) { + const message = + `No Kandev runtime package found for ${process.platform}-${process.arch}.\n` + + `Install with npm 7+ so optional dependencies are installed, or use Homebrew.`; + const runtimeError = new Error(message); + runtimeError.cause = error; + throw runtimeError; + } + return validateRuntime(path.dirname(packageJSON)); +} + +function validateRuntime(bundleDir) { + const executable = path.join(bundleDir, "bin", binaryName("kandev")); + if (!fs.existsSync(executable)) { + throw new Error(`Kandev native binary not found at ${executable}`); + } + + const agentctl = path.join(bundleDir, "bin", binaryName("agentctl")); + if (!fs.existsSync(agentctl)) { + throw new Error(`agentctl binary not found at ${agentctl}`); + } + + return { bundleDir, executable }; +} + +function run(argv, env = process.env) { + let runtime; + try { + runtime = resolveRuntime(env); + } catch (error) { + console.error(`[kandev] ${error.message}`); + return 1; + } + + const child = spawnSync(runtime.executable, argv, { + stdio: "inherit", + env: { + ...env, + KANDEV_BUNDLE_DIR: runtime.bundleDir, + }, + }); + + if (child.error) { + console.error(`[kandev] failed to launch native binary: ${child.error.message}`); + return 1; + } + if (child.signal) { + return 1; + } + return child.status === null ? 1 : child.status; +} + +module.exports = { + binaryName, + platformPackage, + resolveRuntime, + run, + validateRuntime, +}; diff --git a/apps/cli/bin/native-shim.test.mjs b/apps/cli/bin/native-shim.test.mjs new file mode 100644 index 0000000000..13b61c13fd --- /dev/null +++ b/apps/cli/bin/native-shim.test.mjs @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import shim from "./native-shim.js"; + +const { binaryName, platformPackage, resolveRuntime, validateRuntime } = shim; + +function createBundle(dir) { + fs.mkdirSync(path.join(dir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(dir, "bin", "kandev"), "fake"); + fs.writeFileSync(path.join(dir, "bin", "agentctl"), "fake"); +} + +describe("native npm shim", () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-native-shim-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("maps supported platforms to runtime packages", () => { + expect(platformPackage("linux", "x64")).toBe("@kdlbs/runtime-linux-x64"); + expect(platformPackage("darwin", "arm64")).toBe("@kdlbs/runtime-darwin-arm64"); + expect(platformPackage("win32", "x64")).toBe("@kdlbs/runtime-win32-x64"); + }); + + it("uses exe suffix on Windows only", () => { + expect(binaryName("kandev", "win32")).toBe("kandev.exe"); + expect(binaryName("kandev", "linux")).toBe("kandev"); + }); + + it("resolves KANDEV_BUNDLE_DIR before npm packages", () => { + createBundle(tmpDir); + + const runtime = resolveRuntime({ KANDEV_BUNDLE_DIR: tmpDir }, () => { + throw new Error("should not resolve npm package"); + }); + + expect(runtime.bundleDir).toBe(tmpDir); + expect(runtime.executable).toBe(path.join(tmpDir, "bin", "kandev")); + }); + + it("resolves the installed runtime package", () => { + createBundle(tmpDir); + const pkgJSON = path.join(tmpDir, "package.json"); + fs.writeFileSync(pkgJSON, "{}"); + + const runtime = resolveRuntime({}, () => pkgJSON); + + expect(runtime.bundleDir).toBe(tmpDir); + }); + + it("rejects bundles without the native kandev binary", () => { + fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); + + expect(() => validateRuntime(tmpDir)).toThrow(/Kandev native binary not found/); + }); +}); diff --git a/apps/cli/package.json b/apps/cli/package.json index 59a22f0ba3..5d90dad281 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -13,10 +13,10 @@ "bin": { "kandev": "bin/cli.js" }, - "main": "dist/cli.js", + "main": "bin/native-shim.js", "files": [ - "bin", - "dist" + "bin/cli.js", + "bin/native-shim.js" ], "engines": { "npm": ">=7" @@ -28,22 +28,20 @@ "@kdlbs/runtime-darwin-arm64": "0.0.0-bootstrap", "@kdlbs/runtime-win32-x64": "0.0.0-bootstrap" }, - "dependencies": { - "tar": "^7.5.11", - "tree-kill": "^1.2.2" - }, "devDependencies": { "@types/node": "^20", "esbuild": "^0.24.0", + "tar": "^7.5.11", + "tree-kill": "^1.2.2", "tsx": "^4.15.7", "typescript": "^5", "vitest": "^1.6.0" }, "scripts": { "dev": "tsx src/cli.ts", - "build": "tsc -p tsconfig.json", - "bundle": "esbuild dist/cli.js --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.js", - "start": "node dist/cli.js", + "build": "node -c bin/cli.js && node -c bin/native-shim.js", + "bundle": "pnpm build", + "start": "node bin/cli.js", "test": "vitest run", "prepublishOnly": "pnpm build" } diff --git a/apps/cli/src/args.test.ts b/apps/cli/src/args.test.ts index ed01a34548..73c7e9f603 100644 --- a/apps/cli/src/args.test.ts +++ b/apps/cli/src/args.test.ts @@ -4,10 +4,9 @@ import { parseArgs, ParseError, resolvePorts } from "./args"; describe("parseArgs", () => { it("defaults to the run command with no args", () => { - const { options, showHelp, deprecatedFlags } = parseArgs([]); + const { options, showHelp } = parseArgs([]); expect(options.command).toBe("run"); expect(showHelp).toBe(false); - expect(deprecatedFlags).toEqual([]); }); it("parses --port and --port= as the backend port", () => { @@ -17,24 +16,21 @@ describe("parseArgs", () => { it("parses --backend-port the same as --port (no deprecation note)", () => { expect(parseArgs(["--backend-port=3447"]).options.backendPort).toBe(3447); - expect(parseArgs(["--backend-port=3447"]).deprecatedFlags).toEqual([]); }); - it("parses --web-internal-port without a deprecation note", () => { - const r = parseArgs(["--web-internal-port", "12345"]); + it("parses --web-internal-port for dev mode", () => { + const r = parseArgs(["dev", "--web-internal-port", "12345"]); expect(r.options.webPort).toBe(12345); - expect(r.deprecatedFlags).toEqual([]); }); - it("records --web-port as deprecated", () => { - const r = parseArgs(["--web-port=8080"]); - expect(r.options.webPort).toBe(8080); - expect(r.deprecatedFlags).toEqual(["--web-port"]); + it("rejects --web-internal-port outside dev mode", () => { + expect(() => parseArgs(["--web-internal-port", "12345"])).toThrow( + /--web-internal-port only applies to dev mode/, + ); }); - it("dedupes deprecated flags across repeats", () => { - const r = parseArgs(["--web-port=1", "--web-port=2"]); - expect(r.deprecatedFlags).toEqual(["--web-port"]); + it("rejects removed --web-port", () => { + expect(() => parseArgs(["--web-port=8080"])).toThrow(/--web-port has been removed/); }); it("reports --help via showHelp without exiting", () => { @@ -130,9 +126,9 @@ describe("resolvePorts", () => { expect(r.webPort).toBe(8080); }); - it("KANDEV_WEB_PORT sets the internal web port in run/start (backend stays undefined)", () => { + it("KANDEV_WEB_PORT is ignored outside dev", () => { const r = resolvePorts({ command: "run" }, { KANDEV_WEB_PORT: "8080" } as NodeJS.ProcessEnv); - expect(r).toEqual({ backendPort: undefined, webPort: 8080 }); + expect(r).toEqual({ backendPort: undefined, webPort: undefined }); }); it("--port maps to backend in every command (including dev)", () => { @@ -158,7 +154,7 @@ describe("resolvePorts", () => { ).toThrow(/KANDEV_PORT must be an integer between/); }); - it.each(["KANDEV_PORT", "KANDEV_BACKEND_PORT", "KANDEV_WEB_PORT"])( + it.each(["KANDEV_PORT", "KANDEV_BACKEND_PORT"])( "throws ParseError when %s is set to empty string", (name) => { expect(() => resolvePorts({ command: "run" }, { [name]: "" } as NodeJS.ProcessEnv)).toThrow( @@ -166,4 +162,10 @@ describe("resolvePorts", () => { ); }, ); + + it("throws ParseError when KANDEV_WEB_PORT is empty in dev", () => { + expect(() => + resolvePorts({ command: "dev" }, { KANDEV_WEB_PORT: "" } as NodeJS.ProcessEnv), + ).toThrow(/KANDEV_WEB_PORT must be an integer/); + }); }); diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 88d4681fdf..00a8a62628 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -15,8 +15,6 @@ export type CliOptions = { export type ParseResult = { options: CliOptions; showHelp: boolean; - /** Deprecated flags seen on the command line. cli.ts emits warnings after parsing. */ - deprecatedFlags: string[]; }; export class ParseError extends Error {} @@ -24,10 +22,6 @@ export class ParseError extends Error {} export function parseArgs(argv: string[]): ParseResult { const opts: CliOptions = { command: "run" }; let showHelp = false; - const deprecatedFlags: string[] = []; - const noteDeprecated = (flag: string) => { - if (!deprecatedFlags.includes(flag)) deprecatedFlags.push(flag); - }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--help" || arg === "-h") { @@ -77,16 +71,8 @@ export function parseArgs(argv: string[]): ParseResult { opts.webPort = parsePort(arg.slice("--web-internal-port=".length), "--web-internal-port"); continue; } - if (arg === "--web-port") { - opts.webPort = parsePort(takeValue(argv, i, "--web-port"), "--web-port"); - noteDeprecated("--web-port"); - i += 1; - continue; - } - if (arg.startsWith("--web-port=")) { - opts.webPort = parsePort(arg.slice("--web-port=".length), "--web-port"); - noteDeprecated("--web-port"); - continue; + if (arg === "--web-port" || arg.startsWith("--web-port=")) { + throw new ParseError("--web-port has been removed; use --web-internal-port for dev mode"); } if (arg === "--verbose" || arg === "-v") { opts.verbose = true; @@ -101,7 +87,10 @@ export function parseArgs(argv: string[]): ParseResult { continue; } } - return { options: opts, showHelp, deprecatedFlags }; + if (opts.webPort !== undefined && opts.command !== "dev") { + throw new ParseError("--web-internal-port only applies to dev mode"); + } + return { options: opts, showHelp }; } function takeValue(argv: string[], i: number, flag: string): string { @@ -127,10 +116,14 @@ export type ResolvedPorts = { // CLI flags beat env vars; KANDEV_PORT is an alias for KANDEV_BACKEND_PORT. export function resolvePorts(options: CliOptions, env: NodeJS.ProcessEnv): ResolvedPorts { + if (options.webPort !== undefined && options.command !== "dev") { + throw new ParseError("--web-internal-port only applies to dev mode"); + } return { backendPort: options.backendPort ?? envPort(env, "KANDEV_BACKEND_PORT") ?? envPort(env, "KANDEV_PORT"), - webPort: options.webPort ?? envPort(env, "KANDEV_WEB_PORT"), + webPort: + options.command === "dev" ? (options.webPort ?? envPort(env, "KANDEV_WEB_PORT")) : undefined, }; } diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 9fdb5e2dca..b4c12c3dd9 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -52,7 +52,6 @@ Advanced: --web-internal-port Override the internal dev web port. The Go backend reverse-proxies to it in dev; start/run serve from Go. Also reads KANDEV_WEB_PORT. - --web-port Deprecated alias for --web-internal-port. --runtime-version Download and use a specific release tag instead of the installed runtime. For debugging only. Example: kandev --runtime-version v0.16.0 @@ -90,7 +89,7 @@ async function main(): Promise { return; } - const { options, showHelp, deprecatedFlags } = parseArgs(process.argv.slice(2)); + const { options, showHelp } = parseArgs(process.argv.slice(2)); if (options.showVersion) { console.log(pkg.version); @@ -102,13 +101,10 @@ async function main(): Promise { return; } - for (const flag of deprecatedFlags) { - process.stderr.write(`[kandev] ${flag} is deprecated; use --web-internal-port\n`); - } - const resolved = resolvePorts(options, process.env); const backendPort = ensureValidPort(resolved.backendPort, "backend port"); - const webPort = ensureValidPort(resolved.webPort, "web port"); + const webPort = + options.command === "dev" ? ensureValidPort(resolved.webPort, "web port") : undefined; // After an npm/brew upgrade, paths baked into an installed service unit may // be stale. Warn once before launch so the user knows to re-run install. @@ -135,7 +131,6 @@ async function main(): Promise { await runStart({ repoRoot, backendPort, - webPort, verbose: options.verbose, debug: options.debug, headless: options.headless, @@ -146,7 +141,6 @@ async function main(): Promise { await runRelease({ runtimeVersion: options.runtimeVersion, backendPort, - webPort, verbose: options.verbose, debug: options.debug, headless: options.headless, diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 2fc0078223..6acb2e3d5e 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -6,7 +6,6 @@ import { ensureExtracted, findBundleRoot } from "./bundle"; import { DEFAULT_AGENTCTL_PORT, DEFAULT_BACKEND_PORT, - DEFAULT_WEB_PORT, HEALTH_TIMEOUT_MS_RELEASE, resolveCacheDir, resolveDataDir, @@ -14,7 +13,7 @@ import { resolveKandevHomeDir, } from "./constants"; import { ensureAsset, getRelease } from "./github"; -import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health"; +import { resolveHealthTimeoutMs, waitForHealth } from "./health"; import { getBinaryName, getPlatformDir } from "./platform"; import { sortVersionsDesc } from "./version"; import { pickAvailablePort } from "./ports"; @@ -27,7 +26,6 @@ import { openBrowser } from "./web"; export type RunOptions = { runtimeVersion?: string; backendPort?: number; - webPort?: number; /** Show info logs from backend + web */ verbose?: boolean; /** Show debug logs + agent message dumps */ @@ -42,7 +40,6 @@ type PreparedBundle = { backendUrl: string; backendEnv: NodeJS.ProcessEnv; releaseTag: string; - webPort: number; agentctlPort: number; dbPath: string; logLevel: string; @@ -142,7 +139,6 @@ async function downloadRuntimeVersion(runtimeVersion: string): Promise { async function prepareBundleForLaunch({ runtimeVersion, backendPort, - webPort, verbose = false, debug = false, }: RunOptions): Promise { @@ -183,7 +179,6 @@ async function prepareBundleForLaunch({ } const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); - const actualWebPort = webPort ?? (await pickAvailablePort(DEFAULT_WEB_PORT)); const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT); const backendUrl = `http://localhost:${actualBackendPort}`; const showOutput = verbose || debug; @@ -199,7 +194,6 @@ async function prepareBundleForLaunch({ const backendEnv = buildBackendEnv({ ports: { backendPort: actualBackendPort, - webPort: actualWebPort, agentctlPort, backendUrl, }, @@ -217,7 +211,6 @@ async function prepareBundleForLaunch({ backendUrl, backendEnv, releaseTag, - webPort: actualWebPort, agentctlPort, dbPath, logLevel, @@ -253,7 +246,6 @@ async function launchBundle(prepared: PreparedBundle): Promise<{ header: `release: ${prepared.releaseTag}`, ports: { backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), - webPort: prepared.webPort, agentctlPort: prepared.agentctlPort, backendUrl: prepared.backendUrl, }, @@ -272,7 +264,6 @@ async function launchBundle(prepared: PreparedBundle): Promise<{ homeDir: resolveKandevHomeDir(), ports: { backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), - webPort: prepared.webPort, agentctlPort: prepared.agentctlPort, backendUrl: prepared.backendUrl, }, @@ -300,7 +291,6 @@ async function launchBundle(prepared: PreparedBundle): Promise<{ export async function runRelease({ runtimeVersion, backendPort, - webPort, verbose = false, debug = false, headless = false, @@ -308,7 +298,6 @@ export async function runRelease({ const prepared = await prepareBundleForLaunch({ runtimeVersion, backendPort, - webPort, verbose, debug, }); diff --git a/apps/cli/src/shared.ts b/apps/cli/src/shared.ts index f70c989b9f..5e6afcbe5a 100644 --- a/apps/cli/src/shared.ts +++ b/apps/cli/src/shared.ts @@ -14,7 +14,7 @@ import { createProcessSupervisor } from "./process"; export type PortConfig = { backendPort: number; - webPort: number; + webPort?: number; agentctlPort: number; backendUrl: string; }; @@ -39,6 +39,17 @@ export async function pickPorts(backendPort?: number, webPort?: number): Promise }; } +export async function pickBackendPorts(backendPort?: number): Promise { + const resolvedBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); + const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT); + + return { + backendPort: resolvedBackendPort, + agentctlPort, + backendUrl: `http://localhost:${resolvedBackendPort}`, + }; +} + export type BackendEnvOptions = { ports: PortConfig; /** Log level: debug, info, warn, error (default: info) */ @@ -57,6 +68,9 @@ export type BackendEnvOptions = { */ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv { const { ports, logLevel, webProxy = true, extra } = options; + if (webProxy && ports.webPort === undefined) { + throw new Error("webProxy requires a web port"); + } const env: NodeJS.ProcessEnv = { ...process.env, KANDEV_SERVER_PORT: String(ports.backendPort), @@ -87,6 +101,9 @@ export type WebEnvOptions = { */ export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv { const { ports, production = false, debug = false } = options; + if (ports.webPort === undefined) { + throw new Error("web env requires a web port"); + } const env: NodeJS.ProcessEnv = { ...process.env, @@ -171,7 +188,13 @@ export type StartupInfoOptions = { */ export function logStartupInfo(options: StartupInfoOptions): void { const { header, ports, primary = "backend", dbPath, logLevel } = options; - const primaryPort = primary === "web" ? ports.webPort : ports.backendPort; + let primaryPort = ports.backendPort; + if (primary === "web") { + if (ports.webPort === undefined) { + throw new Error("web startup info requires a web port"); + } + primaryPort = ports.webPort; + } const primaryUrl = `http://localhost:${primaryPort}`; const networkHosts = listHostNetworkAddresses(); diff --git a/apps/cli/src/start.ts b/apps/cli/src/start.ts index d24b55dac2..8deb72a6e0 100644 --- a/apps/cli/src/start.ts +++ b/apps/cli/src/start.ts @@ -22,7 +22,7 @@ import { import { resolveHealthTimeoutMs, waitForHealth } from "./health"; import { getBinaryName } from "./platform"; import { createProcessSupervisor } from "./process"; -import { buildBackendEnv, logStartupInfo, pickPorts } from "./shared"; +import { buildBackendEnv, logStartupInfo, pickBackendPorts } from "./shared"; import { launchRestartableBackend } from "./supervisor/backend"; import { openBrowser } from "./web"; @@ -31,8 +31,6 @@ export type StartOptions = { repoRoot: string; /** Optional preferred backend port (finds available port if not specified) */ backendPort?: number; - /** Optional preferred web port (finds available port if not specified) */ - webPort?: number; /** Show info logs from backend + web */ verbose?: boolean; /** Show debug logs + agent message dumps */ @@ -56,12 +54,11 @@ export type StartOptions = { export async function runStart({ repoRoot, backendPort, - webPort, verbose = false, debug = false, headless = false, }: StartOptions): Promise { - const ports = await pickPorts(backendPort, webPort); + const ports = await pickBackendPorts(backendPort); const backendBin = path.join(repoRoot, "apps", "backend", "bin", getBinaryName("kandev")); if (!fs.existsSync(backendBin)) { diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index c1433e6ef3..8dc0e15879 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "bin/**/*.test.mjs"], }, }); diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index cd7af0776b..43149bcd04 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -29,13 +29,6 @@ importers: version: 5.9.3 cli: - dependencies: - tar: - specifier: ^7.5.11 - version: 7.5.13 - tree-kill: - specifier: ^1.2.2 - version: 1.2.2 optionalDependencies: '@kdlbs/runtime-darwin-arm64': specifier: 0.0.0-bootstrap @@ -59,6 +52,12 @@ importers: esbuild: specifier: ^0.24.0 version: 0.24.2 + tar: + specifier: ^7.5.11 + version: 7.5.13 + tree-kill: + specifier: ^1.2.2 + version: 1.2.2 tsx: specifier: ^4.15.7 version: 4.21.0 diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts index c1f36ffb6b..77e4ea1918 100644 --- a/apps/web/e2e/fixtures/backend.ts +++ b/apps/web/e2e/fixtures/backend.ts @@ -156,7 +156,7 @@ function spawnBackendProcess( debug: boolean, port: number, ): ChildProcess { - const proc = spawn(KANDEV_BIN, [], { + const proc = spawn(KANDEV_BIN, ["__backend"], { env: env as unknown as NodeJS.ProcessEnv, stdio: ["ignore", "pipe", "pipe"], detached: true, diff --git a/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts b/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts index 7df00640c3..74d0cb92df 100644 --- a/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-agent-switch.spec.ts @@ -55,6 +55,31 @@ async function waitForSessionEnvironmentId( throw new Error(`session for profile ${agentProfileId} did not get environment id: ${details}`); } +async function pollSessionsForEnvironmentInheritance( + apiClient: InstanceType, + taskId: string, + sourceProfileId: string, + targetProfileId: string, + timeoutMs = 30_000, +) { + const start = Date.now(); + let latestSessions: Awaited> = []; + while (Date.now() - start < timeoutMs) { + const { sessions } = await apiClient.listTaskSessions(taskId); + latestSessions = sessions; + const sourceSession = sessions.find((s) => s.agent_profile_id === sourceProfileId); + const targetSession = sessions.find((s) => s.agent_profile_id === targetProfileId); + if ( + sourceSession?.task_environment_id && + targetSession?.task_environment_id === sourceSession.task_environment_id + ) { + return sessions; + } + await new Promise((r) => setTimeout(r, 500)); + } + return latestSessions; +} + test.describe("Workflow agent profile switching", () => { test("manual step move creates new session with step's agent profile", async ({ apiClient, @@ -402,10 +427,17 @@ test.describe("Workflow agent profile switching", () => { // Move to Step2 await apiClient.moveTask(task.id, workflow.id, step2.id); - const step2EnvironmentId = await waitForSessionEnvironmentId(apiClient, task.id, profileB.id); - const { sessions: finalSessions } = await apiClient.listTaskSessions(task.id); + // Wait for the second session and the async environment handoff. + const finalSessions = await pollSessionsForEnvironmentInheritance( + apiClient, + task.id, + profileA.id, + profileB.id, + 30_000, + ); const step2Session = finalSessions.find((s) => s.agent_profile_id === profileB.id); expect(step2Session).toBeDefined(); + const step2EnvironmentId = step2Session?.task_environment_id; expect(step2EnvironmentId).toBe(step1EnvironmentId); }); diff --git a/docs/cli.md b/docs/cli.md index 8dced3fe5b..e7a146d4ba 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,11 +16,9 @@ flowchart TB resolve --> envvar["KANDEV_BUNDLE_DIR
(Homebrew, tests)"] resolve --> npmpkg["@kdlbs/runtime-{platform}
(npm/npx)"] - resolve --> cache["~/.kandev/bin cache
(--runtime-version only)"] envvar --> supervisor npmpkg --> supervisor - cache --> supervisor makedev --> supervisor binary --> supervisor @@ -89,26 +87,12 @@ kandev run ``` **What happens:** -1. Resolves the runtime bundle (KANDEV_BUNDLE_DIR → npm package → cache). +1. Resolves the runtime bundle (KANDEV_BUNDLE_DIR → npm package). 2. Starts the backend server. 3. Waits for backend health check. -4. Starts the web app. +4. Serves the embedded web UI from the backend. 5. Opens browser when ready. -### `kandev dev` - -Runs the application in development mode with hot-reloading. Requires a local repository checkout. - -```bash -# From the repo root or any subdirectory -kandev dev -``` - -**What happens:** -1. Locates the repo root (looks for `apps/backend` and `apps/web`). -2. Runs `make dev` for the backend (Go with hot-reload). -3. Runs `pnpm dev` for the web app (Vite dev server). - ### `kandev start` Runs the application using the local production backend binary. Requires running @@ -125,11 +109,9 @@ kandev start |--------|-------------|---------| | `--version`, `-V` | Print CLI version and exit | `kandev --version` | | `--port ` | Backend port (alias: `--backend-port`) | `--port 3000` | -| `--web-internal-port ` | Override internal web app port | `--web-internal-port 13000` | -| `--verbose`, `-v` | Show info logs from backend + web | `--verbose` | +| `--verbose`, `-v` | Show info logs from backend | `--verbose` | | `--debug` | Show debug logs + agent message dumps | `--debug` | | `--help`, `-h` | Show help | `--help` | -| `--runtime-version ` | **Advanced/debug only**: download a specific runtime tag from GitHub releases instead of using the installed runtime | `--runtime-version v0.16.0` | ### Examples @@ -137,14 +119,9 @@ kandev start # Print CLI version kandev --version -# Custom ports -kandev --port 18080 --web-internal-port 13000 +# Custom port +kandev --port 18080 -# Dev mode -kandev dev --port 18080 - -# Force a specific runtime version (debug) -kandev --runtime-version v0.16.0 ``` ## Port Selection @@ -167,8 +144,6 @@ If the default port is in use, the CLI finds the next available port automatical | `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port (CLI flag wins) | | `KANDEV_WEB_PORT` | Internal web app port | | `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | -| `KANDEV_GITHUB_OWNER` / `KANDEV_GITHUB_REPO` | Override GitHub repo for `--runtime-version` downloads | -| `KANDEV_GITHUB_TOKEN` | GitHub token for `--runtime-version` API access | ## Makefile Integration @@ -223,15 +198,6 @@ Increase the health check timeout: KANDEV_HEALTH_TIMEOUT_MS=60000 kandev ``` -### Dev Mode: "Unable to locate repo root" - -Run from within the kandev repository: - -```bash -cd /path/to/kandev -kandev dev -``` - ### Start Mode: "Backend binary not found" Build the project first: @@ -245,7 +211,6 @@ kandev start | Path | Contents | |------|----------| -| `~/.kandev/bin/` | Cached downloads from `--runtime-version` (debug only) | | `~/.kandev/data/` | SQLite database and app data | | Homebrew Cellar | Installed runtime (when installed via brew) | | `/node_modules/@kdlbs/runtime-{platform}/` | Installed runtime (when installed via npm/npx) | diff --git a/docs/decisions/0018-runtime-settings-overrides.md b/docs/decisions/0018-runtime-settings-overrides.md index ac6f9fc2da..bf57b3c74c 100644 --- a/docs/decisions/0018-runtime-settings-overrides.md +++ b/docs/decisions/0018-runtime-settings-overrides.md @@ -34,9 +34,9 @@ message debug logs; agent message logs are not a separate top-level toggle. V1 toggle changes require restart. Restart is launcher/supervisor mediated: -- In CLI-managed modes (`kandev start`, `kandev run`, `kandev dev`), the Go - backend requests restart from the Node launcher, and the launcher restarts - its backend and web children. +- In CLI-managed modes (`kandev start`, `kandev run`), the Go backend requests + restart from the native launcher, and the launcher restarts its backend + child. - In service-managed modes, restart delegates to systemd or launchd when the backend can prove it is safe. - If no supported supervisor is available, the UI shows manual restart diff --git a/docs/run-as-a-service.md b/docs/run-as-a-service.md index 35f59cd876..6e8b874caf 100644 --- a/docs/run-as-a-service.md +++ b/docs/run-as-a-service.md @@ -96,7 +96,7 @@ kandev service config [--system] If Kandev is running as a user-mode service installed by `kandev service install`, open **Settings → System → Updates** and use **Apply update** when it appears. The button is shown only when the backend can prove it is running from a kandev-managed service unit/plist with valid service metadata. -Both `npm` and Homebrew install kandev under a versioned directory (e.g. `node_modules/kandev/0.49.0/`, `Cellar/kandev/0.49.0/`). Manual upgrades replace those paths, so the unit file must be refreshed afterward. +Both npm runtime packages and Homebrew install kandev under versioned package-manager paths. Manual upgrades replace those paths, so the unit file must be refreshed afterward. **Manual fix:** re-run install. It's idempotent. @@ -105,7 +105,7 @@ npm i -g kandev@latest # or: brew upgrade kandev kandev service install # rewrites the unit with the new paths ``` -If you launch `kandev` interactively after an upgrade, it will detect a stale unit and print a one-line reminder. You can also check with `kandev service config` — the `cli entry:` and `node path:` lines tell you the paths that *would* be baked in by the next install. +If you launch `kandev` interactively after an upgrade, it will detect a stale unit and print a one-line reminder. You can also check with `kandev service config` to see the paths that would be baked in by the next install. System services (`kandev service install --system`) do not expose UI self-update. Update them from a privileged shell, then re-run `sudo kandev service install --system`. @@ -139,7 +139,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/kandev/bin/cli.js --headless +ExecStart=/usr/local/lib/node_modules/@kdlbs/runtime-linux-x64/bin/kandev --headless Environment=KANDEV_HOME_DIR=/home/alice/.kandev Environment=KANDEV_LOG_LEVEL=info Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index fecf2c2c6b..d93d31fddc 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -107,6 +107,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance, |---|---| | [improve-kandev](improve-kandev/spec.md) | draft | | [homebrew-core](homebrew-core/spec.md) | draft | +| [native-kandev-cli](native-kandev-cli/spec.md) | draft | | [public-share-links](public-share-links/spec.md) | draft | | [ssh-executor](ssh-executor/spec.md) | draft | | [cli-mode-parity](cli-mode-parity/spec.md) | draft | diff --git a/docs/specs/homebrew-core/spec.md b/docs/specs/homebrew-core/spec.md index 1a829b6152..3b688f762a 100644 --- a/docs/specs/homebrew-core/spec.md +++ b/docs/specs/homebrew-core/spec.md @@ -16,17 +16,17 @@ Kandev is currently installable via `brew install kdlbs/kandev/kandev` from the ## What -- Author a homebrew-core-compliant `Formula/kandev.rb` that **builds entirely from source** — Go backend (cgo + sqlite via `mattn/go-sqlite3`), Vite web bundle, and the TypeScript CLI esbuild bundle. +- Author a homebrew-core-compliant `Formula/kandev.rb` that **builds entirely from source** — native Go `kandev` launcher/backend binary (cgo + sqlite via `mattn/go-sqlite3`), agentctl helper binaries, and the Vite web bundle embedded into the Go binary. - Source comes from the GitHub-generated tag archive (`https://github.com/kdlbs/kandev/archive/refs/tags/vX.Y.Z.tar.gz`); per-release sha256 is captured at submission/bump time. -- Build deps: `go => :build`, `pnpm => :build`. Runtime dep: `node` (CLI launcher). `uses_from_macos "sqlite"` (cgo sqlite3 link) ships with macOS and is only installed via Homebrew on Linux. -- Install layout: `libexec/{bin,web,cli}` plus a single `bin/kandev` wrapper produced by `write_env_script`, setting `KANDEV_BUNDLE_DIR=` and `KANDEV_VERSION=`. This is the contract `apps/cli/src/runtime.ts` already honors. +- Build deps: `go => :build`, `pnpm => :build`. Runtime dep: none for Node; `uses_from_macos "sqlite"` (cgo sqlite3 link) ships with macOS and is only installed via Homebrew on Linux. +- Install layout: `libexec/bin` plus a single `bin/kandev` wrapper produced by `write_env_script`, setting `KANDEV_BUNDLE_DIR=` and `KANDEV_VERSION=`. The native launcher uses these values to find helper binaries and log the installed release version. - `livecheck do; url :stable; regex(/^v?(\d+(?:\.\d+)+)$/i); end` — Git strategy (the default for `url :stable`) checks tags directly and avoids the GitHub API rate limit. - Test block: spawns `libexec/bin/kandev` with an isolated `KANDEV_HOME_DIR` and random `KANDEV_SERVER_PORT`, polls `/api/v1/system/health` until the response contains `"healthy"` (60s timeout), then shuts the backend down. Also asserts the formula `version` appears in `bin/kandev --version`. - Tap (`kdlbs/homebrew-kandev`) and its update script (`scripts/release/update-homebrew-tap.sh`) stay untouched — it remains the binary-install fast path; the homebrew-core formula is a parallel, source-built distribution. ## Scenarios -- **GIVEN** the homebrew-core PR is merged, **WHEN** a macOS user runs `brew install kandev`, **THEN** Homebrew downloads the source tarball, compiles the Go binaries, builds the Next.js + CLI bundles, installs them under `Cellar/kandev/X.Y.Z/{bin,libexec}`, and `kandev --help` prints "kandev launcher". +- **GIVEN** the homebrew-core PR is merged, **WHEN** a macOS user runs `brew install kandev`, **THEN** Homebrew downloads the source tarball, builds the Vite web assets, syncs them into the Go embed directory, compiles the Go binaries, installs them under `Cellar/kandev/X.Y.Z/{bin,libexec}`, and `kandev --help` prints "kandev launcher". - **GIVEN** a new kandev release `vX.Y.Z` is tagged, **WHEN** Homebrew's auto-bump worker runs, **THEN** `livecheck` resolves the new tag from GitHub Releases and a bump PR is opened against the formula. - **GIVEN** a maintainer reviews the PR, **WHEN** they run `brew install --build-from-source kandev` locally, **THEN** the build completes without network or sandbox failures and `brew test kandev` passes. diff --git a/docs/specs/native-kandev-cli/plan.md b/docs/specs/native-kandev-cli/plan.md new file mode 100644 index 0000000000..6bb27e9af8 --- /dev/null +++ b/docs/specs/native-kandev-cli/plan.md @@ -0,0 +1,363 @@ +--- +spec: native-kandev-cli +created: 2026-06-15 +status: draft +--- + +# Implementation Plan: Native Kandev CLI + +## Overview + +Refactor the current backend command into a callable backend app, then make `apps/backend/cmd/kandev` a unified native executable that dispatches public launcher commands by default and hidden backend mode via `__backend`. Port the existing TypeScript launcher behavior into Go in phases, starting with `start` so `make start` stops depending on `pnpm`/`tsx`, then `run`, restart supervision, services, `dev`, release packaging, and finally the npm shim. + +--- + +## Backend + +### Backend App Extraction + +Files: + +- `apps/backend/cmd/kandev/main.go` +- `apps/backend/internal/backendapp/app.go` (new) + +Changes: + +- Move the current server startup logic from `cmd/kandev/main.go` into `internal/backendapp`. +- Expose `func Run(args []string) int` or equivalent. +- Use a private `flag.FlagSet` inside backend mode rather than package-global `flag.CommandLine`, so launcher parsing and backend parsing cannot conflict. +- Preserve current backend flags: `-port`, `-log-level`, `-help`, `-version`. +- Keep build metadata (`Version`, `Commit`, `BuildTime`) available to both launcher and backend mode. + +Reason: + +- A single `bin/kandev` binary needs to run either launcher code or backend server code without building a second backend artifact. + +### Unified Entrypoint + +Files: + +- `apps/backend/cmd/kandev/main.go` + +Changes: + +- Replace direct backend startup with dispatch: + +```go +func main() { + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + if len(args) > 0 && args[0] == "__backend" { + return backendapp.Run(args[1:]) + } + return launcher.Run(args) +} +``` + +- Keep `__backend` hidden from public help. +- Add tests for dispatch if `run` can be tested without launching the server. + +Reason: + +- The public executable remains `kandev`, while the launcher can supervise a restartable backend child by spawning itself. + +### Launcher Package + +Files: + +- `apps/backend/internal/launcher/cli` +- `apps/backend/internal/launcher/ports` +- `apps/backend/internal/launcher/paths` +- `apps/backend/internal/launcher/env` +- `apps/backend/internal/launcher/bundle` +- `apps/backend/internal/launcher/health` +- `apps/backend/internal/launcher/process` +- `apps/backend/internal/launcher/supervisor` +- `apps/backend/internal/launcher/service` + +Changes: + +- Port current behavior from `apps/cli/src/args.ts`, `ports.ts`, `constants.ts`, `shared.ts`, `health.ts`, `web.ts`, `process.ts`, and `supervisor/*`. +- Keep packages narrow to satisfy backend lint limits. +- Use standard library APIs first: + - `net.Listen("tcp", "127.0.0.1:0")` for free ports. + - `net/http` for health polling. + - `os/exec` for child processes. + - `os/signal` and platform-specific process group handling for shutdown. + +Reason: + +- This replaces the Node launcher logic while preserving behavior. + +### `start` Mode + +Files: + +- `apps/backend/internal/launcher/start` +- `Makefile` + +Changes: + +- Detect repo root using current `findRepoRoot` semantics. +- Resolve local backend by using `os.Executable()` and spawning ` __backend`. +- Start backend and wait for health; the backend serves the embedded Vite SPA. +- Update `make start` to invoke `apps/backend/bin/kandev start`. + +Reason: + +- This is the quickest user-visible reduction in Node launcher dependency for local production startup. + +### `run` Mode + +Files: + +- `apps/backend/internal/launcher/run` +- `apps/backend/internal/launcher/bundle` +- `.github/workflows/release.yml` + +Changes: + +- Validate release bundle layout with one `bin/kandev` executable. +- Resolve runtime via `KANDEV_BUNDLE_DIR` and explicit bundle context. +- Spawn backend using `/bin/kandev __backend`. +- Preserve quiet backend log buffering and verbose/debug/headless behavior. +- Decide separately whether to port `--runtime-version`; if deferred, return a clear unsupported/deprecated error. + +Reason: + +- This replaces the installed runtime launch path used by Homebrew/manual bundles. + +### Restart Supervisor + +Files: + +- `apps/backend/internal/launcher/supervisor` +- existing backend restart adapter call sites, if they assume manifest shape + +Changes: + +- Port manifest/control/socket behavior from `apps/cli/src/supervisor/*`. +- Manifest records: + - `backend_executable`: absolute path to `bin/kandev` + - `argv`: `["__backend"]` + - backend env allowlist + - mode, port, cwd, home dir +- Restart control restarts only the backend child process. + +Reason: + +- Preserves ADR 0019 while avoiding a separate backend binary. + +### Service Management + +Files: + +- `apps/backend/internal/launcher/service` +- `apps/backend/internal/launcher/service/systemd` +- `apps/backend/internal/launcher/service/launchd` +- `Makefile` service targets + +Changes: + +- Port service argument parsing, help, config output, metadata, systemd unit rendering, launchd plist rendering, install/uninstall/status/logs/restart. +- Generated units execute public `kandev`. +- Service launch path starts backend through hidden child mode. +- Preserve stale-service detection using installed metadata. + +Reason: + +- Services are part of the public launcher surface and must stop depending on the Node CLI. + +### Dev Mode + +Files: + +- `apps/backend/internal/launcher/dev` +- `Makefile` + +Changes: + +- Port repo-root detection, dev env, production DB backup guard, and task-workspace detection. +- Prefer spawning ` __backend` with dev env if it preserves current behavior. +- Keep `pnpm --filter @kandev/web dev` for the web dev server. +- Preserve Windows `winjob` wrapping if still needed. +- Switch `make dev` only after `dev` parity is proven. + +Reason: + +- Dev mode is user-facing for contributors but still intentionally depends on pnpm/Node for the web dev server. + +--- + +## Frontend + +No web UI changes are required by this spec. + +--- + +## Packaging + +### Release Bundles + +Files: + +- `.github/workflows/release.yml` +- `scripts/release/package-bundle.sh` +- release packaging scripts for runtime npm packages + +Changes: + +- Package one primary native executable: + +```text +kandev/ +├── bin/kandev +├── bin/agentctl +└── bin/agentctl-linux-amd64 +``` + +- Stop requiring `dist/kandev/cli` for Homebrew; platform bundles contain native `bin/` artifacts with web assets embedded in `bin/kandev`. +- Add smoke tests for `bin/kandev --help`, `bin/kandev --version`, `bin/kandev run --headless`, and `bin/kandev service --help`. + +### npm/npx Shim + +Files: + +- `apps/cli/bin/cli.js` +- `apps/cli/package.json` +- possibly a small `apps/cli/src` shim test if TypeScript remains + +Changes: + +- Replace full launcher implementation with a small JS shim. +- Shim maps platform/arch to `@kdlbs/runtime-*`. +- Shim resolves the runtime package and execs `bin/kandev(.exe)` with inherited stdio. +- Remove runtime dependencies from the published npm package; keep old TS launcher dependencies as dev-only test/build dependencies until the legacy source is deleted. + +--- + +## Docs + +Files: + +- `apps/cli/README_internal.md` +- `apps/cli/README.md` +- `docs/specs/INDEX.md` +- root/scoped `AGENTS.md` if package layout guidance changes + +Changes: + +- Document that `kandev` is native for Homebrew/release bundles. +- Document that npm/npx still uses Node as the package dispatch mechanism. +- Remove stale references to Homebrew requiring `dist/cli.bundle.js`. + +--- + +## Tests + +- **What:** public launcher help hides internal backend mode. + **File:** `apps/backend/internal/launcher/cli/*_test.go` + **How:** table-driven parser/help test. + +- **What:** `kandev __backend` dispatches to backend mode. + **File:** `apps/backend/cmd/kandev/*_test.go` or `apps/backend/internal/backendapp/*_test.go` + **How:** unit test around dispatchable function; avoid starting the full server where possible. + +- **What:** port/env precedence matches current CLI behavior. + **File:** `apps/backend/internal/launcher/env/*_test.go`, `apps/backend/internal/launcher/ports/*_test.go` + **How:** table-driven unit tests for flags and env vars. + +- **What:** `start` runs the native backend with embedded or filesystem Vite assets. + **File:** `apps/backend/internal/launcher/start/*_test.go` + **How:** temp directory fixtures for repo roots, `apps/web/dist`, and embedded-asset fallback. + +- **What:** bundle validation accepts the single-binary layout. + **File:** `apps/backend/internal/launcher/bundle/*_test.go` + **How:** temp bundle fixtures with missing-artifact cases. + +- **What:** health failures surface captured backend output. + **File:** `apps/backend/internal/launcher/health/*_test.go` or `run/*_test.go` + **How:** `httptest` and short-lived helper process. + +- **What:** supervisor manifest restarts same binary with `__backend`. + **File:** `apps/backend/internal/launcher/supervisor/*_test.go` + **How:** golden manifest test and restart child test with helper executable. + +- **What:** service units execute public `kandev`. + **File:** `apps/backend/internal/launcher/service/systemd/*_test.go`, `apps/backend/internal/launcher/service/launchd/*_test.go` + **How:** golden file tests for rendered unit/plist. + +- **What:** npm shim resolves runtime package and execs native `bin/kandev`. + **File:** `apps/cli/*` + **How:** fixture `node_modules/@kdlbs/runtime-*` package and mocked child process spawn. + +--- + +## E2E Tests + +No browser E2E test is required because the feature has no web UI surface. + +Add release/local smoke coverage instead: + +- **Scenario:** GIVEN a local production build, WHEN `make start` runs, THEN it uses native `apps/backend/bin/kandev start`. + **File:** release smoke script or backend integration test. + **What to verify:** process starts and health endpoints become ready. + +- **Scenario:** GIVEN a bundle fixture, WHEN `bin/kandev run --headless` runs, THEN backend and web become ready. + **File:** release smoke script. + **What to verify:** health endpoint and ready output. + +--- + +## Implementation Waves + +Wave 1: Backend extraction and dispatch + +- Refactor backend app into callable package. +- Add hidden `__backend` dispatch. +- Add launcher skeleton and parser/help tests. + +Wave 2: Shared launcher primitives and `start` + +- Port env, ports, paths, health, browser/process helpers. +- Implement `start`. +- Switch `make start`. + +Wave 3: Installed runtime `run` + +- Port bundle validation and run launch path. +- Add same-binary backend spawning. +- Add bundle smoke tests. + +Wave 4: Supervisor and services + +- Port restart supervisor. +- Port systemd/launchd service management. +- Add golden tests. + +Wave 5: Dev mode + +- Port `dev`. +- Switch `make dev` only after parity is confirmed. + +Wave 6: Packaging cutover + +- Update release bundle shape. +- Convert npm package to thin shim. +- Remove old Node launcher bundle from Homebrew path. +- Update docs. + +Wave 7: Cleanup and verification + +- Delete obsolete TS launcher modules. +- Run format, tests, lint, and release smoke tests. + +--- + +## Open Questions + +- Use `__backend` argv token or `KANDEV_INTERNAL_ROLE=backend` env for hidden backend mode? Recommendation: `__backend`. +- Preserve `--runtime-version ` in the first Go port, or defer/deprecate it? +- Ship old Node CLI fallback for one release while Homebrew/npm packaging switches over? diff --git a/docs/specs/native-kandev-cli/spec.md b/docs/specs/native-kandev-cli/spec.md new file mode 100644 index 0000000000..45e65ea79f --- /dev/null +++ b/docs/specs/native-kandev-cli/spec.md @@ -0,0 +1,155 @@ +--- +status: draft +created: 2026-06-15 +owner: tbd +--- + +# Native Kandev CLI + +## Why + +Users start Kandev from the terminal through `kandev`, but the current launcher is a Node.js/TypeScript program even when Kandev is installed from Homebrew or run from a local production build. This adds a runtime dependency and extra moving parts to the most basic startup path. Users should keep the same `kandev` command while the launcher becomes a native executable. + +## What + +- `kandev` remains the public command for Homebrew, npm/npx, global npm installs, service units, and local development. +- Homebrew and release bundle installs provide a native `bin/kandev` executable that can launch Kandev without executing the TypeScript CLI bundle. +- The native `kandev` executable supports the public launcher commands users need after the web runtime merge: default run, `run`, `start`, `service`, `--help`, `--version`, `--port`, `--backend-port`, `--verbose`, `--debug`, and `--headless`. +- Native `dev` mode is deferred until it can be ported with parity; the native launcher does not advertise or accept `dev`/`--dev`. +- The native launcher starts the backend as a supervised child process by re-executing the same `bin/kandev` binary in a hidden backend mode. +- The hidden backend mode is not a public command and is not shown in normal help output. +- Backend restarts restart only the backend child process; the launcher/supervisor remains alive unless the shutdown policy requires the whole app to exit. +- npm/npx continues to expose the `kandev` command through the `kandev` npm package. The npm package may use a minimal JavaScript shim, but that shim only resolves the platform runtime package and execs its native `bin/kandev`. +- `make start` launches through the native `apps/backend/bin/kandev start` path after building local artifacts. +- Existing service installs continue to be managed by `kandev service ...`; newly generated service units execute the public `kandev` launcher path. +- Startup output continues to show the URL users should open, the MCP URL, database path when applicable, and log level when applicable. +- Production `run` and `start` do not execute a Node.js web runtime; the Go backend serves the embedded Vite SPA assets. + +## API surface + +### Public CLI + +```text +kandev [--port ] [--verbose] [--debug] [--headless] +kandev run [--port ] [--verbose] [--debug] [--headless] +kandev start [--port ] [--verbose] [--debug] [--headless] +kandev service install|uninstall|start|stop|restart|status|logs|config [--system] +kandev --version +kandev --help +``` + +Port flags and environment variables: + +- `--port` and `--backend-port` select the public backend port. +- `KANDEV_PORT` and `KANDEV_BACKEND_PORT` provide backend port defaults. +- There is no production web port flag or environment variable. The Go backend serves the embedded SPA on the backend port. +- `--runtime-version` is not supported by the native launcher and is rejected as a usage error. + +Logging/debug flags: + +- `--verbose` shows backend info logs. +- `--debug` shows debug logs and enables current debug environment behavior. +- `--headless` skips browser opening and prints the ready URL. + +### Hidden backend mode + +The native launcher has a private backend mode: + +```text +kandev __backend [backend flags] +``` + +This mode starts the backend server and is invoked only by the launcher/supervisor. It is intentionally hidden from public help output. Existing backend diagnostic flags may remain available in this mode for tests and direct diagnostics. + +### Runtime bundle + +Release bundles expose this layout: + +```text +kandev/ +├── bin/kandev +├── bin/agentctl +└── bin/agentctl-linux-amd64 +``` + +`bin/kandev` is both the public launcher and the hidden backend-mode executable. + +### Supervisor manifest + +When backend restart supervision is enabled, the launch manifest records the same executable plus hidden backend argv: + +```json +{ + "version": 1, + "backend_executable": "/absolute/path/to/bin/kandev", + "argv": ["__backend"], + "cwd": "/absolute/path/to/bin", + "env": { + "KANDEV_SERVER_PORT": "38429", + "KANDEV_AGENT_STANDALONE_PORT": "39429", + "KANDEV_RESTART_ADAPTER": "supervisor" + }, + "home_dir": "/absolute/path/to/home", + "port": 38429, + "mode": "run", + "created_at": "timestamp" +} +``` + +## State machine + +Launcher process lifecycle: + +- `idle`: command parsed but no children started. +- `backend-starting`: launcher has spawned `kandev __backend` and is waiting for backend health. +- `ready`: backend is healthy and serving API, WebSocket, and SPA routes; browser may be opened unless headless. +- `restarting-backend`: supervisor received a restart request and is replacing only the backend child. +- `shutting-down`: launcher is terminating child processes after signal, service stop, or child exit policy. +- `failed`: startup failed and the launcher exits non-zero after surfacing the actionable error. + +Transitions: + +- `idle` -> `backend-starting`: user runs `kandev`, `kandev run`, `kandev start`, or service unit starts. +- `backend-starting` -> `failed`: backend exits or health timeout expires. +- `backend-starting` -> `ready`: backend health endpoint reports ready. +- `ready` -> `restarting-backend`: backend restart adapter requests restart. +- `restarting-backend` -> `ready`: replacement backend becomes healthy. +- any active state -> `shutting-down`: signal, service stop, or non-restartable child exit. + +## Failure modes + +- If a required backend artifact is missing in `start` mode, `kandev start` exits non-zero with a message that tells the user to run `make build`. +- If the runtime bundle is missing required files in `run` mode, `kandev run` exits non-zero and names the missing artifact. +- If the backend does not become healthy before timeout, the launcher exits non-zero and includes captured backend output when running in quiet mode. +- If the backend child exits unexpectedly after startup, the launcher either restarts it through the supervisor path when the exit is restart-controlled or shuts down the app with the backend exit code. +- If npm/npx optional runtime resolution fails, the JavaScript shim exits non-zero with an actionable message explaining that the platform runtime package is missing. + +## Persistence guarantees + +- Service installation metadata remains durable across restarts and upgrades. +- Supervisor manifest/control files remain under the configured Kandev home directory and are overwritten on each launch with the current executable path and backend argv. +- The launcher does not persist child process state beyond the existing supervisor manifest. After a launcher restart, it starts a fresh backend child. +- User data, database files, task worktrees, and backend-managed persistence remain owned by existing backend persistence behavior; the launcher migration does not change their retention. + +## Scenarios + +- **GIVEN** Kandev is installed from Homebrew, **WHEN** the user runs `kandev --help`, **THEN** the help output describes the public launcher commands and does not show `__backend`. +- **GIVEN** Kandev is installed from Homebrew, **WHEN** the user runs `kandev --version`, **THEN** the command prints the installed Kandev version without executing the Node CLI bundle. +- **GIVEN** a valid release bundle, **WHEN** the user runs `kandev --headless`, **THEN** the native launcher starts `kandev __backend`, waits for the backend to serve API and SPA routes, and prints the backend URL. +- **GIVEN** a local checkout with built backend and web artifacts, **WHEN** the user runs `make start`, **THEN** the Makefile launches through `apps/backend/bin/kandev start` and does not invoke `pnpm -C cli dev -- start`. +- **GIVEN** the backend has requested a restart through the restart adapter, **WHEN** the launcher receives the restart request, **THEN** only the `kandev __backend` child process is replaced. +- **GIVEN** the user presses Ctrl-C while Kandev is running, **WHEN** the launcher handles the signal, **THEN** it terminates the backend child process before exiting. +- **GIVEN** a new service install on Linux or macOS, **WHEN** the user runs `kandev service install`, **THEN** the generated service unit executes the public `kandev` launcher path. +- **GIVEN** a user runs `npx kandev@latest --help`, **WHEN** npm has installed the platform runtime package, **THEN** the npm shim execs the runtime package's native `bin/kandev` and the user sees the same public help output. +## Out of scope + +- Making `npx` itself Node-free. +- Introducing `kandevctl` as a user-facing command. +- Requiring a separate shipped backend binary such as `kandev-backend`. +- Changing task, workflow, agent, or integration behavior after the backend has started. + +## Open questions + +- Should hidden backend mode be selected by the `__backend` argv token or by `KANDEV_INTERNAL_ROLE=backend`? Recommendation: `__backend`. +- Should `--runtime-version ` be ported in a later native launcher implementation or remain deprecated? +- Should one release ship both the old Node CLI bundle and the native `bin/kandev` for compatibility while Homebrew/npm packaging switches over? diff --git a/docs/test_e2e_web.md b/docs/test_e2e_web.md index 4c9594f332..31dac9c268 100644 --- a/docs/test_e2e_web.md +++ b/docs/test_e2e_web.md @@ -105,7 +105,7 @@ Each Playwright worker spawns an isolated `kandev` backend that also serves the - `KANDEV_DOCKER_ENABLED=false` — no Docker - `KANDEV_WORKTREE_ENABLED=false` — no worktrees - `GH_TOKEN` / `GITHUB_TOKEN` stripped — prevents accidental real API calls -3. Spawns `apps/backend/bin/kandev` and waits for `/health` to return 200 +3. Spawns `apps/backend/bin/kandev __backend` and waits for `/health` to return 200 **Frontend:** 1. Sets `KANDEV_WEB_DIST_DIR` to `apps/web/dist` diff --git a/e2e.Dockerfile b/e2e.Dockerfile index b3c3047cf1..8ac79d1468 100644 --- a/e2e.Dockerfile +++ b/e2e.Dockerfile @@ -48,7 +48,7 @@ RUN pnpm install --frozen-lockfile # Copy full source for build COPY apps/ ./ -# Build web app (produces .next/standalone/ needed by E2E fixture) +# Build web app (produces Vite dist/ used by the E2E fixture) RUN pnpm --filter @kandev/web build # --------------------------------------------------------------------------- @@ -79,11 +79,6 @@ COPY --from=go-builder /out/mock-agent ./apps/backend/bin/mock-agent # agentctl must be in PATH — kandev spawns it as a subprocess COPY --from=go-builder /out/agentctl /usr/local/bin/agentctl -# Create static/public symlinks for standalone server (mirrors global-setup.ts) -RUN mkdir -p /app/apps/web/.next/standalone/web/.next && \ - ln -sf /app/apps/web/.next/static /app/apps/web/.next/standalone/web/.next/static && \ - ln -sf /app/apps/web/public /app/apps/web/.next/standalone/web/public - # Store Playwright browsers in a shared location accessible by all users ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers diff --git a/scripts/opencode-code-review b/scripts/opencode-code-review index 7777c5cbd8..852def3fef 100755 --- a/scripts/opencode-code-review +++ b/scripts/opencode-code-review @@ -11,6 +11,7 @@ from typing import Any MAX_INLINE_FINDINGS = 20 MAX_COMMENT_BODY = 60000 +OMISSION_NOTICE_RESERVE = 256 NO_FINDINGS_MARKER = "" DIAGNOSTIC_MARKER = "" FALLBACK_MARKER = "" @@ -25,12 +26,20 @@ def required_env(name: str) -> str: def gh_api(args: list[str]) -> subprocess.CompletedProcess[str]: gh_bin = os.environ.get("GH_BIN", "gh") - return subprocess.run( - [gh_bin, "api", *args], - text=True, - capture_output=True, - check=False, - ) + try: + return subprocess.run( + [gh_bin, "api", *args], + text=True, + capture_output=True, + check=False, + ) + except FileNotFoundError as exc: + return subprocess.CompletedProcess( + args=[gh_bin, "api", *args], + returncode=127, + stdout="", + stderr=f"Binary not found: {exc}", + ) def list_issue_comments() -> list[dict[str, Any]]: @@ -102,8 +111,12 @@ def parse_findings(raw: str) -> tuple[list[Any] | None, str | None]: match = re.search(r"\s*(.*?)\s*", raw, re.S) if not match: return None, "No parseable findings block was found." + payload_text = match.group(1).strip() + fenced = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", payload_text, re.S) + if fenced: + payload_text = fenced.group(1).strip() try: - payload = json.loads(match.group(1)) + payload = json.loads(payload_text) except json.JSONDecodeError as exc: return None, f"Findings block contained invalid JSON: {exc}" if not isinstance(payload, list): @@ -118,7 +131,7 @@ def diagnostic_body(reason: str, status: int, stdout: str, stderr: str) -> str: DIAGNOSTIC_MARKER, "**OpenCode review diagnostic**", "", - "OpenCode did not produce parseable findings, so the review check failed instead of silently skipping comments.", + "OpenCode did not produce parseable findings, so the workflow posted this diagnostic instead of silently skipping comments.", "", f"OpenCode model: `{model}`", f"Commit: `{short_sha}`", @@ -148,6 +161,7 @@ def no_findings_body() -> str: def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]]) -> tuple[str, int, int]: model = required_env("OPENCODE_MODEL") short_sha = required_env("HEAD_SHA")[:12] + body_limit = MAX_COMMENT_BODY - OMISSION_NOTICE_RESERVE body = "\n".join( [ FALLBACK_MARKER, @@ -167,7 +181,7 @@ def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]] if not findings: return heading = f"\n\n## {title}" - if len(body) + len(heading) > MAX_COMMENT_BODY: + if len(body) + len(heading) > body_limit: omitted += len(findings) return body += heading @@ -180,7 +194,7 @@ def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]] str(finding["body"]).strip(), ] ) - if len(body) + len(section) > MAX_COMMENT_BODY: + if len(body) + len(section) > body_limit: omitted += 1 continue body += section @@ -191,8 +205,9 @@ def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]] if omitted: notice = f"\n\n_Additional fallback findings omitted from this comment due to GitHub body size limits: {omitted}._" - if len(body) + len(notice) <= MAX_COMMENT_BODY: - body += notice + if len(notice) > OMISSION_NOTICE_RESERVE: + notice = notice[: OMISSION_NOTICE_RESERVE - 3].rstrip() + "..." + body += notice return body, included, omitted @@ -204,7 +219,16 @@ def normalize_finding(value: Any, changed_paths: set[str]) -> dict[str, Any] | N line = value.get("line") title = value.get("title") body = value.get("body") - if not isinstance(path, str) or not isinstance(line, int): + if not isinstance(path, str): + print(f"Skipped OpenCode finding with invalid path value: {path}") + return None + if isinstance(line, bool): + print(f"Skipped OpenCode finding with invalid line value: {path}:{line}") + return None + if isinstance(line, float) and line.is_integer(): + line = int(line) + if not isinstance(line, int): + print(f"Skipped OpenCode finding with invalid line value: {path}:{line}") return None if not isinstance(title, str) or not isinstance(body, str): return None @@ -246,7 +270,16 @@ def post_inline_finding(finding: dict[str, Any]) -> tuple[bool, str]: return False, result.stderr.strip() -def handle_diagnostic(args: argparse.Namespace, reason: str, status: int, stdout: str, stderr: str, raw: str) -> int: +def handle_diagnostic( + args: argparse.Namespace, + reason: str, + status: int, + stdout: str, + stderr: str, + raw: str, + *, + fail_check: bool, +) -> int: append_summary( args.summary, "OpenCode review diagnostic", @@ -261,7 +294,7 @@ def handle_diagnostic(args: argparse.Namespace, reason: str, status: int, stdout ) upsert_issue_comment(DIAGNOSTIC_MARKER, diagnostic_body(reason, status, stdout, stderr)) print(f"OpenCode did not produce parseable findings: {reason}", file=sys.stderr) - return 1 + return 1 if fail_check else 0 def post_findings(args: argparse.Namespace) -> int: @@ -271,11 +304,27 @@ def post_findings(args: argparse.Namespace) -> int: status = read_status(args.opencode_status) if status != 0: - return handle_diagnostic(args, f"OpenCode exited with status {status}.", status, stdout, stderr, raw) + return handle_diagnostic( + args, + f"OpenCode exited with status {status}.", + status, + stdout, + stderr, + raw, + fail_check=True, + ) findings, error = parse_findings(raw) if error is not None or findings is None: - return handle_diagnostic(args, error or "Unknown parser error.", status, stdout, stderr, raw) + return handle_diagnostic( + args, + error or "Unknown parser error.", + status, + stdout, + stderr, + raw, + fail_check=False, + ) if not findings: posted_no_findings = upsert_issue_comment(NO_FINDINGS_MARKER, no_findings_body()) diff --git a/scripts/opencode-code-review.test.py b/scripts/opencode-code-review.test.py index fd00b3a82e..637c2ea8d1 100755 --- a/scripts/opencode-code-review.test.py +++ b/scripts/opencode-code-review.test.py @@ -78,11 +78,12 @@ class OpenCodeReviewScriptTest(unittest.TestCase): output: str, inline_fail: bool = False, issue_comment_fail: bool = False, + gh_bin: Path | None = None, ) -> subprocess.CompletedProcess[str]: self.output_path.write_text(output, encoding="utf-8") env = { **os.environ, - "GH_BIN": str(self.fake_gh), + "GH_BIN": str(gh_bin or self.fake_gh), "GH_CALLS": str(self.calls_path), "GITHUB_REPOSITORY": "kdlbs/kandev", "PR_NUMBER": "42", @@ -130,10 +131,10 @@ class OpenCodeReviewScriptTest(unittest.TestCase): bodies.append(call[index + 1][len("body=") :]) return bodies - def test_missing_findings_block_fails_and_posts_diagnostic(self) -> None: + def test_missing_findings_block_posts_diagnostic_without_failing(self) -> None: result = self.run_script(output="OpenCode refused to read external_directory (/tmp/*)\n") - self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.returncode, 0) self.assertIn("did not produce parseable findings", result.stderr) self.assertTrue( any("" in body for body in self.bodies()), @@ -141,14 +142,29 @@ class OpenCodeReviewScriptTest(unittest.TestCase): ) self.assertIn("No parseable findings block", self.summary_path.read_text()) - def test_invalid_findings_json_fails_and_posts_diagnostic(self) -> None: + def test_invalid_findings_json_posts_diagnostic_without_failing(self) -> None: result = self.run_script(output="{}\n") - self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.returncode, 0) self.assertIn("did not produce parseable findings", result.stderr) self.assertTrue(any("" in body for body in self.bodies())) self.assertIn("not an array", self.summary_path.read_text()) + def test_fenced_json_findings_are_accepted(self) -> None: + result = self.run_script(output="```json\n[]\n```\n") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(any("" in body for body in self.bodies())) + + def test_missing_gh_binary_reports_gracefully(self) -> None: + result = self.run_script( + output="[]\n", + gh_bin=self.workdir / "missing-gh", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Binary not found", result.stderr) + def test_valid_empty_array_posts_no_findings_comment(self) -> None: result = self.run_script(output="[]\n") @@ -174,6 +190,34 @@ class OpenCodeReviewScriptTest(unittest.TestCase): self.assertTrue(any("" in body for body in bodies)) self.assertTrue(any("src/app.ts:99" in body for body in bodies)) + def test_integral_float_line_values_are_normalized(self) -> None: + result = self.run_script( + output=textwrap.dedent( + """\ + + [{"path":"src/app.ts","line":99.0,"title":"Bad line","body":"This line moved."}] + + """ + ), + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(any("-F" in call and "line=99" in call for call in self.read_calls())) + + def test_invalid_path_values_are_logged(self) -> None: + result = self.run_script( + output=textwrap.dedent( + """\ + + [{"path":42,"line":99,"title":"Bad line","body":"This line moved."}] + + """ + ), + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("invalid path value", result.stdout) + def test_inline_findings_beyond_limit_are_preserved_in_fallback_comment(self) -> None: findings = [ {"path": "src/app.ts", "line": index + 1, "title": f"Finding {index + 1}", "body": "body"} @@ -212,8 +256,12 @@ class OpenCodeReviewScriptTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) bodies = self.bodies() self.assertTrue(any("Additional fallback findings omitted" in body for body in bodies)) + self.assertTrue(all(len(body) <= 60000 for body in bodies)) summary = self.summary_path.read_text() - self.assertRegex(summary, r"Fallback findings included in comment: `1[0-9]`") + included_match = re.search(r"Fallback findings included in comment: `([1-9][0-9]*)`", summary) + self.assertIsNotNone(included_match, summary) + fallback = next(body for body in bodies if "" in body) + self.assertEqual(fallback.count("### src/app.ts:"), int(included_match.group(1))) omitted_match = re.search(r"Fallback findings omitted from comment: `([1-9][0-9]*)`", summary) self.assertIsNotNone(omitted_match, summary) diff --git a/scripts/release/kandev.rb b/scripts/release/kandev.rb index ee32c90e5a..4d76b52586 100644 --- a/scripts/release/kandev.rb +++ b/scripts/release/kandev.rb @@ -6,9 +6,6 @@ class Kandev < Formula license "AGPL-3.0-only" version "__VERSION__" - # Node is required for the CLI launcher. - depends_on "node" - on_macos do if Hardware::CPU.arm? url "__GITHUB_BASE__/kandev-macos-arm64.tar.gz" @@ -31,13 +28,10 @@ class Kandev < Formula def install libexec.install Dir["*"] - # cli/bin/cli.js has #!/usr/bin/env node shebang. (bin/"kandev").write_env_script - # creates a wrapper at $HOMEBREW_PREFIX/bin/kandev that sets KANDEV_BUNDLE_DIR - # (so the CLI launcher finds bin/ and web/ in the Cellar) and KANDEV_VERSION - # (read by run.ts at startup so the launcher logs "release: X.Y.Z" instead of - # "release: (env)"). Calling write_env_script on the bin directory itself would - # name the wrapper after the target's basename (cli.js), giving the wrong name. - (bin/"kandev").write_env_script libexec/"cli/bin/cli.js", + # Create a stable wrapper at $HOMEBREW_PREFIX/bin/kandev that points at the + # native launcher in the Cellar and sets the bundle/version env it uses to + # find bin/. + (bin/"kandev").write_env_script libexec/"bin/kandev", KANDEV_BUNDLE_DIR: libexec.to_s, KANDEV_VERSION: version.to_s end diff --git a/scripts/release/package-bundle.sh b/scripts/release/package-bundle.sh index 6afb39b2c1..95050aaf55 100755 --- a/scripts/release/package-bundle.sh +++ b/scripts/release/package-bundle.sh @@ -1,17 +1,26 @@ #!/usr/bin/env bash # Finalize the dist/kandev/ release layout from already-built pieces. # Caller must have run, in this order: -# - scripts/release/package-cli.sh (produces dist/kandev/cli/) -# - go build ./cmd/{kandev,agentctl} -o dist/kandev/bin/... with Vite -# assets synced into the backend embed package before building kandev. -# After this: dist/kandev/{bin,cli} is ready to install or tar. +# - Vite assets synced into apps/backend/internal/webapp/embedded/generated +# - go build ./cmd/{kandev,agentctl} -o dist/kandev/bin/... +# After this: dist/kandev/bin is ready to install or tar. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" BUNDLE="$ROOT_DIR/dist/kandev" -if [ ! -f "$BUNDLE/cli/bin/cli.js" ]; then - echo "Missing $BUNDLE/cli/bin/cli.js; run scripts/release/package-cli.sh first" >&2 +if [ ! -f "$BUNDLE/bin/kandev" ] && [ ! -f "$BUNDLE/bin/kandev.exe" ]; then + echo "Missing native launcher in $BUNDLE/bin; build cmd/kandev first" >&2 + exit 1 +fi + +if [ ! -f "$BUNDLE/bin/agentctl" ] && [ ! -f "$BUNDLE/bin/agentctl.exe" ]; then + echo "Missing agentctl in $BUNDLE/bin; build cmd/agentctl first" >&2 + exit 1 +fi + +if [ ! -f "$BUNDLE/bin/agentctl-linux-amd64" ]; then + echo "Missing linux/amd64 agentctl helper in $BUNDLE/bin; build cmd/agentctl for linux/amd64 first" >&2 exit 1 fi diff --git a/scripts/release/package-cli.sh b/scripts/release/package-cli.sh deleted file mode 100755 index 1fbbcf7b3d..0000000000 --- a/scripts/release/package-cli.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Build the CLI and copy artifacts into dist/kandev/cli/ for inclusion in release bundles. -# -# The CLI bundle (dist/cli.bundle.js) is a single-file esbuild output with all Node -# dependencies inlined. This is required for Homebrew installs where there is no -# node_modules directory in the Cellar. -# -# Output layout inside dist/kandev/cli/: -# bin/cli.js - Node entrypoint (requires #!/usr/bin/env node shebang + chmod +x) -# dist/cli.bundle.js - Self-contained bundle with inlined deps (used by Homebrew) -# package.json - Package metadata (used to read version at runtime) -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -CLI_DIR="$ROOT_DIR/apps/cli" -OUT_DIR="$ROOT_DIR/dist/kandev/cli" - -echo "Building CLI for release bundle..." - -# Build TypeScript -(cd "$ROOT_DIR/apps" && pnpm --filter kandev build) -echo " TypeScript build complete" - -# Bundle with esbuild (inlines tree-kill and all other deps) -(cd "$ROOT_DIR/apps" && pnpm --filter kandev bundle) -echo " esbuild bundle complete" - -# Copy artifacts into dist/kandev/cli/ -rm -rf "$OUT_DIR" -mkdir -p "$OUT_DIR/bin" "$OUT_DIR/dist" - -# Write a bin/cli.js that points at the bundled dist (for Homebrew/manual installs -# where there is no node_modules to resolve the unbundled dist/cli.js's deps). -# The npm package's bin/cli.js is different — it requires the unbundled dist/cli.js -# because tree-kill etc. are installed normally via npm. -cat > "$OUT_DIR/bin/cli.js" <<'EOF' -#!/usr/bin/env node - -require("../dist/cli.bundle.js"); -EOF -chmod +x "$OUT_DIR/bin/cli.js" - -cp "$CLI_DIR/dist/cli.bundle.js" "$OUT_DIR/dist/cli.bundle.js" -cp "$CLI_DIR/package.json" "$OUT_DIR/package.json" - -echo "CLI artifacts packaged at $OUT_DIR" -echo " bin/cli.js (entrypoint, requires dist/cli.bundle.js, chmod +x)" -echo " dist/cli.bundle.js (self-contained bundle, deps inlined)" -echo " package.json" diff --git a/scripts/release/package-npm-runtime.sh b/scripts/release/package-npm-runtime.sh index 10d861055d..bc9494d93e 100755 --- a/scripts/release/package-npm-runtime.sh +++ b/scripts/release/package-npm-runtime.sh @@ -2,7 +2,7 @@ # Generate per-platform npm runtime packages from built dist/release-assets. # # Each platform bundle (kandev-{platform}.tar.gz) is repackaged as an npm package -# containing only bin/ and web/ (no cli/). These are the @kdlbs/runtime-* packages +# containing the native bin/ directory. These are the @kdlbs/runtime-* packages # that the main kandev npm package declares as optionalDependencies. # # Usage: @@ -70,7 +70,7 @@ for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do rm -rf "$pkg_out" mkdir -p "$pkg_out" - # Extract only bin/ and web/ from the archive (skip cli/) + # Extract the native runtime bundle from the archive. local_tmp="$pkg_out/.extract_tmp" mkdir -p "$local_tmp" tar -xzf "$archive" -C "$local_tmp" @@ -83,7 +83,6 @@ for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do fi cp -R "$bundle_root/bin" "$pkg_out/bin" - cp -R "$bundle_root/web" "$pkg_out/web" rm -rf "$local_tmp" os_field="${PLATFORM_TO_OS[$platform]}" @@ -106,8 +105,7 @@ for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do "os": $os_field, "cpu": $cpu_field, "files": [ - "bin", - "web" + "bin" ] } EOF