diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5b9bdaca..8dd05265 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,17 +38,6 @@ updates: all-docker-auth-api: patterns: ['*'] - - package-ecosystem: docker - directory: /services/assistant-api - schedule: - interval: weekly - open-pull-requests-limit: 1 - cooldown: - default-days: 7 - groups: - all-docker-assistant-api: - patterns: ['*'] - - package-ecosystem: docker directory: /services/auth-ui schedule: @@ -60,17 +49,6 @@ updates: all-docker-auth-ui: patterns: ['*'] - - package-ecosystem: docker - directory: /services/assistant-ui - schedule: - interval: weekly - open-pull-requests-limit: 1 - cooldown: - default-days: 7 - groups: - all-docker-assistant-ui: - patterns: ['*'] - - package-ecosystem: docker directory: /services/app-ui schedule: diff --git a/.github/scripts/verify-system-test-migrations.sh b/.github/scripts/verify-system-test-migrations.sh index 71394fb8..d97048c0 100644 --- a/.github/scripts/verify-system-test-migrations.sh +++ b/.github/scripts/verify-system-test-migrations.sh @@ -8,9 +8,4 @@ PGPASSWORD=ci_auth_pass psql -h 127.0.0.1 -U auth_user -d auth_db -c '\dt' | gre exit 1 } -PGPASSWORD=ci_assistant_pass psql -h 127.0.0.1 -U assistant_user -d assistant_db -c '\dt' | grep -q conversation || { - echo "assistant_db migrations missing" >&2 - exit 1 -} - echo "==> Database migrations verified" diff --git a/.github/scripts/wait-for-system-test-routes.sh b/.github/scripts/wait-for-system-test-routes.sh index 5d6e675c..9eb9cbaa 100644 --- a/.github/scripts/wait-for-system-test-routes.sh +++ b/.github/scripts/wait-for-system-test-routes.sh @@ -19,8 +19,6 @@ check_route() { echo "Waiting for Traefik routes to become reachable..." check_route auth-api "https://auth.jorisjonkers.test/api/actuator/health/liveness" -check_route assistant-api "https://assistant.jorisjonkers.test/api/actuator/health/liveness" -check_route app-ui "https://jorisjonkers.test/" -check_route auth-ui "https://auth.jorisjonkers.test/" -check_route assistant-ui "https://assistant.jorisjonkers.test/" +check_route app-ui "https://jorisjonkers.test/" +check_route auth-ui "https://auth.jorisjonkers.test/" check_route vault "https://vault.jorisjonkers.test/ui/" diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 7e59aa3d..6bfd3ee8 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -58,13 +58,8 @@ jobs: - '.github/workflows/build-and-publish.yml' auth-api: - 'services/auth-api/**' - assistant-api: - - 'services/assistant-api/**' knowledge-api: - 'services/knowledge-api/**' - agent-runner: - - 'services/agent-runner/**' - - 'services/agent-gateway/**' knowledge-ingest-worker: - 'services/knowledge-ingest-worker/**' knowledge-curator: @@ -78,8 +73,6 @@ jobs: - 'gradlew.bat' auth-ui: - 'services/auth-ui/**' - assistant-ui: - - 'services/assistant-ui/**' app-ui: - 'services/app-ui/**' stalwart-tools: @@ -97,15 +90,12 @@ jobs: FILTER_WORKFLOW: ${{ steps.filter.outputs.workflow }} FILTER_GRADLE_CONFIG: ${{ steps.filter.outputs.gradle-config }} FILTER_AUTH_API: ${{ steps.filter.outputs.auth-api }} - FILTER_ASSISTANT_API: ${{ steps.filter.outputs.assistant-api }} FILTER_KNOWLEDGE_API: ${{ steps.filter.outputs.knowledge-api }} - FILTER_AGENT_RUNNER: ${{ steps.filter.outputs.agent-runner }} FILTER_KNOWLEDGE_INGEST_WORKER: ${{ steps.filter.outputs.knowledge-ingest-worker }} FILTER_KNOWLEDGE_CURATOR: ${{ steps.filter.outputs.knowledge-curator }} FILTER_VUE_COMMON: ${{ steps.filter.outputs.vue-common }} FILTER_FRONTEND_CONFIG: ${{ steps.filter.outputs.frontend-config }} FILTER_AUTH_UI: ${{ steps.filter.outputs.auth-ui }} - FILTER_ASSISTANT_UI: ${{ steps.filter.outputs.assistant-ui }} FILTER_APP_UI: ${{ steps.filter.outputs.app-ui }} FILTER_STALWART_TOOLS: ${{ steps.filter.outputs.stalwart-tools }} run: | @@ -148,20 +138,12 @@ jobs: FORCE_ALL="${{ github.event.inputs.force_all || 'false' }}" [[ "$(any "$FORCE_ALL" "$KOTLIN_SHARED" "$FILTER_AUTH_API")" == "true" ]] && append auth-api services/auth-api/Dockerfile "$AMD64" - [[ "$(any "$FORCE_ALL" "$KOTLIN_SHARED" "$FILTER_ASSISTANT_API")" == "true" ]] && append assistant-api services/assistant-api/Dockerfile "$AMD64" [[ "$(any "$FORCE_ALL" "$KOTLIN_SHARED" "$FILTER_KNOWLEDGE_API")" == "true" ]] && append knowledge-api services/knowledge-api/Dockerfile "$AMD64" - # agent-runner bundles the agent-gateway jar (built from the - # same Gradle settings), so a single image rebuild covers both - # the per-workspace tmux/runner shell and the gateway HTTP - # sidecar. amd64-only — the credential PVCs pin every - # consumer to a single amd64 Enschede worker anyway. - [[ "$(any "$FORCE_ALL" "$KOTLIN_SHARED" "$FILTER_AGENT_RUNNER")" == "true" ]] && append agent-runner services/agent-runner/Dockerfile "$AMD64" # Python services — only depend on their own dir; no shared # Kotlin or frontend trigger keys. [[ "$(any "$FORCE_ALL" "$FILTER_WORKFLOW" "$FILTER_KNOWLEDGE_INGEST_WORKER")" == "true" ]] && append knowledge-ingest-worker services/knowledge-ingest-worker/Dockerfile "$AMD64" [[ "$(any "$FORCE_ALL" "$FILTER_WORKFLOW" "$FILTER_KNOWLEDGE_CURATOR")" == "true" ]] && append knowledge-curator services/knowledge-curator/Dockerfile "$AMD64" [[ "$(any "$FORCE_ALL" "$FRONTEND_SHARED" "$FILTER_AUTH_UI")" == "true" ]] && append auth-ui services/auth-ui/Dockerfile "$UI_PLATFORMS" - [[ "$(any "$FORCE_ALL" "$FRONTEND_SHARED" "$FILTER_ASSISTANT_UI")" == "true" ]] && append assistant-ui services/assistant-ui/Dockerfile "$UI_PLATFORMS" [[ "$(any "$FORCE_ALL" "$FRONTEND_SHARED" "$FILTER_APP_UI")" == "true" ]] && append app-ui services/app-ui/Dockerfile "$UI_PLATFORMS" # stalwart-tools: small alpine image baking stalwart-cli + the # reconcile scripts/plans/accounts; amd64-only (frankfurt). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ae72886..a6dc547b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,8 +52,6 @@ jobs: - '.github/workflows/system-tests.yml' auth-api: - 'services/auth-api/**' - assistant-api: - - 'services/assistant-api/**' knowledge-api: - 'services/knowledge-api/**' knowledge-ingest-worker: @@ -69,8 +67,6 @@ jobs: - 'gradlew.bat' auth-ui: - 'services/auth-ui/**' - assistant-ui: - - 'services/assistant-ui/**' app-ui: - 'services/app-ui/**' vue-common: @@ -94,14 +90,12 @@ jobs: FILTER_WORKFLOW: ${{ steps.filter.outputs.workflow }} FILTER_GRADLE_CONFIG: ${{ steps.filter.outputs.gradle-config }} FILTER_AUTH_API: ${{ steps.filter.outputs.auth-api }} - FILTER_ASSISTANT_API: ${{ steps.filter.outputs.assistant-api }} FILTER_KNOWLEDGE_API: ${{ steps.filter.outputs.knowledge-api }} FILTER_KNOWLEDGE_INGEST_WORKER: ${{ steps.filter.outputs.knowledge-ingest-worker }} FILTER_KNOWLEDGE_CURATOR: ${{ steps.filter.outputs.knowledge-curator }} FILTER_VUE_COMMON: ${{ steps.filter.outputs.vue-common }} FILTER_FRONTEND_CONFIG: ${{ steps.filter.outputs.frontend-config }} FILTER_AUTH_UI: ${{ steps.filter.outputs.auth-ui }} - FILTER_ASSISTANT_UI: ${{ steps.filter.outputs.assistant-ui }} FILTER_APP_UI: ${{ steps.filter.outputs.app-ui }} FILTER_SYSTEM_TESTS: ${{ steps.filter.outputs.system-tests }} FILTER_INFRA: ${{ steps.filter.outputs.infra }} @@ -113,10 +107,9 @@ jobs: FRONTEND_SHARED=$(any "$FILTER_WORKFLOW" "$FILTER_VUE_COMMON" "$FILTER_FRONTEND_CONFIG") AUTH_API_KOTLIN=$(any "$KOTLIN_SHARED" "$FILTER_AUTH_API") - ASSISTANT_API_KOTLIN=$(any "$KOTLIN_SHARED" "$FILTER_ASSISTANT_API") KNOWLEDGE_API_KOTLIN=$(any "$KOTLIN_SHARED" "$FILTER_KNOWLEDGE_API") - ANY_KOTLIN=$(any "$KOTLIN_SHARED" "$FILTER_AUTH_API" "$FILTER_ASSISTANT_API" "$FILTER_KNOWLEDGE_API") - ANY_FRONTEND=$(any "$FRONTEND_SHARED" "$FILTER_AUTH_UI" "$FILTER_ASSISTANT_UI" "$FILTER_APP_UI") + ANY_KOTLIN=$(any "$KOTLIN_SHARED" "$FILTER_AUTH_API" "$FILTER_KNOWLEDGE_API") + ANY_FRONTEND=$(any "$FRONTEND_SHARED" "$FILTER_AUTH_UI" "$FILTER_APP_UI") ANY_SERVICE=$(any "$ANY_KOTLIN" "$ANY_FRONTEND") RUN_SYSTEM_TESTS=$(any "$FILTER_WORKFLOW" "$FILTER_SYSTEM_TESTS" "$FILTER_INFRA" "$ANY_SERVICE") ANY_PLATFORM=$(any "$FILTER_PLATFORM") @@ -139,9 +132,8 @@ jobs: # unauthenticated portfolio page. CHANGED_SERVICES="" [[ "$AUTH_API_KOTLIN" == "true" ]] && CHANGED_SERVICES+=" auth-api" - [[ "$ASSISTANT_API_KOTLIN" == "true" ]] && CHANGED_SERVICES+=" assistant-api" [[ "$KNOWLEDGE_API_KOTLIN" == "true" ]] && CHANGED_SERVICES+=" knowledge-api" - CHANGED_SERVICES+=" auth-ui assistant-ui app-ui stalwart-tools" + CHANGED_SERVICES+=" auth-ui app-ui stalwart-tools" CHANGED_SERVICES="${CHANGED_SERVICES# }" echo "any-kotlin=$ANY_KOTLIN" >> "$GITHUB_OUTPUT" @@ -152,23 +144,18 @@ jobs: echo "any-platform=$ANY_PLATFORM" >> "$GITHUB_OUTPUT" echo "run-system-tests=$RUN_SYSTEM_TESTS" >> "$GITHUB_OUTPUT" echo "auth-api-kotlin=$AUTH_API_KOTLIN" >> "$GITHUB_OUTPUT" - echo "assistant-api-kotlin=$ASSISTANT_API_KOTLIN" >> "$GITHUB_OUTPUT" echo "knowledge-api-kotlin=$KNOWLEDGE_API_KOTLIN" >> "$GITHUB_OUTPUT" echo "changed-services=$CHANGED_SERVICES" >> "$GITHUB_OUTPUT" - name: Build dynamic matrices id: matrix env: AUTH_API_KOTLIN: ${{ steps.compute.outputs.auth-api-kotlin }} - ASSISTANT_API_KOTLIN: ${{ steps.compute.outputs.assistant-api-kotlin }} KNOWLEDGE_API_KOTLIN: ${{ steps.compute.outputs.knowledge-api-kotlin }} run: | KOTLIN_MATRIX="[]" if [[ "$AUTH_API_KOTLIN" == "true" ]]; then KOTLIN_MATRIX=$(echo "$KOTLIN_MATRIX" | jq -c '. + [{"module":"auth-api","gradle-path":":services:auth-api"}]') fi - if [[ "$ASSISTANT_API_KOTLIN" == "true" ]]; then - KOTLIN_MATRIX=$(echo "$KOTLIN_MATRIX" | jq -c '. + [{"module":"assistant-api","gradle-path":":services:assistant-api"}]') - fi if [[ "$KNOWLEDGE_API_KOTLIN" == "true" ]]; then KOTLIN_MATRIX=$(echo "$KOTLIN_MATRIX" | jq -c '. + [{"module":"knowledge-api","gradle-path":":services:knowledge-api"}]') fi @@ -179,9 +166,6 @@ jobs: if [[ "$AUTH_API_KOTLIN" == "true" ]]; then INT_MATRIX=$(echo "$INT_MATRIX" | jq -c '. + [{"module":"auth-api","gradle-path":":services:auth-api"}]') fi - if [[ "$ASSISTANT_API_KOTLIN" == "true" ]]; then - INT_MATRIX=$(echo "$INT_MATRIX" | jq -c '. + [{"module":"assistant-api","gradle-path":":services:assistant-api"}]') - fi if [[ "$KNOWLEDGE_API_KOTLIN" == "true" ]]; then INT_MATRIX=$(echo "$INT_MATRIX" | jq -c '. + [{"module":"knowledge-api","gradle-path":":services:knowledge-api"}]') fi @@ -191,9 +175,6 @@ jobs: if [[ "$AUTH_API_KOTLIN" == "true" ]]; then COV_MATRIX=$(echo "$COV_MATRIX" | jq -c '. + [{"module":"auth-api","gradle-path":":services:auth-api","has-integration":true}]') fi - if [[ "$ASSISTANT_API_KOTLIN" == "true" ]]; then - COV_MATRIX=$(echo "$COV_MATRIX" | jq -c '. + [{"module":"assistant-api","gradle-path":":services:assistant-api","has-integration":true}]') - fi if [[ "$KNOWLEDGE_API_KOTLIN" == "true" ]]; then COV_MATRIX=$(echo "$COV_MATRIX" | jq -c '. + [{"module":"knowledge-api","gradle-path":":services:knowledge-api","has-integration":true}]') fi @@ -411,7 +392,7 @@ jobs: - uses: ./.github/actions/setup-node-pnpm with: node-version: ${{ env.NODE_VERSION }} - - run: bash infra/scripts/run-strict-command.sh ./gradlew :services:auth-api:test :services:assistant-api:test --tests "*ArchitectureTest*" + - run: bash infra/scripts/run-strict-command.sh ./gradlew :services:auth-api:test --tests "*ArchitectureTest*" - run: pnpm -r depcruise system-tests: diff --git a/.github/workflows/contract-validate.yml b/.github/workflows/contract-validate.yml index 01808491..a2ea97b8 100644 --- a/.github/workflows/contract-validate.yml +++ b/.github/workflows/contract-validate.yml @@ -1,12 +1,8 @@ name: Contract Validate -# Pins committed OpenAPI contracts and the assistant-api -> assistant-ui type -# contract. The freshly-exported springdoc specs are diffed against the -# committed service `openapi.json` files, then the regenerated TypeScript types -# are diffed against `services/assistant-ui/src/api/generated.ts`. Regenerate -# locally with `./gradlew :services:assistant-api:exportOpenApiSpec -# :services:knowledge-api:exportOpenApiSpec` and -# `pnpm --filter @personal-stack/assistant-ui contract:generate`. +# Pins committed OpenAPI contracts. The freshly-exported springdoc spec is +# diffed against the committed `services/knowledge-api/openapi.json`. Regenerate +# locally with `./gradlew :services:knowledge-api:exportOpenApiSpec`. on: workflow_call: @@ -27,23 +23,15 @@ jobs: - uses: ./.github/actions/setup-java-gradle with: java-version: '21' - - uses: ./.github/actions/setup-node-pnpm - with: - node-version: '24' - name: Export OpenAPI specs run: > bash infra/scripts/run-strict-command.sh ./gradlew - :services:assistant-api:exportOpenApiSpec :services:knowledge-api:exportOpenApiSpec - name: Diff exported specs against committed openapi.json files run: | set -euo pipefail - # The export tasks overwrite the committed paths with fresh springdoc - # output. `git diff --exit-code` against HEAD then surfaces drift. - if ! git diff --exit-code -- services/assistant-api/openapi.json services/knowledge-api/openapi.json; then - echo "::error::One or more committed openapi.json files are stale." - echo "::error::Regenerate locally with: ./gradlew :services:assistant-api:exportOpenApiSpec :services:knowledge-api:exportOpenApiSpec" + if ! git diff --exit-code -- services/knowledge-api/openapi.json; then + echo "::error::Committed openapi.json is stale." + echo "::error::Regenerate locally with: ./gradlew :services:knowledge-api:exportOpenApiSpec" exit 1 fi - - name: Check openapi-typescript output is up to date - run: bash infra/scripts/run-strict-command.sh pnpm --filter @personal-stack/assistant-ui contract:check diff --git a/.github/workflows/crac-train.yml b/.github/workflows/crac-train.yml index 696504d6..3e1633f2 100644 --- a/.github/workflows/crac-train.yml +++ b/.github/workflows/crac-train.yml @@ -28,12 +28,6 @@ jobs: db_user: auth_user db_password: auth_password port: 8081 - - service: assistant-api - db_name: assistant_db - db_user: assistant_user - db_password: assistant_password - port: 8082 - services: postgres: image: postgres:17-alpine env: diff --git a/.github/workflows/migration-guard.yml b/.github/workflows/migration-guard.yml index 9ca7436a..059173e5 100644 --- a/.github/workflows/migration-guard.yml +++ b/.github/workflows/migration-guard.yml @@ -1,4 +1,4 @@ -# Blocks the failure mode that crashlooped assistant-api: an already-applied +# Blocks the failure mode that crashlooped agents-api: an already-applied # Flyway migration was edited, flipping its checksum so `validate` hard-fails # on boot. Enforces that committed migrations are immutable and that versions # only ever increment. Fast (seconds) and unconditional so it is safe to mark diff --git a/.github/workflows/prod-smoke.yml b/.github/workflows/prod-smoke.yml index 1dc0981a..5e46eced 100644 --- a/.github/workflows/prod-smoke.yml +++ b/.github/workflows/prod-smoke.yml @@ -50,7 +50,7 @@ name: Production Smoke # kube-personal && kubectl -n auth-system exec deploy/auth-api -- \ # curl -X POST http://localhost:8081/api/v1/admin/users/canary-bot/permissions \ # -H 'Content-Type: application/json' \ -# -d '{"service":"ASSISTANT","granted":true}' +# -d '{"service":"AGENTS","granted":true}' # # Demote canary-bot from ADMIN once permission is granted so the # canary's blast-radius matches a real user. @@ -74,7 +74,7 @@ permissions: contents: read env: - ASSISTANT_API_URL: https://assistant.jorisjonkers.dev + AGENTS_API_URL: https://agents.jorisjonkers.dev AUTH_API_URL: https://auth.jorisjonkers.dev jobs: @@ -137,7 +137,7 @@ jobs: exit 1 fi body=$(curl -sf -b "${COOKIES}" \ - -X POST "${ASSISTANT_API_URL}/api/v1/repositories" \ + -X POST "${AGENTS_API_URL}/api/v1/repositories" \ -H 'Content-Type: application/json' \ -H "X-XSRF-TOKEN: ${csrf}" \ --data "{\"name\":\"${name}\",\"repoUrl\":\"git@github.com:owner/${name}.git\",\"defaultBranch\":\"main\"}") @@ -163,7 +163,7 @@ jobs: public_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl canary-${REPO_NAME}" private_key=$'-----BEGIN OPENSSH PRIVATE KEY-----\ncanary-private-'"${REPO_NAME}"'\n-----END OPENSSH PRIVATE KEY-----\n' curl -sf -b "${COOKIES}" \ - -X POST "${ASSISTANT_API_URL}/api/v1/repositories/${REPO_ID}/key" \ + -X POST "${AGENTS_API_URL}/api/v1/repositories/${REPO_ID}/key" \ -H 'Content-Type: application/json' \ -H "X-XSRF-TOKEN: ${csrf}" \ --data "$(jq -n --arg pk "$private_key" --arg pub "$public_key" \ @@ -183,7 +183,7 @@ jobs: # missing fingerprint here implies either the Vault write # silently fell back to a no-op store (PR #405 shape) or # the row update was rolled back. - body=$(curl -sf -b "${COOKIES}" "${ASSISTANT_API_URL}/api/v1/repositories/${REPO_ID}") + body=$(curl -sf -b "${COOKIES}" "${AGENTS_API_URL}/api/v1/repositories/${REPO_ID}") fingerprint=$(jq -r '.repository.deployKeyFingerprint // .deployKeyFingerprint // empty' <<<"$body") if [ -z "$fingerprint" ]; then echo "::error::deployKeyFingerprint is empty after attach. Body: $body" @@ -202,7 +202,7 @@ jobs: fi csrf=$(awk '/XSRF-TOKEN/{print $7}' "${COOKIES}" | tail -1) curl -sf -b "${COOKIES}" \ - -X DELETE "${ASSISTANT_API_URL}/api/v1/repositories/${REPO_ID}" \ + -X DELETE "${AGENTS_API_URL}/api/v1/repositories/${REPO_ID}" \ -H "X-XSRF-TOKEN: ${csrf}" \ -o /dev/null || echo "::warning::cleanup DELETE returned non-2xx — repository ${REPO_ID} may need manual cleanup" @@ -213,5 +213,5 @@ jobs: run: | curl -sf -X POST "${WEBHOOK}" \ -H 'Content-Type: application/json' \ - --data "{\"text\":\":rotating_light: prod-smoke failed — assistant attach-key flow broken in production. Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \ + --data "{\"text\":\":rotating_light: prod-smoke failed — agents attach-key flow broken in production. Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \ || true diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index 5fd72535..62c9743b 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -19,7 +19,7 @@ on: on the branch. Defaults to all five services for callers that haven't computed a narrower set. type: string - default: 'auth-api assistant-api auth-ui assistant-ui app-ui stalwart-tools' + default: 'auth-api auth-ui app-ui stalwart-tools' concurrency: group: system-tests-${{ github.ref }} @@ -44,15 +44,9 @@ jobs: - name: auth-api dockerfile: services/auth-api/Dockerfile build_arg: '' - - name: assistant-api - dockerfile: services/assistant-api/Dockerfile - build_arg: '' - name: auth-ui dockerfile: services/auth-ui/Dockerfile build_arg: '' - - name: assistant-ui - dockerfile: services/assistant-ui/Dockerfile - build_arg: VITE_AUTH_URL=https://auth.jorisjonkers.test - name: app-ui dockerfile: services/app-ui/Dockerfile build_arg: VITE_AUTH_URL=https://auth.jorisjonkers.test @@ -171,7 +165,6 @@ jobs: run: >- bash infra/scripts/run-strict-command.sh ./gradlew ${{ matrix.suite.gradle_task }} -Dtest.auth-api.url=https://auth.jorisjonkers.test - -Dtest.assistant-api.url=https://assistant.jorisjonkers.test -Dtest.db.password=ci_auth_pass ${{ matrix.suite.shard_args }} - name: Dump diagnostics on failure diff --git a/.github/workflows/vault-bootstrap-validate.yml b/.github/workflows/vault-bootstrap-validate.yml index 8614b222..9215b341 100644 --- a/.github/workflows/vault-bootstrap-validate.yml +++ b/.github/workflows/vault-bootstrap-validate.yml @@ -138,7 +138,7 @@ jobs: postgres.user=ci-pg-admin \ postgres.password=ci-pg-admin-pass \ auth.user=ci_auth_parent \ - assistant.user=ci_assistant_parent + agents.user=ci_agents_parent - name: Provision a dummy Kubernetes CA so bootstrap-auth.sh runs end-to-end # The script reads `kubernetes_ca_cert=@/var/run/secrets/.../ca.crt`. @@ -200,7 +200,7 @@ jobs: # bootstrap-auth.sh. Sorted alphabetically; keep in sync # with the script when a policy lands or is retired. required_policies=( - assistant-api + agents-api auth-api downloads knowledge-api diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 57ee829d..2ea6bcb4 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -11,49 +11,15 @@ services: OTEL_TRACES_EXPORTER: none OTEL_EXPORTER_OTLP_ENDPOINT: '' - assistant-api: - image: personal-stack/assistant-api:ci - pull_policy: never - depends_on: - vault: - condition: service_healthy - environment: - DB_PASSWORD: ci_assistant_pass - OTEL_TRACES_EXPORTER: none - OTEL_EXPORTER_OTLP_ENDPOINT: '' - # The system-test stack doesn't ship a Kubernetes cluster, so - # the production `Fabric8AgentRunnerOrchestrator` (which - # bootstraps a fabric8 KubernetesClient against the in-cluster - # API) would refuse to start. The `system-test` Spring profile - # swaps it for `StubAgentRunnerOrchestrator` — a no-op - # implementation that returns synthesised RunnerHandles so the - # workspace lifecycle commands run end-to-end except for the - # actual Pod side-effects. Real-cluster behaviour is exercised - # by Fabric8AgentRunnerOrchestratorIntegrationTest under - # assistant-api/integrationTest, against a Testcontainers k3s. - SPRING_PROFILES_ACTIVE: system-test - # Real Vault in the CI stack so the deploy-key write path - # actually runs end-to-end. Previously this was disabled, - # which meant production-only failures like PRs #405 / #409 / - # #426 (Vault policy missing, bootstrap Job broken, - # 202-empty-body JSON parse crash) could ship with green CI. - # Token auth against dev-mode Vault's root token — sufficient - # for the system-test stack where the goal is to exercise the - # write path, not the policy boundary (policy enforcement is - # validated by the bootstrap-validate CI job and prod canary, - # tracks T4 + T5 of the plan). - VAULT_ENABLED: 'true' - VAULT_ADDR: http://vault:8200 - VAULT_AUTHENTICATION: TOKEN - SPRING_CLOUD_VAULT_TOKEN: dev-root-token + agents-api: + profiles: [disabled] auth-ui: image: personal-stack/auth-ui:ci pull_policy: never - assistant-ui: - image: personal-stack/assistant-ui:ci - pull_policy: never + agents-ui: + profiles: [disabled] app-ui: image: personal-stack/app-ui:ci @@ -67,7 +33,7 @@ services: postgres: environment: AUTH_DB_PASSWORD: ci_auth_pass - ASSISTANT_DB_PASSWORD: ci_assistant_pass + AGENTS_DB_PASSWORD: ci_agents_pass N8N_DB_PASSWORD: ci_n8n_pass # ── Disable OTEL on traefik (alloy is disabled) ───────────────── diff --git a/docker-compose.yml b/docker-compose.yml index 96fd0f62..e5258cc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -518,7 +518,7 @@ services: SESSION_COOKIE_DOMAIN: jorisjonkers.test SESSION_COOKIE_SECURE: 'true' SESSION_COOKIE_SAME_SITE: Lax - AUTH_CORS_ALLOWED_ORIGINS: https://jorisjonkers.test,https://auth.jorisjonkers.test,https://assistant.jorisjonkers.test,https://vault.jorisjonkers.test,https://n8n.jorisjonkers.test,https://grafana.jorisjonkers.test,https://dashboard.jorisjonkers.test,https://rabbitmq.jorisjonkers.test,https://stalwart.jorisjonkers.test,https://traefik.jorisjonkers.test,https://status.jorisjonkers.test + AUTH_CORS_ALLOWED_ORIGINS: https://jorisjonkers.test,https://auth.jorisjonkers.test,https://agents.jorisjonkers.test,https://vault.jorisjonkers.test,https://n8n.jorisjonkers.test,https://grafana.jorisjonkers.test,https://dashboard.jorisjonkers.test,https://rabbitmq.jorisjonkers.test,https://stalwart.jorisjonkers.test,https://traefik.jorisjonkers.test,https://status.jorisjonkers.test DEPLOYMENT_ENVIRONMENT: local SERVICE_VERSION: latest OTEL_SERVICE_NAME: auth-api @@ -621,26 +621,23 @@ services: done vault write auth/oidc/role/default @/tmp/vault-oidc-role.json >/dev/null - assistant-api: - image: personal-stack/assistant-api:latest - build: - context: . - dockerfile: services/assistant-api/Dockerfile - container_name: personal-stack-assistant-api + agents-api: + image: ghcr.io/extratoast/agents/agents-api:latest + container_name: personal-stack-agents-api ports: - '8082:8082' environment: DB_HOST: postgres DB_PORT: 5432 - DB_USER: assistant_user - DB_PASSWORD: assistant_password + DB_USER: agents_user + DB_PASSWORD: agents_password VALKEY_HOST: valkey VALKEY_PORT: 6379 SPRING_RABBITMQ_HOST: rabbitmq SPRING_RABBITMQ_PORT: 5672 DEPLOYMENT_ENVIRONMENT: local SERVICE_VERSION: latest - OTEL_SERVICE_NAME: assistant-api + OTEL_SERVICE_NAME: agents-api OTEL_RESOURCE_ATTRIBUTES: deployment.environment=local,service.version=latest OTEL_LOGS_EXPORTER: none OTEL_TRACES_EXPORTER: otlp @@ -662,18 +659,18 @@ services: start_period: 30s labels: - 'traefik.enable=true' - - 'traefik.http.routers.assistant-api.rule=Host(`assistant.jorisjonkers.test`) && PathPrefix(`/api/`) && !PathPrefix(`/api/actuator/health`) && !Path(`/api/v1/health`)' - - 'traefik.http.routers.assistant-api.entrypoints=websecure' - - 'traefik.http.routers.assistant-api.tls=true' - - 'traefik.http.routers.assistant-api.middlewares=forward-auth@docker,security-headers@file' - - 'traefik.http.routers.assistant-api.service=assistant-api' - - 'traefik.http.routers.assistant-api-health.rule=Host(`assistant.jorisjonkers.test`) && (Path(`/api/actuator/health`) || PathPrefix(`/api/actuator/health/`) || Path(`/api/v1/health`))' - - 'traefik.http.routers.assistant-api-health.entrypoints=websecure' - - 'traefik.http.routers.assistant-api-health.tls=true' - - 'traefik.http.routers.assistant-api-health.middlewares=security-headers@file' - - 'traefik.http.routers.assistant-api-health.priority=100' - - 'traefik.http.routers.assistant-api-health.service=assistant-api' - - 'traefik.http.services.assistant-api.loadbalancer.server.port=8082' + - 'traefik.http.routers.agents-api.rule=Host(`agents.jorisjonkers.test`) && PathPrefix(`/api/`) && !PathPrefix(`/api/actuator/health`) && !Path(`/api/v1/health`)' + - 'traefik.http.routers.agents-api.entrypoints=websecure' + - 'traefik.http.routers.agents-api.tls=true' + - 'traefik.http.routers.agents-api.middlewares=forward-auth@docker,security-headers@file' + - 'traefik.http.routers.agents-api.service=agents-api' + - 'traefik.http.routers.agents-api-health.rule=Host(`agents.jorisjonkers.test`) && (Path(`/api/actuator/health`) || PathPrefix(`/api/actuator/health/`) || Path(`/api/v1/health`))' + - 'traefik.http.routers.agents-api-health.entrypoints=websecure' + - 'traefik.http.routers.agents-api-health.tls=true' + - 'traefik.http.routers.agents-api-health.middlewares=security-headers@file' + - 'traefik.http.routers.agents-api-health.priority=100' + - 'traefik.http.routers.agents-api-health.service=agents-api' + - 'traefik.http.services.agents-api.loadbalancer.server.port=8082' # ── Frontend Services ─────────────────────────────────────────── @@ -698,14 +695,9 @@ services: - 'traefik.http.routers.auth-ui.middlewares=security-headers@file' - 'traefik.http.services.auth-ui.loadbalancer.server.port=80' - assistant-ui: - image: personal-stack/assistant-ui:latest - build: - context: . - dockerfile: services/assistant-ui/Dockerfile - args: - VITE_AUTH_URL: https://auth.jorisjonkers.test - container_name: personal-stack-assistant-ui + agents-ui: + image: ghcr.io/extratoast/agents/agents-ui:latest + container_name: personal-stack-agents-ui healthcheck: test: ['CMD-SHELL', 'wget -qO /dev/null http://127.0.0.1/ || exit 1'] interval: 5s @@ -714,12 +706,12 @@ services: start_period: 5s labels: - 'traefik.enable=true' - - 'traefik.http.routers.assistant-ui.rule=Host(`assistant.jorisjonkers.test`)' - - 'traefik.http.routers.assistant-ui.entrypoints=websecure' - - 'traefik.http.routers.assistant-ui.tls=true' - - 'traefik.http.routers.assistant-ui.priority=1' - - 'traefik.http.routers.assistant-ui.middlewares=security-headers@file' - - 'traefik.http.services.assistant-ui.loadbalancer.server.port=80' + - 'traefik.http.routers.agents-ui.rule=Host(`agents.jorisjonkers.test`)' + - 'traefik.http.routers.agents-ui.entrypoints=websecure' + - 'traefik.http.routers.agents-ui.tls=true' + - 'traefik.http.routers.agents-ui.priority=1' + - 'traefik.http.routers.agents-ui.middlewares=security-headers@file' + - 'traefik.http.services.agents-ui.loadbalancer.server.port=80' app-ui: image: personal-stack/app-ui:latest diff --git a/eslint.config.ts b/eslint.config.ts index 4019c174..702eeb70 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -6,8 +6,8 @@ export default antfu( typescript: true, formatters: false, // Generated artefacts (e.g. openapi-typescript output pinned by the - // assistant-api ↔ assistant-ui contract gate) are regenerated on - // every drift check; reformatting them would only churn the diff. + // auth-api ↔ auth-ui contract gate) are regenerated on every drift + // check; reformatting them would only churn the diff. ignores: ['**/src/api/generated.ts'], }, { diff --git a/infra/docker/postgres/init-databases.sh b/infra/docker/postgres/init-databases.sh index 089336c5..05b41325 100755 --- a/infra/docker/postgres/init-databases.sh +++ b/infra/docker/postgres/init-databases.sh @@ -29,8 +29,8 @@ resolve_value() { AUTH_DB_USER=$(resolve_value AUTH_DB_USER auth_db_user auth_user) AUTH_DB_PASSWORD=$(resolve_value AUTH_DB_PASSWORD auth_db_password auth_password) -ASSISTANT_DB_USER=$(resolve_value ASSISTANT_DB_USER assistant_db_user assistant_user) -ASSISTANT_DB_PASSWORD=$(resolve_value ASSISTANT_DB_PASSWORD assistant_db_password assistant_password) +AGENTS_DB_USER=$(resolve_value AGENTS_DB_USER agents_db_user agents_user) +AGENTS_DB_PASSWORD=$(resolve_value AGENTS_DB_PASSWORD agents_db_password agents_password) N8N_DB_USER=$(resolve_value N8N_DB_USER n8n_db_user n8n_user) N8N_DB_PASSWORD=$(resolve_value N8N_DB_PASSWORD n8n_db_password n8n_password) @@ -40,10 +40,10 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E CREATE DATABASE auth_db OWNER ${AUTH_DB_USER}; GRANT ALL PRIVILEGES ON DATABASE auth_db TO ${AUTH_DB_USER}; - -- Assistant service database - CREATE USER ${ASSISTANT_DB_USER} WITH PASSWORD '${ASSISTANT_DB_PASSWORD}'; - CREATE DATABASE assistant_db OWNER ${ASSISTANT_DB_USER}; - GRANT ALL PRIVILEGES ON DATABASE assistant_db TO ${ASSISTANT_DB_USER}; + -- Agents service database + CREATE USER ${AGENTS_DB_USER} WITH PASSWORD '${AGENTS_DB_PASSWORD}'; + CREATE DATABASE agents_db OWNER ${AGENTS_DB_USER}; + GRANT ALL PRIVILEGES ON DATABASE agents_db TO ${AGENTS_DB_USER}; -- n8n database CREATE USER ${N8N_DB_USER} WITH PASSWORD '${N8N_DB_PASSWORD}'; @@ -53,7 +53,7 @@ EOSQL # PostgreSQL 15+ revoked default CREATE on the public schema for non-superusers. # Transfer public schema ownership so each service can run migrations. -for db_and_user in "auth_db:${AUTH_DB_USER}" "assistant_db:${ASSISTANT_DB_USER}" "n8n_db:${N8N_DB_USER}"; do +for db_and_user in "auth_db:${AUTH_DB_USER}" "agents_db:${AGENTS_DB_USER}" "n8n_db:${N8N_DB_USER}"; do db="${db_and_user%%:*}" user="${db_and_user##*:}" psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$db" <<-EOSQL diff --git a/infra/observability/prometheus/prometheus.dev.yml b/infra/observability/prometheus/prometheus.dev.yml index 6153fbe6..657f4755 100644 --- a/infra/observability/prometheus/prometheus.dev.yml +++ b/infra/observability/prometheus/prometheus.dev.yml @@ -14,10 +14,10 @@ scrape_configs: static_configs: - targets: ['auth-api:8081'] - - job_name: assistant-api + - job_name: agents-api metrics_path: /api/actuator/prometheus static_configs: - - targets: ['assistant-api:8082'] + - targets: ['agents-api:8082'] - job_name: traefik static_configs: diff --git a/infra/observability/prometheus/prometheus.yml b/infra/observability/prometheus/prometheus.yml index bc5dfb5d..b7de1c25 100644 --- a/infra/observability/prometheus/prometheus.yml +++ b/infra/observability/prometheus/prometheus.yml @@ -16,10 +16,10 @@ scrape_configs: type: A port: 8081 - - job_name: assistant-api + - job_name: agents-api metrics_path: /api/actuator/prometheus dns_sd_configs: - - names: ['tasks.assistant-api'] + - names: ['tasks.agents-api'] type: A port: 8082 diff --git a/infra/scripts/make-admin.sh b/infra/scripts/make-admin.sh index 90aea546..9ff249cc 100755 --- a/infra/scripts/make-admin.sh +++ b/infra/scripts/make-admin.sh @@ -39,7 +39,7 @@ BEGIN (v_id, 'MAIL'), (v_id, 'N8N'), (v_id, 'GRAFANA'), - (v_id, 'ASSISTANT'), + (v_id, 'AGENTS'), (v_id, 'DASHBOARD'), (v_id, 'RABBITMQ'), (v_id, 'TRAEFIK'), diff --git a/infra/scripts/setup-dev-dns.sh b/infra/scripts/setup-dev-dns.sh index 8af77a9c..f6c76d88 100755 --- a/infra/scripts/setup-dev-dns.sh +++ b/infra/scripts/setup-dev-dns.sh @@ -13,7 +13,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HOSTS=( "$DOMAIN" "auth.$DOMAIN" - "assistant.$DOMAIN" + "agents.$DOMAIN" "vault.$DOMAIN" "rabbitmq.$DOMAIN" "mail.$DOMAIN" diff --git a/package.json b/package.json index 184f78e2..dd8348e9 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,7 @@ "deploy-config:schema:drift": "toolkit_dir=\"${DEPLOY_CONFIG_SCHEMA_TOOLKIT_DIR:-/tmp/personal-stack-deploy-config-schema-toolkit}\"; npm install --no-save --prefix \"$toolkit_dir\" @extratoast/deploy-config-schema@^0.4.0 && DEPLOY_CONFIG_SCHEMA_LOCAL=\"$toolkit_dir/node_modules/@extratoast/deploy-config-schema\" DEPLOY_CONFIG_SCHEMA_BIN=\"$toolkit_dir/node_modules/.bin/deploy-config-schema\" bash platform/scripts/deploy-config-schema/report-edge-drift.sh", "prepare": "husky", "generate:auth-client": "openapi-generator-cli generate -i services/auth-api/build/generated/openapi/auth-api.json -g typescript-fetch -o services/auth-ui/src/shared/services/api/generated --additional-properties=typescriptThreePlus=true,supportsES6=true", - "generate:assistant-client": "openapi-generator-cli generate -i services/assistant-api/build/generated/openapi/assistant-api.json -g typescript-fetch -o services/assistant-ui/src/shared/services/api/generated --additional-properties=typescriptThreePlus=true,supportsES6=true", - "generate:clients": "pnpm generate:auth-client && pnpm generate:assistant-client" + "generate:clients": "pnpm generate:auth-client" }, "devDependencies": { "@antfu/eslint-config": "^9.0.0", diff --git a/platform/agents/kit/render-agent-kit.py b/platform/agents/kit/render-agent-kit.py index 953f42c8..52a6d045 100755 --- a/platform/agents/kit/render-agent-kit.py +++ b/platform/agents/kit/render-agent-kit.py @@ -46,7 +46,6 @@ SPECIFY_SRC = REPO_TEMPLATE_ROOT / ".specify" SPECIFY_CONSTITUTION_SRC = REPOSITORY_ROOT / ".specify" / "memory" / "constitution.md" MANIFEST_PATH = KIT_ROOT / "manifest.yaml" -RUNNER_ENTRYPOINT = REPOSITORY_ROOT / "services" / "agent-runner" / "entrypoint.sh" MCP_CONFIGMAP = ( REPOSITORY_ROOT / "platform" / "cluster" / "flux" / "apps" / "agents" / "mcp" / "agents-mcp-servers-configmap.yaml" ) @@ -412,13 +411,6 @@ def configmap_blocks(manifest: str) -> dict[str, str]: return blocks -def runner_profiles(entrypoint: str) -> set[str]: - match = re.search(r"""case "\$AGENT_MCP_PROFILE" in\s+([-A-Za-z0-9_.|]+)\) ;;""", entrypoint) - if not match: - raise ValueError("AGENT_MCP_PROFILE allow-list not found in entrypoint") - return set(match.group(1).split("|")) - - def profile_names(blocks: dict[str, str], prefix: str, extension: str) -> set[str]: pattern = re.compile(rf"^{re.escape(prefix)}\.([-A-Za-z0-9_.]+)\.{re.escape(extension)}$") return { @@ -455,20 +447,14 @@ def profile_counts(blocks: dict[str, str], profiles: set[str]) -> tuple[dict[str def mcp_profile_check() -> DoctorCheck: try: - entrypoint_profiles = runner_profiles(RUNNER_ENTRYPOINT.read_text()) blocks = configmap_blocks(MCP_CONFIGMAP.read_text()) claude_profiles = profile_names(blocks, prefix="claude-mcp-servers", extension="json") - {""} codex_profiles = profile_names(blocks, prefix="codex-mcp-servers", extension="toml") - {""} - profile_sets = { - "entrypoint": entrypoint_profiles, - "claude": claude_profiles, - "codex": codex_profiles, - } - if len({tuple(sorted(value)) for value in profile_sets.values()}) != 1: - details = "; ".join(f"{name}={','.join(sorted(value))}" for name, value in profile_sets.items()) + if claude_profiles != codex_profiles: + details = f"claude={','.join(sorted(claude_profiles))}; codex={','.join(sorted(codex_profiles))}" return DoctorCheck(name="mcp-profiles", status="fail", detail=f"profile sets differ: {details}") - claude_counts, codex_counts = profile_counts(blocks, entrypoint_profiles) + claude_counts, codex_counts = profile_counts(blocks, claude_profiles) minimal = f"minimal {claude_counts.get('minimal', 0)} Claude/{codex_counts.get('minimal', 0)} Codex servers" full = ( f"full-diagnostic {claude_counts.get('full-diagnostic', 0)} Claude/" @@ -477,7 +463,7 @@ def mcp_profile_check() -> DoctorCheck: return DoctorCheck( name="mcp-profiles", status="ok", - detail=f"{len(entrypoint_profiles)} synchronized profiles; {minimal}; {full}", + detail=f"{len(claude_profiles)} synchronized profiles; {minimal}; {full}", ) except OSError as exc: return DoctorCheck(name="mcp-profiles", status="fail", detail=str(exc)) diff --git a/platform/cluster/flux/apps/agents/agent-gateway-image/service-account.yaml b/platform/cluster/flux/apps/agents/agent-gateway-image/service-account.yaml index 2ad302e3..8e3dc399 100644 --- a/platform/cluster/flux/apps/agents/agent-gateway-image/service-account.yaml +++ b/platform/cluster/flux/apps/agents/agent-gateway-image/service-account.yaml @@ -1,6 +1,6 @@ -# SA used by every runner Pod (created at runtime by assistant-api). +# SA used by every runner Pod (created at runtime by agents-api). # No special RBAC — the runner does not talk to the k8s API itself; -# all orchestration goes through assistant-api which has its own SA +# all orchestration goes through agents-api which has its own SA # with Pod CRUD in this namespace. # # automountServiceAccountToken is false because the runner needs no API diff --git a/platform/cluster/flux/apps/agents/credentials/claude-credentials-pvc.yaml b/platform/cluster/flux/apps/agents/credentials/claude-credentials-pvc.yaml index abbb1bcb..5b3728f6 100644 --- a/platform/cluster/flux/apps/agents/credentials/claude-credentials-pvc.yaml +++ b/platform/cluster/flux/apps/agents/credentials/claude-credentials-pvc.yaml @@ -8,7 +8,7 @@ # binds to the first node that consumes it (WaitForFirstConsumer), # which is fine: every Pod that mounts this volume — the bootstrap # Pod, the refresh-ping + kb-install CronJobs, and the per-workspace -# agent-runner Pods spawned by assistant-api — is pinned to +# agent-runner Pods spawned by agents-api — is pinned to # `personal-stack/node: enschede-gtx-960m-1`, so the PV stays on a # single deterministic node. RWO permits multiple Pods on that node # to mount the volume simultaneously, which is what the orchestrator diff --git a/platform/cluster/flux/apps/agents/credentials/github-app-vss.yaml b/platform/cluster/flux/apps/agents/credentials/github-app-vss.yaml index 588e680a..815f7a81 100644 --- a/platform/cluster/flux/apps/agents/credentials/github-app-vss.yaml +++ b/platform/cluster/flux/apps/agents/credentials/github-app-vss.yaml @@ -1,6 +1,6 @@ -# Bearer the runner's `gh` wrapper presents to assistant-api's +# Bearer the runner's `gh` wrapper presents to agents-api's # installation-token endpoint. Projected from secret/agents/github-app -# (same path the assistant-system copy reads its App id + key from). +# (same path the agents-system copy reads its App id + key from). apiVersion: secrets.hashicorp.com/v1beta1 kind: VaultStaticSecret metadata: diff --git a/platform/cluster/flux/apps/agents/kustomization.yaml b/platform/cluster/flux/apps/agents/kustomization.yaml index 3e8c853e..36de3d22 100644 --- a/platform/cluster/flux/apps/agents/kustomization.yaml +++ b/platform/cluster/flux/apps/agents/kustomization.yaml @@ -10,5 +10,5 @@ resources: - refresh-ping - kb-install - network-policy - - agent-gateway-image # shared image pull only; runner Pods are created at runtime by assistant-api + - agent-gateway-image # shared image pull only; runner Pods are created at runtime by agents-api - rbac diff --git a/platform/cluster/flux/apps/agents/network-policy/runner-egress.yaml b/platform/cluster/flux/apps/agents/network-policy/runner-egress.yaml index 98e67cf9..7dd5318f 100644 --- a/platform/cluster/flux/apps/agents/network-policy/runner-egress.yaml +++ b/platform/cluster/flux/apps/agents/network-policy/runner-egress.yaml @@ -4,7 +4,7 @@ # allow-set mirrors what each CLI + git workflow actually needs: # # - DNS (kube-dns) -# - cluster-internal: knowledge-api, lightrag, assistant-api +# - cluster-internal: knowledge-api, lightrag, agents-api # - external: api.anthropic.com, api.openai.com, github.com, ghcr.io # # External egress is enforced by IP egress in a follow-up once we @@ -25,21 +25,21 @@ spec: - Ingress - Egress ingress: - # Only assistant-api may reach the runner's agent-gateway sidecar. - # Both the Frankfurt deployment (assistant-api) and the Enschede - # terminal-WS replica (assistant-api-ws) bridge the attach + # Only agents-api may reach the runner's agent-gateway sidecar. + # Both the Frankfurt deployment (agents-api) and the Enschede + # terminal-WS replica (agents-api-ws) bridge the attach # WebSocket, so both labels are allowed. - from: - namespaceSelector: matchLabels: - kubernetes.io/metadata.name: assistant-system + kubernetes.io/metadata.name: agents-system podSelector: matchExpressions: - key: app.kubernetes.io/name operator: In values: - - assistant-api - - assistant-api-ws + - agents-api + - agents-api-ws ports: - protocol: TCP port: 8090 @@ -79,21 +79,21 @@ spec: ports: - protocol: TCP port: 8080 - # assistant-api internal endpoint used by the runner's GitHub App + # agents-api internal endpoint used by the runner's GitHub App # token wrappers (`gh` + GitHub MCP). Both the regular Frankfurt # deployment and the Enschede WebSocket replica can serve the # in-cluster token minting route. - to: - namespaceSelector: matchLabels: - kubernetes.io/metadata.name: assistant-system + kubernetes.io/metadata.name: agents-system podSelector: matchExpressions: - key: app.kubernetes.io/name operator: In values: - - assistant-api - - assistant-api-ws + - agents-api + - agents-api-ws ports: - protocol: TCP port: 8082 diff --git a/platform/cluster/flux/apps/agents/rbac/assistant-api-role.yaml b/platform/cluster/flux/apps/agents/rbac/agents-api-role.yaml similarity index 73% rename from platform/cluster/flux/apps/agents/rbac/assistant-api-role.yaml rename to platform/cluster/flux/apps/agents/rbac/agents-api-role.yaml index 529d8210..a9241810 100644 --- a/platform/cluster/flux/apps/agents/rbac/assistant-api-role.yaml +++ b/platform/cluster/flux/apps/agents/rbac/agents-api-role.yaml @@ -1,21 +1,18 @@ -# assistant-api is the only client that mutates agent-runner Pods. +# agents-api is the only client that mutates agent-runner Pods. # It needs Pod + PVC CRUD scoped to agents-system, plus the ability # to read the credential / deploy-key Secrets so it can stamp them # into each Pod's volume mounts. apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: assistant-api-runner-controller + name: agents-api-runner-controller namespace: agents-system rules: # `patch` is required on every resource the orchestrator mutates # because Fabric8AgentRunnerOrchestrator uses `serverSideApply()`, # which Kubernetes implements as PATCH with content-type # application/apply-patch+yaml. Without it the very first - # workspace creation fails at the PVC step: - # persistentvolumeclaims "workspace-" is forbidden: - # User "system:serviceaccount:assistant-system:assistant-api" - # cannot patch resource "persistentvolumeclaims". + # workspace creation fails at the PVC step. # Per-workspace deploy-key Secrets are stamped via server-side # apply when the workspace is project-backed, so the secrets # verb set needs create/patch/delete on top of the original @@ -34,13 +31,13 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: assistant-api-runner-controller + name: agents-api-runner-controller namespace: agents-system subjects: - kind: ServiceAccount - name: assistant-api - namespace: assistant-system + name: agents-api + namespace: agents-system roleRef: kind: Role - name: assistant-api-runner-controller + name: agents-api-runner-controller apiGroup: rbac.authorization.k8s.io diff --git a/platform/cluster/flux/apps/agents/rbac/kustomization.yaml b/platform/cluster/flux/apps/agents/rbac/kustomization.yaml index 7ed96ab2..31164db5 100644 --- a/platform/cluster/flux/apps/agents/rbac/kustomization.yaml +++ b/platform/cluster/flux/apps/agents/rbac/kustomization.yaml @@ -2,4 +2,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - assistant-api-role.yaml + - agents-api-role.yaml diff --git a/platform/cluster/flux/apps/data/postgres/config-configmap.yaml b/platform/cluster/flux/apps/data/postgres/config-configmap.yaml index 98b011bb..4cc66cfa 100644 --- a/platform/cluster/flux/apps/data/postgres/config-configmap.yaml +++ b/platform/cluster/flux/apps/data/postgres/config-configmap.yaml @@ -5,7 +5,7 @@ metadata: namespace: data-system data: # Hand-tuned postgresql.conf for the shared Postgres 17 pod that - # backs auth-api, assistant-api, and n8n. Upstream defaults + # backs auth-api, agents-api, and n8n. Upstream defaults # (shared_buffers=128MB, work_mem=4MB, no slow-query logging) leave # a lot on the table on a node that has plenty of RAM. # diff --git a/platform/cluster/flux/apps/data/postgres/deployment.yaml b/platform/cluster/flux/apps/data/postgres/deployment.yaml index ea9ddde5..731ad053 100644 --- a/platform/cluster/flux/apps/data/postgres/deployment.yaml +++ b/platform/cluster/flux/apps/data/postgres/deployment.yaml @@ -44,8 +44,8 @@ spec: POSTGRES_PASSWORD={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "postgres.password" }}{{ end }} AUTH_DB_USER={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "auth.user" }}{{ end }} AUTH_DB_PASSWORD={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "auth.password" }}{{ end }} - ASSISTANT_DB_USER={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "assistant.user" }}{{ end }} - ASSISTANT_DB_PASSWORD={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "assistant.password" }}{{ end }} + AGENTS_DB_USER={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "agents.user" }}{{ end }} + AGENTS_DB_PASSWORD={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "agents.password" }}{{ end }} KB_DB_USER={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "kb.user" }}{{ end }} KB_DB_PASSWORD={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "kb.password" }}{{ end }} spec: diff --git a/platform/cluster/flux/apps/data/postgres/init-script-configmap.yaml b/platform/cluster/flux/apps/data/postgres/init-script-configmap.yaml index 521a7d77..b60e7353 100644 --- a/platform/cluster/flux/apps/data/postgres/init-script-configmap.yaml +++ b/platform/cluster/flux/apps/data/postgres/init-script-configmap.yaml @@ -36,8 +36,8 @@ data: AUTH_DB_USER=$(resolve_value AUTH_DB_USER auth_db_user auth_user) AUTH_DB_PASSWORD=$(resolve_value AUTH_DB_PASSWORD auth_db_password auth_password) - ASSISTANT_DB_USER=$(resolve_value ASSISTANT_DB_USER assistant_db_user assistant_user) - ASSISTANT_DB_PASSWORD=$(resolve_value ASSISTANT_DB_PASSWORD assistant_db_password assistant_password) + AGENTS_DB_USER=$(resolve_value AGENTS_DB_USER agents_db_user agents_user) + AGENTS_DB_PASSWORD=$(resolve_value AGENTS_DB_PASSWORD agents_db_password agents_password) KB_DB_USER=$(resolve_value KB_DB_USER kb_db_user kb_user) KB_DB_PASSWORD=$(resolve_value KB_DB_PASSWORD kb_db_password kb_password) WOLFMANAGER_DB_USER=wolfmanager_user @@ -49,10 +49,10 @@ data: CREATE DATABASE auth_db OWNER ${AUTH_DB_USER}; GRANT ALL PRIVILEGES ON DATABASE auth_db TO ${AUTH_DB_USER}; - -- Assistant service database - CREATE USER ${ASSISTANT_DB_USER} WITH PASSWORD '${ASSISTANT_DB_PASSWORD}'; - CREATE DATABASE assistant_db OWNER ${ASSISTANT_DB_USER}; - GRANT ALL PRIVILEGES ON DATABASE assistant_db TO ${ASSISTANT_DB_USER}; + -- Agents service database + CREATE USER ${AGENTS_DB_USER} WITH PASSWORD '${AGENTS_DB_PASSWORD}'; + CREATE DATABASE agents_db OWNER ${AGENTS_DB_USER}; + GRANT ALL PRIVILEGES ON DATABASE agents_db TO ${AGENTS_DB_USER}; -- Knowledge-base database. Hosts both the Kotlin knowledge-api -- (read path) and the Python knowledge-ingest-worker (write path). @@ -84,7 +84,7 @@ data: # Also enable pg_stat_statements in each app DB so we can inspect # top-N slow queries by mean_exec_time; the shared library is # preloaded via postgresql.conf (see config-configmap.yaml). - for db_and_user in "auth_db:${AUTH_DB_USER}" "assistant_db:${ASSISTANT_DB_USER}" "knowledge_db:${KB_DB_USER}" "wolfmanager_db:${WOLFMANAGER_DB_USER}" "n8n_db:${N8N_DB_USER}"; do + for db_and_user in "auth_db:${AUTH_DB_USER}" "agents_db:${AGENTS_DB_USER}" "knowledge_db:${KB_DB_USER}" "wolfmanager_db:${WOLFMANAGER_DB_USER}" "n8n_db:${N8N_DB_USER}"; do db="${db_and_user%%:*}" user="${db_and_user##*:}" psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$db" <<-EOSQL diff --git a/platform/cluster/flux/apps/data/priorityclasses.yaml b/platform/cluster/flux/apps/data/priorityclasses.yaml index 566a9fc3..96c855b0 100644 --- a/platform/cluster/flux/apps/data/priorityclasses.yaml +++ b/platform/cluster/flux/apps/data/priorityclasses.yaml @@ -28,7 +28,7 @@ value: 100000 globalDefault: false preemptionPolicy: PreemptLowerPriority description: >- - Critical request-path services (auth-api, assistant-api, knowledge-api). + Critical request-path services (auth-api, agents-api, knowledge-api). Ranked below the data tier but above the default so login and core APIs keep scheduling when the VPS is tight, while non-essential workloads (automation, media) yield. diff --git a/platform/cluster/flux/apps/data/vault/bootstrap-auth.sh b/platform/cluster/flux/apps/data/vault/bootstrap-auth.sh index b7a91747..41a5550b 100644 --- a/platform/cluster/flux/apps/data/vault/bootstrap-auth.sh +++ b/platform/cluster/flux/apps/data/vault/bootstrap-auth.sh @@ -88,18 +88,18 @@ path "transit/keys/auth-api-jwt" { } EOF -cat <<'EOF' >/tmp/assistant-api.hcl -path "secret/data/assistant-api" { +cat <<'EOF' >/tmp/agents-api.hcl +path "secret/data/agents-api" { capabilities = ["read"] } -path "secret/data/assistant-api/*" { +path "secret/data/agents-api/*" { capabilities = ["read"] } -# Static datasource credential (assistant_user), read by the vault-agent +# Static datasource credential (agents_user), read by the vault-agent # template as DB_USER/DB_PASSWORD. Fallback for when the dynamic database -# backend (database/creds/assistant-api below) does not bind — see auth-api. +# backend (database/creds/agents-api below) does not bind — see auth-api. path "secret/data/platform/postgres" { capabilities = ["read"] } @@ -124,7 +124,7 @@ path "rabbitmq/creds/app-consumer" { capabilities = ["read"] } -path "database/creds/assistant-api" { +path "database/creds/agents-api" { capabilities = ["read"] } EOF @@ -263,7 +263,7 @@ path "secret/data/agents/*" { EOF vault policy write auth-api /tmp/auth-api.hcl -vault policy write assistant-api /tmp/assistant-api.hcl +vault policy write agents-api /tmp/agents-api.hcl vault policy write n8n /tmp/n8n.hcl vault policy write postgres /tmp/postgres.hcl vault policy write rabbitmq /tmp/rabbitmq.hcl @@ -278,10 +278,10 @@ vault write auth/kubernetes/role/auth-api \ policies="auth-api" \ ttl="1h" -vault write auth/kubernetes/role/assistant-api \ - bound_service_account_names="assistant-api" \ - bound_service_account_namespaces="assistant-system" \ - policies="assistant-api" \ +vault write auth/kubernetes/role/agents-api \ + bound_service_account_names="agents-api" \ + bound_service_account_namespaces="agents-system" \ + policies="agents-api" \ ttl="1h" vault write auth/kubernetes/role/n8n \ @@ -316,7 +316,7 @@ vault write auth/kubernetes/role/stalwart \ vault write auth/kubernetes/role/vso \ bound_service_account_names="vault-secrets-operator" \ - bound_service_account_namespaces="vso-system,cert-manager,external-dns,observability,automation-system,utility-system,knowledge-system,data-system,media-system,mail-system,agents-system,assistant-system" \ + bound_service_account_namespaces="vso-system,cert-manager,external-dns,observability,automation-system,utility-system,knowledge-system,data-system,media-system,mail-system,agents-system,agents-system" \ policies="vso" \ ttl="1h" @@ -443,10 +443,10 @@ PG_ADMIN_PASS=$(vault kv get -field=postgres.password secret/platform/postgres) N8N_PARENT_ROLE="n8n_user" WOLFMANAGER_PARENT_ROLE="wolfmanager_user" AUTH_PARENT_ROLE=$(vault kv get -field=auth.user secret/platform/postgres) -ASSISTANT_PARENT_ROLE=$(vault kv get -field=assistant.user secret/platform/postgres) +AGENTS_PARENT_ROLE=$(vault kv get -field=agents.user secret/platform/postgres) vault write database/config/postgres \ plugin_name=postgresql-database-plugin \ - allowed_roles="n8n,auth-api,assistant-api,wolfmanager" \ + allowed_roles="n8n,auth-api,agents-api,wolfmanager" \ connection_url="postgres://{{username}}:{{password}}@postgres.data-system.svc.cluster.local:5432/postgres?sslmode=disable" \ username="${PG_ADMIN_USER}" \ password="${PG_ADMIN_PASS}" \ @@ -482,13 +482,13 @@ vault write database/roles/auth-api \ max_ttl="168h" \ creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' IN ROLE \"${AUTH_PARENT_ROLE}\" INHERIT;" \ revocation_statements="REASSIGN OWNED BY \"{{name}}\" TO \"${AUTH_PARENT_ROLE}\"; DROP OWNED BY \"{{name}}\"; DROP ROLE \"{{name}}\";" -# assistant-api has the same spring.cloud.vault.database.role shape as -# auth-api (services/assistant-api/src/main/resources/application.yml), +# agents-api has the same spring.cloud.vault.database.role shape as +# auth-api (services/agents-api/src/main/resources/application.yml), # and was silently affected by the same missing-role bug. -vault write database/roles/assistant-api \ +vault write database/roles/agents-api \ db_name=postgres \ default_ttl="72h" \ max_ttl="168h" \ - creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' IN ROLE \"${ASSISTANT_PARENT_ROLE}\" INHERIT;" \ - revocation_statements="REASSIGN OWNED BY \"{{name}}\" TO \"${ASSISTANT_PARENT_ROLE}\"; DROP OWNED BY \"{{name}}\"; DROP ROLE \"{{name}}\";" -unset PG_ADMIN_USER PG_ADMIN_PASS N8N_PARENT_ROLE WOLFMANAGER_PARENT_ROLE AUTH_PARENT_ROLE ASSISTANT_PARENT_ROLE + creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' IN ROLE \"${AGENTS_PARENT_ROLE}\" INHERIT;" \ + revocation_statements="REASSIGN OWNED BY \"{{name}}\" TO \"${AGENTS_PARENT_ROLE}\"; DROP OWNED BY \"{{name}}\"; DROP ROLE \"{{name}}\";" +unset PG_ADMIN_USER PG_ADMIN_PASS N8N_PARENT_ROLE WOLFMANAGER_PARENT_ROLE AUTH_PARENT_ROLE AGENTS_PARENT_ROLE diff --git a/platform/cluster/flux/apps/edge/edge-catalog-configmap.yaml b/platform/cluster/flux/apps/edge/edge-catalog-configmap.yaml index 7bbbf13a..2f8fdbab 100644 --- a/platform/cluster/flux/apps/edge/edge-catalog-configmap.yaml +++ b/platform/cluster/flux/apps/edge/edge-catalog-configmap.yaml @@ -12,6 +12,18 @@ data: exposure: "public" access: "sso_protected" host: "adguard.jorisjonkers.dev" + - name: "agents-api" + exposure: "public" + access: "sso_protected" + host: "agents.jorisjonkers.dev" + - name: "agents-ui" + exposure: "public" + access: "direct" + host: "agents.jorisjonkers.dev" + - name: "agents-ws" + exposure: "public_and_lan" + access: "sso_protected" + host: "agents-ws.jorisjonkers.dev" - name: "alloy" exposure: "internal_only" access: "cluster_internal" @@ -19,18 +31,6 @@ data: exposure: "public" access: "direct" host: "jorisjonkers.dev" - - name: "assistant-api" - exposure: "public" - access: "sso_protected" - host: "assistant.jorisjonkers.dev" - - name: "assistant-ui" - exposure: "public" - access: "direct" - host: "assistant.jorisjonkers.dev" - - name: "assistant-ws" - exposure: "public_and_lan" - access: "sso_protected" - host: "assistant-ws.jorisjonkers.dev" - name: "auth-api" exposure: "public" access: "direct" diff --git a/platform/cluster/flux/apps/edge/edge-route-catalog-configmap.yaml b/platform/cluster/flux/apps/edge/edge-route-catalog-configmap.yaml index 7e143d28..9709e7b3 100644 --- a/platform/cluster/flux/apps/edge/edge-route-catalog-configmap.yaml +++ b/platform/cluster/flux/apps/edge/edge-route-catalog-configmap.yaml @@ -12,13 +12,9 @@ data: service: "adguard" host: "adguard.jorisjonkers.dev" access: "sso_protected" - - name: "app-ui" - service: "app-ui" - host: "jorisjonkers.dev" - access: "direct" - - name: "assistant-api" - service: "assistant-api" - host: "assistant.jorisjonkers.dev" + - name: "agents-api" + service: "agents-api" + host: "agents.jorisjonkers.dev" access: "sso_protected" path_prefixes: - "/api/" @@ -27,25 +23,29 @@ data: excluded_paths: - "/api/actuator/health" - "/api/v1/health" - - name: "assistant-api-health" - service: "assistant-api" - host: "assistant.jorisjonkers.dev" + - name: "agents-api-health" + service: "agents-api" + host: "agents.jorisjonkers.dev" access: "direct" path_prefixes: - "/api/actuator/health/" exact_paths: - "/api/actuator/health" - "/api/v1/health" - - name: "assistant-ui" - service: "assistant-ui" - host: "assistant.jorisjonkers.dev" + - name: "agents-ui" + service: "agents-ui" + host: "agents.jorisjonkers.dev" access: "direct" excluded_path_prefixes: - "/api/" - - name: "assistant-ws" - service: "assistant-ws" - host: "assistant-ws.jorisjonkers.dev" + - name: "agents-ws" + service: "agents-ws" + host: "agents-ws.jorisjonkers.dev" access: "sso_protected" + - name: "app-ui" + service: "app-ui" + host: "jorisjonkers.dev" + access: "direct" - name: "auth-api" service: "auth-api" host: "auth.jorisjonkers.dev" diff --git a/platform/cluster/flux/apps/edge/traefik-ingressroutes.yaml b/platform/cluster/flux/apps/edge/traefik-ingressroutes.yaml index 4ff5d2a7..71f1e36a 100644 --- a/platform/cluster/flux/apps/edge/traefik-ingressroutes.yaml +++ b/platform/cluster/flux/apps/edge/traefik-ingressroutes.yaml @@ -25,7 +25,7 @@ spec: apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: - name: app-ui + name: agents-api namespace: edge-system annotations: external-dns.alpha.kubernetes.io/target: ingress.jorisjonkers.dev @@ -36,17 +36,20 @@ spec: - websecure routes: - kind: Rule - match: 'Host(`jorisjonkers.dev`)' + match: 'Host(`agents.jorisjonkers.dev`) && PathPrefix(`/api/`) && !PathPrefix(`/api/actuator/health/`) && !Path(`/api/actuator/health`) && !Path(`/api/v1/health`)' + middlewares: + - name: forward-auth + namespace: edge-system services: - - name: app-ui - namespace: app-system - port: 80 + - name: agents-api + namespace: agents-system + port: 8082 tls: {} --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: - name: assistant-api + name: agents-api-health namespace: edge-system annotations: external-dns.alpha.kubernetes.io/target: ingress.jorisjonkers.dev @@ -57,20 +60,17 @@ spec: - websecure routes: - kind: Rule - match: 'Host(`assistant.jorisjonkers.dev`) && PathPrefix(`/api/`) && !PathPrefix(`/api/actuator/health/`) && !Path(`/api/actuator/health`) && !Path(`/api/v1/health`)' - middlewares: - - name: forward-auth - namespace: edge-system + match: 'Host(`agents.jorisjonkers.dev`) && (PathPrefix(`/api/actuator/health/`) || Path(`/api/actuator/health`) || Path(`/api/v1/health`))' services: - - name: assistant-api - namespace: assistant-system + - name: agents-api + namespace: agents-system port: 8082 tls: {} --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: - name: assistant-api-health + name: agents-ui namespace: edge-system annotations: external-dns.alpha.kubernetes.io/target: ingress.jorisjonkers.dev @@ -81,56 +81,56 @@ spec: - websecure routes: - kind: Rule - match: 'Host(`assistant.jorisjonkers.dev`) && (PathPrefix(`/api/actuator/health/`) || Path(`/api/actuator/health`) || Path(`/api/v1/health`))' + match: 'Host(`agents.jorisjonkers.dev`) && !PathPrefix(`/api/`)' services: - - name: assistant-api - namespace: assistant-system - port: 8082 + - name: agents-ui + namespace: agents-system + port: 80 tls: {} --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: - name: assistant-ui + name: agents-ws namespace: edge-system annotations: - external-dns.alpha.kubernetes.io/target: ingress.jorisjonkers.dev - external-dns.alpha.kubernetes.io/cloudflare-proxied: 'true' + external-dns.alpha.kubernetes.io/target: 167.86.79.203 + external-dns.alpha.kubernetes.io/cloudflare-proxied: 'false' kubernetes.io/ingress.class: traefik-public spec: entryPoints: - websecure routes: - kind: Rule - match: 'Host(`assistant.jorisjonkers.dev`) && !PathPrefix(`/api/`)' + match: 'Host(`agents-ws.jorisjonkers.dev`)' + middlewares: + - name: forward-auth + namespace: edge-system services: - - name: assistant-ui - namespace: assistant-system - port: 80 + - name: agents-api-ws + namespace: agents-system + port: 8082 tls: {} --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: - name: assistant-ws + name: app-ui namespace: edge-system annotations: - external-dns.alpha.kubernetes.io/target: 167.86.79.203 - external-dns.alpha.kubernetes.io/cloudflare-proxied: 'false' + external-dns.alpha.kubernetes.io/target: ingress.jorisjonkers.dev + external-dns.alpha.kubernetes.io/cloudflare-proxied: 'true' kubernetes.io/ingress.class: traefik-public spec: entryPoints: - websecure routes: - kind: Rule - match: 'Host(`assistant-ws.jorisjonkers.dev`)' - middlewares: - - name: forward-auth - namespace: edge-system + match: 'Host(`jorisjonkers.dev`)' services: - - name: assistant-api-ws - namespace: assistant-system - port: 8082 + - name: app-ui + namespace: app-system + port: 80 tls: {} --- apiVersion: traefik.io/v1alpha1 diff --git a/platform/cluster/flux/apps/edge/traefik-lan-ingressroutes.yaml b/platform/cluster/flux/apps/edge/traefik-lan-ingressroutes.yaml index 60b7a0a0..c44638e1 100644 --- a/platform/cluster/flux/apps/edge/traefik-lan-ingressroutes.yaml +++ b/platform/cluster/flux/apps/edge/traefik-lan-ingressroutes.yaml @@ -1,7 +1,7 @@ apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: - name: assistant-ws-lan + name: agents-ws-lan namespace: edge-system annotations: kubernetes.io/ingress.class: traefik-lan @@ -10,10 +10,10 @@ spec: - websecure routes: - kind: Rule - match: 'Host(`assistant-ws.jorisjonkers.dev`)' + match: 'Host(`agents-ws.jorisjonkers.dev`)' services: - - name: assistant-api-ws - namespace: assistant-system + - name: agents-api-ws + namespace: agents-system port: 8082 tls: {} --- diff --git a/platform/cluster/flux/apps/grafana-dashboards/dashboards/assistant-api.yaml b/platform/cluster/flux/apps/grafana-dashboards/dashboards/agents-api.yaml similarity index 73% rename from platform/cluster/flux/apps/grafana-dashboards/dashboards/assistant-api.yaml rename to platform/cluster/flux/apps/grafana-dashboards/dashboards/agents-api.yaml index c69f7c29..1368a41e 100644 --- a/platform/cluster/flux/apps/grafana-dashboards/dashboards/assistant-api.yaml +++ b/platform/cluster/flux/apps/grafana-dashboards/dashboards/agents-api.yaml @@ -1,25 +1,25 @@ apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaDashboard metadata: - name: assistant-api + name: agents-api namespace: observability spec: folder: 'Services' resyncPeriod: 24h instanceSelector: { matchLabels: { dashboards: grafana } } - # assistant-api per-feature overview. Previously every query used - # `job="assistant-api"` which never matched anything (the - # ServiceMonitor's scrape job is `assistant-api-actuator`). Switched - # to `application="assistant-api"` which is the Spring Boot common + # agents-api per-feature overview. Previously every query used + # `job="agents-api"` which never matched anything (the + # ServiceMonitor's scrape job is `agents-api-actuator`). Switched + # to `application="agents-api"` which is the Spring Boot common # tag exposed by every Micrometer-Prometheus metric. # # The first panel is a link to the per-endpoint investigation # dashboard for drill-down into individual slow requests. json: | { - "uid": "assistant-api", + "uid": "agents-api", "title": "Assistant API", - "tags": ["assistant", "spring-boot"], + "tags": ["agents", "spring-boot"], "timezone": "browser", "editable": false, "refresh": "30s", @@ -31,53 +31,53 @@ spec: "gridPos": {"h":3,"w":24,"x":0,"y":0}, "options": { "mode": "markdown", - "content": "This dashboard is the per-feature traffic overview. **To drill into one slow request and see its full span tree, logs, metrics, and profile**, open the [Spring Service — Endpoint Investigation](/d/spring-service-endpoints) dashboard, pick `assistant-api` as the service, and click an exemplar dot on the latency heatmap." + "content": "This dashboard is the per-feature traffic overview. **To drill into one slow request and see its full span tree, logs, metrics, and profile**, open the [Spring Service — Endpoint Investigation](/d/spring-service-endpoints) dashboard, pick `agents-api` as the service, and click an exemplar dot on the latency heatmap." } }, { "id": 1, "title": "Request rate by URI", "type": "timeseries", "gridPos": {"h":8,"w":12,"x":0,"y":3}, "datasource": { "type": "prometheus", "uid": "prometheus" }, - "targets": [{ "expr": "sum by(uri)(rate(http_server_requests_seconds_count{application=\"assistant-api\"}[5m]))", "legendFormat": "{{uri}}" }], + "targets": [{ "expr": "sum by(uri)(rate(http_server_requests_seconds_count{application=\"agents-api\"}[5m]))", "legendFormat": "{{uri}}" }], "fieldConfig": { "defaults": { "unit": "reqps" } } }, { "id": 2, "title": "Latency p50 / p95 / p99", "type": "timeseries", "gridPos": {"h":8,"w":12,"x":12,"y":3}, "datasource": { "type": "prometheus", "uid": "prometheus" }, "targets": [ - { "expr": "histogram_quantile(0.50, sum by(le)(rate(http_server_requests_seconds_bucket{application=\"assistant-api\"}[5m])))", "legendFormat": "p50" }, - { "expr": "histogram_quantile(0.95, sum by(le)(rate(http_server_requests_seconds_bucket{application=\"assistant-api\"}[5m])))", "legendFormat": "p95" }, - { "expr": "histogram_quantile(0.99, sum by(le)(rate(http_server_requests_seconds_bucket{application=\"assistant-api\"}[5m])))", "legendFormat": "p99" } + { "expr": "histogram_quantile(0.50, sum by(le)(rate(http_server_requests_seconds_bucket{application=\"agents-api\"}[5m])))", "legendFormat": "p50" }, + { "expr": "histogram_quantile(0.95, sum by(le)(rate(http_server_requests_seconds_bucket{application=\"agents-api\"}[5m])))", "legendFormat": "p95" }, + { "expr": "histogram_quantile(0.99, sum by(le)(rate(http_server_requests_seconds_bucket{application=\"agents-api\"}[5m])))", "legendFormat": "p99" } ], "fieldConfig": { "defaults": { "unit": "s" } } }, { "id": 3, "title": "Error rate (5xx) by URI", "type": "timeseries", "gridPos": {"h":8,"w":12,"x":0,"y":11}, "datasource": { "type": "prometheus", "uid": "prometheus" }, - "targets": [{ "expr": "sum by(uri)(rate(http_server_requests_seconds_count{application=\"assistant-api\", status=~\"5..\"}[5m]))", "legendFormat": "{{uri}}" }], + "targets": [{ "expr": "sum by(uri)(rate(http_server_requests_seconds_count{application=\"agents-api\", status=~\"5..\"}[5m]))", "legendFormat": "{{uri}}" }], "fieldConfig": { "defaults": { "unit": "reqps" } } }, { "id": 4, "title": "Status code mix", "type": "piechart", "gridPos": {"h":8,"w":12,"x":12,"y":11}, "datasource": { "type": "prometheus", "uid": "prometheus" }, - "targets": [{ "expr": "sum by(status)(rate(http_server_requests_seconds_count{application=\"assistant-api\"}[5m]))", "legendFormat": "{{status}}" }], + "targets": [{ "expr": "sum by(status)(rate(http_server_requests_seconds_count{application=\"agents-api\"}[5m]))", "legendFormat": "{{status}}" }], "fieldConfig": { "defaults": { "unit": "reqps" } } }, { "id": 5, "title": "Slowest URIs by p95", "type": "table", "gridPos": {"h":8,"w":24,"x":0,"y":19}, "datasource": { "type": "prometheus", "uid": "prometheus" }, - "targets": [{ "expr": "topk(10, histogram_quantile(0.95, sum by(uri, le)(rate(http_server_requests_seconds_bucket{application=\"assistant-api\"}[5m]))))", "format": "table", "instant": true }], + "targets": [{ "expr": "topk(10, histogram_quantile(0.95, sum by(uri, le)(rate(http_server_requests_seconds_bucket{application=\"agents-api\"}[5m]))))", "format": "table", "instant": true }], "fieldConfig": { "defaults": { "unit": "s" } } }, { "id": 6, "title": "Log volume by level", "type": "timeseries", "gridPos": {"h":8,"w":12,"x":0,"y":27}, "datasource": { "type": "prometheus", "uid": "prometheus" }, - "targets": [{ "expr": "sum by(level)(rate(logback_events_total{application=\"assistant-api\"}[5m]))", "legendFormat": "{{level}}" }], + "targets": [{ "expr": "sum by(level)(rate(logback_events_total{application=\"agents-api\"}[5m]))", "legendFormat": "{{level}}" }], "fieldConfig": { "defaults": { "unit": "ops" } } }, { "id": 7, "title": "Recent errors", "type": "logs", "gridPos": {"h":10,"w":12,"x":12,"y":27}, "datasource": { "type": "loki", "uid": "loki" }, - "targets": [{ "expr": "{service_name=\"assistant-api\"} | json | level=~\"ERROR|error\"" }], + "targets": [{ "expr": "{service_name=\"agents-api\"} | json | level=~\"ERROR|error\"" }], "options": { "showTime": true, "wrapLogMessage": true, "enableLogDetails": true, "sortOrder": "Descending" } } ] diff --git a/platform/cluster/flux/apps/grafana-dashboards/dashboards/infrastructure-overview.yaml b/platform/cluster/flux/apps/grafana-dashboards/dashboards/infrastructure-overview.yaml index a6e40d7a..c187092f 100644 --- a/platform/cluster/flux/apps/grafana-dashboards/dashboards/infrastructure-overview.yaml +++ b/platform/cluster/flux/apps/grafana-dashboards/dashboards/infrastructure-overview.yaml @@ -8,4 +8,4 @@ spec: resyncPeriod: 24h instanceSelector: { matchLabels: { dashboards: grafana } } json: | - {"uid":"infrastructure-overview","title":"Infrastructure Overview","tags":["overview","infrastructure"],"timezone":"browser","editable":false,"refresh":"30s","time":{"from":"now-1h","to":"now"},"templating":{"list":[]},"panels":[{"id":1,"title":"Service Health","type":"stat","gridPos":{"h":4,"w":24,"x":0,"y":0},"targets":[{"expr":"clamp_max(sum by(job)(up{job=~\"auth-api|assistant-api|traefik|grafana|loki|tempo|prometheus|rabbitmq|stalwart\"}), 1)","legendFormat":"{{job}}"}],"options":{"reduceOptions":{"calcs":["lastNotNull"]},"colorMode":"background","graphMode":"none","textMode":"auto"},"fieldConfig":{"defaults":{"mappings":[{"type":"value","options":{"0":{"text":"DOWN","color":"red"},"1":{"text":"UP","color":"green"}}}],"thresholds":{"mode":"absolute","steps":[{"color":"red","value":null},{"color":"green","value":1}]}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":2,"title":"Total Request Rate","type":"timeseries","gridPos":{"h":8,"w":8,"x":0,"y":4},"targets":[{"expr":"sum(rate(http_server_requests_seconds_count{job=~\"auth-api|assistant-api\"}[5m]))","legendFormat":"Spring Boot Services"},{"expr":"sum(rate(traefik_entrypoint_requests_total{entrypoint=\"websecure\"}[5m]))","legendFormat":"Traefik (websecure)"}],"fieldConfig":{"defaults":{"unit":"reqps","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":3,"title":"Error Rate (5xx)","type":"timeseries","gridPos":{"h":8,"w":8,"x":8,"y":4},"targets":[{"expr":"sum by(job)(rate(http_server_requests_seconds_count{job=~\"auth-api|assistant-api\", status=~\"5..\"}[5m]))","legendFormat":"{{job}}"},{"expr":"sum(rate(traefik_service_requests_total{code=~\"5..\"}[5m]))","legendFormat":"Traefik"}],"fieldConfig":{"defaults":{"unit":"reqps","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2},"color":{"mode":"palette-classic"},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":1}]}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":4,"title":"P95 Latency by Service","type":"timeseries","gridPos":{"h":8,"w":8,"x":16,"y":4},"targets":[{"expr":"histogram_quantile(0.95, sum by(job, le)(rate(http_server_requests_seconds_bucket{job=~\"auth-api|assistant-api\"}[5m])))","legendFormat":"{{job}}"}],"fieldConfig":{"defaults":{"unit":"s","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":5,"title":"JVM Heap Used","type":"timeseries","gridPos":{"h":8,"w":8,"x":0,"y":12},"targets":[{"expr":"sum by(job)(jvm_memory_used_bytes{job=~\"auth-api|assistant-api\", area=\"heap\"})","legendFormat":"{{job}} used"},{"expr":"max by(job)(jvm_memory_max_bytes{job=~\"auth-api|assistant-api\", area=\"heap\"})","legendFormat":"{{job}} max"}],"fieldConfig":{"defaults":{"unit":"bytes","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":6,"title":"Process CPU Usage","type":"timeseries","gridPos":{"h":8,"w":8,"x":8,"y":12},"targets":[{"expr":"sum by(job)(process_cpu_usage{job=~\"auth-api|assistant-api\"})","legendFormat":"{{job}} process"},{"expr":"sum by(job)(system_cpu_usage{job=~\"auth-api|assistant-api\"})","legendFormat":"{{job}} system"}],"fieldConfig":{"defaults":{"unit":"percentunit","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2},"min":0,"max":1}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":7,"title":"Error Log Rate by Service","type":"timeseries","gridPos":{"h":8,"w":8,"x":16,"y":12},"targets":[{"datasource":{"type":"loki","uid":"loki"},"expr":"sum by(service_name)(count_over_time({service_name=~\".+\"} | json | __error__=\"\" | level=~\"ERROR|error\" [5m]))","legendFormat":"{{service_name}}"}],"fieldConfig":{"defaults":{"unit":"short","custom":{"drawStyle":"bars","fillOpacity":50,"lineWidth":1,"stacking":{"mode":"normal"}}}}},{"id":8,"title":"Recent Errors","type":"logs","gridPos":{"h":10,"w":24,"x":0,"y":20},"targets":[{"datasource":{"type":"loki","uid":"loki"},"expr":"{service_name=~\".+\"} | json | __error__=\"\" | level=~\"ERROR|error\"","maxLines":50}],"options":{"showTime":true,"showLabels":true,"showCommonLabels":false,"wrapLogMessage":true,"prettifyLogMessage":false,"enableLogDetails":true,"sortOrder":"Descending"}}],"schemaVersion":39} + {"uid":"infrastructure-overview","title":"Infrastructure Overview","tags":["overview","infrastructure"],"timezone":"browser","editable":false,"refresh":"30s","time":{"from":"now-1h","to":"now"},"templating":{"list":[]},"panels":[{"id":1,"title":"Service Health","type":"stat","gridPos":{"h":4,"w":24,"x":0,"y":0},"targets":[{"expr":"clamp_max(sum by(job)(up{job=~\"auth-api|agents-api|traefik|grafana|loki|tempo|prometheus|rabbitmq|stalwart\"}), 1)","legendFormat":"{{job}}"}],"options":{"reduceOptions":{"calcs":["lastNotNull"]},"colorMode":"background","graphMode":"none","textMode":"auto"},"fieldConfig":{"defaults":{"mappings":[{"type":"value","options":{"0":{"text":"DOWN","color":"red"},"1":{"text":"UP","color":"green"}}}],"thresholds":{"mode":"absolute","steps":[{"color":"red","value":null},{"color":"green","value":1}]}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":2,"title":"Total Request Rate","type":"timeseries","gridPos":{"h":8,"w":8,"x":0,"y":4},"targets":[{"expr":"sum(rate(http_server_requests_seconds_count{job=~\"auth-api|agents-api\"}[5m]))","legendFormat":"Spring Boot Services"},{"expr":"sum(rate(traefik_entrypoint_requests_total{entrypoint=\"websecure\"}[5m]))","legendFormat":"Traefik (websecure)"}],"fieldConfig":{"defaults":{"unit":"reqps","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":3,"title":"Error Rate (5xx)","type":"timeseries","gridPos":{"h":8,"w":8,"x":8,"y":4},"targets":[{"expr":"sum by(job)(rate(http_server_requests_seconds_count{job=~\"auth-api|agents-api\", status=~\"5..\"}[5m]))","legendFormat":"{{job}}"},{"expr":"sum(rate(traefik_service_requests_total{code=~\"5..\"}[5m]))","legendFormat":"Traefik"}],"fieldConfig":{"defaults":{"unit":"reqps","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2},"color":{"mode":"palette-classic"},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":1}]}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":4,"title":"P95 Latency by Service","type":"timeseries","gridPos":{"h":8,"w":8,"x":16,"y":4},"targets":[{"expr":"histogram_quantile(0.95, sum by(job, le)(rate(http_server_requests_seconds_bucket{job=~\"auth-api|agents-api\"}[5m])))","legendFormat":"{{job}}"}],"fieldConfig":{"defaults":{"unit":"s","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":5,"title":"JVM Heap Used","type":"timeseries","gridPos":{"h":8,"w":8,"x":0,"y":12},"targets":[{"expr":"sum by(job)(jvm_memory_used_bytes{job=~\"auth-api|agents-api\", area=\"heap\"})","legendFormat":"{{job}} used"},{"expr":"max by(job)(jvm_memory_max_bytes{job=~\"auth-api|agents-api\", area=\"heap\"})","legendFormat":"{{job}} max"}],"fieldConfig":{"defaults":{"unit":"bytes","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2}}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":6,"title":"Process CPU Usage","type":"timeseries","gridPos":{"h":8,"w":8,"x":8,"y":12},"targets":[{"expr":"sum by(job)(process_cpu_usage{job=~\"auth-api|agents-api\"})","legendFormat":"{{job}} process"},{"expr":"sum by(job)(system_cpu_usage{job=~\"auth-api|agents-api\"})","legendFormat":"{{job}} system"}],"fieldConfig":{"defaults":{"unit":"percentunit","custom":{"drawStyle":"line","fillOpacity":10,"lineWidth":2},"min":0,"max":1}},"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":7,"title":"Error Log Rate by Service","type":"timeseries","gridPos":{"h":8,"w":8,"x":16,"y":12},"targets":[{"datasource":{"type":"loki","uid":"loki"},"expr":"sum by(service_name)(count_over_time({service_name=~\".+\"} | json | __error__=\"\" | level=~\"ERROR|error\" [5m]))","legendFormat":"{{service_name}}"}],"fieldConfig":{"defaults":{"unit":"short","custom":{"drawStyle":"bars","fillOpacity":50,"lineWidth":1,"stacking":{"mode":"normal"}}}}},{"id":8,"title":"Recent Errors","type":"logs","gridPos":{"h":10,"w":24,"x":0,"y":20},"targets":[{"datasource":{"type":"loki","uid":"loki"},"expr":"{service_name=~\".+\"} | json | __error__=\"\" | level=~\"ERROR|error\"","maxLines":50}],"options":{"showTime":true,"showLabels":true,"showCommonLabels":false,"wrapLogMessage":true,"prettifyLogMessage":false,"enableLogDetails":true,"sortOrder":"Descending"}}],"schemaVersion":39} diff --git a/platform/cluster/flux/apps/grafana-dashboards/dashboards/kustomization.yaml b/platform/cluster/flux/apps/grafana-dashboards/dashboards/kustomization.yaml index 9ae08532..39ad9c9f 100644 --- a/platform/cluster/flux/apps/grafana-dashboards/dashboards/kustomization.yaml +++ b/platform/cluster/flux/apps/grafana-dashboards/dashboards/kustomization.yaml @@ -2,7 +2,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - assistant-api.yaml + - agents-api.yaml - auth-api.yaml - dcgm.yaml - error-feed.yaml diff --git a/platform/cluster/flux/apps/grafana-dashboards/dashboards/spring-service-endpoints.yaml b/platform/cluster/flux/apps/grafana-dashboards/dashboards/spring-service-endpoints.yaml index 008ea79c..6a4d2263 100644 --- a/platform/cluster/flux/apps/grafana-dashboards/dashboards/spring-service-endpoints.yaml +++ b/platform/cluster/flux/apps/grafana-dashboards/dashboards/spring-service-endpoints.yaml @@ -13,7 +13,7 @@ spec: # metrics<->profiles wiring (see grafana-datasources.yaml). # # Variables: - # $service one of: auth-api, assistant-api + # $service one of: auth-api, agents-api # $route auto-populated from http_server_requests_seconds_count # $pod auto-populated, multi-select # @@ -48,11 +48,11 @@ spec: "name": "service", "label": "Service", "type": "custom", - "query": "auth-api,assistant-api", + "query": "auth-api,agents-api", "current": { "text": "auth-api", "value": "auth-api" }, "options": [ { "text": "auth-api", "value": "auth-api", "selected": true }, - { "text": "assistant-api", "value": "assistant-api", "selected": false } + { "text": "agents-api", "value": "agents-api", "selected": false } ] }, { diff --git a/platform/cluster/flux/apps/knowledge/knowledge-api/deployment.yaml b/platform/cluster/flux/apps/knowledge/knowledge-api/deployment.yaml index 4132c350..65daf945 100644 --- a/platform/cluster/flux/apps/knowledge/knowledge-api/deployment.yaml +++ b/platform/cluster/flux/apps/knowledge/knowledge-api/deployment.yaml @@ -43,7 +43,7 @@ spec: # Vault Agent inject: knowledge-api role (created in # bootstrap-auth.sh) → policy `knowledge-api` → read on # secret/data/platform/postgres + secret/data/knowledge-api. - # Same shape as auth-api / assistant-api use. + # Same shape as auth-api / agents-api use. vault.hashicorp.com/agent-inject: 'true' vault.hashicorp.com/agent-inject-token: 'true' vault.hashicorp.com/agent-pre-populate-only: 'true' diff --git a/platform/cluster/flux/apps/observability-rules/platform-alerts.yaml b/platform/cluster/flux/apps/observability-rules/platform-alerts.yaml index c700e7c3..71897ee0 100644 --- a/platform/cluster/flux/apps/observability-rules/platform-alerts.yaml +++ b/platform/cluster/flux/apps/observability-rules/platform-alerts.yaml @@ -225,7 +225,7 @@ spec: histogram_quantile(0.95, sum by (le, service_name, http_route) ( rate(traces_spanmetrics_latency_bucket{ - span_name="pipeline.controller", service_name=~"auth-api|assistant-api" + span_name="pipeline.controller", service_name=~"auth-api|agents-api" }[5m]) ) ) > 2 @@ -245,7 +245,7 @@ spec: histogram_quantile(0.95, sum by (le, service_name, http_route) ( rate(traces_spanmetrics_latency_bucket{ - span_name="pipeline.response-finalize", service_name=~"auth-api|assistant-api" + span_name="pipeline.response-finalize", service_name=~"auth-api|agents-api" }[5m]) ) ) > 1 @@ -291,7 +291,7 @@ spec: - name: platform-jvm-hikari rules: - alert: HikariConnectionsPending - expr: hikaricp_connections_pending{application=~"auth-api|assistant-api"} > 0 + expr: hikaricp_connections_pending{application=~"auth-api|agents-api"} > 0 for: 5m labels: { severity: warning } annotations: @@ -302,7 +302,7 @@ spec: expr: | histogram_quantile(0.95, sum by (le, application, instance) ( - rate(hikaricp_connections_acquire_seconds_bucket{application=~"auth-api|assistant-api"}[5m]) + rate(hikaricp_connections_acquire_seconds_bucket{application=~"auth-api|agents-api"}[5m]) ) ) > 1 for: 10m @@ -321,7 +321,7 @@ spec: expr: | histogram_quantile(0.95, sum by (le, application, instance, cause) ( - rate(jvm_gc_pause_seconds_bucket{application=~"auth-api|assistant-api"}[5m]) + rate(jvm_gc_pause_seconds_bucket{application=~"auth-api|agents-api"}[5m]) ) ) > 0.5 for: 5m diff --git a/platform/cluster/flux/apps/observability/alloy/release.yaml b/platform/cluster/flux/apps/observability/alloy/release.yaml index c8e2d738..34c1dfaa 100644 --- a/platform/cluster/flux/apps/observability/alloy/release.yaml +++ b/platform/cluster/flux/apps/observability/alloy/release.yaml @@ -91,7 +91,7 @@ spec: } } - // Spring services (auth-api, assistant-api) emit JSON via the + // Spring services (auth-api, agents-api) emit JSON via the // Logstash Logback encoder: each line is {"@timestamp":"…", // "level":"INFO","message":"…","traceId":"…","spanId":"…",…}. // Surface `level` as a Loki label so {level=~"ERROR|WARN"} queries @@ -148,7 +148,7 @@ spec: allowed_origins = [ "https://jorisjonkers.dev", "https://auth.jorisjonkers.dev", - "https://assistant.jorisjonkers.dev", + "https://agents.jorisjonkers.dev", ] allowed_headers = ["*"] } @@ -172,7 +172,7 @@ spec: cors_allowed_origins = [ "https://jorisjonkers.dev", "https://auth.jorisjonkers.dev", - "https://assistant.jorisjonkers.dev", + "https://agents.jorisjonkers.dev", ] } @@ -234,7 +234,7 @@ spec: // SERVER span without http.route — Traefik. Include the // Host so the trace list disambiguates vhosts that share // paths (auth.jorisjonkers.dev/api/x vs - // assistant.jorisjonkers.dev/api/x). + // agents.jorisjonkers.dev/api/x). `set(name, Concat([attributes["http.request.method"], resource.attributes["service.name"], Concat([attributes["server.address"], attributes["url.path"]], "")], " ")) where kind == SPAN_KIND_SERVER and IsString(attributes["http.request.method"]) and IsString(resource.attributes["service.name"]) and IsString(attributes["server.address"]) and IsString(attributes["url.path"]) and not IsString(attributes["http.route"])`, // SERVER span fallback when server.address is missing too `set(name, Concat([attributes["http.request.method"], resource.attributes["service.name"], attributes["url.path"]], " ")) where kind == SPAN_KIND_SERVER and IsString(attributes["http.request.method"]) and IsString(resource.attributes["service.name"]) and IsString(attributes["url.path"]) and not IsString(attributes["http.route"]) and not IsString(attributes["server.address"])`, diff --git a/platform/cluster/flux/apps/observability/gatus/gatus-endpoints-configmap.yaml b/platform/cluster/flux/apps/observability/gatus/gatus-endpoints-configmap.yaml index 5dd5c746..a1c0b0ce 100644 --- a/platform/cluster/flux/apps/observability/gatus/gatus-endpoints-configmap.yaml +++ b/platform/cluster/flux/apps/observability/gatus/gatus-endpoints-configmap.yaml @@ -161,30 +161,30 @@ data: conditions: - "[STATUS] == 200" - "[RESPONSE_TIME] < 1500" - - name: "app-ui" + - name: "agents-api" group: "public-apps" - url: "https://jorisjonkers.dev/" + url: "http://agents-api.agents-system.svc.cluster.local:8082/api/actuator/health/readiness" interval: "60s" conditions: - "[STATUS] == 200" - "[RESPONSE_TIME] < 1500" - - name: "assistant-api" + - name: "agents-ui" group: "public-apps" - url: "http://assistant-api.assistant-system.svc.cluster.local:8082/api/actuator/health/readiness" + url: "https://agents.jorisjonkers.dev/" interval: "60s" conditions: - "[STATUS] == 200" - "[RESPONSE_TIME] < 1500" - - name: "assistant-ui" + - name: "agents-ws" group: "public-apps" - url: "https://assistant.jorisjonkers.dev/" + url: "http://agents-api-ws.agents-system.svc.cluster.local:8082/api/actuator/health/readiness" interval: "60s" conditions: - "[STATUS] == 200" - "[RESPONSE_TIME] < 1500" - - name: "assistant-ws" + - name: "app-ui" group: "public-apps" - url: "http://assistant-api-ws.assistant-system.svc.cluster.local:8082/api/actuator/health/readiness" + url: "https://jorisjonkers.dev/" interval: "60s" conditions: - "[STATUS] == 200" diff --git a/platform/cluster/flux/apps/stateless/assistant-api/deployment-enschede-ws.yaml b/platform/cluster/flux/apps/stateless/agents-api/deployment-enschede-ws.yaml similarity index 55% rename from platform/cluster/flux/apps/stateless/assistant-api/deployment-enschede-ws.yaml rename to platform/cluster/flux/apps/stateless/agents-api/deployment-enschede-ws.yaml index 04e424ef..94f1c423 100644 --- a/platform/cluster/flux/apps/stateless/assistant-api/deployment-enschede-ws.yaml +++ b/platform/cluster/flux/apps/stateless/agents-api/deployment-enschede-ws.yaml @@ -1,8 +1,8 @@ -# Enschede-pinned assistant-api replica that terminates the agent +# Enschede-pinned agents-api replica that terminates the agent # terminal WebSocket close to the runner. The browser-to-runner attach # WS otherwise detours browser(Enschede) -> edge/api(Frankfurt) -> # runner(Enschede), adding a cross-site round trip to every keystroke. -# A *-lan ingress on assistant-ws.jorisjonkers.dev routes that WS to +# A *-lan ingress on agents-ws.jorisjonkers.dev routes that WS to # this replica (split-DNS sends on-site clients straight here), so the # per-keystroke hop stays Enschede-local; the REST API + its Postgres # stay in Frankfurt. Same image/env/ServiceAccount/Vault role as the @@ -11,24 +11,14 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: assistant-api-ws - namespace: assistant-system + name: agents-api-ws + namespace: agents-system annotations: keel.sh/policy: force keel.sh/match-tag: 'true' keel.sh/trigger: poll keel.sh/pollSchedule: '@every 2m' spec: - # Single replica, zero-downtime rollout (mirrors auth-api): - # maxSurge=1 + maxUnavailable=0. On a new image the surge pod is - # created and must reach Ready before the old pod is torn down, so a - # cutover never leaves zero pods serving — critical here because a - # cold start is slow (~5 min: Spring + Vault init on the tight - # Frankfurt VPS). The previous maxSurge=0/maxUnavailable=1 with a - # single replica killed the only pod first and left the service down - # for the whole startup window. Briefly runs 2 pods (~800m CPU - # requested) during the roll; progressDeadlineSeconds covers the - # slow start. replicas: 1 progressDeadlineSeconds: 1800 strategy: @@ -38,35 +28,26 @@ spec: maxUnavailable: 0 selector: matchLabels: - app.kubernetes.io/name: assistant-api-ws + app.kubernetes.io/name: agents-api-ws template: metadata: labels: - app.kubernetes.io/name: assistant-api-ws + app.kubernetes.io/name: agents-api-ws annotations: vault.hashicorp.com/agent-inject: 'true' vault.hashicorp.com/agent-inject-token: 'true' vault.hashicorp.com/agent-pre-populate-only: 'true' - vault.hashicorp.com/role: assistant-api - vault.hashicorp.com/agent-inject-secret-assistant-api.env: rabbitmq/creds/app-consumer - vault.hashicorp.com/agent-inject-template-assistant-api.env: | - # Datasource credential. The Spring Cloud Vault database backend - # (dynamic creds via database/creds/assistant-api) is preferred - # and overrides these when it engages, but it is not binding the - # datasource in the current image, so without an explicit - # credential the connection falls back to the application.yml - # defaults (assistant_user/assistant_password) and Flyway fails - # with "password authentication failed for user assistant_user". - # Supply the real assistant_user credential from the single - # source of truth (secret/platform/postgres). See auth-api. - DB_USER={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "assistant.user" }}{{ end }} - DB_PASSWORD={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "assistant.password" }}{{ end }} + vault.hashicorp.com/role: agents-api + vault.hashicorp.com/agent-inject-secret-agents-api.env: rabbitmq/creds/app-consumer + vault.hashicorp.com/agent-inject-template-agents-api.env: | + DB_USER={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "agents.user" }}{{ end }} + DB_PASSWORD={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "agents.password" }}{{ end }} {{ with secret "rabbitmq/creds/app-consumer" }} SPRING_RABBITMQ_USERNAME={{ .Data.username }} SPRING_RABBITMQ_PASSWORD={{ .Data.password }} {{ end }} spec: - serviceAccountName: assistant-api + serviceAccountName: agents-api priorityClassName: platform-critical-app nodeSelector: # Pin to the always-on t1000 server. The other amd64 Enschede @@ -89,8 +70,8 @@ spec: - name: pyroscope-agent mountPath: /agent containers: - - name: assistant-api - image: ghcr.io/extratoast/personal-stack/assistant-api:latest + - name: agents-api + image: ghcr.io/extratoast/agents/agents-api:latest imagePullPolicy: Always command: - /bin/sh @@ -98,14 +79,8 @@ spec: - | export SPRING_CLOUD_VAULT_TOKEN="$(cat /vault/secrets/token)" set -a - . /vault/secrets/assistant-api.env + . /vault/secrets/agents-api.env set +a - # See auth-api deployment.yaml for the rationale behind these - # JVM flags. MaxRAMPercentage=55 (down from 75) leaves native - # headroom under the 2304 MiB limit for the two javaagents + - # tracing aspect, avoiding the OOMKills the 75 % heap caused - # during cold start. ZGC for sub-ms pauses. AppCDS args were - # dropped with the BellSoft Liberica CRaC image switch. exec java \ -XX:+UseZGC \ -XX:MaxRAMPercentage=55 \ @@ -139,12 +114,8 @@ spec: value: '5672' - name: DEPLOYMENT_ENVIRONMENT value: production - # SERVICE_VERSION is baked into the image by the build pipeline. - # See auth-api deployment.yaml for the rationale. - name: OTEL_SERVICE_NAME - value: assistant-api - # entrypoint.sh composes OTEL_RESOURCE_ATTRIBUTES from - # SERVICE_VERSION + DEPLOYMENT_ENVIRONMENT at container start. + value: agents-api - name: OTEL_EXPORTER_OTLP_ENDPOINT value: http://alloy.observability.svc.cluster.local:4318 - name: OTEL_EXPORTER_OTLP_PROTOCOL @@ -155,20 +126,16 @@ spec: value: none - name: OTEL_TRACES_EXPORTER value: otlp - # See auth-api deployment.yaml for the rationale behind - # these OTel tunings (agent-default sampling, 5s exporter - # timeout + batch schedule, bounded queue). - name: OTEL_EXPORTER_OTLP_TIMEOUT value: '5000' - name: OTEL_BSP_SCHEDULE_DELAY value: '5000' - # See auth-api deployment.yaml — same rationale. - name: OTEL_BSP_MAX_QUEUE_SIZE value: '16384' - name: OTEL_BSP_MAX_EXPORT_BATCH_SIZE value: '2048' - name: PYROSCOPE_APPLICATION_NAME - value: assistant-api + value: agents-api - name: PYROSCOPE_SERVER_ADDRESS value: http://pyroscope.observability.svc.cluster.local:4040 - name: PYROSCOPE_FORMAT @@ -180,40 +147,21 @@ spec: - name: PYROSCOPE_PROFILER_LOCK value: 10ms - name: PYROSCOPE_LABELS - value: service.name=assistant-api,deployment.environment=production - # Agent runtime — per-workspace runner Pod orchestration. - # All values live in application.yml's defaults; overrides - # here cover environment specifics (site selector, image - # tag pinning if needed). + value: service.name=agents-api,deployment.environment=production - name: AGENT_RUNTIME_NAMESPACE value: agents-system - name: AGENT_RUNTIME_IMAGE - value: ghcr.io/extratoast/personal-stack/agent-runner:latest - # Runner Pods must co-locate with the credential PVCs. + value: ghcr.io/extratoast/agents/agent-runner:latest - name: AGENT_RUNTIME_NODE value: enschede-gtx-960m-1 - # Must match users.groups.docker.gid on the selected Nix host. - name: AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS value: '131' - # Read-only GitHub token for the deploy-key branch-protection - # check. Sourced from the github-api-token Secret (key - # `token`), which a VaultStaticSecret projects from Vault — - # both are provisioned separately (ops). `optional: true` - # keeps the pod starting when the Secret is absent; the - # branch-protection check then degrades to null. - name: GITHUB_API_TOKEN valueFrom: secretKeyRef: name: github-api-token key: token optional: true - # GitHub App used to mint short-lived, repo-scoped installation - # tokens for runner GitHub writes (git push, PR creation, - # PR comments, Actions re-runs). - # Sourced from the github-app Secret a VaultStaticSecret - # projects from `secret/agents/github-app`. All `optional: true` - # so the pod starts when the App is not yet provisioned; minting - # then stays disabled and the internal endpoint returns 503. - name: GITHUB_APP_ID valueFrom: secretKeyRef: @@ -240,13 +188,6 @@ spec: path: /api/actuator/health/liveness port: http periodSeconds: 5 - # 600 s (120 × 5 s) window matches auth-api's PR #250 — the 300 s - # budget had no margin for cold-start variance and got crossed - # the moment a rebuild forced the second replica to roll, sending - # the pod into an exit-137 / restart loop. Cold start under - # AppCDS at 1000m typically lands around 150–200 s, so 600 s is - # ample headroom without altering the steady-state liveness - # budget below. failureThreshold: 120 readinessProbe: httpGet: @@ -259,21 +200,11 @@ spec: port: http timeoutSeconds: 5 resources: - # CPU request sized to measured steady state (~50m) + headroom, - # not the cold-start peak — see auth-api for the full rationale. - # The 400m request frees the reservation that kept Frankfurt - # "full"; the 2000m limit lets class loading burst on the idle - # node for a fast cold start. requests: cpu: 400m memory: 1792Mi limits: cpu: 2000m - # 3Gi (was 2304Mi) for the same cold-start peak headroom as - # auth-api: the ~1.27 GiB heap (MaxRAMPercentage=55) plus the - # OTel + Pyroscope javaagents' native footprint plus JIT and - # metaspace peaked over the old limit and OOMKilled the pod. - # Request stays 1792Mi — no added scheduling reservation. memory: 3Gi volumeMounts: - name: pyroscope-agent @@ -286,11 +217,11 @@ spec: apiVersion: v1 kind: Service metadata: - name: assistant-api-ws - namespace: assistant-system + name: agents-api-ws + namespace: agents-system spec: selector: - app.kubernetes.io/name: assistant-api-ws + app.kubernetes.io/name: agents-api-ws ports: - name: http port: 8082 diff --git a/platform/cluster/flux/apps/stateless/assistant-api/deployment.yaml b/platform/cluster/flux/apps/stateless/agents-api/deployment.yaml similarity index 65% rename from platform/cluster/flux/apps/stateless/assistant-api/deployment.yaml rename to platform/cluster/flux/apps/stateless/agents-api/deployment.yaml index c3685f05..998c3d0d 100644 --- a/platform/cluster/flux/apps/stateless/assistant-api/deployment.yaml +++ b/platform/cluster/flux/apps/stateless/agents-api/deployment.yaml @@ -1,14 +1,14 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: assistant-api - namespace: assistant-system + name: agents-api + namespace: agents-system --- apiVersion: apps/v1 kind: Deployment metadata: - name: assistant-api - namespace: assistant-system + name: agents-api + namespace: agents-system annotations: keel.sh/policy: force keel.sh/match-tag: 'true' @@ -34,35 +34,33 @@ spec: maxUnavailable: 0 selector: matchLabels: - app.kubernetes.io/name: assistant-api + app.kubernetes.io/name: agents-api template: metadata: labels: - app.kubernetes.io/name: assistant-api + app.kubernetes.io/name: agents-api annotations: vault.hashicorp.com/agent-inject: 'true' vault.hashicorp.com/agent-inject-token: 'true' vault.hashicorp.com/agent-pre-populate-only: 'true' - vault.hashicorp.com/role: assistant-api - vault.hashicorp.com/agent-inject-secret-assistant-api.env: rabbitmq/creds/app-consumer - vault.hashicorp.com/agent-inject-template-assistant-api.env: | + vault.hashicorp.com/role: agents-api + vault.hashicorp.com/agent-inject-secret-agents-api.env: rabbitmq/creds/app-consumer + vault.hashicorp.com/agent-inject-template-agents-api.env: | # Datasource credential. The Spring Cloud Vault database backend - # (dynamic creds via database/creds/assistant-api) is preferred + # (dynamic creds via database/creds/agents-api) is preferred # and overrides these when it engages, but it is not binding the # datasource in the current image, so without an explicit # credential the connection falls back to the application.yml - # defaults (assistant_user/assistant_password) and Flyway fails - # with "password authentication failed for user assistant_user". - # Supply the real assistant_user credential from the single - # source of truth (secret/platform/postgres). See auth-api. - DB_USER={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "assistant.user" }}{{ end }} - DB_PASSWORD={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "assistant.password" }}{{ end }} + # defaults and Flyway fails. Supply the real credential from the + # single source of truth (secret/platform/postgres). See auth-api. + DB_USER={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "agents.user" }}{{ end }} + DB_PASSWORD={{ with secret "secret/data/platform/postgres" }}{{ index .Data.data "agents.password" }}{{ end }} {{ with secret "rabbitmq/creds/app-consumer" }} SPRING_RABBITMQ_USERNAME={{ .Data.username }} SPRING_RABBITMQ_PASSWORD={{ .Data.password }} {{ end }} spec: - serviceAccountName: assistant-api + serviceAccountName: agents-api priorityClassName: platform-critical-app nodeSelector: personal-stack/site: frankfurt @@ -79,8 +77,8 @@ spec: - name: pyroscope-agent mountPath: /agent containers: - - name: assistant-api - image: ghcr.io/extratoast/personal-stack/assistant-api:latest + - name: agents-api + image: ghcr.io/extratoast/agents/agents-api:latest imagePullPolicy: Always command: - /bin/sh @@ -88,7 +86,7 @@ spec: - | export SPRING_CLOUD_VAULT_TOKEN="$(cat /vault/secrets/token)" set -a - . /vault/secrets/assistant-api.env + . /vault/secrets/agents-api.env set +a # See auth-api deployment.yaml for the rationale behind these # JVM flags. MaxRAMPercentage=55 (down from 75) leaves native @@ -129,12 +127,8 @@ spec: value: '5672' - name: DEPLOYMENT_ENVIRONMENT value: production - # SERVICE_VERSION is baked into the image by the build pipeline. - # See auth-api deployment.yaml for the rationale. - name: OTEL_SERVICE_NAME - value: assistant-api - # entrypoint.sh composes OTEL_RESOURCE_ATTRIBUTES from - # SERVICE_VERSION + DEPLOYMENT_ENVIRONMENT at container start. + value: agents-api - name: OTEL_EXPORTER_OTLP_ENDPOINT value: http://alloy.observability.svc.cluster.local:4318 - name: OTEL_EXPORTER_OTLP_PROTOCOL @@ -145,20 +139,16 @@ spec: value: none - name: OTEL_TRACES_EXPORTER value: otlp - # See auth-api deployment.yaml for the rationale behind - # these OTel tunings (agent-default sampling, 5s exporter - # timeout + batch schedule, bounded queue). - name: OTEL_EXPORTER_OTLP_TIMEOUT value: '5000' - name: OTEL_BSP_SCHEDULE_DELAY value: '5000' - # See auth-api deployment.yaml — same rationale. - name: OTEL_BSP_MAX_QUEUE_SIZE value: '16384' - name: OTEL_BSP_MAX_EXPORT_BATCH_SIZE value: '2048' - name: PYROSCOPE_APPLICATION_NAME - value: assistant-api + value: agents-api - name: PYROSCOPE_SERVER_ADDRESS value: http://pyroscope.observability.svc.cluster.local:4040 - name: PYROSCOPE_FORMAT @@ -170,40 +160,24 @@ spec: - name: PYROSCOPE_PROFILER_LOCK value: 10ms - name: PYROSCOPE_LABELS - value: service.name=assistant-api,deployment.environment=production + value: service.name=agents-api,deployment.environment=production # Agent runtime — per-workspace runner Pod orchestration. - # All values live in application.yml's defaults; overrides - # here cover environment specifics (site selector, image - # tag pinning if needed). - name: AGENT_RUNTIME_NAMESPACE value: agents-system - name: AGENT_RUNTIME_IMAGE - value: ghcr.io/extratoast/personal-stack/agent-runner:latest + value: ghcr.io/extratoast/agents/agent-runner:latest # Runner Pods must co-locate with the credential PVCs. - name: AGENT_RUNTIME_NODE value: enschede-gtx-960m-1 # Must match users.groups.docker.gid on the selected Nix host. - name: AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS value: '131' - # Read-only GitHub token for the deploy-key branch-protection - # check. Sourced from the github-api-token Secret (key - # `token`), which a VaultStaticSecret projects from Vault — - # both are provisioned separately (ops). `optional: true` - # keeps the pod starting when the Secret is absent; the - # branch-protection check then degrades to null. - name: GITHUB_API_TOKEN valueFrom: secretKeyRef: name: github-api-token key: token optional: true - # GitHub App used to mint short-lived, repo-scoped installation - # tokens for runner GitHub writes (git push, PR creation, - # PR comments, Actions re-runs). - # Sourced from the github-app Secret a VaultStaticSecret - # projects from `secret/agents/github-app`. All `optional: true` - # so the pod starts when the App is not yet provisioned; minting - # then stays disabled and the internal endpoint returns 503. - name: GITHUB_APP_ID valueFrom: secretKeyRef: @@ -230,13 +204,6 @@ spec: path: /api/actuator/health/liveness port: http periodSeconds: 5 - # 600 s (120 × 5 s) window matches auth-api's PR #250 — the 300 s - # budget had no margin for cold-start variance and got crossed - # the moment a rebuild forced the second replica to roll, sending - # the pod into an exit-137 / restart loop. Cold start under - # AppCDS at 1000m typically lands around 150–200 s, so 600 s is - # ample headroom without altering the steady-state liveness - # budget below. failureThreshold: 120 readinessProbe: httpGet: @@ -249,21 +216,11 @@ spec: port: http timeoutSeconds: 5 resources: - # CPU request sized to measured steady state (~50m) + headroom, - # not the cold-start peak — see auth-api for the full rationale. - # The 400m request frees the reservation that kept Frankfurt - # "full"; the 2000m limit lets class loading burst on the idle - # node for a fast cold start. requests: cpu: 400m memory: 1792Mi limits: cpu: 2000m - # 3Gi (was 2304Mi) for the same cold-start peak headroom as - # auth-api: the ~1.27 GiB heap (MaxRAMPercentage=55) plus the - # OTel + Pyroscope javaagents' native footprint plus JIT and - # metaspace peaked over the old limit and OOMKilled the pod. - # Request stays 1792Mi — no added scheduling reservation. memory: 3Gi volumeMounts: - name: pyroscope-agent @@ -276,11 +233,11 @@ spec: apiVersion: v1 kind: Service metadata: - name: assistant-api - namespace: assistant-system + name: agents-api + namespace: agents-system spec: selector: - app.kubernetes.io/name: assistant-api + app.kubernetes.io/name: agents-api ports: - name: http port: 8082 diff --git a/platform/cluster/flux/apps/stateless/assistant-api/kustomization.yaml b/platform/cluster/flux/apps/stateless/agents-api/kustomization.yaml similarity index 73% rename from platform/cluster/flux/apps/stateless/assistant-api/kustomization.yaml rename to platform/cluster/flux/apps/stateless/agents-api/kustomization.yaml index 79033cfa..d30b607a 100644 --- a/platform/cluster/flux/apps/stateless/assistant-api/kustomization.yaml +++ b/platform/cluster/flux/apps/stateless/agents-api/kustomization.yaml @@ -2,9 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - namespace.yaml - deployment.yaml - deployment-enschede-ws.yaml - servicemonitor.yaml - pdb.yaml - - vault-secrets-operator-sa.yaml - - github-app-vss.yaml diff --git a/platform/cluster/flux/apps/stateless/agents-api/namespace.yaml b/platform/cluster/flux/apps/stateless/agents-api/namespace.yaml new file mode 100644 index 00000000..3311f82a --- /dev/null +++ b/platform/cluster/flux/apps/stateless/agents-api/namespace.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: agents-system + labels: + pod-security.kubernetes.io/enforce: privileged + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/warn: restricted diff --git a/platform/cluster/flux/apps/stateless/assistant-ui/pdb.yaml b/platform/cluster/flux/apps/stateless/agents-api/pdb.yaml similarity index 72% rename from platform/cluster/flux/apps/stateless/assistant-ui/pdb.yaml rename to platform/cluster/flux/apps/stateless/agents-api/pdb.yaml index 39d3d476..f0b13f23 100644 --- a/platform/cluster/flux/apps/stateless/assistant-ui/pdb.yaml +++ b/platform/cluster/flux/apps/stateless/agents-api/pdb.yaml @@ -1,16 +1,16 @@ # Block voluntary disruptions (node drain, voluntary eviction) from # taking the last Ready replica offline. With minAvailable=1 the # eviction API refuses to evict if doing so would leave 0 pods. -# Required because assistant-ui sits behind the public ingress (and, for +# Required because agents-api sits behind the public ingress (and, for # auth-api, behind every other service's forward-auth) — even a # 30 s gap during ad-hoc node maintenance would lock users out. apiVersion: policy/v1 kind: PodDisruptionBudget metadata: - name: assistant-ui - namespace: assistant-system + name: agents-api + namespace: agents-system spec: minAvailable: 1 selector: matchLabels: - app.kubernetes.io/name: assistant-ui + app.kubernetes.io/name: agents-api diff --git a/platform/cluster/flux/apps/stateless/assistant-api/servicemonitor.yaml b/platform/cluster/flux/apps/stateless/agents-api/servicemonitor.yaml similarity index 83% rename from platform/cluster/flux/apps/stateless/assistant-api/servicemonitor.yaml rename to platform/cluster/flux/apps/stateless/agents-api/servicemonitor.yaml index 83553fc0..0d0b04ad 100644 --- a/platform/cluster/flux/apps/stateless/assistant-api/servicemonitor.yaml +++ b/platform/cluster/flux/apps/stateless/agents-api/servicemonitor.yaml @@ -1,8 +1,8 @@ apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: - name: assistant-api - namespace: assistant-system + name: agents-api + namespace: agents-system labels: release: metrics-stack spec: @@ -13,7 +13,7 @@ spec: jobLabel: app.kubernetes.io/name selector: matchLabels: - app.kubernetes.io/name: assistant-api + app.kubernetes.io/name: agents-api endpoints: - port: http path: /api/actuator/prometheus diff --git a/platform/cluster/flux/apps/stateless/assistant-ui/deployment.yaml b/platform/cluster/flux/apps/stateless/agents-ui/deployment.yaml similarity index 81% rename from platform/cluster/flux/apps/stateless/assistant-ui/deployment.yaml rename to platform/cluster/flux/apps/stateless/agents-ui/deployment.yaml index b410bb78..c41868d0 100644 --- a/platform/cluster/flux/apps/stateless/assistant-ui/deployment.yaml +++ b/platform/cluster/flux/apps/stateless/agents-ui/deployment.yaml @@ -1,8 +1,8 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: assistant-ui - namespace: assistant-system + name: agents-ui + namespace: agents-system annotations: keel.sh/policy: force keel.sh/match-tag: 'true' @@ -25,19 +25,18 @@ spec: maxUnavailable: 0 selector: matchLabels: - app.kubernetes.io/name: assistant-ui + app.kubernetes.io/name: agents-ui template: metadata: labels: - app.kubernetes.io/name: assistant-ui + app.kubernetes.io/name: agents-ui spec: # Pinned to enschede arm64 nodes (the Pis) — frees ~150m CPU per # replica on Frankfurt, which is at 90% requested. UI is nginx- # served static SPA so cross-site latency from Cloudflare → # Frankfurt Traefik → Pi adds ~30 ms first byte but doesn't # affect cached asset loads. PRECONDITION: the UI image at ghcr - # must be multi-arch (linux/amd64,linux/arm64) — see #228 (or - # the build-and-publish.yml change shipped alongside this PR). + # must be multi-arch (linux/amd64,linux/arm64). nodeSelector: kubernetes.io/arch: arm64 # Spread replicas across as many distinct Pis as possible so the @@ -50,10 +49,10 @@ spec: whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: - app.kubernetes.io/name: assistant-ui + app.kubernetes.io/name: agents-ui containers: - - name: assistant-ui - image: ghcr.io/extratoast/personal-stack/assistant-ui:latest + - name: agents-ui + image: ghcr.io/extratoast/agents/agents-ui:latest imagePullPolicy: Always ports: - containerPort: 80 @@ -79,11 +78,11 @@ spec: apiVersion: v1 kind: Service metadata: - name: assistant-ui - namespace: assistant-system + name: agents-ui + namespace: agents-system spec: selector: - app.kubernetes.io/name: assistant-ui + app.kubernetes.io/name: agents-ui ports: - name: http port: 80 diff --git a/platform/cluster/flux/apps/stateless/assistant-ui/kustomization.yaml b/platform/cluster/flux/apps/stateless/agents-ui/kustomization.yaml similarity index 85% rename from platform/cluster/flux/apps/stateless/assistant-ui/kustomization.yaml rename to platform/cluster/flux/apps/stateless/agents-ui/kustomization.yaml index f5023321..eddedb54 100644 --- a/platform/cluster/flux/apps/stateless/assistant-ui/kustomization.yaml +++ b/platform/cluster/flux/apps/stateless/agents-ui/kustomization.yaml @@ -2,6 +2,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - namespace.yaml - deployment.yaml - pdb.yaml diff --git a/platform/cluster/flux/apps/stateless/assistant-api/pdb.yaml b/platform/cluster/flux/apps/stateless/agents-ui/pdb.yaml similarity index 72% rename from platform/cluster/flux/apps/stateless/assistant-api/pdb.yaml rename to platform/cluster/flux/apps/stateless/agents-ui/pdb.yaml index 46ced685..a0c17fcf 100644 --- a/platform/cluster/flux/apps/stateless/assistant-api/pdb.yaml +++ b/platform/cluster/flux/apps/stateless/agents-ui/pdb.yaml @@ -1,16 +1,16 @@ # Block voluntary disruptions (node drain, voluntary eviction) from # taking the last Ready replica offline. With minAvailable=1 the # eviction API refuses to evict if doing so would leave 0 pods. -# Required because assistant-api sits behind the public ingress (and, for +# Required because agents-ui sits behind the public ingress (and, for # auth-api, behind every other service's forward-auth) — even a # 30 s gap during ad-hoc node maintenance would lock users out. apiVersion: policy/v1 kind: PodDisruptionBudget metadata: - name: assistant-api - namespace: assistant-system + name: agents-ui + namespace: agents-system spec: minAvailable: 1 selector: matchLabels: - app.kubernetes.io/name: assistant-api + app.kubernetes.io/name: agents-ui diff --git a/platform/cluster/flux/apps/stateless/assistant-api/github-app-vss.yaml b/platform/cluster/flux/apps/stateless/assistant-api/github-app-vss.yaml deleted file mode 100644 index ce8473c8..00000000 --- a/platform/cluster/flux/apps/stateless/assistant-api/github-app-vss.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# App id + private key (token minting) + the shared bearer (verifying -# the runner's calls). assistant-api reads all three as env. Projected -# from secret/agents/github-app. -apiVersion: secrets.hashicorp.com/v1beta1 -kind: VaultStaticSecret -metadata: - name: github-app - namespace: assistant-system -spec: - vaultAuthRef: vso-system/default - type: kv-v2 - mount: secret - path: agents/github-app - destination: - name: github-app - create: true - refreshAfter: 1h diff --git a/platform/cluster/flux/apps/stateless/assistant-api/vault-secrets-operator-sa.yaml b/platform/cluster/flux/apps/stateless/assistant-api/vault-secrets-operator-sa.yaml deleted file mode 100644 index c67e15c8..00000000 --- a/platform/cluster/flux/apps/stateless/assistant-api/vault-secrets-operator-sa.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# VSO mints its kube-auth JWT against a `vault-secrets-operator` -# ServiceAccount in the secret's own namespace. assistant-system owns -# the github-app VaultStaticSecret, so it needs this SA (and a matching -# entry in the vso role's bound_service_account_namespaces — see -# data/vault/bootstrap-auth.sh). VsoCoverageTest enforces both. -apiVersion: v1 -kind: ServiceAccount -metadata: - name: vault-secrets-operator - namespace: assistant-system diff --git a/platform/cluster/flux/apps/stateless/assistant-ui/namespace.yaml b/platform/cluster/flux/apps/stateless/assistant-ui/namespace.yaml deleted file mode 100644 index b667b71e..00000000 --- a/platform/cluster/flux/apps/stateless/assistant-ui/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: assistant-system diff --git a/platform/cluster/flux/apps/stateless/auth-api/deployment.yaml b/platform/cluster/flux/apps/stateless/auth-api/deployment.yaml index 7ddbe98d..587e25fc 100644 --- a/platform/cluster/flux/apps/stateless/auth-api/deployment.yaml +++ b/platform/cluster/flux/apps/stateless/auth-api/deployment.yaml @@ -192,7 +192,7 @@ spec: - name: CONFIRMATION_URL value: https://auth.jorisjonkers.dev/confirm-email - name: AUTH_CORS_ALLOWED_ORIGINS - value: https://jorisjonkers.dev,https://auth.jorisjonkers.dev,https://assistant.jorisjonkers.dev,https://vault.jorisjonkers.dev,https://n8n.jorisjonkers.dev,https://grafana.jorisjonkers.dev,https://dashboard.jorisjonkers.dev,https://rabbitmq.jorisjonkers.dev,https://status.jorisjonkers.dev + value: https://jorisjonkers.dev,https://auth.jorisjonkers.dev,https://agents.jorisjonkers.dev,https://vault.jorisjonkers.dev,https://n8n.jorisjonkers.dev,https://grafana.jorisjonkers.dev,https://dashboard.jorisjonkers.dev,https://rabbitmq.jorisjonkers.dev,https://status.jorisjonkers.dev - name: DEPLOYMENT_ENVIRONMENT value: production # SERVICE_VERSION is baked into the image by the build pipeline @@ -275,7 +275,7 @@ spec: # process-running time hitting 302 s, so the previous 300 s # budget killed the pod ~2 s before Spring Boot finished. # auth-api carries OAuth2 server + the full Spring Security - # filter chain, so it's slower to wire than assistant-api + # filter chain, so it's slower to wire than agents-api # (which Started at 156 s under the same conditions). 600 s # leaves comfortable margin without changing the steady-state # liveness budget below. diff --git a/platform/cluster/flux/apps/stateless/kustomization.yaml b/platform/cluster/flux/apps/stateless/kustomization.yaml index a50cc7e9..048c19fb 100644 --- a/platform/cluster/flux/apps/stateless/kustomization.yaml +++ b/platform/cluster/flux/apps/stateless/kustomization.yaml @@ -5,7 +5,7 @@ resources: - app-ui - auth-api - auth-ui - - assistant-api - - assistant-ui + - agents-api + - agents-ui - flaresolverr - n8n diff --git a/platform/inventory/fleet.yaml b/platform/inventory/fleet.yaml index 23e68ba3..7e63b49b 100644 --- a/platform/inventory/fleet.yaml +++ b/platform/inventory/fleet.yaml @@ -82,7 +82,7 @@ nodes: - game-streaming # agent-runner Pods mount this node's /var/run/docker.sock for # Docker/Testcontainers. The matching k3s label is required by - # assistant-api before scheduling host-equivalent runner Pods here. + # agents-api before scheduling host-equivalent runner Pods here. - docker-socket - nvidia @@ -196,10 +196,10 @@ service_intent: - app-ui - auth-ui - auth-api - - assistant-ui - - assistant-api + - agents-ui + - agents-api # Alternate host for the agent terminal WebSocket (Enschede replica). - - assistant-ws + - agents-ws - stalwart - n8n - gatus @@ -268,8 +268,8 @@ placement_intent: - app-ui - auth-ui - auth-api - - assistant-ui - - assistant-api + - agents-ui + - agents-api - stalwart - postgres - rabbitmq @@ -301,7 +301,7 @@ placement_intent: - samba - flaresolverr - n8n - # agent-runner Pods are created dynamically by assistant-api + # agent-runner Pods are created dynamically by agents-api # (see platform/cluster/flux/apps/agents/), not declared as a # static service. The static placement intent here mirrors the # nodeSelector the orchestrator applies at Pod creation time: @@ -320,8 +320,8 @@ exposure_intent: - app-ui - auth-ui - auth-api - - assistant-ui - - assistant-api + - agents-ui + - agents-api - stalwart - grafana - n8n @@ -338,7 +338,7 @@ exposure_intent: public_and_lan: # Terminal-WS host: public via Cloudflare so it works off-site, and # LAN so split-DNS routes on-site clients to the Enschede replica. - - assistant-ws + - agents-ws - bazarr - immich - jellyfin @@ -362,8 +362,8 @@ exposure_intent: access_intent: sso_protected: - - assistant-api - - assistant-ws + - agents-api + - agents-ws - stalwart - bazarr - immich @@ -381,9 +381,9 @@ access_intent: app-ui: root auth-ui: auth auth-api: auth - assistant-ui: assistant - assistant-api: assistant - assistant-ws: assistant-ws + agents-ui: agents + agents-api: agents + agents-ws: agents-ws stalwart: stalwart grafana: grafana n8n: n8n @@ -430,7 +430,7 @@ ingress_intent: excluded_path_prefixes: - /api/ - /.well-known/ - - service: assistant-api + - service: agents-api path_prefixes: - /api/ excluded_exact_paths: @@ -438,15 +438,15 @@ ingress_intent: - /api/v1/health excluded_path_prefixes: - /api/actuator/health/ - - name: assistant-api-health - service: assistant-api + - name: agents-api-health + service: agents-api access: direct exact_paths: - /api/actuator/health - /api/v1/health path_prefixes: - /api/actuator/health/ - - service: assistant-ui + - service: agents-ui excluded_path_prefixes: - /api/ - name: knowledge-api-mcp @@ -493,25 +493,25 @@ ingress_intent: namespace: auth-system service: auth-ui port: 80 - assistant-api: - namespace: assistant-system - service: assistant-api + agents-api: + namespace: agents-system + service: agents-api port: 8082 health: path: /api/actuator/health/readiness # Alternate host serving only the agent terminal WebSocket, backed by - # the Enschede-pinned assistant-api-ws replica so on-site keystrokes + # the Enschede-pinned agents-api-ws replica so on-site keystrokes # stay local. Public via Cloudflare; split-DNS routes on-site clients # to the Enschede ingress. - assistant-ws: - namespace: assistant-system - service: assistant-api-ws + agents-ws: + namespace: agents-system + service: agents-api-ws port: 8082 health: path: /api/actuator/health/readiness - assistant-ui: - namespace: assistant-system - service: assistant-ui + agents-ui: + namespace: agents-system + service: agents-ui port: 80 stalwart: namespace: mail-system @@ -653,7 +653,7 @@ ingress_intent: # the agent terminal a dead half-open socket after a tab switch. # `edge_direct` points it at the Frankfurt edge as a grey-cloud A # record so the browser holds a direct TLS WebSocket to traefik. - assistant-ws: edge_direct + agents-ws: edge_direct monitoring_intent: # Cluster-only services with no external ingress. Gatus probes these diff --git a/platform/tests/_helpers.js b/platform/tests/_helpers.js index 9b033269..30c1af03 100644 --- a/platform/tests/_helpers.js +++ b/platform/tests/_helpers.js @@ -1,217 +1,296 @@ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdtemp, readdir, readFile, stat, writeFile, chmod } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import YAML from "yaml"; - -export const testsDir = path.dirname(fileURLToPath(import.meta.url)); -export const repoRoot = path.resolve(testsDir, "../.."); +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, readdir, readFile, stat, writeFile, chmod } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import YAML from 'yaml' + +export const testsDir = path.dirname(fileURLToPath(import.meta.url)) +export const repoRoot = path.resolve(testsDir, '../..') export function repoPath(...segments) { - return path.join(repoRoot, ...segments); + return path.join(repoRoot, ...segments) } export async function readRepoText(...segments) { - return readFile(repoPath(...segments), "utf8"); + return readFile(repoPath(...segments), 'utf8') } export async function readYamlRepo(...segments) { - return YAML.parse(await readRepoText(...segments)); + return YAML.parse(await readRepoText(...segments)) } export async function loadFleet() { - const fleet = await readYamlRepo("platform/inventory/fleet.yaml"); - validateFleet(fleet); - return fleet; + const fleet = await readYamlRepo('platform/inventory/fleet.yaml') + validateFleet(fleet) + return fleet } export function validateFleet(fleet) { - const k8s = fleet.cluster.kubernetes; - const bootstrap = fleet.nodes[k8s.bootstrap_control_plane]; - assert.ok(bootstrap, `bootstrap control plane ${k8s.bootstrap_control_plane} is not defined as a node`); + const k8s = fleet.cluster.kubernetes + const bootstrap = fleet.nodes[k8s.bootstrap_control_plane] + assert.ok(bootstrap, `bootstrap control plane ${k8s.bootstrap_control_plane} is not defined as a node`) assert.ok( - bootstrap.target_roles.includes("k3s-control-plane"), + bootstrap.target_roles.includes('k3s-control-plane'), `bootstrap control plane ${k8s.bootstrap_control_plane} must target the k3s-control-plane role`, - ); - assert.ok(k8s.api_server_endpoint.startsWith("https://"), "cluster kubernetes api_server_endpoint must use https"); - assert.ok(k8s.control_plane_token_file.startsWith("/"), "cluster kubernetes control_plane_token_file must be an absolute path"); - assert.ok(k8s.worker_join_token_file.startsWith("/"), "cluster kubernetes worker_join_token_file must be an absolute path"); + ) + assert.ok(k8s.api_server_endpoint.startsWith('https://'), 'cluster kubernetes api_server_endpoint must use https') + assert.ok( + k8s.control_plane_token_file.startsWith('/'), + 'cluster kubernetes control_plane_token_file must be an absolute path', + ) + assert.ok( + k8s.worker_join_token_file.startsWith('/'), + 'cluster kubernetes worker_join_token_file must be an absolute path', + ) for (const [nodeName, node] of Object.entries(fleet.nodes)) { - assert.ok(node.site in fleet.sites, `node ${nodeName} references unknown site ${node.site}`); - if (node.status === "active") { - assert.ok(node.ssh, `active node ${nodeName} must define ssh connection details`); + assert.ok(node.site in fleet.sites, `node ${nodeName} references unknown site ${node.site}`) + if (node.status === 'active') { + assert.ok(node.ssh, `active node ${nodeName} must define ssh connection details`) } } - const k = fleet.service_intent.kubernetes; - const knownKubernetesServices = new Set([...k.public_apps, ...k.internal_platform, ...k.home_media]); - const knownServices = new Set(knownKubernetesServices); + const k = fleet.service_intent.kubernetes + const knownKubernetesServices = new Set([...k.public_apps, ...k.internal_platform, ...k.home_media]) + const knownServices = new Set(knownKubernetesServices) for (const services of Object.values(fleet.service_intent.host_native ?? {})) { - for (const service of services) knownServices.add(service); + for (const service of services) knownServices.add(service) } const externallyExposedServices = new Set([ ...(fleet.exposure_intent.public ?? []), ...(fleet.exposure_intent.public_and_lan ?? []), ...(fleet.exposure_intent.lan_only ?? []), - ]); + ]) const externallyExposedKubernetesServices = new Set( [...externallyExposedServices].filter((service) => knownKubernetesServices.has(service)), - ); + ) for (const serviceName of fleet.access_intent?.sso_protected ?? []) { - assert.ok(knownServices.has(serviceName), `sso protected service ${serviceName} is not defined in service intent`); - assert.ok(externallyExposedServices.has(serviceName), `sso protected service ${serviceName} must be externally exposed`); + assert.ok(knownServices.has(serviceName), `sso protected service ${serviceName} is not defined in service intent`) + assert.ok( + externallyExposedServices.has(serviceName), + `sso protected service ${serviceName} must be externally exposed`, + ) } for (const [serviceName, hostLabel] of Object.entries(fleet.access_intent?.host_labels ?? {})) { - assert.ok(knownServices.has(serviceName), `host label for service ${serviceName} references unknown service intent`); - assert.ok(String(hostLabel).trim().length > 0, `host label for service ${serviceName} must not be blank`); + assert.ok(knownServices.has(serviceName), `host label for service ${serviceName} references unknown service intent`) + assert.ok(String(hostLabel).trim().length > 0, `host label for service ${serviceName} must not be blank`) } for (const serviceName of externallyExposedServices) { - assert.ok(serviceName in (fleet.access_intent?.host_labels ?? {}), `externally exposed service ${serviceName} must declare a host label`); + assert.ok( + serviceName in (fleet.access_intent?.host_labels ?? {}), + `externally exposed service ${serviceName} must declare a host label`, + ) } for (const [serviceName, backend] of Object.entries(fleet.ingress_intent?.kubernetes_backends ?? {})) { - assert.ok(knownKubernetesServices.has(serviceName), `ingress backend for service ${serviceName} references unknown kubernetes service intent`); - assert.ok(externallyExposedKubernetesServices.has(serviceName), `ingress backend for service ${serviceName} must target an externally exposed kubernetes service`); - validateBackend("ingress", serviceName, backend, true, fleet); + assert.ok( + knownKubernetesServices.has(serviceName), + `ingress backend for service ${serviceName} references unknown kubernetes service intent`, + ) + assert.ok( + externallyExposedKubernetesServices.has(serviceName), + `ingress backend for service ${serviceName} must target an externally exposed kubernetes service`, + ) + validateBackend('ingress', serviceName, backend, true, fleet) } for (const serviceName of externallyExposedKubernetesServices) { - assert.ok(serviceName in (fleet.ingress_intent?.kubernetes_backends ?? {}), `externally exposed kubernetes service ${serviceName} must declare an ingress backend`); + assert.ok( + serviceName in (fleet.ingress_intent?.kubernetes_backends ?? {}), + `externally exposed kubernetes service ${serviceName} must declare an ingress backend`, + ) } for (const [serviceName, origin] of Object.entries(fleet.ingress_intent?.wan_origin_overrides ?? {})) { - assert.ok(externallyExposedKubernetesServices.has(serviceName), `wan origin override for service ${serviceName} must target an externally exposed kubernetes service`); assert.ok( - origin === "home_direct" || origin === "edge_direct", + externallyExposedKubernetesServices.has(serviceName), + `wan origin override for service ${serviceName} must target an externally exposed kubernetes service`, + ) + assert.ok( + origin === 'home_direct' || origin === 'edge_direct', `wan origin override for service ${serviceName} must be 'home_direct' or 'edge_direct' (got '${origin}')`, - ); + ) } - const overrideModes = new Set(Object.values(fleet.ingress_intent?.wan_origin_overrides ?? {})); - if (overrideModes.has("home_direct")) { + const overrideModes = new Set(Object.values(fleet.ingress_intent?.wan_origin_overrides ?? {})) + if (overrideModes.has('home_direct')) { assert.ok( - Object.values(fleet.sites).some((site) => site.purpose === "home_lan_and_media_site" && site.networking?.wan_public_ip), + Object.values(fleet.sites).some( + (site) => site.purpose === 'home_lan_and_media_site' && site.networking?.wan_public_ip, + ), "wan_origin_overrides 'home_direct' needs the home site to set networking.wan_public_ip", - ); + ) } - if (overrideModes.has("edge_direct")) { + if (overrideModes.has('edge_direct')) { assert.ok( - Object.values(fleet.sites).some((site) => site.purpose === "primary_cluster_site" && site.networking?.wan_public_ip), + Object.values(fleet.sites).some( + (site) => site.purpose === 'primary_cluster_site' && site.networking?.wan_public_ip, + ), "wan_origin_overrides 'edge_direct' needs the primary cluster site to set networking.wan_public_ip", - ); + ) } for (const [serviceName, backend] of Object.entries(fleet.monitoring_intent?.kubernetes_backends ?? {})) { - assert.ok(knownKubernetesServices.has(serviceName), `monitoring backend for service ${serviceName} references unknown kubernetes service intent`); - assert.ok(!externallyExposedKubernetesServices.has(serviceName), `monitoring backend for service ${serviceName} duplicates an ingress backend; probe it via ingress_intent instead`); - validateBackend("monitoring", serviceName, backend, false, fleet); + assert.ok( + knownKubernetesServices.has(serviceName), + `monitoring backend for service ${serviceName} references unknown kubernetes service intent`, + ) + assert.ok( + !externallyExposedKubernetesServices.has(serviceName), + `monitoring backend for service ${serviceName} duplicates an ingress backend; probe it via ingress_intent instead`, + ) + validateBackend('monitoring', serviceName, backend, false, fleet) } - const lanExposedServices = [...(fleet.exposure_intent.public_and_lan ?? []), ...(fleet.exposure_intent.lan_only ?? [])]; + const lanExposedServices = [ + ...(fleet.exposure_intent.public_and_lan ?? []), + ...(fleet.exposure_intent.lan_only ?? []), + ] if (lanExposedServices.length > 0) { assert.ok( Object.values(fleet.sites).some((site) => site.networking?.lan_ingress_ip), - "lan exposed services require at least one site lan ingress ip", - ); + 'lan exposed services require at least one site lan ingress ip', + ) assert.ok( - Object.values(fleet.nodes).some((node) => node.status === "active" && node.capabilities.includes("lan-ingress")), - "lan exposed services require at least one active lan ingress node", - ); + Object.values(fleet.nodes).some((node) => node.status === 'active' && node.capabilities.includes('lan-ingress')), + 'lan exposed services require at least one active lan ingress node', + ) } } function validateBackend(kind, serviceName, backend, ingress, fleet) { - assert.ok(String(backend.namespace ?? "").trim().length > 0, `${kind} backend namespace for service ${serviceName} must not be blank`); - assert.ok(String(backend.service ?? "").trim().length > 0, `${kind} backend service name for service ${serviceName} must not be blank`); - assert.ok(backend.port > 0, `${kind} backend port for service ${serviceName} must be positive`); + assert.ok( + String(backend.namespace ?? '').trim().length > 0, + `${kind} backend namespace for service ${serviceName} must not be blank`, + ) + assert.ok( + String(backend.service ?? '').trim().length > 0, + `${kind} backend service name for service ${serviceName} must not be blank`, + ) + assert.ok(backend.port > 0, `${kind} backend port for service ${serviceName} must be positive`) if (backend.health) { - const health = { type: "http", path: "/", ...backend.health }; - assert.ok(["http", "tcp"].includes(health.type), `${kind === "monitoring" ? "monitoring " : ""}health type for service ${serviceName} must be http or tcp`); - if (health.type === "tcp") { - assert.equal(health.path, "/", `tcp ${kind === "monitoring" ? "monitoring " : ""}health for service ${serviceName} must not set a path`); - assert.equal(health.expected_status, undefined, `tcp ${kind === "monitoring" ? "monitoring " : ""}health for service ${serviceName} must not set expected_status`); + const health = { type: 'http', path: '/', ...backend.health } + assert.ok( + ['http', 'tcp'].includes(health.type), + `${kind === 'monitoring' ? 'monitoring ' : ''}health type for service ${serviceName} must be http or tcp`, + ) + if (health.type === 'tcp') { + assert.equal( + health.path, + '/', + `tcp ${kind === 'monitoring' ? 'monitoring ' : ''}health for service ${serviceName} must not set a path`, + ) + assert.equal( + health.expected_status, + undefined, + `tcp ${kind === 'monitoring' ? 'monitoring ' : ''}health for service ${serviceName} must not set expected_status`, + ) } - assert.ok(health.path.startsWith("/"), `${kind === "monitoring" ? "monitoring " : ""}health path for service ${serviceName} must start with /`); - if (health.port !== undefined) assert.ok(health.port > 0, `${kind === "monitoring" ? "monitoring " : ""}health port for service ${serviceName} must be positive`); + assert.ok( + health.path.startsWith('/'), + `${kind === 'monitoring' ? 'monitoring ' : ''}health path for service ${serviceName} must start with /`, + ) + if (health.port !== undefined) + assert.ok( + health.port > 0, + `${kind === 'monitoring' ? 'monitoring ' : ''}health port for service ${serviceName} must be positive`, + ) if (ingress && health.probe_strategy !== undefined) { - assert.ok(["internal", "external", "both"].includes(health.probe_strategy), `health probe_strategy for service ${serviceName} must be internal, external, or both`); - if (["external", "both"].includes(health.probe_strategy)) { - assert.ok(serviceName in (fleet.access_intent?.host_labels ?? {}), `health probe_strategy ${health.probe_strategy} for service ${serviceName} requires a host label`); + assert.ok( + ['internal', 'external', 'both'].includes(health.probe_strategy), + `health probe_strategy for service ${serviceName} must be internal, external, or both`, + ) + if (['external', 'both'].includes(health.probe_strategy)) { + assert.ok( + serviceName in (fleet.access_intent?.host_labels ?? {}), + `health probe_strategy ${health.probe_strategy} for service ${serviceName} requires a host label`, + ) } } if (!ingress) { - assert.ok(health.probe_strategy === undefined || health.probe_strategy === "internal", `monitoring health probe_strategy for service ${serviceName} must be internal (monitoring targets have no external host)`); + assert.ok( + health.probe_strategy === undefined || health.probe_strategy === 'internal', + `monitoring health probe_strategy for service ${serviceName} must be internal (monitoring targets have no external host)`, + ) } } for (const probe of backend.extra_probes ?? []) { - assert.ok(String(probe.name ?? "").trim().length > 0, `extra probe for service ${serviceName} must define a name`); - assert.ok(probe.port > 0, `extra probe ${probe.name} for service ${serviceName} must use a positive port`); - const type = probe.type ?? "tcp"; - const probePath = probe.path ?? "/"; - assert.ok(["http", "tcp"].includes(type), `extra probe ${probe.name} for service ${serviceName} must be http or tcp`); - if (type === "tcp") { - assert.equal(probePath, "/", `tcp extra probe ${probe.name} for service ${serviceName} must not set a path`); - assert.equal(probe.expected_status, undefined, `tcp extra probe ${probe.name} for service ${serviceName} must not set expected_status`); + assert.ok(String(probe.name ?? '').trim().length > 0, `extra probe for service ${serviceName} must define a name`) + assert.ok(probe.port > 0, `extra probe ${probe.name} for service ${serviceName} must use a positive port`) + const type = probe.type ?? 'tcp' + const probePath = probe.path ?? '/' + assert.ok( + ['http', 'tcp'].includes(type), + `extra probe ${probe.name} for service ${serviceName} must be http or tcp`, + ) + if (type === 'tcp') { + assert.equal(probePath, '/', `tcp extra probe ${probe.name} for service ${serviceName} must not set a path`) + assert.equal( + probe.expected_status, + undefined, + `tcp extra probe ${probe.name} for service ${serviceName} must not set expected_status`, + ) } - assert.ok(probePath.startsWith("/"), `extra probe ${probe.name} for service ${serviceName} path must start with /`); + assert.ok(probePath.startsWith('/'), `extra probe ${probe.name} for service ${serviceName} path must start with /`) } } export function parseFleetText(text) { - const fleet = YAML.parse(text); - validateFleet(fleet); - return fleet; + const fleet = YAML.parse(text) + validateFleet(fleet) + return fleet } export function assertContains(text, ...needles) { - for (const needle of needles) assert.ok(text.includes(needle), `expected text to contain ${needle}`); + for (const needle of needles) assert.ok(text.includes(needle), `expected text to contain ${needle}`) } export function assertNotContains(text, ...needles) { - for (const needle of needles) assert.ok(!text.includes(needle), `expected text not to contain ${needle}`); + for (const needle of needles) assert.ok(!text.includes(needle), `expected text not to contain ${needle}`) } export function assertSameMembers(actual, expected, message) { - assert.deepEqual([...actual].sort(), [...expected].sort(), message); + assert.deepEqual([...actual].sort(), [...expected].sort(), message) } export async function filesUnder(root) { - if (!existsSync(root)) return []; - const out = []; + if (!existsSync(root)) return [] + const out = [] async function walk(dir) { for (const entry of await readdir(dir)) { - const full = path.join(dir, entry); - const info = await stat(full); - if (info.isDirectory()) await walk(full); - else if (info.isFile()) out.push(full); + const full = path.join(dir, entry) + const info = await stat(full) + if (info.isDirectory()) await walk(full) + else if (info.isFile()) out.push(full) } } - await walk(root); - return out; + await walk(root) + return out } export function relativePath(root, file) { - return path.relative(root, file).replaceAll(path.sep, "/"); + return path.relative(root, file).replaceAll(path.sep, '/') } export async function sha256(file) { - return createHash("sha256").update(await readFile(file)).digest("hex"); + return createHash('sha256') + .update(await readFile(file)) + .digest('hex') } -export async function tempDir(prefix = "platform-tests-") { - return mkdtemp(path.join(tmpdir(), prefix)); +export async function tempDir(prefix = 'platform-tests-') { + return mkdtemp(path.join(tmpdir(), prefix)) } export async function writeExecutable(file, contents) { - await writeFile(file, contents.trimStart().replace(/\n?$/, "\n"), "utf8"); - await chmod(file, 0o755); - return file; + await writeFile(file, contents.trimStart().replace(/\n?$/, '\n'), 'utf8') + await chmod(file, 0o755) + return file } export async function runProcess(command, args = [], { env = {}, input, cwd = repoRoot } = {}) { @@ -219,31 +298,33 @@ export async function runProcess(command, args = [], { env = {}, input, cwd = re const child = spawn(command, args, { cwd, env: { ...process.env, ...env }, - stdio: ["pipe", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.on("error", reject); - child.on("close", (exitCode) => resolve({ exitCode, stdout, stderr })); - if (input !== undefined) child.stdin.end(input); - else child.stdin.end(); - }); + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (exitCode) => resolve({ exitCode, stdout, stderr })) + if (input !== undefined) child.stdin.end(input) + else child.stdin.end() + }) } export function yamlDocs(text) { - return YAML.parseAllDocuments(text).map((doc) => doc.toJSON()).filter(Boolean); + return YAML.parseAllDocuments(text) + .map((doc) => doc.toJSON()) + .filter(Boolean) } export function getPath(object, keys) { - let current = object; - for (const key of keys) current = current?.[key]; - return current; + let current = object + for (const key of keys) current = current?.[key] + return current } diff --git a/platform/tests/access-intent.test.js b/platform/tests/access-intent.test.js index 7b32c4eb..7849f8fe 100644 --- a/platform/tests/access-intent.test.js +++ b/platform/tests/access-intent.test.js @@ -1,50 +1,51 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { loadFleet } from "./_helpers.js"; +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadFleet } from './_helpers.js' -test("vault is modeled as a public sso protected service", async () => { - const fleet = await loadFleet(); - assert.ok(fleet.access_intent.sso_protected.includes("vault")); - assert.equal(fleet.access_intent.host_labels.vault, "vault"); - assert.ok(fleet.exposure_intent.public.includes("vault")); - assert.ok(!fleet.exposure_intent.internal_only.includes("vault")); -}); +test('vault is modeled as a public sso protected service', async () => { + const fleet = await loadFleet() + assert.ok(fleet.access_intent.sso_protected.includes('vault')) + assert.equal(fleet.access_intent.host_labels.vault, 'vault') + assert.ok(fleet.exposure_intent.public.includes('vault')) + assert.ok(!fleet.exposure_intent.internal_only.includes('vault')) +}) -test("edge exposed services declare stable host labels", async () => { - const fleet = await loadFleet(); - assert.equal(fleet.access_intent.host_labels["app-ui"], "root"); - assert.equal(fleet.access_intent.host_labels["auth-ui"], "auth"); - assert.equal(fleet.access_intent.host_labels["assistant-ui"], "assistant"); - assert.equal(fleet.access_intent.host_labels.stalwart, "stalwart"); - assert.equal(fleet.access_intent.host_labels.gatus, "status"); - assert.equal(fleet.access_intent.host_labels.bazarr, "bazarr"); - assert.equal(fleet.access_intent.host_labels.prowlarr, "prowlarr"); - assert.equal(fleet.access_intent.host_labels.qbittorrent, "qbittorrent"); - assert.equal(fleet.access_intent.host_labels.jellyseerr, "jellyseerr"); -}); +test('edge exposed services declare stable host labels', async () => { + const fleet = await loadFleet() + assert.equal(fleet.access_intent.host_labels['app-ui'], 'root') + assert.equal(fleet.access_intent.host_labels['auth-ui'], 'auth') + assert.equal(fleet.access_intent.host_labels['agents-ui'], 'agents') + assert.equal(fleet.access_intent.host_labels.stalwart, 'stalwart') + assert.equal(fleet.access_intent.host_labels.gatus, 'status') + assert.equal(fleet.access_intent.host_labels.bazarr, 'bazarr') + assert.equal(fleet.access_intent.host_labels.prowlarr, 'prowlarr') + assert.equal(fleet.access_intent.host_labels.qbittorrent, 'qbittorrent') + assert.equal(fleet.access_intent.host_labels.jellyseerr, 'jellyseerr') +}) -test("rabbitmq is modeled as a public sso protected service", async () => { - const fleet = await loadFleet(); - assert.ok(fleet.access_intent.sso_protected.includes("rabbitmq")); - assert.equal(fleet.access_intent.host_labels.rabbitmq, "rabbitmq"); - assert.ok(fleet.exposure_intent.public.includes("rabbitmq")); - assert.ok(!fleet.exposure_intent.internal_only.includes("rabbitmq")); -}); +test('rabbitmq is modeled as a public sso protected service', async () => { + const fleet = await loadFleet() + assert.ok(fleet.access_intent.sso_protected.includes('rabbitmq')) + assert.equal(fleet.access_intent.host_labels.rabbitmq, 'rabbitmq') + assert.ok(fleet.exposure_intent.public.includes('rabbitmq')) + assert.ok(!fleet.exposure_intent.internal_only.includes('rabbitmq')) +}) -test("stalwart admin is modeled as a public sso protected service", async () => { - const fleet = await loadFleet(); - assert.ok(fleet.access_intent.sso_protected.includes("stalwart")); - assert.equal(fleet.access_intent.host_labels.stalwart, "stalwart"); - assert.ok(fleet.exposure_intent.public.includes("stalwart")); - assert.ok(!fleet.exposure_intent.internal_only.includes("stalwart")); -}); +test('stalwart admin is modeled as a public sso protected service', async () => { + const fleet = await loadFleet() + assert.ok(fleet.access_intent.sso_protected.includes('stalwart')) + assert.equal(fleet.access_intent.host_labels.stalwart, 'stalwart') + assert.ok(fleet.exposure_intent.public.includes('stalwart')) + assert.ok(!fleet.exposure_intent.internal_only.includes('stalwart')) +}) -test("media tools are public on both edges with external sso enforcement", async () => { - const fleet = await loadFleet(); - for (const service of ["bazarr", "prowlarr", "qbittorrent", "jellyseerr"]) { - assert.ok(fleet.exposure_intent.public_and_lan.includes(service)); - assert.ok(!fleet.exposure_intent.internal_only.includes(service)); +test('media tools are public on both edges with external sso enforcement', async () => { + const fleet = await loadFleet() + for (const service of ['bazarr', 'prowlarr', 'qbittorrent', 'jellyseerr']) { + assert.ok(fleet.exposure_intent.public_and_lan.includes(service)) + assert.ok(!fleet.exposure_intent.internal_only.includes(service)) } - for (const service of ["bazarr", "prowlarr", "qbittorrent"]) assert.ok(fleet.access_intent.sso_protected.includes(service)); - assert.ok(!fleet.access_intent.sso_protected.includes("jellyseerr")); -}); + for (const service of ['bazarr', 'prowlarr', 'qbittorrent']) + assert.ok(fleet.access_intent.sso_protected.includes(service)) + assert.ok(!fleet.access_intent.sso_protected.includes('jellyseerr')) +}) diff --git a/platform/tests/agent-kit-manifest.test.js b/platform/tests/agent-kit-manifest.test.js index 7b91409e..6f4c2bb5 100644 --- a/platform/tests/agent-kit-manifest.test.js +++ b/platform/tests/agent-kit-manifest.test.js @@ -1,10 +1,10 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import path from "node:path"; -import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import YAML from "yaml"; +import test from 'node:test' +import assert from 'node:assert/strict' +import http from 'node:http' +import path from 'node:path' +import { existsSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import YAML from 'yaml' import { assertContains, assertSameMembers, @@ -18,81 +18,131 @@ import { sha256, tempDir, writeExecutable, -} from "./_helpers.js"; +} from './_helpers.js' -const manifest = await readYamlRepo("platform/agents/kit/manifest.yaml"); +const manifest = await readYamlRepo('platform/agents/kit/manifest.yaml') -test("manifest pins every checked-in skill hook setting and installer file", async () => { - const actualRepoSkillPaths = await repoSkillPaths(); - const manifestSkillPaths = new Set(manifestTargetPaths("skills").filter((item) => item.includes("/skills/"))); - assertSameMembers(manifestSkillPaths, actualRepoSkillPaths, "every checked-in Claude/Codex skill must be listed in the agent-kit manifest"); +test('manifest pins every checked-in skill hook setting and installer file', async () => { + const actualRepoSkillPaths = await repoSkillPaths() + const manifestSkillPaths = new Set(manifestTargetPaths('skills').filter((item) => item.includes('/skills/'))) + assertSameMembers( + manifestSkillPaths, + actualRepoSkillPaths, + 'every checked-in Claude/Codex skill must be listed in the agent-kit manifest', + ) - const actualRepoHookPaths = await repoHookPaths(); - const manifestHookPaths = new Set(manifestTargetPaths("hooks").filter((item) => item.includes("/hooks/"))); - assertSameMembers(manifestHookPaths, actualRepoHookPaths, "every checked-in Claude/Codex hook must be listed in the agent-kit manifest"); + const actualRepoHookPaths = await repoHookPaths() + const manifestHookPaths = new Set(manifestTargetPaths('hooks').filter((item) => item.includes('/hooks/'))) + assertSameMembers( + manifestHookPaths, + actualRepoHookPaths, + 'every checked-in Claude/Codex hook must be listed in the agent-kit manifest', + ) - const actualRepoCommandPaths = await repoCommandPaths(); - const manifestCommandPaths = new Set(manifestTargetPaths("commands").filter((item) => item.includes("/commands/"))); - assertSameMembers(manifestCommandPaths, actualRepoCommandPaths, "every checked-in Claude command must be listed in the agent-kit manifest"); + const actualRepoCommandPaths = await repoCommandPaths() + const manifestCommandPaths = new Set(manifestTargetPaths('commands').filter((item) => item.includes('/commands/'))) + assertSameMembers( + manifestCommandPaths, + actualRepoCommandPaths, + 'every checked-in Claude command must be listed in the agent-kit manifest', + ) - const pinnedPaths = collectPinnedPaths(manifest); - for (const required of [".claude/settings.json", ".codex/hooks.json", "services/knowledge-api/src/main/resources/installer/install.sh"]) { - assert.ok(pinnedPaths.map((item) => item.path).includes(required)); + const pinnedPaths = collectPinnedPaths(manifest) + for (const required of [ + '.claude/settings.json', + '.codex/hooks.json', + 'services/knowledge-api/src/main/resources/installer/install.sh', + ]) { + assert.ok(pinnedPaths.map((item) => item.path).includes(required)) } for (const pinned of pinnedPaths) { - const file = rendererSourcePath(pinned.path); - assert.ok(existsSync(file), `manifest path exists: ${pinned.path}`); - assert.equal(await sha256(file), pinned.sha256, `sha256 for ${pinned.path}`); + const file = rendererSourcePath(pinned.path) + assert.ok(existsSync(file), `manifest path exists: ${pinned.path}`) + assert.equal(await sha256(file), pinned.sha256, `sha256 for ${pinned.path}`) } - for (const command of manifestItems("commands")) { - const name = command.name; - const expectedPath = `.claude/commands/${name}.md`; - assert.equal(command.installer.target_path, `\${CLAUDE_HOME}/commands/${name}.md`); - assert.deepEqual(itemTargetPaths(command), [expectedPath]); - assert.equal(command.targets[0].sha256, await sha256(repoPath(expectedPath))); + for (const command of manifestItems('commands')) { + const name = command.name + const expectedPath = `.claude/commands/${name}.md` + assert.equal(command.installer.target_path, `\${CLAUDE_HOME}/commands/${name}.md`) + assert.deepEqual(itemTargetPaths(command), [expectedPath]) + assert.equal(command.targets[0].sha256, await sha256(repoPath(expectedPath))) } -}); +}) -test("shared skills exist for both Claude and Codex unless a gap is explicit", async () => { - const codexSkillNames = [...await skillNamesUnder(".agents/skills")].filter((name) => !name.startsWith("speckit-")); - const claudeSkillNames = [...await skillNamesUnder(".claude/skills")].filter((name) => !name.startsWith("speckit-")); - assertSameMembers(codexSkillNames, claudeSkillNames, "repo-level Codex and Claude skill sets must stay in lockstep"); +test('shared skills exist for both Claude and Codex unless a gap is explicit', async () => { + const codexSkillNames = [...(await skillNamesUnder('.agents/skills'))].filter((name) => !name.startsWith('speckit-')) + const claudeSkillNames = [...(await skillNamesUnder('.claude/skills'))].filter((name) => !name.startsWith('speckit-')) + assertSameMembers(codexSkillNames, claudeSkillNames, 'repo-level Codex and Claude skill sets must stay in lockstep') - for (const skill of manifestItems("skills").filter((item) => item.name.startsWith("speckit-"))) { - assert.deepEqual(supportedAgents(skill), ["codex"]); - assert.ok(skill.unsupported?.claude?.trim()); + for (const skill of manifestItems('skills').filter((item) => item.name.startsWith('speckit-'))) { + assert.deepEqual(supportedAgents(skill), ['codex']) + assert.ok(skill.unsupported?.claude?.trim()) } - for (const skill of manifestItems("skills")) assertAgentGapIsExplicit(`skill ${skill.name}`, skill); - assertAgentGapIsExplicit("installer", manifest.installer); -}); - -test("Spec Kit Claude commands and Codex skills stay in one to one parity", async () => { - const commandNames = new Set([...await repoCommandPaths()].map((item) => item.substring(item.lastIndexOf("/speckit.") + 9).replace(/\.md$/, ""))); - const skillNames = new Set([...await repoSkillPaths()].map((item) => /^\.agents\/skills\/speckit-([^/]+)\/SKILL\.md$/.exec(item)?.[1]).filter(Boolean)); - assertSameMembers(skillNames, commandNames, "each /speckit. must have a matching Codex speckit- skill"); -}); - -test("installer managed surfaces are listed in the manifest", async () => { - const installer = await readRepoText("services/knowledge-api/src/main/resources/installer/install.sh"); - const installedSkillNames = new Set([...installer.matchAll(/\$\{(?:CODEX_)?SKILLS_DIR}\/([^/]+)\/SKILL\.md/g)].map((match) => match[1])); - const manifestInstallerSkillNames = new Set(manifestItems("skills").filter((item) => item.installer).map((item) => item.name)); - assertSameMembers(manifestInstallerSkillNames, installedSkillNames, "every installer-managed skill must be visible in the agent-kit manifest"); - - const installedHookNames = new Set([...installer.matchAll(/\$\{(?:CODEX_)?HOOKS_DIR}\/([^"]+\.sh)/g)].map((match) => match[1])); + for (const skill of manifestItems('skills')) assertAgentGapIsExplicit(`skill ${skill.name}`, skill) + assertAgentGapIsExplicit('installer', manifest.installer) +}) + +test('Spec Kit Claude commands and Codex skills stay in one to one parity', async () => { + const commandNames = new Set( + [...(await repoCommandPaths())].map((item) => + item.substring(item.lastIndexOf('/speckit.') + 9).replace(/\.md$/, ''), + ), + ) + const skillNames = new Set( + [...(await repoSkillPaths())] + .map((item) => /^\.agents\/skills\/speckit-([^/]+)\/SKILL\.md$/.exec(item)?.[1]) + .filter(Boolean), + ) + assertSameMembers( + skillNames, + commandNames, + 'each /speckit. must have a matching Codex speckit- skill', + ) +}) + +test('installer managed surfaces are listed in the manifest', async () => { + const installer = await readRepoText('services/knowledge-api/src/main/resources/installer/install.sh') + const installedSkillNames = new Set( + [...installer.matchAll(/\$\{(?:CODEX_)?SKILLS_DIR}\/([^/]+)\/SKILL\.md/g)].map((match) => match[1]), + ) + const manifestInstallerSkillNames = new Set( + manifestItems('skills') + .filter((item) => item.installer) + .map((item) => item.name), + ) + assertSameMembers( + manifestInstallerSkillNames, + installedSkillNames, + 'every installer-managed skill must be visible in the agent-kit manifest', + ) + + const installedHookNames = new Set( + [...installer.matchAll(/\$\{(?:CODEX_)?HOOKS_DIR}\/([^"]+\.sh)/g)].map((match) => match[1]), + ) const manifestInstallerHookNames = new Set( - manifestItems("hooks").flatMap((hook) => [hook.installer?.target_path, hook.installer?.codex_target_path].filter(Boolean).map((item) => item.substring(item.lastIndexOf("/") + 1))), - ); - assertSameMembers(manifestInstallerHookNames, installedHookNames, "every installer-managed hook must be visible in the agent-kit manifest"); -}); - -test("installer dry-run covers Claude and Codex managed surfaces", async () => { - const dir = await tempDir(); - const installer = repoPath(manifest.installer.path); - const claudeHome = path.join(dir, "installer-claude"); - const codexHome = path.join(dir, "installer-codex"); - const result = await runProcess("bash", [installer, "--agent", "all", "--dry-run"], { env: { CLAUDE_CONFIG_DIR: claudeHome, CODEX_HOME: codexHome } }); - assert.equal(result.exitCode, 0, result.stderr); + manifestItems('hooks').flatMap((hook) => + [hook.installer?.target_path, hook.installer?.codex_target_path] + .filter(Boolean) + .map((item) => item.substring(item.lastIndexOf('/') + 1)), + ), + ) + assertSameMembers( + manifestInstallerHookNames, + installedHookNames, + 'every installer-managed hook must be visible in the agent-kit manifest', + ) +}) + +test('installer dry-run covers Claude and Codex managed surfaces', async () => { + const dir = await tempDir() + const installer = repoPath(manifest.installer.path) + const claudeHome = path.join(dir, 'installer-claude') + const codexHome = path.join(dir, 'installer-codex') + const result = await runProcess('bash', [installer, '--agent', 'all', '--dry-run'], { + env: { CLAUDE_CONFIG_DIR: claudeHome, CODEX_HOME: codexHome }, + }) + assert.equal(result.exitCode, 0, result.stderr) assertContains( result.stdout, `would write ${claudeHome}/hooks/user-prompt-submit-recall.sh`, @@ -110,732 +160,978 @@ test("installer dry-run covers Claude and Codex managed surfaces", async () => { `would write ${codexHome}/skills/speckit-taskstoissues/SKILL.md`, `would write ${codexHome}/hooks.json`, `${codexHome}/hooks.json has been written with UserPromptSubmit, PreToolUse,`, - ); - assert.equal(existsSync(path.join(codexHome, "hooks.json")), false); -}); - -test("installer agent selection covers Spec Kit commands and Codex skills", async () => { - const dir = await tempDir(); - const installer = repoPath(manifest.installer.path); - for (const agent of ["claude", "codex", "all"]) { - const claudeHome = path.join(dir, `agent-${agent}-claude`); - const codexHome = path.join(dir, `agent-${agent}-codex`); - const env = { CLAUDE_CONFIG_DIR: claudeHome, CODEX_HOME: codexHome }; - const dryRunResult = await runProcess("bash", [installer, "--agent", agent, "--dry-run"], { env }); - assert.equal(dryRunResult.exitCode, 0, dryRunResult.stderr); - assert.equal(dryRunResult.stdout.includes(`would write ${claudeHome}/commands/speckit.analyze.md`), agent !== "codex"); - assert.equal(dryRunResult.stdout.includes(`would write ${codexHome}/skills/speckit-analyze/SKILL.md`), agent !== "claude"); - const installResult = await runProcess("bash", [installer, "--agent", agent], { env }); - assert.equal(installResult.exitCode, 0, installResult.stderr); - const claudeCommand = path.join(claudeHome, "commands/speckit.analyze.md"); - const codexSkill = path.join(codexHome, "skills/speckit-analyze/SKILL.md"); - assert.equal(existsSync(claudeCommand), agent !== "codex"); - assert.equal(existsSync(codexSkill), agent !== "claude"); - const uninstallResult = await runProcess("bash", [installer, "--agent", agent, "--uninstall"], { env }); - assert.equal(uninstallResult.exitCode, 0, uninstallResult.stderr); - assert.equal(existsSync(claudeCommand), false); - assert.equal(existsSync(codexSkill), false); + ) + assert.equal(existsSync(path.join(codexHome, 'hooks.json')), false) +}) + +test('installer agent selection covers Spec Kit commands and Codex skills', async () => { + const dir = await tempDir() + const installer = repoPath(manifest.installer.path) + for (const agent of ['claude', 'codex', 'all']) { + const claudeHome = path.join(dir, `agent-${agent}-claude`) + const codexHome = path.join(dir, `agent-${agent}-codex`) + const env = { CLAUDE_CONFIG_DIR: claudeHome, CODEX_HOME: codexHome } + const dryRunResult = await runProcess('bash', [installer, '--agent', agent, '--dry-run'], { env }) + assert.equal(dryRunResult.exitCode, 0, dryRunResult.stderr) + assert.equal( + dryRunResult.stdout.includes(`would write ${claudeHome}/commands/speckit.analyze.md`), + agent !== 'codex', + ) + assert.equal( + dryRunResult.stdout.includes(`would write ${codexHome}/skills/speckit-analyze/SKILL.md`), + agent !== 'claude', + ) + const installResult = await runProcess('bash', [installer, '--agent', agent], { env }) + assert.equal(installResult.exitCode, 0, installResult.stderr) + const claudeCommand = path.join(claudeHome, 'commands/speckit.analyze.md') + const codexSkill = path.join(codexHome, 'skills/speckit-analyze/SKILL.md') + assert.equal(existsSync(claudeCommand), agent !== 'codex') + assert.equal(existsSync(codexSkill), agent !== 'claude') + const uninstallResult = await runProcess('bash', [installer, '--agent', agent, '--uninstall'], { env }) + assert.equal(uninstallResult.exitCode, 0, uninstallResult.stderr) + assert.equal(existsSync(claudeCommand), false) + assert.equal(existsSync(codexSkill), false) } -}); - -test("installer writes parseable Codex hooks and uninstalls managed files", async () => { - const dir = await tempDir(); - const installer = repoPath(manifest.installer.path); - const claudeHome = path.join(dir, "write-claude"); - const codexHome = path.join(dir, "write-codex"); - const env = { CLAUDE_CONFIG_DIR: claudeHome, CODEX_HOME: codexHome }; - const installResult = await runProcess("bash", [installer, "--agent", "all"], { env }); - assert.equal(installResult.exitCode, 0, installResult.stderr); - assertContains(installResult.stdout, "knowledge-system installer complete", "agent=all"); +}) + +test('installer writes parseable Codex hooks and uninstalls managed files', async () => { + const dir = await tempDir() + const installer = repoPath(manifest.installer.path) + const claudeHome = path.join(dir, 'write-claude') + const codexHome = path.join(dir, 'write-codex') + const env = { CLAUDE_CONFIG_DIR: claudeHome, CODEX_HOME: codexHome } + const installResult = await runProcess('bash', [installer, '--agent', 'all'], { env }) + assert.equal(installResult.exitCode, 0, installResult.stderr) + assertContains(installResult.stdout, 'knowledge-system installer complete', 'agent=all') const claudeFiles = [ - "hooks/user-prompt-submit-recall.sh", - "hooks/pre-tool-use-edit-recall.sh", - "hooks/pre-tool-use-git-commit-capture.sh", - "hooks/stop-session-digest.sh", - "commands/speckit.analyze.md", - "commands/speckit.checklist.md", - "commands/speckit.clarify.md", - "commands/speckit.constitution.md", - "commands/speckit.implement.md", - "commands/speckit.plan.md", - "commands/speckit.specify.md", - "commands/speckit.tasks.md", - "commands/speckit.taskstoissues.md", - "skills/topics/SKILL.md", - "skills/audit/SKILL.md", - "skills/kb-first/SKILL.md", - "skills/token-economy/SKILL.md", - "skills/agent-session-bootstrap/SKILL.md", - "skills/council/SKILL.md", - "skills/council/council.py", - "skills/council/council.toml", - "skills/council/prompts/planner.md", - "skills/council/schemas/plan.schema.json", - ".knowledge-system-allowlist", - ".knowledge-system-version", - ].map((item) => path.join(claudeHome, item)); + 'hooks/user-prompt-submit-recall.sh', + 'hooks/pre-tool-use-edit-recall.sh', + 'hooks/pre-tool-use-git-commit-capture.sh', + 'hooks/stop-session-digest.sh', + 'commands/speckit.analyze.md', + 'commands/speckit.checklist.md', + 'commands/speckit.clarify.md', + 'commands/speckit.constitution.md', + 'commands/speckit.implement.md', + 'commands/speckit.plan.md', + 'commands/speckit.specify.md', + 'commands/speckit.tasks.md', + 'commands/speckit.taskstoissues.md', + 'skills/topics/SKILL.md', + 'skills/audit/SKILL.md', + 'skills/kb-first/SKILL.md', + 'skills/token-economy/SKILL.md', + 'skills/agent-session-bootstrap/SKILL.md', + 'skills/council/SKILL.md', + 'skills/council/council.py', + 'skills/council/council.toml', + 'skills/council/prompts/planner.md', + 'skills/council/schemas/plan.schema.json', + '.knowledge-system-allowlist', + '.knowledge-system-version', + ].map((item) => path.join(claudeHome, item)) const codexFiles = [ - "hooks/kb-user-prompt-recall.sh", - "hooks/pre-tool-use-edit-recall.sh", - "hooks/pre-tool-use-git-commit-capture.sh", - "hooks/kb-stop-digest.sh", - "skills/speckit-analyze/SKILL.md", - "skills/speckit-checklist/SKILL.md", - "skills/speckit-clarify/SKILL.md", - "skills/speckit-constitution/SKILL.md", - "skills/speckit-implement/SKILL.md", - "skills/speckit-plan/SKILL.md", - "skills/speckit-specify/SKILL.md", - "skills/speckit-tasks/SKILL.md", - "skills/speckit-taskstoissues/SKILL.md", - "skills/topics/SKILL.md", - "skills/audit/SKILL.md", - "skills/kb-first/SKILL.md", - "skills/token-economy/SKILL.md", - "skills/agent-session-bootstrap/SKILL.md", - "skills/council/SKILL.md", - "skills/council/council.py", - "skills/council/council.toml", - "skills/council/prompts/planner.md", - "skills/council/schemas/plan.schema.json", - ".knowledge-system-allowlist", - ".knowledge-system-version", - "hooks.json", - ].map((item) => path.join(codexHome, item)); - for (const file of [...claudeFiles, ...codexFiles]) assert.ok(existsSync(file), `installer wrote ${file}`); + 'hooks/kb-user-prompt-recall.sh', + 'hooks/pre-tool-use-edit-recall.sh', + 'hooks/pre-tool-use-git-commit-capture.sh', + 'hooks/kb-stop-digest.sh', + 'skills/speckit-analyze/SKILL.md', + 'skills/speckit-checklist/SKILL.md', + 'skills/speckit-clarify/SKILL.md', + 'skills/speckit-constitution/SKILL.md', + 'skills/speckit-implement/SKILL.md', + 'skills/speckit-plan/SKILL.md', + 'skills/speckit-specify/SKILL.md', + 'skills/speckit-tasks/SKILL.md', + 'skills/speckit-taskstoissues/SKILL.md', + 'skills/topics/SKILL.md', + 'skills/audit/SKILL.md', + 'skills/kb-first/SKILL.md', + 'skills/token-economy/SKILL.md', + 'skills/agent-session-bootstrap/SKILL.md', + 'skills/council/SKILL.md', + 'skills/council/council.py', + 'skills/council/council.toml', + 'skills/council/prompts/planner.md', + 'skills/council/schemas/plan.schema.json', + '.knowledge-system-allowlist', + '.knowledge-system-version', + 'hooks.json', + ].map((item) => path.join(codexHome, item)) + for (const file of [...claudeFiles, ...codexFiles]) assert.ok(existsSync(file), `installer wrote ${file}`) for (const file of [ - path.join(claudeHome, "hooks/user-prompt-submit-recall.sh"), - path.join(claudeHome, "hooks/pre-tool-use-edit-recall.sh"), - path.join(claudeHome, "hooks/pre-tool-use-git-commit-capture.sh"), - path.join(claudeHome, "hooks/stop-session-digest.sh"), - path.join(codexHome, "hooks/kb-user-prompt-recall.sh"), - path.join(codexHome, "hooks/pre-tool-use-edit-recall.sh"), - path.join(codexHome, "hooks/pre-tool-use-git-commit-capture.sh"), - path.join(codexHome, "hooks/kb-stop-digest.sh"), - path.join(claudeHome, "skills/council/council.py"), - path.join(codexHome, "skills/council/council.py"), - ]) assert.ok((await import("node:fs")).statSync(file).mode & 0o111, `installer made hook executable: ${file}`); - assertContains(await readFile(path.join(claudeHome, ".knowledge-system-version"), "utf8"), "scope=user", "managed:", "hooks/user-prompt-submit-recall.sh", "commands/speckit.analyze.md"); - assertContains(await readFile(path.join(codexHome, ".knowledge-system-version"), "utf8"), "agent=codex", "scope=user", "hooks.json", "skills/speckit-analyze/SKILL.md"); - assert.equal(existsSync(path.join(dir, "write-claude/.specify")), false); - assert.equal(existsSync(path.join(dir, "write-codex/.specify")), false); - const codexHooks = JSON.parse(await readFile(path.join(codexHome, "hooks.json"), "utf8")); - assert.deepEqual(hookCommands(codexHooks, "UserPromptSubmit"), [path.join(codexHome, "hooks/kb-user-prompt-recall.sh")]); - assertSameMembers(hookMatchers(codexHooks, "PreToolUse"), ["Edit|Write|apply_patch", "Bash"]); - assertSameMembers(hookCommands(codexHooks, "PreToolUse"), [ + path.join(claudeHome, 'hooks/user-prompt-submit-recall.sh'), + path.join(claudeHome, 'hooks/pre-tool-use-edit-recall.sh'), + path.join(claudeHome, 'hooks/pre-tool-use-git-commit-capture.sh'), + path.join(claudeHome, 'hooks/stop-session-digest.sh'), + path.join(codexHome, 'hooks/kb-user-prompt-recall.sh'), + path.join(codexHome, 'hooks/pre-tool-use-edit-recall.sh'), + path.join(codexHome, 'hooks/pre-tool-use-git-commit-capture.sh'), + path.join(codexHome, 'hooks/kb-stop-digest.sh'), + path.join(claudeHome, 'skills/council/council.py'), + path.join(codexHome, 'skills/council/council.py'), + ]) + assert.ok((await import('node:fs')).statSync(file).mode & 0o111, `installer made hook executable: ${file}`) + assertContains( + await readFile(path.join(claudeHome, '.knowledge-system-version'), 'utf8'), + 'scope=user', + 'managed:', + 'hooks/user-prompt-submit-recall.sh', + 'commands/speckit.analyze.md', + ) + assertContains( + await readFile(path.join(codexHome, '.knowledge-system-version'), 'utf8'), + 'agent=codex', + 'scope=user', + 'hooks.json', + 'skills/speckit-analyze/SKILL.md', + ) + assert.equal(existsSync(path.join(dir, 'write-claude/.specify')), false) + assert.equal(existsSync(path.join(dir, 'write-codex/.specify')), false) + const codexHooks = JSON.parse(await readFile(path.join(codexHome, 'hooks.json'), 'utf8')) + assert.deepEqual(hookCommands(codexHooks, 'UserPromptSubmit'), [ + path.join(codexHome, 'hooks/kb-user-prompt-recall.sh'), + ]) + assertSameMembers(hookMatchers(codexHooks, 'PreToolUse'), ['Edit|Write|apply_patch', 'Bash']) + assertSameMembers(hookCommands(codexHooks, 'PreToolUse'), [ `env KB_AUTO_MCP_HOME=${codexHome} ${codexHome}/hooks/pre-tool-use-edit-recall.sh`, `env KB_AUTO_MCP_HOME=${codexHome} KB_AUTO_MCP_SOURCE=codex:auto-capture:git-commit KB_AUTO_MCP_CLIENT_NAME=Codex ${codexHome}/hooks/pre-tool-use-git-commit-capture.sh`, - ]); - assert.deepEqual(hookCommands(codexHooks, "Stop"), [path.join(codexHome, "hooks/kb-stop-digest.sh")]); - const uninstallResult = await runProcess("bash", [installer, "--agent", "all", "--uninstall"], { env }); - assert.equal(uninstallResult.exitCode, 0, uninstallResult.stderr); - for (const file of [...claudeFiles, ...codexFiles]) assert.equal(existsSync(file), false, `uninstall removed ${file}`); -}); - -test("installer project scope writes claude and codex files under project root", async () => { - const dir = await tempDir(); - const installer = repoPath(manifest.installer.path); - const projectRoot = path.join(dir, "project-root"); - await mkdir(projectRoot, { recursive: true }); - const ignoredClaudeHome = path.join(dir, "ignored-claude"); - const ignoredCodexHome = path.join(dir, "ignored-codex"); - const env = { AGENT_KIT_PROJECT_ROOT: projectRoot, CLAUDE_CONFIG_DIR: ignoredClaudeHome, CODEX_HOME: ignoredCodexHome }; - const installResult = await runProcess("bash", [installer, "--agent", "all", "--scope", "project"], { env }); - assert.equal(installResult.exitCode, 0, installResult.stderr); - assertContains(installResult.stdout, "knowledge-system installer complete", "agent=all", "scope=project"); - const claudeHome = path.join(projectRoot, ".claude"); - const codexHome = path.join(projectRoot, ".codex"); + ]) + assert.deepEqual(hookCommands(codexHooks, 'Stop'), [path.join(codexHome, 'hooks/kb-stop-digest.sh')]) + const uninstallResult = await runProcess('bash', [installer, '--agent', 'all', '--uninstall'], { env }) + assert.equal(uninstallResult.exitCode, 0, uninstallResult.stderr) + for (const file of [...claudeFiles, ...codexFiles]) assert.equal(existsSync(file), false, `uninstall removed ${file}`) +}) + +test('installer project scope writes claude and codex files under project root', async () => { + const dir = await tempDir() + const installer = repoPath(manifest.installer.path) + const projectRoot = path.join(dir, 'project-root') + await mkdir(projectRoot, { recursive: true }) + const ignoredClaudeHome = path.join(dir, 'ignored-claude') + const ignoredCodexHome = path.join(dir, 'ignored-codex') + const env = { + AGENT_KIT_PROJECT_ROOT: projectRoot, + CLAUDE_CONFIG_DIR: ignoredClaudeHome, + CODEX_HOME: ignoredCodexHome, + } + const installResult = await runProcess('bash', [installer, '--agent', 'all', '--scope', 'project'], { env }) + assert.equal(installResult.exitCode, 0, installResult.stderr) + assertContains(installResult.stdout, 'knowledge-system installer complete', 'agent=all', 'scope=project') + const claudeHome = path.join(projectRoot, '.claude') + const codexHome = path.join(projectRoot, '.codex') const managedFiles = [ - path.join(claudeHome, "hooks/user-prompt-submit-recall.sh"), - path.join(claudeHome, "hooks/pre-tool-use-edit-recall.sh"), - path.join(claudeHome, "hooks/pre-tool-use-git-commit-capture.sh"), - path.join(claudeHome, "hooks/stop-session-digest.sh"), - path.join(claudeHome, "commands/speckit.analyze.md"), - path.join(claudeHome, "commands/speckit.taskstoissues.md"), - path.join(claudeHome, "skills/kb-first/SKILL.md"), - path.join(claudeHome, ".knowledge-system-allowlist"), - path.join(claudeHome, ".knowledge-system-version"), - path.join(codexHome, "hooks/kb-user-prompt-recall.sh"), - path.join(codexHome, "hooks/pre-tool-use-edit-recall.sh"), - path.join(codexHome, "hooks/pre-tool-use-git-commit-capture.sh"), - path.join(codexHome, "hooks/kb-stop-digest.sh"), - path.join(codexHome, "skills/speckit-analyze/SKILL.md"), - path.join(codexHome, "skills/speckit-taskstoissues/SKILL.md"), - path.join(codexHome, "skills/kb-first/SKILL.md"), - path.join(codexHome, ".knowledge-system-allowlist"), - path.join(codexHome, ".knowledge-system-version"), - path.join(codexHome, "hooks.json"), - ]; - const specifySeedFiles = manifest.specify.project_seed.targets.map((target) => path.join(projectRoot, target.path)); - for (const file of [...managedFiles, ...specifySeedFiles]) assert.ok(existsSync(file), `project-scope installer wrote ${file}`); - assert.ok((await import("node:fs")).statSync(path.join(projectRoot, ".specify/scripts/bash/check-prerequisites.sh")).mode & 0o111); - assert.equal((await readFile(path.join(projectRoot, ".specify/memory/constitution.md"), "utf8")).trimEnd(), (await readRepoText(".specify/memory/constitution.md")).trimEnd()); - assertContains(await readFile(path.join(claudeHome, ".knowledge-system-version"), "utf8"), "scope=project"); - assertContains(await readFile(path.join(codexHome, ".knowledge-system-version"), "utf8"), "scope=project"); - assert.equal(existsSync(ignoredClaudeHome), false); - assert.equal(existsSync(ignoredCodexHome), false); - const uninstallResult = await runProcess("bash", [installer, "--agent", "all", "--scope", "project", "--uninstall"], { env }); - assert.equal(uninstallResult.exitCode, 0, uninstallResult.stderr); - for (const file of managedFiles) assert.equal(existsSync(file), false, `project-scope uninstall removed ${file}`); - for (const file of specifySeedFiles) assert.ok(existsSync(file), `project-scope uninstall should preserve project-owned Spec Kit seed ${file}`); -}); - -test("installed recall hooks parse payloads dedupe and honor allowlist", async () => { - const dir = await tempDir(); - const { claudeHome, codexHome, installEnvironment } = await installAgentKit(dir, "hook"); - const fakeBin = path.join(dir, "bin"); - await mkdir(fakeBin, { recursive: true }); - const curlLog = path.join(dir, "curl-payloads.jsonl"); - await writeExecutable(path.join(fakeBin, "curl"), [ - "#!/usr/bin/env bash", - 'while [ "$#" -gt 0 ]; do', - ' case "$1" in', - ' -d) shift; printf \'%s\\n\' "$1" >> "${CURL_CAPTURE_FILE}" ;;', - " esac", - " shift || true", - "done", - "cat <<'JSON'", - '{"jsonrpc":"2.0","result":{"structuredContent":{"hits":[{"title":"Prior capture","scope":"project:personal-stack","score":0.92,"id":"01H","snippet":"Remember this module"}]}}}', - "JSON", - ].join("\n")); - const hookEnvironment = { ...installEnvironment, KB_BEARER_TOKEN: "test-token", KB_URL: "http://knowledge.local", CURL_CAPTURE_FILE: curlLog, PATH: `${fakeBin}:${process.env.PATH}` }; - const promptCurlLog = path.join(dir, "prompt-curl-payloads.jsonl"); - const promptResult = await runProcess(path.join(claudeHome, "hooks/user-prompt-submit-recall.sh"), [], { + path.join(claudeHome, 'hooks/user-prompt-submit-recall.sh'), + path.join(claudeHome, 'hooks/pre-tool-use-edit-recall.sh'), + path.join(claudeHome, 'hooks/pre-tool-use-git-commit-capture.sh'), + path.join(claudeHome, 'hooks/stop-session-digest.sh'), + path.join(claudeHome, 'commands/speckit.analyze.md'), + path.join(claudeHome, 'commands/speckit.taskstoissues.md'), + path.join(claudeHome, 'skills/kb-first/SKILL.md'), + path.join(claudeHome, '.knowledge-system-allowlist'), + path.join(claudeHome, '.knowledge-system-version'), + path.join(codexHome, 'hooks/kb-user-prompt-recall.sh'), + path.join(codexHome, 'hooks/pre-tool-use-edit-recall.sh'), + path.join(codexHome, 'hooks/pre-tool-use-git-commit-capture.sh'), + path.join(codexHome, 'hooks/kb-stop-digest.sh'), + path.join(codexHome, 'skills/speckit-analyze/SKILL.md'), + path.join(codexHome, 'skills/speckit-taskstoissues/SKILL.md'), + path.join(codexHome, 'skills/kb-first/SKILL.md'), + path.join(codexHome, '.knowledge-system-allowlist'), + path.join(codexHome, '.knowledge-system-version'), + path.join(codexHome, 'hooks.json'), + ] + const specifySeedFiles = manifest.specify.project_seed.targets.map((target) => path.join(projectRoot, target.path)) + for (const file of [...managedFiles, ...specifySeedFiles]) + assert.ok(existsSync(file), `project-scope installer wrote ${file}`) + assert.ok( + (await import('node:fs')).statSync(path.join(projectRoot, '.specify/scripts/bash/check-prerequisites.sh')).mode & + 0o111, + ) + assert.equal( + (await readFile(path.join(projectRoot, '.specify/memory/constitution.md'), 'utf8')).trimEnd(), + (await readRepoText('.specify/memory/constitution.md')).trimEnd(), + ) + assertContains(await readFile(path.join(claudeHome, '.knowledge-system-version'), 'utf8'), 'scope=project') + assertContains(await readFile(path.join(codexHome, '.knowledge-system-version'), 'utf8'), 'scope=project') + assert.equal(existsSync(ignoredClaudeHome), false) + assert.equal(existsSync(ignoredCodexHome), false) + const uninstallResult = await runProcess('bash', [installer, '--agent', 'all', '--scope', 'project', '--uninstall'], { + env, + }) + assert.equal(uninstallResult.exitCode, 0, uninstallResult.stderr) + for (const file of managedFiles) assert.equal(existsSync(file), false, `project-scope uninstall removed ${file}`) + for (const file of specifySeedFiles) + assert.ok(existsSync(file), `project-scope uninstall should preserve project-owned Spec Kit seed ${file}`) +}) + +test('installed recall hooks parse payloads dedupe and honor allowlist', async () => { + const dir = await tempDir() + const { claudeHome, codexHome, installEnvironment } = await installAgentKit(dir, 'hook') + const fakeBin = path.join(dir, 'bin') + await mkdir(fakeBin, { recursive: true }) + const curlLog = path.join(dir, 'curl-payloads.jsonl') + await writeExecutable( + path.join(fakeBin, 'curl'), + [ + '#!/usr/bin/env bash', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -d) shift; printf \'%s\\n\' "$1" >> "${CURL_CAPTURE_FILE}" ;;', + ' esac', + ' shift || true', + 'done', + "cat <<'JSON'", + '{"jsonrpc":"2.0","result":{"structuredContent":{"hits":[{"title":"Prior capture","scope":"project:personal-stack","score":0.92,"id":"01H","snippet":"Remember this module"}]}}}', + 'JSON', + ].join('\n'), + ) + const hookEnvironment = { + ...installEnvironment, + KB_BEARER_TOKEN: 'test-token', + KB_URL: 'http://knowledge.local', + CURL_CAPTURE_FILE: curlLog, + PATH: `${fakeBin}:${process.env.PATH}`, + } + const promptCurlLog = path.join(dir, 'prompt-curl-payloads.jsonl') + const promptResult = await runProcess(path.join(claudeHome, 'hooks/user-prompt-submit-recall.sh'), [], { env: { ...hookEnvironment, CURL_CAPTURE_FILE: promptCurlLog }, input: `{"user_prompt":"Please improve the personal-stack agent kit memory hooks and installer validation coverage."}`, - }); - assert.equal(promptResult.exitCode, 0, promptResult.stderr); - assertContains(promptResult.stdout, "Knowledge base", "Prior capture", "score 0.92"); - const promptArgs = toolArguments((await readCurlPayloads(promptCurlLog))[0]); - assertContains(promptArgs.query, "personal-stack agent kit memory hooks"); - assert.equal(promptArgs.mode, "hybrid"); - assert.equal(promptArgs.limit, 3); - - const claudeResult = await runProcess(path.join(claudeHome, "hooks/pre-tool-use-edit-recall.sh"), [], { - env: { ...hookEnvironment, CLAUDE_SESSION_ID: "claude-edit-session" }, + }) + assert.equal(promptResult.exitCode, 0, promptResult.stderr) + assertContains(promptResult.stdout, 'Knowledge base', 'Prior capture', 'score 0.92') + const promptArgs = toolArguments((await readCurlPayloads(promptCurlLog))[0]) + assertContains(promptArgs.query, 'personal-stack agent kit memory hooks') + assert.equal(promptArgs.mode, 'hybrid') + assert.equal(promptArgs.limit, 3) + + const claudeResult = await runProcess(path.join(claudeHome, 'hooks/pre-tool-use-edit-recall.sh'), [], { + env: { ...hookEnvironment, CLAUDE_SESSION_ID: 'claude-edit-session' }, input: `{"tool_input":{"file_path":"platform/agents/kit/manifest.yaml"}}`, - }); - assert.equal(claudeResult.exitCode, 0, claudeResult.stderr); - const claudeArgs = toolArguments((await readCurlPayloads(curlLog))[0]); - assertContains(claudeArgs.query, "manifest.yaml", "platform/agents/kit/manifest.yaml"); - assert.equal(claudeArgs.scope, "project:personal-stack"); - assert.equal(claudeArgs.mode, "hybrid"); - assert.equal(claudeArgs.limit, 2); - assertContains(claudeResult.stdout, "Related captures for this file", "Prior capture"); - await runProcess(path.join(claudeHome, "hooks/pre-tool-use-edit-recall.sh"), [], { - env: { ...hookEnvironment, CLAUDE_SESSION_ID: "claude-edit-session" }, + }) + assert.equal(claudeResult.exitCode, 0, claudeResult.stderr) + const claudeArgs = toolArguments((await readCurlPayloads(curlLog))[0]) + assertContains(claudeArgs.query, 'manifest.yaml', 'platform/agents/kit/manifest.yaml') + assert.equal(claudeArgs.scope, 'project:personal-stack') + assert.equal(claudeArgs.mode, 'hybrid') + assert.equal(claudeArgs.limit, 2) + assertContains(claudeResult.stdout, 'Related captures for this file', 'Prior capture') + await runProcess(path.join(claudeHome, 'hooks/pre-tool-use-edit-recall.sh'), [], { + env: { ...hookEnvironment, CLAUDE_SESSION_ID: 'claude-edit-session' }, input: `{"tool_input":{"file_path":"platform/agents/kit/manifest.yaml"}}`, - }); - assert.equal((await readCurlPayloads(curlLog)).length, 1); + }) + assert.equal((await readCurlPayloads(curlLog)).length, 1) - const codexResult = await runProcess(path.join(codexHome, "hooks/pre-tool-use-edit-recall.sh"), [], { - env: { ...hookEnvironment, KB_AUTO_MCP_HOME: codexHome, CODEX_THREAD_ID: "codex-edit-session" }, + const codexResult = await runProcess(path.join(codexHome, 'hooks/pre-tool-use-edit-recall.sh'), [], { + env: { ...hookEnvironment, KB_AUTO_MCP_HOME: codexHome, CODEX_THREAD_ID: 'codex-edit-session' }, input: `{"tool_input":{"file_path":"platform/agents/kit/render-agent-kit.py"}}`, - }); - assert.equal(codexResult.exitCode, 0, codexResult.stderr); - assertContains(await readFile(path.join(codexHome, "hooks/pre-tool-use-edit-recall.sh"), "utf8"), "Related captures for this file"); - const codexArgs = toolArguments((await readCurlPayloads(curlLog)).at(-1)); - assertContains(codexArgs.query, "render-agent-kit.py", "platform/agents/kit/render-agent-kit.py"); - assert.equal(codexArgs.scope, "project:personal-stack"); - await runProcess(path.join(codexHome, "hooks/pre-tool-use-edit-recall.sh"), [], { - env: { ...hookEnvironment, KB_AUTO_MCP_HOME: codexHome, CODEX_THREAD_ID: "codex-secret-session" }, + }) + assert.equal(codexResult.exitCode, 0, codexResult.stderr) + assertContains( + await readFile(path.join(codexHome, 'hooks/pre-tool-use-edit-recall.sh'), 'utf8'), + 'Related captures for this file', + ) + const codexArgs = toolArguments((await readCurlPayloads(curlLog)).at(-1)) + assertContains(codexArgs.query, 'render-agent-kit.py', 'platform/agents/kit/render-agent-kit.py') + assert.equal(codexArgs.scope, 'project:personal-stack') + await runProcess(path.join(codexHome, 'hooks/pre-tool-use-edit-recall.sh'), [], { + env: { ...hookEnvironment, KB_AUTO_MCP_HOME: codexHome, CODEX_THREAD_ID: 'codex-secret-session' }, input: `{"tool_input":{"file_path":".env"}}`, - }); - assert.equal((await readCurlPayloads(curlLog)).length, 2); -}); - -test("installed capture hooks parse commits and stop digest sessions", async () => { - const dir = await tempDir(); - const { claudeHome, codexHome, installEnvironment } = await installAgentKit(dir, "capture"); - const fakeBin = path.join(dir, "capture-bin"); - await mkdir(fakeBin, { recursive: true }); - await writeExecutable(path.join(fakeBin, "curl"), [ - "#!/usr/bin/env bash", - 'payload=""', - 'while [ "$#" -gt 0 ]; do', - ' case "$1" in', - ' -d) shift; payload="$1"; printf \'%s\\n\' "${payload}" >> "${CURL_CAPTURE_FILE}" ;;', - " esac", - " shift || true", - "done", - "python3 - \"$payload\" <<'PY'", - "import json, sys", - "payload = json.loads(sys.argv[1])", - 'name = payload["params"]["name"]', - 'args = payload["params"].get("arguments", {})', - 'if name == "knowledge.digest_transcript":', - ' transcript = args.get("transcript", "")', - ' is_codex = "Codex stop transcript" in transcript', - ' print(json.dumps({"jsonrpc":"2.0","result":{"structuredContent":{"candidates":[{"title":"Codex digest lesson" if is_codex else "Claude digest lesson","body":"Capture Codex stop lessons without duplicates." if is_codex else "Capture Claude stop lessons without duplicates.","suggested_topic":"" if is_codex else "agent-tools","suggested_tags":["agent-kit","hooks"]}]}}}))', - 'elif name == "knowledge.recall":', - ' print(json.dumps({"jsonrpc":"2.0","result":{"structuredContent":{"hits":[]}}}))', - "else:", - ' print(json.dumps({"jsonrpc":"2.0","result":{"structuredContent":{"id":"01HOOK"}}}))', - "PY", - ].join("\n")); - const hookEnvironment = { ...installEnvironment, KB_BEARER_TOKEN: "test-token", KB_URL: "http://knowledge.local", PATH: `${fakeBin}:${process.env.PATH}` }; - const commitCurlLog = path.join(dir, "commit-curl-payloads.jsonl"); - const commitEnvironment = { ...hookEnvironment, CURL_CAPTURE_FILE: commitCurlLog }; - const claudeCommit = await runProcess(path.join(claudeHome, "hooks/pre-tool-use-git-commit-capture.sh"), [], { + }) + assert.equal((await readCurlPayloads(curlLog)).length, 2) +}) + +test('installed capture hooks parse commits and stop digest sessions', async () => { + const dir = await tempDir() + const { claudeHome, codexHome, installEnvironment } = await installAgentKit(dir, 'capture') + const fakeBin = path.join(dir, 'capture-bin') + await mkdir(fakeBin, { recursive: true }) + await writeExecutable( + path.join(fakeBin, 'curl'), + [ + '#!/usr/bin/env bash', + 'payload=""', + 'while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -d) shift; payload="$1"; printf \'%s\\n\' "${payload}" >> "${CURL_CAPTURE_FILE}" ;;', + ' esac', + ' shift || true', + 'done', + 'python3 - "$payload" <<\'PY\'', + 'import json, sys', + 'payload = json.loads(sys.argv[1])', + 'name = payload["params"]["name"]', + 'args = payload["params"].get("arguments", {})', + 'if name == "knowledge.digest_transcript":', + ' transcript = args.get("transcript", "")', + ' is_codex = "Codex stop transcript" in transcript', + ' print(json.dumps({"jsonrpc":"2.0","result":{"structuredContent":{"candidates":[{"title":"Codex digest lesson" if is_codex else "Claude digest lesson","body":"Capture Codex stop lessons without duplicates." if is_codex else "Capture Claude stop lessons without duplicates.","suggested_topic":"" if is_codex else "agent-tools","suggested_tags":["agent-kit","hooks"]}]}}}))', + 'elif name == "knowledge.recall":', + ' print(json.dumps({"jsonrpc":"2.0","result":{"structuredContent":{"hits":[]}}}))', + 'else:', + ' print(json.dumps({"jsonrpc":"2.0","result":{"structuredContent":{"id":"01HOOK"}}}))', + 'PY', + ].join('\n'), + ) + const hookEnvironment = { + ...installEnvironment, + KB_BEARER_TOKEN: 'test-token', + KB_URL: 'http://knowledge.local', + PATH: `${fakeBin}:${process.env.PATH}`, + } + const commitCurlLog = path.join(dir, 'commit-curl-payloads.jsonl') + const commitEnvironment = { ...hookEnvironment, CURL_CAPTURE_FILE: commitCurlLog } + const claudeCommit = await runProcess(path.join(claudeHome, 'hooks/pre-tool-use-git-commit-capture.sh'), [], { env: commitEnvironment, input: `{"tool_name":"Bash","tool_input":{"command":"git commit -m \\"Add hook capture smoke\\""}}`, - }); - assert.equal(claudeCommit.exitCode, 0, claudeCommit.stderr); - const claudeCommitArgs = toolArguments((await readCurlPayloads(commitCurlLog))[0]); - assert.equal(claudeCommitArgs.title, "Add hook capture smoke"); - assert.equal(claudeCommitArgs.scope, "project:personal-stack"); - assert.equal(claudeCommitArgs.source, "claude-code:auto-capture:git-commit"); - assertContains(claudeCommitArgs.body, "Claude PreToolUse", "`git commit` hook"); - assert.deepEqual(claudeCommitArgs.tags, ["auto-capture", "git-commit"]); - - const codexCommit = await runProcess(path.join(codexHome, "hooks/pre-tool-use-git-commit-capture.sh"), [], { - env: { ...commitEnvironment, KB_AUTO_MCP_CLIENT_NAME: "Codex", KB_AUTO_MCP_SOURCE: "codex:auto-capture:git-commit" }, + }) + assert.equal(claudeCommit.exitCode, 0, claudeCommit.stderr) + const claudeCommitArgs = toolArguments((await readCurlPayloads(commitCurlLog))[0]) + assert.equal(claudeCommitArgs.title, 'Add hook capture smoke') + assert.equal(claudeCommitArgs.scope, 'project:personal-stack') + assert.equal(claudeCommitArgs.source, 'claude-code:auto-capture:git-commit') + assertContains(claudeCommitArgs.body, 'Claude PreToolUse', '`git commit` hook') + assert.deepEqual(claudeCommitArgs.tags, ['auto-capture', 'git-commit']) + + const codexCommit = await runProcess(path.join(codexHome, 'hooks/pre-tool-use-git-commit-capture.sh'), [], { + env: { + ...commitEnvironment, + KB_AUTO_MCP_CLIENT_NAME: 'Codex', + KB_AUTO_MCP_SOURCE: 'codex:auto-capture:git-commit', + }, input: `{"tool":{"name":"Bash","input":{"cmd":"git commit -m 'Add Codex commit capture'"}}}`, - }); - assert.equal(codexCommit.exitCode, 0, codexCommit.stderr); - const codexCommitArgs = toolArguments((await readCurlPayloads(commitCurlLog)).at(-1)); - assert.equal(codexCommitArgs.title, "Add Codex commit capture"); - assert.equal(codexCommitArgs.scope, "project:personal-stack"); - assert.equal(codexCommitArgs.source, "codex:auto-capture:git-commit"); - assertContains(codexCommitArgs.body, "Codex", "`git commit` hook"); - await runProcess(path.join(claudeHome, "hooks/pre-tool-use-git-commit-capture.sh"), [], { + }) + assert.equal(codexCommit.exitCode, 0, codexCommit.stderr) + const codexCommitArgs = toolArguments((await readCurlPayloads(commitCurlLog)).at(-1)) + assert.equal(codexCommitArgs.title, 'Add Codex commit capture') + assert.equal(codexCommitArgs.scope, 'project:personal-stack') + assert.equal(codexCommitArgs.source, 'codex:auto-capture:git-commit') + assertContains(codexCommitArgs.body, 'Codex', '`git commit` hook') + await runProcess(path.join(claudeHome, 'hooks/pre-tool-use-git-commit-capture.sh'), [], { env: commitEnvironment, input: `{"tool_name":"Bash","tool_input":{"command":"git commit -m \\"WIP scratch\\""}}`, - }); - assert.equal((await readCurlPayloads(commitCurlLog)).length, 2); - - const claudeTranscript = path.join(dir, "claude-transcript.jsonl"); - await writeFile(claudeTranscript, `{"role":"user","content":"Claude stop transcript should create a durable lesson."}\n`); - const claudeStopCurlLog = path.join(dir, "claude-stop-curl-payloads.jsonl"); - const claudeStopInput = JSON.stringify({ session_id: "claude-stop-session", transcript_path: claudeTranscript }); - const claudeStop = await runProcess(path.join(claudeHome, "hooks/stop-session-digest.sh"), [], { - env: { ...hookEnvironment, CURL_CAPTURE_FILE: claudeStopCurlLog, KB_DIGEST_MAX_CAPTURES: "1" }, + }) + assert.equal((await readCurlPayloads(commitCurlLog)).length, 2) + + const claudeTranscript = path.join(dir, 'claude-transcript.jsonl') + await writeFile( + claudeTranscript, + `{"role":"user","content":"Claude stop transcript should create a durable lesson."}\n`, + ) + const claudeStopCurlLog = path.join(dir, 'claude-stop-curl-payloads.jsonl') + const claudeStopInput = JSON.stringify({ session_id: 'claude-stop-session', transcript_path: claudeTranscript }) + const claudeStop = await runProcess(path.join(claudeHome, 'hooks/stop-session-digest.sh'), [], { + env: { ...hookEnvironment, CURL_CAPTURE_FILE: claudeStopCurlLog, KB_DIGEST_MAX_CAPTURES: '1' }, input: claudeStopInput, - }); - assert.equal(claudeStop.exitCode, 0, claudeStop.stderr); - const claudeStopPayloads = await readCurlPayloads(claudeStopCurlLog); - assert.deepEqual(claudeStopPayloads.map(toolName), ["knowledge.digest_transcript", "knowledge.recall", "knowledge.capture_lesson"]); - assertContains(toolArguments(claudeStopPayloads[0]).transcript, "Claude stop transcript"); - assert.equal(toolArguments(claudeStopPayloads[0]).max_candidates, 1); - assert.equal(toolArguments(claudeStopPayloads[1]).mode, "hybrid"); - const claudeCaptureArgs = toolArguments(claudeStopPayloads[2]); - assert.equal(claudeCaptureArgs.title, "Claude digest lesson"); - assert.equal(claudeCaptureArgs.scope, "topic:agent-tools"); - assert.equal(claudeCaptureArgs.source, "claude-code:auto-digest:claude-stop-session"); - assert.equal(claudeCaptureArgs.session_id, "claude-stop-session"); - assert.deepEqual(claudeCaptureArgs.tags, ["agent-kit", "hooks"]); - assert.equal((await readFile(path.join(claudeHome, "state/sessions/claude-stop-session/digest-budget"), "utf8")).trim(), "0"); - await runProcess(path.join(claudeHome, "hooks/stop-session-digest.sh"), [], { - env: { ...hookEnvironment, CURL_CAPTURE_FILE: claudeStopCurlLog, KB_DIGEST_MAX_CAPTURES: "1" }, + }) + assert.equal(claudeStop.exitCode, 0, claudeStop.stderr) + const claudeStopPayloads = await readCurlPayloads(claudeStopCurlLog) + assert.deepEqual(claudeStopPayloads.map(toolName), [ + 'knowledge.digest_transcript', + 'knowledge.recall', + 'knowledge.capture_lesson', + ]) + assertContains(toolArguments(claudeStopPayloads[0]).transcript, 'Claude stop transcript') + assert.equal(toolArguments(claudeStopPayloads[0]).max_candidates, 1) + assert.equal(toolArguments(claudeStopPayloads[1]).mode, 'hybrid') + const claudeCaptureArgs = toolArguments(claudeStopPayloads[2]) + assert.equal(claudeCaptureArgs.title, 'Claude digest lesson') + assert.equal(claudeCaptureArgs.scope, 'topic:agent-tools') + assert.equal(claudeCaptureArgs.source, 'claude-code:auto-digest:claude-stop-session') + assert.equal(claudeCaptureArgs.session_id, 'claude-stop-session') + assert.deepEqual(claudeCaptureArgs.tags, ['agent-kit', 'hooks']) + assert.equal( + (await readFile(path.join(claudeHome, 'state/sessions/claude-stop-session/digest-budget'), 'utf8')).trim(), + '0', + ) + await runProcess(path.join(claudeHome, 'hooks/stop-session-digest.sh'), [], { + env: { ...hookEnvironment, CURL_CAPTURE_FILE: claudeStopCurlLog, KB_DIGEST_MAX_CAPTURES: '1' }, input: claudeStopInput, - }); - assert.equal((await readCurlPayloads(claudeStopCurlLog)).length, 3); - - const codexTranscript = path.join(dir, "codex-transcript.jsonl"); - await writeFile(codexTranscript, `{"source":"user","message":"Codex stop transcript should fall back to project scope."}\n`); - const codexStopCurlLog = path.join(dir, "codex-stop-curl-payloads.jsonl"); - const codexStop = await runProcess(path.join(codexHome, "hooks/kb-stop-digest.sh"), [], { - env: { ...hookEnvironment, CODEX_HOME: codexHome, CURL_CAPTURE_FILE: codexStopCurlLog, KB_DIGEST_MAX_CAPTURES: "1" }, - input: JSON.stringify({ thread_id: "codex-stop-session", transcriptPath: codexTranscript }), - }); - assert.equal(codexStop.exitCode, 0, codexStop.stderr); - const codexStopPayloads = await readCurlPayloads(codexStopCurlLog); - assert.deepEqual(codexStopPayloads.map(toolName), ["knowledge.digest_transcript", "knowledge.recall", "knowledge.capture_lesson"]); - const codexCaptureArgs = toolArguments(codexStopPayloads[2]); - assert.equal(codexCaptureArgs.title, "Codex digest lesson"); - assert.equal(codexCaptureArgs.scope, "project:personal-stack"); - assert.equal(codexCaptureArgs.source, "codex:auto-digest:codex-stop-session"); - assert.equal(codexCaptureArgs.session_id, "codex-stop-session"); - assert.equal((await readFile(path.join(codexHome, "state/sessions/codex-stop-session/digest-budget"), "utf8")).trim(), "0"); -}); - -test("installed hooks stay silent when disabled or unauthenticated", async () => { - const dir = await tempDir(); - const { claudeHome, codexHome, installEnvironment } = await installAgentKit(dir, "silent"); - const curlLog = path.join(dir, "silent-curl-calls.log"); - const fakeBin = path.join(dir, "silent-bin"); - await mkdir(fakeBin, { recursive: true }); - await writeExecutable(path.join(fakeBin, "curl"), [ - "#!/usr/bin/env bash", - 'printf \'called\\n\' >> "${CURL_CAPTURE_FILE}"', - "cat <<'JSON'", - '{"jsonrpc":"2.0","result":{"structuredContent":{"hits":[],"candidates":[]}}}', - "JSON", - ].join("\n")); - const claudeTranscript = path.join(dir, "silent-claude-transcript.jsonl"); - const codexTranscript = path.join(dir, "silent-codex-transcript.jsonl"); - await writeFile(claudeTranscript, `{"role":"user","content":"Claude stop transcript should not call MCP when disabled."}\n`); - await writeFile(codexTranscript, `{"source":"user","message":"Codex stop transcript should not call MCP when disabled."}\n`); + }) + assert.equal((await readCurlPayloads(claudeStopCurlLog)).length, 3) + + const codexTranscript = path.join(dir, 'codex-transcript.jsonl') + await writeFile( + codexTranscript, + `{"source":"user","message":"Codex stop transcript should fall back to project scope."}\n`, + ) + const codexStopCurlLog = path.join(dir, 'codex-stop-curl-payloads.jsonl') + const codexStop = await runProcess(path.join(codexHome, 'hooks/kb-stop-digest.sh'), [], { + env: { + ...hookEnvironment, + CODEX_HOME: codexHome, + CURL_CAPTURE_FILE: codexStopCurlLog, + KB_DIGEST_MAX_CAPTURES: '1', + }, + input: JSON.stringify({ thread_id: 'codex-stop-session', transcriptPath: codexTranscript }), + }) + assert.equal(codexStop.exitCode, 0, codexStop.stderr) + const codexStopPayloads = await readCurlPayloads(codexStopCurlLog) + assert.deepEqual(codexStopPayloads.map(toolName), [ + 'knowledge.digest_transcript', + 'knowledge.recall', + 'knowledge.capture_lesson', + ]) + const codexCaptureArgs = toolArguments(codexStopPayloads[2]) + assert.equal(codexCaptureArgs.title, 'Codex digest lesson') + assert.equal(codexCaptureArgs.scope, 'project:personal-stack') + assert.equal(codexCaptureArgs.source, 'codex:auto-digest:codex-stop-session') + assert.equal(codexCaptureArgs.session_id, 'codex-stop-session') + assert.equal( + (await readFile(path.join(codexHome, 'state/sessions/codex-stop-session/digest-budget'), 'utf8')).trim(), + '0', + ) +}) + +test('installed hooks stay silent when disabled or unauthenticated', async () => { + const dir = await tempDir() + const { claudeHome, codexHome, installEnvironment } = await installAgentKit(dir, 'silent') + const curlLog = path.join(dir, 'silent-curl-calls.log') + const fakeBin = path.join(dir, 'silent-bin') + await mkdir(fakeBin, { recursive: true }) + await writeExecutable( + path.join(fakeBin, 'curl'), + [ + '#!/usr/bin/env bash', + 'printf \'called\\n\' >> "${CURL_CAPTURE_FILE}"', + "cat <<'JSON'", + '{"jsonrpc":"2.0","result":{"structuredContent":{"hits":[],"candidates":[]}}}', + 'JSON', + ].join('\n'), + ) + const claudeTranscript = path.join(dir, 'silent-claude-transcript.jsonl') + const codexTranscript = path.join(dir, 'silent-codex-transcript.jsonl') + await writeFile( + claudeTranscript, + `{"role":"user","content":"Claude stop transcript should not call MCP when disabled."}\n`, + ) + await writeFile( + codexTranscript, + `{"source":"user","message":"Codex stop transcript should not call MCP when disabled."}\n`, + ) const invocations = [ - [`{"user_prompt":"Recall should stay silent when disabled."}`, path.join(claudeHome, "hooks/user-prompt-submit-recall.sh"), {}], - [`{"user_prompt":"Recall should stay silent when disabled."}`, path.join(codexHome, "hooks/kb-user-prompt-recall.sh"), {}], - [`{"tool_input":{"file_path":"platform/agents/kit/manifest.yaml"}}`, path.join(claudeHome, "hooks/pre-tool-use-edit-recall.sh"), {}], - [`{"tool_input":{"file_path":"platform/agents/kit/manifest.yaml"}}`, path.join(codexHome, "hooks/pre-tool-use-edit-recall.sh"), { KB_AUTO_MCP_HOME: codexHome }], - [`{"tool_name":"Bash","tool_input":{"command":"git commit -m \\"Capture hook silence\\""}}`, path.join(claudeHome, "hooks/pre-tool-use-git-commit-capture.sh"), {}], - [`{"tool":{"name":"Bash","input":{"cmd":"git commit -m 'Capture Codex silence'"}}}`, path.join(codexHome, "hooks/pre-tool-use-git-commit-capture.sh"), { KB_AUTO_MCP_CLIENT_NAME: "Codex", KB_AUTO_MCP_SOURCE: "codex:auto-capture:git-commit" }], - [JSON.stringify({ session_id: "silent-claude", transcript_path: claudeTranscript }), path.join(claudeHome, "hooks/stop-session-digest.sh"), {}], - [JSON.stringify({ thread_id: "silent-codex", transcriptPath: codexTranscript }), path.join(codexHome, "hooks/kb-stop-digest.sh"), { CODEX_HOME: codexHome }], - ]; - const hookEnvironment = { ...installEnvironment, KB_URL: "http://knowledge.local", CURL_CAPTURE_FILE: curlLog, PATH: `${fakeBin}:${process.env.PATH}` }; + [ + `{"user_prompt":"Recall should stay silent when disabled."}`, + path.join(claudeHome, 'hooks/user-prompt-submit-recall.sh'), + {}, + ], + [ + `{"user_prompt":"Recall should stay silent when disabled."}`, + path.join(codexHome, 'hooks/kb-user-prompt-recall.sh'), + {}, + ], + [ + `{"tool_input":{"file_path":"platform/agents/kit/manifest.yaml"}}`, + path.join(claudeHome, 'hooks/pre-tool-use-edit-recall.sh'), + {}, + ], + [ + `{"tool_input":{"file_path":"platform/agents/kit/manifest.yaml"}}`, + path.join(codexHome, 'hooks/pre-tool-use-edit-recall.sh'), + { KB_AUTO_MCP_HOME: codexHome }, + ], + [ + `{"tool_name":"Bash","tool_input":{"command":"git commit -m \\"Capture hook silence\\""}}`, + path.join(claudeHome, 'hooks/pre-tool-use-git-commit-capture.sh'), + {}, + ], + [ + `{"tool":{"name":"Bash","input":{"cmd":"git commit -m 'Capture Codex silence'"}}}`, + path.join(codexHome, 'hooks/pre-tool-use-git-commit-capture.sh'), + { KB_AUTO_MCP_CLIENT_NAME: 'Codex', KB_AUTO_MCP_SOURCE: 'codex:auto-capture:git-commit' }, + ], + [ + JSON.stringify({ session_id: 'silent-claude', transcript_path: claudeTranscript }), + path.join(claudeHome, 'hooks/stop-session-digest.sh'), + {}, + ], + [ + JSON.stringify({ thread_id: 'silent-codex', transcriptPath: codexTranscript }), + path.join(codexHome, 'hooks/kb-stop-digest.sh'), + { CODEX_HOME: codexHome }, + ], + ] + const hookEnvironment = { + ...installEnvironment, + KB_URL: 'http://knowledge.local', + CURL_CAPTURE_FILE: curlLog, + PATH: `${fakeBin}:${process.env.PATH}`, + } for (const [input, command, env] of invocations) { - const result = await runProcess(command, [], { env: { ...hookEnvironment, ...env, KB_BEARER_TOKEN: "test-token", KB_AUTO_MCP_DISABLED: "1" }, input }); - assert.equal(result.exitCode, 0, result.stderr); + const result = await runProcess(command, [], { + env: { ...hookEnvironment, ...env, KB_BEARER_TOKEN: 'test-token', KB_AUTO_MCP_DISABLED: '1' }, + input, + }) + assert.equal(result.exitCode, 0, result.stderr) } - assert.equal(existsSync(curlLog), false); + assert.equal(existsSync(curlLog), false) for (const [input, command, env] of invocations) { - const result = await runProcess(command, [], { env: { ...hookEnvironment, ...env, KB_BEARER_TOKEN: "" }, input }); - assert.equal(result.exitCode, 0, result.stderr); + const result = await runProcess(command, [], { env: { ...hookEnvironment, ...env, KB_BEARER_TOKEN: '' }, input }) + assert.equal(result.exitCode, 0, result.stderr) } - assert.equal(existsSync(curlLog), false); -}); - -test("hook settings reference only manifest hooks", async () => { - const manifestHookPaths = new Set(manifestTargetPaths("hooks").filter((item) => item.includes("/hooks/"))); - const settingsHookPaths = new Set(); - for (const file of [".claude/settings.json", ".codex/hooks.json"]) { - const text = await readRepoText(file); - for (const match of text.matchAll(/\.(?:claude|codex)\/hooks\/[A-Za-z0-9._-]+\.sh/g)) settingsHookPaths.add(match[0]); + assert.equal(existsSync(curlLog), false) +}) + +test('hook settings reference only manifest hooks', async () => { + const manifestHookPaths = new Set(manifestTargetPaths('hooks').filter((item) => item.includes('/hooks/'))) + const settingsHookPaths = new Set() + for (const file of ['.claude/settings.json', '.codex/hooks.json']) { + const text = await readRepoText(file) + for (const match of text.matchAll(/\.(?:claude|codex)\/hooks\/[A-Za-z0-9._-]+\.sh/g)) + settingsHookPaths.add(match[0]) } - assertSameMembers(settingsHookPaths, manifestHookPaths, "checked-in hook settings must not reference scripts outside the agent-kit manifest"); -}); - -test("hook tool calls use canonical knowledge mcp names", async () => { - const knownKnowledgeTools = await canonicalKnowledgeToolNames(); - const hookFiles = [...new Set([...manifestTargetPaths("hooks"), manifest.installer.path])]; - const referencedTools = new Set(); - const legacyNames = []; + assertSameMembers( + settingsHookPaths, + manifestHookPaths, + 'checked-in hook settings must not reference scripts outside the agent-kit manifest', + ) +}) + +test('hook tool calls use canonical knowledge mcp names', async () => { + const knownKnowledgeTools = await canonicalKnowledgeToolNames() + const hookFiles = [...new Set([...manifestTargetPaths('hooks'), manifest.installer.path])] + const referencedTools = new Set() + const legacyNames = [] for (const file of hookFiles) { - const text = await readRepoText(file); - for (const match of text.matchAll(/["']name["']\s*:\s*["'](knowledge\.[A-Za-z_]+)["']/g)) referencedTools.add(match[1]); - for (const match of text.matchAll(/knowledge_(?:recall|capture_lesson|capture_decision|digest_transcript)/g)) legacyNames.push(`${file}:${match[0]}`); + const text = await readRepoText(file) + for (const match of text.matchAll(/["']name["']\s*:\s*["'](knowledge\.[A-Za-z_]+)["']/g)) + referencedTools.add(match[1]) + for (const match of text.matchAll(/knowledge_(?:recall|capture_lesson|capture_decision|digest_transcript)/g)) + legacyNames.push(`${file}:${match[0]}`) } - for (const tool of referencedTools) assert.ok(knownKnowledgeTools.has(tool), `${tool} should be a canonical knowledge tool`); - assert.deepEqual(legacyNames, []); - for (const hook of manifestItems("hooks")) { - for (const tool of hook.mcp_tools ?? []) assert.ok(knownKnowledgeTools.has(tool), `${hook.name} declares canonical tool ${tool}`); + for (const tool of referencedTools) + assert.ok(knownKnowledgeTools.has(tool), `${tool} should be a canonical knowledge tool`) + assert.deepEqual(legacyNames, []) + for (const hook of manifestItems('hooks')) { + for (const tool of hook.mcp_tools ?? []) + assert.ok(knownKnowledgeTools.has(tool), `${hook.name} declares canonical tool ${tool}`) } -}); +}) -test("recall injection hooks fall back to fast mode", async () => { - for (const file of [".claude/hooks/kb-user-prompt-recall.sh", ".codex/hooks/kb-user-prompt-recall.sh"]) { - const script = await readRepoText(file); - assertContains(script, `[ "\${mode}" != "fast" ]`, `call_recall "\${prompt}" "\${limit}" fast`); +test('recall injection hooks fall back to fast mode', async () => { + for (const file of ['.claude/hooks/kb-user-prompt-recall.sh', '.codex/hooks/kb-user-prompt-recall.sh']) { + const script = await readRepoText(file) + assertContains(script, `[ "\${mode}" != "fast" ]`, `call_recall "\${prompt}" "\${limit}" fast`) } - for (const file of [".claude/hooks/pre-tool-use-edit-recall.sh", ".codex/hooks/pre-tool-use-edit-recall.sh"]) { - const script = await readRepoText(file); - assertContains(script, `[ "\${mode}" != "fast" ]`, `call_recall "\${query}" "\${limit}" fast "\${scope}"`, `args["scope"] = sys.argv[4]`); + for (const file of ['.claude/hooks/pre-tool-use-edit-recall.sh', '.codex/hooks/pre-tool-use-edit-recall.sh']) { + const script = await readRepoText(file) + assertContains( + script, + `[ "\${mode}" != "fast" ]`, + `call_recall "\${query}" "\${limit}" fast "\${scope}"`, + `args["scope"] = sys.argv[4]`, + ) } - const installer = await readRepoText(manifest.installer.path); - assert.ok([...installer.matchAll(/call_recall "\$\{(?:prompt|query)}" "\$\{limit}" fast/g)].length >= 2); -}); - -test("renderer templates are declared in the manifest", async () => { - const templatePaths = await rendererTemplatePaths(); - const managedPathList = rendererManagedPathList(); - const managedPaths = new Set(managedPathList); - const pinnedPaths = new Set(collectPinnedPaths(manifest).map((item) => item.path)); - assert.equal(new Set(managedPathList).size, managedPathList.length, "renderer managed paths must not contain duplicates"); - assertSameMembers(templatePaths, managedPaths, "renderer templates must match manifest managed paths exactly"); - for (const managedPath of managedPaths) assert.ok(pinnedPaths.has(managedPath), `${managedPath} is pinned by manifest sha256`); -}); - -test("renderer include partials are declared and resolvable", async () => { - const declaredPartials = new Set(manifest.renderer.include_templates ?? []); - const referencedPartials = await rendererIncludeTemplatePaths(); - assertSameMembers(declaredPartials, referencedPartials, "renderer include partials must be explicit manifest inventory"); - for (const file of declaredPartials) assert.ok(existsSync(repoPath(file)), `renderer include partial exists: ${file}`); -}); - -test("renderer check passes and can render templates to a temp directory", async () => { - const dir = await tempDir(); - const renderer = repoPath(manifest.renderer.script_path); - assert.ok((await import("node:fs")).statSync(renderer).mode & 0o111, "agent-kit renderer should be directly executable"); - const checkResult = await runProcess(renderer, ["--check"]); + const installer = await readRepoText(manifest.installer.path) + assert.ok([...installer.matchAll(/call_recall "\$\{(?:prompt|query)}" "\$\{limit}" fast/g)].length >= 2) +}) + +test('renderer templates are declared in the manifest', async () => { + const templatePaths = await rendererTemplatePaths() + const managedPathList = rendererManagedPathList() + const managedPaths = new Set(managedPathList) + const pinnedPaths = new Set(collectPinnedPaths(manifest).map((item) => item.path)) + assert.equal( + new Set(managedPathList).size, + managedPathList.length, + 'renderer managed paths must not contain duplicates', + ) + assertSameMembers(templatePaths, managedPaths, 'renderer templates must match manifest managed paths exactly') + for (const managedPath of managedPaths) + assert.ok(pinnedPaths.has(managedPath), `${managedPath} is pinned by manifest sha256`) +}) + +test('renderer include partials are declared and resolvable', async () => { + const declaredPartials = new Set(manifest.renderer.include_templates ?? []) + const referencedPartials = await rendererIncludeTemplatePaths() + assertSameMembers( + declaredPartials, + referencedPartials, + 'renderer include partials must be explicit manifest inventory', + ) + for (const file of declaredPartials) assert.ok(existsSync(repoPath(file)), `renderer include partial exists: ${file}`) +}) + +test('renderer check passes and can render templates to a temp directory', async () => { + const dir = await tempDir() + const renderer = repoPath(manifest.renderer.script_path) + assert.ok( + (await import('node:fs')).statSync(renderer).mode & 0o111, + 'agent-kit renderer should be directly executable', + ) + const checkResult = await runProcess(renderer, ['--check']) if (checkResult.exitCode === 0) { - assertContains(checkResult.stdout, "agent kit render check passed"); + assertContains(checkResult.stdout, 'agent kit render check passed') } else { - const missingGeneratedSkillLines = `${checkResult.stdout}${checkResult.stderr}`.split("\n").filter(Boolean); - for (const line of missingGeneratedSkillLines) assert.match(line, /^missing: \.agents\/skills\/speckit-[^/]+\/SKILL\.md$/); - assert.equal(missingGeneratedSkillLines.length, [...await repoSkillPaths()].filter((item) => item.startsWith(".agents/skills/speckit-")).length); + const missingGeneratedSkillLines = `${checkResult.stdout}${checkResult.stderr}`.split('\n').filter(Boolean) + for (const line of missingGeneratedSkillLines) + assert.match(line, /^missing: \.agents\/skills\/speckit-[^/]+\/SKILL\.md$/) + assert.equal( + missingGeneratedSkillLines.length, + [...(await repoSkillPaths())].filter((item) => item.startsWith('.agents/skills/speckit-')).length, + ) } - const outputDir = path.join(dir, "agent-kit-render"); - const renderResult = await runProcess(renderer, ["--output", outputDir]); - assert.equal(renderResult.exitCode, 0, renderResult.stderr); + const outputDir = path.join(dir, 'agent-kit-render') + const renderResult = await runProcess(renderer, ['--output', outputDir]) + assert.equal(renderResult.exitCode, 0, renderResult.stderr) for (const managedPath of rendererManagedPaths()) { - assert.deepEqual(await readFile(path.join(outputDir, managedPath)), await readFile(rendererSourcePath(managedPath)), `rendered output for ${managedPath}`); + assert.deepEqual( + await readFile(path.join(outputDir, managedPath)), + await readFile(rendererSourcePath(managedPath)), + `rendered output for ${managedPath}`, + ) } -}); - -test("portability runbook documents install scope export restore and compatibility matrix", async () => { - const runbook = await readRepoText("platform/agents/kit/PORTABILITY.md"); - assertContains(runbook, "--scope user", "--scope project", "knowledge-vault", "Postgres logical backup", "Restore Order", "Compatibility Matrix", "Claude Code", "Codex", "knowledge.recall"); -}); - -test("agent kit doctor reports static health and skipped live checks", async () => { - const dir = await tempDir(); - const renderer = repoPath(manifest.renderer.script_path); - const home = path.join(dir, "doctor-home"); - const result = await runProcess(renderer, ["--doctor"], { - env: { HOME: home, CLAUDE_CONFIG_DIR: path.join(home, ".claude"), CODEX_HOME: path.join(home, ".codex"), KB_URL: "", KB_BEARER_TOKEN: "" }, - }); - assert.equal(result.exitCode, 0, result.stderr); +}) + +test('portability runbook documents install scope export restore and compatibility matrix', async () => { + const runbook = await readRepoText('platform/agents/kit/PORTABILITY.md') + assertContains( + runbook, + '--scope user', + '--scope project', + 'knowledge-vault', + 'Postgres logical backup', + 'Restore Order', + 'Compatibility Matrix', + 'Claude Code', + 'Codex', + 'knowledge.recall', + ) +}) + +test('agent kit doctor reports static health and skipped live checks', async () => { + const dir = await tempDir() + const renderer = repoPath(manifest.renderer.script_path) + const home = path.join(dir, 'doctor-home') + const result = await runProcess(renderer, ['--doctor'], { + env: { + HOME: home, + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + CODEX_HOME: path.join(home, '.codex'), + KB_URL: '', + KB_BEARER_TOKEN: '', + }, + }) + assert.equal(result.exitCode, 0, result.stderr) assertContains( result.stdout, - "agent kit doctor", - "ok render: generated files match templates", - "ok manifest: kit manifest version 2", - "ok mcp-profiles: 5 synchronized profiles; minimal 2 Claude/2 Codex servers; full-diagnostic 7 Claude/7 Codex servers", - "warn claude-install: manifest missing", - "warn codex-install: manifest missing", - "warn kb-live: KB_URL is not set; live MCP probe skipped", - "summary: 3 ok, 3 warn, 0 fail", - ); -}); - -test("agent kit doctor validates installed manifest versions when expected version is set", async () => { - const dir = await tempDir(); - const renderer = repoPath(manifest.renderer.script_path); - const home = path.join(dir, "doctor-installed"); - const claudeHome = path.join(home, ".claude"); - const codexHome = path.join(home, ".codex"); - await mkdir(claudeHome, { recursive: true }); - await mkdir(codexHome, { recursive: true }); - await writeFile(path.join(claudeHome, ".knowledge-system-version"), "version=current\n"); - await writeFile(path.join(codexHome, ".knowledge-system-version"), "version=current\nagent=codex\n"); - const result = await runProcess(renderer, ["--doctor"], { - env: { HOME: home, CLAUDE_CONFIG_DIR: claudeHome, CODEX_HOME: codexHome, AGENT_KIT_EXPECTED_VERSION: "current", KB_URL: "", KB_BEARER_TOKEN: "" }, - }); - assert.equal(result.exitCode, 0, result.stderr); + 'agent kit doctor', + 'ok render: generated files match templates', + 'ok manifest: kit manifest version 2', + 'ok mcp-profiles: 5 synchronized profiles; minimal 2 Claude/2 Codex servers; full-diagnostic 7 Claude/7 Codex servers', + 'warn claude-install: manifest missing', + 'warn codex-install: manifest missing', + 'warn kb-live: KB_URL is not set; live MCP probe skipped', + 'summary: 3 ok, 3 warn, 0 fail', + ) +}) + +test('agent kit doctor validates installed manifest versions when expected version is set', async () => { + const dir = await tempDir() + const renderer = repoPath(manifest.renderer.script_path) + const home = path.join(dir, 'doctor-installed') + const claudeHome = path.join(home, '.claude') + const codexHome = path.join(home, '.codex') + await mkdir(claudeHome, { recursive: true }) + await mkdir(codexHome, { recursive: true }) + await writeFile(path.join(claudeHome, '.knowledge-system-version'), 'version=current\n') + await writeFile(path.join(codexHome, '.knowledge-system-version'), 'version=current\nagent=codex\n') + const result = await runProcess(renderer, ['--doctor'], { + env: { + HOME: home, + CLAUDE_CONFIG_DIR: claudeHome, + CODEX_HOME: codexHome, + AGENT_KIT_EXPECTED_VERSION: 'current', + KB_URL: '', + KB_BEARER_TOKEN: '', + }, + }) + assert.equal(result.exitCode, 0, result.stderr) assertContains( result.stdout, - "ok manifest: kit manifest version 2", - "ok mcp-profiles: 5 synchronized profiles; minimal 2 Claude/2 Codex servers; full-diagnostic 7 Claude/7 Codex servers", - "ok claude-install: manifest version current, expected current", - "ok codex-install: manifest version current, expected current", - "summary: 5 ok, 1 warn, 0 fail", - ); -}); - -test("agent kit doctor can require live kb credentials", async () => { - const dir = await tempDir(); - const renderer = repoPath(manifest.renderer.script_path); - const home = path.join(dir, "doctor-live"); - const result = await runProcess(renderer, ["--doctor", "--require-live-kb"], { - env: { HOME: home, CLAUDE_CONFIG_DIR: path.join(home, ".claude"), CODEX_HOME: path.join(home, ".codex"), KB_URL: "http://knowledge.local", KB_BEARER_TOKEN: "" }, - }); - assert.equal(result.exitCode, 1, result.stdout); - assertContains(result.stdout, "fail kb-live: KB_BEARER_TOKEN is not set; live MCP probe skipped", "summary: 3 ok, 2 warn, 1 fail"); -}); - -test("agent kit doctor probes tools list and fast recall when live kb credentials exist", async (t) => { - const dir = await tempDir(); - const renderer = repoPath(manifest.renderer.script_path); - const home = path.join(dir, "doctor-live-ok"); - const payloads = []; + 'ok manifest: kit manifest version 2', + 'ok mcp-profiles: 5 synchronized profiles; minimal 2 Claude/2 Codex servers; full-diagnostic 7 Claude/7 Codex servers', + 'ok claude-install: manifest version current, expected current', + 'ok codex-install: manifest version current, expected current', + 'summary: 5 ok, 1 warn, 0 fail', + ) +}) + +test('agent kit doctor can require live kb credentials', async () => { + const dir = await tempDir() + const renderer = repoPath(manifest.renderer.script_path) + const home = path.join(dir, 'doctor-live') + const result = await runProcess(renderer, ['--doctor', '--require-live-kb'], { + env: { + HOME: home, + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + CODEX_HOME: path.join(home, '.codex'), + KB_URL: 'http://knowledge.local', + KB_BEARER_TOKEN: '', + }, + }) + assert.equal(result.exitCode, 1, result.stdout) + assertContains( + result.stdout, + 'fail kb-live: KB_BEARER_TOKEN is not set; live MCP probe skipped', + 'summary: 3 ok, 2 warn, 1 fail', + ) +}) + +test('agent kit doctor probes tools list and fast recall when live kb credentials exist', async (t) => { + const dir = await tempDir() + const renderer = repoPath(manifest.renderer.script_path) + const home = path.join(dir, 'doctor-live-ok') + const payloads = [] const server = http.createServer((request, response) => { - let body = ""; - request.setEncoding("utf8"); - request.on("data", (chunk) => { body += chunk; }); - request.on("end", () => { - const payload = JSON.parse(body); - payloads.push(payload); - const out = payload.method === "tools/list" - ? { jsonrpc: "2.0", id: "agent-kit-doctor-tools", result: { tools: [{ name: "knowledge.recall" }, { name: "knowledge.capture_lesson" }] } } - : payload.method === "tools/call" - ? { jsonrpc: "2.0", id: "agent-kit-doctor-recall", result: { structuredContent: { hits: [{ id: "01H", title: "Prior note" }] } } } - : { jsonrpc: "2.0", error: { code: -32601, message: "method not found" } }; - response.writeHead(200, { "Content-Type": "application/json" }); - response.end(JSON.stringify(out)); - }); - }); + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk) => { + body += chunk + }) + request.on('end', () => { + const payload = JSON.parse(body) + payloads.push(payload) + const out = + payload.method === 'tools/list' + ? { + jsonrpc: '2.0', + id: 'agent-kit-doctor-tools', + result: { tools: [{ name: 'knowledge.recall' }, { name: 'knowledge.capture_lesson' }] }, + } + : payload.method === 'tools/call' + ? { + jsonrpc: '2.0', + id: 'agent-kit-doctor-recall', + result: { structuredContent: { hits: [{ id: '01H', title: 'Prior note' }] } }, + } + : { jsonrpc: '2.0', error: { code: -32601, message: 'method not found' } } + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(out)) + }) + }) const listenResult = await new Promise((resolve) => { - server.once("error", (error) => resolve(error)); - server.listen(0, "127.0.0.1", () => resolve(null)); - }); - if (listenResult?.code === "EPERM") { - server.close(); - t.skip("sandbox does not permit binding a local HTTP server for the live KB doctor probe"); - return; + server.once('error', (error) => resolve(error)) + server.listen(0, '127.0.0.1', () => resolve(null)) + }) + if (listenResult?.code === 'EPERM') { + server.close() + t.skip('sandbox does not permit binding a local HTTP server for the live KB doctor probe') + return } - assert.equal(listenResult, null); + assert.equal(listenResult, null) try { - const port = server.address().port; - const result = await runProcess(renderer, ["--doctor"], { - env: { HOME: home, CLAUDE_CONFIG_DIR: path.join(home, ".claude"), CODEX_HOME: path.join(home, ".codex"), KB_URL: `http://127.0.0.1:${port}`, KB_BEARER_TOKEN: "test-token" }, - }); - assert.equal(result.exitCode, 0, result.stderr); - assertContains(result.stdout, `ok kb-live: reachable at http://127.0.0.1:${port}/mcp with 2 tools; fast recall returned 1 hits`, "summary: 4 ok, 2 warn, 0 fail"); - assert.deepEqual(payloads.map((payload) => payload.method), ["tools/list", "tools/call"]); - const recallPayload = payloads[1]; - assert.equal(recallPayload.params.name, "knowledge.recall"); - assertContains(recallPayload.params.arguments.query, "agent kit doctor"); - assert.equal(recallPayload.params.arguments.scope, "project:personal-stack"); - assert.equal(recallPayload.params.arguments.mode, "fast"); - assert.equal(recallPayload.params.arguments.limit, 1); + const port = server.address().port + const result = await runProcess(renderer, ['--doctor'], { + env: { + HOME: home, + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + CODEX_HOME: path.join(home, '.codex'), + KB_URL: `http://127.0.0.1:${port}`, + KB_BEARER_TOKEN: 'test-token', + }, + }) + assert.equal(result.exitCode, 0, result.stderr) + assertContains( + result.stdout, + `ok kb-live: reachable at http://127.0.0.1:${port}/mcp with 2 tools; fast recall returned 1 hits`, + 'summary: 4 ok, 2 warn, 0 fail', + ) + assert.deepEqual( + payloads.map((payload) => payload.method), + ['tools/list', 'tools/call'], + ) + const recallPayload = payloads[1] + assert.equal(recallPayload.params.name, 'knowledge.recall') + assertContains(recallPayload.params.arguments.query, 'agent kit doctor') + assert.equal(recallPayload.params.arguments.scope, 'project:personal-stack') + assert.equal(recallPayload.params.arguments.mode, 'fast') + assert.equal(recallPayload.params.arguments.limit, 1) } finally { - await new Promise((resolve) => server.close(resolve)); + await new Promise((resolve) => server.close(resolve)) } -}); - -test("knowledge-api installer placeholders and public routes stay exposed", async () => { - const installer = await readRepoText("services/knowledge-api/src/main/resources/installer/install.sh"); - assertContains(installer, "readonly INSTALLER_VERSION='@VERSION@'", "readonly KB_URL='@KB_URL@'", "KB_URL=\"${KB_URL:-@KB_URL@}\"", "${KB_URL}/install.sh"); - const fleet = await readYamlRepo("platform/inventory/fleet.yaml"); - const route = fleet.ingress_intent.route_rules.find((item) => item.name === "knowledge-api-mcp"); - assert.equal(route.service, "knowledge-api"); - assert.equal(route.access, "direct"); - assertSameMembers(route.exact_paths, ["/mcp", "/install.sh"]); - assert.deepEqual(route.path_prefixes, ["/mcp/"]); - const appRoute = fleet.ingress_intent.route_rules.find((item) => item.service === "knowledge-api" && item.name !== "knowledge-api-mcp"); - assertSameMembers(appRoute.excluded_exact_paths, ["/mcp", "/install.sh"]); - assert.deepEqual(appRoute.excluded_path_prefixes, ["/mcp/"]); - const catalog = await readRepoText("platform/cluster/flux/apps/edge/edge-route-catalog-configmap.yaml"); - const ingressRoutes = await readRepoText("platform/cluster/flux/apps/edge/traefik-ingressroutes.yaml"); - assertContains(catalog, 'name: "knowledge-api-mcp"', '- "/mcp"', '- "/install.sh"', '- "/mcp/"'); - assertContains(ingressRoutes, "name: knowledge-api-mcp", "Path(`/install.sh`)", "Path(`/mcp`)", "PathPrefix(`/mcp/`)"); -}); +}) + +test('knowledge-api installer placeholders and public routes stay exposed', async () => { + const installer = await readRepoText('services/knowledge-api/src/main/resources/installer/install.sh') + assertContains( + installer, + "readonly INSTALLER_VERSION='@VERSION@'", + "readonly KB_URL='@KB_URL@'", + 'KB_URL="${KB_URL:-@KB_URL@}"', + '${KB_URL}/install.sh', + ) + const fleet = await readYamlRepo('platform/inventory/fleet.yaml') + const route = fleet.ingress_intent.route_rules.find((item) => item.name === 'knowledge-api-mcp') + assert.equal(route.service, 'knowledge-api') + assert.equal(route.access, 'direct') + assertSameMembers(route.exact_paths, ['/mcp', '/install.sh']) + assert.deepEqual(route.path_prefixes, ['/mcp/']) + const appRoute = fleet.ingress_intent.route_rules.find( + (item) => item.service === 'knowledge-api' && item.name !== 'knowledge-api-mcp', + ) + assertSameMembers(appRoute.excluded_exact_paths, ['/mcp', '/install.sh']) + assert.deepEqual(appRoute.excluded_path_prefixes, ['/mcp/']) + const catalog = await readRepoText('platform/cluster/flux/apps/edge/edge-route-catalog-configmap.yaml') + const ingressRoutes = await readRepoText('platform/cluster/flux/apps/edge/traefik-ingressroutes.yaml') + assertContains(catalog, 'name: "knowledge-api-mcp"', '- "/mcp"', '- "/install.sh"', '- "/mcp/"') + assertContains(ingressRoutes, 'name: knowledge-api-mcp', 'Path(`/install.sh`)', 'Path(`/mcp`)', 'PathPrefix(`/mcp/`)') +}) async function installAgentKit(dir, prefix) { - const claudeHome = path.join(dir, `${prefix}-claude`); - const codexHome = path.join(dir, `${prefix}-codex`); - const installEnvironment = { CLAUDE_CONFIG_DIR: claudeHome, CODEX_HOME: codexHome }; - const installResult = await runProcess("bash", [repoPath(manifest.installer.path), "--agent", "all"], { env: installEnvironment }); - assert.equal(installResult.exitCode, 0, installResult.stderr); - return { claudeHome, codexHome, installEnvironment }; + const claudeHome = path.join(dir, `${prefix}-claude`) + const codexHome = path.join(dir, `${prefix}-codex`) + const installEnvironment = { CLAUDE_CONFIG_DIR: claudeHome, CODEX_HOME: codexHome } + const installResult = await runProcess('bash', [repoPath(manifest.installer.path), '--agent', 'all'], { + env: installEnvironment, + }) + assert.equal(installResult.exitCode, 0, installResult.stderr) + return { claudeHome, codexHome, installEnvironment } } async function repoSkillPaths() { - const paths = []; - for (const base of [".agents/skills", ".claude/skills"]) { - const root = rendererSourceRoot(base); - for (const file of await filesUnder(root)) if (path.basename(file) === "SKILL.md") paths.push(`${base}/${relativePath(root, file)}`); + const paths = [] + for (const base of ['.agents/skills', '.claude/skills']) { + const root = rendererSourceRoot(base) + for (const file of await filesUnder(root)) + if (path.basename(file) === 'SKILL.md') paths.push(`${base}/${relativePath(root, file)}`) } - return new Set(paths); + return new Set(paths) } async function repoCommandPaths() { - const root = rendererSourceRoot(".claude/commands"); - return new Set((await filesUnder(root)).filter((file) => path.basename(file).startsWith("speckit.") && file.endsWith(".md")).map((file) => `.claude/commands/${relativePath(root, file)}`)); + const root = rendererSourceRoot('.claude/commands') + return new Set( + (await filesUnder(root)) + .filter((file) => path.basename(file).startsWith('speckit.') && file.endsWith('.md')) + .map((file) => `.claude/commands/${relativePath(root, file)}`), + ) } async function repoHookPaths() { - const paths = []; - for (const base of [".claude/hooks", ".codex/hooks"]) { - const root = rendererSourceRoot(base); - for (const file of await filesUnder(root)) paths.push(`${base}/${relativePath(root, file)}`); + const paths = [] + for (const base of ['.claude/hooks', '.codex/hooks']) { + const root = rendererSourceRoot(base) + for (const file of await filesUnder(root)) paths.push(`${base}/${relativePath(root, file)}`) } - return new Set(paths); + return new Set(paths) } async function skillNamesUnder(base) { - const root = rendererSourceRoot(base); - return new Set((await filesUnder(root)).map((file) => path.relative(root, file).split(path.sep)[0]).filter(Boolean)); + const root = rendererSourceRoot(base) + return new Set((await filesUnder(root)).map((file) => path.relative(root, file).split(path.sep)[0]).filter(Boolean)) } function manifestItems(section) { - return manifest[section] ?? []; + return manifest[section] ?? [] } function manifestTargetPaths(section) { - return manifestItems(section).flatMap(itemTargetPaths); + return manifestItems(section).flatMap(itemTargetPaths) } function itemTargetPaths(item) { - return (item.targets ?? []).map((target) => target.path).filter(Boolean); + return (item.targets ?? []).map((target) => target.path).filter(Boolean) } function supportedAgents(item) { - return item.supported_agents ?? []; + return item.supported_agents ?? [] } function collectPinnedPaths(node) { - const out = []; + const out = [] function walk(current) { if (Array.isArray(current)) { - for (const item of current) walk(item); - return; + for (const item of current) walk(item) + return } - if (current && typeof current === "object") { - if (current.path && current.sha256) out.push({ path: current.path, sha256: current.sha256 }); - for (const value of Object.values(current)) walk(value); + if (current && typeof current === 'object') { + if (current.path && current.sha256) out.push({ path: current.path, sha256: current.sha256 }) + for (const value of Object.values(current)) walk(value) } } - walk(node); - return out; + walk(node) + return out } function assertAgentGapIsExplicit(label, node) { - const missingAgents = ["claude", "codex"].filter((agent) => !supportedAgents(node).includes(agent)); - for (const agent of missingAgents) assert.ok(node.unsupported?.[agent]?.trim(), `${label} unsupported reason for ${agent}`); + const missingAgents = ['claude', 'codex'].filter((agent) => !supportedAgents(node).includes(agent)) + for (const agent of missingAgents) + assert.ok(node.unsupported?.[agent]?.trim(), `${label} unsupported reason for ${agent}`) } async function canonicalKnowledgeToolNames() { - const text = await readRepoText("services/knowledge-api/src/test/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/McpToolsTest.kt"); - return new Set([...text.matchAll(/"(knowledge\.[a-z_]+)"/g)].map((match) => match[1])); + const text = await readRepoText( + 'services/knowledge-api/src/test/kotlin/com/jorisjonkers/personalstack/knowledge/mcp/McpToolsTest.kt', + ) + return new Set([...text.matchAll(/"(knowledge\.[a-z_]+)"/g)].map((match) => match[1])) } function hookMatchers(settings, event) { - return settings.hooks[event].map((group) => group.matcher).filter(Boolean); + return settings.hooks[event].map((group) => group.matcher).filter(Boolean) } function hookCommands(settings, event) { - return settings.hooks[event].flatMap((group) => group.hooks.map((hook) => hook.command)); + return settings.hooks[event].flatMap((group) => group.hooks.map((hook) => hook.command)) } async function readCurlPayloads(file) { - if (!existsSync(file)) return []; - return (await readFile(file, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line)); + if (!existsSync(file)) return [] + return (await readFile(file, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) } function toolArguments(payload) { - return payload.params.arguments; + return payload.params.arguments } function toolName(payload) { - return payload.params.name; + return payload.params.name } function rendererManagedPathList() { - return manifest.renderer.managed_paths; + return manifest.renderer.managed_paths } function rendererManagedPaths() { - return new Set(rendererManagedPathList()); + return new Set(rendererManagedPathList()) } async function rendererTemplatePaths() { - const templateRoot = repoPath(manifest.renderer.template_root); - const repoTemplatePaths = (await filesUnder(templateRoot)).map((file) => relativePath(templateRoot, file)); - const extraTemplatePaths = []; + const templateRoot = repoPath(manifest.renderer.template_root) + const repoTemplatePaths = (await filesUnder(templateRoot)).map((file) => relativePath(templateRoot, file)) + const extraTemplatePaths = [] for (const mapping of manifest.renderer.extra_templates ?? []) { - assert.ok(existsSync(repoPath(mapping.source_path)), `extra renderer template exists: ${mapping.source_path}`); - extraTemplatePaths.push(mapping.destination_path); + assert.ok(existsSync(repoPath(mapping.source_path)), `extra renderer template exists: ${mapping.source_path}`) + extraTemplatePaths.push(mapping.destination_path) } - return new Set([...repoTemplatePaths, ...extraTemplatePaths]); + return new Set([...repoTemplatePaths, ...extraTemplatePaths]) } async function rendererIncludeTemplatePaths() { - const out = new Set(); + const out = new Set() for (const mapping of manifest.renderer.extra_templates ?? []) { - const source = repoPath(mapping.source_path); - const templateRoot = path.dirname(source); - const text = await readFile(source, "utf8"); + const source = repoPath(mapping.source_path) + const templateRoot = path.dirname(source) + const text = await readFile(source, 'utf8') for (const match of text.matchAll(/^# @agent-kit-include ([A-Za-z0-9_./-]+)$/gm)) { - out.add(relativePath(repoRoot, path.join(templateRoot, match[1]))); + out.add(relativePath(repoRoot, path.join(templateRoot, match[1]))) } } - return out; + return out } function rendererSourceRoot(base) { - const templateRoot = repoPath(manifest.renderer.template_root, base); - return existsSync(templateRoot) ? templateRoot : repoPath(base); + const templateRoot = repoPath(manifest.renderer.template_root, base) + return existsSync(templateRoot) ? templateRoot : repoPath(base) } function rendererSourcePath(itemPath) { - const livePath = repoPath(itemPath); - if (existsSync(livePath)) return livePath; - const templatePath = repoPath(manifest.renderer.template_root, itemPath); - return existsSync(templatePath) ? templatePath : livePath; + const livePath = repoPath(itemPath) + if (existsSync(livePath)) return livePath + const templatePath = repoPath(manifest.renderer.template_root, itemPath) + return existsSync(templatePath) ? templatePath : livePath } diff --git a/platform/tests/fleet-loader.test.js b/platform/tests/fleet-loader.test.js index a12dd143..c147daf3 100644 --- a/platform/tests/fleet-loader.test.js +++ b/platform/tests/fleet-loader.test.js @@ -1,58 +1,70 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { loadFleet, parseFleetText } from "./_helpers.js"; +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadFleet, parseFleetText } from './_helpers.js' -test("loads the seeded platform inventory", async () => { - const fleet = await loadFleet(); +test('loads the seeded platform inventory', async () => { + const fleet = await loadFleet() - assert.equal(fleet.cluster.name, "personal-stack"); - assert.equal(fleet.cluster.kubernetes.bootstrap_control_plane, "frankfurt-contabo-1"); - assert.equal(fleet.cluster.kubernetes.api_server_endpoint, "https://167.86.79.203:6443"); - assert.equal(fleet.cluster.kubernetes.control_plane_token_file, "/var/lib/rancher/k3s/server/node-token"); - assert.equal(fleet.cluster.kubernetes.worker_join_token_file, "/var/lib/personal-stack/secrets/k3s/agent-token"); - assert.deepEqual(Object.keys(fleet.sites).sort(), ["enschede", "frankfurt"]); - assert.equal(fleet.sites.enschede.networking.lan_ingress_ip, "192.168.0.99"); - assert.equal(fleet.sites.enschede.networking.wan_public_ip, "130.89.174.190"); - assert.equal(fleet.ingress_intent.wan_origin_overrides.jellyfin, "home_direct"); + assert.equal(fleet.cluster.name, 'personal-stack') + assert.equal(fleet.cluster.kubernetes.bootstrap_control_plane, 'frankfurt-contabo-1') + assert.equal(fleet.cluster.kubernetes.api_server_endpoint, 'https://167.86.79.203:6443') + assert.equal(fleet.cluster.kubernetes.control_plane_token_file, '/var/lib/rancher/k3s/server/node-token') + assert.equal(fleet.cluster.kubernetes.worker_join_token_file, '/var/lib/personal-stack/secrets/k3s/agent-token') + assert.deepEqual(Object.keys(fleet.sites).sort(), ['enschede', 'frankfurt']) + assert.equal(fleet.sites.enschede.networking.lan_ingress_ip, '192.168.0.99') + assert.equal(fleet.sites.enschede.networking.wan_public_ip, '130.89.174.190') + assert.equal(fleet.ingress_intent.wan_origin_overrides.jellyfin, 'home_direct') for (const node of [ - "frankfurt-contabo-1", - "enschede-gtx-960m-1", - "enschede-t1000-1", - "enschede-rx7900xtx-1", - "enschede-pi-1", - "enschede-pi-2", - "enschede-pi-3", - ]) assert.ok(node in fleet.nodes); - assert.equal(fleet.nodes["frankfurt-contabo-1"].ssh.host, "167.86.79.203"); - assert.equal(fleet.nodes["enschede-t1000-1"].ssh.user, "deploy"); - assert.equal(fleet.nodes["enschede-pi-1"].ssh.host, "100.65.192.22"); - assert.deepEqual([...new Set(Object.values(fleet.nodes).map((node) => node.ssh?.port).filter(Boolean))], [2222]); - assert.equal(fleet.placement_intent.gpu_specific.jellyfin.preferred_gpu_model, "t1000"); + 'frankfurt-contabo-1', + 'enschede-gtx-960m-1', + 'enschede-t1000-1', + 'enschede-rx7900xtx-1', + 'enschede-pi-1', + 'enschede-pi-2', + 'enschede-pi-3', + ]) + assert.ok(node in fleet.nodes) + assert.equal(fleet.nodes['frankfurt-contabo-1'].ssh.host, '167.86.79.203') + assert.equal(fleet.nodes['enschede-t1000-1'].ssh.user, 'deploy') + assert.equal(fleet.nodes['enschede-pi-1'].ssh.host, '100.65.192.22') + assert.deepEqual( + [ + ...new Set( + Object.values(fleet.nodes) + .map((node) => node.ssh?.port) + .filter(Boolean), + ), + ], + [2222], + ) + assert.equal(fleet.placement_intent.gpu_specific.jellyfin.preferred_gpu_model, 't1000') assert.deepEqual(fleet.exposure_intent.public_and_lan.sort(), [ - "assistant-ws", - "bazarr", - "immich", - "jellyfin", - "jellyseerr", - "prowlarr", - "qbittorrent", - "radarr", - "sonarr", - ]); - assert.equal(fleet.ingress_intent.kubernetes_backends.vault.port, 8200); - assert.equal(fleet.ingress_intent.kubernetes_backends["auth-api"].namespace, "auth-system"); - assert.equal(fleet.ingress_intent.kubernetes_backends["assistant-api"].port, 8082); - assert.equal(fleet.ingress_intent.kubernetes_backends.stalwart.namespace, "mail-system"); - assert.equal(fleet.ingress_intent.kubernetes_backends.stalwart.port, 8080); - assert.equal(fleet.ingress_intent.kubernetes_backends.bazarr.port, 6767); - assert.equal(fleet.ingress_intent.kubernetes_backends.jellyseerr.port, 5055); - assert.equal(fleet.ingress_intent.kubernetes_backends.prowlarr.port, 9696); - assert.equal(fleet.ingress_intent.kubernetes_backends.qbittorrent.port, 8080); - assert.ok(!("headscale" in fleet.ingress_intent.kubernetes_backends)); -}); + 'agents-ws', + 'bazarr', + 'immich', + 'jellyfin', + 'jellyseerr', + 'prowlarr', + 'qbittorrent', + 'radarr', + 'sonarr', + ]) + assert.equal(fleet.ingress_intent.kubernetes_backends.vault.port, 8200) + assert.equal(fleet.ingress_intent.kubernetes_backends['auth-api'].namespace, 'auth-system') + assert.equal(fleet.ingress_intent.kubernetes_backends['agents-api'].port, 8082) + assert.equal(fleet.ingress_intent.kubernetes_backends.stalwart.namespace, 'mail-system') + assert.equal(fleet.ingress_intent.kubernetes_backends.stalwart.port, 8080) + assert.equal(fleet.ingress_intent.kubernetes_backends.bazarr.port, 6767) + assert.equal(fleet.ingress_intent.kubernetes_backends.jellyseerr.port, 5055) + assert.equal(fleet.ingress_intent.kubernetes_backends.prowlarr.port, 9696) + assert.equal(fleet.ingress_intent.kubernetes_backends.qbittorrent.port, 8080) + assert.ok(!('headscale' in fleet.ingress_intent.kubernetes_backends)) +}) -test("rejects active nodes without ssh connection details", () => { - assert.throws(() => parseFleetText(` +test('rejects active nodes without ssh connection details', () => { + assert.throws( + () => + parseFleetText(` version: 1 cluster: name: personal-stack @@ -79,11 +91,15 @@ service_intent: host_native: {} placement_intent: {frankfurt_only: [], enschede_only: [], gpu_specific: {}} exposure_intent: {public: [], public_and_lan: [], internal_only: [], lan_only: []} -`), /active node frankfurt-contabo-1 must define ssh connection details/); -}); +`), + /active node frankfurt-contabo-1 must define ssh connection details/, + ) +}) -test("rejects unknown site references", () => { - assert.throws(() => parseFleetText(` +test('rejects unknown site references', () => { + assert.throws( + () => + parseFleetText(` version: 1 cluster: name: personal-stack @@ -110,11 +126,15 @@ service_intent: host_native: {} placement_intent: {frankfurt_only: [], enschede_only: [], gpu_specific: {}} exposure_intent: {public: [], public_and_lan: [], internal_only: [], lan_only: []} -`), /node stray-node references unknown site enschede/); -}); +`), + /node stray-node references unknown site enschede/, + ) +}) -test("rejects externally exposed kubernetes services without ingress backends", () => { - assert.throws(() => parseFleetText(` +test('rejects externally exposed kubernetes services without ingress backends', () => { + assert.throws( + () => + parseFleetText(` version: 1 cluster: name: personal-stack @@ -144,5 +164,7 @@ access_intent: host_labels: {app-ui: root} ingress_intent: kubernetes_backends: {} -`), /externally exposed kubernetes service app-ui must declare an ingress backend/); -}); +`), + /externally exposed kubernetes service app-ui must declare an ingress backend/, + ) +}) diff --git a/platform/tests/gpu-nvidia-role.test.js b/platform/tests/gpu-nvidia-role.test.js index 45becb1d..dc405f88 100644 --- a/platform/tests/gpu-nvidia-role.test.js +++ b/platform/tests/gpu-nvidia-role.test.js @@ -1,23 +1,23 @@ -import test from "node:test"; -import { readRepoText, assertContains } from "./_helpers.js"; +import test from 'node:test' +import { readRepoText, assertContains } from './_helpers.js' -test("gpu nvidia profile allows unfree nvidia packages", async () => { - const profile = await readRepoText("platform/nix/profiles/gpu-nvidia.nix"); - assertContains(profile, "nixpkgs.config.allowUnfreePredicate", "lib.hasPrefix \"nvidia-\" name"); -}); +test('gpu nvidia profile allows unfree nvidia packages', async () => { + const profile = await readRepoText('platform/nix/profiles/gpu-nvidia.nix') + assertContains(profile, 'nixpkgs.config.allowUnfreePredicate', 'lib.hasPrefix "nvidia-" name') +}) -test("gpu nvidia role enables driver modesetting and container toolkit", async () => { - const role = await readRepoText("platform/nix/modules/roles/gpu-nvidia.nix"); +test('gpu nvidia role enables driver modesetting and container toolkit', async () => { + const role = await readRepoText('platform/nix/modules/roles/gpu-nvidia.nix') assertContains( role, - "services.xserver.videoDrivers = [ \"nvidia\" ];", - "hardware.graphics.enable = true;", - "hardware.nvidia = {", - "open = false;", - "modesetting.enable = true;", - "package = lib.mkDefault config.boot.kernelPackages.nvidiaPackages.stable;", - "hardware.nvidia-container-toolkit.enable = true;", - "libva", - "pciutils", - ); -}); + 'services.xserver.videoDrivers = [ "nvidia" ];', + 'hardware.graphics.enable = true;', + 'hardware.nvidia = {', + 'open = false;', + 'modesetting.enable = true;', + 'package = lib.mkDefault config.boot.kernelPackages.nvidiaPackages.stable;', + 'hardware.nvidia-container-toolkit.enable = true;', + 'libva', + 'pciutils', + ) +}) diff --git a/platform/tests/host-definitions.test.js b/platform/tests/host-definitions.test.js index 9e5f2b96..cc1ce081 100644 --- a/platform/tests/host-definitions.test.js +++ b/platform/tests/host-definitions.test.js @@ -1,65 +1,78 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; -import { loadFleet, repoPath, readRepoText, assertContains } from "./_helpers.js"; +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { loadFleet, repoPath, readRepoText, assertContains } from './_helpers.js' -test("every inventory node has a nix host definition and disk layout", async () => { - const fleet = await loadFleet(); +test('every inventory node has a nix host definition and disk layout', async () => { + const fleet = await loadFleet() for (const nodeName of Object.keys(fleet.nodes)) { - assert.ok(existsSync(repoPath("platform/nix/hosts", nodeName, "default.nix")), `host ${nodeName} should define a default.nix`); - assert.ok(existsSync(repoPath("platform/nix/hosts", nodeName, "disko.nix")), `host ${nodeName} should define a disko.nix`); + assert.ok( + existsSync(repoPath('platform/nix/hosts', nodeName, 'default.nix')), + `host ${nodeName} should define a default.nix`, + ) + assert.ok( + existsSync(repoPath('platform/nix/hosts', nodeName, 'disko.nix')), + `host ${nodeName} should define a disko.nix`, + ) } -}); +}) -test("host definitions import profiles implied by fleet roles and capabilities", async () => { - const fleet = await loadFleet(); +test('host definitions import profiles implied by fleet roles and capabilities', async () => { + const fleet = await loadFleet() for (const [nodeName, node] of Object.entries(fleet.nodes)) { - const hostDefinition = await readRepoText("platform/nix/hosts", nodeName, "default.nix"); - if (node.target_roles.includes("k3s-control-plane")) assertContains(hostDefinition, "../../profiles/control-plane.nix"); - if (node.target_roles.includes("k3s-worker")) assertContains(hostDefinition, "../../profiles/worker.nix"); - if (node.target_roles.includes("utility-host")) assertContains(hostDefinition, "../../profiles/utility.nix"); - if (node.capabilities.includes("nvidia")) assertContains(hostDefinition, "../../profiles/gpu-nvidia.nix"); + const hostDefinition = await readRepoText('platform/nix/hosts', nodeName, 'default.nix') + if (node.target_roles.includes('k3s-control-plane')) + assertContains(hostDefinition, '../../profiles/control-plane.nix') + if (node.target_roles.includes('k3s-worker')) assertContains(hostDefinition, '../../profiles/worker.nix') + if (node.target_roles.includes('utility-host')) assertContains(hostDefinition, '../../profiles/utility.nix') + if (node.capabilities.includes('nvidia')) assertContains(hostDefinition, '../../profiles/gpu-nvidia.nix') } -}); +}) -test("raspberry pi hosts override efi boot with generic extlinux", async () => { - for (const nodeName of ["enschede-pi-1", "enschede-pi-2", "enschede-pi-3"]) { - const hostDefinition = await readRepoText("platform/nix/hosts", nodeName, "default.nix"); +test('raspberry pi hosts override efi boot with generic extlinux', async () => { + for (const nodeName of ['enschede-pi-1', 'enschede-pi-2', 'enschede-pi-3']) { + const hostDefinition = await readRepoText('platform/nix/hosts', nodeName, 'default.nix') assertContains( hostDefinition, - "imageBuild ? false", - "lib.optional (!imageBuild) ./disko.nix", - "systemd-boot.enable = lib.mkForce false", - "efi.canTouchEfiVariables = lib.mkForce false", - "generic-extlinux-compatible.enable = true", - ); + 'imageBuild ? false', + 'lib.optional (!imageBuild) ./disko.nix', + 'systemd-boot.enable = lib.mkForce false', + 'efi.canTouchEfiVariables = lib.mkForce false', + 'generic-extlinux-compatible.enable = true', + ) } -}); +}) -test("flake exports host specific raspberry pi sd images", async () => { - const flake = await readRepoText("platform/flake.nix"); - const buildScript = await readRepoText("platform/scripts/build/build-pi-image.sh"); - for (const nodeName of ["enschede-pi-1", "enschede-pi-2", "enschede-pi-3"]) { - assertContains(flake, `${nodeName} = mkHost {`, "extraSpecialArgs = { imageBuild = true; };", "piSdImages ="); - assertContains(buildScript, '#piSdImages.${NODE_NAME}'); +test('flake exports host specific raspberry pi sd images', async () => { + const flake = await readRepoText('platform/flake.nix') + const buildScript = await readRepoText('platform/scripts/build/build-pi-image.sh') + for (const nodeName of ['enschede-pi-1', 'enschede-pi-2', 'enschede-pi-3']) { + assertContains(flake, `${nodeName} = mkHost {`, 'extraSpecialArgs = { imageBuild = true; };', 'piSdImages =') + assertContains(buildScript, '#piSdImages.${NODE_NAME}') } -}); +}) -test("flake exports every inventory node with the correct architecture", async () => { - const fleet = await loadFleet(); - const flake = await readRepoText("platform/flake.nix"); +test('flake exports every inventory node with the correct architecture', async () => { + const fleet = await loadFleet() + const flake = await readRepoText('platform/flake.nix') for (const [nodeName, node] of Object.entries(fleet.nodes)) { - const expectedSystem = node.arch === "amd64" ? "x86_64-linux" : "aarch64-linux"; - assertContains(flake, `${nodeName} = mkHost`, `${nodeName} = mkHost {`, `system = "${expectedSystem}";`, `hostModule = ./nix/hosts/${nodeName}/default.nix;`); + const expectedSystem = node.arch === 'amd64' ? 'x86_64-linux' : 'aarch64-linux' + assertContains( + flake, + `${nodeName} = mkHost`, + `${nodeName} = mkHost {`, + `system = "${expectedSystem}";`, + `hostModule = ./nix/hosts/${nodeName}/default.nix;`, + ) } -}); +}) -test("flake exports deploy targets for every ssh reachable inventory node", async () => { - const fleet = await loadFleet(); - const flake = await readRepoText("platform/flake.nix"); +test('flake exports deploy targets for every ssh reachable inventory node', async () => { + const fleet = await loadFleet() + const flake = await readRepoText('platform/flake.nix') for (const [nodeName, node] of Object.entries(fleet.nodes)) { - if (!node.ssh) continue; - const expectedSystem = node.arch === "amd64" ? "x86_64-linux" : "aarch64-linux"; + if (!node.ssh) continue + const expectedSystem = node.arch === 'amd64' ? 'x86_64-linux' : 'aarch64-linux' assertContains( flake, `deploy.nodes.${nodeName}`, @@ -67,66 +80,70 @@ test("flake exports deploy targets for every ssh reachable inventory node", asyn `sshUser = "${node.ssh.user}"`, `sshOpts = [ "-p" "${node.ssh.port}" ]`, `deploy-rs.lib.${expectedSystem}.activate.nixos self.nixosConfigurations.${nodeName}`, - ); + ) } -}); +}) -test("gtx 960m host imports game streaming service", async () => { - const fleet = await loadFleet(); - const flake = await readRepoText("platform/flake.nix"); - const hostDefinition = await readRepoText("platform/nix/hosts/enschede-gtx-960m-1/default.nix"); - const module = await readRepoText("platform/nix/modules/services/game-streaming.nix"); - const gpuProfile = await readRepoText("platform/nix/profiles/gpu-nvidia.nix"); - const gtxNode = fleet.nodes["enschede-gtx-960m-1"]; - assert.ok(gtxNode.capabilities.includes("game-streaming")); - assert.ok(gtxNode.capabilities.includes("nvidia")); - assert.ok(fleet.service_intent.host_native["enschede-gtx-960m-1"].includes("game-streaming")); - assertContains(flake, "deploy.nodes.enschede-gtx-960m-1", "magicRollback = false"); - assertContains(hostDefinition, "../../modules/services/game-streaming.nix", "\"personal-stack/capability-game-streaming\" = \"true\""); +test('gtx 960m host imports game streaming service', async () => { + const fleet = await loadFleet() + const flake = await readRepoText('platform/flake.nix') + const hostDefinition = await readRepoText('platform/nix/hosts/enschede-gtx-960m-1/default.nix') + const module = await readRepoText('platform/nix/modules/services/game-streaming.nix') + const gpuProfile = await readRepoText('platform/nix/profiles/gpu-nvidia.nix') + const gtxNode = fleet.nodes['enschede-gtx-960m-1'] + assert.ok(gtxNode.capabilities.includes('game-streaming')) + assert.ok(gtxNode.capabilities.includes('nvidia')) + assert.ok(fleet.service_intent.host_native['enschede-gtx-960m-1'].includes('game-streaming')) + assertContains(flake, 'deploy.nodes.enschede-gtx-960m-1', 'magicRollback = false') + assertContains( + hostDefinition, + '../../modules/services/game-streaming.nix', + '"personal-stack/capability-game-streaming" = "true"', + ) assertContains( module, - "ghcr.io/games-on-whales/wolf:stable", - "ghcr.io/games-on-whales/retroarch:edge", - "ghcr.io/games-on-whales/es-de:edge", - "ghcr.io/games-on-whales/xfce:edge", - "ghcr.io/games-on-whales/steam:edge", - "ghcr.io/games-on-whales/heroic-games-launcher:edge", - "ghcr.io/games-on-whales/lutris:edge", - "support_hevc = false", - "Wolf UI", - "title = \"Steam\"", - "title = \"Heroic\"", - "title = \"Lutris\"", - "STEAM_STARTUP_FLAGS=-bigpicture", - "WINEPREFIX=/home/retro/Games/Prefixes/default", - "4K60", - "virtualisation.docker", - "virtualisation.oci-containers", - "WOLF_RENDER_NODE = \"/dev/dri/renderD129\"", - "WOLF_SOCKET_PATH = \"/var/run/wolf/wolf.sock\"", - "\"/run/wolf:/var/run/wolf:rw\"", - "d /var/lib/personal-stack/wolfmanager/config", - "\"DeviceRequests\"", - "--device=nvidia.com/gpu=all", - "--network=host", - "--device=/dev/uinput", - "--device=/dev/uhid", - "hardware.uinput.enable = true", - "nvidia-drm.modeset=1", - "boot.kernelModules", - "services.pipewire", - "uid = 1001", - "localGamesMount = \"/srv/game-streaming\"", + 'ghcr.io/games-on-whales/wolf:stable', + 'ghcr.io/games-on-whales/retroarch:edge', + 'ghcr.io/games-on-whales/es-de:edge', + 'ghcr.io/games-on-whales/xfce:edge', + 'ghcr.io/games-on-whales/steam:edge', + 'ghcr.io/games-on-whales/heroic-games-launcher:edge', + 'ghcr.io/games-on-whales/lutris:edge', + 'support_hevc = false', + 'Wolf UI', + 'title = "Steam"', + 'title = "Heroic"', + 'title = "Lutris"', + 'STEAM_STARTUP_FLAGS=-bigpicture', + 'WINEPREFIX=/home/retro/Games/Prefixes/default', + '4K60', + 'virtualisation.docker', + 'virtualisation.oci-containers', + 'WOLF_RENDER_NODE = "/dev/dri/renderD129"', + 'WOLF_SOCKET_PATH = "/var/run/wolf/wolf.sock"', + '"/run/wolf:/var/run/wolf:rw"', + 'd /var/lib/personal-stack/wolfmanager/config', + '"DeviceRequests"', + '--device=nvidia.com/gpu=all', + '--network=host', + '--device=/dev/uinput', + '--device=/dev/uhid', + 'hardware.uinput.enable = true', + 'nvidia-drm.modeset=1', + 'boot.kernelModules', + 'services.pipewire', + 'uid = 1001', + 'localGamesMount = "/srv/game-streaming"', 'localRomsMount = "${localGamesMount}/roms"', - "device = \"/dev/disk/by-uuid/1120-414D\"", - "fsType = \"vfat\"", + 'device = "/dev/disk/by-uuid/1120-414D"', + 'fsType = "vfat"', 'fileSystems.${gamesMount}', - "wolf-config-seed", - "wolf-config-reconcile", - "config.toml.pre-store-launchers", - "append_app Steam", - "append_app Heroic", - "append_app Lutris", + 'wolf-config-seed', + 'wolf-config-reconcile', + 'config.toml.pre-store-launchers', + 'append_app Steam', + 'append_app Heroic', + 'append_app Lutris', '${wolfState}/cfg/config.toml', '${gamesMount}:/ROMs:ro', '${localRomsMount}:/ROMs-local:ro', @@ -137,22 +154,22 @@ test("gtx 960m host imports game streaming service", async () => { '${pcGamesMount}/lutris:/home/retro/Games/Lutris:rw', '${pcGamesMount}/prefixes:/home/retro/Games/Prefixes:rw', '${pcGamesMount}/downloads:/home/retro/Downloads:rw', - "47984", - "47989", - "47990", - "48010", - "from = 8000", - "to = 8010", - "hardware.nvidia-container-toolkit.enable", - ); + '47984', + '47989', + '47990', + '48010', + 'from = 8000', + 'to = 8010', + 'hardware.nvidia-container-toolkit.enable', + ) assertContains( gpuProfile, - "lib.hasPrefix \"nvidia-\" name", - "lib.hasPrefix \"cuda\" name", - "lib.hasPrefix \"libcu\" name", - "lib.hasPrefix \"libn\" name", - "lib.hasPrefix \"libnv\" name", - "CUDA EULA", - "lib.hasPrefix \"libretro-\" name", - ); -}); + 'lib.hasPrefix "nvidia-" name', + 'lib.hasPrefix "cuda" name', + 'lib.hasPrefix "libcu" name', + 'lib.hasPrefix "libn" name', + 'lib.hasPrefix "libnv" name', + 'CUDA EULA', + 'lib.hasPrefix "libretro-" name', + ) +}) diff --git a/platform/tests/k3s-bootstrap.test.js b/platform/tests/k3s-bootstrap.test.js index 19565324..e7b011ad 100644 --- a/platform/tests/k3s-bootstrap.test.js +++ b/platform/tests/k3s-bootstrap.test.js @@ -1,39 +1,58 @@ -import test from "node:test"; -import { loadFleet, readRepoText, assertContains } from "./_helpers.js"; +import test from 'node:test' +import { loadFleet, readRepoText, assertContains } from './_helpers.js' -test("k3s bootstrap module configures join token path and cluster firewall ports", async () => { - const module = await readRepoText("platform/nix/modules/k3s/bootstrap.nix"); +test('k3s bootstrap module configures join token path and cluster firewall ports', async () => { + const module = await readRepoText('platform/nix/modules/k3s/bootstrap.nix') assertContains( module, - "apiServerEndpoint", - "workerJoinTokenFile", - "--token-file=", - "allowedTCPPorts", - "10250", - "6443", - "allowedUDPPorts = [ 8472 ]", - "systemd.tmpfiles.rules", - "preStart = lib.mkBefore", - "ip -o -4 addr show dev tailscale0 scope global", - "tailscale0 did not receive a global IPv4 address within 60s", - ); -}); + 'apiServerEndpoint', + 'workerJoinTokenFile', + '--token-file=', + 'allowedTCPPorts', + '10250', + '6443', + 'allowedUDPPorts = [ 8472 ]', + 'systemd.tmpfiles.rules', + 'preStart = lib.mkBefore', + 'ip -o -4 addr show dev tailscale0 scope global', + 'tailscale0 did not receive a global IPv4 address within 60s', + ) +}) -test("worker and control plane profiles share the same k3s bootstrap defaults", async () => { - const fleet = await loadFleet(); - const workerProfile = await readRepoText("platform/nix/profiles/worker.nix"); - const controlPlaneProfile = await readRepoText("platform/nix/profiles/control-plane.nix"); - const apiServerEndpoint = fleet.cluster.kubernetes.api_server_endpoint; - const workerJoinTokenFile = fleet.cluster.kubernetes.worker_join_token_file; - assertContains(workerProfile, "../modules/k3s/bootstrap.nix", `apiServerEndpoint = "${apiServerEndpoint}"`, `workerJoinTokenFile = "${workerJoinTokenFile}"`); - assertContains(controlPlaneProfile, "../modules/k3s/bootstrap.nix", `apiServerEndpoint = "${apiServerEndpoint}"`, `workerJoinTokenFile = "${workerJoinTokenFile}"`); -}); +test('worker and control plane profiles share the same k3s bootstrap defaults', async () => { + const fleet = await loadFleet() + const workerProfile = await readRepoText('platform/nix/profiles/worker.nix') + const controlPlaneProfile = await readRepoText('platform/nix/profiles/control-plane.nix') + const apiServerEndpoint = fleet.cluster.kubernetes.api_server_endpoint + const workerJoinTokenFile = fleet.cluster.kubernetes.worker_join_token_file + assertContains( + workerProfile, + '../modules/k3s/bootstrap.nix', + `apiServerEndpoint = "${apiServerEndpoint}"`, + `workerJoinTokenFile = "${workerJoinTokenFile}"`, + ) + assertContains( + controlPlaneProfile, + '../modules/k3s/bootstrap.nix', + `apiServerEndpoint = "${apiServerEndpoint}"`, + `workerJoinTokenFile = "${workerJoinTokenFile}"`, + ) +}) -test("bootstrap docs point workers at the token copy helper before deploy", async () => { - const bootstrapReadme = await readRepoText("platform/cluster/bootstrap/README.md"); - const installPlaybook = await readRepoText("platform/cluster/bootstrap/home-install-playbook.md"); - const helperScript = await readRepoText("platform/scripts/bootstrap/bootstrap-k3s-worker.sh"); - assertContains(bootstrapReadme, "bootstrap-k3s-worker.sh"); - assertContains(installPlaybook, "bootstrap-k3s-worker.sh ", "deploy-host.sh "); - assertContains(helperScript, "K3S_BOOTSTRAP_CONTROL_PLANE_NODE", "K3S_CONTROL_PLANE_TOKEN_FILE", "K3S_WORKER_JOIN_TOKEN_FILE", "platform_ssh_identity_file", "require_platform_ssh_identity_file_if_set", "sudo cat", "sudo tee"); -}); +test('bootstrap docs point workers at the token copy helper before deploy', async () => { + const bootstrapReadme = await readRepoText('platform/cluster/bootstrap/README.md') + const installPlaybook = await readRepoText('platform/cluster/bootstrap/home-install-playbook.md') + const helperScript = await readRepoText('platform/scripts/bootstrap/bootstrap-k3s-worker.sh') + assertContains(bootstrapReadme, 'bootstrap-k3s-worker.sh') + assertContains(installPlaybook, 'bootstrap-k3s-worker.sh ', 'deploy-host.sh ') + assertContains( + helperScript, + 'K3S_BOOTSTRAP_CONTROL_PLANE_NODE', + 'K3S_CONTROL_PLANE_TOKEN_FILE', + 'K3S_WORKER_JOIN_TOKEN_FILE', + 'platform_ssh_identity_file', + 'require_platform_ssh_identity_file_if_set', + 'sudo cat', + 'sudo tee', + ) +}) diff --git a/platform/tests/k3s-node-labels.test.js b/platform/tests/k3s-node-labels.test.js index b22dec25..484ae811 100644 --- a/platform/tests/k3s-node-labels.test.js +++ b/platform/tests/k3s-node-labels.test.js @@ -1,27 +1,28 @@ -import test from "node:test"; -import { loadFleet, readRepoText, assertContains } from "./_helpers.js"; +import test from 'node:test' +import { loadFleet, readRepoText, assertContains } from './_helpers.js' -test("k3s host definitions expose inventory derived node labels", async () => { - const fleet = await loadFleet(); +test('k3s host definitions expose inventory derived node labels', async () => { + const fleet = await loadFleet() for (const [nodeName, node] of Object.entries(fleet.nodes)) { - if (!node.target_roles.some((role) => role.startsWith("k3s-"))) continue; - const hostDefinition = await readRepoText("platform/nix/hosts", nodeName, "default.nix"); + if (!node.target_roles.some((role) => role.startsWith('k3s-'))) continue + const hostDefinition = await readRepoText('platform/nix/hosts', nodeName, 'default.nix') assertContains( hostDefinition, - "personalStack.k3sNodeLabels", + 'personalStack.k3sNodeLabels', `"personal-stack/site" = "${node.site}"`, `"personal-stack/node" = "${nodeName}"`, `"topology.kubernetes.io/region" = "${node.site}"`, - ); - for (const role of node.target_roles) assertContains(hostDefinition, `"personal-stack/role-${role}" = "true"`); - for (const capability of node.capabilities) assertContains(hostDefinition, `"personal-stack/capability-${capability}" = "true"`); + ) + for (const role of node.target_roles) assertContains(hostDefinition, `"personal-stack/role-${role}" = "true"`) + for (const capability of node.capabilities) + assertContains(hostDefinition, `"personal-stack/capability-${capability}" = "true"`) for (const gpu of node.gpus ?? []) { assertContains( hostDefinition, `"personal-stack/gpu-vendor-${gpu.vendor}" = "true"`, `"personal-stack/gpu-model-${gpu.model}" = "true"`, `"personal-stack/gpu-class-${gpu.class}" = "true"`, - ); + ) } } -}); +}) diff --git a/platform/tests/pi-image-build-script.test.js b/platform/tests/pi-image-build-script.test.js index aa7c6844..7a4a066b 100644 --- a/platform/tests/pi-image-build-script.test.js +++ b/platform/tests/pi-image-build-script.test.js @@ -1,13 +1,15 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import path from "node:path"; -import { readFile } from "node:fs/promises"; -import { repoPath, repoRoot, runProcess, tempDir, writeExecutable } from "./_helpers.js"; +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { readFile } from 'node:fs/promises' +import { repoPath, repoRoot, runProcess, tempDir, writeExecutable } from './_helpers.js' -test("build-pi-image targets the host specific sd image output", async () => { - const dir = await tempDir(); - const nixLog = path.join(dir, "nix-pi-image.log"); - const inventoryStub = await writeExecutable(path.join(dir, "inventory-pi-image"), ` +test('build-pi-image targets the host specific sd image output', async () => { + const dir = await tempDir() + const nixLog = path.join(dir, 'nix-pi-image.log') + const inventoryStub = await writeExecutable( + path.join(dir, 'inventory-pi-image'), + ` #!/usr/bin/env bash cat <<'EOF' NODE_NAME=enschede-pi-1 @@ -20,35 +22,41 @@ SSH_HOST=enschede-pi-1 SSH_USER=deploy SSH_PORT=2222 EOF -`); - const nixStub = await writeExecutable(path.join(dir, "nix-pi-image-stub"), ` +`, + ) + const nixStub = await writeExecutable( + path.join(dir, 'nix-pi-image-stub'), + ` #!/usr/bin/env bash printf '%s\\n' "$@" > "${nixLog}" -`); +`, + ) - const result = await runProcess(repoPath("platform/scripts/build/build-pi-image.sh"), ["enschede-pi-1"], { + const result = await runProcess(repoPath('platform/scripts/build/build-pi-image.sh'), ['enschede-pi-1'], { env: { DEPLOY_CONFIG_SCHEMA_BIN: inventoryStub, PLATFORM_NIX: nixStub, - PLATFORM_CURRENT_SYSTEM: "aarch64-linux", + PLATFORM_CURRENT_SYSTEM: 'aarch64-linux', }, - }); + }) - assert.equal(result.exitCode, 0, result.stderr); - assert.deepEqual((await readFile(nixLog, "utf8")).trimEnd().split("\n"), [ - "--extra-experimental-features", - "nix-command flakes", - "build", - `path:${repoPath("platform")}#piSdImages.enschede-pi-1`, - "--out-link", - "result-enschede-pi-1-sd-image", - "--print-build-logs", - ]); -}); + assert.equal(result.exitCode, 0, result.stderr) + assert.deepEqual((await readFile(nixLog, 'utf8')).trimEnd().split('\n'), [ + '--extra-experimental-features', + 'nix-command flakes', + 'build', + `path:${repoPath('platform')}#piSdImages.enschede-pi-1`, + '--out-link', + 'result-enschede-pi-1-sd-image', + '--print-build-logs', + ]) +}) -test("build-pi-image rejects non arm hosts", async () => { - const dir = await tempDir(); - const inventoryStub = await writeExecutable(path.join(dir, "inventory-non-pi-image"), ` +test('build-pi-image rejects non arm hosts', async () => { + const dir = await tempDir() + const inventoryStub = await writeExecutable( + path.join(dir, 'inventory-non-pi-image'), + ` #!/usr/bin/env bash cat <<'EOF' NODE_NAME=frankfurt-contabo-1 @@ -61,16 +69,20 @@ SSH_HOST=167.86.79.203 SSH_USER=deploy SSH_PORT=2222 EOF -`); - const nixStub = await writeExecutable(path.join(dir, "nix-non-pi-image-stub"), ` +`, + ) + const nixStub = await writeExecutable( + path.join(dir, 'nix-non-pi-image-stub'), + ` #!/usr/bin/env bash echo should-not-run >&2 exit 99 -`); - const result = await runProcess(repoPath("platform/scripts/build/build-pi-image.sh"), ["frankfurt-contabo-1"], { +`, + ) + const result = await runProcess(repoPath('platform/scripts/build/build-pi-image.sh'), ['frankfurt-contabo-1'], { cwd: repoRoot, env: { DEPLOY_CONFIG_SCHEMA_BIN: inventoryStub, PLATFORM_NIX: nixStub }, - }); - assert.equal(result.exitCode, 1); - assert.match(result.stderr, /not an arm64 Raspberry Pi image target/); -}); + }) + assert.equal(result.exitCode, 1) + assert.match(result.stderr, /not an arm64 Raspberry Pi image target/) +}) diff --git a/platform/tests/scaffold.test.js b/platform/tests/scaffold.test.js index ccf96e17..a67100cb 100644 --- a/platform/tests/scaffold.test.js +++ b/platform/tests/scaffold.test.js @@ -1,28 +1,29 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; -import { repoPath } from "./_helpers.js"; +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { repoPath } from './_helpers.js' -test("platform scaffold exists for nix and flux bootstrap", () => { +test('platform scaffold exists for nix and flux bootstrap', () => { for (const file of [ - "platform/flake.nix", - "platform/nix/profiles/control-plane.nix", - "platform/nix/profiles/worker.nix", - "platform/nix/profiles/utility.nix", - "platform/nix/profiles/gpu-nvidia.nix", - "platform/nix/modules/base/default.nix", - "platform/nix/modules/services/game-streaming.nix", - "platform/nix/authorized-keys/README.md", - "platform/nix/modules/image/raspberry-pi-sd-image.nix", - "platform/nix/hosts/frankfurt-contabo-1/default.nix", - "platform/nix/hosts/frankfurt-contabo-1/disko.nix", - "platform/scripts/install/install-host.sh", - "platform/scripts/deploy/deploy-host.sh", - "platform/scripts/bootstrap/bootstrap-tailnet.sh", - "platform/scripts/bootstrap/bootstrap-k3s-worker.sh", - "platform/scripts/build/build-pi-image.sh", - "platform/scripts/game-streaming/copy-t1000-emulation-to-gtx.sh", - "platform/cluster/bootstrap/game-streaming-playbook.md", - "platform/cluster/flux/clusters/production/kustomization.yaml", - ]) assert.ok(existsSync(repoPath(file)), `${file} should exist`); -}); + 'platform/flake.nix', + 'platform/nix/profiles/control-plane.nix', + 'platform/nix/profiles/worker.nix', + 'platform/nix/profiles/utility.nix', + 'platform/nix/profiles/gpu-nvidia.nix', + 'platform/nix/modules/base/default.nix', + 'platform/nix/modules/services/game-streaming.nix', + 'platform/nix/authorized-keys/README.md', + 'platform/nix/modules/image/raspberry-pi-sd-image.nix', + 'platform/nix/hosts/frankfurt-contabo-1/default.nix', + 'platform/nix/hosts/frankfurt-contabo-1/disko.nix', + 'platform/scripts/install/install-host.sh', + 'platform/scripts/deploy/deploy-host.sh', + 'platform/scripts/bootstrap/bootstrap-tailnet.sh', + 'platform/scripts/bootstrap/bootstrap-k3s-worker.sh', + 'platform/scripts/build/build-pi-image.sh', + 'platform/scripts/game-streaming/copy-t1000-emulation-to-gtx.sh', + 'platform/cluster/bootstrap/game-streaming-playbook.md', + 'platform/cluster/flux/clusters/production/kustomization.yaml', + ]) + assert.ok(existsSync(repoPath(file)), `${file} should exist`) +}) diff --git a/platform/tests/tailnet-bootstrap.test.js b/platform/tests/tailnet-bootstrap.test.js index 11977a67..b02bc038 100644 --- a/platform/tests/tailnet-bootstrap.test.js +++ b/platform/tests/tailnet-bootstrap.test.js @@ -1,28 +1,49 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { loadFleet, readRepoText, assertContains } from "./_helpers.js"; +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadFleet, readRepoText, assertContains } from './_helpers.js' -test("platform inventory no longer models headscale as a planned service", async () => { - const fleet = await loadFleet(); - const hostNativeServices = Object.values(fleet.service_intent.host_native).flat(); - const placementServices = [...fleet.placement_intent.frankfurt_only, ...fleet.placement_intent.enschede_only]; - const exposedServices = [...fleet.exposure_intent.public, ...fleet.exposure_intent.public_and_lan, ...fleet.exposure_intent.internal_only, ...fleet.exposure_intent.lan_only]; - assert.ok(!hostNativeServices.includes("headscale")); - assert.ok(!placementServices.includes("headscale")); - assert.ok(!exposedServices.includes("headscale")); - assert.ok(!("headscale" in fleet.access_intent.host_labels)); -}); +test('platform inventory no longer models headscale as a planned service', async () => { + const fleet = await loadFleet() + const hostNativeServices = Object.values(fleet.service_intent.host_native).flat() + const placementServices = [...fleet.placement_intent.frankfurt_only, ...fleet.placement_intent.enschede_only] + const exposedServices = [ + ...fleet.exposure_intent.public, + ...fleet.exposure_intent.public_and_lan, + ...fleet.exposure_intent.internal_only, + ...fleet.exposure_intent.lan_only, + ] + assert.ok(!hostNativeServices.includes('headscale')) + assert.ok(!placementServices.includes('headscale')) + assert.ok(!exposedServices.includes('headscale')) + assert.ok(!('headscale' in fleet.access_intent.host_labels)) +}) -test("bootstrap docs describe Tailscale admin console auth key flow", async () => { - const platformReadme = await readRepoText("platform/README.md"); - const bootstrapReadme = await readRepoText("platform/cluster/bootstrap/README.md"); - const tailnetPlaybook = await readRepoText("platform/cluster/bootstrap/tailscale-tailnet-playbook.md"); - assertContains(platformReadme, "hosted `Tailscale` admin console", "bootstrap-tailnet.sh"); - assertContains(bootstrapReadme, "tailscale-tailnet-playbook.md"); - assertContains(tailnetPlaybook, "one-off auth key", "Tailscale admin console", "bootstrap-tailnet.sh ", "MagicDNS"); -}); +test('bootstrap docs describe Tailscale admin console auth key flow', async () => { + const platformReadme = await readRepoText('platform/README.md') + const bootstrapReadme = await readRepoText('platform/cluster/bootstrap/README.md') + const tailnetPlaybook = await readRepoText('platform/cluster/bootstrap/tailscale-tailnet-playbook.md') + assertContains(platformReadme, 'hosted `Tailscale` admin console', 'bootstrap-tailnet.sh') + assertContains(bootstrapReadme, 'tailscale-tailnet-playbook.md') + assertContains( + tailnetPlaybook, + 'one-off auth key', + 'Tailscale admin console', + 'bootstrap-tailnet.sh ', + 'MagicDNS', + ) +}) -test("tailnet bootstrap helper expects an auth key and runs tailscale up remotely", async () => { - const helperScript = await readRepoText("platform/scripts/bootstrap/bootstrap-tailnet.sh"); - assertContains(helperScript, "TS_AUTH_KEY", "PLATFORM_SSH_IDENTITY_FILE", "BOOTSTRAP_SSH_HOST", "require_platform_ssh_identity_file_if_set", "tailscale up", "--auth-key=", "--hostname=", "tailscale status"); -}); +test('tailnet bootstrap helper expects an auth key and runs tailscale up remotely', async () => { + const helperScript = await readRepoText('platform/scripts/bootstrap/bootstrap-tailnet.sh') + assertContains( + helperScript, + 'TS_AUTH_KEY', + 'PLATFORM_SSH_IDENTITY_FILE', + 'BOOTSTRAP_SSH_HOST', + 'require_platform_ssh_identity_file_if_set', + 'tailscale up', + '--auth-key=', + '--hostname=', + 'tailscale status', + ) +}) diff --git a/platform/tests/vault-bootstrap-coverage.test.js b/platform/tests/vault-bootstrap-coverage.test.js index 9e9e85c8..5575a4f0 100644 --- a/platform/tests/vault-bootstrap-coverage.test.js +++ b/platform/tests/vault-bootstrap-coverage.test.js @@ -1,44 +1,55 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import path from "node:path"; -import { filesUnder, readRepoText, repoPath, repoRoot, relativePath } from "./_helpers.js"; +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { filesUnder, readRepoText, repoPath, repoRoot, relativePath } from './_helpers.js' -test("every spring vault database role referenced by a service has a matching bootstrap role", async () => { - const roles = await collectDeclaredVaultDatabaseRoles(); - assert.ok(roles.length > 0, "expected to find at least one spring.cloud.vault.database.role declaration in a service application.yml"); - const bootstrap = await readRepoText("platform/cluster/flux/apps/data/vault/bootstrap-auth.sh"); - const missing = roles.filter(([, role]) => !bootstrap.includes(`vault write database/roles/${role}`)); - assert.deepEqual(missing, []); -}); +test('every spring vault database role referenced by a service has a matching bootstrap role', async () => { + const roles = await collectDeclaredVaultDatabaseRoles() + assert.ok( + roles.length > 0, + 'expected to find at least one spring.cloud.vault.database.role declaration in a service application.yml', + ) + const bootstrap = await readRepoText('platform/cluster/flux/apps/data/vault/bootstrap-auth.sh') + const missing = roles.filter(([, role]) => !bootstrap.includes(`vault write database/roles/${role}`)) + assert.deepEqual(missing, []) +}) -test("every referenced role is listed in allowed_roles on database config postgres", async () => { - const roles = await collectDeclaredVaultDatabaseRoles(); - const bootstrap = await readRepoText("platform/cluster/flux/apps/data/vault/bootstrap-auth.sh"); - const allowedRolesLine = bootstrap.split("\n").find((line) => line.includes("allowed_roles=")); - assert.ok(allowedRolesLine, "no allowed_roles= line found in bootstrap-auth.sh"); - const allowed = /allowed_roles="([^"]+)"/.exec(allowedRolesLine)?.[1]?.split(",").map((item) => item.trim()); - assert.ok(allowed, `could not parse allowed_roles value out of: ${allowedRolesLine}`); - const notAllowed = roles.map(([, role]) => role).filter((role) => !allowed.includes(role)); - assert.deepEqual(notAllowed, []); -}); +test('every referenced role is listed in allowed_roles on database config postgres', async () => { + const roles = await collectDeclaredVaultDatabaseRoles() + const bootstrap = await readRepoText('platform/cluster/flux/apps/data/vault/bootstrap-auth.sh') + const allowedRolesLine = bootstrap.split('\n').find((line) => line.includes('allowed_roles=')) + assert.ok(allowedRolesLine, 'no allowed_roles= line found in bootstrap-auth.sh') + const allowed = /allowed_roles="([^"]+)"/ + .exec(allowedRolesLine)?.[1] + ?.split(',') + .map((item) => item.trim()) + assert.ok(allowed, `could not parse allowed_roles value out of: ${allowedRolesLine}`) + const notAllowed = roles.map(([, role]) => role).filter((role) => !allowed.includes(role)) + assert.deepEqual(notAllowed, []) +}) async function collectDeclaredVaultDatabaseRoles() { - const serviceFiles = (await filesUnder(repoPath("services"))) - .filter((file) => path.basename(file) === "application.yml" && relativePath(repoRoot, file).includes("/src/main/resources/")); - const roles = []; + const serviceFiles = (await filesUnder(repoPath('services'))).filter( + (file) => + path.basename(file) === 'application.yml' && relativePath(repoRoot, file).includes('/src/main/resources/'), + ) + const roles = [] for (const file of serviceFiles) { - const role = await extractDatabaseRole(file); - if (role) roles.push([relativePath(repoRoot, file).split("/")[1], role]); + const role = await extractDatabaseRole(file) + if (role) roles.push([relativePath(repoRoot, file).split('/')[1], role]) } - return roles; + return roles } async function extractDatabaseRole(file) { - const text = await readRepoText(relativePath(repoRoot, file)); - const databaseBlock = /spring:\s*\n(?:.*\n)*?\s+cloud:\s*\n(?:.*\n)*?\s+vault:\s*\n(?:.*\n)*?\s+database:\s*\n((?:\s+.+\n)+)/m.exec(text)?.[1]; - if (!databaseBlock) return null; - const roleLine = databaseBlock.split("\n").find((line) => line.trim().startsWith("role:")); - if (!roleLine) return null; - const raw = roleLine.split("role:")[1].trim(); - return /\$\{[^:}]+:([^}]+)\}/.exec(raw)?.[1]?.trim() ?? raw.replace(/^["']|["']$/g, ""); + const text = await readRepoText(relativePath(repoRoot, file)) + const databaseBlock = + /spring:\s*\n(?:.*\n)*?\s+cloud:\s*\n(?:.*\n)*?\s+vault:\s*\n(?:.*\n)*?\s+database:\s*\n((?:\s+.+\n)+)/m.exec( + text, + )?.[1] + if (!databaseBlock) return null + const roleLine = databaseBlock.split('\n').find((line) => line.trim().startsWith('role:')) + if (!roleLine) return null + const raw = roleLine.split('role:')[1].trim() + return /\$\{[^:}]+:([^}]+)\}/.exec(raw)?.[1]?.trim() ?? raw.replace(/^["']|["']$/g, '') } diff --git a/platform/tests/vso-coverage.test.js b/platform/tests/vso-coverage.test.js index 254eaf23..9ce816ba 100644 --- a/platform/tests/vso-coverage.test.js +++ b/platform/tests/vso-coverage.test.js @@ -1,56 +1,63 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import path from "node:path"; -import { filesUnder, readRepoText, repoPath, repoRoot, relativePath, yamlDocs } from "./_helpers.js"; +import test from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import { filesUnder, readRepoText, repoPath, repoRoot, relativePath, yamlDocs } from './_helpers.js' -test("every namespace owning a VSO secret resource is allow-listed in the vso role", async () => { - const namespaces = await collectVsoSecretNamespaces(); - assert.ok(namespaces.length > 0, "expected to find at least one VaultStaticSecret/VaultDynamicSecret manifest under platform/cluster/flux"); - const bound = await parseVsoRoleBoundNamespaces(); - const missing = namespaces.filter(({ namespace }) => !bound.includes(namespace)); - assert.deepEqual(missing, []); -}); +test('every namespace owning a VSO secret resource is allow-listed in the vso role', async () => { + const namespaces = await collectVsoSecretNamespaces() + assert.ok( + namespaces.length > 0, + 'expected to find at least one VaultStaticSecret/VaultDynamicSecret manifest under platform/cluster/flux', + ) + const bound = await parseVsoRoleBoundNamespaces() + const missing = namespaces.filter(({ namespace }) => !bound.includes(namespace)) + assert.deepEqual(missing, []) +}) -test("every namespace owning a VSO secret resource has a vault-secrets-operator ServiceAccount manifest", async () => { - const namespaces = await collectVsoSecretNamespaces(); - const serviceAccountNamespaces = await collectVsoServiceAccountNamespaces(); - const missing = namespaces.filter(({ namespace }) => !serviceAccountNamespaces.has(namespace)); - assert.deepEqual(missing, []); -}); +test('every namespace owning a VSO secret resource has a vault-secrets-operator ServiceAccount manifest', async () => { + const namespaces = await collectVsoSecretNamespaces() + const serviceAccountNamespaces = await collectVsoServiceAccountNamespaces() + const missing = namespaces.filter(({ namespace }) => !serviceAccountNamespaces.has(namespace)) + assert.deepEqual(missing, []) +}) async function collectVsoSecretNamespaces() { - const fluxApps = repoPath("platform/cluster/flux/apps"); - const files = (await filesUnder(fluxApps)).filter((file) => [".yaml", ".yml"].includes(path.extname(file))); - const refs = []; + const fluxApps = repoPath('platform/cluster/flux/apps') + const files = (await filesUnder(fluxApps)).filter((file) => ['.yaml', '.yml'].includes(path.extname(file))) + const refs = [] for (const file of files) { for (const doc of yamlDocs(await readRepoText(relativePath(repoRoot, file)))) { - if (doc.kind === "VaultStaticSecret" || doc.kind === "VaultDynamicSecret") { - if (doc.metadata?.namespace) refs.push({ namespace: doc.metadata.namespace, sourcePath: relativePath(repoRoot, file) }); + if (doc.kind === 'VaultStaticSecret' || doc.kind === 'VaultDynamicSecret') { + if (doc.metadata?.namespace) + refs.push({ namespace: doc.metadata.namespace, sourcePath: relativePath(repoRoot, file) }) } } } - return refs; + return refs } async function collectVsoServiceAccountNamespaces() { - const fluxApps = repoPath("platform/cluster/flux/apps"); - const files = (await filesUnder(fluxApps)).filter((file) => [".yaml", ".yml"].includes(path.extname(file))); - const namespaces = new Set(); + const fluxApps = repoPath('platform/cluster/flux/apps') + const files = (await filesUnder(fluxApps)).filter((file) => ['.yaml', '.yml'].includes(path.extname(file))) + const namespaces = new Set() for (const file of files) { for (const doc of yamlDocs(await readRepoText(relativePath(repoRoot, file)))) { - if (doc.kind === "ServiceAccount" && doc.metadata?.name === "vault-secrets-operator" && doc.metadata?.namespace) { - namespaces.add(doc.metadata.namespace); + if (doc.kind === 'ServiceAccount' && doc.metadata?.name === 'vault-secrets-operator' && doc.metadata?.namespace) { + namespaces.add(doc.metadata.namespace) } } } - return namespaces; + return namespaces } async function parseVsoRoleBoundNamespaces() { - const bootstrap = await readRepoText("platform/cluster/flux/apps/data/vault/bootstrap-auth.sh"); - const roleBlock = /vault\s+write\s+auth\/kubernetes\/role\/vso\s*\\?\n((?:\s*[^\n]+\\?\n)+)/.exec(bootstrap)?.[1]; - assert.ok(roleBlock, "could not find `vault write auth/kubernetes/role/vso` block in bootstrap-auth.sh"); - const value = /bound_service_account_namespaces="([^"]+)"/.exec(roleBlock)?.[1]; - assert.ok(value, "vso role block has no bound_service_account_namespaces=\"...\" line"); - return value.split(",").map((item) => item.trim()).filter(Boolean); + const bootstrap = await readRepoText('platform/cluster/flux/apps/data/vault/bootstrap-auth.sh') + const roleBlock = /vault\s+write\s+auth\/kubernetes\/role\/vso\s*\\?\n((?:\s*[^\n]+\\?\n)+)/.exec(bootstrap)?.[1] + assert.ok(roleBlock, 'could not find `vault write auth/kubernetes/role/vso` block in bootstrap-auth.sh') + const value = /bound_service_account_namespaces="([^"]+)"/.exec(roleBlock)?.[1] + assert.ok(value, 'vso role block has no bound_service_account_namespaces="..." line') + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2df4829f..9d6111bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,3 @@ packages: - 'services/auth-ui' - - 'services/assistant-ui' - 'services/app-ui' diff --git a/pre-commit-check.sh b/pre-commit-check.sh index 37edd5ca..b7db077f 100755 --- a/pre-commit-check.sh +++ b/pre-commit-check.sh @@ -4,7 +4,7 @@ set -euo pipefail SERVICES_INFRA="postgres valkey rabbitmq" -SERVICES_FULL="postgres valkey rabbitmq vault auth-api assistant-api traefik auth-ui assistant-ui app-ui n8n grafana uptime-kuma stalwart" +SERVICES_FULL="postgres valkey rabbitmq vault auth-api agents-api traefik auth-ui agents-ui app-ui n8n grafana uptime-kuma stalwart" STACK_STARTED=0 RED='\033[0;31m' @@ -21,8 +21,8 @@ trap 'rm -rf "$TMPDIR_JOBS"; teardown_stack' EXIT dump_stack_logs() { local services=( - postgres valkey rabbitmq vault auth-api assistant-api traefik - auth-ui assistant-ui app-ui n8n grafana uptime-kuma stalwart + postgres valkey rabbitmq vault auth-api agents-api traefik + auth-ui agents-ui app-ui n8n grafana uptime-kuma stalwart vault-oidc-init uptime-kuma-init ) @@ -122,7 +122,6 @@ verify_services_healthy() { verify_database_migrations() { log " Verifying database migrations..." docker exec personal-stack-postgres psql -U auth_user -d auth_db -c '\dt' | grep -q app_user - docker exec personal-stack-postgres psql -U assistant_user -d assistant_db -c '\dt' | grep -q conversation } # Run a named job in background, capturing output. Usage: run_job @@ -189,14 +188,13 @@ pnpm install --frozen-lockfile log "Stage 1: Lint, formatting, and security scans" run_job "lint-auth-api" ./gradlew :services:auth-api:detekt :services:auth-api:ktlintCheck -run_job "lint-assistant-api" ./gradlew :services:assistant-api:detekt :services:assistant-api:ktlintCheck run_job "lint-frontend" bash -lc 'pnpm -r lint --max-warnings 0 && pnpm format:check' run_job "scan-trivy" run_trivy_scan run_job "scan-pnpm-audit" run_pnpm_audit run_job "scan-gitleaks" run_gitleaks_scan wait_jobs "lint+security" \ - lint-auth-api lint-assistant-api lint-frontend \ + lint-auth-api lint-frontend \ scan-trivy scan-pnpm-audit scan-gitleaks # ───────────────────────────────────────────────────────────────── @@ -205,25 +203,22 @@ wait_jobs "lint+security" \ log "Stage 2: Unit tests, architecture checks, and Docker image builds" run_job "unit-auth-api" ./gradlew :services:auth-api:test -run_job "unit-assistant-api" ./gradlew :services:assistant-api:test run_job "unit-frontend" pnpm -r test -- --coverage run_job "arch-frontend" pnpm -r depcruise # Build Docker images in parallel while tests run (needed for stage 4) log " Building Docker images in parallel..." run_job "build-auth-api" docker build -f services/auth-api/Dockerfile -t personal-stack/auth-api:latest . -run_job "build-assistant-api" docker build -f services/assistant-api/Dockerfile -t personal-stack/assistant-api:latest . run_job "build-auth-ui" docker build -f services/auth-ui/Dockerfile -t personal-stack/auth-ui:latest . -run_job "build-assistant-ui" docker build --build-arg VITE_AUTH_URL=https://auth.jorisjonkers.test -f services/assistant-ui/Dockerfile -t personal-stack/assistant-ui:latest . run_job "build-app-ui" docker build --build-arg VITE_AUTH_URL=https://auth.jorisjonkers.test -f services/app-ui/Dockerfile -t personal-stack/app-ui:latest . wait_jobs "unit+arch+build" \ - unit-auth-api unit-assistant-api unit-frontend \ + unit-auth-api unit-frontend \ arch-frontend \ - build-auth-api build-assistant-api build-auth-ui build-assistant-ui build-app-ui + build-auth-api build-auth-ui build-app-ui log "Stage 2b: Kotlin architecture tests" -bash infra/scripts/run-strict-command.sh ./gradlew :services:auth-api:test :services:assistant-api:test --tests "*ArchitectureTest*" +bash infra/scripts/run-strict-command.sh ./gradlew :services:auth-api:test --tests "*ArchitectureTest*" log "Stage 'arch-kotlin' passed." # ───────────────────────────────────────────────────────────────── @@ -235,14 +230,12 @@ log " Starting infrastructure services..." docker compose up -d $SERVICES_INFRA --wait --wait-timeout 120 run_job "integ-auth-api" ./gradlew :services:auth-api:integrationTest -run_job "integ-assistant-api" ./gradlew :services:assistant-api:integrationTest -wait_jobs "integration" integ-auth-api integ-assistant-api +wait_jobs "integration" integ-auth-api run_job "coverage-auth-api" ./gradlew :services:auth-api:jacocoTestCoverageVerification -run_job "coverage-assistant-api" ./gradlew :services:assistant-api:jacocoTestCoverageVerification -wait_jobs "coverage" coverage-auth-api coverage-assistant-api +wait_jobs "coverage" coverage-auth-api log " Stopping infrastructure services..." docker compose down --remove-orphans --timeout 10 2>/dev/null || true @@ -279,9 +272,9 @@ check_route() { } if ! check_route app-ui "https://jorisjonkers.test/"; then dump_stack_logs; exit 1; fi if ! check_route auth-ui "https://auth.jorisjonkers.test/"; then dump_stack_logs; exit 1; fi -if ! check_route assistant-ui "https://assistant.jorisjonkers.test/"; then dump_stack_logs; exit 1; fi +if ! check_route agents-ui "https://agents.jorisjonkers.test/"; then dump_stack_logs; exit 1; fi if ! check_route auth-api "https://auth.jorisjonkers.test/api/actuator/health"; then dump_stack_logs; exit 1; fi -if ! check_route assistant-api "https://assistant.jorisjonkers.test/api/actuator/health"; then dump_stack_logs; exit 1; fi +if ! check_route agents-api "https://agents.jorisjonkers.test/api/actuator/health"; then dump_stack_logs; exit 1; fi if ! verify_database_migrations; then dump_stack_logs @@ -303,7 +296,7 @@ fi log " Running system tests..." if ! bash infra/scripts/run-strict-command.sh ./gradlew :services:system-tests:test \ -Dtest.auth-api.url=http://localhost:8081 \ - -Dtest.assistant-api.url=http://localhost:8082; then +; then dump_stack_logs exit 1 fi diff --git a/services/agent-gateway/Dockerfile b/services/agent-gateway/Dockerfile deleted file mode 100644 index 67fde46e..00000000 --- a/services/agent-gateway/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# syntax=docker/dockerfile:1 - -FROM gradle:9.4-jdk21-alpine AS build -WORKDIR /app - -COPY settings.gradle.kts build.gradle.kts gradle.properties* ./ -COPY gradle/ gradle/ -COPY services/auth-api/build.gradle.kts services/auth-api/ -COPY services/assistant-api/build.gradle.kts services/assistant-api/ -COPY services/knowledge-api/build.gradle.kts services/knowledge-api/ -COPY services/agent-gateway/build.gradle.kts services/agent-gateway/ -COPY services/system-tests/build.gradle.kts services/system-tests/ - -RUN --mount=type=secret,id=github_token \ - --mount=type=secret,id=github_actor \ - set -eu; \ - GITHUB_TOKEN="$(cat /run/secrets/github_token)"; \ - GITHUB_ACTOR="$(cat /run/secrets/github_actor)"; \ - export GITHUB_TOKEN GITHUB_ACTOR; \ - gradle :services:agent-gateway:dependencies --no-daemon || true - -COPY services/agent-gateway/src/main/ services/agent-gateway/src/main/ -RUN --mount=type=secret,id=github_token \ - --mount=type=secret,id=github_actor \ - set -eu; \ - GITHUB_TOKEN="$(cat /run/secrets/github_token)"; \ - GITHUB_ACTOR="$(cat /run/secrets/github_actor)"; \ - export GITHUB_TOKEN GITHUB_ACTOR; \ - gradle :services:agent-gateway:bootJar --no-daemon - -FROM bellsoft/liberica-runtime-container:jdk-21-slim-glibc -WORKDIR /app -ARG GIT_SHA=unknown -ENV SERVICE_VERSION=${GIT_SHA} -COPY --from=build /app/services/agent-gateway/build/libs/*.jar /app/agent-gateway.jar -EXPOSE 8090 -ENTRYPOINT ["java", "-XX:+UseZGC", "-XX:MaxRAMPercentage=75", "-jar", "/app/agent-gateway.jar"] diff --git a/services/agent-gateway/build.gradle.kts b/services/agent-gateway/build.gradle.kts deleted file mode 100644 index e1a152a7..00000000 --- a/services/agent-gateway/build.gradle.kts +++ /dev/null @@ -1,41 +0,0 @@ -plugins { - alias(libs.plugins.extratoast.spring) - alias(libs.plugins.extratoast.detekt) - alias(libs.plugins.extratoast.ktlint) - alias(libs.plugins.extratoast.testing) -} - -dependencies { - implementation(libs.kotlin.commons.observability) - implementation(libs.kotlin.commons.timing) - implementation(libs.kotlin.commons.web) - implementation("org.springframework:spring-aop") - implementation("org.aspectj:aspectjweaver:1.9.25.1") - implementation("org.springframework.boot:spring-boot-starter-websocket") - // Tracing runtime jars — same shape as auth-api / assistant-api so - // TimingAutoConfiguration in kotlin-commons-timing activates. - runtimeOnly("io.micrometer:micrometer-tracing-bridge-otel") - runtimeOnly("io.opentelemetry:opentelemetry-exporter-otlp") - testImplementation("org.awaitility:awaitility:4.3.0") -} - -// agent-gateway is the only service in the monorepo whose hot path is -// process exec (tmux / git / ssh) — every meaningful test for those -// classes needs the real binaries on PATH, which is the integration -// image's job. The exclusions below keep the 80 % jacoco bar honest -// for the classes that *are* unit-testable (process abstraction, the -// in-memory session registry, controllers via MockMvc, the log -// tailer, the WS envelope parser) and acknowledge that TmuxClient / -// GitClient / AgentAttachHandler are covered by container-level -// integration tests in the system-tests module rather than here. -// The Spring Boot main class is excluded by the testing convention for -// every service via the `**/*Application*` defaults. -@Suppress("UNCHECKED_CAST") -(extensions.getByName("jacocoExclusionPatterns") as ListProperty).addAll( - // Trailing `*.class` (not `.class`) sweeps any future Kotlin-inner - // companions; the outer-class-only form silently drops `Outer$Inner` - // entries from the exclusion. - "**/tmux/TmuxClient*.class", - "**/git/GitClient*.class", - "**/ws/AgentAttachHandler*.class", -) diff --git a/services/agent-gateway/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agentgateway/ContextLoadsIntegrationTest.kt b/services/agent-gateway/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agentgateway/ContextLoadsIntegrationTest.kt deleted file mode 100644 index b38dd274..00000000 --- a/services/agent-gateway/src/integrationTest/kotlin/com/jorisjonkers/personalstack/agentgateway/ContextLoadsIntegrationTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.context.ApplicationContext - -@Tag("integration") -@SpringBootTest( - properties = [ - "spring.main.web-application-type=none", - "management.tracing.enabled=false", - ], -) -class ContextLoadsIntegrationTest { - @Autowired - private lateinit var context: ApplicationContext - - @Test - fun `spring context boots with default config`() { - assertThat(context).isNotNull - assertThat(context.containsBean("agentSessionManager")).isTrue - assertThat(context.containsBean("tmuxClient")).isTrue - assertThat(context.containsBean("gitClient")).isTrue - } -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/AgentGatewayApplication.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/AgentGatewayApplication.kt deleted file mode 100644 index 4b0aae6d..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/AgentGatewayApplication.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.runApplication - -@SpringBootApplication -class AgentGatewayApplication - -fun main(args: Array) { - runApplication(*args) -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayConfig.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayConfig.kt deleted file mode 100644 index 6ad96452..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayConfig.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.config - -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.context.annotation.Configuration - -@Configuration -@EnableConfigurationProperties(GatewayProperties::class) -class GatewayConfig diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt deleted file mode 100644 index a158cdae..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.config - -import org.springframework.boot.context.properties.ConfigurationProperties - -private const val DEFAULT_LOG_CAP_BYTES = 8L * 1024 * 1024 -private const val DEFAULT_STAGED_INPUT_MAX_BYTES = 5L * 1024 * 1024 - -@ConfigurationProperties(prefix = "agent-gateway") -data class GatewayProperties( - val workspaceRoot: String, - val tmux: Tmux, - val cli: Cli, - val git: Git, - val stagedInputs: StagedInputs = StagedInputs(), -) { - data class Tmux( - val socketName: String, - val stateDir: String, - // Poll cadence for the pipe-pane log tailer. Lower = more - // responsive streamed output at the cost of more wakeups. - val tailIntervalMs: Long = 15, - // The pipe-pane log is only a streaming conduit (the live screen - // lives in tmux, scrollback in the browser), so it is truncated - // once it outgrows this cap to keep the runner disk bounded. - val logCapBytes: Long = DEFAULT_LOG_CAP_BYTES, - val logTrimIntervalSeconds: Long = 30, - ) - - data class Cli( - val claude: String, - val codex: String, - // The runner Pod is the outer sandbox. Docker-socket-enabled runners - // are host-equivalent by design for Docker/Testcontainers, while the - // CLIs still launch with every approval/permission/sandbox prompt - // bypassed. Kept as config so a flag rename upstream is a - // redeploy-free value flip. - val claudeArgs: List = emptyList(), - val codexArgs: List = emptyList(), - ) - - data class Git( - val deployKeyDir: String, - ) - - data class StagedInputs( - val dirName: String = ".agent-inputs", - val maxBytes: Long = DEFAULT_STAGED_INPUT_MAX_BYTES, - ) -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClient.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClient.kt deleted file mode 100644 index eebc5381..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClient.kt +++ /dev/null @@ -1,261 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.git - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import com.jorisjonkers.personalstack.agentgateway.process.ProcessRunner -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import java.io.File -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.attribute.PosixFilePermissions -import kotlin.io.path.Path - -/** - * Wraps git + gh for the runner. The deploy key lives at - * `agent-gateway.git.deploy-key-dir/private_key`; this client - * materialises it into a private file with 0600 and exports - * GIT_SSH_COMMAND so SSH operations can use it without polluting - * ~/.ssh. Runner boot also rewrites GitHub SSH remotes to HTTPS, - * so live clones carry a one-repo App-token allow-list for the - * credential helper. - * - * `gh` is used for the PR open step because issuing a PAT for the - * agent would be a wider blast radius than the per-repo deploy key - * needs to cover — gh authenticates via a GH_TOKEN env var that - * assistant-api injects per-Pod from a scoped fine-grained token. - */ -@Component -class GitClient( - private val runner: ProcessRunner, - private val props: GatewayProperties, -) { - private val log = LoggerFactory.getLogger(GitClient::class.java) - - companion object { - private const val DETAIL_CHARS = 500 - private const val RANDOM_SUFFIX_CHARS = 8 - } - - fun clone( - repoUrl: String, - intoDir: String, - branch: String? = null, - ): String { - val target = Path(intoDir) - if (Files.isDirectory(target.resolve(".git"))) { - return intoDir - } - val argv = - mutableListOf("git", "clone", "--depth", "50").apply { - if (branch != null) { - add("--branch") - add(branch) - } - add(repoUrl) - add(intoDir) - } - runner.run(argv, env = cloneEnv(repoUrl), timeoutSeconds = 300) - return intoDir - } - - fun checkoutNewBranch( - repoDir: String, - branch: String, - ) { - runner.run( - listOf("git", "checkout", "-b", branch), - cwd = File(repoDir), - ) - } - - fun push( - repoDir: String, - remote: String = "origin", - branch: String? = null, - ): String { - val argv = - mutableListOf("git", "push", "-u", remote).apply { - if (branch != null) add(branch) else add("HEAD") - } - return runner.run(argv, cwd = File(repoDir), env = sshEnv(), timeoutSeconds = 300).combined - } - - fun openPr( - repoDir: String, - title: String, - body: String, - base: String = "main", - ): String { - val argv = - listOf( - "gh", - "pr", - "create", - "--title", - title, - "--body", - body, - "--base", - base, - ) - return runner.run(argv, cwd = File(repoDir), env = ghEnv(), timeoutSeconds = 120).stdout.trim() - } - - data class VerifyResult( - val read: Boolean, - val write: Boolean, - val detail: String, - ) - - /** - * Probes deploy-key access without mutating the repo. Read is a plain - * `ls-remote`; write points a throwaway ref at an EXISTING commit and - * deletes it immediately, so no new objects are created and the default - * branch is never touched. An auth/permission denial is a legitimate - * {read|write:false} result, not an error — only genuinely unexpected - * failures (timeouts, malformed remote) surface as detail text. - */ - fun verify( - repoUrl: String, - branch: String? = null, - ): VerifyResult { - val env = sshEnv() - val lsArgs = mutableListOf("git", "ls-remote", repoUrl).apply { if (branch != null) add(branch) } - val ls = runner.run(lsArgs, env = env, timeoutSeconds = 60, checked = false) - if (ls.exitCode != 0) { - val detail = "ls-remote failed: ${ls.combined.trim().take(DETAIL_CHARS)}" - return VerifyResult(read = false, write = false, detail = detail) - } - - val sha = - tipSha(ls.stdout, branch) - ?: return VerifyResult( - read = true, - write = false, - detail = "read ok; no ref found to probe write against", - ) - - val (writeOk, writeDetail) = probeWrite(repoUrl, sha, env) - return VerifyResult(read = true, write = writeOk, detail = "read ok; $writeDetail") - } - - private fun probeWrite( - repoUrl: String, - sha: String, - env: Map, - ): Pair { - val probeRef = "refs/heads/_agent-keycheck-${randomSuffix()}" - try { - val push = - runner.run( - listOf("git", "push", repoUrl, "$sha:$probeRef"), - env = env, - timeoutSeconds = 60, - checked = false, - ) - val ok = push.exitCode == 0 - return ok to if (ok) "write ok" else "write denied: ${push.combined.trim().take(DETAIL_CHARS)}" - } finally { - deleteProbeRef(repoUrl, probeRef, env) - } - } - - private fun tipSha( - lsRemoteStdout: String, - branch: String?, - ): String? { - val lines = - lsRemoteStdout - .lineSequence() - .mapNotNull { line -> - val parts = line.trim().split('\t', limit = 2) - if (parts.size == 2 && parts[0].isNotBlank()) parts[0] to parts[1] else null - }.toList() - if (lines.isEmpty()) return null - val wantRef = branch?.let { "refs/heads/$it" } - return when { - wantRef != null -> lines.firstOrNull { it.second == wantRef }?.first - else -> lines.firstOrNull { it.second == "HEAD" }?.first ?: lines.first().first - } - } - - private fun deleteProbeRef( - repoUrl: String, - probeRef: String, - env: Map, - ) { - runCatching { - runner.run( - listOf("git", "push", repoUrl, ":$probeRef"), - env = env, - timeoutSeconds = 60, - checked = false, - ) - }.onFailure { log.warn("failed to delete probe ref {}: {}", probeRef, it.message) } - } - - private fun randomSuffix(): String = - java.util.UUID - .randomUUID() - .toString() - .substring(0, RANDOM_SUFFIX_CHARS) - - fun currentBranch(repoDir: String): String = - runner - .run( - listOf("git", "rev-parse", "--abbrev-ref", "HEAD"), - cwd = File(repoDir), - ).stdout - .trim() - - private fun sshEnv(): Map { - val key = ensureDeployKey() - val known = Path(props.git.deployKeyDir).resolve("known_hosts").toFile() - val sshOpts = - buildString { - append("ssh -i ${key.toAbsolutePath()} -o IdentitiesOnly=yes") - if (known.exists()) append(" -o UserKnownHostsFile=${known.absolutePath}") - } - return mapOf("GIT_SSH_COMMAND" to sshOpts) - } - - private fun cloneEnv(repoUrl: String): Map = - sshEnv().toMutableMap().also { env -> - env["AGENT_GITHUB_REPO_URL"] = repoUrl - githubSlug(repoUrl)?.let { env["REPO_ALLOW"] = it } - } - - private fun githubSlug(repoUrl: String): String? { - val normalized = - when { - repoUrl.startsWith("git@github.com:") -> repoUrl.removePrefix("git@github.com:") - repoUrl.startsWith("ssh://git@github.com/") -> repoUrl.removePrefix("ssh://git@github.com/") - repoUrl.startsWith("https://github.com/") -> repoUrl.removePrefix("https://github.com/") - repoUrl.startsWith("http://github.com/") -> repoUrl.removePrefix("http://github.com/") - else -> return null - }.removeSuffix(".git") - return normalized.takeIf { it.contains('/') } - } - - private fun ghEnv(): Map { - val token = System.getenv("GH_TOKEN") ?: System.getenv("GITHUB_TOKEN") ?: "" - return mapOf("GH_TOKEN" to token) - } - - private fun ensureDeployKey(): Path { - val source = Path(props.git.deployKeyDir).resolve("private_key") - if (!Files.exists(source)) { - error("deploy key missing at $source — assistant-api should have projected it") - } - // Stash in /tmp with 0600 — Secret-mounted files are owned by - // root and have permissive default modes that openssh refuses. - val target = Path("/tmp/agent-deploy-key") - if (!Files.exists(target) || Files.size(target) != Files.size(source)) { - Files.copy(source, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING) - } - runCatching { - Files.setPosixFilePermissions(target, PosixFilePermissions.fromString("rw-------")) - }.onFailure { log.warn("could not chmod 0600 deploy key: {}", it.message) } - return target - } -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJob.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJob.kt deleted file mode 100644 index 7a9e59d1..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJob.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.headless - -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind -import java.nio.file.Path -import java.time.Instant - -data class HeadlessJob( - val id: String, - val kind: AgentKind, - val status: HeadlessJobStatus, - val outputFile: Path, - val exitCode: Int? = null, - val createdAt: Instant, - val completedAt: Instant? = null, -) diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManager.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManager.kt deleted file mode 100644 index e81b226a..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManager.kt +++ /dev/null @@ -1,224 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.headless - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.DisposableBean -import org.springframework.stereotype.Component -import java.io.File -import java.nio.file.Files -import java.nio.file.Path -import java.time.Instant -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit - -/** - * Async registry for one-shot (headless) agent runs. Each job launches - * the CLI as a child process with stdin closed, streams stdout+stderr - * to a JSONL capture file in the gateway state dir, and updates status - * when the process exits or times out. - * - * Concurrency: one virtual thread per job; job state is updated via - * `ConcurrentHashMap.compute` so concurrent status reads are safe. - * Cancellation kills the OS process and marks the job CANCELLED. - * - * The job registry is in-memory — Pod restarts lose running jobs. The - * caller (assistant-api) should detect a missing job id and surface it - * as a FAILED status. - */ -@Component -class HeadlessJobManager( - private val props: GatewayProperties, - private val processFactory: ProcessFactory = DefaultProcessFactory, -) : DisposableBean { - private val log = LoggerFactory.getLogger(HeadlessJobManager::class.java) - private val jobs = ConcurrentHashMap() - private val processes = ConcurrentHashMap() - private val executor: ExecutorService = Executors.newVirtualThreadPerTaskExecutor() - - fun launch( - kind: AgentKind, - prompt: String, - workspacePath: String? = null, - cliSessionId: String? = null, - timeoutSeconds: Long = DEFAULT_TIMEOUT_SECONDS, - ): HeadlessJob { - val id = UUID.randomUUID().toString().substring(0, 8) - val cwd = File(workspacePath ?: props.workspaceRoot) - val stateDir = Path.of(props.tmux.stateDir).also { Files.createDirectories(it) } - val outputFile = stateDir.resolve("headless-$id.jsonl") - Files.createFile(outputFile) - val command = headlessCommandFor(kind, prompt, cliSessionId) - val job = - HeadlessJob( - id = id, - kind = kind, - status = HeadlessJobStatus.RUNNING, - outputFile = outputFile, - createdAt = Instant.now(), - ) - jobs[id] = job - executor.submit { runJob(id, command, cwd, outputFile.toFile(), timeoutSeconds) } - log.info("launched headless {} job {} in {}", kind, id, cwd) - return job - } - - fun get(id: String): HeadlessJob? = jobs[id] - - fun list(): List = jobs.values.sortedBy { it.createdAt } - - fun cancel(id: String): Boolean { - val process = processes.remove(id) ?: return jobs.containsKey(id) - process.destroyForcibly() - jobs.compute(id) { _, job -> - job?.copy(status = HeadlessJobStatus.CANCELLED, completedAt = Instant.now()) - } - log.info("cancelled headless job {}", id) - return true - } - - fun readOutput( - id: String, - maxChars: Int = MAX_OUTPUT_CHARS, - ): String { - val job = jobs[id] ?: return "" - return runCatching { - val bytes = Files.readAllBytes(job.outputFile) - val text = String(bytes, Charsets.UTF_8) - if (text.length <= maxChars) text else "…" + text.takeLast(maxChars) - }.getOrDefault("") - } - - override fun destroy() { - executor.shutdownNow() - } - - private fun runJob( - id: String, - command: List, - cwd: File, - outputFile: File, - timeoutSeconds: Long, - ) { - val process = startProcess(id, command, cwd) ?: return - processes[id] = process - try { - awaitAndCapture(id, process, outputFile, timeoutSeconds) - } catch (ex: InterruptedException) { - process.destroyForcibly() - jobs.compute(id) { _, job -> - job?.copy(status = HeadlessJobStatus.CANCELLED, completedAt = Instant.now()) - } - } finally { - processes.remove(id) - } - } - - private fun startProcess( - id: String, - command: List, - cwd: File, - ): Process? = - runCatching { - processFactory.start(command, cwd) - }.getOrElse { ex -> - log.error("headless job {} failed to start: {}", id, ex.message) - jobs.compute(id) { _, job -> - job?.copy(status = HeadlessJobStatus.FAILED, completedAt = Instant.now()) - } - null - } - - private fun awaitAndCapture( - id: String, - process: Process, - outputFile: File, - timeoutSeconds: Long, - ) { - // Async gobbler keeps the pipe drained so waitFor never hangs on a full buffer. - val gobbler = Thread.ofVirtual().start { process.inputStream.copyTo(outputFile.outputStream()) } - val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) - gobbler.join(GOBBLER_JOIN_MS) - if (!finished) { - process.destroyForcibly() - updateJob(id, HeadlessJobStatus.FAILED, TIMEOUT_EXIT_CODE) - log.warn("headless job {} timed out after {}s", id, timeoutSeconds) - } else { - val exitCode = process.exitValue() - val status = if (exitCode == 0) HeadlessJobStatus.COMPLETED else HeadlessJobStatus.FAILED - updateJob(id, status, exitCode) - log.info("headless job {} finished status={} exitCode={}", id, status, exitCode) - } - } - - private fun updateJob( - id: String, - status: HeadlessJobStatus, - exitCode: Int, - ) { - jobs.compute(id) { _, job -> - job?.copy(status = status, exitCode = exitCode, completedAt = Instant.now()) - } - } - - /** - * Build the one-shot CLI command for a headless run. Claude uses - * `-p` (print mode) with `--output-format stream-json` for machine- - * parseable streaming events. Codex uses `exec --json` plus the - * configured Codex CLI args; when a `cliSessionId` is explicitly - * provided it resumes that exact context via `exec resume --json`. - */ - private fun headlessCommandFor( - kind: AgentKind, - prompt: String, - cliSessionId: String?, - ): List = - when (kind) { - AgentKind.CLAUDE -> - listOf( - props.cli.claude, - "-p", - "--output-format", - "stream-json", - ) + (cliSessionId?.let { listOf("--resume", it) } ?: emptyList()) + - listOf("--", prompt) - - AgentKind.CODEX -> - if (cliSessionId != null) { - listOf(props.cli.codex, "exec", "resume") + - props.cli.codexArgs + - listOf("--json", cliSessionId, "--", prompt) - } else { - listOf(props.cli.codex, "exec") + - props.cli.codexArgs + - listOf("--json", "--", prompt) - } - - AgentKind.SHELL -> listOf("/bin/sh", "-c", prompt) - } - - fun interface ProcessFactory { - fun start( - command: List, - cwd: File, - ): Process - } - - companion object { - const val DEFAULT_TIMEOUT_SECONDS = 600L - const val MAX_OUTPUT_CHARS = 65_536 - private const val TIMEOUT_EXIT_CODE = -1 - private const val GOBBLER_JOIN_MS = 2_000L - - val DefaultProcessFactory = - ProcessFactory { command, cwd -> - ProcessBuilder(command) - .directory(cwd) - .redirectErrorStream(true) - .start() - } - } -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobStatus.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobStatus.kt deleted file mode 100644 index 50a0f084..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobStatus.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.headless - -enum class HeadlessJobStatus { RUNNING, COMPLETED, FAILED, CANCELLED } diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/process/ProcessRunner.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/process/ProcessRunner.kt deleted file mode 100644 index 79ac6180..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/process/ProcessRunner.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.process - -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import java.io.File -import java.util.concurrent.TimeUnit - -/** - * Thin wrapper around ProcessBuilder. Centralises stdout/stderr capture - * and timeout handling so tmux / git / claude / codex shell-outs share - * exactly one error path. - * - * Why a tiny custom runner rather than a library: the call shape is - * uniform (single argv list, optional working dir, optional env, capture - * combined output, fail fast on non-zero unless told otherwise), and a - * library would still get wrapped to enforce that shape. Twenty lines - * here saves a dependency. - */ -@Component -open class ProcessRunner { - private val log = LoggerFactory.getLogger(ProcessRunner::class.java) - - data class Result( - val exitCode: Int, - val stdout: String, - val stderr: String, - ) { - val combined: String get() = if (stderr.isEmpty()) stdout else stdout + "\n" + stderr - } - - open fun run( - argv: List, - cwd: File? = null, - env: Map = emptyMap(), - timeoutSeconds: Long = 30, - checked: Boolean = true, - ): Result { - log.debug("exec {} (cwd={})", argv, cwd) - val pb = - ProcessBuilder(argv).apply { - if (cwd != null) directory(cwd) - env.forEach { (k, v) -> environment()[k] = v } - redirectErrorStream(false) - } - val process = pb.start() - val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) - if (!finished) { - process.destroyForcibly() - throw ProcessTimeoutException("timed out after ${timeoutSeconds}s: $argv") - } - val stdout = process.inputStream.bufferedReader().use { it.readText() } - val stderr = process.errorStream.bufferedReader().use { it.readText() } - val result = Result(process.exitValue(), stdout, stderr) - if (checked && result.exitCode != 0) { - throw ProcessFailedException(argv, result) - } - return result - } -} - -class ProcessTimeoutException( - msg: String, -) : RuntimeException(msg) - -class ProcessFailedException( - val argv: List, - val result: ProcessRunner.Result, -) : RuntimeException("$argv exited ${result.exitCode}: ${result.stderr.take(STDERR_PREVIEW_CHARS)}") { - companion object { - private const val STDERR_PREVIEW_CHARS = 500 - } -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentKind.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentKind.kt deleted file mode 100644 index 0e27105d..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentKind.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.tmux - -enum class AgentKind { CLAUDE, CODEX, SHELL } diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSession.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSession.kt deleted file mode 100644 index 290f43dc..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSession.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.tmux - -import java.nio.file.Path -import java.time.Instant - -data class AgentSession( - val id: String, - val kind: AgentKind, - val tmuxSession: String, - val logFile: Path, - val cwd: String, - val createdAt: Instant, - // Native CLI session id for observability and future explicit - // continuation flows. Set by the gateway for Claude (from the - // --session-id flag); null for Shell sessions and for Codex until - // async discovery is implemented. - val cliSessionId: String? = null, -) diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt deleted file mode 100644 index a6a3978a..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.tmux - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import jakarta.annotation.PreDestroy -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import java.nio.channels.FileChannel -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.StandardOpenOption -import java.time.Instant -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.io.path.Path - -/** - * In-memory registry of active agents on this Pod. The Pod is the - * unit of restart, and assistant-api owns the source-of-truth state, - * so persisting here would double-bookkeeper for no win. - * - * Concurrency: ConcurrentHashMap is enough — the only racy operation - * is spawn-vs-stop on the same id, and that's a caller bug worth - * surfacing as a 409. - */ -@Component -class AgentSessionManager( - private val tmux: TmuxClient, - private val props: GatewayProperties, -) { - private val log = LoggerFactory.getLogger(AgentSessionManager::class.java) - private val sessions = ConcurrentHashMap() - - // The pipe-pane log only conducts the live stream, so it is capped - // rather than kept whole: this trims any session log that outgrows - // its cap so a long-lived agent cannot fill the runner disk. Active - // tailers restart from the new beginning on the next poll. - private val trimmer = - Executors.newSingleThreadScheduledExecutor { r -> - Thread(r, "agent-log-trimmer").apply { isDaemon = true } - } - - init { - val period = props.tmux.logTrimIntervalSeconds - trimmer.scheduleWithFixedDelay(::trimOversizedLogs, period, period, TimeUnit.SECONDS) - } - - @PreDestroy - fun shutdown() { - trimmer.shutdownNow() - } - - private fun trimOversizedLogs() { - val cap = props.tmux.logCapBytes - sessions.values.forEach { session -> - runCatching { - val file = session.logFile - if (Files.exists(file) && Files.size(file) > cap) { - FileChannel.open(file, StandardOpenOption.WRITE).use { it.truncate(0) } - log.info("trimmed agent {} log past {} bytes", session.id, cap) - } - }.onFailure { log.warn("trim of {} failed: {}", session.logFile, it.message) } - } - } - - fun spawn( - kind: AgentKind, - workspacePath: String? = null, - ): AgentSession { - val id = UUID.randomUUID().toString().substring(0, 8) - val tmuxSession = "agent-$id" - val cwd = workspacePath ?: props.workspaceRoot - val stateDir = tmux.ensureStateDir() - val logFile: Path = stateDir.resolve("$tmuxSession.log") - Files.deleteIfExists(logFile) - Files.createFile(logFile) - - val (command, cliSessionId) = commandAndSessionIdFor(kind) - tmux.newSession(tmuxSession, command, cwd) - tmux.startPipeToFile(tmuxSession, logFile) - - val session = - AgentSession( - id = id, - kind = kind, - tmuxSession = tmuxSession, - logFile = logFile, - cwd = cwd, - createdAt = Instant.now(), - cliSessionId = cliSessionId, - ) - sessions[id] = session - log.info("spawned {} agent {} ({}) in {} cliSessionId={}", kind, id, tmuxSession, cwd, cliSessionId) - return session - } - - fun stop(id: String): Boolean { - val session = sessions.remove(id) ?: return false - tmux.killSession(session.tmuxSession) - log.info("stopped agent {}", id) - return true - } - - fun get(id: String): AgentSession? = sessions[id] - - fun list(): List = sessions.values.sortedBy { it.createdAt } - - fun send( - id: String, - input: String, - enter: Boolean = true, - ) { - val session = sessions[id] ?: error("unknown agent: $id") - tmux.sendKeys(session.tmuxSession, input, enter = enter) - } - - fun stageInput( - id: String, - content: String, - requestedName: String?, - ): StagedInput { - val session = sessions[id] ?: error("unknown agent: $id") - val bytes = content.toByteArray(Charsets.UTF_8) - require(bytes.isNotEmpty()) { "staged input content is empty" } - require(bytes.size.toLong() <= props.stagedInputs.maxBytes) { - "staged input exceeds ${props.stagedInputs.maxBytes} bytes" - } - - val root = Path(session.cwd).toAbsolutePath().normalize() - val dir = root.resolve(props.stagedInputs.dirName).normalize() - require(dir.startsWith(root)) { "staged input directory must stay inside the workspace" } - - Files.createDirectories(dir) - val safeName = safeFileName(requestedName) - val fileName = "${timestamp()}-${UUID.randomUUID().toString().take(ID_PREVIEW_CHARS)}-$safeName" - val target = dir.resolve(fileName).normalize() - require(target.startsWith(dir)) { "staged input path must stay inside the staging directory" } - - Files.write(target, bytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE) - log.info("staged {} bytes for agent {} at {}", bytes.size, id, target) - return StagedInput(path = target.toString(), bytes = bytes.size.toLong(), name = safeName) - } - - fun capture( - id: String, - historyLines: Int = 1_000, - ): String { - val session = sessions[id] ?: error("unknown agent: $id") - return tmux.capture(session.tmuxSession, historyLines) - } - - fun captureWithEscapes(id: String): String { - val session = sessions[id] ?: error("unknown agent: $id") - return tmux.captureWithEscapes(session.tmuxSession) - } - - fun resize( - id: String, - cols: Int, - rows: Int, - ) { - val session = sessions[id] ?: error("unknown agent: $id") - tmux.resize(session.tmuxSession, cols, rows) - } - - /** - * Build the CLI command and return the native session id alongside it. - * - * For Claude: a fresh UUID is generated and passed as `--session-id - * ` so the CLI process has a stable native identity without - * inheriting another conversation. - * - * For Codex: launch the interactive CLI directly. `codex resume --last` - * is intentionally not used here because it can attach a new gateway - * agent to a different saved Codex session on the shared credentials PVC. - * - * Shell has no session id. - */ - private fun commandAndSessionIdFor(kind: AgentKind): Pair, String?> = - when (kind) { - AgentKind.CLAUDE -> { - val cliSessionId = UUID.randomUUID().toString() - val cmd = - listOf(props.cli.claude) + props.cli.claudeArgs + - listOf("--session-id", cliSessionId) - cmd to cliSessionId - } - AgentKind.CODEX -> (listOf(props.cli.codex) + props.cli.codexArgs) to null - AgentKind.SHELL -> listOf("/bin/bash", "-l") to null - } - - private fun safeFileName(requestedName: String?): String { - val raw = requestedName?.trim()?.takeIf { it.isNotBlank() } ?: DEFAULT_STAGED_INPUT_NAME - val leaf = raw.replace('\\', '/').substringAfterLast('/') - val safe = - SAFE_NAME_CHARS - .replace(leaf, "-") - .trim('.', '-', '_') - .take(MAX_STAGED_INPUT_NAME_CHARS) - return safe.takeIf { it.isNotBlank() } ?: DEFAULT_STAGED_INPUT_NAME - } - - private fun timestamp(): String = STAGED_INPUT_TIMESTAMP.format(Instant.now()) - - companion object { - private const val DEFAULT_STAGED_INPUT_NAME = "input.txt" - private const val ID_PREVIEW_CHARS = 8 - private const val MAX_STAGED_INPUT_NAME_CHARS = 80 - private val SAFE_NAME_CHARS = Regex("[^A-Za-z0-9._-]+") - private val STAGED_INPUT_TIMESTAMP = - DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS").withZone(ZoneOffset.UTC) - } -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailer.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailer.kt deleted file mode 100644 index da89a5d7..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailer.kt +++ /dev/null @@ -1,185 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.tmux - -import org.slf4j.LoggerFactory -import java.io.RandomAccessFile -import java.nio.file.Path -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicLong - -/** - * Polls an append-only log file every `intervalMs` and streams any new - * output as bounded text chunks. tmux's pipe-pane writes raw pane output - * here, so this is the streaming-from-PTY mechanism without needing a - * fifo or a JNI tmux library. - * - * The output is relayed straight through to the browser, never buffered - * whole, so the terminal stream costs no standing heap. Two details make - * that safe: - * - * - **Bounded frames.** Each emitted chunk is at most [maxChunkChars] - * characters, so a JSON-wrapped output frame stays well under the - * default 8 KiB WebSocket buffer. A noisy agent that prints megabytes - * streams as many small frames rather than one frame that would force - * a multi-megabyte receive buffer per session. - * - **UTF-8 boundary carry.** A read can end midway through a multi-byte - * codepoint (the box-drawing glyphs a TUI emits are three bytes); the - * trailing partial bytes are held back and prepended to the next read - * so a character is never decoded — or split across frames — in halves. - * - * Single-tailer-per-file model: each WS attach gets its own tailer - * starting at the current end of file, so a freshly-attached client - * streams only new bytes. The whole-screen snapshot that gives the client - * its initial state is sent separately on attach. If the session's log is - * truncated to stay under its disk cap the tailer restarts from the new - * beginning rather than stalling. - */ -class LogTailer( - private val file: Path, - private val intervalMs: Long = 40, - private val maxChunkChars: Int = MAX_CHUNK_CHARS, - private val onText: (String) -> Unit, -) : AutoCloseable { - private val log = LoggerFactory.getLogger(LogTailer::class.java) - private val offset = AtomicLong(0) - - // Trailing bytes of an incomplete UTF-8 sequence held back from the - // previous read until the rest of the codepoint arrives. - private var carry = ByteArray(0) - - private val executor: ScheduledExecutorService = - Executors.newSingleThreadScheduledExecutor { r -> - Thread(r, "log-tailer-${file.fileName}").apply { isDaemon = true } - } - - fun start() { - offset.set(currentLength()) - executor.scheduleWithFixedDelay(::poll, 0, intervalMs, TimeUnit.MILLISECONDS) - } - - private fun currentLength(): Long = - try { - RandomAccessFile(file.toFile(), "r").use { it.length() } - } catch (e: java.io.IOException) { - log.warn("sizing {} failed: {}", file, e.message) - 0L - } - - private fun poll() { - try { - RandomAccessFile(file.toFile(), "r").use { raf -> - val length = raf.length() - var currentOffset = offset.get() - if (length < currentOffset) { - // The log was truncated to stay under its disk cap; - // restart from the new beginning. - offset.set(0) - carry = ByteArray(0) - currentOffset = 0 - } - if (length <= currentOffset) return - raf.seek(currentOffset) - val toRead = (length - currentOffset).coerceAtMost(MAX_READ_BYTES.toLong()).toInt() - val raw = ByteArray(toRead) - val read = raf.read(raw) - if (read <= 0) return - offset.addAndGet(read.toLong()) - emit(raw, read) - } - } catch (e: java.io.IOException) { - log.warn("tail of {} failed: {}", file, e.message) - } - } - - private fun emit( - raw: ByteArray, - read: Int, - ) { - val buf = if (carry.isEmpty()) raw.copyOf(read) else carry + raw.copyOf(read) - val complete = completeUtf8Length(buf) - carry = if (complete < buf.size) buf.copyOfRange(complete, buf.size) else EMPTY - if (complete == 0) return - chunked(String(buf, 0, complete, Charsets.UTF_8), maxChunkChars, onText) - } - - override fun close() { - executor.shutdownNow() - } - - companion object { - const val MAX_CHUNK_CHARS = 1024 - private const val MAX_READ_BYTES = 64 * 1024 - private const val BYTE_TO_UNSIGNED_MASK = 0xFF - private const val NO_UTF8_LEAD_BYTE = -1 - private const val UTF8_CONTINUATION_MASK = 0xC0 - private const val UTF8_CONTINUATION_PREFIX = 0x80 - private const val UTF8_SINGLE_BYTE_LIMIT = 0x80 - private const val UTF8_TWO_BYTE_LEAD_START = 0xC0 - private const val UTF8_TWO_BYTE_LEAD_END = 0xDF - private const val UTF8_THREE_BYTE_LEAD_START = 0xE0 - private const val UTF8_THREE_BYTE_LEAD_END = 0xEF - private const val UTF8_FOUR_BYTE_LEAD_START = 0xF0 - private const val UTF8_FOUR_BYTE_LEAD_END = 0xF7 - private const val UTF8_SINGLE_BYTE_SEQUENCE_LENGTH = 1 - private const val UTF8_TWO_BYTE_SEQUENCE_LENGTH = 2 - private const val UTF8_THREE_BYTE_SEQUENCE_LENGTH = 3 - private const val UTF8_FOUR_BYTE_SEQUENCE_LENGTH = 4 - private val EMPTY = ByteArray(0) - - /** - * Index of the first byte of an incomplete trailing UTF-8 - * sequence, or `buf.size` if the buffer ends on a complete - * codepoint. Malformed lead bytes are treated as complete so the - * decoder substitutes them rather than the carry growing forever. - */ - internal fun completeUtf8Length(buf: ByteArray): Int { - if (buf.isEmpty()) return 0 - val leadIndex = trailingLeadByteIndex(buf) - if (leadIndex == NO_UTF8_LEAD_BYTE) return buf.size - val availableSequenceBytes = buf.size - leadIndex - val expectedSequenceBytes = utf8SequenceLength(buf[leadIndex].toUnsignedInt()) - return if (availableSequenceBytes >= expectedSequenceBytes) buf.size else leadIndex - } - - private fun trailingLeadByteIndex(buf: ByteArray): Int { - var i = buf.size - 1 - while (i >= 0 && buf[i].isUtf8Continuation()) { - i-- - } - return i - } - - private fun Byte.isUtf8Continuation(): Boolean = - (toInt() and UTF8_CONTINUATION_MASK) == UTF8_CONTINUATION_PREFIX - - private fun Byte.toUnsignedInt(): Int = toInt() and BYTE_TO_UNSIGNED_MASK - - private fun utf8SequenceLength(lead: Int): Int = - when { - lead < UTF8_SINGLE_BYTE_LIMIT -> UTF8_SINGLE_BYTE_SEQUENCE_LENGTH - lead in UTF8_TWO_BYTE_LEAD_START..UTF8_TWO_BYTE_LEAD_END -> UTF8_TWO_BYTE_SEQUENCE_LENGTH - lead in UTF8_THREE_BYTE_LEAD_START..UTF8_THREE_BYTE_LEAD_END -> UTF8_THREE_BYTE_SEQUENCE_LENGTH - lead in UTF8_FOUR_BYTE_LEAD_START..UTF8_FOUR_BYTE_LEAD_END -> UTF8_FOUR_BYTE_SEQUENCE_LENGTH - else -> UTF8_SINGLE_BYTE_SEQUENCE_LENGTH - } - - /** - * Feeds [text] to [action] in pieces of at most [maxChars], - * never splitting a surrogate pair across two pieces. - */ - internal fun chunked( - text: String, - maxChars: Int, - action: (String) -> Unit, - ) { - var i = 0 - while (i < text.length) { - var end = (i + maxChars).coerceAtMost(text.length) - if (end < text.length && Character.isHighSurrogate(text[end - 1])) end-- - action(text.substring(i, end)) - i = end - } - } - } -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/StagedInput.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/StagedInput.kt deleted file mode 100644 index 920101d3..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/StagedInput.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.tmux - -data class StagedInput( - val path: String, - val bytes: Long, - val name: String, -) diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/TmuxClient.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/TmuxClient.kt deleted file mode 100644 index 2850dd75..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/TmuxClient.kt +++ /dev/null @@ -1,225 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.tmux - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import com.jorisjonkers.personalstack.agentgateway.process.ProcessRunner -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.Path - -/** - * Wraps the tmux CLI. Every method shells out — the gateway never - * embeds libtmux because tmux itself is the cheapest, most portable - * implementation of "a pty I can stream from N readers at once". - * - * One tmux server per Pod (`-L ` keeps it off the user's - * default server). Each agent is one session with one window with one - * pane: nesting more than that adds no value and complicates - * pipe-pane targeting. - */ -@Component -class TmuxClient( - private val runner: ProcessRunner, - private val props: GatewayProperties, -) { - private val log = LoggerFactory.getLogger(TmuxClient::class.java) - - fun ensureStateDir(): Path { - val dir = Path(props.tmux.stateDir) - Files.createDirectories(dir) - return dir - } - - fun newSession( - name: String, - command: List, - cwd: String, - env: Map = emptyMap(), - ) { - ensureStateDir() - val argv = - mutableListOf( - "tmux", - "-L", - props.tmux.socketName, - "new-session", - "-d", - "-s", - name, - "-x", - "200", - "-y", - "50", - "-c", - cwd, - ) + command - runner.run(argv, env = env) - // No client ever attaches to this session (the gateway tails the - // pipe-pane log instead), so tmux's default client-driven sizing - // never runs and a resize-window from the browser would be - // recomputed away. window-size=manual makes the browser's resize - // frames the sole authority over the pane geometry, so the TUI - // renders at the xterm's real width instead of the 200x50 - // bootstrap size. - runner.run( - listOf("tmux", "-L", props.tmux.socketName, "set-option", "-t", name, "window-size", "manual"), - checked = false, - ) - log.info("tmux session {} created in {}", name, cwd) - } - - fun killSession(name: String) { - runner.run( - listOf("tmux", "-L", props.tmux.socketName, "kill-session", "-t", name), - checked = false, - ) - } - - fun sendKeys( - session: String, - text: String, - enter: Boolean = true, - ) { - val argv = - mutableListOf( - "tmux", - "-L", - props.tmux.socketName, - "send-keys", - "-t", - "$session:0.0", - "-l", - text, - ) - runner.run(argv) - if (enter) { - runner.run( - listOf( - "tmux", - "-L", - props.tmux.socketName, - "send-keys", - "-t", - "$session:0.0", - "Enter", - ), - ) - } - } - - fun sendKey( - session: String, - key: String, - ) { - runner.run( - listOf( - "tmux", - "-L", - props.tmux.socketName, - "send-keys", - "-t", - "$session:0.0", - key, - ), - ) - } - - fun capture( - session: String, - historyLines: Int = 1_000, - ): String = - runner - .run( - listOf( - "tmux", - "-L", - props.tmux.socketName, - "capture-pane", - "-p", - "-S", - "-$historyLines", - "-t", - "$session:0.0", - ), - ).stdout - - /** - * Visible screen only, WITH ANSI escapes (`-e`), no `-S` history. - * This is the one-shot snapshot a WS client renders on attach so a - * full-screen TUI shows up immediately without replaying the log. - */ - fun captureWithEscapes(session: String): String = - runner - .run( - listOf( - "tmux", - "-L", - props.tmux.socketName, - "capture-pane", - "-e", - "-p", - "-t", - "$session:0.0", - ), - ).stdout - - fun resize( - session: String, - cols: Int, - rows: Int, - ) { - runner.run( - listOf( - "tmux", - "-L", - props.tmux.socketName, - "resize-window", - "-t", - "$session:0.0", - "-x", - cols.toString(), - "-y", - rows.toString(), - ), - checked = false, - ) - } - - fun listSessions(): List { - val result = - runner.run( - listOf( - "tmux", - "-L", - props.tmux.socketName, - "list-sessions", - "-F", - "#{session_name}", - ), - checked = false, - ) - if (result.exitCode != 0) return emptyList() - return result.stdout.lines().filter { it.isNotBlank() } - } - - fun startPipeToFile( - session: String, - file: Path, - ) { - runner.run( - listOf( - "tmux", - "-L", - props.tmux.socketName, - "pipe-pane", - "-O", - "-t", - "$session:0.0", - "cat >> ${file.toAbsolutePath()}", - ), - ) - } - - fun sessionExists(name: String): Boolean = name in listSessions() -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt deleted file mode 100644 index d379a697..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.web - -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSession -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSessionManager -import com.jorisjonkers.personalstack.agentgateway.web.dto.AgentResponse -import com.jorisjonkers.personalstack.agentgateway.web.dto.SendInputRequest -import com.jorisjonkers.personalstack.agentgateway.web.dto.SpawnAgentRequest -import com.jorisjonkers.personalstack.agentgateway.web.dto.StageInputRequest -import com.jorisjonkers.personalstack.agentgateway.web.dto.StagedInputResponse -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -@RequestMapping("/agents") -class AgentController( - private val sessions: AgentSessionManager, -) { - @GetMapping - fun list(): List = sessions.list().map(::toResponse) - - @PostMapping - fun spawn( - @RequestBody req: SpawnAgentRequest, - ): ResponseEntity { - val session = sessions.spawn(req.kind, req.workspacePath) - return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(session)) - } - - @GetMapping("/{id}") - fun get( - @PathVariable id: String, - ): ResponseEntity = - sessions.get(id)?.let { ResponseEntity.ok(toResponse(it)) } - ?: ResponseEntity.notFound().build() - - @PostMapping("/{id}/send") - fun send( - @PathVariable id: String, - @RequestBody req: SendInputRequest, - ): ResponseEntity { - sessions.send(id, req.input, req.enter) - return ResponseEntity.accepted().build() - } - - @PostMapping("/{id}/staged-inputs") - fun stageInput( - @PathVariable id: String, - @RequestBody req: StageInputRequest, - ): ResponseEntity { - val staged = sessions.stageInput(id, req.content, req.name) - return ResponseEntity - .status(HttpStatus.CREATED) - .body(StagedInputResponse(path = staged.path, bytes = staged.bytes, name = staged.name)) - } - - @GetMapping("/{id}/capture") - fun capture( - @PathVariable id: String, - ): ResponseEntity> { - val text = sessions.capture(id) - return ResponseEntity.ok(mapOf("text" to text)) - } - - @DeleteMapping("/{id}") - fun stop( - @PathVariable id: String, - ): ResponseEntity { - val ok = sessions.stop(id) - return if (ok) ResponseEntity.noContent().build() else ResponseEntity.notFound().build() - } - - private fun toResponse(s: AgentSession) = - AgentResponse( - id = s.id, - kind = s.kind, - cwd = s.cwd, - createdAt = s.createdAt.toString(), - cliSessionId = s.cliSessionId, - ) -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt deleted file mode 100644 index a96d4358..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.web - -import com.jorisjonkers.personalstack.agentgateway.process.ProcessFailedException -import com.jorisjonkers.personalstack.agentgateway.process.ProcessTimeoutException -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.RestControllerAdvice - -@RestControllerAdvice -class ErrorAdvice { - @ExceptionHandler(ProcessFailedException::class) - fun onProcessFailed(e: ProcessFailedException): ResponseEntity> = - ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( - mapOf( - "error" to "process-failed", - "argv" to e.argv, - "exit" to e.result.exitCode, - "stderr" to e.result.stderr.take(STDERR_PREVIEW_CHARS), - ), - ) - - @ExceptionHandler(ProcessTimeoutException::class) - fun onTimeout(e: ProcessTimeoutException): ResponseEntity> = - ResponseEntity - .status(HttpStatus.GATEWAY_TIMEOUT) - .body(mapOf("error" to "timeout", "message" to (e.message ?: ""))) - - @ExceptionHandler(IllegalStateException::class) - fun onIllegalState(e: IllegalStateException): ResponseEntity> = - ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to (e.message ?: "not-found"))) - - @ExceptionHandler(IllegalArgumentException::class) - fun onIllegalArgument(e: IllegalArgumentException): ResponseEntity> = - ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("error" to (e.message ?: "bad-request"))) - - companion object { - private const val STDERR_PREVIEW_CHARS = 2_000 - } -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/GitController.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/GitController.kt deleted file mode 100644 index 3bd59e28..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/GitController.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.web - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import com.jorisjonkers.personalstack.agentgateway.git.GitClient -import com.jorisjonkers.personalstack.agentgateway.web.dto.CloneRequest -import com.jorisjonkers.personalstack.agentgateway.web.dto.GitOperationResponse -import com.jorisjonkers.personalstack.agentgateway.web.dto.GitVerifyRequest -import com.jorisjonkers.personalstack.agentgateway.web.dto.GitVerifyResponse -import com.jorisjonkers.personalstack.agentgateway.web.dto.OpenPrRequest -import com.jorisjonkers.personalstack.agentgateway.web.dto.PushRequest -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.nio.file.Path -import kotlin.io.path.Path - -@RestController -@RequestMapping("/git") -class GitController( - private val git: GitClient, - private val props: GatewayProperties, -) { - @PostMapping("/clone") - fun clone( - @RequestBody req: CloneRequest, - ): GitOperationResponse { - val target = req.intoDir ?: defaultWorkspaceFor(req.repoUrl).toString() - val dir = git.clone(req.repoUrl, target, req.branch) - return GitOperationResponse(ok = true, output = dir) - } - - @PostMapping("/push") - fun push( - @RequestBody req: PushRequest, - ): GitOperationResponse { - val out = git.push(req.repoDir, branch = req.branch) - return GitOperationResponse(ok = true, output = out) - } - - @PostMapping("/open-pr") - fun openPr( - @RequestBody req: OpenPrRequest, - ): GitOperationResponse { - val url = git.openPr(req.repoDir, req.title, req.body, req.base) - return GitOperationResponse(ok = true, output = url) - } - - @PostMapping("/verify") - fun verify( - @RequestBody req: GitVerifyRequest, - ): GitVerifyResponse { - val result = git.verify(req.repoUrl, req.branch) - return GitVerifyResponse(read = result.read, write = result.write, detail = result.detail) - } - - private fun defaultWorkspaceFor(repoUrl: String): Path { - // git@github.com:owner/repo.git → repo - val tail = repoUrl.substringAfterLast('/').removeSuffix(".git") - return Path(props.workspaceRoot).resolve(tail) - } -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/HeadlessController.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/HeadlessController.kt deleted file mode 100644 index 6564fd15..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/HeadlessController.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.web - -import com.jorisjonkers.personalstack.agentgateway.headless.HeadlessJob -import com.jorisjonkers.personalstack.agentgateway.headless.HeadlessJobManager -import com.jorisjonkers.personalstack.agentgateway.web.dto.HeadlessJobResponse -import com.jorisjonkers.personalstack.agentgateway.web.dto.HeadlessRequest -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -@RequestMapping("/agents/headless") -class HeadlessController( - private val jobs: HeadlessJobManager, -) { - @PostMapping - fun launch( - @RequestBody req: HeadlessRequest, - ): ResponseEntity { - val job = - jobs.launch( - kind = req.kind, - prompt = req.prompt, - workspacePath = req.workspacePath, - cliSessionId = req.cliSessionId, - timeoutSeconds = req.timeoutSeconds ?: HeadlessJobManager.DEFAULT_TIMEOUT_SECONDS, - ) - return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(job, null)) - } - - @GetMapping("/{id}") - fun get( - @PathVariable id: String, - ): ResponseEntity { - val job = jobs.get(id) ?: return ResponseEntity.notFound().build() - val output = - if (job.status != com.jorisjonkers.personalstack.agentgateway.headless.HeadlessJobStatus.RUNNING) { - jobs.readOutput(id) - } else { - null - } - return ResponseEntity.ok(toResponse(job, output)) - } - - @DeleteMapping("/{id}") - fun cancel( - @PathVariable id: String, - ): ResponseEntity = - if (jobs.cancel(id)) ResponseEntity.noContent().build() else ResponseEntity.notFound().build() - - private fun toResponse( - job: HeadlessJob, - output: String?, - ) = HeadlessJobResponse( - id = job.id, - kind = job.kind, - status = job.status, - exitCode = job.exitCode, - output = output, - createdAt = job.createdAt.toString(), - completedAt = job.completedAt?.toString(), - ) -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/HealthController.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/HealthController.kt deleted file mode 100644 index cbfec4bc..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/HealthController.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.web - -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -class HealthController { - @GetMapping("/healthz") - fun health(): Map = mapOf("status" to "ok") -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt deleted file mode 100644 index 623f6dc1..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/dto/Dtos.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.web.dto - -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind - -data class SpawnAgentRequest( - val kind: AgentKind, - val workspacePath: String? = null, -) - -data class SendInputRequest( - val input: String, - val enter: Boolean = true, -) - -data class StageInputRequest( - val content: String, - val name: String? = null, -) - -data class StagedInputResponse( - val path: String, - val bytes: Long, - val name: String, -) - -data class AgentResponse( - val id: String, - val kind: AgentKind, - val cwd: String, - val createdAt: String, - val cliSessionId: String? = null, -) - -data class CloneRequest( - val repoUrl: String, - val branch: String? = null, - val intoDir: String? = null, -) - -data class PushRequest( - val repoDir: String, - val branch: String? = null, -) - -data class OpenPrRequest( - val repoDir: String, - val title: String, - val body: String, - val base: String = "main", -) - -data class GitOperationResponse( - val ok: Boolean, - val output: String, -) - -data class GitVerifyRequest( - val repoUrl: String, - val branch: String? = null, -) - -data class GitVerifyResponse( - val read: Boolean, - val write: Boolean, - val detail: String, -) - -data class HeadlessRequest( - val kind: com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind, - val prompt: String, - val workspacePath: String? = null, - val cliSessionId: String? = null, - val timeoutSeconds: Long? = null, -) - -data class HeadlessJobResponse( - val id: String, - val kind: com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind, - val status: com.jorisjonkers.personalstack.agentgateway.headless.HeadlessJobStatus, - val exitCode: Int? = null, - val output: String? = null, - val createdAt: String, - val completedAt: String? = null, -) diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/ws/AgentAttachHandler.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/ws/AgentAttachHandler.kt deleted file mode 100644 index dfc9a4b6..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/ws/AgentAttachHandler.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.ws - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSessionManager -import com.jorisjonkers.personalstack.agentgateway.tmux.LogTailer -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import org.springframework.web.socket.CloseStatus -import org.springframework.web.socket.TextMessage -import org.springframework.web.socket.WebSocketSession -import org.springframework.web.socket.handler.TextWebSocketHandler -import tools.jackson.databind.ObjectMapper -import java.util.concurrent.ConcurrentHashMap - -/** - * One WS per client-attach. Inbound JSON is `{"input": "...", "enter": - * true}`; outbound JSON is `{"output": "...bytes-as-utf8..."}`. The - * envelope intentionally stays trivial — the rich Block protocol - * (Step 7) lives one layer up, inside assistant-api, which parses the - * agent's stdout for fenced JSON blocks and emits Block frames to the - * browser. Keeping the gateway dumb means a CLI flag flip in Claude - * Code doesn't ripple into the runner image. - */ -@Component -class AgentAttachHandler( - private val sessions: AgentSessionManager, - private val mapper: ObjectMapper, - private val props: GatewayProperties, -) : TextWebSocketHandler() { - private val log = LoggerFactory.getLogger(AgentAttachHandler::class.java) - private val tailers = ConcurrentHashMap() - - override fun afterConnectionEstablished(session: WebSocketSession) { - val agentId = - agentIdOf(session) ?: run { - session.close(CloseStatus.BAD_DATA.withReason("missing agentId")) - return - } - val agent = - sessions.get(agentId) ?: run { - session.close(CloseStatus.BAD_DATA.withReason("unknown agent")) - return - } - log.info("ws attach to agent {} (tmux={})", agentId, agent.tmuxSession) - - // One current-screen snapshot (with ANSI) so the TUI renders - // immediately; the EOF-based tailer then streams only new bytes, - // so nothing in the snapshot gets replayed from the log. - // - // capture-pane joins pane rows with bare "\n"; the xterm runs - // with convertEol off (correct for the live PTY stream, which - // already emits CR), so each "\n" in the snapshot would drop a - // row without returning to column 0 — the banner staircases to - // the right. Normalise the snapshot's line ends to CRLF so it - // repaints flush-left. Existing CRLF is left intact. - val snapshot = - runCatching { sessions.captureWithEscapes(agentId) } - .getOrDefault("") - .replace("\r\n", "\n") - .replace("\n", "\r\n") - LogTailer.chunked(snapshot, LogTailer.MAX_CHUNK_CHARS) { sendOutput(session, it) } - - val tailer = - LogTailer(agent.logFile, intervalMs = props.tmux.tailIntervalMs) { text -> - sendOutput(session, text) - } - tailers[session.id] = tailer - tailer.start() - } - - // Output is relayed as small `{"output": "..."}` frames; LogTailer - // already bounds each chunk so a JSON frame stays under the default - // WebSocket buffer and nothing is buffered whole on the heap. - private fun sendOutput( - session: WebSocketSession, - text: String, - ) { - if (text.isEmpty() || !session.isOpen) return - val msg = mapper.writeValueAsString(mapOf("output" to text)) - synchronized(session) { session.sendMessage(TextMessage(msg)) } - } - - override fun handleTextMessage( - session: WebSocketSession, - message: TextMessage, - ) { - val agentId = agentIdOf(session) ?: return - val payload = - runCatching { mapper.readValue(message.payload, Map::class.java) } - .getOrElse { - log.warn("bad ws payload: {}", message.payload.take(120)) - return - } - val resize = payload["resize"] as? Map<*, *> - if (resize != null) { - handleResize(agentId, resize) - } else { - val input = payload["input"] as? String ?: return - val enter = payload["enter"] as? Boolean ?: true - sessions.send(agentId, input, enter) - } - } - - private fun handleResize( - agentId: String, - resize: Map<*, *>, - ) { - val cols = (resize["cols"] as? Number)?.toInt() ?: return - val rows = (resize["rows"] as? Number)?.toInt() ?: return - runCatching { sessions.resize(agentId, cols, rows) } - .onFailure { log.warn("resize of agent {} failed: {}", agentId, it.message) } - } - - override fun afterConnectionClosed( - session: WebSocketSession, - status: CloseStatus, - ) { - tailers.remove(session.id)?.close() - } - - private fun agentIdOf(session: WebSocketSession): String? { - val path = session.uri?.path ?: return null - val match = Regex("/ws/agents/([^/]+)/attach").find(path) ?: return null - return match.groupValues[1] - } -} diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/ws/WebSocketConfig.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/ws/WebSocketConfig.kt deleted file mode 100644 index 7f2682d4..00000000 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/ws/WebSocketConfig.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.ws - -import org.springframework.context.annotation.Configuration -import org.springframework.web.socket.config.annotation.EnableWebSocket -import org.springframework.web.socket.config.annotation.WebSocketConfigurer -import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry - -@Configuration -@EnableWebSocket -class WebSocketConfig( - private val agentAttachHandler: AgentAttachHandler, -) : WebSocketConfigurer { - override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { - registry - .addHandler(agentAttachHandler, "/ws/agents/*/attach") - .setAllowedOriginPatterns("*") - } -} diff --git a/services/agent-gateway/src/main/resources/application.yml b/services/agent-gateway/src/main/resources/application.yml deleted file mode 100644 index c5b65abd..00000000 --- a/services/agent-gateway/src/main/resources/application.yml +++ /dev/null @@ -1,93 +0,0 @@ -spring: - application: - name: agent-gateway - threads: - virtual: - enabled: true - # spring-cloud-starter-vault-config is on the runtime classpath via the - # shared Spring convention plugin, but the gateway holds no secrets — it - # only drives tmux + the CLIs over a WS/REST plane. Left enabled, the - # actuator health registry eagerly instantiates the Vault health - # contributor (vaultTemplate -> vaultSessionManager -> clientAuthentication) - # at context init; with no token in a runner Pod that throws a fatal - # BeanCreationException before Tomcat ever binds. Disable the integration. - cloud: - vault: - enabled: false - autoconfigure: - exclude: - - org.springframework.cloud.vault.config.VaultAutoConfiguration - - org.springframework.cloud.vault.config.VaultReactiveAutoConfiguration - -server: - port: 8090 - -agent-gateway: - # Per-workspace runner Pods mount one PVC at /workspace. The - # gateway holds the canonical view of that mount; everything below - # is keyed off the same directory. - workspace-root: /workspace - # tmux state — sessions, logs, the fifo per pane the WS handler - # tails for streaming output. Kept under /tmp so a Pod restart - # is unambiguous (the log is gone, the agent is gone) instead of - # leaving half-attached panes behind on a volume. - tmux: - socket-name: agent-gw - state-dir: /tmp/agent-gateway - # ~40ms keeps streamed output feeling live. Per-keystroke - # send-keys is a separate, smaller latency source. - tail-interval-ms: 15 - cli: - claude: ${CLAUDE_BIN:claude} - codex: ${CODEX_BIN:codex} - # The runner Pod is the outer sandbox. Docker-socket-enabled runners are - # host-equivalent for Docker/Testcontainers, while the CLIs still launch - # with approval/permission/sandbox prompts bypassed. Listed here so an - # upstream flag rename is a config flip, not an image rebuild. - claude-args: - - --dangerously-skip-permissions - codex-args: - - --dangerously-bypass-approvals-and-sandbox - - --dangerously-bypass-hook-trust - git: - # The deploy-key Secret is mounted into the Pod at this path by - # assistant-api when it creates the runner Pod (Step 3). - deploy-key-dir: /var/run/secrets/agents/github-deploy-key - staged-inputs: - dir-name: .agent-inputs - max-bytes: 5242880 - -management: - endpoints: - web: - base-path: /api/actuator - exposure: - include: health,info,prometheus,metrics - endpoint: - health: - show-details: always - group: - liveness: - include: ping - health: - # No Vault, no datasource — keep the gateway's health registry from - # instantiating contributors for infrastructure it never wires. - vault: - enabled: false - db: - enabled: false - metrics: - tags: - application: ${spring.application.name} - tracing: - sampling: - probability: ${TRACING_SAMPLE_PROBABILITY:1.0} - propagation: - type: w3c - otlp: - tracing: - endpoint: ${OTLP_TRACING_ENDPOINT:http://alloy.observability.svc.cluster.local:4318/v1/traces} - observations: - key-values: - service.name: ${spring.application.name} - deployment.environment: ${DEPLOYMENT_ENVIRONMENT:unknown} diff --git a/services/agent-gateway/src/main/resources/logback-spring.xml b/services/agent-gateway/src/main/resources/logback-spring.xml deleted file mode 100644 index d3e1353b..00000000 --- a/services/agent-gateway/src/main/resources/logback-spring.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - traceId - spanId - - - - - - diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClientVerifyTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClientVerifyTest.kt deleted file mode 100644 index 1d66bb00..00000000 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClientVerifyTest.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.git - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import com.jorisjonkers.personalstack.agentgateway.process.ProcessFailedException -import com.jorisjonkers.personalstack.agentgateway.process.ProcessRunner -import com.jorisjonkers.personalstack.agentgateway.process.ProcessTimeoutException -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.file.Files -import java.nio.file.Path - -class GitClientVerifyTest { - @TempDir - lateinit var keyDir: Path - - private val repoUrl = "git@github.com:owner/repo.git" - - private fun props() = - GatewayProperties( - workspaceRoot = "/workspace", - tmux = GatewayProperties.Tmux(socketName = "s", stateDir = "/tmp"), - cli = GatewayProperties.Cli(claude = "claude", codex = "codex"), - git = GatewayProperties.Git(deployKeyDir = keyDir.toString()), - ) - - @BeforeEach - fun stageKey() { - Files.writeString(keyDir.resolve("private_key"), "FAKE-KEY") - } - - /** - * Records every argv it sees and replays scripted results so the probe - * sequence (ls-remote -> push probe -> push delete) can be asserted - * without a real remote. - */ - private class FakeRunner( - private val results: List, - ) : ProcessRunner() { - val calls = mutableListOf>() - val envs = mutableListOf>() - private var idx = 0 - - override fun run( - argv: List, - cwd: File?, - env: Map, - timeoutSeconds: Long, - checked: Boolean, - ): Result { - calls.add(argv) - envs.add(env) - val result = results[idx.coerceAtMost(results.size - 1)] - idx++ - if (checked && result.exitCode != 0) throw ProcessFailedException(argv, result) - return result - } - } - - private fun ok(stdout: String = "") = ProcessRunner.Result(0, stdout, "") - - private fun fail(stderr: String) = ProcessRunner.Result(1, "", stderr) - - private val lsRemoteHead = - "deadbeef00000000000000000000000000000000\tHEAD\n" + - "deadbeef00000000000000000000000000000000\trefs/heads/main\n" - - @Test - fun `clone skips an existing checkout`() { - val target = keyDir.resolve("workspace/repo") - Files.createDirectories(target.resolve(".git")) - val runner = FakeRunner(listOf(ok())) - - val result = GitClient(runner, props()).clone(repoUrl, target.toString(), branch = "main") - - assertThat(result).isEqualTo(target.toString()) - assertThat(runner.calls).isEmpty() - } - - @Test - fun `clone scopes the app-token helper to the requested repository`() { - val target = keyDir.resolve("workspace/extra") - val runner = FakeRunner(listOf(ok())) - - val result = - GitClient(runner, props()).clone( - "git@github.com:owner/extra.git", - target.toString(), - branch = "main", - ) - - assertThat(result).isEqualTo(target.toString()) - assertThat(runner.calls.single()) - .containsExactly( - "git", - "clone", - "--depth", - "50", - "--branch", - "main", - "git@github.com:owner/extra.git", - target.toString(), - ) - assertThat(runner.envs.single()["AGENT_GITHUB_REPO_URL"]).isEqualTo("git@github.com:owner/extra.git") - assertThat(runner.envs.single()["REPO_ALLOW"]).isEqualTo("owner/extra") - } - - @Test - fun `read ok and write ok with HEAD when no branch given`() { - val runner = FakeRunner(listOf(ok(lsRemoteHead), ok(), ok())) - val result = GitClient(runner, props()).verify(repoUrl) - - assertThat(result.read).isTrue - assertThat(result.write).isTrue - assertThat(runner.calls[0]).containsExactly("git", "ls-remote", repoUrl) - // probe push points an EXISTING sha at a throwaway ref, never main - val probePush = runner.calls[1] - assertThat(probePush[0]).isEqualTo("git") - assertThat(probePush[1]).isEqualTo("push") - assertThat(probePush[3]).startsWith("deadbeef00000000000000000000000000000000:refs/heads/_agent-keycheck-") - assertThat(probePush[3]).doesNotContain("refs/heads/main") - // delete must always run - val deletePush = runner.calls[2] - assertThat(deletePush[3]).startsWith(":refs/heads/_agent-keycheck-") - } - - @Test - fun `read ok write denied still deletes probe ref`() { - val runner = FakeRunner(listOf(ok(lsRemoteHead), fail("remote: Permission to owner/repo.git denied"), ok())) - val result = GitClient(runner, props()).verify(repoUrl) - - assertThat(result.read).isTrue - assertThat(result.write).isFalse - assertThat(result.detail).contains("denied") - assertThat(runner.calls).hasSize(3) - assertThat(runner.calls[2][3]).startsWith(":refs/heads/_agent-keycheck-") - } - - @Test - fun `read denied skips write probe entirely`() { - val runner = FakeRunner(listOf(fail("Permission denied (publickey)."))) - val result = GitClient(runner, props()).verify(repoUrl) - - assertThat(result.read).isFalse - assertThat(result.write).isFalse - assertThat(runner.calls).hasSize(1) - } - - @Test - fun `probe ref is deleted even when probe push throws`() { - val throwing = - object : ProcessRunner() { - val calls = mutableListOf>() - private var idx = 0 - - override fun run( - argv: List, - cwd: File?, - env: Map, - timeoutSeconds: Long, - checked: Boolean, - ): Result { - calls.add(argv) - idx++ - return when (idx) { - 1 -> Result(0, lsRemoteHead, "") - 2 -> throw ProcessTimeoutException("boom during push") - else -> Result(0, "", "") - } - } - } - - runCatching { GitClient(throwing, props()).verify(repoUrl) } - // ls-remote, probe push (threw), delete push - assertThat(throwing.calls).hasSize(3) - assertThat(throwing.calls[2][3]).startsWith(":refs/heads/_agent-keycheck-") - } - - @Test - fun `targets the requested branch tip not HEAD`() { - val branchSha = "cafe000000000000000000000000000000000000" - val lsRemoteBranch = "$branchSha\trefs/heads/feature\n" - val runner = FakeRunner(listOf(ok(lsRemoteBranch), ok(), ok())) - GitClient(runner, props()).verify(repoUrl, branch = "feature") - - assertThat(runner.calls[0]).containsExactly("git", "ls-remote", repoUrl, "feature") - assertThat(runner.calls[1][3]).startsWith("$branchSha:refs/heads/_agent-keycheck-") - } -} diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManagerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManagerTest.kt deleted file mode 100644 index 926c529a..00000000 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/headless/HeadlessJobManagerTest.kt +++ /dev/null @@ -1,189 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.headless - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.file.Path -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -class HeadlessJobManagerTest { - private fun manager( - tmp: Path, - processFactory: HeadlessJobManager.ProcessFactory, - ): HeadlessJobManager { - val props = - GatewayProperties( - workspaceRoot = tmp.toString(), - tmux = GatewayProperties.Tmux(socketName = "agent-gw", stateDir = tmp.toString()), - cli = - GatewayProperties.Cli( - claude = "/usr/local/bin/claude", - codex = "/usr/local/bin/codex", - codexArgs = - listOf( - "--dangerously-bypass-approvals-and-sandbox", - "--dangerously-bypass-hook-trust", - ), - ), - git = GatewayProperties.Git(deployKeyDir = "/x"), - ) - return HeadlessJobManager(props, processFactory) - } - - @Test - fun `launch registers job and returns RUNNING status immediately`( - @TempDir tmp: Path, - ) { - val latch = CountDownLatch(1) - val mgr = - manager(tmp) { _, _ -> - latch.await(5, TimeUnit.SECONDS) - processOf(exitCode = 0, output = "done") - } - - val job = mgr.launch(AgentKind.CLAUDE, "write a hello-world program") - - assertThat(job.status).isEqualTo(HeadlessJobStatus.RUNNING) - assertThat(job.exitCode).isNull() - assertThat(job.completedAt).isNull() - latch.countDown() - mgr.destroy() - } - - @Test - fun `launch completes with COMPLETED status on zero exit`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) { _, _ -> processOf(exitCode = 0, output = "result: ok") } - - val job = mgr.launch(AgentKind.CLAUDE, "say hello") - - // Wait for the async process to finish. - val deadline = System.currentTimeMillis() + 3_000 - while (mgr.get(job.id)?.status == HeadlessJobStatus.RUNNING && System.currentTimeMillis() < deadline) { - Thread.sleep(50) - } - - val done = mgr.get(job.id)!! - assertThat(done.status).isEqualTo(HeadlessJobStatus.COMPLETED) - assertThat(done.exitCode).isEqualTo(0) - assertThat(done.completedAt).isNotNull() - mgr.destroy() - } - - @Test - fun `launch marks job FAILED on non-zero exit`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) { _, _ -> processOf(exitCode = 1, output = "error: failed") } - - val job = mgr.launch(AgentKind.CODEX, "do something") - - val deadline = System.currentTimeMillis() + 3_000 - while (mgr.get(job.id)?.status == HeadlessJobStatus.RUNNING && System.currentTimeMillis() < deadline) { - Thread.sleep(50) - } - - val done = mgr.get(job.id)!! - assertThat(done.status).isEqualTo(HeadlessJobStatus.FAILED) - assertThat(done.exitCode).isEqualTo(1) - mgr.destroy() - } - - @Test - fun `launch codex headless uses bypass flags and starts fresh by default`( - @TempDir tmp: Path, - ) { - val latch = CountDownLatch(1) - var capturedCommand: List? = null - val mgr = - manager(tmp) { command, _ -> - capturedCommand = command - latch.countDown() - processOf(exitCode = 0, output = "done") - } - - mgr.launch(AgentKind.CODEX, "inspect repo") - - assertThat(latch.await(3, TimeUnit.SECONDS)).isTrue() - assertThat(capturedCommand) - .containsExactly( - "/usr/local/bin/codex", - "exec", - "--dangerously-bypass-approvals-and-sandbox", - "--dangerously-bypass-hook-trust", - "--json", - "--", - "inspect repo", - ) - assertThat(capturedCommand).doesNotContain("resume") - mgr.destroy() - } - - @Test - fun `cancel kills the process and marks job CANCELLED`( - @TempDir tmp: Path, - ) { - val latch = CountDownLatch(1) - val mgr = - manager(tmp) { _, _ -> - // Simulate a long-running process - latch.await(10, TimeUnit.SECONDS) - processOf(exitCode = 0, output = "") - } - - val job = mgr.launch(AgentKind.CLAUDE, "long running task") - assertThat(job.status).isEqualTo(HeadlessJobStatus.RUNNING) - - val cancelled = mgr.cancel(job.id) - assertThat(cancelled).isTrue() - - // The job should eventually reach CANCELLED state. - val deadline = System.currentTimeMillis() + 3_000 - while (mgr.get(job.id)?.status == HeadlessJobStatus.RUNNING && System.currentTimeMillis() < deadline) { - Thread.sleep(50) - } - - latch.countDown() - mgr.destroy() - } - - @Test - fun `readOutput returns captured output after completion`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) { _, _ -> processOf(exitCode = 0, output = "hello from claude") } - - val job = mgr.launch(AgentKind.CLAUDE, "say hello") - - val deadline = System.currentTimeMillis() + 3_000 - while (mgr.get(job.id)?.status == HeadlessJobStatus.RUNNING && System.currentTimeMillis() < deadline) { - Thread.sleep(50) - } - - val output = mgr.readOutput(job.id) - assertThat(output).contains("hello from claude") - mgr.destroy() - } - - /** - * Build a fake [Process] that exits immediately with [exitCode] and - * writes [output] to its stdout stream. - */ - private fun processOf( - exitCode: Int, - output: String, - ): Process { - // Write output to a temp file so we can read it back via inputStream. - val tmp = File.createTempFile("hls-test", ".txt").also { it.deleteOnExit() } - tmp.writeText(output) - // `cat && exit ` — cross-platform enough for unit tests. - return ProcessBuilder("sh", "-c", "cat ${tmp.absolutePath}; exit $exitCode") - .redirectErrorStream(true) - .start() - } -} diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/process/ProcessRunnerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/process/ProcessRunnerTest.kt deleted file mode 100644 index b65ca575..00000000 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/process/ProcessRunnerTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.process - -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.condition.DisabledOnOs -import org.junit.jupiter.api.condition.OS - -class ProcessRunnerTest { - private val runner = ProcessRunner() - - @Test - @DisabledOnOs(OS.WINDOWS) - fun `run captures stdout and exit code`() { - val result = runner.run(listOf("/bin/sh", "-c", "echo hello")) - assertThat(result.exitCode).isZero - assertThat(result.stdout.trim()).isEqualTo("hello") - } - - @Test - @DisabledOnOs(OS.WINDOWS) - fun `non-zero exit throws ProcessFailedException by default`() { - assertThatThrownBy { runner.run(listOf("/bin/sh", "-c", "exit 7")) } - .isInstanceOf(ProcessFailedException::class.java) - } - - @Test - @DisabledOnOs(OS.WINDOWS) - fun `unchecked mode returns the failed result instead of throwing`() { - val result = runner.run(listOf("/bin/sh", "-c", "exit 7"), checked = false) - assertThat(result.exitCode).isEqualTo(7) - } - - @Test - @DisabledOnOs(OS.WINDOWS) - fun `timeout fires and reports ProcessTimeoutException`() { - assertThatThrownBy { - runner.run(listOf("/bin/sh", "-c", "sleep 5"), timeoutSeconds = 1) - }.isInstanceOf(ProcessTimeoutException::class.java) - } -} diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt deleted file mode 100644 index af587a28..00000000 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt +++ /dev/null @@ -1,307 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.tmux - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.awaitility.Awaitility.await -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.nio.file.Files -import java.nio.file.Path -import java.time.Duration - -class AgentSessionManagerTest { - private val tmux = mockk(relaxed = true) - - private fun manager( - tmp: Path, - stagedInputs: GatewayProperties.StagedInputs = GatewayProperties.StagedInputs(), - ): AgentSessionManager { - val props = - GatewayProperties( - workspaceRoot = "/workspace", - tmux = GatewayProperties.Tmux(socketName = "agent-gw", stateDir = tmp.toString()), - cli = - GatewayProperties.Cli( - claude = "/usr/local/bin/claude", - codex = "/usr/local/bin/codex", - claudeArgs = listOf("--dangerously-skip-permissions"), - codexArgs = - listOf( - "--dangerously-bypass-approvals-and-sandbox", - "--dangerously-bypass-hook-trust", - ), - ), - git = GatewayProperties.Git(deployKeyDir = "/x"), - stagedInputs = stagedInputs, - ) - every { tmux.ensureStateDir() } returns tmp - return AgentSessionManager(tmux, props) - } - - @Test - fun `spawn registers session and starts tmux + pipe`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CLAUDE, workspacePath = "/workspace/repo") - assertThat(s.kind).isEqualTo(AgentKind.CLAUDE) - assertThat(s.tmuxSession).isEqualTo("agent-${s.id}") - assertThat(s.cwd).isEqualTo("/workspace/repo") - assertThat(s.logFile.parent).isEqualTo(tmp) - assertThat(s.cliSessionId).isNotNull() - verify { - tmux.newSession( - s.tmuxSession, - match { cmd -> - cmd.containsAll(listOf("/usr/local/bin/claude", "--dangerously-skip-permissions")) && - cmd.contains("--session-id") && - cmd[cmd.indexOf("--session-id") + 1] == s.cliSessionId - }, - "/workspace/repo", - ) - } - verify { tmux.startPipeToFile(s.tmuxSession, s.logFile) } - assertThat(mgr.get(s.id)).isEqualTo(s) - assertThat(mgr.list()).hasSize(1) - } - - @Test - fun `spawn launches claude in full-trust mode with configured flags`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CLAUDE) - // Claude gets --session-id appended so each gateway agent has - // a fresh native session instead of inheriting another conversation. - assertThat(s.cliSessionId).isNotNull() - verify { - tmux.newSession( - s.tmuxSession, - match { cmd -> - cmd.containsAll(listOf("/usr/local/bin/claude", "--dangerously-skip-permissions")) && - cmd.contains("--session-id") && - cmd[cmd.indexOf("--session-id") + 1] == s.cliSessionId - }, - "/workspace", - ) - } - } - - @Test - fun `spawn never resumes claude from another session`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CLAUDE) - verify { - tmux.newSession( - s.tmuxSession, - match { cmd -> - cmd.containsAll(listOf("/usr/local/bin/claude", "--dangerously-skip-permissions")) && - cmd.contains("--session-id") && - cmd[cmd.indexOf("--session-id") + 1] == s.cliSessionId && - !cmd.contains("--resume") - }, - "/workspace", - ) - } - } - - @Test - fun `spawn never resumes codex last session`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CODEX) - assertThat(s.cliSessionId).isNull() - verify { - tmux.newSession( - s.tmuxSession, - match { cmd -> - cmd.containsAll( - listOf( - "/usr/local/bin/codex", - "--dangerously-bypass-approvals-and-sandbox", - "--dangerously-bypass-hook-trust", - ), - ) && - !cmd.contains("resume") && - !cmd.contains("--last") - }, - "/workspace", - ) - } - } - - @Test - fun `spawn launches codex with approval+sandbox bypass flag`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CODEX) - // Codex has no deterministic create-time session id flag; discovery is async. - assertThat(s.cliSessionId).isNull() - verify { - tmux.newSession( - s.tmuxSession, - listOf( - "/usr/local/bin/codex", - "--dangerously-bypass-approvals-and-sandbox", - "--dangerously-bypass-hook-trust", - ), - "/workspace", - ) - } - } - - @Test - fun `spawn launches shell bare with no trust flags`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.SHELL) - assertThat(s.cliSessionId).isNull() - verify { tmux.newSession(s.tmuxSession, listOf("/bin/bash", "-l"), "/workspace") } - } - - @Test - fun `spawn uses workspaceRoot when no path provided`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CODEX) - assertThat(s.cwd).isEqualTo("/workspace") - } - - @Test - fun `stop kills tmux session and removes from registry`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.SHELL) - assertThat(mgr.stop(s.id)).isTrue - assertThat(mgr.get(s.id)).isNull() - verify { tmux.killSession(s.tmuxSession) } - } - - @Test - fun `stop returns false for unknown id`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - assertThat(mgr.stop("does-not-exist")).isFalse - } - - @Test - fun `send delegates to tmux sendKeys with enter flag`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CLAUDE) - mgr.send(s.id, "list files", enter = true) - verify { tmux.sendKeys(s.tmuxSession, "list files", enter = true) } - } - - @Test - fun `stageInput writes content under session cwd with sanitized name`( - @TempDir tmp: Path, - ) { - val workspace = tmp.resolve("workspace") - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CLAUDE, workspacePath = workspace.toString()) - - val staged = mgr.stageInput(s.id, "large document", "../source notes?.md") - - assertThat(staged.name).isEqualTo("source-notes-.md") - assertThat(staged.bytes).isEqualTo("large document".toByteArray().size.toLong()) - assertThat(Path.of(staged.path).parent).isEqualTo(workspace.resolve(".agent-inputs")) - assertThat(Files.readString(Path.of(staged.path))).isEqualTo("large document") - } - - @Test - fun `stageInput rejects content over configured byte cap`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp, GatewayProperties.StagedInputs(maxBytes = 4)) - val s = mgr.spawn(AgentKind.SHELL, workspacePath = tmp.resolve("workspace").toString()) - - assertThatThrownBy { mgr.stageInput(s.id, "12345", "too-large.txt") } - .isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("exceeds 4 bytes") - } - - @Test - fun `stageInput rejects staging directory outside workspace`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp, GatewayProperties.StagedInputs(dirName = "../outside")) - val s = mgr.spawn(AgentKind.SHELL, workspacePath = tmp.resolve("workspace").toString()) - - assertThatThrownBy { mgr.stageInput(s.id, "content", "input.txt") } - .isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("inside the workspace") - } - - @Test - fun `capture delegates to tmux capture`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CLAUDE) - every { tmux.capture(s.tmuxSession, 1_000) } returns "screen content" - assertThat(mgr.capture(s.id)).isEqualTo("screen content") - } - - @Test - fun `captureWithEscapes delegates to tmux captureWithEscapes`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CLAUDE) - every { tmux.captureWithEscapes(s.tmuxSession) } returns "ansi screen" - assertThat(mgr.captureWithEscapes(s.id)).isEqualTo("ansi screen") - } - - @Test - fun `resize delegates to tmux resize`( - @TempDir tmp: Path, - ) { - val mgr = manager(tmp) - val s = mgr.spawn(AgentKind.CLAUDE) - mgr.resize(s.id, 100, 30) - verify { tmux.resize(s.tmuxSession, 100, 30) } - } - - @Test - fun `trims a session log once it outgrows its disk cap`( - @TempDir tmp: Path, - ) { - val props = - GatewayProperties( - workspaceRoot = "/workspace", - tmux = - GatewayProperties.Tmux( - socketName = "agent-gw", - stateDir = tmp.toString(), - logCapBytes = 64, - logTrimIntervalSeconds = 1, - ), - cli = GatewayProperties.Cli(claude = "/c", codex = "/x"), - git = GatewayProperties.Git(deployKeyDir = "/x"), - ) - every { tmux.ensureStateDir() } returns tmp - val mgr = AgentSessionManager(tmux, props) - try { - val s = mgr.spawn(AgentKind.SHELL) - Files.write(s.logFile, ByteArray(200) { 'x'.code.toByte() }) - assertThat(Files.size(s.logFile)).isEqualTo(200L) - await().atMost(Duration.ofSeconds(5)).until { Files.size(s.logFile) == 0L } - } finally { - mgr.shutdown() - } - } -} diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailerTest.kt deleted file mode 100644 index 3d687fed..00000000 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailerTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.tmux - -import org.assertj.core.api.Assertions.assertThat -import org.awaitility.Awaitility.await -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.nio.channels.FileChannel -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.StandardOpenOption -import java.time.Duration -import java.util.concurrent.CopyOnWriteArrayList - -class LogTailerTest { - @Test - fun `tailer defaults to a low-latency 40ms poll interval`( - @TempDir tmp: Path, - ) { - val tailer = LogTailer(tmp.resolve("agent.log")) { } - val field = LogTailer::class.java.getDeclaredField("intervalMs").apply { isAccessible = true } - assertThat(field.getLong(tailer)).isEqualTo(40L) - } - - @Test - fun `tailer emits text appended to the file`( - @TempDir tmp: Path, - ) { - val file = tmp.resolve("agent.log") - Files.createFile(file) - val received = CopyOnWriteArrayList() - LogTailer(file, intervalMs = 50) { received.add(it) }.use { tailer -> - tailer.start() - Files.writeString(file, "hello ", StandardOpenOption.APPEND) - Files.writeString(file, "world\n", StandardOpenOption.APPEND) - await().atMost(Duration.ofSeconds(2)).until { - received.joinToString("").contains("hello world") - } - assertThat(received.joinToString("")).contains("hello world") - } - } - - @Test - fun `tailer starts at EOF and ignores bytes written before start`( - @TempDir tmp: Path, - ) { - val file = tmp.resolve("agent.log") - Files.writeString(file, "OLD HISTORY THAT MUST NOT REPLAY\n") - val received = CopyOnWriteArrayList() - LogTailer(file, intervalMs = 50) { received.add(it) }.use { tailer -> - tailer.start() - Files.writeString(file, "fresh bytes\n", StandardOpenOption.APPEND) - await().atMost(Duration.ofSeconds(2)).until { - received.joinToString("").contains("fresh bytes") - } - assertThat(received.joinToString("")).contains("fresh bytes") - assertThat(received.joinToString("")).doesNotContain("OLD HISTORY") - } - } - - @Test - fun `completeUtf8Length holds back a codepoint split across a read`() { - // "Aé": 0x41 then 0xC3 0xA9 — the whole buffer is complete. - assertThat(LogTailer.completeUtf8Length(byteArrayOf(0x41, 0xC3.toByte(), 0xA9.toByte()))).isEqualTo(3) - // Same "é" missing its continuation byte: carry from the lead at index 1. - assertThat(LogTailer.completeUtf8Length(byteArrayOf(0x41, 0xC3.toByte()))).isEqualTo(1) - // Box-drawing "─" (E2 94 80) missing its last byte: nothing is ready yet. - assertThat(LogTailer.completeUtf8Length(byteArrayOf(0xE2.toByte(), 0x94.toByte()))).isEqualTo(0) - } - - @Test - fun `chunked bounds pieces and never splits a surrogate pair`() { - val text = "a😀b" // the emoji is U+1F600, one codepoint as a surrogate pair - val out = mutableListOf() - LogTailer.chunked(text, 2, out::add) - assertThat(out.joinToString("")).isEqualTo(text) - assertThat(out).allSatisfy { assertThat(it.length).isLessThanOrEqualTo(2) } - out.filter { it.isNotEmpty() }.forEach { - assertThat(Character.isLowSurrogate(it.first())).isFalse() - assertThat(Character.isHighSurrogate(it.last())).isFalse() - } - } - - @Test - fun `streams a multibyte char even when it is split across two reads`( - @TempDir tmp: Path, - ) { - val file = tmp.resolve("split.log") - Files.createFile(file) - val received = CopyOnWriteArrayList() - LogTailer(file, intervalMs = 20) { received.add(it) }.use { tailer -> - tailer.start() - // "─" is E2 94 80; deliver the lead two bytes, then the rest + 'A'. - // A poll may land between the writes (exercising the carry) or - // after both; either way the decoded result must be intact. - Files.write(file, byteArrayOf(0xE2.toByte(), 0x94.toByte()), StandardOpenOption.APPEND) - Thread.sleep(40) - Files.write(file, byteArrayOf(0x80.toByte(), 0x41), StandardOpenOption.APPEND) - await().atMost(Duration.ofSeconds(2)).until { received.joinToString("") == "─A" } - } - } - - @Test - fun `restarts from the new beginning after the log is truncated to its cap`( - @TempDir tmp: Path, - ) { - val file = tmp.resolve("capped.log") - Files.writeString(file, "old-content") - val received = CopyOnWriteArrayList() - LogTailer(file, intervalMs = 20) { received.add(it) }.use { tailer -> - tailer.start() - Files.writeString(file, "-more", StandardOpenOption.APPEND) - await().atMost(Duration.ofSeconds(2)).until { received.joinToString("") == "-more" } - FileChannel.open(file, StandardOpenOption.WRITE).use { it.truncate(0) } - Files.writeString(file, "fresh", StandardOpenOption.APPEND) - await().atMost(Duration.ofSeconds(2)).until { received.joinToString("") == "-morefresh" } - } - } -} diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/TmuxClientTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/TmuxClientTest.kt deleted file mode 100644 index 57ac4a5d..00000000 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/TmuxClientTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.tmux - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import com.jorisjonkers.personalstack.agentgateway.process.ProcessRunner -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import java.io.File -import java.nio.file.Path - -class TmuxClientTest { - private val runner = mockk(relaxed = true) - private val props = - GatewayProperties( - workspaceRoot = "/workspace", - tmux = GatewayProperties.Tmux(socketName = "agent-gw", stateDir = "/tmp/agent-gateway-test"), - cli = GatewayProperties.Cli(claude = "claude", codex = "codex"), - git = GatewayProperties.Git(deployKeyDir = "/var/run/secrets/agents/github-deploy-key"), - ) - private val client = TmuxClient(runner, props) - - @Test - fun `newSession invokes tmux with socket name and cwd`() { - // newSession issues new-session then a window-size set-option, so - // capture every call and assert against the new-session one. - val calls = mutableListOf>() - every { runner.run(capture(calls), any(), any(), any(), any()) } returns - ProcessRunner.Result(0, "", "") - - client.newSession("agent-abc", listOf("claude"), "/workspace/repo") - - val create = calls.single { it.contains("new-session") } - assertThat(create).containsSubsequence("tmux", "-L", "agent-gw") - assertThat(create).contains("-s", "agent-abc") - assertThat(create).contains("-c", "/workspace/repo") - assertThat(create).endsWith("claude") - - // The pane is pinned to manual sizing so browser resize frames win. - val manualSize = calls.single { it.contains("set-option") } - assertThat(manualSize).containsSubsequence("set-option", "-t", "agent-abc", "window-size", "manual") - } - - @Test - fun `sendKeys with enter sends the text then Enter as a separate invocation`() { - every { runner.run(any(), any(), any(), any(), any()) } returns ProcessRunner.Result(0, "", "") - - client.sendKeys("agent-abc", "hello world") - - verify { - runner.run( - match { it.containsAll(listOf("send-keys", "-t", "agent-abc:0.0", "-l", "hello world")) }, - any(), - any(), - any(), - any(), - ) - runner.run( - match { it.containsAll(listOf("send-keys", "-t", "agent-abc:0.0", "Enter")) }, - any(), - any(), - any(), - any(), - ) - } - } - - @Test - fun `captureWithEscapes captures visible screen with ansi and no history flag`() { - val argv = slot>() - every { runner.run(capture(argv), any(), any(), any(), any()) } returns - ProcessRunner.Result(0, "screen with ansi", "") - - val out = client.captureWithEscapes("agent-abc") - - assertThat(out).contains("ansi") - assertThat(argv.captured).containsSubsequence("tmux", "-L", "agent-gw") - assertThat(argv.captured).containsSubsequence("capture-pane", "-e", "-p") - assertThat(argv.captured).contains("-t", "agent-abc:0.0") - assertThat(argv.captured).doesNotContain("-S") - } - - @Test - fun `resize invokes tmux resize-window with cols and rows`() { - val argv = slot>() - every { runner.run(capture(argv), any(), any(), any(), any()) } returns - ProcessRunner.Result(0, "", "") - - client.resize("agent-abc", 120, 40) - - assertThat(argv.captured).containsSubsequence("tmux", "-L", "agent-gw") - assertThat(argv.captured).contains("resize-window", "-t", "agent-abc:0.0") - assertThat(argv.captured).containsSubsequence("-x", "120") - assertThat(argv.captured).containsSubsequence("-y", "40") - } - - @Test - fun `listSessions parses tmux list-sessions output`() { - every { runner.run(any(), any(), any(), any(), any()) } returns - ProcessRunner.Result(0, "agent-abc\nagent-def\n", "") - - assertThat(client.listSessions()).containsExactly("agent-abc", "agent-def") - } - - @Test - fun `listSessions returns empty list when tmux server is not running`() { - every { runner.run(any(), any(), any(), any(), any()) } returns - ProcessRunner.Result(1, "", "no server running") - - assertThat(client.listSessions()).isEmpty() - } - - @Test - fun `ensureStateDir creates the directory`( - @TempDir tmp: Path, - ) { - val withTmp = - props.copy( - tmux = props.tmux.copy(stateDir = tmp.resolve("agent-gw").toString()), - ) - val tmuxWithTmp = TmuxClient(runner, withTmp) - val dir = tmuxWithTmp.ensureStateDir() - assertThat(File(dir.toString())).exists().isDirectory() - } -} diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentControllerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentControllerTest.kt deleted file mode 100644 index 204193bc..00000000 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentControllerTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.web - -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSession -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSessionManager -import com.jorisjonkers.personalstack.agentgateway.tmux.StagedInput -import io.mockk.every -import io.mockk.mockk -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import tools.jackson.databind.ObjectMapper -import java.nio.file.Path -import java.time.Instant - -class AgentControllerTest { - private val sessions = mockk() - private val mockMvc: MockMvc = - MockMvcBuilders - .standaloneSetup(AgentController(sessions)) - .setControllerAdvice(ErrorAdvice()) - .build() - private val mapper = ObjectMapper() - - private val sample = - AgentSession( - id = "abc12345", - kind = AgentKind.CLAUDE, - tmuxSession = "agent-abc12345", - logFile = Path.of("/tmp/agent.log"), - cwd = "/workspace/repo", - createdAt = Instant.parse("2026-05-19T10:00:00Z"), - ) - - @Test - fun `POST agents spawns and returns 201 with session`() { - every { sessions.spawn(AgentKind.CLAUDE, "/workspace/repo") } returns sample - mockMvc - .perform( - post("/agents") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"kind":"CLAUDE","workspacePath":"/workspace/repo"}"""), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.id").value("abc12345")) - .andExpect(jsonPath("$.kind").value("CLAUDE")) - } - - @Test - fun `GET agents lists sessions`() { - every { sessions.list() } returns listOf(sample) - mockMvc - .perform(get("/agents")) - .andExpect(status().isOk) - .andExpect(jsonPath("$[0].id").value("abc12345")) - } - - @Test - fun `GET agents id returns 404 when unknown`() { - every { sessions.get("nope") } returns null - mockMvc.perform(get("/agents/nope")).andExpect(status().isNotFound) - } - - @Test - fun `DELETE agents id returns 204 on success`() { - every { sessions.stop("abc12345") } returns true - mockMvc.perform(delete("/agents/abc12345")).andExpect(status().isNoContent) - } - - @Test - fun `POST agents id send returns 202`() { - every { sessions.send("abc12345", "hi", true) } returns Unit - mockMvc - .perform( - post("/agents/abc12345/send") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"input":"hi","enter":true}"""), - ).andExpect(status().isAccepted) - } - - @Test - fun `POST agents id staged-inputs returns staged file metadata`() { - every { - sessions.stageInput("abc12345", "large document", "source.txt") - } returns StagedInput(path = "/workspace/.agent-inputs/20260604-source.txt", bytes = 14, name = "source.txt") - - mockMvc - .perform( - post("/agents/abc12345/staged-inputs") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"content":"large document","name":"source.txt"}"""), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.path").value("/workspace/.agent-inputs/20260604-source.txt")) - .andExpect(jsonPath("$.bytes").value(14)) - .andExpect(jsonPath("$.name").value("source.txt")) - } - - @Test - fun `POST agents id staged-inputs returns 400 on invalid content`() { - every { - sessions.stageInput("abc12345", "", "source.txt") - } throws IllegalArgumentException("staged input content is empty") - - mockMvc - .perform( - post("/agents/abc12345/staged-inputs") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"content":"","name":"source.txt"}"""), - ).andExpect(status().isBadRequest) - .andExpect(jsonPath("$.error").value("staged input content is empty")) - } -} diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/ws/AgentAttachHandlerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/ws/AgentAttachHandlerTest.kt deleted file mode 100644 index dc009d31..00000000 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/ws/AgentAttachHandlerTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.jorisjonkers.personalstack.agentgateway.ws - -import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentKind -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSession -import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSessionManager -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir -import org.springframework.web.socket.TextMessage -import org.springframework.web.socket.WebSocketSession -import tools.jackson.databind.ObjectMapper -import java.net.URI -import java.nio.file.Path -import java.time.Instant - -class AgentAttachHandlerTest { - private val sessions = mockk(relaxed = true) - private val mapper = ObjectMapper() - private val props = - GatewayProperties( - workspaceRoot = "/workspace", - tmux = GatewayProperties.Tmux(socketName = "agent-gw", stateDir = "/tmp"), - cli = GatewayProperties.Cli(claude = "claude", codex = "codex"), - git = GatewayProperties.Git(deployKeyDir = "/x"), - ) - private val handler = AgentAttachHandler(sessions, mapper, props) - - private fun agent( - id: String, - tmp: Path, - ): AgentSession = - AgentSession( - id = id, - kind = AgentKind.SHELL, - tmuxSession = "agent-$id", - logFile = tmp.resolve("agent-$id.log").also { it.toFile().createNewFile() }, - cwd = "/workspace", - createdAt = Instant.now(), - ) - - private fun wsSession(id: String): WebSocketSession { - val ws = mockk(relaxed = true) - every { ws.uri } returns URI("ws://host/ws/agents/$id/attach") - every { ws.id } returns "ws-$id" - every { ws.isOpen } returns true - return ws - } - - @Test - fun `attach sends one ansi snapshot frame before tailing`( - @TempDir tmp: Path, - ) { - val ws = wsSession("abc") - every { sessions.get("abc") } returns agent("abc", tmp) - every { sessions.captureWithEscapes("abc") } returns "SCREEN-SNAPSHOT" - val sent = slot() - every { ws.sendMessage(capture(sent)) } returns Unit - - handler.afterConnectionEstablished(ws) - - verify { sessions.captureWithEscapes("abc") } - assertThat(sent.captured.payload).contains("SCREEN-SNAPSHOT") - assertThat(sent.captured.payload).contains("\"output\"") - - handler.afterConnectionClosed(ws, org.springframework.web.socket.CloseStatus.NORMAL) - } - - @Test - fun `resize frame routes to AgentSessionManager resize`() { - val ws = wsSession("abc") - handler.handleMessage(ws, TextMessage("""{"resize":{"cols":120,"rows":40}}""")) - verify { sessions.resize("abc", 120, 40) } - verify(exactly = 0) { sessions.send(any(), any(), any()) } - } - - @Test - fun `input frame still routes to AgentSessionManager send`() { - val ws = wsSession("abc") - handler.handleMessage(ws, TextMessage("""{"input":"ls\r","enter":false}""")) - verify { sessions.send("abc", "ls\r", false) } - verify(exactly = 0) { sessions.resize(any(), any(), any()) } - } - - @Test - fun `unknown frame is ignored without resize or send`() { - val ws = wsSession("abc") - handler.handleMessage(ws, TextMessage("""{"hello":"world"}""")) - verify(exactly = 0) { sessions.resize(any(), any(), any()) } - verify(exactly = 0) { sessions.send(any(), any(), any()) } - } - - @Test - fun `malformed json is ignored`() { - val ws = wsSession("abc") - handler.handleMessage(ws, TextMessage("not json at all")) - verify(exactly = 0) { sessions.resize(any(), any(), any()) } - verify(exactly = 0) { sessions.send(any(), any(), any()) } - } -} diff --git a/services/agent-runner/Dockerfile b/services/agent-runner/Dockerfile deleted file mode 100644 index 19936942..00000000 --- a/services/agent-runner/Dockerfile +++ /dev/null @@ -1,272 +0,0 @@ -# syntax=docker/dockerfile:1 - -# agent-runner: the image every per-workspace Pod boots from. Bundles: -# - Claude Code CLI + Codex CLI (the unit of "the agent") -# - tmux + bash + git + gh (the agent's tools) -# - language toolchains: node 22, jdk 21, python, go (pinned below) -# - uv + mise for fast Python/toolchain management -# - ast-grep (sg) for AST-scoped symbol search -# - the agent-gateway jar as PID 1, owning the tmux server and the -# REST/WS plane assistant-api talks to -# -# The image is deliberately mutable inside the container; adversarial -# use is in scope. Isolation comes from the Pod boundary + the -# `agent-runners` NetworkPolicy + the credential PVCs being mounted -# only where they are needed. - -FROM gradle:9.4-jdk21-alpine AS gateway-build -WORKDIR /app -COPY settings.gradle.kts build.gradle.kts gradle.properties* ./ -COPY gradle/ gradle/ -COPY services/auth-api/build.gradle.kts services/auth-api/ -COPY services/assistant-api/build.gradle.kts services/assistant-api/ -COPY services/knowledge-api/build.gradle.kts services/knowledge-api/ -COPY services/agent-gateway/build.gradle.kts services/agent-gateway/ -COPY services/system-tests/build.gradle.kts services/system-tests/ -RUN --mount=type=secret,id=github_token \ - --mount=type=secret,id=github_actor \ - set -eu; \ - GITHUB_TOKEN="$(cat /run/secrets/github_token)"; \ - GITHUB_ACTOR="$(cat /run/secrets/github_actor)"; \ - export GITHUB_TOKEN GITHUB_ACTOR; \ - gradle :services:agent-gateway:dependencies --no-daemon || true -COPY services/agent-gateway/src/main/ services/agent-gateway/src/main/ -RUN --mount=type=secret,id=github_token \ - --mount=type=secret,id=github_actor \ - set -eu; \ - GITHUB_TOKEN="$(cat /run/secrets/github_token)"; \ - GITHUB_ACTOR="$(cat /run/secrets/github_actor)"; \ - export GITHUB_TOKEN GITHUB_ACTOR; \ - gradle :services:agent-gateway:bootJar --no-daemon - -FROM debian:bookworm-slim -ARG GIT_SHA=unknown -ENV SERVICE_VERSION=${GIT_SHA} -ENV DEBIAN_FRONTEND=noninteractive - -# Base tools. `gh` from the official apt source; Docker CLI / Compose -# from Docker's apt source; node 22 LTS via the nodesource one-liner; -# Temurin JDK 21 from the Adoptium apt source; python from Debian. -# -# Java must be 21, not Debian bookworm's `default-jdk` — that resolves -# to openjdk-17, and the gateway jar is compiled to Java 21 bytecode -# (class file 65.0), so a 17 runtime dies at boot with -# `UnsupportedClassVersionError ... only ... up to 61.0` and every -# runner Pod crash-loops. openjdk-21 is in neither bookworm main nor -# backports, so the Adoptium Temurin build is the source. -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl gnupg jq tini \ - bash zsh tmux git openssh-client \ - build-essential \ - python3 python3-pip python3-venv \ - ripgrep fd-find bat less \ - bubblewrap \ - && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ - > /etc/apt/sources.list.d/github-cli.list \ - && curl -fsSL https://packages.adoptium.net/artifactory/api/gpg/key/public \ - | gpg --dearmor -o /usr/share/keyrings/adoptium.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" \ - > /etc/apt/sources.list.d/adoptium.list \ - && curl -fsSL https://download.docker.com/linux/debian/gpg \ - | gpg --dearmor -o /usr/share/keyrings/docker.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" \ - > /etc/apt/sources.list.d/docker.list \ - && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - gh nodejs temurin-21-jdk \ - docker-ce-cli docker-buildx-plugin docker-compose-plugin \ - && docker --version >/dev/null \ - && docker compose version >/dev/null \ - && rm -rf /var/lib/apt/lists/* - -# Claude Code + Codex CLIs. Both ship via npm; pin to the versions -# the auth flow was tested against and bump deliberately. The CLIs -# read OAuth tokens from $HOME/.claude/.credentials.json and -# $CODEX_HOME/auth.json — both mounted as PVCs by every Pod that -# uses this image. -RUN npm install -g \ - @anthropic-ai/claude-code \ - @openai/codex \ - && npm cache clean --force - -# Playwright MCP (browser automation) + a matching chromium. The MCP -# server (bin: playwright-mcp) is installed globally so the stdio launch -# needs no runtime npx fetch. The chromium revision is tied to the -# Playwright version, so `playwright` is pinned to the exact version -# @playwright/mcp depends on — bump the two in lockstep. Browsers + their -# system libs live in a shared, world-readable path so the non-root -# agent user can launch chromium headless. -ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright -RUN npm install -g @playwright/mcp@0.0.75 playwright@1.61.0-alpha-1778188671000 \ - && playwright install --with-deps chromium \ - && chmod -R a+rX /ms-playwright \ - && npm cache clean --force - -# GitHub MCP server (stdio), launched via gh-mcp-wrapper which mints a -# fresh App installation token at spawn (the App token rotates hourly -# and a static MCP header cannot follow it). -ARG GITHUB_MCP_VERSION=1.1.2 -RUN arch="$(dpkg --print-architecture)" \ - && case "$arch" in \ - amd64) gmcp_arch=x86_64 ;; \ - arm64) gmcp_arch=arm64 ;; \ - *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ - esac \ - && curl -fsSL "https://github.com/github/github-mcp-server/releases/download/v${GITHUB_MCP_VERSION}/github-mcp-server_Linux_${gmcp_arch}.tar.gz" \ - | tar -xz -C /usr/local/bin github-mcp-server \ - && chmod +x /usr/local/bin/github-mcp-server - -# Go toolchain — installed as root from the official binary tarball so -# the GOROOT is world-readable and the agent user can install tooling -# under GOPATH without privilege. -# Fetch checksums from https://go.dev/dl/ before bumping. -ARG GO_VERSION=1.24.3 -RUN arch="$(dpkg --print-architecture)" \ - && case "$arch" in \ - amd64) go_arch=amd64 ;; \ - arm64) go_arch=arm64 ;; \ - *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ - esac \ - && curl -fsSL "https://dl.google.com/go/go${GO_VERSION}.linux-${go_arch}.tar.gz" \ - -o /tmp/go.tar.gz \ - && tar -C /usr/local -xzf /tmp/go.tar.gz \ - && rm /tmp/go.tar.gz -ENV GOROOT=/usr/local/go -ENV GOPATH=/home/agent/go -ENV PATH=/usr/local/go/bin:${PATH} - -# uv — fast Python package/toolchain manager. Installed from GitHub -# releases as a static binary so it works without pip or a venv. -# Fetch checksums from https://github.com/astral-sh/uv/releases before bumping. -ARG UV_VERSION=0.7.8 -RUN arch="$(dpkg --print-architecture)" \ - && case "$arch" in \ - amd64) uv_arch=x86_64-unknown-linux-gnu ;; \ - arm64) uv_arch=aarch64-unknown-linux-gnu ;; \ - *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ - esac \ - && curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${uv_arch}.tar.gz" \ - -o /tmp/uv.tar.gz \ - && tar -C /usr/local/bin -xzf /tmp/uv.tar.gz --strip-components=1 \ - "uv-${uv_arch}/uv" "uv-${uv_arch}/uvx" \ - && rm /tmp/uv.tar.gz \ - && chmod +x /usr/local/bin/uv /usr/local/bin/uvx - -# Serena MCP — LSP-backed semantic code intelligence. Installed as a -# uv-managed tool for the agent user later in the file so the `serena` -# command is on the runner PATH without using uvx/network at MCP startup. -ARG SERENA_AGENT_VERSION=1.5.3 - -# mise — polyglot runtime manager (Node, Python, Go, Ruby …). -# Installed as the root-owned system binary so the agent user can -# activate toolchains without a separate install step. -# Fetch checksums from https://github.com/jdx/mise/releases before bumping. -ARG MISE_VERSION=2025.5.16 -RUN arch="$(dpkg --print-architecture)" \ - && case "$arch" in \ - amd64) mise_arch=x64 ;; \ - arm64) mise_arch=arm64 ;; \ - *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ - esac \ - && curl -fsSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-${mise_arch}" \ - -o /usr/local/bin/mise \ - && chmod +x /usr/local/bin/mise - -# ast-grep (sg) — structural search/rewrite for Kotlin, TypeScript, -# Python etc. Installed globally so skills and hooks can call `sg` -# without a workspace-level install. The npm prefix is /usr, so the -# package's `sg` bin collides with Debian's /usr/bin/sg (newgrp) and the -# global install aborts with EEXIST; drop the shadow-utils `sg` first so -# `sg` resolves to ast-grep, which is the binary this image wants. -ARG AST_GREP_VERSION=0.38.4 -RUN rm -f /usr/bin/sg \ - && npm install -g "@ast-grep/cli@${AST_GREP_VERSION}" \ - && npm cache clean --force - -# The gateway jar runs on the Temurin 21 JDK installed above; the -# entrypoint launches it with ZGC the same way every other service does. -COPY --from=gateway-build /app/services/agent-gateway/build/libs/*.jar /opt/agent-gateway.jar -COPY platform/agents/kit/templates/repo/.specify /opt/agent-kit/sdd -COPY services/agent-runner/entrypoint.sh /opt/entrypoint.sh -RUN chmod +x /opt/entrypoint.sh -RUN set -eu; \ - tmp="$(mktemp -d)"; \ - mkdir -p "${tmp}/.claude" "${tmp}/.codex"; \ - output="$(HOME="${tmp}" CODEX_HOME="${tmp}/.codex" CLAUDE_CONFIG_DIR="${tmp}/.claude" AGENT_RUNNER_ENTRYPOINT_SELF_TEST=agent-kit-manifest /opt/entrypoint.sh)"; \ - printf '%s\n' "${output}" | grep -q "manifest missing for Claude"; \ - printf '%s\n' "${output}" | grep -q "manifest missing for Codex"; \ - printf 'version=old\n' > "${tmp}/.claude/.knowledge-system-version"; \ - printf 'version=old\n' > "${tmp}/.codex/.knowledge-system-version"; \ - output="$(HOME="${tmp}" CODEX_HOME="${tmp}/.codex" CLAUDE_CONFIG_DIR="${tmp}/.claude" AGENT_KIT_EXPECTED_VERSION=current AGENT_RUNNER_ENTRYPOINT_SELF_TEST=agent-kit-manifest /opt/entrypoint.sh)"; \ - printf '%s\n' "${output}" | grep -q "is old, expected current"; \ - printf 'version=current\n' > "${tmp}/.claude/.knowledge-system-version"; \ - printf 'version=current\n' > "${tmp}/.codex/.knowledge-system-version"; \ - output="$(HOME="${tmp}" CODEX_HOME="${tmp}/.codex" CLAUDE_CONFIG_DIR="${tmp}/.claude" AGENT_KIT_EXPECTED_VERSION=current AGENT_RUNNER_ENTRYPOINT_SELF_TEST=agent-kit-manifest /opt/entrypoint.sh)"; \ - test -z "${output}"; \ - allow="$(REPO_URL='git@github.com:o/primary.git' REPO_URLS='https://github.com/o/extra.git#dev git@github.com:o/second.git' AGENT_RUNNER_ENTRYPOINT_SELF_TEST=repo-allow /opt/entrypoint.sh)"; \ - test "${allow}" = 'o/primary o/extra o/second'; \ - test -z "$(REPO_URL='' REPO_URLS='' AGENT_RUNNER_ENTRYPOINT_SELF_TEST=repo-allow /opt/entrypoint.sh)"; \ - test "$(REPO_URL='git@github.com:o/personal-stack.git' AGENT_RUNNER_ENTRYPOINT_SELF_TEST=repo-dir /opt/entrypoint.sh)" = 'personal-stack'; \ - test "$(REPO_URL='https://github.com/o/website.git' AGENT_RUNNER_ENTRYPOINT_SELF_TEST=repo-dir /opt/entrypoint.sh)" = 'website'; \ - mkdir -p "${tmp}/workspace/personal-stack/.git"; \ - WORKSPACE_ROOT="${tmp}/workspace" AGENT_KIT_SDD_SOURCE=/opt/agent-kit/sdd AGENT_RUNNER_ENTRYPOINT_SELF_TEST=speckit-seed /opt/entrypoint.sh; \ - test -f "${tmp}/workspace/personal-stack/.specify/templates/spec-template.md"; \ - test -x "${tmp}/workspace/personal-stack/.specify/scripts/bash/create-new-feature.sh"; \ - test -f "${tmp}/workspace/personal-stack/.specify/memory/constitution.md"; \ - test -f "${tmp}/workspace/personal-stack/.specify/.agent-kit-sdd-seed.sha256"; \ - rm -rf "${tmp}" - -# Shared token helper used by gh, GitHub MCP, and Git's credential -# helper. Centralising cache/minting logic keeps push + PR creation on -# the same repo-scoped token. -COPY services/agent-runner/agent-github-token.sh /usr/local/bin/agent-github-token -RUN chmod +x /usr/local/bin/agent-github-token - -# `gh` wrapper that mints a fresh App installation token before each -# real gh call. Installed ahead of the apt gh (/usr/bin/gh) on PATH so -# `gh pr create` / `gh run rerun` carry a short-lived, repo-scoped -# token; the wrapper is a no-op passthrough when the App is not wired. -COPY services/agent-runner/gh-app-token-wrapper.sh /usr/local/bin/gh -RUN chmod +x /usr/local/bin/gh - -# Git credential helper for HTTPS pushes. The entrypoint rewrites -# github.com SSH remotes to HTTPS after boot-time clone, so `git push` -# uses the same installation token instead of requiring an SSH key. -COPY services/agent-runner/git-credential-agent-gh-app.sh /usr/local/bin/git-credential-agent-gh-app -RUN chmod +x /usr/local/bin/git-credential-agent-gh-app - -# Wrapper that launches the GitHub MCP server with a freshly-minted App -# token (see gh-mcp-wrapper.sh). Referenced as the `github` MCP server's -# command in the agents-mcp-servers ConfigMap. -COPY services/agent-runner/gh-mcp-wrapper.sh /usr/local/bin/gh-mcp-wrapper -RUN chmod +x /usr/local/bin/gh-mcp-wrapper - -# Non-root agent user, with /workspace writable. /home/agent is the -# anchor for ~/.claude, ~/.codex, ~/.gitconfig, ~/.ssh — all mounted -# from PVCs/Secrets at runtime. The runtime Pod also injects the host -# Docker socket's supplemental group; the image-level docker group keeps -# local/manual runs usable when the host happens to use the common GID. -ARG DOCKER_GROUP_ID=999 -RUN if getent group docker >/dev/null; then :; \ - elif getent group "${DOCKER_GROUP_ID}" >/dev/null; then groupadd docker; \ - else groupadd --gid "${DOCKER_GROUP_ID}" docker; fi \ - && useradd -m -u 1000 -G docker -s /bin/bash agent \ - && mkdir -p /workspace /home/agent/.claude /home/agent/.codex /home/agent/.ssh \ - && chown -R agent:agent /workspace /home/agent -USER agent -WORKDIR /workspace -ENV HOME=/home/agent -ENV CODEX_HOME=/home/agent/.codex -# /home/agent/go/bin — user-installed Go binaries -# /home/agent/.local/bin — uv-managed tools + pip installs -# GOROOT/GOPATH are set above at the root layer; repeat for agent context. -ENV GOPATH=/home/agent/go -ENV PATH=/home/agent/.npm-global/bin:/home/agent/go/bin:/home/agent/.local/bin:${PATH} -RUN uv tool install -p 3.13 "serena-agent==${SERENA_AGENT_VERSION}" \ - && serena --help >/dev/null - -EXPOSE 8090 -ENTRYPOINT ["/usr/bin/tini", "--", "/opt/entrypoint.sh"] diff --git a/services/agent-runner/agent-github-token.sh b/services/agent-runner/agent-github-token.sh deleted file mode 100755 index 215a90de..00000000 --- a/services/agent-runner/agent-github-token.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash -# Prints a GitHub token for the requested repo. A pre-seeded -# GH_TOKEN/GITHUB_PERSONAL_ACCESS_TOKEN wins; otherwise the helper mints -# a short-lived GitHub App installation token through assistant-api and -# caches it per repo until it is close to expiry. The target repo is the -# first argument, else AGENT_GITHUB_REPO_URL, else the current git -# remote, else REPO_URL. That keeps commands run inside -# /workspace/ scoped to that repo, while root-level commands -# still fall back to the primary repository. -set -u - -CACHE_DIR="${GH_APP_TOKEN_CACHE_DIR:-/tmp}" -SKEW="${GH_APP_TOKEN_SKEW_SECONDS:-300}" -WORKSPACE_ROOT="${WORKSPACE_ROOT:-/workspace}" - -# Pre-seeded tokens win and are repo-agnostic. -if [ -n "${GH_TOKEN:-}" ]; then - printf '%s' "$GH_TOKEN" - exit 0 -fi - -if [ -n "${GITHUB_PERSONAL_ACCESS_TOKEN:-}" ]; then - printf '%s' "$GITHUB_PERSONAL_ACCESS_TOKEN" - exit 0 -fi - -repo_slug() { - local url="$1" - case "$url" in - git@github.com:*) url="${url#git@github.com:}" ;; - ssh://git@github.com/*) url="${url#ssh://git@github.com/}" ;; - https://github.com/*) url="${url#https://github.com/}" ;; - http://github.com/*) url="${url#http://github.com/}" ;; - esac - url="${url%.git}" - printf '%s' "$url" -} - -target_repo_url() { - if [ -n "${1:-}" ]; then - printf '%s' "$1" - return 0 - fi - if [ -n "${AGENT_GITHUB_REPO_URL:-}" ]; then - printf '%s' "$AGENT_GITHUB_REPO_URL" - return 0 - fi - if git -C "$WORKSPACE_ROOT" remote get-url origin 2>/dev/null; then - return 0 - fi - if git remote get-url origin 2>/dev/null; then - return 0 - fi - if [ -n "${REPO_URL:-}" ]; then - printf '%s' "$REPO_URL" - return 0 - fi - return 1 -} - -REPO_URL_RESOLVED="$(target_repo_url "${1:-}")" || exit 1 -[ -n "$REPO_URL_RESOLVED" ] || exit 1 -SLUG="$(repo_slug "$REPO_URL_RESOLVED")" -# Per-repo cache so back-to-back calls for different repos don't clobber -# each other; an explicit GH_APP_TOKEN_CACHE still overrides for one repo. -CACHE="${GH_APP_TOKEN_CACHE:-${CACHE_DIR}/.gh-app-token-$(printf '%s' "$SLUG" | tr '/' '-')}" - -parse_expiry() { - local value="$1" - date -d "$value" +%s 2>/dev/null || - date -j -f "%Y-%m-%dT%H:%M:%SZ" "$value" +%s 2>/dev/null || - echo 0 -} - -cached_token() { - local now exp tok - now=$(date +%s) - [ -f "$CACHE" ] || return 1 - exp=$(sed -n 1p "$CACHE" 2>/dev/null) - tok=$(sed -n 2p "$CACHE" 2>/dev/null) - [ -n "$exp" ] && [ -n "$tok" ] && [ "$exp" -gt "$((now + SKEW))" ] 2>/dev/null || return 1 - printf '%s' "$tok" -} - -mint_token() { - [ -n "${GITHUB_APP_TOKEN_URL:-}" ] && [ -n "${GITHUB_APP_TOKEN_BEARER:-}" ] || return 1 - - local payload resp tok exp_iso exp - payload=$(jq -nc --arg repoUrl "$REPO_URL_RESOLVED" '{repoUrl: $repoUrl}') || return 1 - resp=$(curl -fsS --max-time 10 -X POST "$GITHUB_APP_TOKEN_URL" \ - -H "Authorization: Bearer $GITHUB_APP_TOKEN_BEARER" \ - -H "Content-Type: application/json" \ - -d "$payload" 2>/dev/null) || return 1 - - tok=$(printf '%s' "$resp" | jq -r '.token // empty' 2>/dev/null) - exp_iso=$(printf '%s' "$resp" | jq -r '.expiresAt // empty' 2>/dev/null) - [ -n "$tok" ] || return 1 - - exp=$(parse_expiry "$exp_iso") - ( - umask 077 - printf '%s\n%s\n' "$exp" "$tok" >"$CACHE" - ) - printf '%s' "$tok" -} - -cached_token || mint_token diff --git a/services/agent-runner/entrypoint.sh b/services/agent-runner/entrypoint.sh deleted file mode 100755 index 4b4df09e..00000000 --- a/services/agent-runner/entrypoint.sh +++ /dev/null @@ -1,518 +0,0 @@ -#!/bin/sh -# agent-runner entrypoint. Three responsibilities: -# -# 1. Surface the deploy-key mount through to a writable file with -# 0600 so ssh accepts it. The Vault Secret mount makes the file -# world-readable; openssh refuses keys that aren't 0600. -# 2. Make the runner's identity reproducible (git user.name + -# user.email come from env or fall back to a marker). -# 3. exec the gateway jar as PID 1 (via tini so children reap). -set -eu - -CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" -CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" -WORKSPACE_ROOT="${WORKSPACE_ROOT:-/workspace}" -AGENT_KIT_SDD_SOURCE="${AGENT_KIT_SDD_SOURCE:-/opt/agent-kit/sdd}" -AGENT_KIT_SDD_MARKER_FILE="${AGENT_KIT_SDD_MARKER_FILE:-.agent-kit-sdd-seed.sha256}" - -check_agent_kit_manifest() { - agent_name="$1" - manifest_path="$2" - expected_version="${AGENT_KIT_EXPECTED_VERSION:-}" - - if [ ! -f "$manifest_path" ]; then - echo "[entrypoint] WARN: knowledge-system agent kit manifest missing for ${agent_name} at ${manifest_path}; run agents-kb-install or /install.sh --agent all" - return - fi - - installed_version=$(awk -F= '/^version=/{print $2; exit}' "$manifest_path" 2>/dev/null || true) - if [ -z "$installed_version" ]; then - echo "[entrypoint] WARN: knowledge-system agent kit manifest for ${agent_name} has no version at ${manifest_path}" - return - fi - - if [ -n "$expected_version" ] && [ "$installed_version" != "$expected_version" ]; then - echo "[entrypoint] WARN: knowledge-system agent kit manifest for ${agent_name} is ${installed_version}, expected ${expected_version}; run agents-kb-install or /install.sh --agent all" - fi -} - -check_agent_kit_manifests() { - check_agent_kit_manifest "Claude" "${CLAUDE_CONFIG_DIR}/.knowledge-system-version" - check_agent_kit_manifest "Codex" "${CODEX_HOME}/.knowledge-system-version" -} - -# Strip a GitHub remote (ssh or https) down to its owner/repo slug. -repo_slug() { - printf '%s' "$1" | sed \ - -e 's#^git@github.com:##' \ - -e 's#^ssh://git@github.com/##' \ - -e 's#^https://github.com/##' \ - -e 's#^http://github.com/##' \ - -e 's#\.git$##' -} - -repo_dir_name() { - basename "$1" .git -} - -# The credential-helper allow-list: owner/repo slugs for the primary REPO_URL -# plus every REPO_URLS entry ("url[#branch]"). Bounding the helper to exactly -# the workspace's repos keeps the App token's blast radius the same as the old -# single-repo gate even though the helper now serves more than one repo. -derive_repo_allow() { - _allow="" - [ -n "${REPO_URL:-}" ] && _allow="$(repo_slug "$REPO_URL")" - for _entry in $(printf '%s' "${REPO_URLS:-}" | tr ';\n' ' '); do - _allow="${_allow} $(repo_slug "${_entry%%#*}")" - done - printf '%s' "$_allow" | tr -s ' ' | sed -e 's/^ *//' -e 's/ *$//' -} - -# Trust a cloned repo dir for git, Claude Code, and Codex so the CLIs do not -# stop to ask. Mirrors the WORKSPACE_ROOT trust seeded below, per extra repo. -register_repo_trust() { - _dir="$1" - if ! git config --global --get-all safe.directory | grep -Fxq "$_dir"; then - git config --global --add safe.directory "$_dir" - fi - if [ -f "$HOME/.claude.json" ]; then - _ctmp=$(mktemp) - if jq --arg d "$_dir" ' - (.projects //= {}) - | (.projects[$d] //= {}) - | (.projects[$d].hasTrustDialogAccepted //= true) - | (.projects[$d].hasCompletedProjectOnboarding //= true) - ' "$HOME/.claude.json" > "$_ctmp"; then - mv "$_ctmp" "$HOME/.claude.json" - else - rm -f "$_ctmp" - fi - fi - if [ -f "$CODEX_HOME/config.toml" ] && - ! grep -q "^\[projects\.\"${_dir}\"\]" "$CODEX_HOME/config.toml"; then - printf '\n[projects."%s"]\ntrust_level = "trusted"\n' "$_dir" \ - >> "$CODEX_HOME/config.toml" - fi -} - -speckit_marker_hash() { - _speckit_path="$1" - _speckit_marker="$2" - if [ -f "$_speckit_marker" ]; then - awk -v p="$_speckit_path" '$2 == p { print $1; exit }' "$_speckit_marker" - fi -} - -speckit_seed_file() { - _speckit_src="$1" - _speckit_dest="$2" - _speckit_rel="$3" - _speckit_src_hash="$(sha256sum "$_speckit_src" | awk '{ print $1 }')" - _speckit_old_hash="$(speckit_marker_hash "$_speckit_rel" "$_speckit_marker")" - _speckit_record_hash="" - - mkdir -p "$(dirname "$_speckit_dest")" - if [ ! -f "$_speckit_dest" ]; then - cp -p "$_speckit_src" "$_speckit_dest" - _speckit_record_hash="$_speckit_src_hash" - elif [ -n "$_speckit_old_hash" ]; then - _speckit_dest_hash="$(sha256sum "$_speckit_dest" | awk '{ print $1 }')" - if [ "$_speckit_dest_hash" = "$_speckit_old_hash" ]; then - if [ "$_speckit_dest_hash" != "$_speckit_src_hash" ]; then - cp -p "$_speckit_src" "$_speckit_dest" - fi - _speckit_record_hash="$_speckit_src_hash" - else - if [ "$_speckit_old_hash" != "$_speckit_src_hash" ]; then - echo "[entrypoint] WARN: ${_speckit_dest} has local changes and a stale SDD seed; leaving it unchanged" - fi - _speckit_record_hash="$_speckit_old_hash" - fi - else - _speckit_dest_hash="$(sha256sum "$_speckit_dest" | awk '{ print $1 }')" - if [ "$_speckit_dest_hash" = "$_speckit_src_hash" ]; then - _speckit_record_hash="$_speckit_src_hash" - fi - fi - - if [ -n "$_speckit_record_hash" ]; then - printf '%s %s\n' "$_speckit_record_hash" "$_speckit_rel" >> "$_speckit_new_marker" - fi -} - -speckit_seed() { - _speckit_repo="$1" - _speckit_dest_root="${_speckit_repo}/.specify" - _speckit_marker="${_speckit_dest_root}/${AGENT_KIT_SDD_MARKER_FILE}" - - if [ ! -d "$AGENT_KIT_SDD_SOURCE" ]; then - echo "[entrypoint] WARN: SDD source missing at ${AGENT_KIT_SDD_SOURCE}; skipping ${_speckit_repo}" - return - fi - - _speckit_new_marker="$(mktemp)" - for _speckit_dir in templates scripts; do - if [ -d "${AGENT_KIT_SDD_SOURCE}/${_speckit_dir}" ]; then - find "${AGENT_KIT_SDD_SOURCE}/${_speckit_dir}" -type f | sort | while IFS= read -r _speckit_src; do - _speckit_rel="${_speckit_src#${AGENT_KIT_SDD_SOURCE}/}" - speckit_seed_file "$_speckit_src" "${_speckit_dest_root}/${_speckit_rel}" "$_speckit_rel" - done - fi - done - - if [ -f "${AGENT_KIT_SDD_SOURCE}/templates/constitution-template.md" ]; then - speckit_seed_file \ - "${AGENT_KIT_SDD_SOURCE}/templates/constitution-template.md" \ - "${_speckit_dest_root}/memory/constitution.md" \ - "memory/constitution.md" - fi - - if [ -s "$_speckit_new_marker" ]; then - mkdir -p "$_speckit_dest_root" - mv "$_speckit_new_marker" "$_speckit_marker" - else - rm -f "$_speckit_new_marker" - fi -} - -speckit_seed_workspace() { - for _speckit_git_dir in "${WORKSPACE_ROOT}"/*/.git; do - [ -d "$_speckit_git_dir" ] || continue - speckit_seed "${_speckit_git_dir%/.git}" - done -} - -if [ "${AGENT_RUNNER_ENTRYPOINT_SELF_TEST:-}" = "agent-kit-manifest" ]; then - check_agent_kit_manifests - exit 0 -fi - -if [ "${AGENT_RUNNER_ENTRYPOINT_SELF_TEST:-}" = "repo-allow" ]; then - derive_repo_allow - exit 0 -fi - -if [ "${AGENT_RUNNER_ENTRYPOINT_SELF_TEST:-}" = "repo-dir" ]; then - repo_dir_name "${REPO_URL:-}" - exit 0 -fi - -if [ "${AGENT_RUNNER_ENTRYPOINT_SELF_TEST:-}" = "speckit-seed" ]; then - speckit_seed_workspace - exit 0 -fi - -check_agent_kit_manifests - -# Bootstrap git identity — assistant-api injects GIT_AUTHOR_NAME / -# GIT_AUTHOR_EMAIL when it creates the Pod; fall back to a clearly -# non-human identity so accidental commits are easy to spot. -git config --global user.name "${GIT_AUTHOR_NAME:-Personal Stack Agent}" -git config --global user.email "${GIT_AUTHOR_EMAIL:-agents@jorisjonkers.dev}" -git config --global init.defaultBranch main -git config --global credential.helper agent-gh-app -git config --global credential.useHttpPath true -export GIT_TERMINAL_PROMPT=0 - -# Restore ~/.claude.json from the latest PVC-backed backup if the -# runtime file is missing. Claude Code keeps OAuth tokens in -# ~/.claude/.credentials.json (lives on claude-credentials PVC, so -# survives Pod restarts) but its user config — including the -# org/project bindings without which `claude -p` refuses to run — -# sits at ~/.claude.json (sibling of ~/.claude/, in the container's -# writable layer, lost on every restart). Claude itself writes a -# rolling backup to ~/.claude/backups/.claude.json.backup. on -# every config write, which DOES land on the PVC, so the post- -# bootstrap behaviour is "every fresh Pod has a backup it can -# restore from". This snippet performs that restore. The latest -# backup wins so token-refresh updates propagate forward. -if [ ! -f "$HOME/.claude.json" ] && [ -d "$HOME/.claude/backups" ]; then - latest=$(ls -1t "$HOME/.claude/backups"/.claude.json.backup.* 2>/dev/null | head -n1 || true) - if [ -n "$latest" ]; then - cp "$latest" "$HOME/.claude.json" - fi -fi - -# Suppress Claude's first-run prompts without clobbering a restored -# config. The OAuth token persists on the claude-credentials PVC, but -# the per-user flags that record "theme chosen / onboarding done / -# directory trusted" live in ~/.claude.json, which is lost on every -# fresh Pod unless a backup happened to exist. A logged-in CLI that -# still has theme==undefined or hasCompletedOnboarding!=true re-runs -# the onboarding wizard (which is also where the theme picker lives), -# and an untrusted project dir re-shows the trust dialog — both block -# the non-interactive tmux session. `jq //=` fills only the missing -# keys, so a real restored config (its own theme, oauthAccount, the -# project history array) is preserved verbatim. WORKSPACE_ROOT keys -# the per-project trust entry to the dir the gateway launches the CLI -# in (AgentSessionManager defaults cwd to the gateway's workspace-root). -if ! git config --global --get-all safe.directory | grep -Fxq "$WORKSPACE_ROOT"; then - git config --global --add safe.directory "$WORKSPACE_ROOT" -fi -if [ ! -f "$HOME/.claude.json" ]; then - echo '{}' > "$HOME/.claude.json" -fi -claude_tmp=$(mktemp) -if jq --arg ws "$WORKSPACE_ROOT" ' - (.theme //= "dark") - | (.hasCompletedOnboarding //= true) - | (.bypassPermissionsModeAccepted //= true) - | (.projects //= {}) - | (.projects[$ws] //= {}) - | (.projects[$ws].hasTrustDialogAccepted //= true) - | (.projects[$ws].hasCompletedProjectOnboarding //= true) - ' "$HOME/.claude.json" > "$claude_tmp"; then - mv "$claude_tmp" "$HOME/.claude.json" -else - rm -f "$claude_tmp" -fi - -# Register MCP servers into Claude Code from the declarative ConfigMap -# (agents-mcp-servers, mounted at /etc/agent-mcp). The selected profile -# is an mcpServers object with @KB_URL@/@KB_BEARER_TOKEN@ placeholders -# filled from the Pod env, so no secret is baked into the ConfigMap. -# The managed servers win for their own keys; any hand-added server -# already in the config is preserved. Absent mount (feature off) => no-op. -AGENT_MCP_PROFILE="${AGENT_MCP_PROFILE:-minimal}" -case "$AGENT_MCP_PROFILE" in - minimal|frontend|cluster|code-intel|full-diagnostic) ;; - *) - echo "[entrypoint] WARN: unknown AGENT_MCP_PROFILE=$AGENT_MCP_PROFILE; using minimal" - AGENT_MCP_PROFILE="minimal" - ;; -esac - -AGENT_MCP_DIR="${AGENT_MCP_DIR:-/etc/agent-mcp}" -if [ -n "${AGENT_MCP_SERVERS_FILE:-}" ]; then - MCP_SERVERS_FILE="$AGENT_MCP_SERVERS_FILE" -else - MCP_PROFILE_FILE="${AGENT_MCP_DIR}/claude-mcp-servers.${AGENT_MCP_PROFILE}.json" - MCP_MINIMAL_FILE="${AGENT_MCP_DIR}/claude-mcp-servers.minimal.json" - MCP_LEGACY_FILE="${AGENT_MCP_DIR}/claude-mcp-servers.json" - if [ -f "$MCP_PROFILE_FILE" ]; then - MCP_SERVERS_FILE="$MCP_PROFILE_FILE" - elif [ -f "$MCP_MINIMAL_FILE" ]; then - echo "[entrypoint] WARN: MCP profile $AGENT_MCP_PROFILE not found; using minimal" - MCP_SERVERS_FILE="$MCP_MINIMAL_FILE" - else - MCP_SERVERS_FILE="$MCP_LEGACY_FILE" - fi -fi -if [ -f "$MCP_SERVERS_FILE" ]; then - mcp_rendered=$(mktemp) - sed -e "s|@KB_URL@|${KB_URL:-}|g" \ - -e "s|@KB_BEARER_TOKEN@|${KB_BEARER_TOKEN:-}|g" \ - "$MCP_SERVERS_FILE" > "$mcp_rendered" - mcp_merged=$(mktemp) - if jq -s ' - .[0] as $cfg | .[1] as $servers - | $cfg + { mcpServers: (($cfg.mcpServers // {}) * $servers) } - ' "$HOME/.claude.json" "$mcp_rendered" > "$mcp_merged" 2>/dev/null; then - mv "$mcp_merged" "$HOME/.claude.json" - else - echo "[entrypoint] WARN: failed to merge MCP servers from $MCP_SERVERS_FILE" - rm -f "$mcp_merged" - fi - rm -f "$mcp_rendered" -fi - -# Trust the workspace for Codex the same way. ~/.codex sits on the -# codex-credentials PVC (CODEX_HOME), so auth.json persists, but a -# fresh checkout of the workspace dir is "untrusted" until the -# interactive trust prompt is answered, and the default approval -# policy stops to ask before each command — both stall the tmux -# session. Seeding global non-interactive approval/sandbox plus a -# per-project trusted entry removes every prompt. Only created when -# absent so a hand-edited config on the PVC is never overwritten. -if [ ! -f "$CODEX_HOME/config.toml" ]; then - mkdir -p "$CODEX_HOME" - cat > "$CODEX_HOME/config.toml" < "$codex_tmp" - { - echo "" - echo "[features]" - echo "apps = false" - echo "" - echo "[apps._default]" - echo "enabled = false" - if [ -f "$CODEX_MCP_FILE" ]; then - echo "" - sed -e "s|@KB_URL@|${KB_URL:-}|g" "$CODEX_MCP_FILE" - fi - } >> "$codex_tmp" - # Collapse runs of blank lines so repeated boots don't grow the file. - awk 'NF{print; blank=0; next} {blank++} blank<2' "$codex_tmp" > "$CODEX_HOME/config.toml" - rm -f "$codex_tmp" -fi - -# Stage the deploy key into /tmp with restrictive perms. The gateway -# does the same dance defensively, but doing it once at boot makes -# manual debugging from the shell less surprising. -if [ -f /var/run/secrets/agents/github-deploy-key/private_key ]; then - cp /var/run/secrets/agents/github-deploy-key/private_key /tmp/agent-deploy-key - chmod 0600 /tmp/agent-deploy-key - if [ -f /var/run/secrets/agents/github-deploy-key/known_hosts ]; then - mkdir -p "$HOME/.ssh" - cp /var/run/secrets/agents/github-deploy-key/known_hosts "$HOME/.ssh/known_hosts" - fi -fi - -clone_repo_into_workspace() { - _repo_url="$1" - _repo_branch="${2:-}" - _repo_name="$(repo_dir_name "$_repo_url")" - _repo_target="${WORKSPACE_ROOT}/${_repo_name}" - - if [ -d "${_repo_target}/.git" ]; then - echo "[entrypoint] ${_repo_name} already present; skipping clone" - register_repo_trust "$_repo_target" - return - fi - - # Migrate legacy single-repo PVCs that cloned the primary directly - # into /workspace. Keep staged inputs at the multi-repo root; move - # the git checkout into /workspace/. - if [ -d "${WORKSPACE_ROOT}/.git" ] && [ ! -e "$_repo_target" ]; then - echo "[entrypoint] migrating legacy workspace root clone into ${_repo_target}" - mkdir -p "$_repo_target" - find "$WORKSPACE_ROOT" -mindepth 1 -maxdepth 1 \ - ! -name "$_repo_name" \ - ! -name ".agent-inputs" \ - -exec mv {} "$_repo_target/" \; - register_repo_trust "$_repo_target" - return - fi - - if [ -e "$_repo_target" ]; then - echo "[entrypoint] WARN: ${_repo_target} exists but is not a git checkout; skipping clone of ${_repo_url}" - return - fi - - if [ -n "$_repo_branch" ]; then - git clone --branch "$_repo_branch" "$_repo_url" "$_repo_target" \ - || echo "[entrypoint] WARN: clone of ${_repo_url} failed; continuing" - else - git clone "$_repo_url" "$_repo_target" \ - || echo "[entrypoint] WARN: clone of ${_repo_url} failed; continuing" - fi - if [ -d "${_repo_target}/.git" ]; then - register_repo_trust "$_repo_target" - fi -} - -# Clone every workspace repository into its own named folder under -# /workspace. The orchestrator passes REPO_URL (+ optional REPO_BRANCH) -# for the primary repository and REPO_URLS for additional repositories. -# /workspace itself remains the multi-repo root where agents start. -if [ -n "${REPO_URL:-}" ]; then - export GIT_SSH_COMMAND="ssh -i /tmp/agent-deploy-key -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${HOME}/.ssh/known_hosts" - clone_repo_into_workspace "$REPO_URL" "${REPO_BRANCH:-}" -fi - -# Keep boot-time clone on the deploy-key path above, then make every -# interactive git operation prefer HTTPS so the credential helper can -# provide the short-lived GitHub App token. This fixes pushes from -# repo-backed workspaces whose SSH deploy key is absent or read-only, -# and it also covers `gh pr create` when gh shells out to git. -# Both SSH URL spellings must rewrite to HTTPS. A bare `git config` per -# value overwrites rather than appends, so setting the key twice dropped -# the first spelling: only `ssh://git@github.com/` survived and the -# SCP-style `git@github.com:` form (what the gateway clones) fell through -# to real SSH + the deploy key. --add keeps both; --unset-all first keeps -# this idempotent across reboots that reuse the same HOME. -git config --global --unset-all url.https://github.com/.insteadOf 2>/dev/null || true -git config --global --add url.https://github.com/.insteadOf git@github.com: -git config --global --add url.https://github.com/.insteadOf ssh://git@github.com/ - -# Bound the App-token credential helper to exactly this workspace's repos -# (primary + REPO_URLS), exported so the helper and the gateway's child git -# processes inherit it. Empty (no repo configured) leaves the helper's own -# default. Set before the multi-repo clone so those clones authenticate. -REPO_ALLOW="$(derive_repo_allow)" -if [ -n "$REPO_ALLOW" ]; then - export REPO_ALLOW -fi - -# Multi-repo workspaces: clone every repo in REPO_URLS (space/newline/semicolon -# separated, each "url[#branch]") into its own subdir of the workspace over -# HTTPS, authenticated by the App-token credential helper — no per-repo deploy -# key needed. Idempotent: a repo already cloned is left alone. -if [ -n "${REPO_URLS:-}" ]; then - for entry in $(printf '%s' "$REPO_URLS" | tr ';\n' ' '); do - repo_clone_url="${entry%%#*}" - repo_branch="" - case "$entry" in *"#"*) repo_branch="${entry#*#}" ;; esac - clone_repo_into_workspace "$repo_clone_url" "$repo_branch" - done -fi - -speckit_seed_workspace - -# The gateway shares this Pod's memory cgroup with the agent CLIs it -# launches (Claude Code, Codex) and whatever the workspace itself runs. -# MaxRAMPercentage made the JVM lazily fill 75% of the Pod limit with -# garbage before collecting, starving those co-tenants and getting the -# whole Pod OOMKilled. The gateway is a thin streaming relay with a tiny -# live set, so an absolute 1g heap is ample and leaves the rest of the -# Pod's RAM for the CLIs regardless of the limit. -exec java \ - -XX:+UseZGC \ - -Xmx1g \ - -jar /opt/agent-gateway.jar diff --git a/services/agent-runner/gh-app-token-wrapper.sh b/services/agent-runner/gh-app-token-wrapper.sh deleted file mode 100755 index 7556eb90..00000000 --- a/services/agent-runner/gh-app-token-wrapper.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -# `gh` wrapper for agent runners. Before delegating to the real gh, it -# exports a fresh repo-scoped token as GH_TOKEN. The shared token helper -# reuses a cached GitHub App installation token while it still has spare -# life, so back-to-back gh calls mint at most once per token lifetime. -# -# Pure passthrough — `gh` still runs — when the App is not wired (no -# endpoint URL / bearer / repo in the environment) or when minting -# fails, so a misconfiguration degrades to unauthenticated gh rather -# than a hard error. -set -u - -REAL_GH=/usr/bin/gh -TOKEN_HELPER="${AGENT_GITHUB_TOKEN_HELPER:-/usr/local/bin/agent-github-token}" - -if [ -z "${GH_TOKEN:-}" ]; then - token=$("$TOKEN_HELPER" 2>/dev/null || true) - if [ -n "$token" ]; then - export GH_TOKEN="$token" - fi -fi - -exec "$REAL_GH" "$@" diff --git a/services/agent-runner/gh-mcp-wrapper.sh b/services/agent-runner/gh-mcp-wrapper.sh deleted file mode 100755 index 02d6aa8e..00000000 --- a/services/agent-runner/gh-mcp-wrapper.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# Launches the GitHub MCP server (stdio) with a GitHub token. -# -# github-mcp-server requires GITHUB_PERSONAL_ACCESS_TOKEN at process -# start. The shared token helper returns a pre-seeded token or mints a -# fresh GitHub App installation token through assistant-api using the -# same cache as the gh CLI wrapper. -set -u - -TOKEN_HELPER="${AGENT_GITHUB_TOKEN_HELPER:-/usr/local/bin/agent-github-token}" - -can_retry_mint() { - [ -n "${GITHUB_APP_TOKEN_URL:-}" ] && - [ -n "${GITHUB_APP_TOKEN_BEARER:-}" ] -} - -TOKEN="" -attempt=1 -max_attempts="${GITHUB_MCP_TOKEN_RETRIES:-4}" -retry_sleep="${GITHUB_MCP_TOKEN_RETRY_SLEEP_SECONDS:-3}" -case "$max_attempts" in - ''|*[!0-9]*) max_attempts=4 ;; -esac -case "$retry_sleep" in - ''|*[!0-9]*) retry_sleep=3 ;; -esac -while :; do - TOKEN=$("$TOKEN_HELPER" 2>/dev/null || true) - [ -n "$TOKEN" ] && break - - if ! can_retry_mint || [ "$attempt" -ge "$max_attempts" ] 2>/dev/null; then - break - fi - - echo "[gh-mcp-wrapper] WARN: GitHub token unavailable; retrying before MCP startup ($attempt/$max_attempts)" >&2 - sleep "$retry_sleep" - attempt=$((attempt + 1)) -done - -if [ -z "$TOKEN" ]; then - cat >&2 <<'EOF' -[gh-mcp-wrapper] ERROR: no GitHub token available for github-mcp-server. -[gh-mcp-wrapper] Set GITHUB_PERSONAL_ACCESS_TOKEN/GH_TOKEN, or provide -[gh-mcp-wrapper] GITHUB_APP_TOKEN_URL and GITHUB_APP_TOKEN_BEARER. The -[gh-mcp-wrapper] runner also needs REPO_URL or a current git remote inside -[gh-mcp-wrapper] /workspace/ so it can mint a repo-scoped token. -EOF - exit 78 -fi - -export GITHUB_PERSONAL_ACCESS_TOKEN="$TOKEN" - -# The runner uses GitHub App installation tokens. They are repo-scoped and -# cannot call user-token endpoints like GET /user, so keep the advertised -# MCP surface focused on repo, PR, issue, action, and git workflows. -TOOLSETS="${GITHUB_MCP_TOOLSETS:-repos,pull_requests,issues,actions,git}" -EXCLUDE_TOOLS="${GITHUB_MCP_EXCLUDE_TOOLS:-create_repository,fork_repository}" - -args=(stdio) -[ -n "$TOOLSETS" ] && args+=(--toolsets "$TOOLSETS") -[ -n "$EXCLUDE_TOOLS" ] && args+=(--exclude-tools "$EXCLUDE_TOOLS") - -exec github-mcp-server "${args[@]}" diff --git a/services/agent-runner/git-credential-agent-gh-app.sh b/services/agent-runner/git-credential-agent-gh-app.sh deleted file mode 100755 index 84ae3bec..00000000 --- a/services/agent-runner/git-credential-agent-gh-app.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# Git credential helper for runner workspaces. Git calls this as -# `git credential-agent-gh-app get` and receives a repo-scoped GitHub -# App token as an HTTPS password, allowing `git push` without SSH keys. -set -u - -TOKEN_HELPER="${AGENT_GITHUB_TOKEN_HELPER:-/usr/local/bin/agent-github-token}" - -normalize_repo_path() { - local url="$1" - case "$url" in - git@github.com:*) url="${url#git@github.com:}" ;; - ssh://git@github.com/*) url="${url#ssh://git@github.com/}" ;; - https://github.com/*) url="${url#https://github.com/}" ;; - http://github.com/*) url="${url#http://github.com/}" ;; - *) return 1 ;; - esac - url="${url%.git}" - printf '%s' "$url" -} - -action="${1:-}" -[ "$action" = "get" ] || exit 0 - -protocol="" -host="" -path="" -while IFS='=' read -r key value; do - [ -n "$key" ] || break - case "$key" in - protocol) protocol="$value" ;; - host) host="$value" ;; - path) path="$value" ;; - esac -done - -[ "$protocol" = "https" ] || exit 0 -[ "$host" = "github.com" ] || exit 0 - -requested_slug="${path%.git}" - -if [ -n "${REPO_ALLOW:-}" ]; then - # Multi-repo: mint a token for any requested repo in the allow-list. The - # allow-list (set by the entrypoint from the workspace's repos) keeps the - # blast radius bounded — a request for a repo outside it returns nothing. - [ -n "$requested_slug" ] || exit 0 - allowed=0 - for slug in $REPO_ALLOW; do - [ "$requested_slug" = "$slug" ] && allowed=1 && break - done - [ "$allowed" = 1 ] || exit 0 - token=$(AGENT_GITHUB_REPO_URL="https://${host}/${requested_slug}" "$TOKEN_HELPER" 2>/dev/null) || exit 0 -else - # Legacy single-repo gate: only serve the configured primary REPO_URL. - primary_slug="" - [ -n "${REPO_URL:-}" ] && primary_slug=$(normalize_repo_path "$REPO_URL" || true) - if [ -n "$requested_slug" ] && [ -n "$primary_slug" ]; then - [ "$requested_slug" = "$primary_slug" ] || exit 0 - fi - token=$("$TOKEN_HELPER" 2>/dev/null) || exit 0 -fi - -[ -n "$token" ] || exit 0 - -printf 'username=x-access-token\n' -printf 'password=%s\n\n' "$token" diff --git a/services/app-ui/public/icons/assistant.svg b/services/app-ui/public/icons/agents.svg similarity index 100% rename from services/app-ui/public/icons/assistant.svg rename to services/app-ui/public/icons/agents.svg diff --git a/services/app-ui/src/__tests__/AppsGrid.test.ts b/services/app-ui/src/__tests__/AppsGrid.test.ts index f10b29e0..24357884 100644 --- a/services/app-ui/src/__tests__/AppsGrid.test.ts +++ b/services/app-ui/src/__tests__/AppsGrid.test.ts @@ -44,7 +44,7 @@ describe('appsGrid', () => { 'SERVICE_MAIL', 'SERVICE_N8N', 'SERVICE_GRAFANA', - 'SERVICE_ASSISTANT', + 'SERVICE_AGENTS', 'SERVICE_DASHBOARD', 'SERVICE_TRAEFIK', 'SERVICE_RABBITMQ', diff --git a/services/app-ui/src/__tests__/serviceRegistry.test.ts b/services/app-ui/src/__tests__/serviceRegistry.test.ts index cd53082d..fbac4ba6 100644 --- a/services/app-ui/src/__tests__/serviceRegistry.test.ts +++ b/services/app-ui/src/__tests__/serviceRegistry.test.ts @@ -6,7 +6,7 @@ describe('service registry', () => { const permissions = SERVICE_REGISTRY.map((s) => s.permission) expect(permissions).toContain('VAULT') expect(permissions).toContain('GRAFANA') - expect(permissions).toContain('ASSISTANT') + expect(permissions).toContain('AGENTS') expect(permissions).toContain('DASHBOARD') expect(permissions).toContain('TRAEFIK') expect(permissions).toContain('RABBITMQ') diff --git a/services/app-ui/src/features/apps/data/serviceRegistry.ts b/services/app-ui/src/features/apps/data/serviceRegistry.ts index e5380b26..7926744d 100644 --- a/services/app-ui/src/features/apps/data/serviceRegistry.ts +++ b/services/app-ui/src/features/apps/data/serviceRegistry.ts @@ -58,11 +58,11 @@ export const SERVICE_REGISTRY: ServiceEntry[] = [ description: 'Observability dashboard', }, { - permission: 'ASSISTANT', - label: 'Assistant', - url: buildServiceUrl('assistant'), - iconUrl: '/icons/assistant.svg', - description: 'AI assistant', + permission: 'AGENTS', + label: 'Agents', + url: buildServiceUrl('agents'), + iconUrl: '/icons/agents.svg', + description: 'AI agents', }, { permission: 'DASHBOARD', diff --git a/services/assistant-api/CONTRACT.md b/services/assistant-api/CONTRACT.md deleted file mode 100644 index 915af020..00000000 --- a/services/assistant-api/CONTRACT.md +++ /dev/null @@ -1,65 +0,0 @@ -# assistant-api ↔ assistant-ui OpenAPI contract - -This service emits its OpenAPI 3.1 spec via springdoc at -`/api/v1/api-docs`. The spec is committed to -`services/assistant-api/openapi.json` and consumed by -`services/assistant-ui` through `openapi-typescript`-generated -`src/api/generated.ts`. CI fails any PR that lets the committed spec, -the live springdoc output, and the generated TypeScript drift apart. - -## Pieces - -- `services/assistant-api/openapi.json` — pinned spec (committed). -- `services/assistant-ui/src/api/generated.ts` — pinned TS types - (committed; produced by `openapi-typescript` v7.4.4 plus the - banner script). -- `OpenApiSpecExportTest` (integration tier, tag - `contract-export`) — boots the full Spring context and dumps the - spec to the committed path. -- Gradle task `:services:assistant-api:exportOpenApiSpec` — - runs only the tagged test; the default `integrationTest` task - excludes the tag. -- npm scripts in `services/assistant-ui/package.json`: - - `contract:generate` — regenerate types from the committed spec. - - `contract:check` — regenerate to `/tmp` and `diff -u` against - the committed copy; non-zero exit on drift. -- `.github/workflows/contract-validate.yml` — runs both gates on - every PR that touches assistant-api, assistant-ui, vue-common, - published commons modules, or shared build config. - -## Regenerate after an API change - -```bash -./gradlew :services:assistant-api:exportOpenApiSpec -pnpm --filter @personal-stack/assistant-ui contract:generate -git add services/assistant-api/openapi.json \ - services/assistant-ui/src/api/generated.ts -``` - -Commit the two files alongside the controller / DTO change in the -same PR. - -## What CI failures look like - -- **`openapi.json` drift.** The Gradle export task in - `contract-validate.yml` overwrites the committed file with the live - springdoc output, then `git diff --exit-code` flags the change. - Resolve by running `./gradlew :services:assistant-api:exportOpenApiSpec` - locally and committing the result. -- **`generated.ts` drift.** `pnpm contract:check` regenerates the TS - output to `/tmp` and `diff -u`s it against the committed copy. The - CI log shows the patch. Resolve by running - `pnpm --filter @personal-stack/assistant-ui contract:generate` and - committing the new `src/api/generated.ts`. - -## Migration status (PR H) - -The repositories feature is the canary consumer: -`services/assistant-ui/src/features/repositories/types/index.ts` now -derives `Repository`, `CreateRepositoryInput`, and -`AttachDeployKeyInput` from `components['schemas']` in -`@/api/generated`. `RepositoryDetail` + `AttachedProject` stay -hand-rolled until `GET /api/v1/repositories/{id}` returns a typed -response body (the controller currently emits `Map`). -Further feature directories migrate in follow-up PRs once their DTOs -ship with concrete response types. diff --git a/services/assistant-api/Dockerfile b/services/assistant-api/Dockerfile deleted file mode 100644 index b74341fa..00000000 --- a/services/assistant-api/Dockerfile +++ /dev/null @@ -1,76 +0,0 @@ -# syntax=docker/dockerfile:1 - -FROM gradle:9.5.1-jdk21-alpine AS build -WORKDIR /app - -# Layer 1: Copy only build scripts for dependency caching -COPY settings.gradle.kts build.gradle.kts gradle.properties* ./ -COPY gradle/ gradle/ -COPY services/auth-api/build.gradle.kts services/auth-api/ -COPY services/assistant-api/build.gradle.kts services/assistant-api/ -COPY services/knowledge-api/build.gradle.kts services/knowledge-api/ -COPY services/agent-gateway/build.gradle.kts services/agent-gateway/ -COPY services/system-tests/build.gradle.kts services/system-tests/ - -# Resolve dependencies (cached unless build files change) -RUN --mount=type=secret,id=github_token \ - --mount=type=secret,id=github_actor \ - set -eu; \ - GITHUB_TOKEN="$(cat /run/secrets/github_token)"; \ - GITHUB_ACTOR="$(cat /run/secrets/github_actor)"; \ - export GITHUB_TOKEN GITHUB_ACTOR; \ - gradle :services:assistant-api:dependencies --no-daemon || true - -# Layer 2: Copy source code and build -COPY services/assistant-api/src/main/ services/assistant-api/src/main/ -RUN --mount=type=secret,id=github_token \ - --mount=type=secret,id=github_actor \ - set -eu; \ - GITHUB_TOKEN="$(cat /run/secrets/github_token)"; \ - GITHUB_ACTOR="$(cat /run/secrets/github_actor)"; \ - export GITHUB_TOKEN GITHUB_ACTOR; \ - gradle :services:assistant-api:bootJar --no-daemon - -# Eclipse Temurin only used here for the otel jar download — its alpine -# variant has curl out of the box and is small. The runtime stage has -# moved to BellSoft Liberica below. -FROM eclipse-temurin:25-jre-alpine AS otel -RUN apk add --no-cache curl && \ - curl -fsSL -o /otel-javaagent.jar \ - "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.26.1/opentelemetry-javaagent.jar" - -# Runtime base: BellSoft Liberica JDK 21 with CRaC patches on Debian slim -# (glibc). See auth-api/Dockerfile for the full rationale — this PR is -# the precondition for the build-time checkpoint flow in follow-up PRs. -# AppCDS is intentionally dropped (Liberica CRaC's CDS handling differs -# and CRaC will replace it anyway). Cold start temporarily regresses -# to the pre-AppCDS baseline; #254 extends the 300 s startupProbe to -# 600 s to cover it and must merge first. -# Training-capable image. See auth-api/Dockerfile for the full -# rationale — runs CracTrainingRunner against local sidecar -# Postgres/Valkey/RabbitMQ and dumps a JVM checkpoint to -# /opt/crac/checkpoint. Used only by the crac-train CI workflow. -FROM bellsoft/liberica-runtime-container:jdk-21-crac-slim-glibc AS train -WORKDIR /app -COPY --from=build /app/services/assistant-api/build/libs/*.jar app.jar -COPY --from=otel /otel-javaagent.jar otel-javaagent.jar -RUN mkdir -p /opt/crac/checkpoint -EXPOSE 8082 -ENTRYPOINT ["java", \ - "-XX:CRaCCheckpointTo=/opt/crac/checkpoint", \ - "-XX:+UseZGC", \ - "-XX:MaxRAMPercentage=75", \ - "-Dspring.profiles.active=crac-train", \ - "-javaagent:otel-javaagent.jar", \ - "-jar", "app.jar"] - -FROM bellsoft/liberica-runtime-container:jdk-21-crac-slim-glibc -WORKDIR /app -# Build-time identity (see services/auth-api/Dockerfile for rationale). -ARG GIT_SHA=unknown -ENV SERVICE_VERSION=${GIT_SHA} -COPY --from=build /app/services/assistant-api/build/libs/*.jar app.jar -COPY --from=otel /otel-javaagent.jar otel-javaagent.jar -COPY services/assistant-api/entrypoint.sh /app/entrypoint.sh -EXPOSE 8082 -ENTRYPOINT ["/app/entrypoint.sh", "-javaagent:otel-javaagent.jar", "-jar", "app.jar"] diff --git a/services/assistant-api/build.gradle.kts b/services/assistant-api/build.gradle.kts deleted file mode 100644 index b304ba46..00000000 --- a/services/assistant-api/build.gradle.kts +++ /dev/null @@ -1,117 +0,0 @@ -plugins { - alias(libs.plugins.extratoast.spring) - alias(libs.plugins.extratoast.detekt) - alias(libs.plugins.extratoast.ktlint) - alias(libs.plugins.extratoast.testing) - alias(libs.plugins.extratoast.jooq.codegen) -} - -jooqCodegen { - schemaName.set("PUBLIC") - packageName.set("com.jorisjonkers.personalstack.assistant.jooq") - migrationLocations.set(listOf("filesystem:src/main/resources/db/migration")) -} - -dependencies { - implementation(libs.kotlin.commons.command) - implementation(libs.kotlin.commons.crac) - implementation(libs.kotlin.commons.events) - implementation(libs.kotlin.commons.exceptions) - implementation(libs.kotlin.commons.observability) - implementation(libs.kotlin.commons.timing) - implementation(libs.kotlin.commons.vault) - implementation(libs.kotlin.commons.web) - testImplementation(libs.kotlin.commons.archunit.test) - testImplementation(libs.kotlin.commons.test.support) - implementation("org.springframework.boot:spring-boot-starter-websocket") - // See auth-api build.gradle.kts — needed for the - // ApplicationTracingAspect in kotlin-commons-observability to take effect. - // Spring Boot 4 doesn't ship a starter-aop shortcut. - implementation("org.springframework:spring-aop") - implementation("org.aspectj:aspectjweaver:1.9.25.1") - implementation("org.springframework.boot:spring-boot-starter-data-redis") - implementation("org.springframework.boot:spring-boot-starter-amqp") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql") - implementation("org.springframework.boot:spring-boot-starter-jooq") - implementation("org.jooq:jooq") - runtimeOnly("org.postgresql:postgresql") - // Tracing runtime jars. kotlin-commons-timing's TimingAutoConfiguration becomes - // active when these are on the classpath: spans flow to Alloy → Tempo - // and MDC traceId/spanId start populating in the JSON log lines. - runtimeOnly("io.micrometer:micrometer-tracing-bridge-otel") - runtimeOnly("io.opentelemetry:opentelemetry-exporter-otlp") - testImplementation("org.testcontainers:testcontainers-junit-jupiter") - testImplementation("org.testcontainers:testcontainers-postgresql") - testImplementation("org.testcontainers:testcontainers-rabbitmq") - // k3s lets Fabric8AgentRunnerOrchestratorIntegrationTest exercise - // the orchestrator through a real Kubernetes API — the seam where - // PR #372 (PVC patch-verb missing on the production Role) failed - // in production despite green unit tests. The negative case in - // that test locks the RBAC contract in. - testImplementation("org.testcontainers:testcontainers-k3s") - // fabric8 KubernetesClient — drives the per-workspace runner Pod - // lifecycle. Server-side apply, no informer cache (one-shot CRUD - // is enough), pulled in with the kubernetes-client-bom to keep - // model + httpclient versions aligned. - implementation(platform("io.fabric8:kubernetes-client-bom:7.7.0")) - implementation("io.fabric8:kubernetes-client") - testImplementation("io.fabric8:kubernetes-server-mock") -} - -// agent-runner orchestration shells out to Kubernetes and HTTP from -// classes that only carry signal under an integration cluster; the -// 80 % jacoco bar stays honest for the classes that are actually -// unit-testable by registering the IO-bound shells with the shared -// `jacocoExclusionPatterns` extension from the testing convention. -// -// `VaultDeployKeyStore`, `KnowledgeMcpClient`, `LightRagClient` are -// `@ConditionalOnProperty` adapters that only wire when their -// upstream is available; unit tests can't reach them and the -// integration tier doesn't yet stand up a Vault / knowledge-api -// fixture for the assistant-api integration test suite (the -// Fabric8 orchestrator integration test covers the k8s side). They -// follow the same IO-bound exclusion treatment as -// `HttpAgentGatewayClient`. The trailing `*.class` (rather than just -// `.class`) sweeps Kotlin-generated `Outer$Inner.class` companions — -// the HTTP shells declare request/response DTOs inline and the -// companions would otherwise drag the package into the report on -// their own. -@Suppress("UNCHECKED_CAST") -(extensions.getByName("jacocoExclusionPatterns") as ListProperty).addAll( - "**/infrastructure/k8s/**", - "**/infrastructure/integration/HttpAgentGatewayClient*.class", - "**/infrastructure/integration/VaultDeployKeyStore*.class", - "**/infrastructure/integration/KnowledgeMcpClient*.class", - "**/infrastructure/integration/LightRagClient*.class", - "**/infrastructure/ws/**", -) - -// The OpenAPI contract is pinned to `services/assistant-api/openapi.json` -// (committed). The `contract-export` JUnit tag identifies the single -// springdoc MVC slice test that hits `/api/v1/api-docs` without booting the -// app server and writes the spec to disk. The default `integrationTest` task -// skips that tag; `exportOpenApiSpec` runs only that tag for the contract -// drift gate and the `services/assistant-ui` contract-typegen pipeline. -tasks.named("integrationTest") { - useJUnitPlatform { - includeTags("integration") - excludeTags("contract-export") - } -} - -tasks.register("exportOpenApiSpec") { - description = "Exports the OpenAPI spec to services/assistant-api/openapi.json from a springdoc MVC slice" - group = "documentation" - testClassesDirs = sourceSets["integrationTest"].output.classesDirs - classpath = sourceSets["integrationTest"].runtimeClasspath - useJUnitPlatform { - includeTags("contract-export") - } - systemProperty("openapi.spec.output", file("openapi.json").absolutePath) - // Always re-run: the spec is derived from the live springdoc output, - // so caching past runs would defeat the drift gate. The CI workflow - // diffs the freshly-written file against the committed copy. - outputs.upToDateWhen { false } -} diff --git a/services/assistant-api/entrypoint.sh b/services/assistant-api/entrypoint.sh deleted file mode 100755 index db29e28b..00000000 --- a/services/assistant-api/entrypoint.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh -# Compose OTEL_RESOURCE_ATTRIBUTES from per-attribute env vars at -# container start. Kubernetes env-var interpolation (`value: "$(FOO)"`) -# only references other env vars declared *in the manifest*, not env -# vars baked into the image — so a Dockerfile `ENV SERVICE_VERSION=…` -# cannot reach `OTEL_RESOURCE_ATTRIBUTES` set in deploy.yml directly. -# This shim is the cheap way to keep the image-baked build SHA and let -# the manifest still set the environment label per-cluster. -# -# `SERVICE_VERSION` defaults to whatever the image bakes (the GIT_SHA -# from the build), but a K8s manifest can still override it for -# e.g. local-dev pods that aren't built through CI. -set -eu - -attrs="service.version=${SERVICE_VERSION:-unknown}" -if [ -n "${DEPLOYMENT_ENVIRONMENT:-}" ]; then - attrs="${attrs},deployment.environment=${DEPLOYMENT_ENVIRONMENT}" -fi - -# If the manifest set OTEL_RESOURCE_ATTRIBUTES explicitly, append our -# attributes to it rather than overwriting — lets operators add ad-hoc -# attributes via the manifest without losing the image-baked version. -if [ -n "${OTEL_RESOURCE_ATTRIBUTES:-}" ]; then - export OTEL_RESOURCE_ATTRIBUTES="${OTEL_RESOURCE_ATTRIBUTES},${attrs}" -else - export OTEL_RESOURCE_ATTRIBUTES="${attrs}" -fi - -exec java "$@" diff --git a/services/assistant-api/openapi.json b/services/assistant-api/openapi.json deleted file mode 100644 index 781e2b2c..00000000 --- a/services/assistant-api/openapi.json +++ /dev/null @@ -1,1947 +0,0 @@ -{ - "openapi" : "3.1.0", - "info" : { - "title" : "Assistant API", - "description" : "Conversation and messaging service for jorisjonkers.dev", - "version" : "1.0.0" - }, - "servers" : [ { - "url" : "https://assistant.jorisjonkers.dev", - "description" : "Production" - }, { - "url" : "http://localhost:8082", - "description" : "Local development" - } ], - "security" : [ { - "xUserId" : [ ] - } ], - "paths" : { - "/api/v1/workspaces" : { - "get" : { - "tags" : [ "workspace-controller" ], - "operationId" : "list", - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/WorkspaceResponse" - } - } - } - } - } - } - }, - "post" : { - "tags" : [ "workspace-controller" ], - "operationId" : "create", - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/CreateWorkspaceRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/WorkspaceResponse" - } - } - } - } - } - } - }, - "/api/v1/workspaces/{workspaceId}/sessions" : { - "post" : { - "tags" : [ "agent-session-controller" ], - "operationId" : "start", - "parameters" : [ { - "name" : "workspaceId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/StartAgentSessionRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "object", - "additionalProperties" : { - "type" : "string", - "format" : "uuid" - } - } - } - } - } - } - } - }, - "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/staged-inputs" : { - "post" : { - "tags" : [ "agent-session-controller" ], - "operationId" : "stageInput", - "parameters" : [ { - "name" : "workspaceId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "sessionId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/StageInputRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/StagedInputResponse" - } - } - } - } - } - } - }, - "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/input" : { - "post" : { - "tags" : [ "agent-session-controller" ], - "operationId" : "send", - "parameters" : [ { - "name" : "workspaceId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "sessionId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/SendUserInputRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/workspaces/{workspaceId}/git/open-pr" : { - "post" : { - "tags" : [ "git-controller" ], - "operationId" : "openPr", - "parameters" : [ { - "name" : "workspaceId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/OpenPrRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/workspaces/{id}/repositories" : { - "post" : { - "tags" : [ "workspace-controller" ], - "operationId" : "attachRepository", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/AttachWorkspaceRepositoryRequest" - } - } - }, - "required" : true - }, - "responses" : { - "204" : { - "description" : "No Content" - } - } - } - }, - "/api/v1/repositories" : { - "get" : { - "tags" : [ "repository-controller" ], - "operationId" : "list_1", - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/RepositoryResponse" - } - } - } - } - } - } - }, - "post" : { - "tags" : [ "repository-controller" ], - "operationId" : "create_1", - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/CreateRepositoryRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/RepositoryResponse" - } - } - } - } - } - } - }, - "/api/v1/repositories/{id}/verify" : { - "post" : { - "tags" : [ "repository-controller" ], - "operationId" : "verify", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/RepositoryResponse" - } - } - } - } - } - } - }, - "/api/v1/repositories/{id}/key" : { - "post" : { - "tags" : [ "repository-controller" ], - "operationId" : "attachKey", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/AttachRepositoryDeployKeyRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/projects" : { - "get" : { - "tags" : [ "project-controller" ], - "operationId" : "list_2", - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/ProjectResponse" - } - } - } - } - } - } - }, - "post" : { - "tags" : [ "project-controller" ], - "operationId" : "create_2", - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/CreateProjectRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/ProjectResponse" - } - } - } - } - } - } - }, - "/api/v1/projects/{projectId}/links/{linkId}/key" : { - "post" : { - "tags" : [ "project-controller" ], - "operationId" : "attachKey_1", - "parameters" : [ { - "name" : "projectId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "linkId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/AttachDeployKeyRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/projects/{id}/repositories" : { - "post" : { - "tags" : [ "project-controller" ], - "operationId" : "linkRepository", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/LinkRepositoryRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/RepositoryResponse" - } - } - } - } - } - } - } - }, - "/api/v1/projects/{id}/links" : { - "post" : { - "tags" : [ "project-controller" ], - "operationId" : "addLink", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/AddGithubLinkRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/GithubLinkResponse" - } - } - } - } - }, - "deprecated" : true - } - }, - "/api/v1/conversations" : { - "get" : { - "tags" : [ "conversation-controller" ], - "operationId" : "listByUser", - "parameters" : [ { - "name" : "X-User-Id", - "in" : "header", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/ConversationResponse" - } - } - } - } - } - } - }, - "post" : { - "tags" : [ "conversation-controller" ], - "operationId" : "create_3", - "parameters" : [ { - "name" : "X-User-Id", - "in" : "header", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/CreateConversationRequest" - } - } - }, - "required" : true - }, - "responses" : { - "201" : { - "description" : "Created", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/ConversationResponse" - } - } - } - } - } - } - }, - "/api/v1/conversations/{conversationId}/messages" : { - "get" : { - "tags" : [ "message-controller" ], - "operationId" : "list_3", - "parameters" : [ { - "name" : "conversationId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/MessageResponse" - } - } - } - } - } - } - }, - "post" : { - "tags" : [ "message-controller" ], - "operationId" : "send_1", - "parameters" : [ { - "name" : "conversationId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "X-User-Id", - "in" : "header", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/SendMessageRequest" - } - } - }, - "required" : true - }, - "responses" : { - "201" : { - "description" : "Created", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/MessageResponse" - } - } - } - } - } - } - }, - "/api/v1/chat-sessions" : { - "get" : { - "tags" : [ "chat-session-controller" ], - "operationId" : "list_4", - "parameters" : [ { - "name" : "X-User-Id", - "in" : "header", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/ChatSessionResponse" - } - } - } - } - } - } - }, - "post" : { - "tags" : [ "chat-session-controller" ], - "operationId" : "create_4", - "parameters" : [ { - "name" : "X-User-Id", - "in" : "header", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/StartChatSessionRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/ChatSessionResponse" - } - } - } - } - } - } - }, - "/api/v1/chat-sessions/{id}/messages" : { - "post" : { - "tags" : [ "chat-session-controller" ], - "operationId" : "appendMessage", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/AppendChatMessageRequest" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/ChatMessageResponse" - } - } - } - } - } - } - }, - "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/turns" : { - "get" : { - "tags" : [ "agent-session-controller" ], - "operationId" : "turns", - "parameters" : [ { - "name" : "workspaceId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "sessionId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/TurnResponse" - } - } - } - } - } - } - } - }, - "/api/v1/workspaces/{id}" : { - "get" : { - "tags" : [ "workspace-controller" ], - "operationId" : "get", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/WorkspaceDetailResponse" - } - } - } - } - } - }, - "delete" : { - "tags" : [ "workspace-controller" ], - "operationId" : "destroy", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/repositories/{id}" : { - "get" : { - "tags" : [ "repository-controller" ], - "operationId" : "get_1", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "object", - "additionalProperties" : { } - } - } - } - } - } - }, - "delete" : { - "tags" : [ "repository-controller" ], - "operationId" : "delete", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/projects/{projectId}/links/{linkId}/setup-guide" : { - "get" : { - "tags" : [ "setup-guide-controller" ], - "operationId" : "guide", - "parameters" : [ { - "name" : "projectId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "linkId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "text/markdown" : { - "schema" : { - "type" : "string" - } - }, - "text/plain" : { - "schema" : { - "type" : "string" - } - } - } - } - } - } - }, - "/api/v1/projects/{id}" : { - "get" : { - "tags" : [ "project-controller" ], - "operationId" : "get_2", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "object", - "additionalProperties" : { } - } - } - } - } - } - } - }, - "/api/v1/health" : { - "get" : { - "tags" : [ "health-controller" ], - "operationId" : "health", - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "object", - "additionalProperties" : { - "type" : "string" - } - } - } - } - } - } - } - }, - "/api/v1/conversations/{id}" : { - "get" : { - "tags" : [ "conversation-controller" ], - "operationId" : "getById", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "$ref" : "#/components/schemas/ConversationResponse" - } - } - } - } - } - }, - "delete" : { - "tags" : [ "conversation-controller" ], - "operationId" : "archive", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "X-User-Id", - "in" : "header", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "204" : { - "description" : "No Content" - } - } - } - }, - "/api/v1/chat-sessions/{id}" : { - "get" : { - "tags" : [ "chat-session-controller" ], - "operationId" : "get_3", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK", - "content" : { - "*/*" : { - "schema" : { - "type" : "object", - "additionalProperties" : { } - } - } - } - } - } - }, - "delete" : { - "tags" : [ "chat-session-controller" ], - "operationId" : "archive_1", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "X-User-Id", - "in" : "header", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}" : { - "delete" : { - "tags" : [ "agent-session-controller" ], - "operationId" : "stop", - "parameters" : [ { - "name" : "workspaceId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "sessionId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/workspaces/{id}/repositories/{repoId}" : { - "delete" : { - "tags" : [ "workspace-controller" ], - "operationId" : "detachRepository", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "repoId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/projects/{projectId}/links/{linkId}" : { - "delete" : { - "tags" : [ "project-controller" ], - "operationId" : "removeLink", - "parameters" : [ { - "name" : "projectId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "linkId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK" - } - } - } - }, - "/api/v1/projects/{id}/repositories/{repoId}" : { - "delete" : { - "tags" : [ "project-controller" ], - "operationId" : "unlinkRepository", - "parameters" : [ { - "name" : "id", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - }, { - "name" : "repoId", - "in" : "path", - "required" : true, - "schema" : { - "type" : "string", - "format" : "uuid" - } - } ], - "responses" : { - "200" : { - "description" : "OK" - } - } - } - } - }, - "components" : { - "schemas" : { - "CreateWorkspaceRequest" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "maxLength" : 80, - "minLength" : 1 - }, - "repoUrl" : { - "type" : [ "string", "null" ] - }, - "branch" : { - "type" : [ "string", "null" ] - }, - "kind" : { - "type" : "string", - "enum" : [ "REPO_BACKED", "SCRATCH", "CHAT" ] - }, - "projectId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - }, - "repositoryId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - }, - "primaryRepositoryId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - }, - "repositoryIds" : { - "type" : [ "array", "null" ], - "items" : { - "type" : "string", - "format" : "uuid" - } - }, - "githubLinkId" : { - "type" : [ "string", "null" ], - "format" : "uuid", - "deprecated" : true, - "description" : "Use repositoryId" - } - }, - "required" : [ "kind", "name" ] - }, - "WorkspaceResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "name" : { - "type" : "string" - }, - "repoUrl" : { - "type" : [ "string", "null" ] - }, - "branch" : { - "type" : [ "string", "null" ] - }, - "podName" : { - "type" : [ "string", "null" ] - }, - "gatewayEndpoint" : { - "type" : [ "string", "null" ] - }, - "status" : { - "type" : "string" - }, - "kind" : { - "type" : "string" - }, - "projectId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - }, - "repositoryId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - }, - "githubLinkId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - }, - "updatedAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "createdAt", "id", "kind", "name", "status", "updatedAt" ] - }, - "StartAgentSessionRequest" : { - "type" : "object", - "properties" : { - "kind" : { - "type" : "string", - "enum" : [ "CLAUDE", "CODEX", "SHELL" ] - } - }, - "required" : [ "kind" ] - }, - "StageInputRequest" : { - "type" : "object", - "properties" : { - "content" : { - "type" : "string" - }, - "name" : { - "type" : [ "string", "null" ] - } - }, - "required" : [ "content" ] - }, - "StagedInputResponse" : { - "type" : "object", - "properties" : { - "path" : { - "type" : "string" - }, - "bytes" : { - "type" : "integer", - "format" : "int64" - }, - "name" : { - "type" : "string" - } - }, - "required" : [ "bytes", "name", "path" ] - }, - "SendUserInputRequest" : { - "type" : "object", - "properties" : { - "text" : { - "type" : "string" - }, - "enter" : { - "type" : "boolean" - } - }, - "required" : [ "enter", "text" ] - }, - "OpenPrRequest" : { - "type" : "object", - "properties" : { - "repoDir" : { - "type" : "string" - }, - "title" : { - "type" : "string" - }, - "body" : { - "type" : "string" - }, - "base" : { - "type" : "string" - } - }, - "required" : [ "base", "body", "repoDir", "title" ] - }, - "AttachWorkspaceRepositoryRequest" : { - "type" : "object", - "properties" : { - "repositoryId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - } - }, - "required" : [ "repositoryId" ] - }, - "CreateRepositoryRequest" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "maxLength" : 80, - "minLength" : 1 - }, - "repoUrl" : { - "type" : "string", - "minLength" : 1 - }, - "defaultBranch" : { - "type" : "string" - } - }, - "required" : [ "defaultBranch", "name", "repoUrl" ] - }, - "AccessVerificationResponse" : { - "type" : "object", - "properties" : { - "read" : { - "type" : [ "boolean", "null" ] - }, - "write" : { - "type" : [ "boolean", "null" ] - }, - "defaultBranchProtected" : { - "type" : [ "boolean", "null" ] - }, - "checkedAt" : { - "type" : [ "string", "null" ], - "format" : "date-time" - }, - "messages" : { - "type" : "array", - "items" : { - "type" : "string" - } - } - }, - "required" : [ "messages" ] - }, - "RepositoryResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "name" : { - "type" : "string" - }, - "repoUrl" : { - "type" : "string" - }, - "defaultBranch" : { - "type" : "string" - }, - "vaultKeyPath" : { - "type" : "string" - }, - "deployKeyFingerprint" : { - "type" : [ "string", "null" ] - }, - "deployKeyAddedAt" : { - "type" : [ "string", "null" ], - "format" : "date-time" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - }, - "updatedAt" : { - "type" : "string", - "format" : "date-time" - }, - "verification" : { - "oneOf" : [ { - "$ref" : "#/components/schemas/AccessVerificationResponse" - }, { - "type" : "null" - } ] - } - }, - "required" : [ "createdAt", "defaultBranch", "id", "name", "repoUrl", "updatedAt", "vaultKeyPath" ] - }, - "AttachRepositoryDeployKeyRequest" : { - "type" : "object", - "properties" : { - "privateKeyOpenssh" : { - "type" : "string", - "minLength" : 1 - }, - "publicKeyOpenssh" : { - "type" : "string", - "minLength" : 1 - }, - "knownHosts" : { - "type" : [ "string", "null" ] - } - }, - "required" : [ "privateKeyOpenssh", "publicKeyOpenssh" ] - }, - "CreateProjectRequest" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "maxLength" : 80, - "minLength" : 1 - }, - "slug" : { - "type" : "string", - "pattern" : "^[a-z0-9][a-z0-9-]{0,62}$" - }, - "description" : { - "type" : [ "string", "null" ], - "maxLength" : 1000, - "minLength" : 0 - } - }, - "required" : [ "name", "slug" ] - }, - "ProjectResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "name" : { - "type" : "string" - }, - "slug" : { - "type" : "string" - }, - "description" : { - "type" : "string" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - }, - "updatedAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "createdAt", "description", "id", "name", "slug", "updatedAt" ] - }, - "AttachDeployKeyRequest" : { - "type" : "object", - "properties" : { - "privateKeyOpenssh" : { - "type" : "string", - "minLength" : 1 - }, - "publicKeyOpenssh" : { - "type" : "string", - "minLength" : 1 - }, - "knownHosts" : { - "type" : [ "string", "null" ] - } - }, - "required" : [ "privateKeyOpenssh", "publicKeyOpenssh" ] - }, - "LinkRepositoryRequest" : { - "type" : "object", - "properties" : { - "repositoryId" : { - "type" : "string", - "format" : "uuid" - } - }, - "required" : [ "repositoryId" ] - }, - "AddGithubLinkRequest" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "minLength" : 1 - }, - "repoUrl" : { - "type" : "string", - "minLength" : 1 - }, - "defaultBranch" : { - "type" : "string" - } - }, - "required" : [ "defaultBranch", "name", "repoUrl" ] - }, - "GithubLinkResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "projectId" : { - "type" : "string", - "format" : "uuid" - }, - "name" : { - "type" : "string" - }, - "repoUrl" : { - "type" : "string" - }, - "defaultBranch" : { - "type" : "string" - }, - "vaultKeyPath" : { - "type" : "string" - }, - "deployKeyFingerprint" : { - "type" : [ "string", "null" ] - }, - "deployKeyAddedAt" : { - "type" : [ "string", "null" ], - "format" : "date-time" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - }, - "updatedAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "createdAt", "defaultBranch", "id", "name", "projectId", "repoUrl", "updatedAt", "vaultKeyPath" ] - }, - "CreateConversationRequest" : { - "type" : "object", - "properties" : { - "title" : { - "type" : "string", - "maxLength" : 200, - "minLength" : 0 - } - }, - "required" : [ "title" ] - }, - "ConversationResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "userId" : { - "type" : "string", - "format" : "uuid" - }, - "title" : { - "type" : "string" - }, - "status" : { - "type" : "string" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - }, - "updatedAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "createdAt", "id", "status", "title", "updatedAt", "userId" ] - }, - "SendMessageRequest" : { - "type" : "object", - "properties" : { - "content" : { - "type" : "string", - "maxLength" : 10000, - "minLength" : 0 - } - }, - "required" : [ "content" ] - }, - "MessageResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "conversationId" : { - "type" : "string", - "format" : "uuid" - }, - "role" : { - "type" : "string" - }, - "content" : { - "type" : "string" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "content", "conversationId", "createdAt", "id", "role" ] - }, - "StartChatSessionRequest" : { - "type" : "object", - "properties" : { - "title" : { - "type" : [ "string", "null" ], - "maxLength" : 120, - "minLength" : 0 - }, - "kind" : { - "type" : [ "string", "null" ], - "enum" : [ "PLAIN", "KNOWLEDGE" ] - } - } - }, - "ChatSessionResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "userId" : { - "type" : "string", - "format" : "uuid" - }, - "title" : { - "type" : [ "string", "null" ] - }, - "status" : { - "type" : "string" - }, - "kind" : { - "type" : "string" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - }, - "updatedAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "createdAt", "id", "kind", "status", "updatedAt", "userId" ] - }, - "AppendChatMessageRequest" : { - "type" : "object", - "properties" : { - "body" : { - "type" : "string", - "minLength" : 1 - }, - "role" : { - "type" : "string", - "enum" : [ "USER", "ASSISTANT", "SYSTEM" ] - } - }, - "required" : [ "body", "role" ] - }, - "ChatMessageResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "sessionId" : { - "type" : "string", - "format" : "uuid" - }, - "role" : { - "type" : "string" - }, - "body" : { - "type" : "string" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "body", "createdAt", "id", "role", "sessionId" ] - }, - "TurnResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "sessionId" : { - "type" : "string", - "format" : "uuid" - }, - "role" : { - "type" : "string" - }, - "body" : { - "type" : "string" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "body", "createdAt", "id", "role", "sessionId" ] - }, - "WorkspaceAgentSessionResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "workspaceId" : { - "type" : "string", - "format" : "uuid" - }, - "kind" : { - "type" : "string", - "enum" : [ "CLAUDE", "CODEX", "SHELL" ] - }, - "gatewayAgentId" : { - "type" : [ "string", "null" ] - }, - "status" : { - "type" : "string" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - }, - "updatedAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "createdAt", "id", "kind", "status", "updatedAt", "workspaceId" ] - }, - "WorkspaceDetailResponse" : { - "type" : "object", - "properties" : { - "workspace" : { - "$ref" : "#/components/schemas/WorkspaceWithRepositoriesResponse" - }, - "sessions" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/WorkspaceAgentSessionResponse" - } - } - }, - "required" : [ "sessions", "workspace" ] - }, - "WorkspaceRepositoryResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "name" : { - "type" : "string" - }, - "isPrimary" : { - "type" : "boolean" - }, - "attachedAt" : { - "type" : "string", - "format" : "date-time" - }, - "repoUrl" : { - "type" : "string" - }, - "defaultBranch" : { - "type" : "string" - }, - "vaultKeyPath" : { - "type" : "string" - }, - "deployKeyFingerprint" : { - "type" : [ "string", "null" ] - }, - "deployKeyAddedAt" : { - "type" : [ "string", "null" ], - "format" : "date-time" - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - }, - "updatedAt" : { - "type" : "string", - "format" : "date-time" - }, - "verification" : { - "oneOf" : [ { - "$ref" : "#/components/schemas/AccessVerificationResponse" - }, { - "type" : "null" - } ] - } - }, - "required" : [ "attachedAt", "createdAt", "defaultBranch", "id", "isPrimary", "name", "repoUrl", "updatedAt", "vaultKeyPath" ] - }, - "WorkspaceWithRepositoriesResponse" : { - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "format" : "uuid" - }, - "name" : { - "type" : "string" - }, - "repoUrl" : { - "type" : [ "string", "null" ] - }, - "branch" : { - "type" : [ "string", "null" ] - }, - "podName" : { - "type" : [ "string", "null" ] - }, - "gatewayEndpoint" : { - "type" : [ "string", "null" ] - }, - "status" : { - "type" : "string" - }, - "kind" : { - "type" : "string" - }, - "projectId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - }, - "repositoryId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - }, - "githubLinkId" : { - "type" : [ "string", "null" ], - "format" : "uuid" - }, - "repositories" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/WorkspaceRepositoryResponse" - } - }, - "createdAt" : { - "type" : "string", - "format" : "date-time" - }, - "updatedAt" : { - "type" : "string", - "format" : "date-time" - } - }, - "required" : [ "createdAt", "id", "kind", "name", "repositories", "status", "updatedAt" ] - } - }, - "securitySchemes" : { - "xUserId" : { - "type" : "apiKey", - "description" : "User identifier injected by Traefik forward-auth", - "name" : "X-User-Id", - "in" : "header" - } - } - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/IntegrationTestBase.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/IntegrationTestBase.kt deleted file mode 100644 index 2d300f1b..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/IntegrationTestBase.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.jorisjonkers.personalstack.assistant - -import org.junit.jupiter.api.Tag -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.DynamicPropertyRegistry -import org.springframework.test.context.DynamicPropertySource -import org.testcontainers.containers.GenericContainer -import org.testcontainers.junit.jupiter.Testcontainers -import org.testcontainers.postgresql.PostgreSQLContainer -import org.testcontainers.rabbitmq.RabbitMQContainer - -@Tag("integration") -@SpringBootTest -@Testcontainers -abstract class IntegrationTestBase { - companion object { - private val postgres = - PostgreSQLContainer("postgres:17-alpine").apply { - withDatabaseName("assistant_db") - withUsername("assistant_user") - withPassword("assistant_pass") - } - - @Suppress("DEPRECATION") - private val valkey = - GenericContainer("valkey/valkey:7-alpine").apply { - withExposedPorts(6379) - } - - private val rabbitmq = RabbitMQContainer("rabbitmq:3-management-alpine") - - init { - postgres.start() - valkey.start() - rabbitmq.start() - } - - @JvmStatic - @DynamicPropertySource - fun configureProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.datasource.url") { postgres.jdbcUrl } - registry.add("spring.datasource.username") { postgres.username } - registry.add("spring.datasource.password") { postgres.password } - registry.add("spring.data.redis.host") { valkey.host } - registry.add("spring.data.redis.port") { valkey.getMappedPort(6379).toString() } - registry.add("spring.rabbitmq.host") { rabbitmq.host } - registry.add("spring.rabbitmq.port") { rabbitmq.amqpPort.toString() } - } - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/contract/OpenApiSpecExportTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/contract/OpenApiSpecExportTest.kt deleted file mode 100644 index d5dfc836..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/contract/OpenApiSpecExportTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.contract - -import com.jorisjonkers.personalstack.assistant.application.RepositoryVerificationService -import com.jorisjonkers.personalstack.assistant.application.chat.ChatAnswerStreamService -import com.jorisjonkers.personalstack.assistant.application.maintenance.RunnerMaintenanceService -import com.jorisjonkers.personalstack.assistant.application.query.ChatSessionQueryService -import com.jorisjonkers.personalstack.assistant.application.query.GetConversationQueryService -import com.jorisjonkers.personalstack.assistant.application.query.GetMessageQueryService -import com.jorisjonkers.personalstack.assistant.application.query.GetTurnHistoryQueryService -import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService -import com.jorisjonkers.personalstack.assistant.application.query.ListWorkspacesQueryService -import com.jorisjonkers.personalstack.assistant.application.query.ProjectQueryService -import com.jorisjonkers.personalstack.assistant.application.query.RepositoryQueryService -import com.jorisjonkers.personalstack.assistant.application.setup.SetupGuideService -import com.jorisjonkers.personalstack.assistant.config.OpenApiConfig -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.assistant.infrastructure.integration.GitHubAppInstallationTokenClient -import com.jorisjonkers.personalstack.assistant.infrastructure.web.AdminRunnerController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.AgentRunnerUnavailableExceptionHandler -import com.jorisjonkers.personalstack.assistant.infrastructure.web.AgentSessionController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.ChatSessionController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.ConversationController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.GitController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.HealthController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.InternalGitHubTokenController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.KubernetesExceptionHandler -import com.jorisjonkers.personalstack.assistant.infrastructure.web.MessageController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.ProjectController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.RepositoryAccessDeniedExceptionHandler -import com.jorisjonkers.personalstack.assistant.infrastructure.web.RepositoryController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.SetupGuideController -import com.jorisjonkers.personalstack.assistant.infrastructure.web.WorkspaceController -import com.jorisjonkers.personalstack.common.command.CommandBus -import com.jorisjonkers.personalstack.common.test.openapi.OpenApiSliceExporter -import com.jorisjonkers.personalstack.common.test.openapi.OpenApiWebMvcSliceConfiguration -import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler -import io.mockk.mockk -import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.SpringBootConfiguration -import org.springframework.boot.test.context.TestConfiguration -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc -import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest -import org.springframework.context.annotation.Bean -import org.springframework.test.context.ContextConfiguration -import org.springframework.test.web.servlet.MockMvc -import java.nio.file.Path -import java.nio.file.Paths - -// Pinned contract: hitting /api/v1/api-docs in the springdoc MVC slice -// produces the OpenAPI spec checked into the repo. The `exportOpenApiSpec` -// Gradle task runs only this tag and writes the JSON output to -// services/assistant-api/openapi.json. -@Tag("contract-export") -@WebMvcTest( - controllers = [ - AdminRunnerController::class, - AgentSessionController::class, - ChatSessionController::class, - ConversationController::class, - GitController::class, - HealthController::class, - InternalGitHubTokenController::class, - MessageController::class, - ProjectController::class, - RepositoryController::class, - SetupGuideController::class, - WorkspaceController::class, - ], - properties = [ - "springdoc.api-docs.path=/api/v1/api-docs", - ], -) -@AutoConfigureMockMvc(addFilters = false) -@ContextConfiguration( - classes = [ - OpenApiSpecExportTest.Application::class, - OpenApiSpecExportTest.Collaborators::class, - OpenApiWebMvcSliceConfiguration::class, - OpenApiConfig::class, - GlobalExceptionHandler::class, - AgentRunnerUnavailableExceptionHandler::class, - KubernetesExceptionHandler::class, - RepositoryAccessDeniedExceptionHandler::class, - AdminRunnerController::class, - AgentSessionController::class, - ChatSessionController::class, - ConversationController::class, - GitController::class, - HealthController::class, - InternalGitHubTokenController::class, - MessageController::class, - ProjectController::class, - RepositoryController::class, - SetupGuideController::class, - WorkspaceController::class, - ], -) -class OpenApiSpecExportTest { - @Autowired - private lateinit var mockMvc: MockMvc - - @Test - fun `export OpenAPI spec to repo root`() { - OpenApiSliceExporter.writeJson(mockMvc, resolveOpenApiSpecPath(), "/api/v1/api-docs") - } - - private fun resolveOpenApiSpecPath(): Path { - // The Gradle task sets `openapi.spec.output` to the canonical - // committed location. Fallback to `/openapi.json` when run - // directly from the IDE so a one-off invocation still works. - val override = System.getProperty("openapi.spec.output") - if (override != null) { - return Paths.get(override) - } - return Paths.get(System.getProperty("user.dir")).resolve("openapi.json") - } - - @SpringBootConfiguration - class Application - - @TestConfiguration(proxyBeanMethods = false) - class Collaborators { - @Bean - fun agentGatewayClient(): AgentGatewayClient = mockk(relaxed = true) - - @Bean - fun chatSessionQueryService(): ChatSessionQueryService = mockk(relaxed = true) - - @Bean - fun chatAnswerStreamService(): ChatAnswerStreamService = mockk() - - @Bean - fun commandBus(): CommandBus = mockk(relaxed = true) - - @Bean - fun getConversationQueryService(): GetConversationQueryService = mockk(relaxed = true) - - @Bean - fun getMessageQueryService(): GetMessageQueryService = mockk(relaxed = true) - - @Bean - fun getTurnHistoryQueryService(): GetTurnHistoryQueryService = mockk(relaxed = true) - - @Bean - fun getWorkspaceQueryService(): GetWorkspaceQueryService = mockk(relaxed = true) - - @Bean - fun githubAppInstallationTokenClient(): GitHubAppInstallationTokenClient = mockk(relaxed = true) - - @Bean - fun githubLinkRepository(): GithubLinkRepository = mockk(relaxed = true) - - @Bean - fun listWorkspacesQueryService(): ListWorkspacesQueryService = mockk(relaxed = true) - - @Bean - fun projectQueryService(): ProjectQueryService = mockk(relaxed = true) - - @Bean - fun repositoryQueryService(): RepositoryQueryService = mockk(relaxed = true) - - @Bean - fun repositoryVerificationService(): RepositoryVerificationService = mockk(relaxed = true) - - @Bean - fun runnerMaintenanceService(): RunnerMaintenanceService = mockk(relaxed = true) - - @Bean - fun setupGuideService(): SetupGuideService = mockk(relaxed = true) - - @Bean - fun workspaceAgentSessionRepository(): WorkspaceAgentSessionRepository = mockk(relaxed = true) - - @Bean - fun workspaceRepository(): WorkspaceRepository = mockk(relaxed = true) - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/AssistantApiContractIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/AssistantApiContractIntegrationTest.kt deleted file mode 100644 index 16c8d3c3..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/AssistantApiContractIntegrationTest.kt +++ /dev/null @@ -1,349 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.flow - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.context.WebApplicationContext -import java.util.UUID - -class AssistantApiContractIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var webApplicationContext: WebApplicationContext - - private lateinit var mockMvc: MockMvc - private val objectMapper = ObjectMapper() - - @BeforeEach - fun setUp() { - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build() - } - - @Test - fun `OpenAPI spec endpoint returns valid JSON`() { - mockMvc - .perform(get("/api/v1/api-docs")) - .andExpect(status().isOk) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.openapi").exists()) - .andExpect(jsonPath("$.info").exists()) - .andExpect(jsonPath("$.paths").exists()) - } - - @Test - fun `conversation creation response matches expected schema`() { - val userId = UUID.randomUUID().toString() - - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "Contract Test Chat"))), - ).andExpect(status().isCreated) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.userId").exists()) - .andExpect(jsonPath("$.title").value("Contract Test Chat")) - .andExpect(jsonPath("$.status").value("ACTIVE")) - .andExpect(jsonPath("$.createdAt").exists()) - .andExpect(jsonPath("$.updatedAt").exists()) - } - - @Test - fun `message response matches expected schema`() { - val userId = UUID.randomUUID().toString() - - val convResult = - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "Message Schema Test"))), - ).andExpect(status().isCreated) - .andReturn() - - val conversationId = objectMapper.readTree(convResult.response.contentAsString)["id"].asText() - - mockMvc - .perform( - post("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "Schema check"))), - ).andExpect(status().isCreated) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.conversationId").exists()) - .andExpect(jsonPath("$.role").value("USER")) - .andExpect(jsonPath("$.content").value("Schema check")) - .andExpect(jsonPath("$.createdAt").exists()) - } - - @Test - fun `health endpoint response matches schema`() { - mockMvc - .perform(get("/api/v1/health")) - .andExpect(status().isOk) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value("ok")) - .andExpect(jsonPath("$.service").value("assistant-api")) - } - - @Test - fun `repository creation response matches expected schema`() { - val unique = UUID.randomUUID().toString().take(8) - mockMvc - .perform( - post("/api/v1/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "repo-$unique", - "repoUrl" to "git@github.com:owner/repo-$unique.git", - "defaultBranch" to "main", - ), - ), - ), - ).andExpect(status().isCreated) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.name").value("repo-$unique")) - .andExpect(jsonPath("$.repoUrl").value("git@github.com:owner/repo-$unique.git")) - .andExpect(jsonPath("$.defaultBranch").value("main")) - .andExpect(jsonPath("$.deployKeyFingerprint").isEmpty) - } - - @Test - fun `repository list response is a JSON array`() { - val unique = UUID.randomUUID().toString().take(8) - mockMvc.perform( - post("/api/v1/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "list-$unique", - "repoUrl" to "git@github.com:o/list-$unique.git", - "defaultBranch" to "main", - ), - ), - ), - ) - - mockMvc - .perform(get("/api/v1/repositories")) - .andExpect(status().isOk) - .andExpect(jsonPath("$").isArray) - } - - @Test - fun `repository detail returns attached projects array`() { - val unique = UUID.randomUUID().toString().take(8) - val createResult = - mockMvc - .perform( - post("/api/v1/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "detail-$unique", - "repoUrl" to "git@github.com:o/detail-$unique.git", - "defaultBranch" to "main", - ), - ), - ), - ).andExpect(status().isCreated) - .andReturn() - val repoId = objectMapper.readTree(createResult.response.contentAsString)["id"].asText() - mockMvc - .perform(get("/api/v1/repositories/$repoId")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.repository.id").value(repoId)) - .andExpect(jsonPath("$.attachedProjects").isArray) - } - - @Test - fun `linking and unlinking a repository to a project surfaces in detail responses`() { - val unique = UUID.randomUUID().toString().take(8) - val projectResult = - mockMvc - .perform( - post("/api/v1/projects") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf("name" to "Project $unique", "slug" to "project-$unique"), - ), - ), - ).andExpect(status().isCreated) - .andReturn() - val projectId = objectMapper.readTree(projectResult.response.contentAsString)["id"].asText() - - val repoResult = - mockMvc - .perform( - post("/api/v1/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "link-$unique", - "repoUrl" to "git@github.com:o/link-$unique.git", - "defaultBranch" to "main", - ), - ), - ), - ).andExpect(status().isCreated) - .andReturn() - val repoId = objectMapper.readTree(repoResult.response.contentAsString)["id"].asText() - - mockMvc - .perform( - post("/api/v1/projects/$projectId/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("repositoryId" to repoId))), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$[0].id").value(repoId)) - - mockMvc - .perform(get("/api/v1/projects/$projectId")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.repositories[0].id").value(repoId)) - - mockMvc - .perform(get("/api/v1/repositories/$repoId")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.attachedProjects[0].id").value(projectId)) - - mockMvc - .perform(delete("/api/v1/projects/$projectId/repositories/$repoId")) - .andExpect(status().isNoContent) - } - - @Test - fun `creating a workspace with an unknown repositoryId returns 404 ProblemDetail`() { - val bogus = UUID.randomUUID() - mockMvc - .perform( - post("/api/v1/workspaces") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "bogus-repo-workspace", - "kind" to "REPO_BACKED", - "repositoryId" to bogus.toString(), - ), - ), - ), - ).andExpect(status().isNotFound) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value(404)) - .andExpect(jsonPath("$.title").value("Resource Not Found")) - .andExpect(jsonPath("$.detail").value(org.hamcrest.Matchers.containsString(bogus.toString()))) - } - - @Test - fun `chat session creation response matches expected schema`() { - val userId = UUID.randomUUID().toString() - mockMvc - .perform( - post("/api/v1/chat-sessions") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "Demo chat"))), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.userId").value(userId)) - .andExpect(jsonPath("$.title").value("Demo chat")) - .andExpect(jsonPath("$.status").value("ACTIVE")) - } - - @Test - fun `chat session list endpoint returns array for user`() { - val userId = UUID.randomUUID().toString() - mockMvc.perform( - post("/api/v1/chat-sessions") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "List me"))), - ) - mockMvc - .perform(get("/api/v1/chat-sessions").header("X-User-Id", userId)) - .andExpect(status().isOk) - .andExpect(jsonPath("$").isArray) - .andExpect(jsonPath("$[0].id").exists()) - .andExpect(jsonPath("$[0].status").exists()) - } - - @Test - fun `appending a chat message returns the message envelope and surfaces in detail`() { - val userId = UUID.randomUUID().toString() - val createResult = - mockMvc - .perform( - post("/api/v1/chat-sessions") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "msg-test"))), - ).andExpect(status().isCreated) - .andReturn() - val sessionId = objectMapper.readTree(createResult.response.contentAsString)["id"].asText() - - mockMvc - .perform( - post("/api/v1/chat-sessions/$sessionId/messages") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf("body" to "hello world", "role" to "USER"), - ), - ), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.body").value("hello world")) - .andExpect(jsonPath("$.role").value("USER")) - .andExpect(jsonPath("$.sessionId").value(sessionId)) - - mockMvc - .perform(get("/api/v1/chat-sessions/$sessionId")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.session.id").value(sessionId)) - .andExpect(jsonPath("$.messages[0].body").value("hello world")) - } - - @Test - fun `conversation list response is valid JSON array`() { - val userId = UUID.randomUUID().toString() - - // Create a conversation so the list is non-empty - mockMvc.perform( - post("/api/v1/conversations") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "List Test Chat"))), - ) - - mockMvc - .perform(get("/api/v1/conversations").header("X-User-Id", userId)) - .andExpect(status().isOk) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$").isArray) - .andExpect(jsonPath("$[0].id").exists()) - .andExpect(jsonPath("$[0].title").exists()) - .andExpect(jsonPath("$[0].status").exists()) - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/ConversationFlowIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/ConversationFlowIntegrationTest.kt deleted file mode 100644 index 6c61b87e..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/ConversationFlowIntegrationTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.flow - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.context.WebApplicationContext -import java.util.UUID - -class ConversationFlowIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var webApplicationContext: WebApplicationContext - - private lateinit var mockMvc: MockMvc - private val objectMapper = ObjectMapper() - - @BeforeEach - fun setUp() { - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build() - } - - @Test - fun `create and get conversation`() { - val userId = UUID.randomUUID().toString() - - val result = - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "Integration Chat"))), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.title").value("Integration Chat")) - .andExpect(jsonPath("$.status").value("ACTIVE")) - .andReturn() - - val id = objectMapper.readTree(result.response.contentAsString)["id"].asText() - - mockMvc - .perform(get("/api/v1/conversations/$id").header("X-User-Id", userId)) - .andExpect(status().isOk) - .andExpect(jsonPath("$.title").value("Integration Chat")) - } - - @Test - fun `create conversation without X-User-Id returns 500`() { - mockMvc - .perform( - post("/api/v1/conversations") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "No Auth"))), - ).andExpect(status().isInternalServerError) - } - - @Test - fun `list conversations returns only user's conversations`() { - val userId1 = UUID.randomUUID().toString() - val userId2 = UUID.randomUUID().toString() - - mockMvc.perform( - post("/api/v1/conversations") - .header("X-User-Id", userId1) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "User1 Chat"))), - ) - - mockMvc.perform( - post("/api/v1/conversations") - .header("X-User-Id", userId2) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "User2 Chat"))), - ) - - mockMvc - .perform(get("/api/v1/conversations").header("X-User-Id", userId1)) - .andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].title").value("User1 Chat")) - } - - @Test - fun `archive conversation returns 204`() { - val userId = UUID.randomUUID().toString() - - val result = - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "To Archive"))), - ).andReturn() - - val id = objectMapper.readTree(result.response.contentAsString)["id"].asText() - - mockMvc - .perform(delete("/api/v1/conversations/$id").header("X-User-Id", userId)) - .andExpect(status().isNoContent) - } - - @Test - fun `archive non-existent conversation returns 404`() { - val userId = UUID.randomUUID().toString() - val nonExistentId = UUID.randomUUID() - - mockMvc - .perform(delete("/api/v1/conversations/$nonExistentId").header("X-User-Id", userId)) - .andExpect(status().isNotFound) - } - - @Test - fun `get non-existent conversation returns 404`() { - val userId = UUID.randomUUID().toString() - val nonExistentId = UUID.randomUUID() - - mockMvc - .perform(get("/api/v1/conversations/$nonExistentId").header("X-User-Id", userId)) - .andExpect(status().isNotFound) - } - - @Test - fun `create conversation with blank title returns 422`() { - val userId = UUID.randomUUID().toString() - - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to ""))), - ).andExpect(status().isUnprocessableContent) - } - - @Test - fun `send and retrieve messages`() { - val userId = UUID.randomUUID().toString() - - val convResult = - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "Message Test"))), - ).andExpect(status().isCreated) - .andReturn() - - val conversationId = objectMapper.readTree(convResult.response.contentAsString)["id"].asText() - - mockMvc - .perform( - post("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "Hello there"))), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.content").value("Hello there")) - .andExpect(jsonPath("$.role").value("USER")) - - mockMvc - .perform( - get("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].content").value("Hello there")) - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/MessageFlowIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/MessageFlowIntegrationTest.kt deleted file mode 100644 index 858bc4b3..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/MessageFlowIntegrationTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.flow - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.context.WebApplicationContext -import java.util.UUID - -class MessageFlowIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var webApplicationContext: WebApplicationContext - - private lateinit var mockMvc: MockMvc - private val objectMapper = ObjectMapper() - - @BeforeEach - fun setUp() { - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build() - } - - @Test - fun `send message and retrieve by conversation`() { - val userId = UUID.randomUUID().toString() - val conversationId = createConversation(userId, "Msg Flow Test") - - mockMvc - .perform( - post("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "First message"))), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.content").value("First message")) - - mockMvc - .perform( - get("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].content").value("First message")) - } - - @Test - fun `send message without X-User-Id returns 500`() { - val userId = UUID.randomUUID().toString() - val conversationId = createConversation(userId, "No Auth Msg") - - mockMvc - .perform( - post("/api/v1/conversations/$conversationId/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "Should fail"))), - ).andExpect(status().isInternalServerError) - } - - @Test - fun `send message with blank content returns 422`() { - val userId = UUID.randomUUID().toString() - val conversationId = createConversation(userId, "Blank Msg") - - mockMvc - .perform( - post("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to ""))), - ).andExpect(status().isUnprocessableContent) - } - - @Test - fun `messages are ordered by creation time`() { - val userId = UUID.randomUUID().toString() - val conversationId = createConversation(userId, "Ordered Msgs") - - mockMvc.perform( - post("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "First"))), - ) - - mockMvc.perform( - post("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "Second"))), - ) - - mockMvc - .perform( - get("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].content").value("First")) - .andExpect(jsonPath("$[1].content").value("Second")) - } - - @Test - fun `messages for different conversations are isolated`() { - val userId = UUID.randomUUID().toString() - val conv1 = createConversation(userId, "Conv1") - val conv2 = createConversation(userId, "Conv2") - - mockMvc.perform( - post("/api/v1/conversations/$conv1/messages") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "Conv1 message"))), - ) - - mockMvc.perform( - post("/api/v1/conversations/$conv2/messages") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "Conv2 message"))), - ) - - mockMvc - .perform( - get("/api/v1/conversations/$conv1/messages") - .header("X-User-Id", userId), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].content").value("Conv1 message")) - - mockMvc - .perform( - get("/api/v1/conversations/$conv2/messages") - .header("X-User-Id", userId), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].content").value("Conv2 message")) - } - - private fun createConversation( - userId: String, - title: String, - ): String { - val result = - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to title))), - ).andExpect(status().isCreated) - .andReturn() - - return objectMapper.readTree(result.response.contentAsString)["id"].asText() - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt deleted file mode 100644 index 596f5214..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt +++ /dev/null @@ -1,231 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.flow - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.http.MediaType -import org.springframework.test.context.ActiveProfiles -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.context.WebApplicationContext -import java.util.UUID - -@ActiveProfiles("system-test") -class WorkspaceRepositoryFlowIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var webApplicationContext: WebApplicationContext - - private lateinit var mockMvc: MockMvc - private val objectMapper = ObjectMapper() - - @BeforeEach - fun setUp() { - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build() - } - - @Test - fun `project workspace creation surfaces primary and project repository extras in detail`() { - val unique = unique() - val projectId = createProject(unique) - val primaryId = createRepository("primary-$unique") - val extraOneId = createRepository("extra-one-$unique") - val extraTwoId = createRepository("extra-two-$unique") - linkRepository(projectId, extraOneId) - linkRepository(projectId, extraTwoId) - - val workspaceId = - createWorkspace( - name = "workspace-$unique", - projectId = projectId, - repositoryId = primaryId, - ) - - val detail = getWorkspaceDetail(workspaceId) - val repositories = detail["workspace"]["repositories"] - assertThat(repositories.map { it["id"].asText() }) - .containsExactlyInAnyOrder(primaryId, extraOneId, extraTwoId) - assertThat(repositories.map { it["name"].asText() }) - .containsExactlyInAnyOrder("primary-$unique", "extra-one-$unique", "extra-two-$unique") - } - - @Test - fun `workspace creation accepts multi-repository selection with explicit primary`() { - val unique = unique() - val primaryId = createRepository("primary-$unique") - val extraOneId = createRepository("extra-one-$unique") - val extraTwoId = createRepository("extra-two-$unique") - - val result = - mockMvc - .perform( - post("/api/v1/workspaces") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "workspace-$unique", - "kind" to "REPO_BACKED", - "primaryRepositoryId" to primaryId, - "repositoryIds" to listOf(extraOneId, primaryId, extraTwoId), - ), - ), - ), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.repositoryId").value(primaryId)) - .andReturn() - val workspaceId = objectMapper.readTree(result.response.contentAsString)["id"].asText() - - val repositories = getWorkspaceDetail(workspaceId)["workspace"]["repositories"] - assertThat(repositories.map { it["id"].asText() }) - .containsExactlyInAnyOrder(primaryId, extraOneId, extraTwoId) - assertThat(repositories.single { it["isPrimary"].asBoolean() }["id"].asText()) - .isEqualTo(primaryId) - } - - @Test - fun `attach and detach endpoints mutate workspace detail repositories`() { - val unique = unique() - val primaryId = createRepository("primary-$unique") - val extraId = createRepository("extra-$unique") - val workspaceId = createWorkspace(name = "workspace-$unique", repositoryId = primaryId) - - assertWorkspaceRepositories(workspaceId, primaryId) - - mockMvc - .perform( - post("/api/v1/workspaces/$workspaceId/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("repositoryId" to extraId))), - ).andExpect(status().isNoContent) - - assertWorkspaceRepositories(workspaceId, primaryId, extraId) - - mockMvc - .perform(delete("/api/v1/workspaces/$workspaceId/repositories/$extraId")) - .andExpect(status().isNoContent) - - assertWorkspaceRepositories(workspaceId, primaryId) - } - - @Test - fun `detaching the primary repository returns the validation error`() { - val unique = unique() - val primaryId = createRepository("primary-$unique") - val workspaceId = createWorkspace(name = "workspace-$unique", repositoryId = primaryId) - - mockMvc - .perform(delete("/api/v1/workspaces/$workspaceId/repositories/$primaryId")) - .andExpect(status().isBadRequest) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value(400)) - .andExpect(jsonPath("$.title").value("Bad Request")) - .andExpect(jsonPath("$.detail").value("cannot detach the primary repository from a workspace")) - - assertWorkspaceRepositories(workspaceId, primaryId) - } - - private fun createProject(unique: String): String { - val result = - mockMvc - .perform( - post("/api/v1/projects") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "Project $unique", - "slug" to "project-$unique", - ), - ), - ), - ).andExpect(status().isCreated) - .andReturn() - return objectMapper.readTree(result.response.contentAsString)["id"].asText() - } - - private fun createRepository(name: String): String { - val result = - mockMvc - .perform( - post("/api/v1/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to name, - "repoUrl" to "git@github.com:o/$name.git", - "defaultBranch" to "main", - ), - ), - ), - ).andExpect(status().isCreated) - .andReturn() - return objectMapper.readTree(result.response.contentAsString)["id"].asText() - } - - private fun linkRepository( - projectId: String, - repositoryId: String, - ) { - mockMvc - .perform( - post("/api/v1/projects/$projectId/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("repositoryId" to repositoryId))), - ).andExpect(status().isCreated) - } - - private fun createWorkspace( - name: String, - repositoryId: String, - projectId: String? = null, - ): String { - val body = - mutableMapOf( - "name" to name, - "kind" to "REPO_BACKED", - "repositoryId" to repositoryId, - ) - if (projectId != null) { - body["projectId"] = projectId - } - val result = - mockMvc - .perform( - post("/api/v1/workspaces") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(body)), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.repositoryId").value(repositoryId)) - .andReturn() - return objectMapper.readTree(result.response.contentAsString)["id"].asText() - } - - private fun getWorkspaceDetail(workspaceId: String) = - mockMvc - .perform(get("/api/v1/workspaces/$workspaceId")) - .andExpect(status().isOk) - .andReturn() - .let { objectMapper.readTree(it.response.contentAsString) } - - private fun assertWorkspaceRepositories( - workspaceId: String, - vararg expectedRepositoryIds: String, - ) { - val repositoryIds = - getWorkspaceDetail(workspaceId)["workspace"]["repositories"] - .map { it["id"].asText() } - assertThat(repositoryIds).containsExactlyInAnyOrder(*expectedRepositoryIds) - } - - private fun unique(): String = UUID.randomUUID().toString().take(8) -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt deleted file mode 100644 index 0b543c11..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt +++ /dev/null @@ -1,553 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.k8s - -import com.jorisjonkers.personalstack.assistant.config.AgentRuntimeProperties -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import com.jorisjonkers.personalstack.assistant.infrastructure.k8s.Fabric8AgentRunnerOrchestrator -import io.fabric8.kubernetes.client.KubernetesClient -import io.fabric8.kubernetes.client.KubernetesClientException -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.ObjectProvider -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers -import org.testcontainers.k3s.K3sContainer -import java.time.Instant -import java.util.UUID - -/** - * End-to-end exercise of [Fabric8AgentRunnerOrchestrator] against a - * real Kubernetes API supplied by Testcontainers' `k3s` module. The - * orchestrator's unit-test surface is empty by design — every method - * delegates to fabric8's builder + `serverSideApply()` chain, which - * is exactly the seam where #372's RBAC bug (and its predecessors) - * landed in production. - * - * The test class boots a single-node k3s once, applies the canonical - * RBAC + namespaces, then exercises the orchestrator through a - * `KubernetesClient` authenticated as the production - * `assistant-system:assistant-api` ServiceAccount via the - * TokenRequest API — so RBAC permissions are enforced identically - * to production. - * - * Scenarios: - * 1. provision with production RBAC creates pvc + pod + service - * 2. provision with pre-#372 restricted RBAC fails with patch-forbidden - * 3. provision is idempotent (server-side apply semantics) - * 4. destroy removes the four resources - * 5. provision with a project link stamps a workspace-scoped Secret - */ -@Tag("integration") -@Testcontainers -class Fabric8AgentRunnerOrchestratorIntegrationTest { - private lateinit var admin: KubernetesClient - private lateinit var saScoped: KubernetesClient - - @BeforeEach - fun setUp() { - admin = K3sTestSupport.createAdminClient(k3s) - K3sTestSupport.bootstrapNamespacesAndServiceAccount(admin) - } - - @AfterEach - fun cleanup() { - // Wipe orchestrator-owned resources so the next scenario - // starts clean. RBAC + namespaces stay; per-test RBAC - // variations overwrite the Role via server-side apply. - admin.pods().inNamespace(K3sTestSupport.AGENTS_NAMESPACE).delete() - admin.services().inNamespace(K3sTestSupport.AGENTS_NAMESPACE).delete() - admin.persistentVolumeClaims().inNamespace(K3sTestSupport.AGENTS_NAMESPACE).delete() - admin - .secrets() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withLabel("app.kubernetes.io/part-of", "agent-runner") - .delete() - if (this::saScoped.isInitialized) saScoped.close() - admin.close() - } - - @Test - @DisplayName("provision with production RBAC creates PVC + Pod + Service") - fun `provision with production RBAC creates pvc pod service`() { - K3sTestSupport.applyProductionRbac(admin) - saScoped = K3sTestSupport.createServiceAccountScopedClient(k3s, admin) - val orchestrator = orchestrator(saScoped, deployKeysProvider = empty(), githubLinks = empty()) - val workspace = adHocWorkspace() - - orchestrator.provision(workspace) - - val short = workspace.id.short() - assertThat( - admin - .persistentVolumeClaims() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("workspace-$short") - .get(), - ).isNotNull - assertThat( - admin - .pods() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-$short") - .get(), - ).isNotNull - assertThat( - admin - .services() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-$short") - .get(), - ).isNotNull - } - - @Test - @DisplayName("provisioned Pod carries KB_URL + KB_BEARER_TOKEN so the knowledge hooks can authenticate") - fun `provisioned pod carries knowledge mcp env`() { - K3sTestSupport.applyProductionRbac(admin) - saScoped = K3sTestSupport.createServiceAccountScopedClient(k3s, admin) - val orchestrator = orchestrator(saScoped, deployKeysProvider = empty(), githubLinks = empty()) - val workspace = adHocWorkspace() - - orchestrator.provision(workspace) - - val env = - admin - .pods() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-${workspace.id.short()}") - .get() - .spec - .containers - .single() - .env - val kbUrl = env.single { it.name == "KB_URL" } - assertThat(kbUrl.value).isEqualTo("http://knowledge-api.knowledge-system.svc.cluster.local:8080") - val bearer = env.single { it.name == "KB_BEARER_TOKEN" } - // Sourced from the Secret, never hard-coded as a literal value. - assertThat(bearer.value).isNull() - assertThat(bearer.valueFrom.secretKeyRef.name).isEqualTo("agents-kb-bearer") - assertThat(bearer.valueFrom.secretKeyRef.key).isEqualTo("bearer") - - // IS_SANDBOX tells Claude Code it is sandboxed so it skips the - // bypass-permissions warning/acceptance. - assertThat(env.single { it.name == "IS_SANDBOX" }.value).isEqualTo("1") - assertThat(env.single { it.name == "DOCKER_HOST" }.value).isEqualTo("unix:///var/run/docker.sock") - assertThat(env.single { it.name == "TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE" }.value) - .isEqualTo("/var/run/docker.sock") - val nodeHostIp = env.single { it.name == "AGENT_RUNNER_NODE_HOST_IP" } - assertThat(nodeHostIp.value).isNull() - assertThat(nodeHostIp.valueFrom.fieldRef.fieldPath).isEqualTo("status.hostIP") - assertThat(env.single { it.name == "TESTCONTAINERS_HOST_OVERRIDE" }.value) - .isEqualTo("$(AGENT_RUNNER_NODE_HOST_IP)") - // REPO_URL + REPO_BRANCH drive the entrypoint's boot-time clone - // (adHocWorkspace is repo-backed with a branch). - assertThat(env.single { it.name == "REPO_URL" }.value).isEqualTo("git@github.com:example/repo.git") - assertThat(env.single { it.name == "REPO_BRANCH" }.value).isEqualTo("main") - - // GITHUB_APP_TOKEN_URL + GITHUB_APP_TOKEN_BEARER let the runner's - // `gh` wrapper mint repo-scoped App tokens; the bearer is an - // optional Secret ref, never a literal. - val expectedTokenUrl = - "http://assistant-api.assistant-system.svc.cluster.local:8082" + - "/api/v1/internal/github/installation-token" - assertThat(env.single { it.name == "GITHUB_APP_TOKEN_URL" }.value).isEqualTo(expectedTokenUrl) - val appBearer = env.single { it.name == "GITHUB_APP_TOKEN_BEARER" } - assertThat(appBearer.value).isNull() - assertThat(appBearer.valueFrom.secretKeyRef.name).isEqualTo("github-app") - assertThat(appBearer.valueFrom.secretKeyRef.key).isEqualTo("token-bearer") - assertThat(appBearer.valueFrom.secretKeyRef.optional).isTrue() - - // The agents-mcp-servers ConfigMap is mounted (optionally) at - // /etc/agent-mcp so the entrypoint can seed Claude's mcpServers. - val pod = - admin - .pods() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-${workspace.id.short()}") - .get() - assertThat(pod.spec.nodeSelector) - .containsEntry("personal-stack/node", "enschede-gtx-960m-1") - .containsEntry("personal-stack/capability-docker-socket", "true") - val mcpMount = - pod.spec.containers - .single() - .volumeMounts - .single { it.name == "mcp-config" } - assertThat(mcpMount.mountPath).isEqualTo("/etc/agent-mcp") - val mcpVol = pod.spec.volumes.single { it.name == "mcp-config" } - assertThat(mcpVol.configMap.name).isEqualTo("agents-mcp-servers") - assertThat(mcpVol.configMap.optional).isTrue() - val dockerMount = - pod.spec.containers - .single() - .volumeMounts - .single { it.name == "docker-socket" } - assertThat(dockerMount.mountPath).isEqualTo("/var/run/docker.sock") - val dockerVol = pod.spec.volumes.single { it.name == "docker-socket" } - assertThat(dockerVol.hostPath.path).isEqualTo("/var/run/docker.sock") - assertThat(dockerVol.hostPath.type).isEqualTo("Socket") - - // The runner must hold no Kubernetes API credential: cluster reads - // go through the read-only MCP server, never the SA token. An - // unsandboxed agent therefore cannot reach the API server to - // modify or delete any Pod. - assertThat(pod.spec.automountServiceAccountToken).isFalse() - assertThat(pod.spec.securityContext.supplementalGroups) - .contains(131L) - .doesNotContain(998L, 999L) - - // A startup probe must gate liveness so the gateway's JVM cold - // start is not killed mid-boot (which re-provisioned the runner - // and 503'd start-session). - val container = pod.spec.containers.single() - assertThat(container.startupProbe).isNotNull - assertThat(container.startupProbe.httpGet.path).isEqualTo("/healthz") - assertThat(container.startupProbe.failureThreshold).isEqualTo(60) - assertThat(container.livenessProbe).isNotNull - } - - @Test - @DisplayName("provision with pre-#372 restricted RBAC fails with PVC patch-forbidden") - fun `provision with pre #372 restricted RBAC fails with patch forbidden on pvc`() { - K3sTestSupport.applyPre372RestrictedRbac(admin) - saScoped = K3sTestSupport.createServiceAccountScopedClient(k3s, admin) - val orchestrator = orchestrator(saScoped, deployKeysProvider = empty(), githubLinks = empty()) - - assertThatThrownBy { orchestrator.provision(adHocWorkspace()) } - .isInstanceOf(KubernetesClientException::class.java) - .hasMessageContaining("cannot patch resource \"persistentvolumeclaims\"") - } - - @Test - @DisplayName("provision is idempotent — calling twice does not error") - fun `provision is idempotent`() { - K3sTestSupport.applyProductionRbac(admin) - saScoped = K3sTestSupport.createServiceAccountScopedClient(k3s, admin) - val orchestrator = orchestrator(saScoped, deployKeysProvider = empty(), githubLinks = empty()) - val workspace = adHocWorkspace() - - orchestrator.provision(workspace) - orchestrator.provision(workspace) - - assertThat( - admin - .pods() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-${workspace.id.short()}") - .get(), - ).isNotNull - } - - @Test - @DisplayName("scaleDown removes Pod and Service but keeps the PVC") - fun `scaleDown removes pod and service but keeps the pvc`() { - K3sTestSupport.applyProductionRbac(admin) - saScoped = K3sTestSupport.createServiceAccountScopedClient(k3s, admin) - val orchestrator = orchestrator(saScoped, deployKeysProvider = empty(), githubLinks = empty()) - val workspace = adHocWorkspace() - orchestrator.provision(workspace) - - orchestrator.scaleDown(workspace) - - val short = workspace.id.short() - // PVC must survive scale-down so a follow-up provision can re-attach it. - assertThat( - admin - .persistentVolumeClaims() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("workspace-$short") - .get(), - ).isNotNull - // Pod and Service are torn down. - assertDeletedOrTerminating { - admin - .pods() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-$short") - .get() - ?.metadata - ?.deletionTimestamp - } - assertDeletedOrTerminating { - admin - .services() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-$short") - .get() - ?.metadata - ?.deletionTimestamp - } - } - - @Test - @DisplayName("destroy removes the PVC, Pod, and Service") - fun `destroy removes the four resources`() { - K3sTestSupport.applyProductionRbac(admin) - saScoped = K3sTestSupport.createServiceAccountScopedClient(k3s, admin) - val orchestrator = orchestrator(saScoped, deployKeysProvider = empty(), githubLinks = empty()) - val workspace = adHocWorkspace() - orchestrator.provision(workspace) - - orchestrator.destroy(workspace) - - // Deletes are async in Kubernetes — the `pvc-protection` - // finalizer + Pod garbage collection keep `get()` returning - // a still-Terminating object for a few hundred ms. We assert - // either disappearance (gc complete) or a non-null - // `deletionTimestamp` (gc in progress), both of which prove - // the orchestrator successfully issued the delete. - val short = workspace.id.short() - assertDeletedOrTerminating { - admin - .persistentVolumeClaims() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("workspace-$short") - .get() - ?.metadata - ?.deletionTimestamp - } - assertDeletedOrTerminating { - admin - .pods() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-$short") - .get() - ?.metadata - ?.deletionTimestamp - } - assertDeletedOrTerminating { - admin - .services() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-$short") - .get() - ?.metadata - ?.deletionTimestamp - } - } - - /** - * Resource is considered deleted if it's either gone (`get()` - * returns null, the supplier returns null because of the safe - * navigation chain) or in the Terminating phase (`deletionTimestamp` - * non-null). Either proves the orchestrator issued the delete; - * the actual gc-completion isn't the orchestrator's responsibility. - */ - private fun assertDeletedOrTerminating(deletionTimestamp: () -> String?) { - // Poll briefly in case the API hasn't materialised the - // deletionTimestamp on the next GET yet — single-digit ms - // typically. - repeat(POLL_ATTEMPTS) { - val ts = deletionTimestamp() - if (ts != null) return - Thread.sleep(POLL_INTERVAL_MS) - } - // Final read — if it's null here, the resource is fully gone - // (deleted + gc'd), which is also acceptable. - assertThat(deletionTimestamp()).isNull() - } - - @Test - @DisplayName("provision with project link stamps a workspace-scoped Secret") - fun `provision with project link stamps workspace scoped secret`() { - K3sTestSupport.applyProductionRbac(admin) - saScoped = K3sTestSupport.createServiceAccountScopedClient(k3s, admin) - - val projectId = ProjectId(UUID.randomUUID()) - val linkId = GithubLinkId.random() - val link = - GithubLink( - id = linkId, - projectId = projectId, - name = "test-repo", - repoUrl = "git@github.com:example/test-repo.git", - defaultBranch = "main", - vaultKeyPath = "secret/agents/projects/$projectId/repos/$linkId", - deployKeyFingerprint = "SHA256:test", - deployKeyAddedAt = Instant.now(), - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - val keys = StaticDeployKeyStore(linkId, EXAMPLE_KEY_MATERIAL) - val links = SingleEntryGithubLinkRepo(link) - val orchestrator = orchestrator(saScoped, deployKeysProvider = wrap(keys), githubLinks = wrap(links)) - - val workspace = adHocWorkspace().copy(githubLinkId = linkId) - orchestrator.provision(workspace) - - val short = workspace.id.short() - val secret = - admin - .secrets() - .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) - .withName("agent-runner-deploy-key-$short") - .get() - assertThat(secret).isNotNull - assertThat(secret.metadata.labels["agent-runner/github-link-id"]).isEqualTo(linkId.toString()) - assertThat(secret.data).containsKeys("private_key", "public_key", "known_hosts", "fingerprint") - } - - private fun orchestrator( - client: KubernetesClient, - deployKeysProvider: ObjectProvider, - githubLinks: ObjectProvider, - workspaceRepos: ObjectProvider = empty(), - repositories: ObjectProvider = empty(), - ): Fabric8AgentRunnerOrchestrator = - Fabric8AgentRunnerOrchestrator( - client = client, - props = testProps(), - deployKeysProvider = deployKeysProvider, - githubLinks = githubLinks, - workspaceRepos = workspaceRepos, - repositories = repositories, - ) - - private fun testProps(): AgentRuntimeProperties = - AgentRuntimeProperties( - namespace = K3sTestSupport.AGENTS_NAMESPACE, - image = "ghcr.io/extratoast/personal-stack/agent-runner:latest", - serviceAccount = "agent-runner", - workspaceStorageClass = "local-path", - claudeCredentialsPvc = "claude-credentials", - codexCredentialsPvc = "codex-credentials", - githubDeployKeySecret = "agents-github-deploy-key", - ) - - private fun adHocWorkspace(): Workspace = - Workspace( - id = WorkspaceId.random(), - name = "integration-test", - repoUrl = "git@github.com:example/repo.git", - branch = "main", - podName = null, - pvcName = null, - gatewayEndpoint = null, - status = WorkspaceStatus.PENDING, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private class StaticDeployKeyStore( - private val linkId: GithubLinkId, - private val material: DeployKeyStore.KeyMaterial, - ) : DeployKeyStore { - override fun store( - projectId: ProjectId, - linkId: GithubLinkId, - privateKeyOpenssh: String, - publicKeyOpenssh: String, - knownHosts: String, - ): DeployKeyStore.StoredKey = error("not used in this test") - - override fun remove( - projectId: ProjectId, - linkId: GithubLinkId, - ) = error("not used in this test") - - override fun readPublicKey( - projectId: ProjectId, - linkId: GithubLinkId, - ): String? = material.publicKey - - override fun loadKey( - projectId: ProjectId, - linkId: GithubLinkId, - ): DeployKeyStore.KeyMaterial? = if (linkId == this.linkId) material else null - - override fun store( - repositoryId: com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId, - privateKeyOpenssh: String, - publicKeyOpenssh: String, - knownHosts: String, - ): DeployKeyStore.StoredKey = error("not used in this test") - - override fun remove(repositoryId: com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId) = - error("not used in this test") - - override fun readPublicKey( - repositoryId: com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId, - ): String? = material.publicKey - - override fun loadKey( - repositoryId: com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId, - ): DeployKeyStore.KeyMaterial? = if (repositoryId.value == linkId.value) material else null - } - - private class SingleEntryGithubLinkRepo( - private val link: GithubLink, - ) : GithubLinkRepository { - override fun save(link: GithubLink): GithubLink = error("not used in this test") - - override fun findById(id: GithubLinkId): GithubLink? = if (id == link.id) link else null - - override fun findAllByProjectId(projectId: ProjectId): List = - if (projectId == link.projectId) listOf(link) else emptyList() - - override fun delete(id: GithubLinkId) = error("not used in this test") - } - - /** - * Minimal `ObjectProvider` impl. The orchestrator only ever - * reads `.ifAvailable`, so the unused defaults are fine — but - * we still throw helpfully on the methods that don't fit a - * "single optional value" mental model so a regression that - * starts calling them surfaces clearly. - */ - private class SingleValueObjectProvider( - private val value: T?, - ) : ObjectProvider { - override fun getObject(): T = value ?: error("no value provided") - - override fun getObject(vararg args: Any?): T = getObject() - - override fun getIfAvailable(): T? = value - - override fun getIfUnique(): T? = value - - override fun iterator(): MutableIterator = - (if (value != null) mutableListOf(value) else mutableListOf()).iterator() - } - - private fun empty(): ObjectProvider = SingleValueObjectProvider(null) - - private fun wrap(value: T): ObjectProvider = SingleValueObjectProvider(value) - - companion object { - // Singleton k3s container per test class. `@JvmStatic` lets the - // Testcontainers JUnit-Jupiter extension recognise it as a - // class-level container and start it in `beforeAll`, before - // any user lifecycle method runs. - @JvmStatic - @Container - private val k3s: K3sContainer = K3sTestSupport.newContainer() - - private const val POLL_ATTEMPTS = 20 - private const val POLL_INTERVAL_MS = 50L - - // Synthetic key material — the orchestrator never parses it, - // just base64-encodes the bytes into a k8s Secret. The - // explicit "NOT-A-REAL-KEY" string keeps secret scanners - // from flagging the file. - private val EXAMPLE_KEY_MATERIAL = - DeployKeyStore.KeyMaterial( - privateKey = "TEST-PRIVATE-KEY-NOT-A-REAL-KEY", - publicKey = "TEST-PUBLIC-KEY-NOT-A-REAL-KEY", - knownHosts = "github.com ssh-rsa AAAA-test-only", - fingerprint = "SHA256:test", - ) - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/K3sTestSupport.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/K3sTestSupport.kt deleted file mode 100644 index 6769952a..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/K3sTestSupport.kt +++ /dev/null @@ -1,266 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.k8s - -import io.fabric8.kubernetes.api.model.NamespaceBuilder -import io.fabric8.kubernetes.api.model.ServiceAccountBuilder -import io.fabric8.kubernetes.api.model.rbac.PolicyRuleBuilder -import io.fabric8.kubernetes.api.model.rbac.RoleBindingBuilder -import io.fabric8.kubernetes.api.model.rbac.RoleBuilder -import io.fabric8.kubernetes.api.model.rbac.RoleRefBuilder -import io.fabric8.kubernetes.api.model.rbac.SubjectBuilder -import io.fabric8.kubernetes.client.ConfigBuilder -import io.fabric8.kubernetes.client.KubernetesClient -import io.fabric8.kubernetes.client.KubernetesClientBuilder -import org.testcontainers.k3s.K3sContainer -import org.testcontainers.utility.DockerImageName -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.isDirectory - -/** - * Shared fixtures for the Fabric8 orchestrator integration tests. - * - * `K3sContainer` boots a real (single-node) Kubernetes API in ~20s - * via Docker-in-Docker. The container is amd64-only; CI (ubuntu- - * latest) supports this out of the box. On Apple Silicon locally, - * Rosetta + Docker Desktop's emulation works; the tests will - * otherwise be skipped if `docker info --format '{{.OSType}}'` - * disagrees. - * - * The helpers below cover the three reusable pieces the orchestrator - * tests need: - * - * 1. [createAdminClient] — a `KubernetesClient` authenticated as - * cluster-admin via the kubeconfig the K3sContainer emits. Used - * for test setup (namespaces, ServiceAccounts) and assertions. - * - * 2. [createServiceAccountScopedClient] — a `KubernetesClient` that - * impersonates the production assistant-api ServiceAccount. RBAC - * enforces against the impersonated identity exactly as it would - * for a real authenticated request, so the orchestrator under - * test exercises the same permission boundary as production. Why - * impersonation over TokenRequest: it skips audience-binding + - * cert-trust mechanics that vary between Kubernetes distributions - * and would only obscure what the test is actually checking. - * - * 3. [applyProductionRbac] / [applyPre372RestrictedRbac] — load the - * canonical RBAC manifest from the repo (or build a deliberately - * restricted copy that omits the `patch` verb on PVCs, locking in - * the regression that #372 fixed). - */ -object K3sTestSupport { - /** - * The image tag mirrors the cluster's k3s minor version. Bump - * deliberately when the production cluster does — the orchestrator - * only exercises core APIs that are stable across releases, but a - * matched tag avoids surprises from API-validation drift. - */ - private val K3S_IMAGE = DockerImageName.parse("rancher/k3s:v1.30.4-k3s1") - - /** - * Two namespaces the orchestrator and its RoleBinding reference. - * Production has these created via separate Flux Kustomizations; in - * the test they're a one-liner per Namespace. - */ - const val AGENTS_NAMESPACE = "agents-system" - const val ASSISTANT_NAMESPACE = "assistant-system" - const val ASSISTANT_SERVICE_ACCOUNT = "assistant-api" - const val ROLE_NAME = "assistant-api-runner-controller" - - /** - * The runner Pods set `spec.serviceAccountName: agent-runner` (see - * [AgentRuntimeProperties.serviceAccount]). K3s' admission chain - * validates SA existence at Pod create time; missing SA → 403 with - * "error looking up service account ... not found", so the test - * pre-creates it. Production gets the same SA via the agents- - * domain Kustomization; we just stamp it here. - */ - const val RUNNER_SERVICE_ACCOUNT = "agent-runner" - - fun newContainer(): K3sContainer = - K3sContainer(K3S_IMAGE) - // No need for Traefik in tests; faster boot. - .withCommand("server", "--disable=traefik", "--disable=servicelb", "--disable=metrics-server") - - fun createAdminClient(container: K3sContainer): KubernetesClient = - KubernetesClientBuilder() - .withConfig( - io.fabric8.kubernetes.client.Config - .fromKubeconfig(container.kubeConfigYaml), - ).build() - - /** - * Bootstrap the two namespaces and the assistant-api SA the - * production RoleBinding subjects on. Idempotent (server-side - * apply); safe to call once per test. - */ - fun bootstrapNamespacesAndServiceAccount(admin: KubernetesClient) { - val agentsNamespace = repoRoot().resolve("platform/cluster/flux/apps/agents/namespace.yaml") - Files.newInputStream(agentsNamespace).use { stream -> - admin - .load(stream) - .serverSideApply() - } - admin - .namespaces() - .resource( - NamespaceBuilder() - .withNewMetadata() - .withName(ASSISTANT_NAMESPACE) - .endMetadata() - .build(), - ).serverSideApply() - admin - .serviceAccounts() - .inNamespace(ASSISTANT_NAMESPACE) - .resource( - ServiceAccountBuilder() - .withNewMetadata() - .withName(ASSISTANT_SERVICE_ACCOUNT) - .withNamespace(ASSISTANT_NAMESPACE) - .endMetadata() - .build(), - ).serverSideApply() - admin - .serviceAccounts() - .inNamespace(AGENTS_NAMESPACE) - .resource( - ServiceAccountBuilder() - .withNewMetadata() - .withName(RUNNER_SERVICE_ACCOUNT) - .withNamespace(AGENTS_NAMESPACE) - .endMetadata() - .build(), - ).serverSideApply() - } - - /** - * Apply the canonical Role + RoleBinding from the committed - * platform manifest. Loading the actual file rather than building - * the manifest inline means the test fails the moment someone - * tweaks the production RBAC in a way that's incompatible with - * what the orchestrator now needs — the production manifest IS the - * spec. - */ - fun applyProductionRbac(admin: KubernetesClient) { - val manifest = repoRoot().resolve("platform/cluster/flux/apps/agents/rbac/assistant-api-role.yaml") - Files.newInputStream(manifest).use { stream -> - admin - .load(stream) - .inNamespace(AGENTS_NAMESPACE) - .serverSideApply() - } - } - - /** - * Programmatically build a Role + RoleBinding identical to the - * production version EXCEPT for the missing `patch` verb on - * persistentvolumeclaims. This is exactly the state #372 fixed — - * the orchestrator's `serverSideApply()` (which is a PATCH) on - * the workspace PVC will fail with a 403. Used by the negative - * test that locks the regression in. - */ - fun applyPre372RestrictedRbac(admin: KubernetesClient) { - val role = - RoleBuilder() - .withNewMetadata() - .withName(ROLE_NAME) - .withNamespace(AGENTS_NAMESPACE) - .endMetadata() - .withRules( - PolicyRuleBuilder() - .withApiGroups("") - .withResources("pods", "pods/log", "services") - .withVerbs("get", "list", "watch", "create", "delete", "patch") - .build(), - // Deliberately omits "patch" — the regression. - PolicyRuleBuilder() - .withApiGroups("") - .withResources("persistentvolumeclaims") - .withVerbs("get", "list", "watch", "create", "delete") - .build(), - PolicyRuleBuilder() - .withApiGroups("") - .withResources("secrets") - .withVerbs("get", "list", "watch") - .build(), - ).build() - val binding = - RoleBindingBuilder() - .withNewMetadata() - .withName(ROLE_NAME) - .withNamespace(AGENTS_NAMESPACE) - .endMetadata() - .withSubjects( - SubjectBuilder() - .withKind("ServiceAccount") - .withName(ASSISTANT_SERVICE_ACCOUNT) - .withNamespace(ASSISTANT_NAMESPACE) - .build(), - ).withRoleRef( - RoleRefBuilder() - .withKind("Role") - .withName(ROLE_NAME) - .withApiGroup("rbac.authorization.k8s.io") - .build(), - ).build() - admin - .rbac() - .roles() - .inNamespace(AGENTS_NAMESPACE) - .resource(role) - .serverSideApply() - admin - .rbac() - .roleBindings() - .inNamespace(AGENTS_NAMESPACE) - .resource(binding) - .serverSideApply() - } - - /** - * Build a fresh client that impersonates the assistant-api - * ServiceAccount. RBAC enforces against the impersonated - * identity exactly as it would for a real authenticated request, - * which is what the orchestrator tests need to exercise. The - * impersonation flags mirror what a real SA-bound request sees - * via the API server's authentication chain — the user is - * `system:serviceaccount::`, the groups include - * `system:serviceaccounts`, `system:serviceaccounts:`, and - * `system:authenticated`. We start from the admin - * `kubeConfigYaml` to inherit the API URL, CA cert, and admin - * credentials (impersonation requires the underlying client to - * have `impersonate` verb cluster-admin grants by default). - */ - fun createServiceAccountScopedClient( - container: K3sContainer, - admin: KubernetesClient, - ): KubernetesClient { - val cfg = - ConfigBuilder( - io.fabric8.kubernetes.client.Config - .fromKubeconfig(container.kubeConfigYaml), - ).withImpersonateUsername("system:serviceaccount:$ASSISTANT_NAMESPACE:$ASSISTANT_SERVICE_ACCOUNT") - .withImpersonateGroups( - "system:serviceaccounts", - "system:serviceaccounts:$ASSISTANT_NAMESPACE", - "system:authenticated", - ).build() - return KubernetesClientBuilder().withConfig(cfg).build() - } - - /** - * Walk up from the JVM's current working directory until a - * sibling `platform/` directory appears. Gradle runs tests from - * the module dir (`services/assistant-api/`), so the search - * goes up two levels in the normal case; the walk handles - * IDE runners that pick odd working directories. - */ - fun repoRoot(): Path { - var dir: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath() - while (dir != null) { - if (dir.resolve("platform").isDirectory()) return dir - dir = dir.parent - } - error("could not locate repo root containing platform/ from ${System.getProperty("user.dir")}") - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqChatMessageRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqChatMessageRepositoryIntegrationTest.kt deleted file mode 100644 index f2403770..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqChatMessageRepositoryIntegrationTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.persistence - -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessage -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.util.UUID - -class JooqChatMessageRepositoryIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var messages: ChatMessageRepository - - @Autowired - private lateinit var sessions: ChatSessionRepository - - private fun newSession(): ChatSession = - ChatSession( - id = ChatSessionId.random(), - userId = UUID.randomUUID(), - title = "session", - status = ChatSessionStatus.ACTIVE, - kind = ChatSessionKind.PLAIN, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ).also(sessions::save) - - private fun newMessage( - sessionId: ChatSessionId, - body: String = "hello", - createdAt: Instant = Instant.now(), - ) = ChatMessage( - id = ChatMessageId.random(), - sessionId = sessionId, - role = ChatMessageRole.USER, - body = body, - createdAt = createdAt, - ) - - @Test - fun `save and findById round-trip`() { - val s = newSession() - val m = newMessage(s.id) - messages.save(m) - val loaded = messages.findById(m.id) - assertThat(loaded).isNotNull - assertThat(loaded!!.body).isEqualTo("hello") - } - - @Test - fun `findAllBySessionIdOrderedByTime returns messages chronologically`() { - val s = newSession() - val first = newMessage(s.id, body = "first", createdAt = Instant.parse("2025-01-01T00:00:00Z")) - val second = newMessage(s.id, body = "second", createdAt = Instant.parse("2025-01-01T00:01:00Z")) - val third = newMessage(s.id, body = "third", createdAt = Instant.parse("2025-01-01T00:02:00Z")) - // Save out of order on purpose. - messages.save(third) - messages.save(first) - messages.save(second) - - val ordered = messages.findAllBySessionIdOrderedByTime(s.id) - assertThat(ordered.map { it.body }).containsExactly("first", "second", "third") - } - - @Test - fun `cascading delete removes messages when the session is removed`() { - val s = newSession() - messages.save(newMessage(s.id)) - messages.save(newMessage(s.id)) - sessions.delete(s.id) - assertThat(messages.findAllBySessionIdOrderedByTime(s.id)).isEmpty() - } - - @Test - fun `deleteAllBySessionId removes the rows`() { - val s = newSession() - messages.save(newMessage(s.id)) - messages.save(newMessage(s.id)) - messages.deleteAllBySessionId(s.id) - assertThat(messages.findAllBySessionIdOrderedByTime(s.id)).isEmpty() - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqChatSessionRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqChatSessionRepositoryIntegrationTest.kt deleted file mode 100644 index e5739f04..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqChatSessionRepositoryIntegrationTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.persistence - -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.util.UUID - -class JooqChatSessionRepositoryIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var sessions: ChatSessionRepository - - private fun newSession( - userId: UUID = UUID.randomUUID(), - title: String? = "x", - kind: ChatSessionKind = ChatSessionKind.PLAIN, - ) = ChatSession( - id = ChatSessionId.random(), - userId = userId, - title = title, - status = ChatSessionStatus.ACTIVE, - kind = kind, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `save and findById round-trip the kind`() { - val s = newSession(kind = ChatSessionKind.KNOWLEDGE) - sessions.save(s) - assertThat(sessions.findById(s.id)!!.kind).isEqualTo(ChatSessionKind.KNOWLEDGE) - } - - @Test - fun `save and findById round-trip with null title`() { - val s = newSession(title = null) - sessions.save(s) - val loaded = sessions.findById(s.id) - assertThat(loaded).isNotNull - assertThat(loaded!!.title).isNull() - assertThat(loaded.status).isEqualTo(ChatSessionStatus.ACTIVE) - } - - @Test - fun `findAllByUserId returns only the user's sessions`() { - val userA = UUID.randomUUID() - val userB = UUID.randomUUID() - sessions.save(newSession(userId = userA, title = "A1")) - sessions.save(newSession(userId = userA, title = "A2")) - sessions.save(newSession(userId = userB, title = "B")) - - val forA = sessions.findAllByUserId(userA) - assertThat(forA).hasSize(2) - assertThat(forA.map { it.title }).containsExactlyInAnyOrder("A1", "A2") - } - - @Test - fun `save updates the row on conflict`() { - val s = newSession() - sessions.save(s) - sessions.save(s.copy(status = ChatSessionStatus.ARCHIVED, updatedAt = Instant.now())) - - val loaded = sessions.findById(s.id) - assertThat(loaded!!.status).isEqualTo(ChatSessionStatus.ARCHIVED) - } - - @Test - fun `delete removes the row`() { - val s = newSession() - sessions.save(s) - sessions.delete(s.id) - assertThat(sessions.findById(s.id)).isNull() - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqConversationRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqConversationRepositoryIntegrationTest.kt deleted file mode 100644 index 880bec45..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqConversationRepositoryIntegrationTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.persistence - -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.util.UUID - -class JooqConversationRepositoryIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var conversationRepository: ConversationRepository - - @Test - fun `save and findById returns the saved conversation`() { - val conversation = buildConversation(title = "Test Conversation") - conversationRepository.save(conversation) - - val found = conversationRepository.findById(conversation.id) - - assertThat(found).isNotNull - assertThat(found!!.title).isEqualTo("Test Conversation") - assertThat(found.status).isEqualTo(ConversationStatus.ACTIVE) - } - - @Test - fun `findById returns null when conversation does not exist`() { - val result = conversationRepository.findById(ConversationId(UUID.randomUUID())) - - assertThat(result).isNull() - } - - @Test - fun `findByUserId returns all conversations for the user`() { - val userId = UUID.randomUUID() - val first = buildConversation(userId = userId, title = "First") - val second = buildConversation(userId = userId, title = "Second") - val other = buildConversation(title = "Other User") - conversationRepository.save(first) - conversationRepository.save(second) - conversationRepository.save(other) - - val results = conversationRepository.findByUserId(userId) - - assertThat(results).hasSize(2) - assertThat(results.map { it.title }).containsExactlyInAnyOrder("First", "Second") - } - - @Test - fun `save updates existing conversation on conflict`() { - val conversation = buildConversation(title = "Original") - conversationRepository.save(conversation) - - val updated = - conversation.copy( - title = "Updated", - status = ConversationStatus.ARCHIVED, - updatedAt = Instant.now(), - ) - conversationRepository.save(updated) - - val found = conversationRepository.findById(conversation.id) - assertThat(found).isNotNull - assertThat(found!!.title).isEqualTo("Updated") - assertThat(found.status).isEqualTo(ConversationStatus.ARCHIVED) - } - - private fun buildConversation( - userId: UUID = UUID.randomUUID(), - title: String = "Test", - ): Conversation { - val now = Instant.now() - return Conversation( - id = ConversationId(UUID.randomUUID()), - userId = userId, - title = title, - status = ConversationStatus.ACTIVE, - createdAt = now, - updatedAt = now, - ) - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqMessageRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqMessageRepositoryIntegrationTest.kt deleted file mode 100644 index 328a442b..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqMessageRepositoryIntegrationTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.persistence - -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.assistant.domain.model.Message -import com.jorisjonkers.personalstack.assistant.domain.model.MessageId -import com.jorisjonkers.personalstack.assistant.domain.model.MessageRole -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import com.jorisjonkers.personalstack.assistant.domain.port.MessageRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.util.UUID - -class JooqMessageRepositoryIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var conversationRepository: ConversationRepository - - @Autowired - private lateinit var messageRepository: MessageRepository - - @Test - fun `save and findByConversationId returns saved messages`() { - val conversation = buildConversation() - conversationRepository.save(conversation) - - val message = buildMessage(conversationId = conversation.id, content = "Hello", role = MessageRole.USER) - messageRepository.save(message) - - val found = messageRepository.findByConversationId(conversation.id) - - assertThat(found).hasSize(1) - assertThat(found[0].content).isEqualTo("Hello") - assertThat(found[0].role).isEqualTo(MessageRole.USER) - } - - @Test - fun `findByConversationId returns empty list when no messages exist`() { - val conversation = buildConversation() - conversationRepository.save(conversation) - - val found = messageRepository.findByConversationId(conversation.id) - - assertThat(found).isEmpty() - } - - @Test - fun `findByConversationId returns messages in ascending creation order`() { - val conversation = buildConversation() - conversationRepository.save(conversation) - - val first = buildMessage(conversationId = conversation.id, content = "First", role = MessageRole.USER) - val second = - buildMessage( - conversationId = conversation.id, - content = "Second", - role = MessageRole.ASSISTANT, - createdAt = Instant.now().plusSeconds(1), - ) - messageRepository.save(first) - messageRepository.save(second) - - val found = messageRepository.findByConversationId(conversation.id) - - assertThat(found).hasSize(2) - assertThat(found[0].content).isEqualTo("First") - assertThat(found[1].content).isEqualTo("Second") - } - - @Test - fun `findByConversationId does not return messages for other conversations`() { - val conversation1 = buildConversation() - val conversation2 = buildConversation() - conversationRepository.save(conversation1) - conversationRepository.save(conversation2) - - messageRepository.save(buildMessage(conversationId = conversation1.id, content = "Conv1 message")) - messageRepository.save(buildMessage(conversationId = conversation2.id, content = "Conv2 message")) - - val found = messageRepository.findByConversationId(conversation1.id) - - assertThat(found).hasSize(1) - assertThat(found[0].content).isEqualTo("Conv1 message") - } - - private fun buildConversation(): Conversation { - val now = Instant.now() - return Conversation( - id = ConversationId(UUID.randomUUID()), - userId = UUID.randomUUID(), - title = "Test Conversation", - status = ConversationStatus.ACTIVE, - createdAt = now, - updatedAt = now, - ) - } - - private fun buildMessage( - conversationId: ConversationId, - content: String = "Test message", - role: MessageRole = MessageRole.USER, - createdAt: Instant = Instant.now(), - ): Message = - Message( - id = MessageId(UUID.randomUUID()), - conversationId = conversationId, - role = role, - content = content, - createdAt = createdAt, - ) -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqProjectRepositoryRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqProjectRepositoryRepositoryIntegrationTest.kt deleted file mode 100644 index 0cf34ad2..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqProjectRepositoryRepositoryIntegrationTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.persistence - -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.util.UUID - -class JooqProjectRepositoryRepositoryIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var junction: ProjectRepositoryRepository - - @Autowired - private lateinit var repositories: RepositoryRepository - - @Autowired - private lateinit var projects: ProjectsRepository - - private fun project() = - Project( - id = ProjectId.random(), - name = "p", - slug = "p-${UUID.randomUUID().toString().take(6)}", - description = "", - createdAt = Instant.now(), - updatedAt = Instant.now(), - ).also(projects::save) - - private fun repository() = - Repository( - id = RepositoryId.random(), - name = "r-${UUID.randomUUID()}", - repoUrl = "git@x:o/r.git", - defaultBranch = "main", - vaultKeyPath = "x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ).also(repositories::save) - - @Test - fun `link inserts a row and exists returns true`() { - val p = project() - val r = repository() - junction.link(p.id, r.id) - assertThat(junction.exists(p.id, r.id)).isTrue - } - - @Test - fun `link is idempotent on duplicate insert`() { - val p = project() - val r = repository() - junction.link(p.id, r.id) - junction.link(p.id, r.id) - assertThat(junction.findAllByProjectId(p.id)).hasSize(1) - } - - @Test - fun `unlink removes the row`() { - val p = project() - val r = repository() - junction.link(p.id, r.id) - junction.unlink(p.id, r.id) - assertThat(junction.exists(p.id, r.id)).isFalse - } - - @Test - fun `findAllByProjectId returns junction rows for the project`() { - val p = project() - val r1 = repository() - val r2 = repository() - junction.link(p.id, r1.id) - junction.link(p.id, r2.id) - - val links = junction.findAllByProjectId(p.id) - assertThat(links.map { it.repositoryId }).containsExactlyInAnyOrder(r1.id, r2.id) - } - - @Test - fun `findAllByRepositoryId returns junction rows for the repository`() { - val p1 = project() - val p2 = project() - val r = repository() - junction.link(p1.id, r.id) - junction.link(p2.id, r.id) - - val links = junction.findAllByRepositoryId(r.id) - assertThat(links.map { it.projectId }).containsExactlyInAnyOrder(p1.id, p2.id) - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqRepositoryRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqRepositoryRepositoryIntegrationTest.kt deleted file mode 100644 index 606080fc..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqRepositoryRepositoryIntegrationTest.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.persistence - -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import com.jorisjonkers.personalstack.assistant.domain.model.AccessVerification -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.within -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.time.temporal.ChronoUnit -import java.util.UUID - -class JooqRepositoryRepositoryIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var repositories: RepositoryRepository - - @Autowired - private lateinit var junction: ProjectRepositoryRepository - - @Autowired - private lateinit var projects: ProjectsRepository - - private fun newRepository(name: String = "repo-${UUID.randomUUID()}") = - Repository( - id = RepositoryId.random(), - name = name, - repoUrl = "git@github.com:owner/$name.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `save and findById round-trip`() { - val r = newRepository() - repositories.save(r) - - val loaded = repositories.findById(r.id) - assertThat(loaded).isNotNull - assertThat(loaded!!.name).isEqualTo(r.name) - assertThat(loaded.repoUrl).isEqualTo(r.repoUrl) - } - - @Test - fun `findByName returns the repository by globally-unique name`() { - val r = newRepository(name = "lookup-by-name-${UUID.randomUUID()}") - repositories.save(r) - - val loaded = repositories.findByName(r.name) - assertThat(loaded).isNotNull - assertThat(loaded!!.id).isEqualTo(r.id) - } - - @Test - fun `save updates fingerprint on conflict`() { - val r = newRepository() - repositories.save(r) - val withKey = - r.copy( - deployKeyFingerprint = "SHA256:abc", - deployKeyAddedAt = Instant.now(), - updatedAt = Instant.now(), - ) - repositories.save(withKey) - - val loaded = repositories.findById(r.id) - assertThat(loaded!!.deployKeyFingerprint).isEqualTo("SHA256:abc") - assertThat(loaded.deployKeyAddedAt).isNotNull - } - - @Test - fun `findAllByProjectId returns only linked repositories`() { - val project = - Project( - id = ProjectId.random(), - name = "test-project", - slug = "test-project-${UUID.randomUUID().toString().take(6)}", - description = "", - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - projects.save(project) - val r1 = newRepository() - val r2 = newRepository() - val r3 = newRepository() - repositories.save(r1) - repositories.save(r2) - repositories.save(r3) - junction.link(project.id, r1.id) - junction.link(project.id, r2.id) - - val attached = repositories.findAllByProjectId(project.id) - - assertThat(attached.map { it.id }).containsExactlyInAnyOrder(r1.id, r2.id) - } - - @Test - fun `freshly saved repository has a null verification until a probe runs`() { - val r = newRepository() - repositories.save(r) - assertThat(repositories.findById(r.id)!!.verification).isNull() - } - - @Test - fun `verification round-trips through the V11 columns including multi-line messages`() { - val r = newRepository() - repositories.save(r) - val checkedAt = Instant.now() - val withVerification = - r.copy( - updatedAt = Instant.now(), - verification = - AccessVerification( - read = true, - write = false, - defaultBranchProtected = false, - checkedAt = checkedAt, - messages = - listOf( - "deploy key is read-only — agent commits/pushes will fail: denied", - "default branch 'main' is NOT protected on GitHub", - ), - ), - ) - repositories.save(withVerification) - - val loaded = repositories.findById(r.id)!!.verification - assertThat(loaded).isNotNull - assertThat(loaded!!.read).isTrue - assertThat(loaded.write).isFalse - assertThat(loaded.defaultBranchProtected).isFalse - assertThat(loaded.checkedAt).isCloseTo(checkedAt, within(1, ChronoUnit.SECONDS)) - assertThat(loaded.messages).hasSize(2) - assertThat(loaded.messages).anyMatch { it.contains("read-only") } - assertThat(loaded.messages).anyMatch { it.contains("NOT protected") } - } - - @Test - fun `null booleans with a checkedAt still load as an inconclusive verification`() { - val r = newRepository() - repositories.save(r) - repositories.save( - r.copy( - updatedAt = Instant.now(), - verification = - AccessVerification( - read = null, - write = null, - defaultBranchProtected = null, - checkedAt = Instant.now(), - messages = listOf("deploy-key access could not be verified (verify gateway unavailable)"), - ), - ), - ) - - val loaded = repositories.findById(r.id)!!.verification - assertThat(loaded).isNotNull - assertThat(loaded!!.read).isNull() - assertThat(loaded.write).isNull() - assertThat(loaded.defaultBranchProtected).isNull() - assertThat(loaded.messages).anyMatch { it.contains("could not be verified") } - } - - @Test - fun `delete removes the row`() { - val r = newRepository() - repositories.save(r) - repositories.delete(r.id) - assertThat(repositories.findById(r.id)).isNull() - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryIntegrationTest.kt deleted file mode 100644 index 603fa44f..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryIntegrationTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.persistence - -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.util.UUID - -@Suppress("DEPRECATION") -class JooqWorkspaceRepositoryIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var workspaces: WorkspaceRepository - - @Autowired - private lateinit var repositories: RepositoryRepository - - @Autowired - private lateinit var projects: ProjectsRepository - - private fun freshRepository(): Repository { - val r = - Repository( - id = RepositoryId.random(), - name = "wsr-${UUID.randomUUID()}", - repoUrl = "u", - defaultBranch = "main", - vaultKeyPath = "x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - repositories.save(r) - return r - } - - private fun freshProject(): Project { - val p = - Project( - id = ProjectId.random(), - name = "p", - slug = "p-${UUID.randomUUID().toString().take(6)}", - description = "", - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - projects.save(p) - return p - } - - private fun newWorkspace( - kind: WorkspaceKind = WorkspaceKind.REPO_BACKED, - repositoryId: RepositoryId? = null, - projectId: ProjectId? = null, - ): Workspace { - val now = Instant.now() - return Workspace( - id = WorkspaceId.random(), - name = "demo", - repoUrl = "git@github.com:o/r.git", - branch = "main", - podName = null, - pvcName = null, - gatewayEndpoint = null, - status = WorkspaceStatus.PENDING, - createdAt = now, - updatedAt = now, - kind = kind, - repositoryId = repositoryId, - projectId = projectId, - ) - } - - @Test - fun `save and findById round-trips kind, repositoryId, projectId`() { - val repo = freshRepository() - val project = freshProject() - val w = - newWorkspace( - kind = WorkspaceKind.SCRATCH, - repositoryId = repo.id, - projectId = project.id, - ) - workspaces.save(w) - - val loaded = workspaces.findById(w.id) - assertThat(loaded).isNotNull - assertThat(loaded!!.kind).isEqualTo(WorkspaceKind.SCRATCH) - assertThat(loaded.repositoryId).isEqualTo(repo.id) - assertThat(loaded.projectId).isEqualTo(project.id) - } - - @Test - fun `save updates pod info on conflict`() { - val w = newWorkspace() - workspaces.save(w) - val withPod = - w.copy( - podName = "agent-runner-x", - pvcName = "workspace-x", - gatewayEndpoint = "http://x.svc:8090", - status = WorkspaceStatus.STARTING, - updatedAt = Instant.now(), - ) - workspaces.save(withPod) - - val loaded = workspaces.findById(w.id) - assertThat(loaded!!.podName).isEqualTo("agent-runner-x") - assertThat(loaded.status).isEqualTo(WorkspaceStatus.STARTING) - } - - @Test - fun `findAllByStatusNot excludes the supplied status`() { - val a = newWorkspace() - val b = newWorkspace().copy(status = WorkspaceStatus.DESTROYED) - workspaces.save(a) - workspaces.save(b) - - val active = workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) - assertThat(active.map { it.id }).contains(a.id).doesNotContain(b.id) - } - - @Test - fun `delete removes the row`() { - val w = newWorkspace() - workspaces.save(w) - workspaces.delete(w.id) - assertThat(workspaces.findById(w.id)).isNull() - } -} diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt deleted file mode 100644 index f0aa90c9..00000000 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.persistence - -import com.jorisjonkers.personalstack.assistant.IntegrationTestBase -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.Instant -import java.util.UUID - -class JooqWorkspaceRepositoryRepositoryIntegrationTest : IntegrationTestBase() { - @Autowired - private lateinit var junction: WorkspaceRepositoryRepository - - @Autowired - private lateinit var workspaces: WorkspaceRepository - - @Autowired - private lateinit var repositories: RepositoryRepository - - private fun workspace() = - Workspace( - id = WorkspaceId.random(), - name = "w", - repoUrl = "git@github.com:o/primary.git", - branch = "main", - podName = null, - pvcName = null, - gatewayEndpoint = null, - status = WorkspaceStatus.PENDING, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ).also(workspaces::save) - - private fun repository() = - Repository( - id = RepositoryId.random(), - name = "r-${UUID.randomUUID()}", - repoUrl = "git@github.com:o/r.git", - defaultBranch = "main", - vaultKeyPath = "x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ).also(repositories::save) - - @Test - fun `attach inserts a row carried by findAllByWorkspaceId`() { - val w = workspace() - val r = repository() - junction.attach(w.id, r.id, isPrimary = true) - - val links = junction.findAllByWorkspaceId(w.id) - assertThat(links).hasSize(1) - assertThat(links.single().repositoryId).isEqualTo(r.id) - assertThat(links.single().isPrimary).isTrue - } - - @Test - fun `attach is idempotent on duplicate insert`() { - val w = workspace() - val r = repository() - junction.attach(w.id, r.id) - junction.attach(w.id, r.id) - assertThat(junction.findAllByWorkspaceId(w.id)).hasSize(1) - } - - @Test - fun `attaching a repository as primary promotes it and clears previous primary`() { - val w = workspace() - val r1 = repository() - val r2 = repository() - junction.attach(w.id, r1.id, isPrimary = true) - junction.attach(w.id, r2.id) - - val promoted = junction.attach(w.id, r2.id, isPrimary = true) - - assertThat(promoted.isPrimary).isTrue - val links = junction.findAllByWorkspaceId(w.id) - assertThat(links.filter { it.isPrimary }.map { it.repositoryId }).containsExactly(r2.id) - assertThat(links.first().repositoryId).isEqualTo(r2.id) - } - - @Test - fun `detach removes the row`() { - val w = workspace() - val r = repository() - junction.attach(w.id, r.id) - junction.detach(w.id, r.id) - assertThat(junction.findAllByWorkspaceId(w.id)).isEmpty() - } - - @Test - fun `findAllByWorkspaceId returns every attached repository`() { - val w = workspace() - val r1 = repository() - val r2 = repository() - junction.attach(w.id, r1.id, isPrimary = true) - junction.attach(w.id, r2.id) - - val links = junction.findAllByWorkspaceId(w.id) - assertThat(links.map { it.repositoryId }).containsExactlyInAnyOrder(r1.id, r2.id) - assertThat(links.filter { it.isPrimary }.map { it.repositoryId }).containsExactly(r1.id) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/AssistantApiApplication.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/AssistantApiApplication.kt deleted file mode 100644 index 7512eb0d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/AssistantApiApplication.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.jorisjonkers.personalstack.assistant - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.runApplication - -@SpringBootApplication(scanBasePackages = ["com.jorisjonkers.personalstack"]) -class AssistantApiApplication - -fun main(args: Array) { - runApplication(*args) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/RepositoryVerificationService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/RepositoryVerificationService.kt deleted file mode 100644 index 10376124..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/RepositoryVerificationService.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application - -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -/** - * On-demand re-verification of a repository's deploy-key access, - * exposed via POST /repositories/{id}/verify. Loads the row, runs the - * combined gateway + branch-protection probe, persists the outcome, - * and returns the refreshed repository so the controller can render - * the new status without a second round-trip. - */ -@Service -class RepositoryVerificationService( - private val repositories: RepositoryRepository, - private val verifyAccess: VerifyRepositoryAccess, -) { - @Transactional - fun reverify(id: RepositoryId): Repository? { - val repository = repositories.findById(id) ?: return null - val result = verifyAccess.verify(repository.repoUrl, repository.defaultBranch) - return repositories.save( - repository.copy( - updatedAt = Instant.now(), - verification = result.toAccessVerification(), - ), - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/VerifyRepositoryAccess.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/VerifyRepositoryAccess.kt deleted file mode 100644 index 60a6dc48..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/VerifyRepositoryAccess.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application - -import com.jorisjonkers.personalstack.assistant.domain.model.AccessVerification -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.BranchProtectionClient -import org.springframework.stereotype.Service -import java.time.Clock -import java.time.Instant - -/** - * Combined deploy-key verification: the gateway's read/write probe - * plus the GitHub branch-protection lookup, flattened into the single - * result shape the UI renders. Every sub-check degrades gracefully — - * an unreachable gateway leaves read/write null and a missing token - * leaves protection null; neither throws. - */ -@Service -class VerifyRepositoryAccess( - private val gateway: AgentGatewayClient, - private val branchProtection: BranchProtectionClient, - private val clock: Clock = Clock.systemUTC(), -) { - data class Result( - val read: Boolean?, - val write: Boolean?, - val defaultBranchProtected: Boolean?, - val checkedAt: Instant, - val messages: List, - ) { - fun toAccessVerification() = - AccessVerification( - read = read, - write = write, - defaultBranchProtected = defaultBranchProtected, - checkedAt = checkedAt, - messages = messages, - ) - } - - fun verify( - repoUrl: String, - defaultBranch: String, - ): Result { - val messages = mutableListOf() - - val access = gateway.verifyAccess(repoUrl, defaultBranch) - val read = access?.read - val write = access?.write - when { - access == null -> - messages += "deploy-key access could not be verified (verify gateway unavailable)" - !access.read -> - messages += "deploy key cannot read the repository: ${access.detail}" - !access.write -> - messages += "deploy key is read-only — agent commits/pushes will fail: ${access.detail}" - } - - val protected = branchProtection.isBranchProtected(repoUrl, defaultBranch) - when (protected) { - null -> messages += "branch protection on '$defaultBranch' could not be determined" - false -> messages += "default branch '$defaultBranch' is NOT protected on GitHub" - true -> Unit - } - - return Result( - read = read, - write = write, - defaultBranchProtected = protected, - checkedAt = Instant.now(clock), - messages = messages, - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/chat/ChatAnswerStreamService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/chat/ChatAnswerStreamService.kt deleted file mode 100644 index 82956cdf..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/chat/ChatAnswerStreamService.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.chat - -import com.jorisjonkers.personalstack.assistant.application.command.AppendChatMessageCommand -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.assistant.infrastructure.integration.LightRagClient -import com.jorisjonkers.personalstack.common.command.CommandBus -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.http.MediaType -import org.springframework.stereotype.Service -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter -import java.util.concurrent.Executor - -@Service -class ChatAnswerStreamService( - private val sessions: ChatSessionRepository, - private val commandBus: CommandBus, - private val lightRag: LightRagClient, - @param:Qualifier("chatStreamExecutor") private val executor: Executor, -) { - fun stream( - sessionId: ChatSessionId, - userBody: String, - ): SseEmitter { - require(userBody.isNotBlank()) { "chat message body must not be blank" } - sessions.findById(sessionId) - ?: throw NotFoundException("ChatSession", sessionId.value.toString()) - - val emitter = SseEmitter(TIMEOUT_MILLIS) - executor.execute { - generateAnswer(sessionId, userBody, emitter) - } - return emitter - } - - private fun generateAnswer( - sessionId: ChatSessionId, - userBody: String, - emitter: SseEmitter, - ) { - runCatching { - val full = - lightRag.streamQuery(userBody) { piece -> - sendEvent(emitter, "chunk", mapOf("text" to piece)) - } - check(full.isNotBlank()) { "no answer produced" } - persistAssistantMessage(sessionId, full, emitter) - }.onFailure { - sendTerminalError(emitter, it) - } - } - - private fun persistAssistantMessage( - sessionId: ChatSessionId, - full: String, - emitter: SseEmitter, - ) { - val messageId = ChatMessageId.random() - commandBus.dispatch( - AppendChatMessageCommand( - messageId = messageId, - sessionId = sessionId, - role = ChatMessageRole.ASSISTANT, - body = full, - ), - ) - sendEvent(emitter, "done", mapOf("messageId" to messageId.value.toString())) - emitter.complete() - } - - private fun sendTerminalError( - emitter: SseEmitter, - failure: Throwable, - ) { - runCatching { - val message = failure.message ?: "answer generation failed" - sendEvent(emitter, "error", mapOf("message" to message, "retryable" to true)) - } - emitter.complete() - } - - private fun sendEvent( - emitter: SseEmitter, - name: String, - data: Map, - ) { - emitter.send(SseEmitter.event().name(name).data(data, MediaType.APPLICATION_JSON)) - } - - private companion object { - private const val TIMEOUT_MILLIS = 120_000L - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AddGithubLinkCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AddGithubLinkCommand.kt deleted file mode 100644 index 8205291a..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AddGithubLinkCommand.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.common.command.Command - -data class AddGithubLinkCommand( - val linkId: GithubLinkId, - val projectId: ProjectId, - val name: String, - val repoUrl: String, - val defaultBranch: String = "main", -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AddGithubLinkCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AddGithubLinkCommandHandler.kt deleted file mode 100644 index ff7382a9..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AddGithubLinkCommandHandler.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -/** - * Creates the GithubLink row without yet attaching a deploy key — - * the link is in "needs key" state until the operator follows the - * setup guide and POSTs the keypair via AttachDeployKey. The Vault - * path is pre-allocated so the setup guide can deep-link to it - * (and so a partial flow doesn't ship a link without a planned - * home for its key). - */ -@Component -class AddGithubLinkCommandHandler( - private val projects: ProjectsRepository, - private val links: GithubLinkRepository, -) : CommandHandler { - @Transactional - override fun handle(command: AddGithubLinkCommand) { - val project = - projects.findById(command.projectId) - ?: error("project not found: ${command.projectId}") - require(command.name.isNotBlank()) { "link name must not be blank" } - require(command.repoUrl.isNotBlank()) { "repo URL must not be blank" } - val now = Instant.now() - val vaultPath = "secret/data/agents/projects/${project.id}/repos/${command.linkId}" - links.save( - GithubLink( - id = command.linkId, - projectId = project.id, - name = command.name.trim(), - repoUrl = command.repoUrl.trim(), - defaultBranch = command.defaultBranch.ifBlank { "main" }, - vaultKeyPath = vaultPath, - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = now, - updatedAt = now, - ), - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AppendChatMessageCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AppendChatMessageCommand.kt deleted file mode 100644 index b9509fe7..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AppendChatMessageCommand.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.common.command.Command - -data class AppendChatMessageCommand( - val messageId: ChatMessageId, - val sessionId: ChatSessionId, - val role: ChatMessageRole, - val body: String, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AppendChatMessageCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AppendChatMessageCommandHandler.kt deleted file mode 100644 index 588b4130..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AppendChatMessageCommandHandler.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessage -import com.jorisjonkers.personalstack.assistant.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -@Component -class AppendChatMessageCommandHandler( - private val sessions: ChatSessionRepository, - private val messages: ChatMessageRepository, -) : CommandHandler { - @Transactional - override fun handle(command: AppendChatMessageCommand) { - require(command.body.isNotBlank()) { "chat message body must not be blank" } - val session = - sessions.findById(command.sessionId) - ?: throw NotFoundException("ChatSession", command.sessionId.value.toString()) - val now = Instant.now() - messages.save( - ChatMessage( - id = command.messageId, - sessionId = session.id, - role = command.role, - body = command.body, - createdAt = now, - ), - ) - sessions.save(session.copy(updatedAt = now)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveChatSessionCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveChatSessionCommand.kt deleted file mode 100644 index b21e118b..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveChatSessionCommand.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.common.command.Command -import java.util.UUID - -data class ArchiveChatSessionCommand( - val sessionId: ChatSessionId, - val userId: UUID, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveChatSessionCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveChatSessionCommandHandler.kt deleted file mode 100644 index 3067c4f4..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveChatSessionCommandHandler.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import com.jorisjonkers.personalstack.common.exception.DomainException -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -@Component -class ArchiveChatSessionCommandHandler( - private val sessions: ChatSessionRepository, -) : CommandHandler { - @Transactional - override fun handle(command: ArchiveChatSessionCommand) { - val session = - sessions.findById(command.sessionId) - ?: throw NotFoundException("ChatSession", command.sessionId.value.toString()) - - if (session.userId != command.userId) { - throw DomainException( - "User ${command.userId} does not own chat session ${command.sessionId.value}", - "FORBIDDEN", - ) - } - sessions.save( - session.copy( - status = ChatSessionStatus.ARCHIVED, - updatedAt = Instant.now(), - ), - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveConversationCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveConversationCommand.kt deleted file mode 100644 index 32372c8c..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveConversationCommand.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.common.command.Command - -data class ArchiveConversationCommand( - val conversationId: ConversationId, - val userId: String, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveConversationCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveConversationCommandHandler.kt deleted file mode 100644 index 3d30cd33..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveConversationCommandHandler.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import com.jorisjonkers.personalstack.common.exception.DomainException -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import org.springframework.stereotype.Service -import java.time.Instant -import java.util.UUID - -@Service -class ArchiveConversationCommandHandler( - private val conversationRepository: ConversationRepository, -) : CommandHandler { - @Suppress("ThrowsCount") - override fun handle(command: ArchiveConversationCommand) { - val conversation = - conversationRepository.findById(command.conversationId) - ?: throw NotFoundException("Conversation", command.conversationId.value.toString()) - - val requestingUserId = - runCatching { UUID.fromString(command.userId) }.getOrNull() - ?: throw DomainException("Invalid userId format: ${command.userId}", "INVALID_USER_ID") - - if (conversation.userId != requestingUserId) { - throw DomainException( - "User ${command.userId} does not own conversation ${command.conversationId.value}", - "FORBIDDEN", - ) - } - - val archived = - conversation.copy( - status = ConversationStatus.ARCHIVED, - updatedAt = Instant.now(), - ) - conversationRepository.save(archived) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachDeployKeyCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachDeployKeyCommand.kt deleted file mode 100644 index b765e2e7..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachDeployKeyCommand.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.common.command.Command - -/** - * The paste-the-key flow. Operator generates a deploy key locally - * (the setup guide walks them through `ssh-keygen -t ed25519`), - * pastes the private + public halves into the wizard, the API - * lands the pair in Vault at the link's pre-allocated path and - * mirrors the fingerprint into the row. - */ -data class AttachDeployKeyCommand( - val linkId: GithubLinkId, - val privateKeyOpenssh: String, - val publicKeyOpenssh: String, - /** - * Optional override for `known_hosts`. The setup guide tells - * the operator to run `ssh-keyscan github.com` and paste the - * result; if blank we fall back to a static github.com entry - * baked into the API (the keys rotate rarely; the runner image - * also re-scans on its own at boot for belt-and-braces). - */ - val knownHosts: String? = null, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachDeployKeyCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachDeployKeyCommandHandler.kt deleted file mode 100644 index e221a71d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachDeployKeyCommandHandler.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.beans.factory.ObjectProvider -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -/** - * Static fallback for `known_hosts` if the wizard didn't provide - * one. Hard-coded values come from GitHub's published SSH host - * keys (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints). - * Update by hand on rotation — the public list shows when GitHub - * rolls keys (last on March 2023). - */ -private val GITHUB_KNOWN_HOSTS_FALLBACK = - """ - github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl - github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= - github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk= - """.trimIndent() - -/** - * `deployKeysProvider` is an [ObjectProvider] because the Vault- - * backed [DeployKeyStore] is `@ConditionalOnProperty(spring.cloud - * .vault.enabled)`: in tests where Vault is disabled the bean is - * absent and the constructor would otherwise fail to autowire. - * Resolving lazily lets the handler exist at boot and surface a - * clear "vault disabled" error only when an operator actually - * attempts to attach a key. - */ -@Component -class AttachDeployKeyCommandHandler( - private val links: GithubLinkRepository, - private val deployKeysProvider: ObjectProvider, -) : CommandHandler { - @Transactional - override fun handle(command: AttachDeployKeyCommand) { - val link = - links.findById(command.linkId) - ?: error("github link not found: ${command.linkId}") - require(command.privateKeyOpenssh.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) { - "private key must be OpenSSH-format (starts with `-----BEGIN OPENSSH PRIVATE KEY-----`)" - } - require(command.publicKeyOpenssh.matches(Regex("^ssh-(ed25519|rsa)\\s.+", RegexOption.DOT_MATCHES_ALL))) { - "public key must be an OpenSSH single-line key (ssh-ed25519 / ssh-rsa preferred)" - } - val deployKeys = - deployKeysProvider.ifAvailable - ?: error("vault disabled — deploy-key storage adapter is not loaded") - val stored = - deployKeys.store( - projectId = link.projectId, - linkId = link.id, - privateKeyOpenssh = command.privateKeyOpenssh, - publicKeyOpenssh = command.publicKeyOpenssh, - knownHosts = command.knownHosts?.takeIf { it.isNotBlank() } ?: GITHUB_KNOWN_HOSTS_FALLBACK, - ) - links.save( - link.copy( - vaultKeyPath = stored.vaultPath, - deployKeyFingerprint = stored.fingerprint, - deployKeyAddedAt = Instant.now(), - updatedAt = Instant.now(), - ), - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachRepositoryDeployKeyCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachRepositoryDeployKeyCommand.kt deleted file mode 100644 index b8175fc8..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachRepositoryDeployKeyCommand.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.common.command.Command - -/** - * Paste-the-key flow keyed by repository. The operator generates an - * ed25519 pair locally, the wizard sends both halves here, the API - * writes them to the per-repository Vault path and mirrors the - * fingerprint into the Repository row. - */ -data class AttachRepositoryDeployKeyCommand( - val repositoryId: RepositoryId, - val privateKeyOpenssh: String, - val publicKeyOpenssh: String, - val knownHosts: String? = null, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachRepositoryDeployKeyCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachRepositoryDeployKeyCommandHandler.kt deleted file mode 100644 index 0577ea64..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachRepositoryDeployKeyCommandHandler.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.VerifyRepositoryAccess -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.beans.factory.ObjectProvider -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -/** - * Static fallback for `known_hosts` when the wizard left the field - * blank. The published GitHub SSH host keys - * (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints). - * Update by hand on rotation — last GitHub rotation was March 2023. - */ -private val GITHUB_KNOWN_HOSTS_FALLBACK = - """ - github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl - github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= - github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk= - """.trimIndent() - -/** - * `deployKeysProvider` is an [ObjectProvider] for the same reason - * [AttachDeployKeyCommandHandler] uses one — the Vault-backed - * [DeployKeyStore] is `@ConditionalOnProperty(spring.cloud.vault.enabled)`. - */ -@Component -class AttachRepositoryDeployKeyCommandHandler( - private val repositories: RepositoryRepository, - private val deployKeysProvider: ObjectProvider, - private val verifyAccess: VerifyRepositoryAccess, -) : CommandHandler { - @Transactional - override fun handle(command: AttachRepositoryDeployKeyCommand) { - val repository = - repositories.findById(command.repositoryId) - ?: error("repository not found: ${command.repositoryId}") - validateKeyMaterial(command) - val deployKeys = - deployKeysProvider.ifAvailable - ?: error("vault disabled — deploy-key storage adapter is not loaded") - // Re-running attach IS the key-replacement path: the Vault - // store overwrites the secret at the repository's fixed path - // (DeployKeyStore.store does an unconditional writeSecret), the - // fingerprint mirror below replaces the old one, and the - // verification re-runs against the new key. - val stored = - deployKeys.store( - repositoryId = repository.id, - privateKeyOpenssh = command.privateKeyOpenssh, - publicKeyOpenssh = command.publicKeyOpenssh, - knownHosts = command.knownHosts?.takeIf { it.isNotBlank() } ?: GITHUB_KNOWN_HOSTS_FALLBACK, - ) - val result = verifyAccess.verify(repository.repoUrl, repository.defaultBranch) - repositories.save( - repository.copy( - vaultKeyPath = stored.vaultPath, - deployKeyFingerprint = stored.fingerprint, - deployKeyAddedAt = Instant.now(), - updatedAt = Instant.now(), - verification = result.toAccessVerification(), - ), - ) - } - - private fun validateKeyMaterial(command: AttachRepositoryDeployKeyCommand) { - require(command.privateKeyOpenssh.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) { - "private key must be OpenSSH-format (starts with `-----BEGIN OPENSSH PRIVATE KEY-----`)" - } - require(command.publicKeyOpenssh.matches(Regex("^ssh-(ed25519|rsa)\\s.+", RegexOption.DOT_MATCHES_ALL))) { - "public key must be an OpenSSH single-line key (ssh-ed25519 / ssh-rsa preferred)" - } - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt deleted file mode 100644 index 0702e1a0..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.common.command.Command - -/** - * Attach an additional repository to a workspace. The link becomes part - * of REPO_URLS for future runner boots, and a currently running gateway - * is asked to clone it immediately into /workspace/. - */ -data class AttachWorkspaceRepositoryCommand( - val workspaceId: WorkspaceId, - val repositoryId: RepositoryId, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt deleted file mode 100644 index 3662283f..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import org.springframework.web.client.RestClientException - -@Component -class AttachWorkspaceRepositoryCommandHandler( - private val workspaces: WorkspaceRepository, - private val repositories: RepositoryRepository, - private val links: WorkspaceRepositoryRepository, - private val gateway: AgentGatewayClient, -) : CommandHandler { - private val log = LoggerFactory.getLogger(AttachWorkspaceRepositoryCommandHandler::class.java) - - @Transactional - override fun handle(command: AttachWorkspaceRepositoryCommand) { - val workspace = - workspaces.findById(command.workspaceId) - ?: throw NoSuchElementException("workspace not found: ${command.workspaceId}") - val repository = - repositories.findById(command.repositoryId) - ?: throw NoSuchElementException("repository not found: ${command.repositoryId}") - links.attach(command.workspaceId, command.repositoryId, isPrimary = false) - if (workspace.gatewayEndpoint != null && gateway.isReady(workspace)) { - // The link is the source of truth: the entrypoint clones every - // attached repo at the next boot. Cloning into the live workspace - // is a convenience, so a failure here (e.g. the running gateway's - // boot-frozen credentials cannot yet reach the repo) must not roll - // back the attach — log and let the boot clone reconcile it. - try { - gateway.clone(workspace, repository.repoUrl, repository.defaultBranch) - } catch (e: RestClientException) { - log.warn( - "live clone of {} into workspace {} failed; attach persisted, boot clone will reconcile", - repository.repoUrl, - command.workspaceId, - e, - ) - } - } - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateProjectCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateProjectCommand.kt deleted file mode 100644 index e85d7222..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateProjectCommand.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.common.command.Command - -data class CreateProjectCommand( - val projectId: ProjectId, - val name: String, - val slug: String, - val description: String = "", -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateProjectCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateProjectCommandHandler.kt deleted file mode 100644 index 4a0c0e6a..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateProjectCommandHandler.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -@Component -class CreateProjectCommandHandler( - private val projects: ProjectsRepository, -) : CommandHandler { - @Transactional - override fun handle(command: CreateProjectCommand) { - require(command.name.isNotBlank()) { "project name must not be blank" } - require(command.slug.matches(Regex("^[a-z0-9][a-z0-9-]{0,62}$"))) { - "slug must match ^[a-z0-9][a-z0-9-]{0,62}$" - } - val existing = projects.findBySlug(command.slug) - if (existing != null && existing.id != command.projectId) { - error("slug already in use: ${command.slug}") - } - val now = Instant.now() - projects.save( - Project( - id = command.projectId, - name = command.name.trim(), - slug = command.slug, - description = command.description.trim(), - createdAt = now, - updatedAt = now, - ), - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateRepositoryCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateRepositoryCommand.kt deleted file mode 100644 index 90bac69f..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateRepositoryCommand.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.common.command.Command - -data class CreateRepositoryCommand( - val repositoryId: RepositoryId, - val name: String, - val repoUrl: String, - val defaultBranch: String = "main", -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateRepositoryCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateRepositoryCommandHandler.kt deleted file mode 100644 index b3a026d7..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateRepositoryCommandHandler.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -/** - * Creates the Repository row in "needs key" state. The Vault path - * is pre-allocated under `secret/data/agents/repositories/` so - * the deploy-key wizard can deep-link to a stable URL even before - * the operator pastes the key in. The fingerprint stays null until - * [AttachRepositoryDeployKeyCommandHandler] runs. - */ -@Component -class CreateRepositoryCommandHandler( - private val repositories: RepositoryRepository, -) : CommandHandler { - @Transactional - override fun handle(command: CreateRepositoryCommand) { - require(command.name.isNotBlank()) { "repository name must not be blank" } - require(command.repoUrl.isNotBlank()) { "repo URL must not be blank" } - val existing = repositories.findByName(command.name.trim()) - if (existing != null && existing.id != command.repositoryId) { - error("repository name already in use: ${command.name.trim()}") - } - val now = Instant.now() - val vaultPath = "secret/data/agents/repositories/${command.repositoryId}" - repositories.save( - Repository( - id = command.repositoryId, - name = command.name.trim(), - repoUrl = command.repoUrl.trim(), - defaultBranch = command.defaultBranch.ifBlank { "main" }, - vaultKeyPath = vaultPath, - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = now, - updatedAt = now, - ), - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt deleted file mode 100644 index d6231760..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind -import com.jorisjonkers.personalstack.common.command.Command - -/** - * Three flavours dictated by [kind]: - * - * 1. `REPO_BACKED`: `repositoryId` set (preferred) or the legacy - * `githubLinkId` set during the V9 migration window. The - * handler resolves the repository, populates `repoUrl` / - * `branch` from it, and the orchestrator stamps the per-repo - * deploy key into the Pod. - * - * 2. `SCRATCH`: `repositoryId` / `githubLinkId` left null. The - * runner Pod boots without a clone; `repoUrl` may still be - * supplied if the operator wants a free-text URL for future - * use, but the orchestrator does not act on it. - * - * 3. `CHAT`: not handled here — chat lives in [ChatSession] and - * is created via [StartChatSessionCommand]. A `kind = CHAT` - * workspace creation request is rejected. - * - * [projectId] is optional context: setting it groups the workspace - * under a project in the UI without otherwise affecting the Pod. - */ -data class CreateWorkspaceCommand( - val workspaceId: WorkspaceId, - val name: String, - val repoUrl: String?, - val branch: String?, - val kind: WorkspaceKind = WorkspaceKind.REPO_BACKED, - val projectId: ProjectId? = null, - val repositoryId: RepositoryId? = null, - /** - * Ordered repository selection for multi-repo workspaces. The - * first entry becomes the primary repository when [repositoryId] - * is omitted; all other distinct entries are attached as - * additional repositories before the runner is provisioned. - */ - val repositoryIds: List = emptyList(), - @Deprecated( - "Use repositoryId — kept for the V9 migration window so existing UI " + - "callers continue to work until PR F migrates them.", - ReplaceWith("repositoryId"), - ) - val githubLinkId: GithubLinkId? = null, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt deleted file mode 100644 index 5b23f238..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt +++ /dev/null @@ -1,288 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.VerifyRepositoryAccess -import com.jorisjonkers.personalstack.assistant.application.exception.RepositoryAccessDeniedException -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.ObjectProvider -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -/** - * Creates the workspace record and provisions the runner Pod. For a - * repo-backed workspace the clone happens inside the runner at boot - * (the orchestrator passes REPO_URL/REPO_BRANCH and the entrypoint - * clones into /workspace/ with the mounted deploy key) — not from here. - * An earlier create-time `gateway.clone` raced the runner's startup: - * it fired before the gateway was up, failed, and was swallowed, - * leaving the workspace empty. - * - * Repo resolution prefers [CreateWorkspaceCommand.repositoryId] - * (the new shape). If only the legacy [CreateWorkspaceCommand.githubLinkId] - * is set, the handler still resolves through [GithubLinkRepository] - * and logs a deprecation warning so the migration is visible in - * production logs. - */ -@Component -@Suppress("DEPRECATION", "LongParameterList") -class CreateWorkspaceCommandHandler( - private val workspaces: WorkspaceRepository, - private val orchestrator: AgentRunnerOrchestrator, - private val projectRepositories: ProjectRepositoryRepository, - private val workspaceRepositories: WorkspaceRepositoryRepository, - /** - * Optional — present only when the Projects feature is wired - * (Vault enabled). When absent, every CreateWorkspace must - * either supply repoUrl or stay repo-less. - */ - private val githubLinks: ObjectProvider, - /** - * The new per-Repository lookup. Same wiring rule as - * [githubLinks] — `@ConditionalOnProperty` keeps it absent in - * tests that disable Vault. - */ - private val repositories: ObjectProvider, - private val verifyAccess: VerifyRepositoryAccess, -) : CommandHandler { - private val log = LoggerFactory.getLogger(CreateWorkspaceCommandHandler::class.java) - - @Transactional - override fun handle(command: CreateWorkspaceCommand) { - require(command.kind != WorkspaceKind.CHAT) { - "CHAT workspaces are not persisted — use StartChatSessionCommand instead" - } - if (command.repositoryId != null && command.githubLinkId != null) { - log.warn( - "CreateWorkspaceCommand received both repositoryId={} and (deprecated) githubLinkId={}; " + - "preferring repositoryId", - command.repositoryId, - command.githubLinkId, - ) - } else if (command.githubLinkId != null) { - log.warn( - "CreateWorkspaceCommand uses deprecated githubLinkId={}; migrate the caller to repositoryId", - command.githubLinkId, - ) - } - val resolved = resolveRepo(command) - if (command.kind == WorkspaceKind.REPO_BACKED && resolved.repoUrl != null) { - verifyOrFail(resolved.repoUrl, resolved.branch, resolved.repositoryId) - } - val workspace = persistInitial(command, resolved) - seedRepositoryMembership(workspace, command) - val withPod = provisionAndUpdate(workspace) - log.info("workspace {} provisioned as pod {}", workspace.id, withPod.podName) - } - - private fun verifyOrFail( - repoUrl: String, - branch: String?, - repositoryId: RepositoryId?, - ) { - val effectiveBranch = branch?.takeIf { it.isNotBlank() } ?: "main" - val result = verifyAccess.verify(repoUrl, effectiveBranch) - // read == false is an explicit "auth ok but no read" or an - // unauthenticated reject — either way the runner could never - // clone, so fail loudly instead of leaving a dead workspace. - // read == null (gateway unavailable) is NOT fatal: it degrades - // to a warning so a verify-gateway outage can't block the - // whole create flow. - if (result.read == false) { - throw RepositoryAccessDeniedException( - repositoryId = repositoryId ?: RepositoryId.random(), - reason = result.messages.joinToString("; ").ifBlank { "read access denied" }, - ) - } - // write == false / unprotected main / inconclusive checks are - // loud warnings only — the operator sets branch protection on - // GitHub, and a read-only key is recorded so the UI can prompt. - result.messages.forEach { log.warn("workspace create verify warning [{}]: {}", repoUrl, it) } - } - - private fun persistInitial( - command: CreateWorkspaceCommand, - resolved: ResolvedRepo, - ): Workspace { - val now = Instant.now() - val workspace = - Workspace( - id = command.workspaceId, - name = command.name, - repoUrl = resolved.repoUrl, - branch = resolved.branch, - podName = null, - pvcName = null, - gatewayEndpoint = null, - status = WorkspaceStatus.PENDING, - createdAt = now, - updatedAt = now, - repositoryId = resolved.repositoryId, - projectId = command.projectId, - kind = command.kind, - githubLinkId = resolved.legacyLinkId, - ) - workspaces.save(workspace) - return workspace - } - - private fun seedRepositoryMembership( - workspace: Workspace, - command: CreateWorkspaceCommand, - ) { - val repoId = workspace.repositoryId ?: return - val repoPort = repositories.ifAvailable ?: return - val realPrimaryRepoId = - repoPort - .findById(repoId) - ?.id - ?: return - - workspaceRepositories.attach(workspace.id, realPrimaryRepoId, isPrimary = true) - selectedExtraRepositoryIds(command, realPrimaryRepoId) - .forEach { repositoryId -> - val repository = requireRepository(repoPort, repositoryId) - workspaceRepositories.attach( - workspace.id, - repository.id, - isPrimary = false, - ) - } - } - - private fun selectedExtraRepositoryIds( - command: CreateWorkspaceCommand, - primaryRepoId: RepositoryId, - ): List { - val extras = - if (command.repositoryIds.isNotEmpty()) { - command.repositoryIds.filterNot { it == primaryRepoId } - } else { - command.projectId - ?.let { projectRepositories.findAllByProjectId(it) } - .orEmpty() - .map { it.repositoryId } - .filterNot { it == primaryRepoId } - } - return extras.distinct() - } - - private fun requireRepository( - repos: RepositoryRepository, - repositoryId: RepositoryId, - ) = repos.findById(repositoryId) - ?: throw NoSuchElementException("repository not found: repositoryId=${repositoryId.value}") - - private fun provisionAndUpdate(workspace: Workspace): Workspace { - val handle = orchestrator.provision(workspace) - val withPod = - workspace.withPodInfo( - podName = handle.podName, - pvcName = handle.pvcName, - gatewayEndpoint = handle.gatewayEndpoint, - ) - workspaces.save(withPod) - return withPod - } - - private data class ResolvedRepo( - val repoUrl: String?, - val branch: String?, - val repositoryId: RepositoryId?, - val legacyLinkId: GithubLinkId?, - ) - - private fun resolveRepo(command: CreateWorkspaceCommand): ResolvedRepo { - val primaryRepositoryId = primaryRepositoryId(command) - return when { - command.kind == WorkspaceKind.SCRATCH -> ResolvedRepo(null, null, null, null) - primaryRepositoryId != null -> resolveFromRepository(command, primaryRepositoryId) - command.githubLinkId != null -> resolveFromLegacyLink(command, command.githubLinkId) - else -> ResolvedRepo(command.repoUrl, command.branch, null, null) - } - } - - private fun primaryRepositoryId(command: CreateWorkspaceCommand): RepositoryId? = - command.repositoryId ?: command.repositoryIds.firstOrNull() - - private fun resolveFromRepository( - command: CreateWorkspaceCommand, - repoId: RepositoryId, - ): ResolvedRepo { - // Split the two failure modes apart so the API surfaces a - // distinct status: - // * RepositoryRepository absent → IllegalStateException - // (config issue, 409 Conflict via GlobalExceptionHandler). - // * Row not found → NoSuchElementException (404). - val repos = - repositories.ifAvailable - ?: throw IllegalStateException( - "Repository feature is not configured; cannot create workspace for repositoryId=${repoId.value}", - ) - val repo = - repos.findById(repoId) - ?: throw NoSuchElementException( - "repository not found: repositoryId=${repoId.value}", - ) - val branch = command.branch?.takeIf { it.isNotBlank() } ?: repo.defaultBranch - // legacyLinkId is set ONLY when a real github_links row with - // the same id exists. Setting it unconditionally used to be - // safe under the assumption every repository carried a 1:1 - // github_links mirror, but post-V9 repositories created - // directly (no project link first) don't — the - // `workspaces.github_link_id → github_links.id` FK then - // violates on insert with `DataIntegrityViolationException` - // and the workspace create fails with an opaque 500. - // - // The orchestrator's `resolveKeyMaterial` accepts a null - // linkId (falls back to the cluster-wide deploy key); this - // is the right shape once the per-link Vault path is fully - // retired anyway. - val legacyLinkId = GithubLinkId(repoId.value) - val realLegacyId = - githubLinks - .ifAvailable - ?.findById(legacyLinkId) - ?.let { legacyLinkId } - return ResolvedRepo( - repoUrl = repo.repoUrl, - branch = branch, - repositoryId = repoId, - legacyLinkId = realLegacyId, - ) - } - - private fun resolveFromLegacyLink( - command: CreateWorkspaceCommand, - linkId: GithubLinkId, - ): ResolvedRepo { - val links = - githubLinks.ifAvailable - ?: throw IllegalStateException( - "Projects feature is not configured; cannot create workspace for githubLinkId=${linkId.value}", - ) - val link = - links.findById(linkId) - ?: throw NoSuchElementException( - "github link not found: githubLinkId=${linkId.value}", - ) - val branch = command.branch?.takeIf { it.isNotBlank() } ?: link.defaultBranch - return ResolvedRepo( - repoUrl = link.repoUrl, - branch = branch, - repositoryId = RepositoryId(linkId.value), - legacyLinkId = linkId, - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DeleteRepositoryCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DeleteRepositoryCommand.kt deleted file mode 100644 index fe386632..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DeleteRepositoryCommand.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.common.command.Command - -data class DeleteRepositoryCommand( - val repositoryId: RepositoryId, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DeleteRepositoryCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DeleteRepositoryCommandHandler.kt deleted file mode 100644 index 7004f07a..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DeleteRepositoryCommandHandler.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.beans.factory.ObjectProvider -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional - -/** - * Deletes a repository row. The Vault key is best-effort removed — - * a Vault outage doesn't block the row delete because the row is - * the source of truth for "is this repo still active" and a stale - * Vault entry is invisible without a row to point at it. The - * junction-table rows cascade via the foreign-key ON DELETE CASCADE. - */ -@Component -class DeleteRepositoryCommandHandler( - private val repositories: RepositoryRepository, - private val deployKeysProvider: ObjectProvider, -) : CommandHandler { - @Transactional - override fun handle(command: DeleteRepositoryCommand) { - val repository = repositories.findById(command.repositoryId) ?: return - deployKeysProvider.ifAvailable?.let { ks -> - runCatching { ks.remove(repository.id) } - } - repositories.delete(repository.id) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DestroyWorkspaceCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DestroyWorkspaceCommand.kt deleted file mode 100644 index f63b2af7..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DestroyWorkspaceCommand.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.common.command.Command - -data class DestroyWorkspaceCommand( - val id: WorkspaceId, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DestroyWorkspaceCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DestroyWorkspaceCommandHandler.kt deleted file mode 100644 index f3218634..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DestroyWorkspaceCommandHandler.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional - -@Component -class DestroyWorkspaceCommandHandler( - private val workspaces: WorkspaceRepository, - private val orchestrator: AgentRunnerOrchestrator, -) : CommandHandler { - @Transactional - override fun handle(command: DestroyWorkspaceCommand) { - val workspace = workspaces.findById(command.id) ?: return - orchestrator.destroy(workspace) - workspaces.save(workspace.markDestroyed()) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommand.kt deleted file mode 100644 index 7aed3406..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommand.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.common.command.Command - -/** - * Detach an additional repository from a workspace. The primary repository - * cannot be detached this way — it is owned by the workspace row itself. - */ -data class DetachWorkspaceRepositoryCommand( - val workspaceId: WorkspaceId, - val repositoryId: RepositoryId, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandler.kt deleted file mode 100644 index 82b1b671..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandler.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional - -@Component -class DetachWorkspaceRepositoryCommandHandler( - private val links: WorkspaceRepositoryRepository, -) : CommandHandler { - @Transactional - override fun handle(command: DetachWorkspaceRepositoryCommand) { - val link = - links - .findAllByWorkspaceId(command.workspaceId) - .firstOrNull { it.repositoryId == command.repositoryId } - require(link == null || !link.isPrimary) { - "cannot detach the primary repository from a workspace" - } - links.detach(command.workspaceId, command.repositoryId) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/LinkRepositoryToProjectCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/LinkRepositoryToProjectCommand.kt deleted file mode 100644 index ff2dabbf..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/LinkRepositoryToProjectCommand.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.common.command.Command - -data class LinkRepositoryToProjectCommand( - val projectId: ProjectId, - val repositoryId: RepositoryId, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/LinkRepositoryToProjectCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/LinkRepositoryToProjectCommandHandler.kt deleted file mode 100644 index 8b32592d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/LinkRepositoryToProjectCommandHandler.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional - -@Component -class LinkRepositoryToProjectCommandHandler( - private val projects: ProjectsRepository, - private val repositories: RepositoryRepository, - private val junction: ProjectRepositoryRepository, -) : CommandHandler { - @Transactional - override fun handle(command: LinkRepositoryToProjectCommand) { - projects.findById(command.projectId) - ?: error("project not found: ${command.projectId}") - repositories.findById(command.repositoryId) - ?: error("repository not found: ${command.repositoryId}") - junction.link(command.projectId, command.repositoryId) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/OpenPullRequestCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/OpenPullRequestCommand.kt deleted file mode 100644 index 2f8ca2e7..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/OpenPullRequestCommand.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.common.command.Command - -data class OpenPullRequestCommand( - val workspaceId: WorkspaceId, - val repoDir: String, - val title: String, - val body: String, - val base: String = "main", -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/OpenPullRequestCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/OpenPullRequestCommandHandler.kt deleted file mode 100644 index 99ab7a05..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/OpenPullRequestCommandHandler.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component - -/** - * Thin pass-through to the gateway's /git/open-pr. The interesting - * machinery (deploy key, gh CLI auth, repo dir conventions) lives in - * the gateway; the API layer's job is just to address the right - * workspace and let the result propagate as the command's stored - * URL via a follow-up Turn ingest (Block protocol). - */ -@Component -class OpenPullRequestCommandHandler( - private val workspaces: WorkspaceRepository, - private val gateway: AgentGatewayClient, -) : CommandHandler { - override fun handle(command: OpenPullRequestCommand) { - val workspace = workspaces.findById(command.workspaceId) ?: error("workspace not found: ${command.workspaceId}") - gateway.openPr(workspace, command.repoDir, command.title, command.body, command.base) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/RemoveGithubLinkCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/RemoveGithubLinkCommand.kt deleted file mode 100644 index 9c1f9d12..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/RemoveGithubLinkCommand.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.common.command.Command - -data class RemoveGithubLinkCommand( - val linkId: GithubLinkId, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/RemoveGithubLinkCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/RemoveGithubLinkCommandHandler.kt deleted file mode 100644 index d3eadb45..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/RemoveGithubLinkCommandHandler.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.beans.factory.ObjectProvider -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional - -@Component -class RemoveGithubLinkCommandHandler( - private val links: GithubLinkRepository, - private val deployKeysProvider: ObjectProvider, -) : CommandHandler { - @Transactional - override fun handle(command: RemoveGithubLinkCommand) { - val link = links.findById(command.linkId) ?: return - deployKeysProvider.ifAvailable?.let { ks -> - runCatching { ks.remove(link.projectId, link.id) } - } - links.delete(link.id) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendMessageCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendMessageCommand.kt deleted file mode 100644 index a1821332..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendMessageCommand.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.MessageId -import com.jorisjonkers.personalstack.assistant.domain.model.MessageRole -import com.jorisjonkers.personalstack.common.command.Command - -data class SendMessageCommand( - val messageId: MessageId, - val conversationId: ConversationId, - val userId: String, - val content: String, - val role: MessageRole, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendMessageCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendMessageCommandHandler.kt deleted file mode 100644 index 429b5254..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendMessageCommandHandler.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Message -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import com.jorisjonkers.personalstack.assistant.domain.port.MessageRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import org.springframework.stereotype.Service -import java.time.Instant - -@Service -class SendMessageCommandHandler( - private val conversationRepository: ConversationRepository, - private val messageRepository: MessageRepository, -) : CommandHandler { - override fun handle(command: SendMessageCommand) { - require(command.content.isNotBlank()) { "Message content must not be blank" } - - conversationRepository.findById(command.conversationId) - ?: throw NotFoundException("Conversation", command.conversationId.value.toString()) - - val message = - Message( - id = command.messageId, - conversationId = command.conversationId, - role = command.role, - content = command.content, - createdAt = Instant.now(), - ) - messageRepository.save(message) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendUserInputCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendUserInputCommand.kt deleted file mode 100644 index 1fb1d5ae..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendUserInputCommand.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.common.command.Command - -data class SendUserInputCommand( - val sessionId: WorkspaceAgentSessionId, - val text: String, - val enter: Boolean = true, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendUserInputCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendUserInputCommandHandler.kt deleted file mode 100644 index 595b5cf2..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendUserInputCommandHandler.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.rag.ContextBuilder -import com.jorisjonkers.personalstack.assistant.domain.model.Turn -import com.jorisjonkers.personalstack.assistant.domain.model.TurnId -import com.jorisjonkers.personalstack.assistant.domain.model.TurnRole -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.TurnRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -@Component -class SendUserInputCommandHandler( - private val sessions: WorkspaceAgentSessionRepository, - private val workspaces: WorkspaceRepository, - private val turns: TurnRepository, - private val gateway: AgentGatewayClient, - private val contextBuilder: ContextBuilder, -) : CommandHandler { - @Transactional - override fun handle(command: SendUserInputCommand) { - val session = sessions.findById(command.sessionId) ?: error("session not found: ${command.sessionId}") - val workspace = workspaces.findById(session.workspaceId) ?: error("workspace missing for session") - val gatewayAgentId = session.gatewayAgentId ?: error("session not bound to a gateway agent yet") - - // Persist the raw user prompt for transcript fidelity; the - // RAG augmentation only travels to the agent, not to the - // stored history (otherwise replays would double-inject - // outdated context). - turns.save( - Turn( - id = TurnId.random(), - sessionId = session.id, - role = TurnRole.USER, - body = command.text, - createdAt = Instant.now(), - ), - ) - - val augmented = contextBuilder.augment(command.text) - gateway.sendInput(workspace, gatewayAgentId, augmented, command.enter) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommand.kt deleted file mode 100644 index dbb003f8..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommand.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.common.command.Command - -data class StartAgentSessionCommand( - val sessionId: WorkspaceAgentSessionId, - val workspaceId: WorkspaceId, - val kind: WorkspaceAgentKind, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt deleted file mode 100644 index 94eb7091..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandler.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.exception.AgentRunnerUnavailableException -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import org.springframework.web.client.ResourceAccessException -import java.time.Instant - -/** - * Three failure modes are surfaced as a typed 503 instead of an opaque - * 500 / 502: - * - * 1. The workspace's runner Pod is not ready yet — `isReady()` polls - * the runner's `/healthz` and reports a transport-level failure - * before we attempt the real `POST /agents`. Throws - * [AgentRunnerUnavailableException] with `runnerStatus="NotReady"`. - * 2. The runner's Service has Ready endpoints but the spawn HTTP call - * still gets `Connection refused` — a race between Endpoints - * publish and the runner's HTTP listener binding. The handler - * retries the spawn a bounded number of times with backoff before - * surfacing `runnerStatus="ConnectionRefused"`. - * 3. The runner Pod is gone (evicted / deleted) or wedged in - * CrashLoopBackOff — typically a workspace created against an older - * agent-runner image. Here a `/healthz` poll alone never recovers: - * no Pod will ever answer. The handler re-provisions the runner - * (an idempotent ensure that lands a fresh Pod pulling the current - * `:latest`), re-points the workspace's `gatewayEndpoint`/`podName`, - * and only then re-gates readiness. The 503 is surfaced solely when - * re-provision itself fails or the fresh Pod still hasn't passed - * `/healthz` inside the retry budget. - * - * The 503 carries a `Retry-After` header (via - * [AgentRunnerUnavailableExceptionHandler]) so well-behaved clients - * back off automatically; the UI consumes the `runnerStatus` / - * `retryAfterSeconds` ProblemDetail extensions to render a useful - * inline message instead of a top-right "Internal Server Error" toast. - */ -@Component -class StartAgentSessionCommandHandler( - private val workspaces: WorkspaceRepository, - private val sessions: WorkspaceAgentSessionRepository, - private val gateway: AgentGatewayClient, - private val orchestrator: AgentRunnerOrchestrator, - /** - * Initial backoff between spawn retries. The default matches a - * production-tuned 1 s; tests pass `0` so the suite doesn't - * burn 6 s on the worst-case retry scenario. - */ - private val backoffInitialMs: Long = BACKOFF_INITIAL_MS, -) : CommandHandler { - private val log = LoggerFactory.getLogger(StartAgentSessionCommandHandler::class.java) - - @Transactional - override fun handle(command: StartAgentSessionCommand) { - val workspace = - workspaces.findById(command.workspaceId) - ?: throw NoSuchElementException("workspace not found: ${command.workspaceId.value}") - - val healthy = ensureRunnerReady(workspace) - - val now = Instant.now() - val session = - WorkspaceAgentSession( - id = command.sessionId, - workspaceId = healthy.id, - kind = command.kind, - gatewayAgentId = null, - status = WorkspaceAgentSessionStatus.STARTING, - createdAt = now, - updatedAt = now, - ) - sessions.save(session) - - val gatewayAgent = spawnAgentWithRetry(healthy, command) - sessions.save(session.bindGatewayAgent(gatewayAgent.id, gatewayAgent.cliSessionId)) - } - - /** - * Pre-flight the `/healthz` probe before touching the spawn - * endpoint so a not-yet-ready runner surfaces as a clean 503 - * rather than a `ResourceAccessException` deep in the RestClient - * stack. - * - * When the runner answers `/healthz` it is left untouched — a - * healthy Pod is never destroyed. When it does not, the Pod is - * either still warming up or it is missing / crash-looping; the - * latter never self-heals on its own, so the runner is - * re-provisioned (destroy + provision lands a fresh Pod on the - * current image), the workspace is re-pointed at the new - * Pod/endpoint, and readiness is re-gated against the fresh Pod. - * - * @return the workspace to spawn against — the same instance when - * the runner was already healthy, or the re-pointed copy after a - * successful re-provision. - */ - private fun ensureRunnerReady(workspace: Workspace): Workspace { - if (gateway.isReady(workspace)) return workspace - log.info( - "runner for workspace {} (pod {}) did not answer /healthz — re-provisioning a fresh Pod", - workspace.id.value, - workspace.podName, - ) - val reprovisioned = reprovision(workspace) - gateRunnerReadinessWithRetry(reprovisioned) - return reprovisioned - } - - /** - * Scale down any stale Pod/Service then provision a fresh one. - * The workspace PVC is preserved so /workspace contents survive the - * re-provision. `scaleDown` is idempotent (a 404 on a missing Pod - * is a no-op). A failure to provision is the only thing that turns - * into a 503 here — the caller can retry. - */ - private fun reprovision(workspace: Workspace): Workspace { - val handle = - runCatching { - orchestrator.scaleDown(workspace) - orchestrator.provision(workspace) - }.getOrElse { ex -> - throw AgentRunnerUnavailableException( - workspaceId = workspace.id, - runnerStatus = "ReprovisionFailed", - cause = ex, - ) - } - val repointed = - workspace.withPodInfo( - podName = handle.podName, - pvcName = handle.pvcName, - gatewayEndpoint = handle.gatewayEndpoint, - ) - workspaces.save(repointed) - log.info("re-provisioned runner for workspace {} as pod {}", workspace.id.value, handle.podName) - return repointed - } - - /** - * Poll `/healthz` against the freshly provisioned Pod within the - * spawn retry budget. A new Pod takes a few seconds to schedule, - * pull, and pass its readiness probe; the same bounded backoff the - * spawn path uses keeps the total wait inside the existing budget - * before surfacing a `NotReady` 503. - */ - private fun gateRunnerReadinessWithRetry(workspace: Workspace) { - repeat(MAX_SPAWN_ATTEMPTS) { attempt -> - if (gateway.isReady(workspace)) return - if (attempt < MAX_SPAWN_ATTEMPTS - 1) { - val sleepMs = backoffInitialMs * (attempt + 1) - if (sleepMs > 0) Thread.sleep(sleepMs) - } - } - throw AgentRunnerUnavailableException( - workspaceId = workspace.id, - runnerStatus = "NotReady", - ) - } - - /** - * Retry the spawn on `ResourceAccessException` (the Spring - * RestClient wrapping of any transport-level failure: socket - * refused, read timeout, …). Each attempt sleeps an increasing - * amount; after [MAX_SPAWN_ATTEMPTS] exhausted attempts the - * caller gets a 503 instead of a 500. - */ - private fun spawnAgentWithRetry( - workspace: Workspace, - command: StartAgentSessionCommand, - ): AgentGatewayClient.GatewayAgent { - var lastFailure: ResourceAccessException? = null - repeat(MAX_SPAWN_ATTEMPTS) { attempt -> - try { - return gateway.spawnAgent(workspace, command.kind) - } catch (ex: ResourceAccessException) { - lastFailure = ex - val sleepMs = backoffInitialMs * (attempt + 1) - log.warn( - "agent spawn attempt {} for workspace {} failed: {} — retrying in {}ms", - attempt + 1, - workspace.id.value, - ex.message, - sleepMs, - ) - if (sleepMs > 0) Thread.sleep(sleepMs) - } - } - throw AgentRunnerUnavailableException( - workspaceId = workspace.id, - runnerStatus = "ConnectionRefused", - retryAfterSeconds = AgentRunnerUnavailableException.DEFAULT_RETRY_AFTER_SECONDS, - cause = lastFailure, - ) - } - - companion object { - const val MAX_SPAWN_ATTEMPTS: Int = 3 - const val BACKOFF_INITIAL_MS: Long = 1_000 - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartChatSessionCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartChatSessionCommand.kt deleted file mode 100644 index 578bcb82..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartChatSessionCommand.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.common.command.Command -import java.util.UUID - -data class StartChatSessionCommand( - val sessionId: ChatSessionId, - val userId: UUID, - val title: String? = null, - val kind: ChatSessionKind = ChatSessionKind.PLAIN, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartChatSessionCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartChatSessionCommandHandler.kt deleted file mode 100644 index 4613b43c..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartChatSessionCommandHandler.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -/** - * Spawns a no-Pod chat session for a user. Returns nothing; the - * caller passes the freshly generated id in so a follow-up read can - * locate the new row. - */ -@Component -class StartChatSessionCommandHandler( - private val sessions: ChatSessionRepository, -) : CommandHandler { - @Transactional - override fun handle(command: StartChatSessionCommand) { - val now = Instant.now() - val title = command.title?.trim()?.takeIf { it.isNotEmpty() } - sessions.save( - ChatSession( - id = command.sessionId, - userId = command.userId, - title = title, - status = ChatSessionStatus.ACTIVE, - kind = command.kind, - createdAt = now, - updatedAt = now, - ), - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartConversationCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartConversationCommand.kt deleted file mode 100644 index 60c635bd..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartConversationCommand.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.common.command.Command -import java.util.UUID - -data class StartConversationCommand( - val conversationId: ConversationId, - val userId: UUID, - val title: String, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartConversationCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartConversationCommandHandler.kt deleted file mode 100644 index 6d9920de..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartConversationCommandHandler.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.event.ConversationStartedEvent -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.context.ApplicationEventPublisher -import org.springframework.stereotype.Service -import java.time.Instant - -@Service -class StartConversationCommandHandler( - private val conversationRepository: ConversationRepository, - private val eventPublisher: ApplicationEventPublisher, -) : CommandHandler { - override fun handle(command: StartConversationCommand) { - require(command.title.isNotBlank()) { "Title must not be blank" } - - val now = Instant.now() - val conversation = - Conversation( - id = command.conversationId, - userId = command.userId, - title = command.title.trim(), - status = ConversationStatus.ACTIVE, - createdAt = now, - updatedAt = now, - ) - conversationRepository.save(conversation) - - eventPublisher.publishEvent( - ConversationStartedEvent( - conversationId = conversation.id, - userId = conversation.userId, - ), - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartHeadlessJobCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartHeadlessJobCommand.kt deleted file mode 100644 index b0edaf80..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartHeadlessJobCommand.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.common.command.Command - -data class StartHeadlessJobCommand( - val sessionId: WorkspaceAgentSessionId, - val workspaceId: WorkspaceId, - val kind: WorkspaceAgentKind, - val prompt: String, - val timeoutSeconds: Long? = null, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartHeadlessJobCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartHeadlessJobCommandHandler.kt deleted file mode 100644 index aea85f15..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartHeadlessJobCommandHandler.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.exception.AgentRunnerUnavailableException -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -/** - * Provisions the runner if needed, submits a one-shot headless job to - * the gateway, and persists a [WorkspaceAgentSession] to track it. - * - * Idle sweep protection (skipping workspaces with running headless jobs) - * depends on N3's `run_mode` column — activate after N3 merges. - */ -@Component -class StartHeadlessJobCommandHandler( - private val workspaces: WorkspaceRepository, - private val sessions: WorkspaceAgentSessionRepository, - private val gateway: AgentGatewayClient, - private val orchestrator: AgentRunnerOrchestrator, -) : CommandHandler { - private val log = LoggerFactory.getLogger(StartHeadlessJobCommandHandler::class.java) - - @Transactional - override fun handle(command: StartHeadlessJobCommand) { - val workspace = - workspaces.findById(command.workspaceId) - ?: throw NoSuchElementException("workspace not found: ${command.workspaceId.value}") - val healthy = ensureRunnerReady(workspace) - val job = launchJob(healthy, command) - persistSession(command, healthy, job) - log.info("headless job {} launched for workspace {}", job.id, workspace.id.value) - } - - private fun launchJob( - workspace: Workspace, - command: StartHeadlessJobCommand, - ): AgentGatewayClient.HeadlessJob = - runCatching { - gateway.startHeadlessJob( - workspace = workspace, - kind = command.kind, - prompt = command.prompt, - cliSessionId = null, - timeoutSeconds = command.timeoutSeconds, - ) - }.getOrElse { ex -> - throw AgentRunnerUnavailableException( - workspaceId = workspace.id, - runnerStatus = "HeadlessLaunchFailed", - cause = ex, - ) - } - - private fun persistSession( - command: StartHeadlessJobCommand, - workspace: Workspace, - job: AgentGatewayClient.HeadlessJob, - ) { - val now = Instant.now() - sessions.save( - WorkspaceAgentSession( - id = command.sessionId, - workspaceId = workspace.id, - kind = command.kind, - gatewayAgentId = job.id, - status = WorkspaceAgentSessionStatus.RUNNING, - createdAt = now, - updatedAt = now, - ), - ) - } - - @Suppress("ThrowsCount", "ReturnCount") - private fun ensureRunnerReady(workspace: Workspace): Workspace { - if (gateway.isReady(workspace)) return workspace - log.info("runner for workspace {} not ready — provisioning for headless", workspace.id.value) - return runCatching { reprovisionAndWait(workspace) } - .getOrElse { ex -> - if (ex is AgentRunnerUnavailableException) throw ex - throw AgentRunnerUnavailableException( - workspaceId = workspace.id, - runnerStatus = "ReprovisionFailed", - cause = ex, - ) - } - } - - private fun reprovisionAndWait(workspace: Workspace): Workspace { - orchestrator.destroy(workspace) - val handle = orchestrator.provision(workspace) - val repointed = - workspace.withPodInfo( - podName = handle.podName, - pvcName = handle.pvcName, - gatewayEndpoint = handle.gatewayEndpoint, - ) - workspaces.save(repointed) - repeat(READINESS_ATTEMPTS) { - if (gateway.isReady(repointed)) return repointed - Thread.sleep(READINESS_POLL_MS) - } - throw AgentRunnerUnavailableException( - workspaceId = workspace.id, - runnerStatus = "NotReady", - ) - } - - companion object { - private const val READINESS_ATTEMPTS = 30 - private const val READINESS_POLL_MS = 5_000L - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StopAgentSessionCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StopAgentSessionCommand.kt deleted file mode 100644 index cd465f9d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StopAgentSessionCommand.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.common.command.Command - -data class StopAgentSessionCommand( - val sessionId: WorkspaceAgentSessionId, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StopAgentSessionCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StopAgentSessionCommandHandler.kt deleted file mode 100644 index 69f49ccf..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StopAgentSessionCommandHandler.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.rag.LessonAutoCapture -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional - -@Component -class StopAgentSessionCommandHandler( - private val workspaces: WorkspaceRepository, - private val sessions: WorkspaceAgentSessionRepository, - private val gateway: AgentGatewayClient, - private val autoCapture: LessonAutoCapture, -) : CommandHandler { - @Transactional - override fun handle(command: StopAgentSessionCommand) { - val session = sessions.findById(command.sessionId) ?: return - val workspace = - workspaces.findById(session.workspaceId) - ?: error("workspace ${session.workspaceId} missing for session ${session.id}") - val gatewayId = session.gatewayAgentId - if (gatewayId != null) { - runCatching { gateway.stopAgent(workspace, gatewayId) } - } - sessions.save(session.markStopped()) - // The stop command is a definitive "this session is done" - // signal — flush a final auto-capture pass against the - // full transcript on the way out. - runCatching { autoCapture.capture(session.id) } - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/UnlinkRepositoryFromProjectCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/UnlinkRepositoryFromProjectCommand.kt deleted file mode 100644 index 796e91c0..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/UnlinkRepositoryFromProjectCommand.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.common.command.Command - -data class UnlinkRepositoryFromProjectCommand( - val projectId: ProjectId, - val repositoryId: RepositoryId, -) : Command diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/UnlinkRepositoryFromProjectCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/UnlinkRepositoryFromProjectCommandHandler.kt deleted file mode 100644 index cb3d8404..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/UnlinkRepositoryFromProjectCommandHandler.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional - -@Component -class UnlinkRepositoryFromProjectCommandHandler( - private val junction: ProjectRepositoryRepository, -) : CommandHandler { - @Transactional - override fun handle(command: UnlinkRepositoryFromProjectCommand) { - junction.unlink(command.projectId, command.repositoryId) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/exception/AgentRunnerUnavailableException.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/exception/AgentRunnerUnavailableException.kt deleted file mode 100644 index f147865d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/exception/AgentRunnerUnavailableException.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.exception - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId - -/** - * The agent-runner Pod for a workspace is not reachable yet. - * - * Thrown by [StartAgentSessionCommandHandler] (or by retry logic in - * [HttpAgentGatewayClient]) when the runner's HTTP listener returns - * `Connection refused`, or when the Service has no Ready endpoints - * because the Pod is still scheduling / pulling its image. Mapped - * by [AgentRunnerUnavailableExceptionHandler] to a 503 ProblemDetail - * carrying a `Retry-After` header and the structured - * `runnerStatus` / `retryAfterSeconds` extensions so the UI can - * render a "runner not ready, retry in 5s" message inline. - * - * `runnerStatus` is a short, machine-friendly label of *why* the - * runner is unavailable. The current vocabulary: - * - * * `Pending` — the Pod has been created but isn't yet - * scheduled / hasn't pulled its image. - * * `NotReady` — the Pod is Running but its readinessProbe - * hasn't passed (the Service has no Ready endpoints). - * * `ConnectionRefused` — the Service has Ready endpoints but a - * POST to `/agents` still gets EOF / Connection refused (race - * between Endpoints publish and the HTTP listener binding). - * * `Unknown` — fallback when the readiness probe wasn't - * reachable to classify the failure. - */ -class AgentRunnerUnavailableException( - val workspaceId: WorkspaceId, - val runnerStatus: String, - val retryAfterSeconds: Int = DEFAULT_RETRY_AFTER_SECONDS, - cause: Throwable? = null, -) : RuntimeException( - buildMessage(workspaceId, runnerStatus), - cause, - ) { - companion object { - const val DEFAULT_RETRY_AFTER_SECONDS: Int = 5 - - private fun buildMessage( - workspaceId: WorkspaceId, - runnerStatus: String, - ): String = "agent-runner for workspace ${workspaceId.value} is unavailable (status=$runnerStatus)" - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/exception/RepositoryAccessDeniedException.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/exception/RepositoryAccessDeniedException.kt deleted file mode 100644 index b9437bff..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/exception/RepositoryAccessDeniedException.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.exception - -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId - -/** - * The deploy key for a repo-backed workspace cannot read its - * repository, so creating the workspace would leave a runner that can - * never clone. Thrown by [CreateWorkspaceCommandHandler] when the - * gateway verify reports `read == false`; mapped to a 422 so the UI - * surfaces a fix-the-key message instead of a generic 500. - * - * `write == false` and an unprotected default branch are NOT fatal — - * those degrade to recorded warnings, since the chosen posture is - * read-write + protected main and an unprotected main is an operator - * concern fixed on GitHub, not a reason to block workspace creation. - */ -class RepositoryAccessDeniedException( - val repositoryId: RepositoryId, - val reason: String, -) : RuntimeException("deploy key cannot read repository ${repositoryId.value}: $reason") diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/ConnectedClientTracker.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/ConnectedClientTracker.kt deleted file mode 100644 index 1e44fa92..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/ConnectedClientTracker.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.idle - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import org.springframework.stereotype.Component -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger - -/** - * Counts open browser WebSocket connections per workspace. The idle - * sweep consults this tracker to guarantee a workspace with a live - * client is never scaled to zero while the user is watching — even if - * they are not actively typing (no [WorkspaceActivityTracker.touch] - * from keystrokes). - * - * Attach/detach are called by [com.jorisjonkers.personalstack.assistant.infrastructure.ws.SessionAttachHandler] - * on WebSocket open and close respectively. Thread-safe via atomic - * reference counting; the count never goes below zero. - */ -@Component -class ConnectedClientTracker { - private val counts = ConcurrentHashMap() - - fun attach(workspaceId: WorkspaceId) { - counts.computeIfAbsent(workspaceId) { AtomicInteger(0) }.incrementAndGet() - } - - fun detach(workspaceId: WorkspaceId) { - counts.computeIfPresent(workspaceId) { _, counter -> - if (counter.decrementAndGet() <= 0) null else counter - } - } - - fun isConnected(workspaceId: WorkspaceId): Boolean = (counts[workspaceId]?.get() ?: 0) > 0 -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownScheduler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownScheduler.kt deleted file mode 100644 index c6c58622..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownScheduler.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.idle - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Component -import java.time.Clock -import java.time.Duration -import java.time.Instant - -/** - * Periodic sweep: any READY workspace that is both idle long enough - * and has no connected browser clients gets its Pod + Service torn - * down via [AgentRunnerOrchestrator.scaleDown]. - * - * Scale-down is suppressed when: - * - A browser WebSocket is open ([ConnectedClientTracker.isConnected]). - * - The workspace has RUNNING agent sessions and their last activity - * is within `agent-runtime.agent-idle-after-seconds` (default 4 h). - * This protects long-running autonomous agent tasks after the user - * disconnects — the idle timer resets while the AI streams output. - * Once the AI goes quiet the longer grace period begins. - * - * Defaults: 30 min for sessions-idle, 4 h for agent-running, sweep - * every 5 min. All overridable via env. - */ -@Component -class IdleScaleDownScheduler( - private val workspaces: WorkspaceRepository, - private val agentSessions: WorkspaceAgentSessionRepository, - private val orchestrator: AgentRunnerOrchestrator, - private val tracker: WorkspaceActivityTracker, - private val connected: ConnectedClientTracker, - private val clock: Clock = Clock.systemUTC(), - @param:Value("\${agent-runtime.idle-after-seconds:1800}") - private val idleAfterSeconds: Long, - @param:Value("\${agent-runtime.agent-idle-after-seconds:14400}") - private val agentIdleAfterSeconds: Long, -) { - private val log = LoggerFactory.getLogger(IdleScaleDownScheduler::class.java) - - @Scheduled(fixedDelayString = "\${agent-runtime.idle-sweep-period-ms:300000}") - fun sweep() { - val candidates = - workspaces - .findAllByStatusNot(WorkspaceStatus.DESTROYED) - .filter { it.status == WorkspaceStatus.READY && isEligibleForScaleDown(it) } - candidates.forEach { scaleDown(it) } - if (candidates.isNotEmpty()) log.info("idle-sweep scaled down {} workspace(s)", candidates.size) - } - - private fun isEligibleForScaleDown(workspace: Workspace): Boolean { - if (connected.isConnected(workspace.id)) return false - val lastSeen = effectiveLastSeen(workspace) - val hasRunning = - agentSessions - .findAllByWorkspaceId(workspace.id) - .any { it.status == WorkspaceAgentSessionStatus.RUNNING } - val threshold = - Duration.ofSeconds(if (hasRunning) agentIdleAfterSeconds else idleAfterSeconds) - return !lastSeen.isAfter(clock.instant().minus(threshold)) - } - - private fun effectiveLastSeen(workspace: Workspace): Instant = tracker.lastSeen(workspace.id) ?: workspace.updatedAt - - private fun scaleDown(workspace: Workspace) { - runCatching { orchestrator.scaleDown(workspace) } - .onFailure { - log.warn("scale-down of {} failed: {}", workspace.id, it.message) - return - } - workspaces.save( - workspace.copy( - status = WorkspaceStatus.IDLE, - podName = null, - gatewayEndpoint = null, - updatedAt = clock.instant(), - ), - ) - tracker.forget(workspace.id) - log.info("workspace {} idle-scaled to zero (last seen {})", workspace.id, effectiveLastSeen(workspace)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/WorkspaceActivityTracker.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/WorkspaceActivityTracker.kt deleted file mode 100644 index 60ff6558..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/WorkspaceActivityTracker.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.idle - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import org.springframework.stereotype.Component -import java.time.Clock -import java.time.Instant -import java.util.concurrent.ConcurrentHashMap - -/** - * In-memory "last seen" tracker for workspace activity. Anything - * that proves a workspace is live — a WS attach, a Turn save, a - * user-input REST call — records here. The IdleScaleDownScheduler - * then compares the timestamps against an `idleAfter` threshold - * and tears down workspaces whose last-seen is older than that. - * - * Lives in `application/idle/` rather than `infrastructure/` because - * the policy ("idle = no signals for N minutes") is a domain rule. - * The actual signal sources are infrastructure adapters that touch - * `touch(workspaceId)` as they observe activity. - */ -@Component -class WorkspaceActivityTracker( - private val clock: Clock = Clock.systemUTC(), -) { - private val lastSeen = ConcurrentHashMap() - - fun touch(workspaceId: WorkspaceId) { - lastSeen[workspaceId] = clock.instant() - } - - fun lastSeen(workspaceId: WorkspaceId): Instant? = lastSeen[workspaceId] - - fun forget(workspaceId: WorkspaceId) { - lastSeen.remove(workspaceId) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt deleted file mode 100644 index 590f0bcb..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceService.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.maintenance - -import com.jorisjonkers.personalstack.assistant.application.idle.WorkspaceActivityTracker -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import java.time.Clock - -/** - * Bulk maintenance operations on runner Pods. Unlike [IdleScaleDownScheduler], - * which acts on a time threshold, these operations are admin-triggered - * and act on all active workspaces immediately. - * - * Primary use cases: - * - **Rolling restart**: after pushing a new agent-runner image, call - * [gracefulScaleDownAll] to tear down every Pod. The next session - * start on each workspace will re-provision via [AgentRunnerOrchestrator.provision], - * pulling `:latest` automatically. The workspace PVC is preserved, - * but a later "new session" starts a fresh agent process instead - * of resuming a prior native CLI conversation. - * - **Pre-node-drain**: before draining the runner node, call - * [gracefulScaleDownAll] so Pods are stopped cleanly rather than - * evicted. After the node returns, workspaces re-provision on demand. - */ -@Component -class RunnerMaintenanceService( - private val workspaces: WorkspaceRepository, - private val orchestrator: AgentRunnerOrchestrator, - private val tracker: WorkspaceActivityTracker, - private val clock: Clock = Clock.systemUTC(), -) { - private val log = LoggerFactory.getLogger(RunnerMaintenanceService::class.java) - - data class MaintenanceResult( - val cycled: Int, - val workspaceIds: List, - ) - - private val activeStatuses = setOf(WorkspaceStatus.READY, WorkspaceStatus.STARTING, WorkspaceStatus.FAILED) - - /** - * Scale down every workspace whose Pod is (or should be) running. - * Each workspace transitions to [WorkspaceStatus.IDLE] with its PVC - * preserved so the next [AgentRunnerOrchestrator.provision] re-attaches - * the same disk and CLI history. - * - * Scale-down failures are logged and skipped — the orchestrator's - * [AgentRunnerOrchestrator.scaleDown] treats a missing Pod as a no-op, - * so the only real failure is an unreachable k8s API server. - */ - fun gracefulScaleDownAll(): MaintenanceResult { - val candidates = - workspaces - .findAllByStatusNot(WorkspaceStatus.DESTROYED) - .filter { it.status in activeStatuses } - val cycled = candidates.mapNotNull { scaleDownToIdle(it) } - if (cycled.isNotEmpty()) { - log.info("maintenance: graceful scale-down cycled {} workspace(s)", cycled.size) - } - return MaintenanceResult( - cycled = cycled.size, - workspaceIds = cycled, - ) - } - - /** Returns the workspace id string on success, null when scaleDown failed. */ - private fun scaleDownToIdle(workspace: Workspace): String? { - runCatching { orchestrator.scaleDown(workspace) } - .onFailure { - log.warn("maintenance: scale-down of {} failed: {} — skipping", workspace.id, it.message) - return null - } - workspaces.save( - workspace.copy( - status = WorkspaceStatus.IDLE, - podName = null, - gatewayEndpoint = null, - updatedAt = clock.instant(), - ), - ) - tracker.forget(workspace.id) - log.info("maintenance: workspace {} scaled to zero (was {})", workspace.id, workspace.status) - return workspace.id.value.toString() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/ChatSessionQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/ChatSessionQueryService.kt deleted file mode 100644 index 2a7f306a..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/ChatSessionQueryService.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessage -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import org.springframework.stereotype.Service -import java.util.UUID - -@Service -class ChatSessionQueryService( - private val sessions: ChatSessionRepository, - private val messages: ChatMessageRepository, -) { - data class ChatSessionDetail( - val session: ChatSession, - val messages: List, - ) - - fun list(userId: UUID): List = sessions.findAllByUserId(userId) - - fun get(id: ChatSessionId): ChatSessionDetail? { - val session = sessions.findById(id) ?: return null - return ChatSessionDetail(session, messages.findAllBySessionIdOrderedByTime(id)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetConversationQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetConversationQueryService.kt deleted file mode 100644 index 54f311c1..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetConversationQueryService.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import org.springframework.stereotype.Service -import java.util.UUID - -@Service -class GetConversationQueryService( - private val conversationRepository: ConversationRepository, -) { - fun findById(id: ConversationId): Conversation = - conversationRepository.findById(id) - ?: throw NotFoundException("Conversation", id.value.toString()) - - fun findByUserId(userId: UUID): List = conversationRepository.findByUserId(userId) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetMessageQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetMessageQueryService.kt deleted file mode 100644 index 48a96e58..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetMessageQueryService.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.Message -import com.jorisjonkers.personalstack.assistant.domain.port.MessageRepository -import org.springframework.stereotype.Service - -@Service -class GetMessageQueryService( - private val messageRepository: MessageRepository, -) { - fun findByConversationId(conversationId: ConversationId): List = - messageRepository.findByConversationId(conversationId) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetTurnHistoryQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetTurnHistoryQueryService.kt deleted file mode 100644 index d6af63a9..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetTurnHistoryQueryService.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.Turn -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.port.TurnRepository -import org.springframework.stereotype.Service - -@Service -class GetTurnHistoryQueryService( - private val turns: TurnRepository, -) { - fun history( - sessionId: WorkspaceAgentSessionId, - limit: Int = 200, - ): List = turns.findBySessionId(sessionId, limit) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt deleted file mode 100644 index ac01f64a..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import org.springframework.stereotype.Service -import java.time.Instant - -@Service -class GetWorkspaceQueryService( - private val workspaces: WorkspaceRepository, - private val sessions: WorkspaceAgentSessionRepository, - private val workspaceRepositories: WorkspaceRepositoryRepository, - private val repositories: RepositoryRepository, -) { - data class WorkspaceView( - val workspace: Workspace, - val sessions: List, - val repositories: List, - ) - - data class WorkspaceRepositoryView( - val repository: Repository, - val isPrimary: Boolean, - val attachedAt: Instant, - ) - - fun getSummary(id: WorkspaceId): Workspace? = workspaces.findById(id) - - fun get(id: WorkspaceId): WorkspaceView? { - val workspace = workspaces.findById(id) ?: return null - val links = repositoryLinks(workspace) - val resolvedRepositories = - links.map { link -> - val repository = - repositories.findById(link.repositoryId) - ?: throw IllegalStateException( - "Workspace ${id.value} references missing repository ${link.repositoryId.value}", - ) - WorkspaceRepositoryView(repository, link.isPrimary, link.attachedAt) - } - return WorkspaceView(workspace, sessions.findAllByWorkspaceId(id), resolvedRepositories) - } - - private fun repositoryLinks(workspace: Workspace): List { - val links = workspaceRepositories.findAllByWorkspaceId(workspace.id) - val primaryRepositoryId = workspace.repositoryId ?: return links - if (links.any { it.repositoryId == primaryRepositoryId }) return links - val primaryLink = - WorkspaceRepositoryRepository.Link( - workspaceId = workspace.id, - repositoryId = primaryRepositoryId, - isPrimary = true, - attachedAt = workspace.createdAt, - ) - return listOf(primaryLink) + links - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/ListWorkspacesQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/ListWorkspacesQueryService.kt deleted file mode 100644 index 2a2957e1..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/ListWorkspacesQueryService.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import org.springframework.stereotype.Service - -@Service -class ListWorkspacesQueryService( - private val repo: WorkspaceRepository, -) { - fun listActive(): List = repo.findAllByStatusNot(WorkspaceStatus.DESTROYED) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/ProjectQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/ProjectQueryService.kt deleted file mode 100644 index 06507756..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/ProjectQueryService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import org.springframework.stereotype.Service - -@Service -class ProjectQueryService( - private val projects: ProjectsRepository, - private val links: GithubLinkRepository, -) { - data class ProjectDetail( - val project: Project, - val links: List, - ) - - fun list(): List = projects.findAll() - - fun get(id: ProjectId): ProjectDetail? { - val project = projects.findById(id) ?: return null - return ProjectDetail(project, links.findAllByProjectId(id)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/RepositoryQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/RepositoryQueryService.kt deleted file mode 100644 index fe4156f9..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/RepositoryQueryService.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import org.springframework.stereotype.Service - -@Service -class RepositoryQueryService( - private val repositories: RepositoryRepository, - private val junction: ProjectRepositoryRepository, - private val projects: ProjectsRepository, -) { - data class RepositoryDetail( - val repository: Repository, - val attachedProjects: List, - ) - - fun list(): List = repositories.findAll() - - fun listByProject(projectId: com.jorisjonkers.personalstack.assistant.domain.model.ProjectId): List = - repositories.findAllByProjectId(projectId) - - fun get(id: RepositoryId): RepositoryDetail? { - val repository = repositories.findById(id) ?: return null - val attached = - junction - .findAllByRepositoryId(id) - .mapNotNull { projects.findById(it.projectId) } - return RepositoryDetail(repository, attached) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ContextBuilder.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ContextBuilder.kt deleted file mode 100644 index ad185abf..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ContextBuilder.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import com.jorisjonkers.personalstack.assistant.config.RagProperties -import com.jorisjonkers.personalstack.assistant.domain.port.RetrievalPort -import io.micrometer.core.instrument.MeterRegistry -import org.springframework.stereotype.Component - -/** - * Aggregates retrieval results from every RetrievalPort bean, - * dedupes by note id (when present) then by text, filters below the - * score floor, ranks by score, and formats into a single - * `...` envelope that SendUserInputCommandHandler - * prepends to the agent's input. - * - * No envelope is emitted when no hit clears the score floor or when - * the character budget is exhausted before the first chunk lands. - * - * Why a single envelope rather than per-source headers: every CLI - * we plug in has its own quirks for parsing user input. A single - * fenced region with consistent markers is the cheapest "ignore if - * you don't understand it" surface. - */ -@Component -class ContextBuilder( - private val sources: List, - private val props: RagProperties, - registry: MeterRegistry, -) { - private val injectedHits = registry.counter("rag.hits.injected") - private val injectedChars = registry.counter("rag.chars.injected") - - fun augment(userPrompt: String): String { - if (!props.enabled || sources.isEmpty()) return userPrompt - val chunks = buildChunks(dedupedAndFiltered(userPrompt)) - // Empty when all snippets failed the score floor, the merged list was - // empty, or the character budget was too tight for even the first chunk. - if (chunks.isEmpty()) return userPrompt - val usedChars = chunks.sumOf { it.length } - injectedHits.increment(chunks.size.toDouble()) - injectedChars.increment(usedChars.toDouble()) - return buildEnvelope(chunks) + userPrompt - } - - private fun buildChunks(merged: List): List { - var usedChars = 0 - val result = mutableListOf() - for (s in merged) { - val chunk = "[${s.source}] ${s.text.take(800)}\n" - if (usedChars + chunk.length > props.maxContextChars) break - result += chunk - usedChars += chunk.length - } - return result - } - - private fun buildEnvelope(chunks: List): String = - buildString { - append("\n") - chunks.forEach { append(it) } - append("\n\n") - } - - private fun dedupedAndFiltered(query: String): List { - val seenIds = mutableSetOf() - val seenTexts = mutableSetOf() - return sources - .flatMap { it.retrieve(query, props.maxSnippets) } - .filter { it.score >= props.minScore } - .sortedByDescending { it.score } - .filter { s -> - if (s.id != null) seenIds.add(s.id) else seenTexts.add(s.text.trim()) - }.take(props.maxSnippets) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCapture.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCapture.kt deleted file mode 100644 index 71d270de..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCapture.kt +++ /dev/null @@ -1,192 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import com.jorisjonkers.personalstack.assistant.config.RagProperties -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.port.KnowledgeWritePort -import com.jorisjonkers.personalstack.assistant.domain.port.TurnRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import org.slf4j.LoggerFactory -import org.springframework.scheduling.annotation.Async -import org.springframework.stereotype.Component -import java.time.Duration -import java.util.Locale - -/** - * Orchestrates the auto-capture pipeline: - * - * sessions ─► turns ─► LessonExtractor ─► TokenBucket gate - * ─► KnowledgeWritePort.ingestNote - * - * `capture(sessionId)` is the public entrypoint and is `@Async` so - * the WS handler's hot path stays out of the LLM/MCP round trip. - * The token bucket defaults to capacity = 3 with a 15-minute refill, - * so a single session can't flood the KB even if every turn is - * marker-flagged. Dedupe runs before token consumption so skipped - * candidates do not burn the write budget. - */ -@Component -open class LessonAutoCapture( - private val workspaces: WorkspaceRepository, - private val sessions: WorkspaceAgentSessionRepository, - private val turns: TurnRepository, - private val extractor: LessonExtractor, - private val knowledgeWrite: KnowledgeWritePort, - private val rag: RagProperties, -) { - private val log = LoggerFactory.getLogger(LessonAutoCapture::class.java) - private val bucket = - TokenBucket( - capacity = rag.autoCaptureSessionCapacity, - refillInterval = Duration.ofMinutes(rag.autoCaptureBucketRefillMinutes), - ) - - @Async - open fun capture(sessionId: WorkspaceAgentSessionId) { - if (!rag.enabled) return - val resolved = resolveSession(sessionId) ?: return - val history = turns.findBySessionId(sessionId, limit = TURN_FETCH_LIMIT) - val candidates = extractor.extract(resolved.workspace, history) - if (candidates.isEmpty()) return - ingestUpToBucket(resolved.session, resolved.workspace, candidates) - } - - private data class Resolved( - val session: WorkspaceAgentSession, - val workspace: Workspace, - ) - - private data class CapturePolicyContext( - val sessionId: String, - val agentKind: String, - val inferredScope: String, - ) - - private fun resolveSession(sessionId: WorkspaceAgentSessionId): Resolved? { - val session = sessions.findById(sessionId) ?: return null - val workspace = workspaces.findById(session.workspaceId) ?: return null - return Resolved(session, workspace) - } - - private fun ingestUpToBucket( - session: WorkspaceAgentSession, - workspace: Workspace, - candidates: List, - ) { - val context = - CapturePolicyContext( - sessionId = session.id.toString(), - agentKind = session.kind.name.lowercase(), - inferredScope = ScopeInference.scopeFor(workspace), - ) - for (c in candidates) { - if (isDuplicate(context, c)) continue - if (!bucket.tryAcquire(context.sessionId)) { - log.info("auto-capture bucket exhausted for session {}", context.sessionId) - return - } - ingestCandidate(context, c) - } - } - - private fun isDuplicate( - context: CapturePolicyContext, - candidate: LessonExtractor.Candidate, - ): Boolean { - val duplicate = - runCatching { knowledgeWrite.findDuplicateEvidence(candidate.dedupeQuery, rag.autoCaptureDedupeScore) } - .onFailure { log.warn("auto-capture dedupe failed: {}", it.message) } - .getOrNull() - if (duplicate == null) return false - log.info( - "auto-capture skipped duplicate for session {} source={} score={}", - context.sessionId, - duplicate.source, - duplicate.score, - ) - return true - } - - private fun ingestCandidate( - context: CapturePolicyContext, - candidate: LessonExtractor.Candidate, - ) { - val request = captureRequest(context, candidate) - runCatching { knowledgeWrite.ingestNote(request) } - .onFailure { log.warn("auto-capture ingest failed: {}", it.message) } - } - - private fun captureRequest( - context: CapturePolicyContext, - candidate: LessonExtractor.Candidate, - ): KnowledgeWritePort.CaptureRequest { - val scope = captureScope(context, candidate) - return KnowledgeWritePort.CaptureRequest( - title = candidate.title, - body = captureBody(context, candidate, scope), - scope = scope, - tags = captureTags(context, candidate), - source = "assistant-ui:auto-capture:${context.sessionId}", - sessionId = context.sessionId, - confidence = candidate.confidence, - ) - } - - private fun captureScope( - context: CapturePolicyContext, - candidate: LessonExtractor.Candidate, - ): String = - if (candidate.confidence >= rag.autoCaptureScopedMinConfidence) { - context.inferredScope - } else { - INBOX_SCOPE - } - - private fun captureTags( - context: CapturePolicyContext, - candidate: LessonExtractor.Candidate, - ): List = - ( - candidate.tags + - listOf( - "agent:${context.agentKind}", - confidenceTag(candidate.confidence), - "dedupe:checked", - ) + - candidate.triggerTerms.take(TRIGGER_TAG_LIMIT).map { "trigger:$it" } - ).distinct() - - private fun captureBody( - context: CapturePolicyContext, - candidate: LessonExtractor.Candidate, - scope: String, - ): String = - buildString { - append(candidate.body) - append("\n\nCapture policy:\n") - append("- source: assistant-ui:auto-capture:${context.sessionId}\n") - append("- session_id: ${context.sessionId}\n") - append("- agent_kind: ${context.agentKind}\n") - append("- inferred_scope: ${context.inferredScope}\n") - append("- capture_scope: $scope\n") - append("- confidence: ${String.format(Locale.ROOT, "%.2f", candidate.confidence)}\n") - append("- dedupe: checked recall threshold ${rag.autoCaptureDedupeScore}; no duplicate hit\n") - } - - private fun confidenceTag(confidence: Double): String = - when { - confidence >= HIGH_CONFIDENCE -> "confidence:high" - confidence >= MEDIUM_CONFIDENCE -> "confidence:medium" - else -> "confidence:low" - } - - companion object { - private const val TURN_FETCH_LIMIT = 50 - private const val INBOX_SCOPE = "_inbox" - private const val HIGH_CONFIDENCE = 0.75 - private const val MEDIUM_CONFIDENCE = 0.55 - private const val TRIGGER_TAG_LIMIT = 6 - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractor.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractor.kt deleted file mode 100644 index 3ef9d204..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractor.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import com.jorisjonkers.personalstack.assistant.domain.model.Turn -import com.jorisjonkers.personalstack.assistant.domain.model.TurnRole -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import org.springframework.stereotype.Component - -/** - * Heuristic extractor that turns a session's transcript into - * candidate KB lessons. v1 is intentionally simple: - * - * - Pair each USER turn with the immediately-following AGENT - * turn(s) up to the next USER turn. - * - Keep the pair only if the agent reply is substantive - * (>= minBodyChars) AND either contains an explicit lesson - * marker ("TIL:", "Note:", "Lesson:") OR the user turn is a - * question (ends with "?" or starts with "how/why/what/..."). - * - The title is the user's prompt, truncated; the body includes - * trigger terms, short evidence excerpts, and a Q/A lesson section - * so the captured note can be reviewed and recalled later. - * - * The actual quality bar is enforced at the curator step downstream - * (it routes low-confidence notes into `_inbox/_needs-review`), so - * this side stays cheap and lets the curator pick. - */ -@Component -class LessonExtractor( - private val minBodyChars: Int = MIN_BODY_CHARS, - private val maxBodyChars: Int = MAX_BODY_CHARS, -) { - private val markerRegex = Regex("\\b(TIL|Note|Lesson|Key takeaway):", RegexOption.IGNORE_CASE) - private val questionStarter = - Regex("^\\s*(how|why|what|when|where|which|who|can|does|do|should)\\b", RegexOption.IGNORE_CASE) - - data class Candidate( - val title: String, - val body: String, - val tags: List, - val confidence: Double, - val triggerTerms: List, - val excerpts: List, - val dedupeQuery: String, - ) - - private data class Pair( - val user: Turn, - val agentBody: String, - ) - - fun extract( - workspace: Workspace, - turns: List, - ): List { - val ordered = turns.sortedBy { it.createdAt } - return collectPairs(ordered) - .filter { it.agentBody.length >= minBodyChars && isWorthCapturing(it.user.body, it.agentBody) } - .map { toCandidate(workspace, it) } - } - - private fun collectPairs(ordered: List): List { - val pairs = mutableListOf() - var i = 0 - while (i < ordered.size) { - val turn = ordered[i] - if (turn.role == TurnRole.USER) { - val (agentBody, next) = collectAgentReplies(ordered, i + 1) - if (agentBody.isNotBlank()) pairs.add(Pair(turn, agentBody)) - i = next - } else { - i += 1 - } - } - return pairs - } - - private fun collectAgentReplies( - turns: List, - from: Int, - ): kotlin.Pair { - val sb = StringBuilder() - var j = from - while (j < turns.size && turns[j].role != TurnRole.USER) { - if (turns[j].role == TurnRole.AGENT) { - if (sb.isNotEmpty()) sb.append('\n') - sb.append(turns[j].body) - } - j += 1 - } - return sb.toString() to j - } - - private fun toCandidate( - workspace: Workspace, - pair: Pair, - ): Candidate { - val tags = - buildList { - add("auto-capture") - add("assistant-ui") - add("kind:turn-pair") - if (markerRegex.containsMatchIn(pair.agentBody)) add("has-marker") - workspace.repoUrl?.let { add("repo:${ScopeInference.repoSlug(it)}") } - } - val triggerTerms = triggerTerms(workspace, pair.user.body, pair.agentBody) - val excerpts = excerpts(pair.user.body, pair.agentBody) - val title = makeTitle(workspace, pair.user.body) - return Candidate( - title = title, - body = makeBody(pair.user.body, pair.agentBody, triggerTerms, excerpts), - tags = tags, - confidence = scoreFor(pair.agentBody), - triggerTerms = triggerTerms, - excerpts = excerpts, - dedupeQuery = (listOf(title) + triggerTerms + excerpts).joinToString(" ").take(DEDUPE_QUERY_CHARS), - ) - } - - private fun isWorthCapturing( - userBody: String, - agentBody: String, - ): Boolean { - val isQuestion = userBody.trim().endsWith("?") || questionStarter.containsMatchIn(userBody) - val hasMarker = markerRegex.containsMatchIn(agentBody) - return isQuestion || hasMarker - } - - private fun makeTitle( - workspace: Workspace, - userBody: String, - ): String { - val prefix = workspace.name.take(TITLE_PREFIX_CHARS) - val tail = - userBody - .lines() - .firstOrNull { it.isNotBlank() } - ?.trim() - .orEmpty() - .take(TITLE_TAIL_CHARS) - return if (tail.isBlank()) prefix else "$prefix — $tail" - } - - private fun makeBody( - userBody: String, - agentBody: String, - triggerTerms: List, - excerpts: List, - ): String { - val q = userBody.trim().take(maxBodyChars / Q_FRACTION_DENOMINATOR) - val a = agentBody.trim().take(maxBodyChars * A_FRACTION_NUMERATOR / A_FRACTION_DENOMINATOR) - return buildString { - append("Trigger:\n") - triggerTerms.forEach { append("- ").append(it).append('\n') } - append("\nEvidence:\n") - excerpts.forEach { append("- ").append(it).append('\n') } - append("\nLesson:\n") - append("Q: ").append(q).append("\n\n") - append("A: ").append(a) - } - } - - private fun scoreFor(agentBody: String): Double { - val markerBonus = if (markerRegex.containsMatchIn(agentBody)) MARKER_BONUS else 0.0 - val lengthBonus = (agentBody.length.coerceAtMost(LENGTH_CAP) / LENGTH_DIVISOR) - val codeFenceBonus = if (agentBody.contains("```")) CODE_FENCE_BONUS else 0.0 - return (BASELINE + markerBonus + lengthBonus + codeFenceBonus).coerceIn(0.0, MAX_CONFIDENCE) - } - - private fun triggerTerms( - workspace: Workspace, - userBody: String, - agentBody: String, - ): List { - val text = "$userBody\n$agentBody" - val triggers = linkedSetOf() - workspace.repoUrl?.let { triggers += ScopeInference.repoSlug(it) } - pathRegex.findAll(text).take(MAX_PATH_TRIGGERS).forEach { triggers += it.value } - commandRegex.findAll(text).take(MAX_COMMAND_TRIGGERS).forEach { triggers += it.value } - wordRegex.findAll(userBody).forEach { match -> - val word = match.value.lowercase() - if (word !in stopWords) triggers += word - if (triggers.size >= MAX_TRIGGER_TERMS) return triggers.toList() - } - wordRegex.findAll(agentBody).forEach { match -> - val word = match.value.lowercase() - if (word !in stopWords) triggers += word - if (triggers.size >= MAX_TRIGGER_TERMS) return triggers.toList() - } - return triggers.take(MAX_TRIGGER_TERMS) - } - - private fun excerpts( - userBody: String, - agentBody: String, - ): List = - listOfNotNull( - compactExcerpt("user", userBody), - compactExcerpt("agent", agentBody), - ) - - private fun compactExcerpt( - label: String, - text: String, - ): String? { - val normalized = - text - .lines() - .map(String::trim) - .filter(String::isNotBlank) - .joinToString(" ") - if (normalized.isBlank()) return null - return "$label: ${normalized.take(EXCERPT_CHARS)}" - } - - companion object { - const val MIN_BODY_CHARS = 240 - const val MAX_BODY_CHARS = 4_000 - - private const val TITLE_PREFIX_CHARS = 40 - private const val TITLE_TAIL_CHARS = 120 - private const val Q_FRACTION_DENOMINATOR = 4 - private const val A_FRACTION_NUMERATOR = 3 - private const val A_FRACTION_DENOMINATOR = 4 - - private const val BASELINE = 0.35 - private const val MARKER_BONUS = 0.15 - private const val LENGTH_CAP = 2_000 - private const val LENGTH_DIVISOR = 4_000.0 - private const val CODE_FENCE_BONUS = 0.05 - private const val MAX_CONFIDENCE = 0.85 - private const val MAX_TRIGGER_TERMS = 14 - private const val MAX_PATH_TRIGGERS = 4 - private const val MAX_COMMAND_TRIGGERS = 4 - private const val EXCERPT_CHARS = 320 - private const val DEDUPE_QUERY_CHARS = 1_200 - - private val pathRegex = Regex("""[\w./-]+\.(?:kt|kts|ts|tsx|vue|py|ya?ml|sh|nix|md|sql|json|toml)""") - private val commandRegex = - Regex("""(?:\./gradlew|gradle|npm|pnpm|kubectl|helm|flux|kustomize|docker|git|gh|nix|uv|python3?)\b""") - private val wordRegex = Regex("""[A-Za-z][A-Za-z0-9_-]{3,}""") - private val stopWords = - setOf( - "about", - "after", - "agent", - "also", - "answer", - "because", - "before", - "does", - "from", - "have", - "into", - "should", - "that", - "their", - "there", - "this", - "turn", - "what", - "when", - "where", - "which", - "with", - "work", - "would", - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ScopeInference.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ScopeInference.kt deleted file mode 100644 index 4b1e8079..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ScopeInference.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace - -/** - * Picks the knowledge-base scope for an auto-capture from a - * workspace. Matches the convention recorded in the centralized KB: - * - * project: — last path segment of the remote URL - * minus a trailing `.git` - * agent: — for repo-less Q&A workspaces - * - * The function is deterministic and exposed as a top-level helper - * so unit tests can pin the slug derivation without booting any - * Spring context. - */ -object ScopeInference { - fun scopeFor(workspace: Workspace): String { - val repo = workspace.repoUrl - if (!repo.isNullOrBlank()) { - return "project:${repoSlug(repo)}" - } - return "agent:${nameSlug(workspace.name)}" - } - - fun repoSlug(repoUrl: String): String { - val tail = repoUrl.trim().substringAfterLast('/').substringAfterLast(':') - return tail.removeSuffix(".git").ifBlank { "unknown" } - } - - fun nameSlug(name: String): String = - name - .trim() - .lowercase() - .replace(Regex("[^a-z0-9]+"), "-") - .trim('-') - .ifBlank { "workspace" } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/TokenBucket.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/TokenBucket.kt deleted file mode 100644 index 774667c6..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/TokenBucket.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import java.time.Clock -import java.time.Duration -import java.time.Instant -import java.util.concurrent.ConcurrentHashMap - -/** - * Per-key token bucket. Hands out at most `capacity` permits and - * regenerates one every `refillInterval`. Used to cap auto-capture - * volume per agent session — the KB enrichment plan called out - * runaway captures as the failure mode worth designing against. - * - * No external deps because the semantics needed are trivial and - * pulling in Resilience4j or Guava for one bucket-per-session would - * be heavier than the implementation. - */ -class TokenBucket( - private val capacity: Int, - private val refillInterval: Duration, - private val clock: Clock = Clock.systemUTC(), -) { - private data class State( - var tokens: Int, - var lastRefill: Instant, - ) - - private val state = ConcurrentHashMap() - - /** True if a permit was available and consumed; false otherwise. */ - fun tryAcquire(key: String): Boolean { - val now = clock.instant() - val s = state.computeIfAbsent(key) { State(capacity, now) } - synchronized(s) { - val elapsed = Duration.between(s.lastRefill, now) - if (elapsed >= refillInterval) { - val refills = (elapsed.toMillis() / refillInterval.toMillis()).toInt() - s.tokens = (s.tokens + refills).coerceAtMost(capacity) - s.lastRefill = s.lastRefill.plus(refillInterval.multipliedBy(refills.toLong())) - } - if (s.tokens <= 0) return false - s.tokens -= 1 - return true - } - } - - fun snapshot(key: String): Int = state[key]?.tokens ?: capacity -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/setup/SetupGuideService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/setup/SetupGuideService.kt deleted file mode 100644 index 56322300..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/setup/SetupGuideService.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.setup - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import org.springframework.core.io.ClassPathResource -import org.springframework.stereotype.Service -import java.nio.charset.StandardCharsets - -/** - * Renders the deploy-key setup guide from a classpath markdown - * template. Placeholders use `{{NAME}}` syntax — kept simple - * because the rendering target is a small fixed set of fields - * and pulling in Mustache/Pebble for this would be over-engineered. - * - * Anything in the template that the API cannot meaningfully fill in - * is left as the literal placeholder, surfaced to the operator as - * "the wizard is missing data" rather than rendering empty values. - */ -@Service -class SetupGuideService { - private val template: String by lazy { - ClassPathResource("templates/deploy-key-setup.md") - .inputStream - .use { it.readBytes().toString(StandardCharsets.UTF_8) } - } - - fun render(link: GithubLink): String = - template - .replace("{{LINK_NAME}}", link.name) - .replace("{{REPO_URL}}", link.repoUrl) - .replace("{{VAULT_KEY_PATH}}", link.vaultKeyPath) - .replace("{{DEPLOY_KEY_PAGE_URL}}", deployKeyPageUrl(link.repoUrl)) - - /** - * Constructs the GitHub web URL that opens the deploy-keys - * settings page for the linked repository. Best-effort: - * - * - `git@github.com:owner/repo.git` → https://github.com/owner/repo/settings/keys - * - `https://github.com/owner/repo.git` → same - * - anything else → owner of the repoUrl - * unchanged, the operator - * can paste it into a - * browser themselves - */ - private fun deployKeyPageUrl(repoUrl: String): String { - val trimmed = repoUrl.trim().removeSuffix(".git") - val sshMatch = Regex("^git@github\\.com:([^/]+)/(.+)$").find(trimmed) - if (sshMatch != null) { - val (owner, repo) = sshMatch.destructured - return "https://github.com/$owner/$repo/settings/keys" - } - val httpsMatch = Regex("^https?://github\\.com/([^/]+)/([^/?#]+).*$").find(trimmed) - if (httpsMatch != null) { - val (owner, repo) = httpsMatch.destructured - return "https://github.com/$owner/$repo/settings/keys" - } - return repoUrl - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeConfig.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeConfig.kt deleted file mode 100644 index 267e3938..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeConfig.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import io.fabric8.kubernetes.client.KubernetesClient -import io.fabric8.kubernetes.client.KubernetesClientBuilder -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.http.client.SimpleClientHttpRequestFactory -import org.springframework.web.client.RestClient -import java.time.Duration - -@Configuration -@EnableConfigurationProperties(AgentRuntimeProperties::class, RagProperties::class) -class AgentRuntimeConfig { - /** - * In-cluster mode by default — KubernetesClientBuilder picks up - * the SA token + CA from /var/run/secrets when running in a Pod - * and falls back to ~/.kube/config locally for tests. - */ - @Bean - fun kubernetesClient(): KubernetesClient = KubernetesClientBuilder().build() - - @Bean - fun agentGatewayRestClient(props: AgentRuntimeProperties): RestClient { - val factory = - SimpleClientHttpRequestFactory().apply { - setConnectTimeout(Duration.ofMillis(props.gatewayConnectTimeoutMs)) - setReadTimeout(Duration.ofMillis(props.gatewayReadTimeoutMs)) - } - return RestClient.builder().requestFactory(factory).build() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt deleted file mode 100644 index 43814c00..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import org.springframework.boot.context.properties.ConfigurationProperties - -private const val NIXOS_DOCKER_GROUP_GID = 131L - -private val DEFAULT_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS = - listOf(NIXOS_DOCKER_GROUP_GID) - -private val DEFAULT_NODE_SELECTOR = - mapOf( - "personal-stack/node" to "enschede-gtx-960m-1", - "personal-stack/capability-docker-socket" to "true", - ) - -/** - * Properties that drive Pod creation for workspaces. All sourced from - * application.yml so the manifests need no rebuild when the image - * tag or namespace moves. - */ -@ConfigurationProperties(prefix = "agent-runtime") -data class AgentRuntimeProperties( - val namespace: String, - val image: String, - val imagePullPolicy: String = "Always", - val serviceAccount: String, - val workspaceStorageClass: String = "local-path", - val workspaceStorageSize: String = "8Gi", - val gatewayPort: Int = 8090, - val claudeCredentialsPvc: String, - val codexCredentialsPvc: String, - val githubDeployKeySecret: String, - // Base URL of the knowledge-api MCP server the installed Claude - // Code hooks read from `KB_URL` (they append `/mcp` themselves). - // The in-cluster address skips edge + forward-auth, which a CLI - // can't satisfy. - val knowledgeBaseUrl: String = "http://knowledge-api.knowledge-system.svc.cluster.local:8080", - // Secret the hooks' `KB_BEARER_TOKEN` is sourced from; projected - // from Vault by the agents-kb-bearer VaultStaticSecret. - val knowledgeBearerSecret: String = "agents-kb-bearer", - val knowledgeBearerSecretKey: String = "bearer", - // ConfigMap (mounted at /etc/agent-mcp) whose claude-mcp-servers.json - // the entrypoint seeds into Claude Code's mcpServers. Optional mount, - // so an absent ConfigMap just means no managed MCP servers. - val mcpServersConfigMap: String = "agents-mcp-servers", - // MCP server profile selected by the runner entrypoint. Keep the - // default narrow; wider diagnostic profiles are explicit opt-ins. - val defaultMcpProfile: String = "minimal", - // Mount the host Docker socket into runner Pods so agent sessions can run - // Docker CLI commands and JVM Testcontainers suites from inside /workspace. - // This is host-equivalent access. The supplemental group default is pinned - // in platform/nix/hosts/enschede-gtx-960m-1/default.nix; override both the - // Nix host and this property together when moving runners to another node. - val dockerSocketEnabled: Boolean = true, - val dockerSocketPath: String = "/var/run/docker.sock", - val dockerSocketSupplementalGroups: List = DEFAULT_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS, - val nodeSelector: Map = DEFAULT_NODE_SELECTOR, - val gatewayConnectTimeoutMs: Long = 5_000, - val gatewayReadTimeoutMs: Long = 60_000, - // Base URL of the standing agent-gateway used for the - // workspace-independent `/git/verify` deploy-key probe at - // attach/create time (the per-workspace gateway sidecar only - // exists once a runner Pod is up). Empty => verify is skipped and - // the stored result degrades to read=write=null. - val verifyGatewayBaseUrl: String = "", - // Read-only GitHub API token for the branch-protection check. - // Empty => branch protection reports null (never a hard failure). - val githubApiToken: String = "", - // GitHub REST base — overridable for tests / GitHub Enterprise. - val githubApiBaseUrl: String = "https://api.github.com", - // GitHub App credentials used to mint short-lived, repo-scoped - // installation tokens for runner GitHub writes (`git push` via - // HTTPS, `gh pr create`, PR comments, Actions re-runs). Requested - // permissions stay repo-scoped: contents:write, pull_requests:write, - // actions:write, never administration. The App's numeric id and its - // PEM private key (PKCS#1 or PKCS#8). Both empty => minting disabled - // and the internal token endpoint reports 503. - val githubAppId: String = "", - val githubAppPrivateKey: String = "", - // Shared bearer the runner must present on the in-cluster - // /api/v1/internal/github/installation-token call. Empty => the - // internal endpoint is fail-closed (rejects every request) so an - // unconfigured deployment never exposes token minting unauthed. - val githubAppTokenBearer: String = "", - // In-cluster URL of this service's installation-token endpoint, - // injected into runner Pods so the `gh` wrapper can mint tokens. - val githubAppTokenUrl: String = - "http://assistant-api.assistant-system.svc.cluster.local:8082/api/v1/internal/github/installation-token", - // Secret (in the runner's namespace) the runner's - // GITHUB_APP_TOKEN_BEARER is sourced from; same value as - // [githubAppTokenBearer]. optional => absent secret keeps the Pod - // starting with the `gh` wrapper degrading to a no-op. - val githubAppBearerSecret: String = "github-app", - val githubAppBearerSecretKey: String = "token-bearer", -) { - init { - require(defaultMcpProfile in VALID_MCP_PROFILES) { - "agent-runtime.default-mcp-profile must be one of " + - VALID_MCP_PROFILES.joinToString(", ") + - "; got '$defaultMcpProfile'" - } - require(dockerSocketPath.startsWith("/")) { - "agent-runtime.docker-socket-path must be an absolute path; got '$dockerSocketPath'" - } - require(dockerSocketSupplementalGroups.all { it > 0L }) { - "agent-runtime.docker-socket-supplemental-groups must contain positive numeric gids" - } - } - - companion object { - val VALID_MCP_PROFILES: Set = - setOf( - "minimal", - "frontend", - "cluster", - "code-intel", - "full-diagnostic", - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AsyncConfig.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AsyncConfig.kt deleted file mode 100644 index 1513dff0..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AsyncConfig.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.scheduling.annotation.EnableAsync -import org.springframework.scheduling.annotation.EnableScheduling -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor -import java.util.concurrent.Executor -import java.util.concurrent.ThreadPoolExecutor - -@Configuration -@EnableAsync -@EnableScheduling -class AsyncConfig { - /** - * Fallback ObjectMapper for components that need JSON but are - * out of the Spring-MVC auto-configuration scope (the WebSocket - * starter doesn't bring JacksonAutoConfiguration with it on its - * own under @SpringBootTest in this module). Marked - * `@ConditionalOnMissingBean` semantics via @Qualifier so the - * Boot-provided one wins when both exist. - */ - @Bean - @Qualifier("assistantApiObjectMapper") - fun assistantApiObjectMapper(): ObjectMapper = jacksonObjectMapper() - - @Bean(name = ["chatStreamExecutor"]) - fun chatStreamExecutor(): Executor = - ThreadPoolTaskExecutor().apply { - corePoolSize = CHAT_STREAM_CORE_POOL_SIZE - maxPoolSize = CHAT_STREAM_MAX_POOL_SIZE - queueCapacity = CHAT_STREAM_QUEUE_CAPACITY - setThreadNamePrefix("chat-stream-") - setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy()) - initialize() - } - - private companion object { - private const val CHAT_STREAM_CORE_POOL_SIZE = 2 - private const val CHAT_STREAM_MAX_POOL_SIZE = 8 - private const val CHAT_STREAM_QUEUE_CAPACITY = 50 - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/CommandBusConfig.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/CommandBusConfig.kt deleted file mode 100644 index 429382d3..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/CommandBusConfig.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import com.jorisjonkers.personalstack.common.command.Command -import com.jorisjonkers.personalstack.common.command.CommandBus -import com.jorisjonkers.personalstack.common.command.CommandHandler -import org.springframework.aop.support.AopUtils -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import kotlin.reflect.KClass -import kotlin.reflect.full.allSupertypes - -@Configuration -class CommandBusConfig { - @Bean - fun commandBus(handlers: List>): CommandBus = SpringCommandBus(handlers) -} - -class SpringCommandBus( - handlers: List>, -) : CommandBus { - private val handlerMap: Map, CommandHandler<*>> = - buildMap { - for (handler in handlers) { - val commandType = - resolveCommandType(handler) - ?: error("Cannot determine command type for ${handler::class.simpleName}") - put(commandType, handler) - } - } - - @Suppress("UNCHECKED_CAST") - override fun dispatch(command: T) { - val handler = - handlerMap[command::class] as? CommandHandler - ?: error("No handler registered for ${command::class.simpleName}") - handler.handle(command) - } - - // See auth-api's CommandBusConfig — same rationale: unwrap CGLIB - // proxies and walk the full supertype graph so the generic - // CommandHandler parameter is found even when AOP wraps the bean. - private fun resolveCommandType(handler: CommandHandler<*>): KClass<*>? = - AopUtils - .getTargetClass(handler) - .kotlin - .allSupertypes - .firstOrNull { it.classifier == CommandHandler::class } - ?.arguments - ?.firstOrNull() - ?.type - ?.classifier as? KClass<*> -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/OpenApiConfig.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/OpenApiConfig.kt deleted file mode 100644 index 37988ea2..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/OpenApiConfig.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import io.swagger.v3.oas.models.Components -import io.swagger.v3.oas.models.OpenAPI -import io.swagger.v3.oas.models.info.Info -import io.swagger.v3.oas.models.security.SecurityRequirement -import io.swagger.v3.oas.models.security.SecurityScheme -import io.swagger.v3.oas.models.servers.Server -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration - -@Configuration -class OpenApiConfig { - @Bean - fun openApi(): OpenAPI = - OpenAPI() - .info( - Info() - .title("Assistant API") - .description("Conversation and messaging service for jorisjonkers.dev") - .version("1.0.0"), - ).addServersItem(Server().url("https://assistant.jorisjonkers.dev").description("Production")) - .addServersItem(Server().url("http://localhost:8082").description("Local development")) - .components( - Components() - .addSecuritySchemes( - "xUserId", - SecurityScheme() - .type(SecurityScheme.Type.APIKEY) - .`in`(SecurityScheme.In.HEADER) - .name("X-User-Id") - .description("User identifier injected by Traefik forward-auth"), - ), - ).addSecurityItem(SecurityRequirement().addList("xUserId")) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/RagProperties.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/RagProperties.kt deleted file mode 100644 index b276f4c8..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/RagProperties.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import org.springframework.boot.context.properties.ConfigurationProperties - -@ConfigurationProperties(prefix = "rag") -data class RagProperties( - val enabled: Boolean = true, - // Sensible cluster-default URLs make the integration-test boot - // path work without needing every test class to wire dynamic - // properties for the RAG feature. Production application.yml - // overrides these via env vars. - val knowledgeMcpUrl: String = "http://knowledge-api.knowledge-system.svc.cluster.local:8080", - val knowledgeMcpToken: String = "", - val lightragUrl: String = "http://lightrag.knowledge-system.svc.cluster.local:9621", - val maxSnippets: Int = 5, - val maxContextChars: Int = 4_000, - // Hits below this score are dropped before injection. Keeps the - // context envelope from growing on marginal matches. - val minScore: Double = 0.3, - // Passed to knowledge.recall as the `mode` parameter. `deep` runs - // hybrid retrieval + listwise reranker on the knowledge-api side. - val recallMode: String = "deep", - // Assistant-ui auto-capture uses the same policy shape as CLI - // digest hooks: bounded writes, duplicate suppression, explicit - // confidence, and review routing for weak candidates. - val autoCaptureSessionCapacity: Int = 3, - val autoCaptureBucketRefillMinutes: Long = 15, - val autoCaptureDedupeScore: Double = 0.86, - val autoCaptureScopedMinConfidence: Double = 0.55, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/SecurityConfig.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/SecurityConfig.kt deleted file mode 100644 index 410e83e8..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/SecurityConfig.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import jakarta.servlet.FilterChain -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.boot.web.servlet.FilterRegistrationBean -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.web.filter.OncePerRequestFilter -import java.nio.charset.StandardCharsets -import java.security.MessageDigest - -@Configuration -class SecurityConfig { - @Bean - fun xUserIdFilterRegistration(): FilterRegistrationBean = - FilterRegistrationBean(XUserIdFilter()).apply { - addUrlPatterns("/api/*") - order = 1 - } - - @Bean - fun internalBearerFilterRegistration( - props: AgentRuntimeProperties, - ): FilterRegistrationBean = - FilterRegistrationBean(InternalBearerAuthFilter(props.githubAppTokenBearer)).apply { - addUrlPatterns("/api/v1/internal/*") - order = 0 - } -} - -class XUserIdFilter : OncePerRequestFilter() { - override fun doFilterInternal( - request: HttpServletRequest, - response: HttpServletResponse, - filterChain: FilterChain, - ) { - val userId = request.getHeader("X-User-Id") - if (userId.isNullOrBlank()) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing X-User-Id header") - return - } - filterChain.doFilter(request, response) - } - - override fun shouldNotFilter(request: HttpServletRequest): Boolean { - // /api/v1/internal/* carries no X-User-Id (it skips the edge - // forward-auth); the bearer filter guards it instead. - val path = request.servletPath - return path.startsWith("/api/actuator") || - path.startsWith("/api/v1/health") || - path.startsWith("/api/v1/api-docs") || - path.startsWith("/api/v1/swagger-ui") || - path.startsWith("/api/v1/internal/") - } -} - -/** - * Guards the in-cluster `/api/v1/internal/` endpoints with a shared - * bearer. Fail-closed: when no bearer is configured every request is - * rejected, so an unconfigured deployment never exposes these - * endpoints unauthenticated. The comparison is constant-time. - */ -class InternalBearerAuthFilter( - configuredBearer: String, -) : OncePerRequestFilter() { - private val expected: ByteArray? = - configuredBearer.trim().takeIf { it.isNotEmpty() }?.toByteArray(StandardCharsets.UTF_8) - - override fun doFilterInternal( - request: HttpServletRequest, - response: HttpServletResponse, - filterChain: FilterChain, - ) { - if (expected == null) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Internal endpoint not configured") - return - } - val presented = - request - .getHeader("Authorization") - ?.removePrefix("Bearer ") - ?.trim() - ?.toByteArray(StandardCharsets.UTF_8) - if (presented == null || !MessageDigest.isEqual(expected, presented)) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid bearer") - return - } - filterChain.doFilter(request, response) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/event/ConversationStartedEvent.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/event/ConversationStartedEvent.kt deleted file mode 100644 index 2be6f715..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/event/ConversationStartedEvent.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.event - -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.common.event.DomainEvent -import java.time.Instant -import java.util.UUID - -data class ConversationStartedEvent( - val conversationId: ConversationId, - val userId: UUID, - override val occurredAt: Instant = Instant.now(), -) : DomainEvent diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatMessage.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatMessage.kt deleted file mode 100644 index c1ad1b0b..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatMessage.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant - -data class ChatMessage( - val id: ChatMessageId, - val sessionId: ChatSessionId, - val role: ChatMessageRole, - val body: String, - val createdAt: Instant, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatMessageId.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatMessageId.kt deleted file mode 100644 index 5f7e3085..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatMessageId.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.util.UUID - -@JvmInline -value class ChatMessageId( - val value: UUID, -) { - override fun toString(): String = value.toString() - - companion object { - fun random(): ChatMessageId = ChatMessageId(UUID.randomUUID()) - - fun parse(s: String): ChatMessageId = ChatMessageId(UUID.fromString(s)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatMessageRole.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatMessageRole.kt deleted file mode 100644 index 74ccbf22..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatMessageRole.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -enum class ChatMessageRole { - USER, - ASSISTANT, - SYSTEM, -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSession.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSession.kt deleted file mode 100644 index 05c6dda4..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSession.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant -import java.util.UUID - -/** - * Chat sessions are the no-Pod conversation surface. They live next - * to [Conversation] — the older type that the legacy chat UI still - * writes against — and exist so the redesigned UI can surface - * "chat" / "scratch" / "workspace" as three sibling tabs without - * coupling each tab to a Pod lifecycle. The legacy `conversations` - * table stays for the rollout window. - */ -data class ChatSession( - val id: ChatSessionId, - val userId: UUID, - val title: String?, - val status: ChatSessionStatus, - val kind: ChatSessionKind, - val createdAt: Instant, - val updatedAt: Instant, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSessionId.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSessionId.kt deleted file mode 100644 index 35096a3f..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSessionId.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.util.UUID - -@JvmInline -value class ChatSessionId( - val value: UUID, -) { - override fun toString(): String = value.toString() - - companion object { - fun random(): ChatSessionId = ChatSessionId(UUID.randomUUID()) - - fun parse(s: String): ChatSessionId = ChatSessionId(UUID.fromString(s)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSessionKind.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSessionKind.kt deleted file mode 100644 index 672edeba..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSessionKind.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -/** - * What a chat session is wired to talk to. - * - * - [PLAIN] — the existing no-Pod conversation surface: messages are - * persisted and nothing else happens server-side. - * - [KNOWLEDGE] — a session that operates on the knowledge base via an - * agent-runner Pod calling the `knowledge.*` MCP tools. The Pod - * binding + streaming land in a follow-up; this value lets the - * redesigned UI mark a session as KB-mode and lets the backend route - * on it without a second migration later. - */ -enum class ChatSessionKind { - PLAIN, - KNOWLEDGE, -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSessionStatus.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSessionStatus.kt deleted file mode 100644 index 2c54b646..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ChatSessionStatus.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -enum class ChatSessionStatus { - ACTIVE, - ARCHIVED, -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Conversation.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Conversation.kt deleted file mode 100644 index b1fe293f..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Conversation.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant -import java.util.UUID - -data class Conversation( - val id: ConversationId, - val userId: UUID, - val title: String, - val status: ConversationStatus, - val createdAt: Instant, - val updatedAt: Instant, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ConversationId.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ConversationId.kt deleted file mode 100644 index 55767816..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ConversationId.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.util.UUID - -@JvmInline -value class ConversationId( - val value: UUID, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ConversationStatus.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ConversationStatus.kt deleted file mode 100644 index db589bfa..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/ConversationStatus.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -enum class ConversationStatus { - ACTIVE, - ARCHIVED, - DELETED, -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/GithubLink.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/GithubLink.kt deleted file mode 100644 index ff2a1d11..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/GithubLink.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant -import java.util.UUID - -@JvmInline -value class GithubLinkId( - val value: UUID, -) { - override fun toString(): String = value.toString() - - companion object { - fun random(): GithubLinkId = GithubLinkId(UUID.randomUUID()) - - fun parse(s: String): GithubLinkId = GithubLinkId(UUID.fromString(s)) - } -} - -/** - * A linked GitHub repository under a Project. The deploy key for - * this single repo lives at `vaultKeyPath` (a full - * `secret/data/agents/projects//repos/` path); the - * fingerprint is mirrored into the row so the UI can show it - * without re-reading Vault. - * - * `deployKeyFingerprint` is nullable so a row can exist briefly in - * a "key not yet attached" state while the operator is following - * the setup guide. - */ -data class GithubLink( - val id: GithubLinkId, - val projectId: ProjectId, - val name: String, - val repoUrl: String, - val defaultBranch: String, - val vaultKeyPath: String, - val deployKeyFingerprint: String?, - val deployKeyAddedAt: Instant?, - val createdAt: Instant, - val updatedAt: Instant, -) { - val isKeyAttached: Boolean get() = deployKeyFingerprint != null -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Message.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Message.kt deleted file mode 100644 index 7cbc80ef..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Message.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant - -data class Message( - val id: MessageId, - val conversationId: ConversationId, - val role: MessageRole, - val content: String, - val createdAt: Instant, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/MessageId.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/MessageId.kt deleted file mode 100644 index d5592bad..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/MessageId.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.util.UUID - -@JvmInline -value class MessageId( - val value: UUID, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/MessageRole.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/MessageRole.kt deleted file mode 100644 index 656e6d85..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/MessageRole.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -enum class MessageRole { - USER, - ASSISTANT, -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Project.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Project.kt deleted file mode 100644 index 6e0d5c76..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Project.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant -import java.util.UUID - -@JvmInline -value class ProjectId( - val value: UUID, -) { - override fun toString(): String = value.toString() - - companion object { - fun random(): ProjectId = ProjectId(UUID.randomUUID()) - - fun parse(s: String): ProjectId = ProjectId(UUID.fromString(s)) - } -} - -/** - * A grouping of one or more GitHub repositories. Projects are the - * stable identity that a deploy key belongs to — even if a repo URL - * changes (rename, fork), the key stays bound to the project and - * the row in github_links is updated rather than re-keyed. - */ -data class Project( - val id: ProjectId, - val name: String, - val slug: String, - val description: String, - val createdAt: Instant, - val updatedAt: Instant, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Repository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Repository.kt deleted file mode 100644 index a8a5068f..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Repository.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant - -/** - * A GitHub repository plus its deploy key. Repositories live - * independently of any Project — many Projects can link the same - * repository through the project_repositories junction. The deploy - * key for the repo lives at `vaultKeyPath`; the fingerprint is - * mirrored into the row so the UI can render it without a Vault - * round-trip. - * - * `deployKeyFingerprint` is nullable so a row can sit briefly in a - * "key not yet attached" state while the operator follows the - * setup-guide wizard. - */ -data class Repository( - val id: RepositoryId, - val name: String, - val repoUrl: String, - val defaultBranch: String, - val vaultKeyPath: String, - val deployKeyFingerprint: String?, - val deployKeyAddedAt: Instant?, - val createdAt: Instant, - val updatedAt: Instant, - val verification: AccessVerification? = null, -) { - val isKeyAttached: Boolean get() = deployKeyFingerprint != null -} - -/** - * Cached outcome of the last deploy-key verification probe. Each - * boolean is three-valued: null = not yet checked / inconclusive, - * distinct from an explicit false. Mirrored onto the repositories row - * so the UI renders without a live probe. - */ -data class AccessVerification( - val read: Boolean?, - val write: Boolean?, - val defaultBranchProtected: Boolean?, - val checkedAt: Instant?, - val messages: List, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/RepositoryId.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/RepositoryId.kt deleted file mode 100644 index ed51130b..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/RepositoryId.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.util.UUID - -@JvmInline -value class RepositoryId( - val value: UUID, -) { - override fun toString(): String = value.toString() - - companion object { - fun random(): RepositoryId = RepositoryId(UUID.randomUUID()) - - fun parse(s: String): RepositoryId = RepositoryId(UUID.fromString(s)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Turn.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Turn.kt deleted file mode 100644 index f49027dd..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Turn.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant -import java.util.UUID - -@JvmInline -value class TurnId( - val value: UUID, -) { - override fun toString(): String = value.toString() - - companion object { - fun random(): TurnId = TurnId(UUID.randomUUID()) - - fun parse(s: String): TurnId = TurnId(UUID.fromString(s)) - } -} - -enum class TurnRole { USER, AGENT, SYSTEM } - -/** - * An append-only entry in the per-session transcript. The body is - * stored as a raw string in this PR; Step 7 layers the discriminated- - * union Block protocol on top by parsing fenced JSON in the agent's - * stream and ingesting one Turn per Block. Until then the entire - * agent response is one big text Turn. - */ -data class Turn( - val id: TurnId, - val sessionId: WorkspaceAgentSessionId, - val role: TurnRole, - val body: String, - val createdAt: Instant, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Workspace.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Workspace.kt deleted file mode 100644 index 28d9df7a..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/Workspace.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant - -/** - * A Workspace is a long-lived "place where agents work". Three - * flavours discriminated by [kind]: - * - * - `REPO_BACKED` — `repositoryId` points at a [Repository], the - * orchestrator clones it and stamps the per-repo deploy key into - * a workspace-scoped k8s Secret. - * - `SCRATCH` — Pod without a clone. `repositoryId` is null. - * - `CHAT` — never actually persisted as a workspace row - * today; chat lives in its own [ChatSession] table. The enum - * value exists for type-symmetry with the UI tabs. - * - * `projectId` is set when the workspace was opened "inside a - * project context" so the UI can group workspaces by project; it - * stays null for ad-hoc work. - * - * `githubLinkId` is kept around through the M:N migration window so - * the orchestrator and Vault adapter continue to find the legacy - * per-link path. New code reads [repositoryId]; a follow-up PR - * drops the column once every consumer migrates. - */ -data class Workspace( - val id: WorkspaceId, - val name: String, - val repoUrl: String?, - val branch: String?, - val podName: String?, - val pvcName: String?, - val gatewayEndpoint: String?, - val status: WorkspaceStatus, - val createdAt: Instant, - val updatedAt: Instant, - val repositoryId: RepositoryId? = null, - val projectId: ProjectId? = null, - val kind: WorkspaceKind = WorkspaceKind.REPO_BACKED, - @Deprecated( - "Use repositoryId — kept for the V9 migration window so legacy " + - "rows + the orchestrator's per-link deploy-key lookup keep working.", - ReplaceWith("repositoryId"), - ) - val githubLinkId: GithubLinkId? = null, -) { - val isRepoBacked: Boolean get() = repoUrl != null - - fun withPodInfo( - podName: String, - pvcName: String, - gatewayEndpoint: String, - ): Workspace = - copy( - podName = podName, - pvcName = pvcName, - gatewayEndpoint = gatewayEndpoint, - status = WorkspaceStatus.STARTING, - updatedAt = Instant.now(), - ) - - fun markReady(): Workspace = copy(status = WorkspaceStatus.READY, updatedAt = Instant.now()) - - fun markFailed(): Workspace = copy(status = WorkspaceStatus.FAILED, updatedAt = Instant.now()) - - fun markDestroyed(): Workspace = copy(status = WorkspaceStatus.DESTROYED, updatedAt = Instant.now()) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentKind.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentKind.kt deleted file mode 100644 index a37ad171..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentKind.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -/** - * The CLI the agent process is running. Matches the gateway's - * AgentKind enum; kept as a separate type in the assistant-api - * domain so a CLI added at the gateway layer (a hypothetical - * `gemini`, say) doesn't recompile half this module before its - * domain semantics are settled. - */ -enum class WorkspaceAgentKind { CLAUDE, CODEX, SHELL } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSession.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSession.kt deleted file mode 100644 index 6b0cedf9..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSession.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.time.Instant - -enum class WorkspaceAgentSessionStatus { STARTING, RUNNING, STOPPED, FAILED } - -/** - * One agent process inside a workspace's runner Pod. The - * `gatewayAgentId` is what the agent-gateway hands back when we POST - * /agents — the assistant-api always addresses gateway resources by - * that short id, never by our own UUID, because the gateway is the - * source of truth for "is this process actually alive". - * - * `cliSessionId` is the native CLI session id returned by the - * gateway at spawn time (Claude: from `--session-id `; - * Codex: captured after spawn, null until discovered; Shell: never - * set). Stored for observability and future explicit continuation - * flows; new session starts do not implicitly resume it. - * - * `runMode` is `INTERACTIVE` for browser-terminal sessions and will - * be `HEADLESS` once N4 headless runs land. - */ -data class WorkspaceAgentSession( - val id: WorkspaceAgentSessionId, - val workspaceId: WorkspaceId, - val kind: WorkspaceAgentKind, - val gatewayAgentId: String?, - val status: WorkspaceAgentSessionStatus, - val createdAt: Instant, - val updatedAt: Instant, - val cliSessionId: String? = null, - val runMode: String = "INTERACTIVE", -) { - fun bindGatewayAgent( - gatewayAgentId: String, - cliSessionId: String? = null, - ): WorkspaceAgentSession = - copy( - gatewayAgentId = gatewayAgentId, - cliSessionId = cliSessionId, - status = WorkspaceAgentSessionStatus.RUNNING, - updatedAt = Instant.now(), - ) - - fun markStopped(): WorkspaceAgentSession = - copy(status = WorkspaceAgentSessionStatus.STOPPED, updatedAt = Instant.now()) - - fun markFailed(): WorkspaceAgentSession = - copy(status = WorkspaceAgentSessionStatus.FAILED, updatedAt = Instant.now()) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSessionId.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSessionId.kt deleted file mode 100644 index 1e3aa61c..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSessionId.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.util.UUID - -@JvmInline -value class WorkspaceAgentSessionId( - val value: UUID, -) { - override fun toString(): String = value.toString() - - companion object { - fun random(): WorkspaceAgentSessionId = WorkspaceAgentSessionId(UUID.randomUUID()) - - fun parse(s: String): WorkspaceAgentSessionId = WorkspaceAgentSessionId(UUID.fromString(s)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceId.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceId.kt deleted file mode 100644 index a99377aa..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceId.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import java.util.UUID - -@JvmInline -value class WorkspaceId( - val value: UUID, -) { - override fun toString(): String = value.toString() - - fun short(): String = value.toString().substring(0, SHORT_ID_LENGTH) - - companion object { - /** - * UUID prefix length we use as a stable short identifier - * for Pod / PVC / Service names. 8 hex chars leaves a 1-in- - * 4-billion collision space — plenty when there are - * typically fewer than 50 simultaneous workspaces. - */ - private const val SHORT_ID_LENGTH = 8 - - fun random(): WorkspaceId = WorkspaceId(UUID.randomUUID()) - - fun parse(s: String): WorkspaceId = WorkspaceId(UUID.fromString(s)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceKind.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceKind.kt deleted file mode 100644 index 49377fd7..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceKind.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -/** - * Discriminator for the three workspace flavours surfaced in the UI: - * - * - `REPO_BACKED` — Pod with a git clone. Repository is required. - * - `SCRATCH` — Pod without a clone. Repository is null. - * - `CHAT` — no Pod. A plain LLM conversation surface that - * lives outside the workspaces table (see - * [ChatSession]); the enum value exists here so a - * workspace row with `kind = CHAT` can never be - * created — the type system gives the orchestrator - * a single value to switch on. - */ -enum class WorkspaceKind { - REPO_BACKED, - SCRATCH, - CHAT, -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceStatus.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceStatus.kt deleted file mode 100644 index f95a945b..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceStatus.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -/** - * Workspace lifecycle. A workspace's status is the join of its own - * desired state and the underlying runner Pod's observed phase: - * - * PENDING — record created, Pod not yet scheduled - * STARTING — Pod scheduled, agent-gateway boot pending - * READY — gateway answering, ready for agents - * IDLE — no agents and no WS clients for N minutes; scaled to 0 - * FAILED — terminal; Pod crash-looping or gateway never came up - * DESTROYED — workspace torn down, PVC reclaimed - */ -enum class WorkspaceStatus { PENDING, STARTING, READY, IDLE, FAILED, DESTROYED } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt deleted file mode 100644 index 45eef1f3..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentGatewayClient.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind - -/** - * Driven port: the HTTP/WS facade in front of an agent-gateway - * running inside a workspace's runner Pod. Methods are the small - * intersection of operations assistant-api actually needs — anything - * more granular is invoked by the agent itself via its tooling, - * not by this control plane. - */ -interface AgentGatewayClient { - data class GatewayAgent( - val id: String, - val kind: WorkspaceAgentKind, - val cwd: String, - val cliSessionId: String? = null, - ) - - /** - * Result of the gateway's `/git/verify` probe. `read` proves the - * staged deploy key can `git ls-remote`; `write` is a - * non-destructive throwaway-ref push/delete against an existing - * commit. `detail` carries the gateway's human-readable message. - */ - data class AccessVerification( - val read: Boolean, - val write: Boolean, - val detail: String, - ) - - data class StagedInput( - val path: String, - val bytes: Long, - val name: String, - ) - - /** - * Ask the standing agent-gateway to verify deploy-key access to - * [repoUrl] on [branch] (HEAD when null). Returns null when no - * gateway base URL is configured or the call is inconclusive — - * callers degrade gracefully rather than fail hard. - */ - fun verifyAccess( - repoUrl: String, - branch: String?, - ): AccessVerification? - - fun spawnAgent( - workspace: Workspace, - kind: WorkspaceAgentKind, - workspacePath: String? = null, - ): GatewayAgent - - fun stopAgent( - workspace: Workspace, - gatewayAgentId: String, - ) - - fun sendInput( - workspace: Workspace, - gatewayAgentId: String, - input: String, - enter: Boolean = true, - ) - - fun stageInput( - workspace: Workspace, - gatewayAgentId: String, - content: String, - name: String?, - ): StagedInput - - fun capture( - workspace: Workspace, - gatewayAgentId: String, - ): String - - fun clone( - workspace: Workspace, - repoUrl: String, - branch: String? = null, - ): String - - fun openPr( - workspace: Workspace, - repoDir: String, - title: String, - body: String, - base: String = "main", - ): String - - fun isReady(workspace: Workspace): Boolean - - enum class HeadlessStatus { RUNNING, COMPLETED, FAILED, CANCELLED } - - data class HeadlessJob( - val id: String, - val status: HeadlessStatus, - val exitCode: Int?, - val output: String?, - ) - - fun startHeadlessJob( - workspace: Workspace, - kind: WorkspaceAgentKind, - prompt: String, - cliSessionId: String? = null, - timeoutSeconds: Long? = null, - ): HeadlessJob - - fun pollHeadlessJob( - workspace: Workspace, - headlessJobId: String, - ): HeadlessJob -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentRunnerOrchestrator.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentRunnerOrchestrator.kt deleted file mode 100644 index 7ef48d06..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/AgentRunnerOrchestrator.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace - -/** - * Driven port: cluster-level Pod lifecycle for a workspace. The - * fabric8 implementation lives in `infrastructure/k8s/` and is the - * only place that touches the Kubernetes API. Keeping this port - * narrow (provision, scaleDown, destroy, lookup) makes the application - * layer agnostic to the cluster backend. - */ -interface AgentRunnerOrchestrator { - data class RunnerHandle( - val podName: String, - val pvcName: String, - val gatewayEndpoint: String, - ) - - fun provision(workspace: Workspace): RunnerHandle - - /** - * Tear down the runner Pod and Service while leaving the workspace - * PVC and per-workspace deploy-key Secret intact. Used by the idle - * sweep so a follow-up [provision] can re-attach the same disk - * without a re-clone. - */ - fun scaleDown(workspace: Workspace) - - /** - * Full teardown: removes the Pod, Service, workspace PVC, and - * per-workspace deploy-key Secret. Call only when the workspace is - * being permanently deleted. - */ - fun destroy(workspace: Workspace) - - fun isReady(workspace: Workspace): Boolean -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/BranchProtectionClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/BranchProtectionClient.kt deleted file mode 100644 index 07e72d3d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/BranchProtectionClient.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -/** - * Driven port for the GitHub branch-protection lookup. Implementations - * resolve owner/repo from a deploy-key repo URL (ssh or https) and ask - * the GitHub REST API whether the default branch is protected. - * - * The result is intentionally three-valued: a missing token, a 404 - * (no protection / no permission), or any transport error all collapse - * to `null` — the caller treats that as "inconclusive", never a hard - * failure. - */ -interface BranchProtectionClient { - /** - * @return `true` when the branch has protection configured, - * `false` when GitHub reports it explicitly unprotected, and - * `null` when the answer is inconclusive (no token, parse - * failure, 404, network/auth error). - */ - fun isBranchProtected( - repoUrl: String, - branch: String, - ): Boolean? -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ChatMessageRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ChatMessageRepository.kt deleted file mode 100644 index 36c01a86..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ChatMessageRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessage -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId - -interface ChatMessageRepository { - fun save(message: ChatMessage): ChatMessage - - fun findById(id: ChatMessageId): ChatMessage? - - fun findAllBySessionIdOrderedByTime(sessionId: ChatSessionId): List - - fun deleteAllBySessionId(sessionId: ChatSessionId) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ChatSessionRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ChatSessionRepository.kt deleted file mode 100644 index e50bf89c..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ChatSessionRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import java.util.UUID - -interface ChatSessionRepository { - fun save(session: ChatSession): ChatSession - - fun findById(id: ChatSessionId): ChatSession? - - fun findAllByUserId(userId: UUID): List - - fun delete(id: ChatSessionId) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ConversationRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ConversationRepository.kt deleted file mode 100644 index f8ac9be0..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ConversationRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import java.util.UUID - -interface ConversationRepository { - fun findById(id: ConversationId): Conversation? - - fun findByUserId(userId: UUID): List - - fun save(conversation: Conversation): Conversation -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/MessageRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/MessageRepository.kt deleted file mode 100644 index 04774fce..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/MessageRepository.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.Message - -interface MessageRepository { - fun save(message: Message): Message - - fun findByConversationId(id: ConversationId): List -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ProjectRepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ProjectRepositoryRepository.kt deleted file mode 100644 index df4b5d6f..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ProjectRepositoryRepository.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import java.time.Instant - -/** - * Junction-table port for the project_repositories M:N mapping. - * Operations are explicit (`link` / `unlink`) rather than save/find - * because the row carries no payload beyond the pair + linkedAt. - */ -interface ProjectRepositoryRepository { - data class Link( - val projectId: ProjectId, - val repositoryId: RepositoryId, - val linkedAt: Instant, - ) - - fun link( - projectId: ProjectId, - repositoryId: RepositoryId, - ): Link - - fun unlink( - projectId: ProjectId, - repositoryId: RepositoryId, - ) - - fun exists( - projectId: ProjectId, - repositoryId: RepositoryId, - ): Boolean - - fun findAllByProjectId(projectId: ProjectId): List - - fun findAllByRepositoryId(repositoryId: RepositoryId): List -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ProjectsPort.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ProjectsPort.kt deleted file mode 100644 index 17c92b14..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/ProjectsPort.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId - -interface ProjectsRepository { - fun save(project: Project): Project - - fun findById(id: ProjectId): Project? - - fun findBySlug(slug: String): Project? - - fun findAll(): List - - fun delete(id: ProjectId) -} - -interface GithubLinkRepository { - fun save(link: GithubLink): GithubLink - - fun findById(id: GithubLinkId): GithubLink? - - fun findAllByProjectId(projectId: ProjectId): List - - fun delete(id: GithubLinkId) -} - -/** - * Driven port for the Vault-backed deploy key storage. One pair of - * (private_key, public_key, known_hosts, fingerprint) per - * GithubLink. Adapters write to the canonical - * `secret/data/agents/projects//repos/` path and return - * the fingerprint that the GithubLink row will mirror. - */ -interface DeployKeyStore { - data class StoredKey( - val fingerprint: String, - val vaultPath: String, - ) - - data class KeyMaterial( - val privateKey: String, - val publicKey: String, - val knownHosts: String, - val fingerprint: String, - ) - - /** - * Persist the supplied OpenSSH-format ed25519 keypair at the - * canonical path for the given link. Returns the SHA-256 - * fingerprint of the public key (in the OpenSSH "SHA256:..." - * form), which the row mirrors so the UI doesn't have to query - * Vault to render. - */ - fun store( - projectId: ProjectId, - linkId: GithubLinkId, - privateKeyOpenssh: String, - publicKeyOpenssh: String, - knownHosts: String, - ): StoredKey - - fun remove( - projectId: ProjectId, - linkId: GithubLinkId, - ) - - /** - * Return the public key half (only) at the link's path. Used - * by the setup-guide endpoint when the operator paged in - * mid-flow and we need to remind them which key to paste into - * GitHub. - */ - fun readPublicKey( - projectId: ProjectId, - linkId: GithubLinkId, - ): String? - - /** - * Load the full key material for a link. Returns null if no - * key is attached. Used by the orchestrator to stamp a - * workspace-scoped k8s Secret out of the Vault-stored bytes - * at Pod-creation time. - */ - fun loadKey( - projectId: ProjectId, - linkId: GithubLinkId, - ): KeyMaterial? - - /** - * Repository-keyed write path. The redesigned UI invokes this - * via the repository deploy-key wizard; the underlying Vault - * path keeps the legacy `secret/data/agents/projects//repos/` - * shape during the V9 migration window, so the adapter - * supplies a "synthetic" project id (the repository's own UUID) - * to retain backwards-compatibility with the orchestrator's - * legacy loader. - */ - fun store( - repositoryId: RepositoryId, - privateKeyOpenssh: String, - publicKeyOpenssh: String, - knownHosts: String, - ): StoredKey - - fun remove(repositoryId: RepositoryId) - - fun readPublicKey(repositoryId: RepositoryId): String? - - fun loadKey(repositoryId: RepositoryId): KeyMaterial? -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RepositoryRepository.kt deleted file mode 100644 index ec8e8e42..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RepositoryRepository.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId - -/** - * Persistence port for [Repository]. The junction table that - * carries the M:N membership against Project is owned by - * [ProjectRepositoryRepository]; this port stays focused on the - * single-table CRUD so the deploy-key flow can use it without - * pulling in junction concerns. - */ -interface RepositoryRepository { - fun save(repository: Repository): Repository - - fun findById(id: RepositoryId): Repository? - - fun findByName(name: String): Repository? - - fun findAll(): List - - fun findAllByProjectId(projectId: ProjectId): List - - fun delete(id: RepositoryId) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RetrievalPort.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RetrievalPort.kt deleted file mode 100644 index 3b66e045..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/RetrievalPort.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -/** - * Driven port: "give me snippets relevant to this prompt." The - * application doesn't care whether they came from LightRAG, FTS, or - * a static vector store — only the final list is exposed. Adapters - * fan out to the underlying sources and rerank as needed. - */ -interface RetrievalPort { - data class Snippet( - val source: String, - val text: String, - val score: Double, - val id: String? = null, - ) - - fun retrieve( - query: String, - limit: Int, - ): List -} - -/** - * Driven port for capturing what an agent learned. Auto-capture - * sends explicit provenance, confidence, and duplicate-check context; - * manual callers can still use the simple convenience overload. - */ -interface KnowledgeWritePort { - data class CaptureRequest( - val title: String, - val body: String, - val scope: String, - val tags: List = emptyList(), - val source: String? = null, - val sessionId: String? = null, - val confidence: Double? = null, - ) - - data class DuplicateEvidence( - val id: String?, - val source: String, - val score: Double, - ) - - fun ingestNote( - title: String, - body: String, - scope: String, - tags: List = emptyList(), - ) = ingestNote(CaptureRequest(title = title, body = body, scope = scope, tags = tags)) - - fun ingestNote(request: CaptureRequest) - - fun findDuplicateEvidence( - query: String, - minScore: Double, - ): DuplicateEvidence? = null -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/TurnRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/TurnRepository.kt deleted file mode 100644 index 6d1d2bb4..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/TurnRepository.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.Turn -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId - -interface TurnRepository { - fun save(turn: Turn): Turn - - fun findBySessionId( - sessionId: WorkspaceAgentSessionId, - limit: Int = 200, - ): List -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceAgentSessionRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceAgentSessionRepository.kt deleted file mode 100644 index e47deec8..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceAgentSessionRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId - -interface WorkspaceAgentSessionRepository { - fun save(session: WorkspaceAgentSession): WorkspaceAgentSession - - fun findById(id: WorkspaceAgentSessionId): WorkspaceAgentSession? - - fun findAllByWorkspaceId(workspaceId: WorkspaceId): List - - fun delete(id: WorkspaceAgentSessionId) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceRepository.kt deleted file mode 100644 index 920d84a3..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus - -interface WorkspaceRepository { - fun save(workspace: Workspace): Workspace - - fun findById(id: WorkspaceId): Workspace? - - fun findAllByStatusNot(status: WorkspaceStatus): List - - fun delete(id: WorkspaceId) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceRepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceRepositoryRepository.kt deleted file mode 100644 index e7826833..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/domain/port/WorkspaceRepositoryRepository.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.port - -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import java.time.Instant - -/** - * Junction-table port for the workspace_repositories M:N mapping — the - * repositories a workspace clones beyond its primary. Operations are explicit - * (`attach` / `detach`) rather than save/find because the row carries no - * payload beyond the pair, the primary flag, and attachedAt. - */ -interface WorkspaceRepositoryRepository { - data class Link( - val workspaceId: WorkspaceId, - val repositoryId: RepositoryId, - val isPrimary: Boolean, - val attachedAt: Instant, - ) - - fun attach( - workspaceId: WorkspaceId, - repositoryId: RepositoryId, - isPrimary: Boolean = false, - ): Link - - fun detach( - workspaceId: WorkspaceId, - repositoryId: RepositoryId, - ) - - fun findAllByWorkspaceId(workspaceId: WorkspaceId): List -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/.gitkeep b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt deleted file mode 100644 index 2a522ee9..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt +++ /dev/null @@ -1,302 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.fasterxml.jackson.annotation.JsonProperty -import com.jorisjonkers.personalstack.assistant.config.AgentRuntimeProperties -import org.slf4j.LoggerFactory -import org.springframework.http.HttpHeaders -import org.springframework.stereotype.Component -import org.springframework.web.client.RestClient -import org.springframework.web.client.RestClientResponseException -import java.io.ByteArrayOutputStream -import java.security.KeyFactory -import java.security.Signature -import java.security.spec.PKCS8EncodedKeySpec -import java.time.Instant -import java.util.Base64 - -/** - * Mints short-lived, single-repo GitHub App installation tokens so the - * runner's `gh` can create PRs/re-run Actions and `git push` can create - * feature branches over HTTPS. Each token is scoped to the one - * repository being acted on and carries `contents:write`, - * `pull_requests:write`, `actions:write`, `issues:write`, - * `workflows:write`, and `packages:read`; `administration` is never - * requested, so the token cannot change repo settings or rulesets. - * `main` stays guarded by branch protection. - * - * `workflows:write` lets the runner author the per-repo CI pipeline that - * ends in the required `Pipeline Complete` check; `issues:write` lets it - * keep tracking issues/milestones current; `packages:read` lets local - * builds resolve published `dev.extratoast.*` / `@extratoast` artifacts. - * Artifact publishing stays a CI concern (the release workflow's own - * `GITHUB_TOKEN`), so `packages:write` is not requested here. - * - * Disabled — [enabled] is false and [mint] returns null — whenever the - * App id or private key is absent, so an unconfigured deployment is a - * no-op rather than a failure. - */ -@Component -class GitHubAppInstallationTokenClient( - private val restClient: RestClient, - private val props: AgentRuntimeProperties, -) { - private val log = LoggerFactory.getLogger(GitHubAppInstallationTokenClient::class.java) - - val enabled: Boolean - get() = props.githubAppId.isNotBlank() && props.githubAppPrivateKey.isNotBlank() - - data class InstallationToken( - val token: String, - val expiresAt: Instant, - ) - - /** - * Returns a fresh installation token scoped to [repoUrl]'s repo, or - * null when minting is disabled, the URL is unparseable, the App is - * not installed on that owner, or any transport/GitHub error occurs. - * Never throws — the caller maps null to 503. - */ - fun mint(repoUrl: String): InstallationToken? { - if (!enabled) return null - val slug = GitHubBranchProtectionClient.parseOwnerRepo(repoUrl) - if (slug == null) { - log.warn("installation-token mint skipped — could not parse owner/repo from {}", repoUrl) - return null - } - val base = props.githubApiBaseUrl.trim().trimEnd('/') - return runCatching { - val jwt = appJwt() - val installationId = - installationId(base, slug, jwt) - ?: error("no installation for ${slug.owner}/${slug.repo}") - val resp = - accessToken(base, installationId, slug.repo, jwt) - ?: error("empty access-token response") - warnOnNarrowedGrant(slug, resp.permissions) - InstallationToken(token = resp.token, expiresAt = Instant.parse(resp.expiresAt)) - }.onFailure { ex -> - val detail = (ex as? RestClientResponseException)?.responseBodyAsString?.takeIf { it.isNotBlank() } - log.warn("installation-token mint for {} failed: {}{}", repoUrl, ex.message, detail?.let { " — $it" } ?: "") - }.getOrNull() - } - - /** - * GitHub silently narrows a token to whatever the installation - * actually holds, so requesting `contents:write` against an App that - * was installed with only `metadata:read` yields a token that can - * neither push, pull, nor touch Actions — with no error. Compare the - * granted set against [REQUESTED_PERMISSIONS] and log an actionable - * warning when anything is missing or weaker, so the fix (widen the - * App's permissions, then approve the request on each installation) - * is visible instead of surfacing as a mystified read-only runner. - */ - private fun warnOnNarrowedGrant( - slug: GitHubBranchProtectionClient.OwnerRepo, - granted: Map, - ) { - val shortfall = narrowedPermissions(REQUESTED_PERMISSIONS, granted) - if (shortfall.isNotEmpty()) { - log.warn( - "installation token for {}/{} is missing requested permissions {} (granted: {}). " + - "Widen the personal-stack-agents App's repository permissions (contents/pull_requests/" + - "actions/issues/workflows: read & write, packages: read), then approve the updated " + - "permissions on the {} installation — until then runner git push / gh pr / gh run rerun / " + - "workflow edits / issue edits stay restricted.", - slug.owner, - slug.repo, - shortfall, - granted, - slug.owner, - ) - } - } - - private fun installationId( - base: String, - slug: GitHubBranchProtectionClient.OwnerRepo, - jwt: String, - ): Long? = - restClient - .get() - .uri("$base/repos/${slug.owner}/${slug.repo}/installation") - .header(HttpHeaders.AUTHORIZATION, "Bearer $jwt") - .header(HttpHeaders.ACCEPT, "application/vnd.github+json") - .header(GH_API_VERSION_HEADER, GH_API_VERSION) - .retrieve() - .body(InstallationResponse::class.java) - ?.id - - private fun accessToken( - base: String, - installationId: Long, - repo: String, - jwt: String, - ): AccessTokenResponse? = - restClient - .post() - .uri("$base/app/installations/$installationId/access_tokens") - .header(HttpHeaders.AUTHORIZATION, "Bearer $jwt") - .header(HttpHeaders.ACCEPT, "application/vnd.github+json") - .header(GH_API_VERSION_HEADER, GH_API_VERSION) - .body( - AccessTokenRequest( - repositories = listOf(repo), - permissions = REQUESTED_PERMISSIONS, - ), - ).retrieve() - .body(AccessTokenResponse::class.java) - - /** - * RS256 JWT asserting the App's identity, valid for ~9 minutes - * (GitHub caps App JWTs at 10). `iat` is backdated 60s to absorb - * clock skew between this Pod and GitHub. - */ - private fun appJwt(): String { - val now = Instant.now().epochSecond - val header = b64Url("""{"alg":"RS256","typ":"JWT"}""".toByteArray()) - val payload = b64Url("""{"iat":${now - 60},"exp":${now + 540},"iss":"${props.githubAppId}"}""".toByteArray()) - val signingInput = "$header.$payload" - val signature = b64Url(sign(signingInput.toByteArray())) - return "$signingInput.$signature" - } - - private fun sign(input: ByteArray): ByteArray { - val spec = PKCS8EncodedKeySpec(pkcs8Der(props.githubAppPrivateKey)) - val key = KeyFactory.getInstance("RSA").generatePrivate(spec) - return Signature.getInstance("SHA256withRSA").run { - initSign(key) - update(input) - sign() - } - } - - private data class AccessTokenRequest( - val repositories: List, - val permissions: Map, - ) - - private data class InstallationResponse( - val id: Long = 0, - ) - - private data class AccessTokenResponse( - val token: String = "", - @param:JsonProperty("expires_at") val expiresAt: String = "", - val permissions: Map = emptyMap(), - ) - - // The DER/ASN.1 byte and bit-shift literals below are structural - // crypto constants, not tunable values. - @Suppress("MagicNumber") - companion object { - private const val GH_API_VERSION_HEADER = "X-GitHub-Api-Version" - private const val GH_API_VERSION = "2022-11-28" - - // The permissions a runner token carries: enough for git push, - // gh pr create/comment, gh run rerun, authoring `.github/workflows` - // (the Pipeline Complete pipeline), keeping tracking issues current, - // and resolving published packages for local builds. `administration` - // is deliberately absent so the token cannot change repo settings or - // rulesets; `packages` is read-only because publishing is done by the - // release workflow's own GITHUB_TOKEN, not by runner tokens. - val REQUESTED_PERMISSIONS = - mapOf( - "contents" to "write", - "pull_requests" to "write", - "actions" to "write", - "issues" to "write", - "workflows" to "write", - "packages" to "read", - ) - - private val PERMISSION_RANK = mapOf("read" to 1, "write" to 2, "admin" to 3) - - /** - * Requested permissions that the granted set does not satisfy — - * either absent, or granted at a weaker level than asked. Pure so - * the narrowed-grant detection is unit-testable without HTTP. - */ - fun narrowedPermissions( - requested: Map, - granted: Map, - ): List = - requested - .filter { (perm, level) -> - val have = granted[perm]?.let { PERMISSION_RANK[it] ?: 0 } ?: 0 - val want = PERMISSION_RANK[level] ?: 0 - have < want - }.keys - .sorted() - - // The fixed PKCS#8 PrivateKeyInfo prelude for an RSA key: - // version INTEGER 0 + AlgorithmIdentifier { rsaEncryption, NULL }. - private val RSA_PKCS8_ALG_ID = - byteArrayOf(0x02, 0x01, 0x00) + - byteArrayOf( - 0x30, - 0x0d, - 0x06, - 0x09, - 0x2a, - 0x86.toByte(), - 0x48, - 0x86.toByte(), - 0xf7.toByte(), - 0x0d, - 0x01, - 0x01, - 0x01, - 0x05, - 0x00, - ) - - private fun b64Url(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) - - /** - * Returns PKCS#8 DER for a PEM private key. A PKCS#8 PEM - * (`BEGIN PRIVATE KEY`) is decoded directly; a PKCS#1 PEM - * (`BEGIN RSA PRIVATE KEY`, the format GitHub hands out) is - * wrapped in the PKCS#8 envelope so `KeyFactory` accepts it - * without a BouncyCastle dependency. - */ - fun pkcs8Der(pem: String): ByteArray { - val der = Base64.getMimeDecoder().decode(stripPem(pem)) - return if (pem.contains("BEGIN RSA PRIVATE KEY")) wrapPkcs1AsPkcs8(der) else der - } - - private fun stripPem(pem: String): String = - pem - .replace(Regex("-----BEGIN [^-]+-----"), "") - .replace(Regex("-----END [^-]+-----"), "") - .replace(Regex("\\s"), "") - - private fun wrapPkcs1AsPkcs8(pkcs1: ByteArray): ByteArray { - val privateKeyOctet = derTlv(0x04, pkcs1) - return derTlv(0x30, RSA_PKCS8_ALG_ID + privateKeyOctet) - } - - // Emits a DER tag-length-value with definite long-form length. - private fun derTlv( - tag: Int, - content: ByteArray, - ): ByteArray { - val out = ByteArrayOutputStream() - out.write(tag) - val len = content.size - if (len < 0x80) { - out.write(len) - } else { - val lenBytes = - generateSequence(len) { if (it > 0) it shr 8 else null } - .takeWhile { it > 0 } - .map { (it and 0xff).toByte() } - .toList() - .reversed() - out.write(0x80 or lenBytes.size) - lenBytes.forEach { out.write(it.toInt()) } - } - out.write(content) - return out.toByteArray() - } - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubBranchProtectionClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubBranchProtectionClient.kt deleted file mode 100644 index 782c4a7d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubBranchProtectionClient.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.jorisjonkers.personalstack.assistant.config.AgentRuntimeProperties -import com.jorisjonkers.personalstack.assistant.domain.port.BranchProtectionClient -import org.slf4j.LoggerFactory -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatusCode -import org.springframework.stereotype.Component -import org.springframework.web.client.RestClient - -/** - * GitHub REST adapter for `GET /repos/{owner}/{repo}/branches/{branch}/protection`. - * Uses a read-only token from [AgentRuntimeProperties.githubApiToken]; - * an empty token, an unparseable repo URL, a 404, or any transport - * failure all yield `null` so branch protection never blocks a flow. - */ -@Component -class GitHubBranchProtectionClient( - private val restClient: RestClient, - private val props: AgentRuntimeProperties, -) : BranchProtectionClient { - private val log = LoggerFactory.getLogger(GitHubBranchProtectionClient::class.java) - - override fun isBranchProtected( - repoUrl: String, - branch: String, - ): Boolean? { - val token = props.githubApiToken.trim() - if (token.isEmpty()) { - log.info("branch-protection check skipped — GITHUB_API_TOKEN not configured") - return null - } - val slug = parseOwnerRepo(repoUrl) - if (slug == null) { - log.warn("branch-protection check skipped — could not parse owner/repo from {}", repoUrl) - return null - } - val base = props.githubApiBaseUrl.trim().trimEnd('/') - return runCatching { - var protected = false - restClient - .get() - .uri("$base/repos/${slug.owner}/${slug.repo}/branches/$branch/protection") - .header(HttpHeaders.AUTHORIZATION, "Bearer $token") - .header(HttpHeaders.ACCEPT, "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - // 404 = no protection or token can't see it; 401/403 = - // inconclusive. Map all client errors to "not true" - // without throwing so the runCatching below only trips - // on transport faults. - .retrieve() - .onStatus(HttpStatusCode::is4xxClientError) { _, _ -> } - .toBodilessEntity() - .also { resp -> protected = resp.statusCode.is2xxSuccessful } - protected - }.onFailure { - log.warn("branch-protection check for {} failed: {}", repoUrl, it.message) - }.getOrNull() - } - - data class OwnerRepo( - val owner: String, - val repo: String, - ) - - companion object { - // git@github.com:Owner/Repo(.git) | ssh://git@github.com/Owner/Repo(.git) - // https://github.com/Owner/Repo(.git) | http://... - private val SCP_LIKE = Regex("""^[^@]+@[^:]+:(?[^/]+)/(?[^/]+?)(?:\.git)?/?$""") - private val URL_LIKE = Regex("""^[a-zA-Z]+://(?:[^@/]+@)?[^/]+/(?[^/]+)/(?[^/]+?)(?:\.git)?/?$""") - - fun parseOwnerRepo(repoUrl: String): OwnerRepo? { - val trimmed = repoUrl.trim() - val match = SCP_LIKE.matchEntire(trimmed) ?: URL_LIKE.matchEntire(trimmed) ?: return null - val owner = match.groups["owner"]?.value?.takeIf { it.isNotBlank() } - val repo = match.groups["repo"]?.value?.takeIf { it.isNotBlank() } - return if (owner != null && repo != null) OwnerRepo(owner, repo) else null - } - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt deleted file mode 100644 index 7e4a1663..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClient.kt +++ /dev/null @@ -1,288 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.jorisjonkers.personalstack.assistant.config.AgentRuntimeProperties -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import org.slf4j.LoggerFactory -import org.springframework.http.HttpStatusCode -import org.springframework.stereotype.Component -import org.springframework.web.client.RestClient - -/** - * Thin REST adapter for the agent-gateway sidecar. Each method is - * one HTTP call; the gateway is the sole authority over what those - * verbs actually mean, so this client deliberately holds no logic of - * its own beyond URI building and response mapping. - */ -@Component -class HttpAgentGatewayClient( - private val restClient: RestClient, - private val props: AgentRuntimeProperties, -) : AgentGatewayClient { - private val log = LoggerFactory.getLogger(HttpAgentGatewayClient::class.java) - - private data class GatewayAgentDto( - val id: String, - val kind: WorkspaceAgentKind, - val cwd: String, - val cliSessionId: String? = null, - ) - - private data class SpawnBody( - val kind: WorkspaceAgentKind, - val workspacePath: String? = null, - ) - - private data class SendBody( - val input: String, - val enter: Boolean, - ) - - private data class StageInputBody( - val content: String, - val name: String?, - ) - - private data class StagedInputDto( - val path: String, - val bytes: Long, - val name: String, - ) - - private data class CloneBody( - val repoUrl: String, - val branch: String? = null, - val intoDir: String? = null, - ) - - private data class OpenPrBody( - val repoDir: String, - val title: String, - val body: String, - val base: String = "main", - ) - - private data class GitResult( - val ok: Boolean, - val output: String, - ) - - private data class VerifyBody( - val repoUrl: String, - val branch: String? = null, - ) - - private data class VerifyResult( - val read: Boolean = false, - val write: Boolean = false, - val detail: String = "", - ) - - private fun endpoint(workspace: Workspace): String = - workspace.gatewayEndpoint ?: error("workspace ${workspace.id} not yet provisioned with a gateway endpoint") - - override fun spawnAgent( - workspace: Workspace, - kind: WorkspaceAgentKind, - workspacePath: String?, - ): AgentGatewayClient.GatewayAgent { - val dto = - restClient - .post() - .uri("${endpoint(workspace)}/agents") - .body(SpawnBody(kind, workspacePath)) - .retrieve() - .body(GatewayAgentDto::class.java) - ?: error("empty response from gateway") - return AgentGatewayClient.GatewayAgent( - id = dto.id, - kind = dto.kind, - cwd = dto.cwd, - cliSessionId = dto.cliSessionId, - ) - } - - override fun stopAgent( - workspace: Workspace, - gatewayAgentId: String, - ) { - restClient - .delete() - .uri("${endpoint(workspace)}/agents/$gatewayAgentId") - .retrieve() - // idempotent — ignore 404 from a gateway agent we've already stopped - .onStatus(HttpStatusCode::is4xxClientError) { _, _ -> } - .toBodilessEntity() - } - - override fun sendInput( - workspace: Workspace, - gatewayAgentId: String, - input: String, - enter: Boolean, - ) { - restClient - .post() - .uri("${endpoint(workspace)}/agents/$gatewayAgentId/send") - .body(SendBody(input, enter)) - .retrieve() - .toBodilessEntity() - } - - override fun stageInput( - workspace: Workspace, - gatewayAgentId: String, - content: String, - name: String?, - ): AgentGatewayClient.StagedInput { - val dto = - restClient - .post() - .uri("${endpoint(workspace)}/agents/$gatewayAgentId/staged-inputs") - .body(StageInputBody(content, name)) - .retrieve() - .body(StagedInputDto::class.java) - ?: error("empty staged input response from gateway") - return AgentGatewayClient.StagedInput(path = dto.path, bytes = dto.bytes, name = dto.name) - } - - override fun capture( - workspace: Workspace, - gatewayAgentId: String, - ): String { - @Suppress("UNCHECKED_CAST") - val body = - restClient - .get() - .uri("${endpoint(workspace)}/agents/$gatewayAgentId/capture") - .retrieve() - .body(Map::class.java) as Map - return body["text"] ?: "" - } - - override fun clone( - workspace: Workspace, - repoUrl: String, - branch: String?, - ): String { - val resp = - restClient - .post() - .uri("${endpoint(workspace)}/git/clone") - .body(CloneBody(repoUrl = repoUrl, branch = branch)) - .retrieve() - .body(GitResult::class.java) - ?: error("empty response from gateway") - return resp.output - } - - override fun openPr( - workspace: Workspace, - repoDir: String, - title: String, - body: String, - base: String, - ): String { - val resp = - restClient - .post() - .uri("${endpoint(workspace)}/git/open-pr") - .body(OpenPrBody(repoDir = repoDir, title = title, body = body, base = base)) - .retrieve() - .body(GitResult::class.java) - ?: error("empty response from gateway") - return resp.output - } - - override fun verifyAccess( - repoUrl: String, - branch: String?, - ): AgentGatewayClient.AccessVerification? { - val base = props.verifyGatewayBaseUrl.trim().trimEnd('/') - if (base.isEmpty()) { - log.info("verifyAccess skipped — no verify-gateway-base-url configured") - return null - } - return runCatching { - restClient - .post() - .uri("$base/git/verify") - .body(VerifyBody(repoUrl = repoUrl, branch = branch)) - .retrieve() - .body(VerifyResult::class.java) - ?: error("empty response from gateway /git/verify") - }.map { - AgentGatewayClient.AccessVerification(read = it.read, write = it.write, detail = it.detail) - }.onFailure { - log.warn("verifyAccess for {} failed: {}", repoUrl, it.message) - }.getOrNull() - } - - override fun isReady(workspace: Workspace): Boolean = - runCatching { - restClient - .get() - .uri("${endpoint(workspace)}/healthz") - .retrieve() - .toBodilessEntity() - true - }.getOrElse { false } - - private data class HeadlessRequestBody( - val kind: WorkspaceAgentKind, - val prompt: String, - val cliSessionId: String? = null, - val timeoutSeconds: Long? = null, - ) - - private data class HeadlessJobDto( - val id: String, - val status: String, - val exitCode: Int? = null, - val output: String? = null, - ) - - override fun startHeadlessJob( - workspace: Workspace, - kind: WorkspaceAgentKind, - prompt: String, - cliSessionId: String?, - timeoutSeconds: Long?, - ): AgentGatewayClient.HeadlessJob { - val dto = - restClient - .post() - .uri("${endpoint(workspace)}/agents/headless") - .body(HeadlessRequestBody(kind, prompt, cliSessionId, timeoutSeconds)) - .retrieve() - .body(HeadlessJobDto::class.java) - ?: error("empty response from gateway /agents/headless") - return dto.toDomain() - } - - override fun pollHeadlessJob( - workspace: Workspace, - headlessJobId: String, - ): AgentGatewayClient.HeadlessJob { - val dto = - restClient - .get() - .uri("${endpoint(workspace)}/agents/headless/$headlessJobId") - .retrieve() - .body(HeadlessJobDto::class.java) - ?: error("empty response from gateway /agents/headless/$headlessJobId") - return dto.toDomain() - } - - private fun HeadlessJobDto.toDomain(): AgentGatewayClient.HeadlessJob = - AgentGatewayClient.HeadlessJob( - id = id, - status = - runCatching { - AgentGatewayClient.HeadlessStatus.valueOf(status) - }.getOrDefault(AgentGatewayClient.HeadlessStatus.FAILED), - exitCode = exitCode, - output = output, - ) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClient.kt deleted file mode 100644 index 23d7f5fa..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClient.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.jorisjonkers.personalstack.assistant.config.RagProperties -import com.jorisjonkers.personalstack.assistant.domain.port.KnowledgeWritePort -import com.jorisjonkers.personalstack.assistant.domain.port.RetrievalPort -import org.slf4j.LoggerFactory -import org.springframework.http.HttpHeaders -import org.springframework.stereotype.Component -import org.springframework.web.client.RestClient -import java.util.concurrent.atomic.AtomicLong - -/** - * JSON-RPC over HTTP against knowledge-api's /mcp endpoint. The - * server speaks the MCP 2024-11 dialect; we only need two tools - * (`knowledge.recall` and `knowledge.capture_lesson`) so the client - * is one-call-per-method rather than a full session manager. - * - * Auth: bearer-token allow-list (see knowledge-api/vault-secrets); - * each calling service has its own token, scoped by Vault path. - */ -@Component -class KnowledgeMcpClient( - private val restClient: RestClient, - private val props: RagProperties, -) : RetrievalPort, - KnowledgeWritePort { - private val log = LoggerFactory.getLogger(KnowledgeMcpClient::class.java) - private val mapper = jacksonObjectMapper() - private val seq = AtomicLong(0) - - override fun retrieve( - query: String, - limit: Int, - ): List { - if (!props.enabled) return emptyList() - return runCatching { - val resp = - callTool( - RECALL_TOOL, - mapOf("query" to query, "limit" to limit, "mode" to props.recallMode), - ) - val result = resp?.get("result") ?: return@runCatching emptyList() - // Prefer structuredContent.hits (MCP 2025-06) for typed parsing; - // fall back to text block for compatibility with older server. - val structuredHits = result.get("structuredContent")?.get("hits") - if (structuredHits != null && structuredHits.isArray) { - parseStructuredHits(structuredHits).take(limit) - } else { - val text = - result - .get("content") - ?.firstOrNull() - ?.get("text") - ?.asText() - .orEmpty() - if (text.isBlank()) emptyList() else parseRecallHits(text).take(limit) - } - }.getOrElse { - log.warn("knowledge.recall failed: {}", it.message) - emptyList() - } - } - - private fun parseStructuredHits(hitsNode: JsonNode): List = - hitsNode.mapNotNull { hit -> - val id = hit.get("id")?.asText()?.takeIf { it.isNotBlank() } - val title = hit.get("title")?.asText().orEmpty() - val snippet = hit.get("snippet")?.asText().orEmpty() - val score = hit.get("score")?.asDouble() ?: 0.0 - val scope = hit.get("scope")?.asText().orEmpty() - if (title.isBlank() && snippet.isBlank()) return@mapNotNull null - RetrievalPort.Snippet( - source = "kb:$scope:$title".trimEnd(':'), - text = snippet.ifEmpty { title }, - score = score, - id = id, - ) - } - - override fun ingestNote(request: KnowledgeWritePort.CaptureRequest) { - if (!props.enabled) return - runCatching { - val args = - mutableMapOf( - "title" to request.title, - "body" to request.body, - "scope" to request.scope, - "tags" to request.tags, - ) - request.source?.let { args["source"] = it } - request.sessionId?.let { args["session_id"] = it } - request.confidence?.let { args["confidence"] = it } - callTool( - CAPTURE_LESSON_TOOL, - args, - ) - }.onFailure { log.warn("knowledge.capture failed: {}", it.message) } - } - - override fun findDuplicateEvidence( - query: String, - minScore: Double, - ): KnowledgeWritePort.DuplicateEvidence? = - retrieve(query, limit = 1) - .firstOrNull { it.score >= minScore } - ?.let { hit -> - KnowledgeWritePort.DuplicateEvidence( - id = hit.id, - source = hit.source, - score = hit.score, - ) - } - - private fun callTool( - name: String, - arguments: Map, - ): JsonNode? { - val payload = - mapOf( - "jsonrpc" to "2.0", - "id" to seq.incrementAndGet(), - "method" to "tools/call", - "params" to mapOf("name" to name, "arguments" to arguments), - ) - return restClient - .post() - .uri("${props.knowledgeMcpUrl}/mcp") - .headers { h -> - h[HttpHeaders.CONTENT_TYPE] = "application/json" - if (props.knowledgeMcpToken.isNotBlank()) { - h[HttpHeaders.AUTHORIZATION] = "Bearer ${props.knowledgeMcpToken}" - } - }.body(payload) - .retrieve() - .body(String::class.java) - ?.let(mapper::readTree) - } - - private fun parseRecallHits(text: String): List { - // The recall tool's text form is one hit per blank-line-separated block: - // "title\nsnippet\n(score=0.42)". Keep parsing tolerant — server - // formatting churn shouldn't break agent context. - return text.split(Regex("\\n\\n+")).mapNotNull { chunk -> - val lines = chunk.lines().filter { it.isNotBlank() } - if (lines.isEmpty()) return@mapNotNull null - val score = scoreFromTrailer(lines.last()) - val title = lines.first() - val body = lines.drop(1).filterNot { it.startsWith("(score=") }.joinToString("\n") - RetrievalPort.Snippet(source = "kb:$title", text = body.ifEmpty { title }, score = score) - } - } - - private fun scoreFromTrailer(line: String): Double { - val m = Regex("score=([0-9.]+)").find(line) ?: return 0.0 - return m.groupValues[1].toDoubleOrNull() ?: 0.0 - } - - private companion object { - const val RECALL_TOOL = "knowledge.recall" - const val CAPTURE_LESSON_TOOL = "knowledge.capture_lesson" - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/LightRagClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/LightRagClient.kt deleted file mode 100644 index b9540fbe..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/LightRagClient.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.config.RagProperties -import com.jorisjonkers.personalstack.assistant.domain.port.RetrievalPort -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.http.MediaType -import org.springframework.stereotype.Component -import org.springframework.web.client.RestClient - -/** - * LightRAG /query in mix mode. The response shape is - * `{ "response": "..." }` — LightRAG renders a single fused answer - * rather than a list of snippets, so we wrap the whole answer as - * one Snippet. The KB recall adapter complements this by returning - * the per-note hits, which keeps the "raw snippets" channel useful - * for the agent to expand on a specific source. - */ -@Component -class LightRagClient( - private val restClient: RestClient, - private val props: RagProperties, - @param:Qualifier("assistantApiObjectMapper") - private val objectMapper: ObjectMapper, -) : RetrievalPort { - private val log = LoggerFactory.getLogger(LightRagClient::class.java) - - /** - * `top_k` snake-case stays on the wire because that's what - * LightRAG's HTTP API expects; the Kotlin field uses camelCase - * and Jackson rewrites it via @JsonProperty. - */ - private data class Req( - val query: String, - val mode: String = "mix", - @param:JsonProperty("top_k") val topK: Int = DEFAULT_TOP_K, - ) - - private data class StreamReq( - val query: String, - val mode: String = "mix", - @param:JsonProperty("top_k") val topK: Int = DEFAULT_TOP_K, - val stream: Boolean = true, - ) - - private data class Resp( - val response: String?, - ) - - override fun retrieve( - query: String, - limit: Int, - ): List { - if (!props.enabled) return emptyList() - return runCatching { - val body = - restClient - .post() - .uri("${props.lightragUrl}/query") - .body(Req(query = query, topK = limit)) - .retrieve() - .body(Resp::class.java) - val text = body?.response ?: "" - if (text.isBlank()) emptyList() else listOf(RetrievalPort.Snippet("lightrag", text, 1.0)) - }.getOrElse { - log.warn("LightRAG query failed: {}", it.message) - emptyList() - } - } - - fun streamQuery( - query: String, - onChunk: (String) -> Unit, - ): String { - val streamed = streamFromLightRag(query, onChunk) - if (streamed.isNotBlank()) return streamed - - val fallback = - retrieve(query, props.maxSnippets) - .firstOrNull() - ?.text - ?: "" - if (fallback.isNotBlank()) onChunk(fallback) - return fallback - } - - private fun streamFromLightRag( - query: String, - onChunk: (String) -> Unit, - ): String { - val answer = StringBuilder() - runCatching { - if (!props.enabled) return@runCatching "" - restClient - .post() - .uri("${props.lightragUrl}/query/stream") - .accept(MediaType.parseMediaType("application/x-ndjson"), MediaType.APPLICATION_JSON) - .body(StreamReq(query = query, topK = props.maxSnippets)) - .exchange { _, response -> - check(response.statusCode.is2xxSuccessful) { - "LightRAG stream returned ${response.statusCode}" - } - response.body.bufferedReader(Charsets.UTF_8).use { reader -> - reader.forEachLine { line -> - appendStreamPiece(line, answer, onChunk) - } - } - } - answer.toString() - }.onFailure { - log.warn("LightRAG stream failed: {}", it.message) - } - return answer.toString() - } - - private fun appendStreamPiece( - line: String, - answer: StringBuilder, - onChunk: (String) -> Unit, - ) { - if (line.isBlank()) return - val piece = objectMapper.readTree(line).path("response").asText(null) - if (piece.isNullOrEmpty()) return - answer.append(piece) - onChunk(piece) - } - - companion object { - private const val DEFAULT_TOP_K = 5 - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/VaultDeployKeyStore.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/VaultDeployKeyStore.kt deleted file mode 100644 index 40be3070..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/VaultDeployKeyStore.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.common.vault.VaultKeyValueWriter -import com.jorisjonkers.personalstack.common.vault.VaultSecretProvider -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.stereotype.Component -import java.security.MessageDigest -import java.util.Base64 - -/** - * Vault-backed adapter that materialises a deploy key. Two path - * schemes coexist during the V9 migration window: - * - * - Legacy: `secret/data/agents/projects//repos/` - * — written by the old per-project link flow, still read by the - * orchestrator when a Workspace carries `githubLinkId`. - * - New: `secret/data/agents/repositories/` — - * written by the redesigned per-repository attach-key wizard. - * The repository-keyed readers fall back to the legacy path so a - * pre-V9 deploy key remains usable after the migration without a - * manual Vault copy. - * - * The fingerprint is mirrored back into the Repository / GithubLink - * row so the UI does not need a Vault round-trip to render. - */ -@Component -@ConditionalOnProperty("spring.cloud.vault.enabled", havingValue = "true", matchIfMissing = false) -open class VaultDeployKeyStore( - private val writer: VaultKeyValueWriter, - private val reader: VaultSecretProvider, -) : DeployKeyStore { - override fun store( - projectId: ProjectId, - linkId: GithubLinkId, - privateKeyOpenssh: String, - publicKeyOpenssh: String, - knownHosts: String, - ): DeployKeyStore.StoredKey = - writeKey(legacyPath(projectId, linkId), privateKeyOpenssh, publicKeyOpenssh, knownHosts) - - override fun remove( - projectId: ProjectId, - linkId: GithubLinkId, - ) { - writer.deleteSecret(legacyPath(projectId, linkId)) - } - - override fun readPublicKey( - projectId: ProjectId, - linkId: GithubLinkId, - ): String? = runCatching { reader.getSecret(legacyPath(projectId, linkId), "public_key") }.getOrNull() - - override fun loadKey( - projectId: ProjectId, - linkId: GithubLinkId, - ): DeployKeyStore.KeyMaterial? = readKey(legacyPath(projectId, linkId)) - - override fun store( - repositoryId: RepositoryId, - privateKeyOpenssh: String, - publicKeyOpenssh: String, - knownHosts: String, - ): DeployKeyStore.StoredKey = - writeKey(repositoryPath(repositoryId), privateKeyOpenssh, publicKeyOpenssh, knownHosts) - - override fun remove(repositoryId: RepositoryId) { - writer.deleteSecret(repositoryPath(repositoryId)) - } - - /** - * Per-repo readers fall back to the legacy `linkId == repoId` - * path when the new path is empty. The V9 migration copies - * github_links.id verbatim into repositories.id, so a legacy - * key remains addressable by the same UUID through the rollout - * window — when [readPublicKey] / [loadKey] called on the new - * path returns nothing, every project that has a junction row - * with that repository_id is tried in order as the legacy - * `(projectId, linkId)` path. - */ - override fun readPublicKey(repositoryId: RepositoryId): String? { - val fromNew = runCatching { reader.getSecret(repositoryPath(repositoryId), "public_key") }.getOrNull() - if (fromNew != null) return fromNew - return null - } - - override fun loadKey(repositoryId: RepositoryId): DeployKeyStore.KeyMaterial? = - readKey(repositoryPath(repositoryId)) - - private fun writeKey( - path: String, - privateKeyOpenssh: String, - publicKeyOpenssh: String, - knownHosts: String, - ): DeployKeyStore.StoredKey { - val fingerprint = sha256Fingerprint(publicKeyOpenssh) - writer.writeSecret( - path, - mapOf( - "private_key" to privateKeyOpenssh.trim() + "\n", - "public_key" to publicKeyOpenssh.trim() + "\n", - "known_hosts" to knownHosts.trim() + "\n", - "fingerprint" to fingerprint, - ), - ) - return DeployKeyStore.StoredKey(fingerprint = fingerprint, vaultPath = path) - } - - private fun readKey(path: String): DeployKeyStore.KeyMaterial? { - val priv = runCatching { reader.getSecret(path, "private_key") }.getOrNull() ?: return null - val pub = runCatching { reader.getSecret(path, "public_key") }.getOrNull() ?: return null - val known = runCatching { reader.getSecret(path, "known_hosts") }.getOrNull() ?: "" - val fp = runCatching { reader.getSecret(path, "fingerprint") }.getOrNull() ?: "" - return DeployKeyStore.KeyMaterial(privateKey = priv, publicKey = pub, knownHosts = known, fingerprint = fp) - } - - private fun legacyPath( - projectId: ProjectId, - linkId: GithubLinkId, - ): String = "secret/data/agents/projects/$projectId/repos/$linkId" - - private fun repositoryPath(repositoryId: RepositoryId): String = "secret/data/agents/repositories/$repositoryId" - - private fun sha256Fingerprint(publicKey: String): String { - // GitHub-compatible OpenSSH SHA-256 fingerprint: the - // base64-encoded sha256 of the raw key body (the middle - // field between "ssh-ed25519" and the comment), with the - // trailing `=` padding stripped. - val parts = publicKey.trim().split(Regex("\\s+")) - if (parts.size < 2) error("malformed public key — expected ` [comment]`") - val body = Base64.getDecoder().decode(parts[1]) - val digest = MessageDigest.getInstance("SHA-256").digest(body) - return "SHA256:" + Base64.getEncoder().withoutPadding().encodeToString(digest) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt deleted file mode 100644 index cb966bcf..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt +++ /dev/null @@ -1,611 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.k8s - -import com.jorisjonkers.personalstack.assistant.config.AgentRuntimeProperties -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import io.fabric8.kubernetes.api.model.ContainerPortBuilder -import io.fabric8.kubernetes.api.model.EnvVarBuilder -import io.fabric8.kubernetes.api.model.PersistentVolumeClaim -import io.fabric8.kubernetes.api.model.PersistentVolumeClaimBuilder -import io.fabric8.kubernetes.api.model.Pod -import io.fabric8.kubernetes.api.model.PodBuilder -import io.fabric8.kubernetes.api.model.Quantity -import io.fabric8.kubernetes.api.model.Secret -import io.fabric8.kubernetes.api.model.SecretBuilder -import io.fabric8.kubernetes.api.model.ServiceBuilder -import io.fabric8.kubernetes.api.model.Volume -import io.fabric8.kubernetes.api.model.VolumeBuilder -import io.fabric8.kubernetes.api.model.VolumeMountBuilder -import io.fabric8.kubernetes.client.KubernetesClient -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.ObjectProvider -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Component -import java.util.Base64 - -/** - * Owns the entire lifecycle of one workspace's runner Pod. The shape - * is fixed: one Pod (`agent-runner-`), one workspace PVC - * (`workspace-`), one ClusterIP Service so the gateway has - * a stable in-cluster name. Adversarial use is anticipated, so the - * Pod runs as UID 1000, with read-only mounts for the credential - * PVCs that the agent itself doesn't need to write. Docker socket - * access is intentionally host-equivalent and exists so repo tests - * using Testcontainers can run inside future agent sessions. - * - * Per-workspace deploy-key isolation: when the workspace's - * `githubLinkId` is set, the orchestrator looks up the link, reads - * the deploy key from Vault via [DeployKeyStore], and stamps a - * workspace-scoped k8s Secret out of it. The Pod mounts that Secret - * instead of the cluster-wide `agents-github-deploy-key`, so a - * workspace can only ever push to its own Project's repos. - * - * fabric8's fluent builder chains naturally split into one helper per - * pod section (labels, env, mounts, volumes, container body); below - * the 15-function / LargeClass detekt thresholds would defeat the - * readability win. The class stays as a single orchestrator because - * every helper here operates on the same shared props + client. - */ -@Suppress("TooManyFunctions", "LargeClass", "DEPRECATION") -@Component -@Profile("!system-test") -class Fabric8AgentRunnerOrchestrator( - private val client: KubernetesClient, - private val props: AgentRuntimeProperties, - private val deployKeysProvider: ObjectProvider, - private val githubLinks: ObjectProvider, - private val workspaceRepos: ObjectProvider, - private val repositories: ObjectProvider, -) : AgentRunnerOrchestrator { - private val log = LoggerFactory.getLogger(Fabric8AgentRunnerOrchestrator::class.java) - - override fun provision(workspace: Workspace): AgentRunnerOrchestrator.RunnerHandle { - val short = workspace.id.short() - val podName = "agent-runner-$short" - val pvcName = "workspace-$short" - val serviceName = "agent-runner-$short" - val deployKeySecretName = ensureDeployKeySecret(workspace, short) - applyResources(workspace, podName, pvcName, serviceName, deployKeySecretName) - val endpoint = "http://$serviceName.${props.namespace}.svc.cluster.local:${props.gatewayPort}" - log.info( - "provisioned runner pod {} for workspace {} using deploy-key Secret {}", - podName, - workspace.id, - deployKeySecretName, - ) - return AgentRunnerOrchestrator.RunnerHandle( - podName = podName, - pvcName = pvcName, - gatewayEndpoint = endpoint, - ) - } - - private fun applyResources( - workspace: Workspace, - podName: String, - pvcName: String, - serviceName: String, - deployKeySecretName: String, - ) { - client - .persistentVolumeClaims() - .inNamespace(props.namespace) - .resource(pvc(pvcName)) - .serverSideApply() - client - .pods() - .inNamespace(props.namespace) - .resource(pod(workspace, podName, pvcName, deployKeySecretName)) - .serverSideApply() - client - .services() - .inNamespace(props.namespace) - .resource(service(serviceName, podName)) - .serverSideApply() - } - - override fun scaleDown(workspace: Workspace) { - val short = workspace.id.short() - client - .pods() - .inNamespace(props.namespace) - .withName("agent-runner-$short") - .delete() - client - .services() - .inNamespace(props.namespace) - .withName("agent-runner-$short") - .delete() - log.info("scaled down runner pod for workspace {} (PVC preserved)", workspace.id) - } - - override fun destroy(workspace: Workspace) { - val short = workspace.id.short() - client - .pods() - .inNamespace(props.namespace) - .withName("agent-runner-$short") - .delete() - client - .services() - .inNamespace(props.namespace) - .withName("agent-runner-$short") - .delete() - client - .persistentVolumeClaims() - .inNamespace(props.namespace) - .withName("workspace-$short") - .delete() - // Per-workspace deploy-key Secret only exists when the workspace was bound to a GithubLink. - if (workspace.githubLinkId != null) { - client - .secrets() - .inNamespace(props.namespace) - .withName(workspaceSecretName(short)) - .delete() - } - log.info("destroyed runner pod and PVC for workspace {}", workspace.id) - } - - override fun isReady(workspace: Workspace): Boolean { - val name = workspace.podName ?: return false - val pod = - client - .pods() - .inNamespace(props.namespace) - .withName(name) - .get() ?: return false - val containerReady = - pod.status - ?.containerStatuses - ?.firstOrNull() - ?.ready ?: false - return containerReady && pod.status?.phase == "Running" - } - - private fun workspaceSecretName(short: String): String = "agent-runner-deploy-key-$short" - - /** - * Resolve the deploy-key Secret name for a workspace. If the - * workspace is project-backed and the linked GithubLink has a - * Vault-stored key, that key gets stamped into a workspace- - * scoped Secret and its name is returned. Every other path — - * no link, link without key, Vault adapter disabled — falls - * back to the shared cluster-wide Secret so the Pod can still - * come up. - */ - private fun ensureDeployKeySecret( - workspace: Workspace, - short: String, - ): String { - val material = resolveKeyMaterial(workspace) ?: return props.githubDeployKeySecret - val secretName = workspaceSecretName(short) - val secret = buildWorkspaceSecret(secretName, short, material.link, material.key) - client - .secrets() - .inNamespace(props.namespace) - .resource(secret) - .serverSideApply() - return secretName - } - - private data class ResolvedKey( - val link: GithubLink, - val key: DeployKeyStore.KeyMaterial, - ) - - // Early-out validation chain — explicit guards read better than - // a chained Result here; suppressing detekt's bounded-return - // and bounded-function rules with intent. - @Suppress("ReturnCount") - private fun resolveKeyMaterial(workspace: Workspace): ResolvedKey? { - val linkId = workspace.githubLinkId ?: return null - val links = githubLinks.ifAvailable ?: return null - val keys = deployKeysProvider.ifAvailable ?: return null - val link = - links.findById(linkId).also { - if (it == null) log.warn("workspace {} references missing GithubLink {}", workspace.id, linkId) - } ?: return null - val key = - keys.loadKey(link.projectId, link.id).also { - if (it == null) log.warn("workspace {} link {} has no Vault key yet", workspace.id, linkId) - } ?: return null - return ResolvedKey(link, key) - } - - private fun buildWorkspaceSecret( - name: String, - short: String, - link: GithubLink, - material: DeployKeyStore.KeyMaterial, - ): Secret { - // fabric8 7.x's typed-builder `withLabels` / `withData` - // resolve cleanly only when the `Map` literal lives inline - // at the call site; an extracted `val labels: Map` makes Kotlin's overload-resolution choke on K/V - // type parameters. Inlining is the pragmatic fix. - return SecretBuilder() - .withNewMetadata() - .withName(name) - .withNamespace(props.namespace) - .withLabels( - mapOf( - "app.kubernetes.io/part-of" to "agent-runner", - "agent-runner/workspace-id" to short, - "agent-runner/github-link-id" to link.id.toString(), - ), - ).endMetadata() - .withType("Opaque") - .withData( - mapOf( - "private_key" to b64(material.privateKey), - "public_key" to b64(material.publicKey), - "known_hosts" to b64(material.knownHosts), - "fingerprint" to b64(material.fingerprint), - ), - ).build() - } - - private fun b64(s: String): String = Base64.getEncoder().encodeToString(s.toByteArray()) - - private fun pvc(name: String): PersistentVolumeClaim = - PersistentVolumeClaimBuilder() - .withNewMetadata() - .withName(name) - .withNamespace(props.namespace) - .endMetadata() - .withNewSpec() - .withAccessModes("ReadWriteOnce") - .withStorageClassName(props.workspaceStorageClass) - .withNewResources() - .withRequests(mapOf("storage" to Quantity(props.workspaceStorageSize))) - .endResources() - .endSpec() - .build() - - // fabric8 builder chains can't be cleanly split into helpers - // because the intermediate fluent types are private; LongMethod - // is the natural shape here and suppressed with intent. The - // inline `mapOf(...)` calls are likewise intentional: extracting - // them into typed vals trips Kotlin overload resolution on - // fabric8 7.x's `withLabels` / `withRequests` / `withLimits`. - @Suppress("LongMethod") - private fun pod( - workspace: Workspace, - name: String, - workspacePvc: String, - deployKeySecret: String, - ): Pod = - PodBuilder() - .withNewMetadata() - .withName(name) - .withNamespace(props.namespace) - .withLabels(podLabels(workspace)) - .endMetadata() - .withNewSpec() - .withServiceAccountName(props.serviceAccount) - // The runner never calls the Kubernetes API itself — cluster - // reads go through the read-only kubernetes MCP server over - // HTTP. Don't project the SA token into the Pod, so the agent - // (which runs unsandboxed) holds no API credential at all and - // cannot reach the API server even if the SA were later granted - // RBAC by mistake. - .withAutomountServiceAccountToken(false) - .withNodeSelector(props.nodeSelector) - .withRestartPolicy("Always") - .withNewSecurityContext() - .withRunAsUser(RUN_AS_UID) - .withRunAsGroup(RUN_AS_GID) - .withFsGroup(FS_GROUP) - .withSupplementalGroups(podSupplementalGroups()) - .endSecurityContext() - .addNewContainer() - .withName("agent-runner") - .withImage(props.image) - .withImagePullPolicy(props.imagePullPolicy) - .withPorts(ContainerPortBuilder().withName("gateway").withContainerPort(props.gatewayPort).build()) - .withEnv(podEnv(workspace)) - .withVolumeMounts(podVolumeMounts()) - // Startup probe gates liveness + readiness until the gateway's - // JVM has finished its cold start. Without it the liveness probe - // (failureThreshold 3 x 10s ~= 30s, no initial delay) killed the - // booting Spring Boot gateway before it bound :8090, which - // re-provisioned the runner in a loop and 503'd every - // start-session. 60 x 5s = 5 min of boot headroom. - .withNewStartupProbe() - .withNewHttpGet() - .withPath("/healthz") - .withNewPort("gateway") - .endHttpGet() - .withPeriodSeconds(STARTUP_PERIOD_SECONDS) - .withFailureThreshold(STARTUP_FAILURE_THRESHOLD) - .endStartupProbe() - .withNewReadinessProbe() - .withNewHttpGet() - .withPath("/healthz") - .withNewPort("gateway") - .endHttpGet() - .withPeriodSeconds(READINESS_PERIOD_SECONDS) - .withFailureThreshold(READINESS_FAILURE_THRESHOLD) - .endReadinessProbe() - .withNewLivenessProbe() - .withNewHttpGet() - .withPath("/healthz") - .withNewPort("gateway") - .endHttpGet() - .withPeriodSeconds(LIVENESS_PERIOD_SECONDS) - .endLivenessProbe() - .withNewResources() - .withRequests(mapOf("cpu" to Quantity(CPU_REQUEST), "memory" to Quantity(MEMORY_REQUEST))) - .withLimits(mapOf("cpu" to Quantity(CPU_LIMIT), "memory" to Quantity(MEMORY_LIMIT))) - .endResources() - .endContainer() - .withVolumes(podVolumes(workspacePvc, deployKeySecret)) - .endSpec() - .build() - - private fun podLabels(workspace: Workspace): Map = - mapOf( - "app.kubernetes.io/name" to "agent-runner", - "app.kubernetes.io/part-of" to "agent-runner", - "agent-runner/workspace-id" to workspace.id.short(), - ) - - private fun podEnv(workspace: Workspace) = - buildList { - add(EnvVarBuilder().withName("HOME").withValue("/home/agent").build()) - add(EnvVarBuilder().withName("CODEX_HOME").withValue("/home/agent/.codex").build()) - add(EnvVarBuilder().withName("DEPLOYMENT_ENVIRONMENT").withValue("production").build()) - add(EnvVarBuilder().withName("OTEL_SERVICE_NAME").withValue("agent-gateway").build()) - add( - EnvVarBuilder() - .withName("OTEL_EXPORTER_OTLP_ENDPOINT") - .withValue("http://alloy.observability.svc.cluster.local:4318") - .build(), - ) - add(EnvVarBuilder().withName("OTEL_EXPORTER_OTLP_PROTOCOL").withValue("http/protobuf").build()) - // The runner Pod is the outer sandbox for the agent process. - // Docker socket access is the explicit host-equivalent exception - // for Testcontainers and Docker CLI workflows. IS_SANDBOX tells - // Claude Code so that --dangerously-skip-permissions runs without - // the bypass-mode warning + acceptance prompt. - add(EnvVarBuilder().withName("IS_SANDBOX").withValue("1").build()) - add(EnvVarBuilder().withName("AGENT_MCP_PROFILE").withValue(props.defaultMcpProfile).build()) - addAll(dockerEnv()) - addAll(knowledgeEnv()) - addAll(githubAppTokenEnv()) - // REPO_URL/REPO_BRANCH drive the entrypoint's boot-time clone - // into /workspace/. Cloning in the runner removes the race that - // left repo-backed workspaces empty: the old create-time - // gateway.clone fired before the runner gateway was up and was - // swallowed. Only repo-backed workspaces carry a repoUrl. - workspace.repoUrl?.let { url -> - add(EnvVarBuilder().withName("REPO_URL").withValue(url).build()) - workspace.branch?.let { add(EnvVarBuilder().withName("REPO_BRANCH").withValue(it).build()) } - } - // REPO_URLS carries the workspace's additional repos (everything - // attached that is not the primary), as url#branch entries. The - // entrypoint clones each into /workspace/ over the - // App-token credential helper. - additionalRepoUrls(workspace).takeIf { it.isNotEmpty() }?.let { urls -> - add(EnvVarBuilder().withName("REPO_URLS").withValue(urls.joinToString(" ")).build()) - } - } - - private fun additionalRepoUrls(workspace: Workspace): List { - val links = workspaceRepos.ifAvailable ?: return emptyList() - val repos = repositories.ifAvailable ?: return emptyList() - return links - .findAllByWorkspaceId(workspace.id) - .filterNot { it.isPrimary } - .mapNotNull { link -> - repos.findById(link.repositoryId)?.let { repo -> "${repo.repoUrl}#${repo.defaultBranch}" } - } - } - - private fun dockerEnv() = - if (!props.dockerSocketEnabled) { - emptyList() - } else { - listOf( - EnvVarBuilder().withName("DOCKER_HOST").withValue("unix://${props.dockerSocketPath}").build(), - EnvVarBuilder() - .withName("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE") - .withValue(props.dockerSocketPath) - .build(), - EnvVarBuilder() - .withName("AGENT_RUNNER_NODE_HOST_IP") - .withNewValueFrom() - .withNewFieldRef() - .withFieldPath("status.hostIP") - .endFieldRef() - .endValueFrom() - .build(), - EnvVarBuilder() - .withName("TESTCONTAINERS_HOST_OVERRIDE") - .withValue("$(AGENT_RUNNER_NODE_HOST_IP)") - .build(), - ) - } - - // KB_URL + KB_BEARER_TOKEN are the exact names the knowledge-system - // install.sh hooks read; without the bearer every hook short-circuits - // to a no-op and the knowledge.* MCP tools are unreachable. - private fun knowledgeEnv() = - listOf( - EnvVarBuilder().withName("KB_URL").withValue(props.knowledgeBaseUrl).build(), - EnvVarBuilder() - .withName("KB_BEARER_TOKEN") - .withNewValueFrom() - .withNewSecretKeyRef() - .withName(props.knowledgeBearerSecret) - .withKey(props.knowledgeBearerSecretKey) - .endSecretKeyRef() - .endValueFrom() - .build(), - ) - - // The bearer is an optional Secret ref: an absent github-app Secret - // keeps the Pod starting and the `gh` wrapper degrades to a no-op. - private fun githubAppTokenEnv() = - listOf( - EnvVarBuilder().withName("GITHUB_APP_TOKEN_URL").withValue(props.githubAppTokenUrl).build(), - EnvVarBuilder() - .withName("GITHUB_APP_TOKEN_BEARER") - .withNewValueFrom() - .withNewSecretKeyRef() - .withName(props.githubAppBearerSecret) - .withKey(props.githubAppBearerSecretKey) - .withOptional(true) - .endSecretKeyRef() - .endValueFrom() - .build(), - ) - - private fun podVolumeMounts() = - buildList { - add(VolumeMountBuilder().withName("workspace").withMountPath("/workspace").build()) - add(VolumeMountBuilder().withName("claude-credentials").withMountPath("/home/agent/.claude").build()) - add(VolumeMountBuilder().withName("codex-credentials").withMountPath("/home/agent/.codex").build()) - if (props.dockerSocketEnabled) { - add( - VolumeMountBuilder() - .withName(DOCKER_SOCKET_VOLUME) - .withMountPath(props.dockerSocketPath) - .build(), - ) - } - add( - VolumeMountBuilder() - .withName("github-deploy-key") - .withMountPath("/var/run/secrets/agents/github-deploy-key") - .withReadOnly(true) - .build(), - ) - // Declarative MCP server set; the entrypoint seeds it into - // ~/.claude.json. Optional volume, so an absent ConfigMap - // leaves the runner with no managed MCP servers. - add( - VolumeMountBuilder() - .withName("mcp-config") - .withMountPath("/etc/agent-mcp") - .withReadOnly(true) - .build(), - ) - } - - private fun podVolumes( - workspacePvc: String, - deployKeySecret: String, - ) = buildList { - add(pvcVolume("workspace", workspacePvc)) - add(pvcVolume("claude-credentials", props.claudeCredentialsPvc)) - add(pvcVolume("codex-credentials", props.codexCredentialsPvc)) - dockerSocketVolume()?.let(::add) - add(githubDeployKeyVolume(deployKeySecret)) - add(mcpConfigVolume()) - } - - private fun pvcVolume( - name: String, - claim: String, - ) = VolumeBuilder() - .withName(name) - .withNewPersistentVolumeClaim() - .withClaimName(claim) - .endPersistentVolumeClaim() - .build() - - private fun dockerSocketVolume(): Volume? = - if (!props.dockerSocketEnabled) { - null - } else { - VolumeBuilder() - .withName(DOCKER_SOCKET_VOLUME) - .withNewHostPath() - .withPath(props.dockerSocketPath) - .withType("Socket") - .endHostPath() - .build() - } - - private fun githubDeployKeyVolume(deployKeySecret: String): Volume = - VolumeBuilder() - .withName("github-deploy-key") - .withNewSecret() - .withSecretName(deployKeySecret) - .endSecret() - .build() - - private fun mcpConfigVolume(): Volume = - VolumeBuilder() - .withName("mcp-config") - .withNewConfigMap() - .withName(props.mcpServersConfigMap) - .withOptional(true) - .endConfigMap() - .build() - - private fun podSupplementalGroups(): List = - if (props.dockerSocketEnabled) { - (listOf(RUN_AS_GID) + props.dockerSocketSupplementalGroups).distinct() - } else { - listOf(RUN_AS_GID) - } - - private fun service( - name: String, - podName: String, - ): io.fabric8.kubernetes.api.model.Service = - ServiceBuilder() - .withNewMetadata() - .withName(name) - .withNamespace(props.namespace) - .endMetadata() - .withNewSpec() - .withSelector(mapOf("agent-runner/workspace-id" to podName.substringAfter("agent-runner-"))) - .addNewPort() - .withName("gateway") - .withPort(props.gatewayPort) - .withNewTargetPort("gateway") - .endPort() - .endSpec() - .build() - - companion object { - // Pod security: non-root, with fsGroup so PVC mounts get the right owner. - private const val RUN_AS_UID = 1000L - private const val RUN_AS_GID = 1000L - private const val FS_GROUP = 1000L - private const val DOCKER_SOCKET_VOLUME = "docker-socket" - - // Resource sizing. One Pod hosts the gateway JVM, the agent CLIs - // (Claude Code, Codex) and the workspace's own processes in a - // single memory cgroup. At a 3Gi limit a real Claude session - // alongside the JVM tripped the cgroup OOM killer (exit 137), - // taking every tmux session in the Pod down at once. The gateway - // heap is now capped small (see agent-runner entrypoint), and the - // limit is raised to 10Gi so the CLIs and build tooling have real - // headroom; the request reserves a baseline that covers the JVM - // plus an idle CLI. - private const val CPU_REQUEST = "250m" - private const val MEMORY_REQUEST = "2Gi" - private const val CPU_LIMIT = "2000m" - private const val MEMORY_LIMIT = "10Gi" - - // Probe cadence. The startup probe loops 60×5s = 5 min so the - // gateway's JVM cold start (slower on a fresh image pull) - // completes before liveness can fire; readiness shares the same - // budget as a backstop. - private const val STARTUP_PERIOD_SECONDS = 5 - private const val STARTUP_FAILURE_THRESHOLD = 60 - private const val READINESS_PERIOD_SECONDS = 5 - private const val READINESS_FAILURE_THRESHOLD = 60 - private const val LIVENESS_PERIOD_SECONDS = 10 - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/StubAgentRunnerOrchestrator.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/StubAgentRunnerOrchestrator.kt deleted file mode 100644 index 060e3207..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/StubAgentRunnerOrchestrator.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.k8s - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import org.slf4j.LoggerFactory -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Component - -/** - * No-op orchestrator activated by the `system-test` Spring profile. - * The system-test docker-compose stack doesn't include a Kubernetes - * cluster, so the real fabric8 orchestrator (`@Profile("!system-test")`) - * cannot run there — the Playwright workspace-flow specs would all - * fall over on the first PVC apply. - * - * The stub returns synthesised runner-handle metadata so the - * assistant-api's workspace lifecycle commands behave identically - * end-to-end except for the actual Pod side-effects. `isReady` is - * permanently true, which lets the UI flip a fresh workspace - * straight to `READY` (rather than parking in `STARTING` while - * polling a Pod that will never schedule). - * - * Real-cluster behaviour is exercised by - * `Fabric8AgentRunnerOrchestratorIntegrationTest` in the assistant-api - * `integrationTest` source set against a Testcontainers k3s. - */ -@Component -@Profile("system-test") -class StubAgentRunnerOrchestrator : AgentRunnerOrchestrator { - private val log = LoggerFactory.getLogger(StubAgentRunnerOrchestrator::class.java) - - override fun provision(workspace: Workspace): AgentRunnerOrchestrator.RunnerHandle { - val short = workspace.id.short() - val handle = - AgentRunnerOrchestrator.RunnerHandle( - podName = "agent-runner-$short", - pvcName = "workspace-$short", - gatewayEndpoint = "http://stub.system-test.invalid:0", - ) - log.info("stub orchestrator: provision({}) -> {}", workspace.id, handle.podName) - return handle - } - - override fun scaleDown(workspace: Workspace) { - log.info("stub orchestrator: scaleDown({}) — no-op", workspace.id) - } - - override fun destroy(workspace: Workspace) { - log.info("stub orchestrator: destroy({}) — no-op", workspace.id) - } - - override fun isReady(workspace: Workspace): Boolean = true -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/messaging/.gitkeep b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/messaging/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqChatMessageRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqChatMessageRepository.kt deleted file mode 100644 index 0b5533e2..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqChatMessageRepository.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessage -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.port.ChatMessageRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqChatMessageRepository( - private val dsl: DSLContext, -) : ChatMessageRepository { - override fun save(message: ChatMessage): ChatMessage { - val createdAt = message.createdAt.atOffset(ZoneOffset.UTC) - dsl - .insertInto(TABLE) - .set(ID, message.id.value) - .set(SESSION_ID, message.sessionId.value) - .set(ROLE, message.role.name) - .set(BODY, message.body) - .set(CREATED_AT, createdAt) - .onConflict(ID) - .doUpdate() - .set(BODY, message.body) - .execute() - return message - } - - override fun findById(id: ChatMessageId): ChatMessage? = - dsl - .selectFrom(TABLE) - .where(ID.eq(id.value)) - .fetchOne() - ?.toMessage() - - override fun findAllBySessionIdOrderedByTime(sessionId: ChatSessionId): List = - dsl - .selectFrom(TABLE) - .where(SESSION_ID.eq(sessionId.value)) - .orderBy(CREATED_AT.asc()) - .fetch() - .map { it.toMessage() } - - override fun deleteAllBySessionId(sessionId: ChatSessionId) { - dsl.deleteFrom(TABLE).where(SESSION_ID.eq(sessionId.value)).execute() - } - - private fun Record.toMessage(): ChatMessage = - ChatMessage( - id = ChatMessageId(this[ID]), - sessionId = ChatSessionId(this[SESSION_ID]), - role = ChatMessageRole.valueOf(this[ROLE]), - body = this[BODY], - createdAt = this[CREATED_AT].toInstant(), - ) - - companion object { - @JvmStatic val TABLE = DSL.table("chat_session_messages") - - @JvmStatic val ID = DSL.field("id", UUID::class.java) - - @JvmStatic val SESSION_ID = DSL.field("chat_session_id", UUID::class.java) - - @JvmStatic val ROLE = DSL.field("role", String::class.java) - - @JvmStatic val BODY = DSL.field("body", String::class.java) - - @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqChatSessionRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqChatSessionRepository.kt deleted file mode 100644 index bca8c6b5..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqChatSessionRepository.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqChatSessionRepository( - private val dsl: DSLContext, -) : ChatSessionRepository { - override fun save(session: ChatSession): ChatSession { - val createdAt = session.createdAt.atOffset(ZoneOffset.UTC) - val updatedAt = session.updatedAt.atOffset(ZoneOffset.UTC) - dsl - .insertInto(TABLE) - .set(ID, session.id.value) - .set(USER_ID, session.userId) - .set(TITLE, session.title) - .set(STATUS, session.status.name) - .set(KIND, session.kind.name) - .set(CREATED_AT, createdAt) - .set(UPDATED_AT, updatedAt) - .onConflict(ID) - .doUpdate() - .set(TITLE, session.title) - .set(STATUS, session.status.name) - .set(UPDATED_AT, updatedAt) - .execute() - return session - } - - override fun findById(id: ChatSessionId): ChatSession? = - dsl - .selectFrom(TABLE) - .where(ID.eq(id.value)) - .fetchOne() - ?.toSession() - - override fun findAllByUserId(userId: UUID): List = - dsl - .selectFrom(TABLE) - .where(USER_ID.eq(userId)) - .orderBy(CREATED_AT.desc()) - .fetch() - .map { it.toSession() } - - override fun delete(id: ChatSessionId) { - dsl.deleteFrom(TABLE).where(ID.eq(id.value)).execute() - } - - private fun Record.toSession(): ChatSession = - ChatSession( - id = ChatSessionId(this[ID]), - userId = this[USER_ID], - title = this[TITLE], - status = ChatSessionStatus.valueOf(this[STATUS]), - kind = ChatSessionKind.valueOf(this[KIND]), - createdAt = this[CREATED_AT].toInstant(), - updatedAt = this[UPDATED_AT].toInstant(), - ) - - companion object { - @JvmStatic val TABLE = DSL.table("chat_sessions") - - @JvmStatic val ID = DSL.field("id", UUID::class.java) - - @JvmStatic val USER_ID = DSL.field("user_id", UUID::class.java) - - @JvmStatic val TITLE = DSL.field("title", String::class.java) - - @JvmStatic val STATUS = DSL.field("status", String::class.java) - - @JvmStatic val KIND = DSL.field("kind", String::class.java) - - @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) - - @JvmStatic val UPDATED_AT = DSL.field("updated_at", OffsetDateTime::class.java) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqConversationRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqConversationRepository.kt deleted file mode 100644 index 052d243d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqConversationRepository.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import com.jorisjonkers.personalstack.assistant.jooq.tables.Conversation.CONVERSATION -import org.jooq.DSLContext -import org.jooq.Record -import org.springframework.stereotype.Repository -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqConversationRepository( - private val dsl: DSLContext, -) : ConversationRepository { - override fun findById(id: ConversationId): Conversation? = - dsl - .selectFrom(CONVERSATION) - .where(CONVERSATION.ID.eq(id.value)) - .fetchOne() - ?.toConversation() - - override fun findByUserId(userId: UUID): List = - dsl - .selectFrom(CONVERSATION) - .where(CONVERSATION.USER_ID.eq(userId)) - .fetch() - .map { it.toConversation() } - - override fun save(conversation: Conversation): Conversation { - val createdAt = conversation.createdAt.atOffset(ZoneOffset.UTC).toLocalDateTime() - val updatedAt = conversation.updatedAt.atOffset(ZoneOffset.UTC).toLocalDateTime() - dsl - .insertInto(CONVERSATION) - .set(CONVERSATION.ID, conversation.id.value) - .set(CONVERSATION.USER_ID, conversation.userId) - .set(CONVERSATION.TITLE, conversation.title) - .set(CONVERSATION.STATUS, conversation.status.name) - .set(CONVERSATION.CREATED_AT, createdAt) - .set(CONVERSATION.UPDATED_AT, updatedAt) - .onConflict(CONVERSATION.ID) - .doUpdate() - .set(CONVERSATION.TITLE, conversation.title) - .set(CONVERSATION.STATUS, conversation.status.name) - .set(CONVERSATION.UPDATED_AT, updatedAt) - .execute() - return conversation - } - - private fun Record.toConversation(): Conversation = - Conversation( - id = ConversationId(this[CONVERSATION.ID] as UUID), - userId = this[CONVERSATION.USER_ID] as UUID, - title = this[CONVERSATION.TITLE] as String, - status = ConversationStatus.valueOf(this[CONVERSATION.STATUS] as String), - createdAt = - (this[CONVERSATION.CREATED_AT] as java.time.LocalDateTime) - .toInstant(ZoneOffset.UTC), - updatedAt = - (this[CONVERSATION.UPDATED_AT] as java.time.LocalDateTime) - .toInstant(ZoneOffset.UTC), - ) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqGithubLinkRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqGithubLinkRepository.kt deleted file mode 100644 index 26d4ecba..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqGithubLinkRepository.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqGithubLinkRepository( - private val dsl: DSLContext, -) : GithubLinkRepository { - override fun save(link: GithubLink): GithubLink { - val createdAt = link.createdAt.atOffset(ZoneOffset.UTC) - val updatedAt = link.updatedAt.atOffset(ZoneOffset.UTC) - val addedAt = link.deployKeyAddedAt?.atOffset(ZoneOffset.UTC) - dsl - .insertInto(LINKS) - .set(ID, link.id.value) - .set(PROJECT_ID, link.projectId.value) - .set(NAME, link.name) - .set(REPO_URL, link.repoUrl) - .set(DEFAULT_BRANCH, link.defaultBranch) - .set(VAULT_KEY_PATH, link.vaultKeyPath) - .set(FINGERPRINT, link.deployKeyFingerprint) - .set(KEY_ADDED_AT, addedAt) - .set(CREATED_AT, createdAt) - .set(UPDATED_AT, updatedAt) - .onConflict(ID) - .doUpdate() - .set(NAME, link.name) - .set(REPO_URL, link.repoUrl) - .set(DEFAULT_BRANCH, link.defaultBranch) - .set(VAULT_KEY_PATH, link.vaultKeyPath) - .set(FINGERPRINT, link.deployKeyFingerprint) - .set(KEY_ADDED_AT, addedAt) - .set(UPDATED_AT, updatedAt) - .execute() - return link - } - - override fun findById(id: GithubLinkId): GithubLink? = - dsl - .selectFrom(LINKS) - .where(ID.eq(id.value)) - .fetchOne() - ?.toLink() - - override fun findAllByProjectId(projectId: ProjectId): List = - dsl - .selectFrom(LINKS) - .where(PROJECT_ID.eq(projectId.value)) - .orderBy(CREATED_AT.asc()) - .fetch() - .map { it.toLink() } - - override fun delete(id: GithubLinkId) { - dsl.deleteFrom(LINKS).where(ID.eq(id.value)).execute() - } - - private fun Record.toLink(): GithubLink = - GithubLink( - id = GithubLinkId(this[ID]), - projectId = ProjectId(this[PROJECT_ID]), - name = this[NAME], - repoUrl = this[REPO_URL], - defaultBranch = this[DEFAULT_BRANCH], - vaultKeyPath = this[VAULT_KEY_PATH], - deployKeyFingerprint = this[FINGERPRINT], - deployKeyAddedAt = this[KEY_ADDED_AT]?.toInstant(), - createdAt = this[CREATED_AT].toInstant(), - updatedAt = this[UPDATED_AT].toInstant(), - ) - - companion object { - @JvmStatic val LINKS = DSL.table("github_links") - - @JvmStatic val ID = DSL.field("id", UUID::class.java) - - @JvmStatic val PROJECT_ID = DSL.field("project_id", UUID::class.java) - - @JvmStatic val NAME = DSL.field("name", String::class.java) - - @JvmStatic val REPO_URL = DSL.field("repo_url", String::class.java) - - @JvmStatic val DEFAULT_BRANCH = DSL.field("default_branch", String::class.java) - - @JvmStatic val VAULT_KEY_PATH = DSL.field("vault_key_path", String::class.java) - - @JvmStatic val FINGERPRINT = DSL.field("deploy_key_fingerprint", String::class.java) - - @JvmStatic val KEY_ADDED_AT = DSL.field("deploy_key_added_at", OffsetDateTime::class.java) - - @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) - - @JvmStatic val UPDATED_AT = DSL.field("updated_at", OffsetDateTime::class.java) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqMessageRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqMessageRepository.kt deleted file mode 100644 index c1a524fa..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqMessageRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.Message -import com.jorisjonkers.personalstack.assistant.domain.model.MessageId -import com.jorisjonkers.personalstack.assistant.domain.model.MessageRole -import com.jorisjonkers.personalstack.assistant.domain.port.MessageRepository -import com.jorisjonkers.personalstack.assistant.jooq.tables.Message.MESSAGE -import org.jooq.DSLContext -import org.jooq.Record -import org.springframework.stereotype.Repository -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqMessageRepository( - private val dsl: DSLContext, -) : MessageRepository { - override fun save(message: Message): Message { - val createdAt = message.createdAt.atOffset(ZoneOffset.UTC).toLocalDateTime() - dsl - .insertInto(MESSAGE) - .set(MESSAGE.ID, message.id.value) - .set(MESSAGE.CONVERSATION_ID, message.conversationId.value) - .set(MESSAGE.ROLE, message.role.name) - .set(MESSAGE.CONTENT, message.content) - .set(MESSAGE.CREATED_AT, createdAt) - .execute() - return message - } - - override fun findByConversationId(id: ConversationId): List = - dsl - .selectFrom(MESSAGE) - .where(MESSAGE.CONVERSATION_ID.eq(id.value)) - .orderBy(MESSAGE.CREATED_AT.asc()) - .fetch() - .map { it.toMessage() } - - private fun Record.toMessage(): Message = - Message( - id = MessageId(this[MESSAGE.ID] as UUID), - conversationId = ConversationId(this[MESSAGE.CONVERSATION_ID] as UUID), - role = MessageRole.valueOf(this[MESSAGE.ROLE] as String), - content = this[MESSAGE.CONTENT] as String, - createdAt = - (this[MESSAGE.CREATED_AT] as java.time.LocalDateTime) - .toInstant(ZoneOffset.UTC), - ) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqProjectRepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqProjectRepositoryRepository.kt deleted file mode 100644 index 054ec8ce..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqProjectRepositoryRepository.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqProjectRepositoryRepository( - private val dsl: DSLContext, -) : ProjectRepositoryRepository { - override fun link( - projectId: ProjectId, - repositoryId: RepositoryId, - ): ProjectRepositoryRepository.Link { - val now = Instant.now() - val linkedAt = now.atOffset(ZoneOffset.UTC) - dsl - .insertInto(JUNCTION) - .set(PROJECT_ID, projectId.value) - .set(REPOSITORY_ID, repositoryId.value) - .set(LINKED_AT, linkedAt) - .onConflict(PROJECT_ID, REPOSITORY_ID) - .doNothing() - .execute() - return ProjectRepositoryRepository.Link(projectId, repositoryId, now) - } - - override fun unlink( - projectId: ProjectId, - repositoryId: RepositoryId, - ) { - dsl - .deleteFrom(JUNCTION) - .where(PROJECT_ID.eq(projectId.value)) - .and(REPOSITORY_ID.eq(repositoryId.value)) - .execute() - } - - override fun exists( - projectId: ProjectId, - repositoryId: RepositoryId, - ): Boolean = - dsl - .fetchExists( - dsl - .selectOne() - .from(JUNCTION) - .where(PROJECT_ID.eq(projectId.value)) - .and(REPOSITORY_ID.eq(repositoryId.value)), - ) - - override fun findAllByProjectId(projectId: ProjectId): List = - dsl - .selectFrom(JUNCTION) - .where(PROJECT_ID.eq(projectId.value)) - .orderBy(LINKED_AT.asc()) - .fetch() - .map { it.toLink() } - - override fun findAllByRepositoryId(repositoryId: RepositoryId): List = - dsl - .selectFrom(JUNCTION) - .where(REPOSITORY_ID.eq(repositoryId.value)) - .orderBy(LINKED_AT.asc()) - .fetch() - .map { it.toLink() } - - private fun Record.toLink(): ProjectRepositoryRepository.Link = - ProjectRepositoryRepository.Link( - projectId = ProjectId(this[PROJECT_ID]), - repositoryId = RepositoryId(this[REPOSITORY_ID]), - linkedAt = this[LINKED_AT].toInstant(), - ) - - companion object { - @JvmStatic val JUNCTION = DSL.table("project_repositories") - - @JvmStatic val PROJECT_ID = DSL.field("project_id", UUID::class.java) - - @JvmStatic val REPOSITORY_ID = DSL.field("repository_id", UUID::class.java) - - @JvmStatic val LINKED_AT = DSL.field("linked_at", OffsetDateTime::class.java) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqProjectsRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqProjectsRepository.kt deleted file mode 100644 index 7debd0f8..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqProjectsRepository.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqProjectsRepository( - private val dsl: DSLContext, -) : ProjectsRepository { - override fun save(project: Project): Project { - val createdAt = project.createdAt.atOffset(ZoneOffset.UTC) - val updatedAt = project.updatedAt.atOffset(ZoneOffset.UTC) - dsl - .insertInto(PROJECTS) - .set(ID, project.id.value) - .set(NAME, project.name) - .set(SLUG, project.slug) - .set(DESCRIPTION, project.description) - .set(CREATED_AT, createdAt) - .set(UPDATED_AT, updatedAt) - .onConflict(ID) - .doUpdate() - .set(NAME, project.name) - .set(SLUG, project.slug) - .set(DESCRIPTION, project.description) - .set(UPDATED_AT, updatedAt) - .execute() - return project - } - - override fun findById(id: ProjectId): Project? = - dsl - .selectFrom(PROJECTS) - .where(ID.eq(id.value)) - .fetchOne() - ?.toProject() - - override fun findBySlug(slug: String): Project? = - dsl - .selectFrom(PROJECTS) - .where(SLUG.eq(slug)) - .fetchOne() - ?.toProject() - - override fun findAll(): List = - dsl - .selectFrom(PROJECTS) - .orderBy(CREATED_AT.desc()) - .fetch() - .map { it.toProject() } - - override fun delete(id: ProjectId) { - dsl.deleteFrom(PROJECTS).where(ID.eq(id.value)).execute() - } - - private fun Record.toProject(): Project = - Project( - id = ProjectId(this[ID] as UUID), - name = this[NAME] as String, - slug = this[SLUG] as String, - description = this[DESCRIPTION] as String, - createdAt = (this[CREATED_AT] as OffsetDateTime).toInstant(), - updatedAt = (this[UPDATED_AT] as OffsetDateTime).toInstant(), - ) - - companion object { - @JvmStatic val PROJECTS = DSL.table("projects") - - @JvmStatic val ID = DSL.field("id", UUID::class.java) - - @JvmStatic val NAME = DSL.field("name", String::class.java) - - @JvmStatic val SLUG = DSL.field("slug", String::class.java) - - @JvmStatic val DESCRIPTION = DSL.field("description", String::class.java) - - @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) - - @JvmStatic val UPDATED_AT = DSL.field("updated_at", OffsetDateTime::class.java) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqRepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqRepositoryRepository.kt deleted file mode 100644 index 20d74466..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqRepositoryRepository.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.AccessVerification -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID -import org.springframework.stereotype.Repository as SpringRepository - -@SpringRepository -class JooqRepositoryRepository( - private val dsl: DSLContext, -) : RepositoryRepository { - @Suppress("LongMethod") // single fluent jOOQ upsert; see JooqWorkspaceRepository.save for the same shape - override fun save(repository: Repository): Repository { - val createdAt = repository.createdAt.atOffset(ZoneOffset.UTC) - val updatedAt = repository.updatedAt.atOffset(ZoneOffset.UTC) - val addedAt = repository.deployKeyAddedAt?.atOffset(ZoneOffset.UTC) - val verifiedAt = repository.verification?.checkedAt?.atOffset(ZoneOffset.UTC) - val messages = repository.verification?.messages?.joinToString("\n") - dsl - .insertInto(REPOSITORIES) - .set(ID, repository.id.value) - .set(NAME, repository.name) - .set(REPO_URL, repository.repoUrl) - .set(DEFAULT_BRANCH, repository.defaultBranch) - .set(VAULT_KEY_PATH, repository.vaultKeyPath) - .set(FINGERPRINT, repository.deployKeyFingerprint) - .set(KEY_ADDED_AT, addedAt) - .set(VERIFY_READ, repository.verification?.read) - .set(VERIFY_WRITE, repository.verification?.write) - .set(VERIFY_PROTECTED, repository.verification?.defaultBranchProtected) - .set(VERIFY_CHECKED_AT, verifiedAt) - .set(VERIFY_MESSAGES, messages) - .set(CREATED_AT, createdAt) - .set(UPDATED_AT, updatedAt) - .onConflict(ID) - .doUpdate() - .set(NAME, repository.name) - .set(REPO_URL, repository.repoUrl) - .set(DEFAULT_BRANCH, repository.defaultBranch) - .set(VAULT_KEY_PATH, repository.vaultKeyPath) - .set(FINGERPRINT, repository.deployKeyFingerprint) - .set(KEY_ADDED_AT, addedAt) - .set(VERIFY_READ, repository.verification?.read) - .set(VERIFY_WRITE, repository.verification?.write) - .set(VERIFY_PROTECTED, repository.verification?.defaultBranchProtected) - .set(VERIFY_CHECKED_AT, verifiedAt) - .set(VERIFY_MESSAGES, messages) - .set(UPDATED_AT, updatedAt) - .execute() - return repository - } - - override fun findById(id: RepositoryId): Repository? = - dsl - .selectFrom(REPOSITORIES) - .where(ID.eq(id.value)) - .fetchOne() - ?.toRepository() - - override fun findByName(name: String): Repository? = - dsl - .selectFrom(REPOSITORIES) - .where(NAME.eq(name)) - .fetchOne() - ?.toRepository() - - override fun findAll(): List = - dsl - .selectFrom(REPOSITORIES) - .orderBy(CREATED_AT.desc()) - .fetch() - .map { it.toRepository() } - - override fun findAllByProjectId(projectId: ProjectId): List = - dsl - .select( - ID, - NAME, - REPO_URL, - DEFAULT_BRANCH, - VAULT_KEY_PATH, - FINGERPRINT, - KEY_ADDED_AT, - VERIFY_READ, - VERIFY_WRITE, - VERIFY_PROTECTED, - VERIFY_CHECKED_AT, - VERIFY_MESSAGES, - CREATED_AT, - UPDATED_AT, - ).from(REPOSITORIES) - .innerJoin(JUNCTION) - .on(JUNCTION_REPOSITORY_ID.eq(ID)) - .where(JUNCTION_PROJECT_ID.eq(projectId.value)) - .orderBy(CREATED_AT.desc()) - .fetch() - .map { it.toRepository() } - - override fun delete(id: RepositoryId) { - dsl.deleteFrom(REPOSITORIES).where(ID.eq(id.value)).execute() - } - - private fun Record.toRepository(): Repository = - Repository( - id = RepositoryId(this[ID]), - name = this[NAME], - repoUrl = this[REPO_URL], - defaultBranch = this[DEFAULT_BRANCH], - vaultKeyPath = this[VAULT_KEY_PATH], - deployKeyFingerprint = this[FINGERPRINT], - deployKeyAddedAt = this[KEY_ADDED_AT]?.toInstant(), - createdAt = this[CREATED_AT].toInstant(), - updatedAt = this[UPDATED_AT].toInstant(), - verification = toVerification(), - ) - - private fun Record.toVerification(): AccessVerification? { - val checkedAt = this[VERIFY_CHECKED_AT]?.toInstant() - val read = this[VERIFY_READ] - val write = this[VERIFY_WRITE] - val protected = this[VERIFY_PROTECTED] - // A row is "verified" once any probe has run. Pre-verify rows - // carry a null verification so the UI can distinguish "never - // checked" from "checked, all null". - val everChecked = listOf(checkedAt, read, write, protected).any { it != null } - if (!everChecked) return null - val messages = this[VERIFY_MESSAGES]?.split("\n")?.filter { it.isNotBlank() } ?: emptyList() - return AccessVerification( - read = read, - write = write, - defaultBranchProtected = protected, - checkedAt = checkedAt, - messages = messages, - ) - } - - companion object { - @JvmStatic val REPOSITORIES = DSL.table("repositories") - - @JvmStatic val ID = DSL.field("id", UUID::class.java) - - @JvmStatic val NAME = DSL.field("name", String::class.java) - - @JvmStatic val REPO_URL = DSL.field("repo_url", String::class.java) - - @JvmStatic val DEFAULT_BRANCH = DSL.field("default_branch", String::class.java) - - @JvmStatic val VAULT_KEY_PATH = DSL.field("vault_key_path", String::class.java) - - @JvmStatic val FINGERPRINT = DSL.field("deploy_key_fingerprint", String::class.java) - - @JvmStatic val KEY_ADDED_AT = DSL.field("deploy_key_added_at", OffsetDateTime::class.java) - - @JvmStatic val VERIFY_READ = DSL.field("verify_read", Boolean::class.javaObjectType) - - @JvmStatic val VERIFY_WRITE = DSL.field("verify_write", Boolean::class.javaObjectType) - - @JvmStatic val VERIFY_PROTECTED = DSL.field("verify_default_branch_protected", Boolean::class.javaObjectType) - - @JvmStatic val VERIFY_CHECKED_AT = DSL.field("verify_checked_at", OffsetDateTime::class.java) - - @JvmStatic val VERIFY_MESSAGES = DSL.field("verify_messages", String::class.java) - - @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) - - @JvmStatic val UPDATED_AT = DSL.field("updated_at", OffsetDateTime::class.java) - - @JvmStatic val JUNCTION = DSL.table("project_repositories") - - @JvmStatic val JUNCTION_PROJECT_ID = DSL.field("project_repositories.project_id", UUID::class.java) - - @JvmStatic val JUNCTION_REPOSITORY_ID = DSL.field("project_repositories.repository_id", UUID::class.java) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqTurnRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqTurnRepository.kt deleted file mode 100644 index 9fcb8766..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqTurnRepository.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.Turn -import com.jorisjonkers.personalstack.assistant.domain.model.TurnId -import com.jorisjonkers.personalstack.assistant.domain.model.TurnRole -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.port.TurnRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqTurnRepository( - private val dsl: DSLContext, -) : TurnRepository { - override fun save(turn: Turn): Turn { - val createdAt = turn.createdAt.atOffset(ZoneOffset.UTC) - dsl - .insertInto(TURNS) - .set(ID, turn.id.value) - .set(SESSION_ID, turn.sessionId.value) - .set(ROLE, turn.role.name) - .set(BODY, turn.body) - .set(CREATED_AT, createdAt) - .execute() - return turn - } - - override fun findBySessionId( - sessionId: WorkspaceAgentSessionId, - limit: Int, - ): List = - dsl - .selectFrom(TURNS) - .where(SESSION_ID.eq(sessionId.value)) - .orderBy(CREATED_AT.asc()) - .limit(limit) - .fetch() - .map { it.toTurn() } - - private fun Record.toTurn(): Turn = - Turn( - id = TurnId(this[ID] as UUID), - sessionId = WorkspaceAgentSessionId(this[SESSION_ID] as UUID), - role = TurnRole.valueOf(this[ROLE] as String), - body = this[BODY] as String, - createdAt = (this[CREATED_AT] as OffsetDateTime).toInstant(), - ) - - companion object { - @JvmStatic val TURNS = DSL.table("turns") - - @JvmStatic val ID = DSL.field("id", UUID::class.java) - - @JvmStatic val SESSION_ID = DSL.field("session_id", UUID::class.java) - - @JvmStatic val ROLE = DSL.field("role", String::class.java) - - @JvmStatic val BODY = DSL.field("body", String::class.java) - - @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceAgentSessionRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceAgentSessionRepository.kt deleted file mode 100644 index a61aa687..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceAgentSessionRepository.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqWorkspaceAgentSessionRepository( - private val dsl: DSLContext, -) : WorkspaceAgentSessionRepository { - override fun save(session: WorkspaceAgentSession): WorkspaceAgentSession { - val createdAt = session.createdAt.atOffset(ZoneOffset.UTC) - val updatedAt = session.updatedAt.atOffset(ZoneOffset.UTC) - dsl - .insertInto(WAS) - .set(ID, session.id.value) - .set(WORKSPACE_ID, session.workspaceId.value) - .set(KIND, session.kind.name) - .set(GATEWAY_AGENT_ID, session.gatewayAgentId) - .set(STATUS, session.status.name) - .set(CLI_SESSION_ID, session.cliSessionId) - .set(RUN_MODE, session.runMode) - .set(CREATED_AT, createdAt) - .set(UPDATED_AT, updatedAt) - .onConflict(ID) - .doUpdate() - .set(GATEWAY_AGENT_ID, session.gatewayAgentId) - .set(STATUS, session.status.name) - .set(CLI_SESSION_ID, session.cliSessionId) - .set(RUN_MODE, session.runMode) - .set(UPDATED_AT, updatedAt) - .execute() - return session - } - - override fun findById(id: WorkspaceAgentSessionId): WorkspaceAgentSession? = - dsl - .selectFrom(WAS) - .where(ID.eq(id.value)) - .fetchOne() - ?.toSession() - - override fun findAllByWorkspaceId(workspaceId: WorkspaceId): List = - dsl - .selectFrom(WAS) - .where(WORKSPACE_ID.eq(workspaceId.value)) - .orderBy(CREATED_AT.asc()) - .fetch() - .map { it.toSession() } - - override fun delete(id: WorkspaceAgentSessionId) { - dsl.deleteFrom(WAS).where(ID.eq(id.value)).execute() - } - - private fun Record.toSession(): WorkspaceAgentSession = - WorkspaceAgentSession( - id = WorkspaceAgentSessionId(this[ID]), - workspaceId = WorkspaceId(this[WORKSPACE_ID]), - kind = WorkspaceAgentKind.valueOf(this[KIND]), - gatewayAgentId = this[GATEWAY_AGENT_ID], - status = WorkspaceAgentSessionStatus.valueOf(this[STATUS]), - createdAt = this[CREATED_AT].toInstant(), - updatedAt = this[UPDATED_AT].toInstant(), - cliSessionId = this[CLI_SESSION_ID], - runMode = this[RUN_MODE] ?: "INTERACTIVE", - ) - - companion object { - @JvmStatic val WAS = DSL.table("workspace_agent_sessions") - - @JvmStatic val ID = DSL.field("id", UUID::class.java) - - @JvmStatic val WORKSPACE_ID = DSL.field("workspace_id", UUID::class.java) - - @JvmStatic val KIND = DSL.field("kind", String::class.java) - - @JvmStatic val GATEWAY_AGENT_ID = DSL.field("gateway_agent_id", String::class.java) - - @JvmStatic val STATUS = DSL.field("status", String::class.java) - - @JvmStatic val CLI_SESSION_ID = DSL.field("cli_session_id", String::class.java) - - @JvmStatic val RUN_MODE = DSL.field("run_mode", String::class.java) - - @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) - - @JvmStatic val UPDATED_AT = DSL.field("updated_at", OffsetDateTime::class.java) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepository.kt deleted file mode 100644 index 705502b8..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepository.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -/** - * Uses jOOQ's untyped DSL rather than the codegen classes. Codegen - * would work but the generated classes only appear after a successful - * build with the new Flyway migrations applied; the untyped form - * makes the boot path resilient to a fresh checkout that hasn't run - * the codegen task yet. Same pattern as auth-api's - * audit-log persistence, which predates the codegen plugin. - */ -@Repository -@Suppress("DEPRECATION") -class JooqWorkspaceRepository( - private val dsl: DSLContext, -) : WorkspaceRepository { - @Suppress("LongMethod") - override fun save(workspace: Workspace): Workspace { - val createdAt = workspace.createdAt.atOffset(ZoneOffset.UTC) - val updatedAt = workspace.updatedAt.atOffset(ZoneOffset.UTC) - dsl - .insertInto(WORKSPACES) - .set(ID, workspace.id.value) - .set(NAME, workspace.name) - .set(REPO_URL, workspace.repoUrl) - .set(BRANCH, workspace.branch) - .set(POD_NAME, workspace.podName) - .set(PVC_NAME, workspace.pvcName) - .set(GATEWAY_ENDPOINT, workspace.gatewayEndpoint) - .set(STATUS, workspace.status.name) - .set(GITHUB_LINK_ID, workspace.githubLinkId?.value) - .set(REPOSITORY_ID, workspace.repositoryId?.value) - .set(PROJECT_ID, workspace.projectId?.value) - .set(KIND, workspace.kind.name) - .set(CREATED_AT, createdAt) - .set(UPDATED_AT, updatedAt) - .onConflict(ID) - .doUpdate() - .set(NAME, workspace.name) - .set(REPO_URL, workspace.repoUrl) - .set(BRANCH, workspace.branch) - .set(POD_NAME, workspace.podName) - .set(PVC_NAME, workspace.pvcName) - .set(GATEWAY_ENDPOINT, workspace.gatewayEndpoint) - .set(STATUS, workspace.status.name) - .set(GITHUB_LINK_ID, workspace.githubLinkId?.value) - .set(REPOSITORY_ID, workspace.repositoryId?.value) - .set(PROJECT_ID, workspace.projectId?.value) - .set(KIND, workspace.kind.name) - .set(UPDATED_AT, updatedAt) - .execute() - return workspace - } - - override fun findById(id: WorkspaceId): Workspace? = - dsl - .selectFrom(WORKSPACES) - .where(ID.eq(id.value)) - .fetchOne() - ?.toWorkspace() - - override fun findAllByStatusNot(status: WorkspaceStatus): List = - dsl - .selectFrom(WORKSPACES) - .where(STATUS.ne(status.name)) - .orderBy(CREATED_AT.desc()) - .fetch() - .map { it.toWorkspace() } - - override fun delete(id: WorkspaceId) { - dsl.deleteFrom(WORKSPACES).where(ID.eq(id.value)).execute() - } - - private fun Record.toWorkspace(): Workspace = - Workspace( - id = WorkspaceId(this[ID]), - name = this[NAME], - repoUrl = this[REPO_URL], - branch = this[BRANCH], - podName = this[POD_NAME], - pvcName = this[PVC_NAME], - gatewayEndpoint = this[GATEWAY_ENDPOINT], - status = WorkspaceStatus.valueOf(this[STATUS]), - githubLinkId = this[GITHUB_LINK_ID]?.let { GithubLinkId(it) }, - repositoryId = this[REPOSITORY_ID]?.let { RepositoryId(it) }, - projectId = this[PROJECT_ID]?.let { ProjectId(it) }, - kind = WorkspaceKind.valueOf(this[KIND]), - createdAt = this[CREATED_AT].toInstant(), - updatedAt = this[UPDATED_AT].toInstant(), - ) - - companion object { - @JvmStatic val WORKSPACES = DSL.table("workspaces") - - @JvmStatic val ID = DSL.field("id", UUID::class.java) - - @JvmStatic val NAME = DSL.field("name", String::class.java) - - @JvmStatic val REPO_URL = DSL.field("repo_url", String::class.java) - - @JvmStatic val BRANCH = DSL.field("branch", String::class.java) - - @JvmStatic val POD_NAME = DSL.field("pod_name", String::class.java) - - @JvmStatic val PVC_NAME = DSL.field("pvc_name", String::class.java) - - @JvmStatic val GATEWAY_ENDPOINT = DSL.field("gateway_endpoint", String::class.java) - - @JvmStatic val STATUS = DSL.field("status", String::class.java) - - @JvmStatic val GITHUB_LINK_ID = DSL.field("github_link_id", UUID::class.java) - - @JvmStatic val REPOSITORY_ID = DSL.field("repository_id", UUID::class.java) - - @JvmStatic val PROJECT_ID = DSL.field("project_id", UUID::class.java) - - @JvmStatic val KIND = DSL.field("kind", String::class.java) - - @JvmStatic val CREATED_AT = DSL.field("created_at", OffsetDateTime::class.java) - - @JvmStatic val UPDATED_AT = DSL.field("updated_at", OffsetDateTime::class.java) - - @Suppress("unused") - private val unusedLdt = LocalDateTime::class.java - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt deleted file mode 100644 index 840d40a3..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.persistence - -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import org.jooq.DSLContext -import org.jooq.Record -import org.jooq.impl.DSL -import org.springframework.stereotype.Repository -import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.util.UUID - -@Repository -class JooqWorkspaceRepositoryRepository( - private val dsl: DSLContext, -) : WorkspaceRepositoryRepository { - override fun attach( - workspaceId: WorkspaceId, - repositoryId: RepositoryId, - isPrimary: Boolean, - ): WorkspaceRepositoryRepository.Link { - val now = Instant.now() - if (isPrimary) { - attachPrimary(workspaceId, repositoryId, now) - } else { - attachSecondary(workspaceId, repositoryId, now) - } - return findLink(workspaceId, repositoryId) - ?: WorkspaceRepositoryRepository.Link(workspaceId, repositoryId, isPrimary, now) - } - - private fun attachPrimary( - workspaceId: WorkspaceId, - repositoryId: RepositoryId, - now: Instant, - ) { - dsl - .update(JUNCTION) - .set(IS_PRIMARY, false) - .where(WORKSPACE_ID.eq(workspaceId.value)) - .execute() - dsl - .insertInto(JUNCTION) - .set(WORKSPACE_ID, workspaceId.value) - .set(REPOSITORY_ID, repositoryId.value) - .set(IS_PRIMARY, true) - .set(ATTACHED_AT, now.atOffset(ZoneOffset.UTC)) - .onConflict(WORKSPACE_ID, REPOSITORY_ID) - .doUpdate() - .set(IS_PRIMARY, true) - .execute() - } - - private fun attachSecondary( - workspaceId: WorkspaceId, - repositoryId: RepositoryId, - now: Instant, - ) { - dsl - .insertInto(JUNCTION) - .set(WORKSPACE_ID, workspaceId.value) - .set(REPOSITORY_ID, repositoryId.value) - .set(IS_PRIMARY, false) - .set(ATTACHED_AT, now.atOffset(ZoneOffset.UTC)) - .onConflict(WORKSPACE_ID, REPOSITORY_ID) - .doNothing() - .execute() - } - - override fun detach( - workspaceId: WorkspaceId, - repositoryId: RepositoryId, - ) { - dsl - .deleteFrom(JUNCTION) - .where(WORKSPACE_ID.eq(workspaceId.value)) - .and(REPOSITORY_ID.eq(repositoryId.value)) - .execute() - } - - override fun findAllByWorkspaceId(workspaceId: WorkspaceId): List = - dsl - .selectFrom(JUNCTION) - .where(WORKSPACE_ID.eq(workspaceId.value)) - .orderBy(IS_PRIMARY.desc(), ATTACHED_AT.asc()) - .fetch() - .map { it.toLink() } - - private fun findLink( - workspaceId: WorkspaceId, - repositoryId: RepositoryId, - ): WorkspaceRepositoryRepository.Link? = - dsl - .selectFrom(JUNCTION) - .where(WORKSPACE_ID.eq(workspaceId.value)) - .and(REPOSITORY_ID.eq(repositoryId.value)) - .fetchOne() - ?.toLink() - - private fun Record.toLink(): WorkspaceRepositoryRepository.Link = - WorkspaceRepositoryRepository.Link( - workspaceId = WorkspaceId(this[WORKSPACE_ID]), - repositoryId = RepositoryId(this[REPOSITORY_ID]), - isPrimary = this[IS_PRIMARY], - attachedAt = this[ATTACHED_AT].toInstant(), - ) - - companion object { - @JvmStatic val JUNCTION = DSL.table("workspace_repositories") - - @JvmStatic val WORKSPACE_ID = DSL.field("workspace_id", UUID::class.java) - - @JvmStatic val REPOSITORY_ID = DSL.field("repository_id", UUID::class.java) - - @JvmStatic val IS_PRIMARY = DSL.field("is_primary", Boolean::class.java) - - @JvmStatic val ATTACHED_AT = DSL.field("attached_at", OffsetDateTime::class.java) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/security/.gitkeep b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/security/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt deleted file mode 100644 index 63e0d3ca..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AdminRunnerController.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.maintenance.RunnerMaintenanceService -import io.swagger.v3.oas.annotations.Hidden -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@Hidden -@RestController -@RequestMapping("/api/v1/admin/runners") -class AdminRunnerController( - private val maintenance: RunnerMaintenanceService, -) { - data class RollingRestartResponse( - val cycled: Int, - val workspaceIds: List, - ) - - /** - * Gracefully scale down every active runner Pod so each workspace - * transitions to IDLE with its PVC preserved. The next session start - * on any workspace re-provisions the Pod pulling `:latest` and starts - * a fresh agent process. - * - * Intended for two scenarios: - * 1. After pushing a new agent-runner image to pick up the update. - * 2. Before draining the runner node for maintenance. - * - * Requires the `X-User-Id` header (forwarded by the SSO proxy). - */ - @PostMapping("/rolling-restart") - fun rollingRestart(): RollingRestartResponse { - val result = maintenance.gracefulScaleDownAll() - return RollingRestartResponse( - cycled = result.cycled, - workspaceIds = result.workspaceIds, - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentRunnerUnavailableExceptionHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentRunnerUnavailableExceptionHandler.kt deleted file mode 100644 index 94a3cdc1..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentRunnerUnavailableExceptionHandler.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.exception.AgentRunnerUnavailableException -import com.jorisjonkers.personalstack.common.web.ProblemDetail -import org.slf4j.LoggerFactory -import org.slf4j.MDC -import org.springframework.core.Ordered -import org.springframework.core.annotation.Order -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.RestControllerAdvice -import org.springframework.web.context.request.WebRequest -import java.net.URI - -/** - * Maps [AgentRunnerUnavailableException] to a **503 Service - * Unavailable** with a `Retry-After` header and a ProblemDetail - * payload exposing `runnerStatus` / `retryAfterSeconds` extensions. - * - * Sits at `HIGHEST_PRECEDENCE` for the same reason - * [KubernetesExceptionHandler] does — the generic - * `Exception → 500` mapper in `GlobalExceptionHandler` should never - * see this exception type. - * - * Visible 5xx instead of a leaky 500 lets the UI render a friendly - * inline "runner not ready, retry in 5s" message, and lets the - * Playwright system tests assert on a stable shape. - */ -@RestControllerAdvice -@Order(Ordered.HIGHEST_PRECEDENCE) -class AgentRunnerUnavailableExceptionHandler { - private val log = LoggerFactory.getLogger(AgentRunnerUnavailableExceptionHandler::class.java) - - @ExceptionHandler(AgentRunnerUnavailableException::class) - fun handle( - ex: AgentRunnerUnavailableException, - request: WebRequest?, - ): ResponseEntity { - val traceId = MDC.get("traceId") ?: MDC.get("trace_id") - val path = pathOf(request) - log.warn( - "agent-runner unavailable traceId={} path={} workspace={} status={} retryAfter={}s", - traceId, - path, - ex.workspaceId.value, - ex.runnerStatus, - ex.retryAfterSeconds, - ) - val body = - ProblemDetail( - type = URI.create("https://jorisjonkers.dev/errors/agent-runner-unavailable"), - title = "Agent runner not ready", - status = HttpStatus.SERVICE_UNAVAILABLE.value(), - detail = - "Workspace ${ex.workspaceId.value} runner is ${ex.runnerStatus}. " + - "Retry in ${ex.retryAfterSeconds}s.", - instance = path?.let(URI::create), - traceId = traceId, - runnerStatus = ex.runnerStatus, - retryAfterSeconds = ex.retryAfterSeconds, - ) - return ResponseEntity - .status(HttpStatus.SERVICE_UNAVAILABLE) - .header(HttpHeaders.RETRY_AFTER, ex.retryAfterSeconds.toString()) - .body(body) - } - - private fun pathOf(request: WebRequest?): String? = - when (request) { - null -> null - else -> - runCatching { - request - .getDescription(false) - .removePrefix("uri=") - .takeIf { it.isNotBlank() } - }.getOrNull() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionController.kt deleted file mode 100644 index 3787277f..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionController.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.command.SendUserInputCommand -import com.jorisjonkers.personalstack.assistant.application.command.StartAgentSessionCommand -import com.jorisjonkers.personalstack.assistant.application.command.StopAgentSessionCommand -import com.jorisjonkers.personalstack.assistant.application.query.GetTurnHistoryQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.SendUserInputRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.StageInputRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.StagedInputResponse -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.StartAgentSessionRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.TurnResponse -import com.jorisjonkers.personalstack.common.command.CommandBus -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.util.UUID - -/** - * `workspaceId` is bound from the path purely so Spring's routing - * keeps the nested URL shape intact (sessions are conceptually - * children of a workspace). The handler bodies operate on the - * session id only — session uniqueness is guaranteed at the - * persistence layer regardless of the parent workspace. - */ -@Suppress("UnusedParameter") -@RestController -@RequestMapping("/api/v1/workspaces/{workspaceId}/sessions") -class AgentSessionController( - private val commandBus: CommandBus, - private val turnHistory: GetTurnHistoryQueryService, - private val sessions: WorkspaceAgentSessionRepository, - private val workspaces: WorkspaceRepository, - private val gateway: AgentGatewayClient, -) { - @PostMapping - fun start( - @PathVariable workspaceId: UUID, - @RequestBody req: StartAgentSessionRequest, - ): ResponseEntity> { - val sessionId = WorkspaceAgentSessionId.random() - commandBus.dispatch( - StartAgentSessionCommand( - sessionId = sessionId, - workspaceId = WorkspaceId(workspaceId), - kind = req.kind, - ), - ) - return ResponseEntity.status(HttpStatus.CREATED).body(mapOf("sessionId" to sessionId.value)) - } - - @PostMapping("/{sessionId}/input") - fun send( - @PathVariable workspaceId: UUID, - @PathVariable sessionId: UUID, - @RequestBody req: SendUserInputRequest, - ): ResponseEntity { - commandBus.dispatch( - SendUserInputCommand( - sessionId = WorkspaceAgentSessionId(sessionId), - text = req.text, - enter = req.enter, - ), - ) - return ResponseEntity.accepted().build() - } - - @PostMapping("/{sessionId}/staged-inputs") - fun stageInput( - @PathVariable workspaceId: UUID, - @PathVariable sessionId: UUID, - @RequestBody req: StageInputRequest, - ): ResponseEntity { - val workspaceModelId = WorkspaceId(workspaceId) - val session = - sessions.findById(WorkspaceAgentSessionId(sessionId)) - ?: error("session not found: $sessionId") - require(session.workspaceId == workspaceModelId) { "session does not belong to workspace: $sessionId" } - val workspace = workspaces.findById(workspaceModelId) ?: error("workspace not found: $workspaceId") - val gatewayAgentId = session.gatewayAgentId ?: error("session not bound to a gateway agent yet") - val staged = gateway.stageInput(workspace, gatewayAgentId, req.content, req.name) - return ResponseEntity - .status(HttpStatus.CREATED) - .body(StagedInputResponse(path = staged.path, bytes = staged.bytes, name = staged.name)) - } - - @GetMapping("/{sessionId}/turns") - fun turns( - @PathVariable workspaceId: UUID, - @PathVariable sessionId: UUID, - ): List = turnHistory.history(WorkspaceAgentSessionId(sessionId)).map(TurnResponse::of) - - @DeleteMapping("/{sessionId}") - fun stop( - @PathVariable workspaceId: UUID, - @PathVariable sessionId: UUID, - ): ResponseEntity { - commandBus.dispatch(StopAgentSessionCommand(WorkspaceAgentSessionId(sessionId))) - return ResponseEntity.noContent().build() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ChatSessionController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ChatSessionController.kt deleted file mode 100644 index 437eec2b..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ChatSessionController.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.chat.ChatAnswerStreamService -import com.jorisjonkers.personalstack.assistant.application.command.AppendChatMessageCommand -import com.jorisjonkers.personalstack.assistant.application.command.ArchiveChatSessionCommand -import com.jorisjonkers.personalstack.assistant.application.command.StartChatSessionCommand -import com.jorisjonkers.personalstack.assistant.application.query.ChatSessionQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.AppendChatMessageRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.ChatMessageResponse -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.ChatSessionResponse -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.StartChatSessionRequest -import com.jorisjonkers.personalstack.common.command.CommandBus -import io.swagger.v3.oas.annotations.Hidden -import jakarta.validation.Valid -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestHeader -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter -import java.util.UUID - -@RestController -@RequestMapping("/api/v1/chat-sessions") -class ChatSessionController( - private val commandBus: CommandBus, - private val chatSessionQuery: ChatSessionQueryService, - private val chatAnswerStream: ChatAnswerStreamService, -) { - @PostMapping - fun create( - @RequestHeader("X-User-Id") userId: String, - @Valid @RequestBody req: StartChatSessionRequest, - ): ResponseEntity { - val userUuid = UUID.fromString(userId) - val sessionId = ChatSessionId.random() - commandBus.dispatch( - StartChatSessionCommand( - sessionId = sessionId, - userId = userUuid, - title = req.title, - kind = req.kind ?: ChatSessionKind.PLAIN, - ), - ) - val detail = - chatSessionQuery.get(sessionId) - ?: error("chat session not visible immediately after create") - return ResponseEntity.status(HttpStatus.CREATED).body(ChatSessionResponse.of(detail.session)) - } - - @GetMapping - fun list( - @RequestHeader("X-User-Id") userId: String, - ): List = - chatSessionQuery - .list(UUID.fromString(userId)) - .map(ChatSessionResponse::of) - - @GetMapping("/{id}") - fun get( - @PathVariable id: UUID, - ): ResponseEntity> { - val detail = chatSessionQuery.get(ChatSessionId(id)) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok( - mapOf( - "session" to ChatSessionResponse.of(detail.session), - "messages" to detail.messages.map(ChatMessageResponse::of), - ), - ) - } - - @PostMapping("/{id}/messages") - fun appendMessage( - @PathVariable id: UUID, - @Valid @RequestBody req: AppendChatMessageRequest, - ): ResponseEntity { - val messageId = ChatMessageId.random() - commandBus.dispatch( - AppendChatMessageCommand( - messageId = messageId, - sessionId = ChatSessionId(id), - role = req.role, - body = req.body, - ), - ) - val detail = chatSessionQuery.get(ChatSessionId(id)) ?: return ResponseEntity.notFound().build() - val message = - detail.messages.firstOrNull { it.id == messageId } - ?: error("message not visible immediately after append") - return ResponseEntity.status(HttpStatus.CREATED).body(ChatMessageResponse.of(message)) - } - - // Excluded from the OpenAPI contract: an SSE/text-event-stream - // endpoint cannot be modelled usefully by openapi-typescript, and the - // UI consumes it through a hand-written fetch + ReadableStream reader - // rather than the generated client. Keeping it out of the spec leaves - // the generated types in sync without a degenerate stream type. - @Hidden - @PostMapping("/{id}/messages/stream") - fun streamMessage( - @PathVariable id: UUID, - @Valid @RequestBody req: AppendChatMessageRequest, - ): ResponseEntity { - val emitter = chatAnswerStream.stream(ChatSessionId(id), req.body) - return ResponseEntity - .ok() - .contentType(MediaType.TEXT_EVENT_STREAM) - .header("Cache-Control", "no-cache") - .header("X-Accel-Buffering", "no") - .body(emitter) - } - - @DeleteMapping("/{id}") - fun archive( - @PathVariable id: UUID, - @RequestHeader("X-User-Id") userId: String, - ): ResponseEntity { - commandBus.dispatch( - ArchiveChatSessionCommand( - sessionId = ChatSessionId(id), - userId = UUID.fromString(userId), - ), - ) - return ResponseEntity.noContent().build() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ConversationController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ConversationController.kt deleted file mode 100644 index 14d4842d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ConversationController.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.command.ArchiveConversationCommand -import com.jorisjonkers.personalstack.assistant.application.command.StartConversationCommand -import com.jorisjonkers.personalstack.assistant.application.query.GetConversationQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.ConversationResponse -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.CreateConversationRequest -import com.jorisjonkers.personalstack.common.command.CommandBus -import jakarta.validation.Valid -import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestHeader -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController -import java.util.UUID - -@RestController -@RequestMapping("/api/v1/conversations") -class ConversationController( - private val commandBus: CommandBus, - private val getConversationQueryService: GetConversationQueryService, -) { - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - fun create( - @RequestHeader("X-User-Id") userId: String, - @Valid @RequestBody request: CreateConversationRequest, - ): ConversationResponse { - val userUuid = UUID.fromString(userId) - val conversationId = ConversationId(UUID.randomUUID()) - commandBus.dispatch( - StartConversationCommand( - conversationId = conversationId, - userId = userUuid, - title = request.title, - ), - ) - val created = getConversationQueryService.findById(conversationId) - return ConversationResponse.from(created) - } - - @GetMapping("/{id}") - fun getById( - @PathVariable id: UUID, - ): ConversationResponse { - val conversation = getConversationQueryService.findById(ConversationId(id)) - return ConversationResponse.from(conversation) - } - - @DeleteMapping("/{id}") - @ResponseStatus(HttpStatus.NO_CONTENT) - fun archive( - @PathVariable id: UUID, - @RequestHeader("X-User-Id") userId: String, - ) { - commandBus.dispatch(ArchiveConversationCommand(conversationId = ConversationId(id), userId = userId)) - } - - @GetMapping - fun listByUser( - @RequestHeader("X-User-Id") userId: String, - ): List { - val userUuid = UUID.fromString(userId) - return getConversationQueryService - .findByUserId(userUuid) - .map { ConversationResponse.from(it) } - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/GitController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/GitController.kt deleted file mode 100644 index 90f89fc3..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/GitController.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.command.OpenPullRequestCommand -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.common.command.CommandBus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.util.UUID - -@RestController -@RequestMapping("/api/v1/workspaces/{workspaceId}/git") -class GitController( - private val commandBus: CommandBus, -) { - data class OpenPrRequest( - val repoDir: String, - val title: String, - val body: String, - val base: String = "main", - ) - - @PostMapping("/open-pr") - fun openPr( - @PathVariable workspaceId: UUID, - @RequestBody req: OpenPrRequest, - ): ResponseEntity { - commandBus.dispatch( - OpenPullRequestCommand( - workspaceId = WorkspaceId(workspaceId), - repoDir = req.repoDir, - title = req.title, - body = req.body, - base = req.base, - ), - ) - return ResponseEntity.accepted().build() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/HealthController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/HealthController.kt deleted file mode 100644 index fe98756b..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/HealthController.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import jakarta.annotation.security.PermitAll -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -@RequestMapping("/api/v1") -class HealthController { - @GetMapping("/health") - @PermitAll - fun health(): ResponseEntity> = - ResponseEntity.ok( - mapOf( - "status" to "ok", - "service" to "assistant-api", - ), - ) -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/InternalGitHubTokenController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/InternalGitHubTokenController.kt deleted file mode 100644 index 1542f7b0..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/InternalGitHubTokenController.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.infrastructure.integration.GitHubAppInstallationTokenClient -import io.swagger.v3.oas.annotations.Hidden -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.time.Instant - -/** - * In-cluster only: the runner's `gh` wrapper calls this to obtain a - * fresh, single-repo GitHub App installation token for runner GitHub - * writes (`git push`, `gh pr create`, PR comments, Actions re-runs). - * Reached over svc.cluster.local, so it bypasses the - * edge forward-auth that gates the public API; the - * InternalBearerAuthFilter (see SecurityConfig) gates it with a shared - * bearer instead, and is fail-closed when the bearer is unset. - * - * 503 when minting is disabled (no App configured); 502 when the App - * is configured but GitHub minting failed (not installed on the owner, - * transport error, …) so the caller can fall back to read-only `gh`. - */ -@Hidden -@RestController -@RequestMapping("/api/v1/internal/github") -class InternalGitHubTokenController( - private val tokens: GitHubAppInstallationTokenClient, -) { - data class InstallationTokenRequest( - val repoUrl: String? = null, - ) - - data class InstallationTokenResponse( - val token: String, - val expiresAt: Instant, - ) - - @PostMapping("/installation-token") - fun installationToken( - @RequestBody request: InstallationTokenRequest, - ): ResponseEntity { - if (!tokens.enabled) { - return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build() - } - val repoUrl = request.repoUrl?.takeIf { it.isNotBlank() } ?: return ResponseEntity.badRequest().build() - return tokens - .mint(repoUrl) - ?.let { ResponseEntity.ok(InstallationTokenResponse(it.token, it.expiresAt)) } - ?: ResponseEntity.status(HttpStatus.BAD_GATEWAY).build() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/KubernetesExceptionHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/KubernetesExceptionHandler.kt deleted file mode 100644 index 29d6cf9d..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/KubernetesExceptionHandler.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.common.web.FieldError -import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler -import com.jorisjonkers.personalstack.common.web.ProblemDetail -import io.fabric8.kubernetes.api.model.Status -import io.fabric8.kubernetes.client.KubernetesClientException -import org.slf4j.LoggerFactory -import org.slf4j.MDC -import org.springframework.core.Ordered -import org.springframework.core.annotation.Order -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.RestControllerAdvice -import org.springframework.web.context.request.WebRequest -import java.net.URI - -/** - * Surfaces the upstream Kubernetes API server's verdict instead of - * the generic 500 the [GlobalExceptionHandler] would otherwise - * return. - * - * The Fabric8 client wraps every non-2xx API server response in - * [KubernetesClientException] and stamps the parsed `Status` object - * (when the server returned one) onto the exception. The - * `kubernetesCode` / `kubernetesReason` extension fields expose the - * verdict to clients so the UI can distinguish a 403 (RBAC denial) - * from a 422 (schema validation) without parsing free-form text. - * - * `@Order(HIGHEST_PRECEDENCE)` keeps this handler ahead of the - * generic `Exception` mapper in [GlobalExceptionHandler] so a - * `KubernetesClientException` always lands here. - */ -@RestControllerAdvice -@Order(Ordered.HIGHEST_PRECEDENCE) -class KubernetesExceptionHandler { - private val log = LoggerFactory.getLogger(KubernetesExceptionHandler::class.java) - - // 31 lines after ktlint expands the log arguments onto their own - // lines — extracting one more helper would split the ProblemDetail - // builder from the local context it needs (traceId, k8sCode, …) - // and obscure rather than clarify. - @Suppress("LongMethod") - @ExceptionHandler(KubernetesClientException::class) - fun handleKubernetesClient( - ex: KubernetesClientException, - request: WebRequest?, - ): ResponseEntity { - val status = ex.status - val k8sCode = status?.code ?: ex.code.takeIf { it > 0 } - val k8sReason = status?.reason - val k8sMessage = status?.message ?: ex.message ?: "no message" - val traceId = MDC.get("traceId") ?: MDC.get("trace_id") - val path = pathOf(request) - log.error( - "Kubernetes API error traceId={} path={} code={} reason={} message={}", - traceId, - path, - k8sCode, - k8sReason, - k8sMessage, - ex, - ) - val body = - ProblemDetail( - type = URI.create("https://jorisjonkers.dev/errors/kubernetes-api"), - title = "Kubernetes API Error", - status = HttpStatus.BAD_GATEWAY.value(), - detail = buildDetail(k8sCode, k8sReason, k8sMessage), - instance = path?.let(URI::create), - errors = fieldErrorsFor(status), - traceId = traceId, - exception = ex.javaClass.name, - kubernetesCode = k8sCode, - kubernetesReason = k8sReason, - ) - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(body) - } - - private fun buildDetail( - k8sCode: Int?, - k8sReason: String?, - k8sMessage: String, - ): String = - buildString { - append("Kubernetes API request failed") - when { - k8sCode != null && k8sReason != null -> append(" (code=$k8sCode, reason=$k8sReason)") - k8sCode != null -> append(" (code=$k8sCode)") - k8sReason != null -> append(" (reason=$k8sReason)") - } - append(": ") - append(k8sMessage.takeIf { it.isNotBlank() } ?: "no message from API server") - } - - /** - * When the API server returns a structured `causes` list (e.g. - * CRD admission webhook rejecting a spec field), expose each - * cause as a `FieldError` so the UI can highlight the offending - * input without parsing the free-form `detail`. - */ - private fun fieldErrorsFor(status: Status?): List = - status - ?.details - ?.causes - ?.map { cause -> - FieldError( - field = cause.field ?: "", - message = cause.message ?: cause.reason ?: "Invalid value", - rejectedValue = null, - ) - }.orEmpty() - - private fun pathOf(request: WebRequest?): String? = - when (request) { - null -> null - else -> - runCatching { - request - .getDescription(false) - .removePrefix("uri=") - .takeIf { it.isNotBlank() } - }.getOrNull() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/MessageController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/MessageController.kt deleted file mode 100644 index 1ccd4cec..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/MessageController.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.command.SendMessageCommand -import com.jorisjonkers.personalstack.assistant.application.query.GetMessageQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.MessageId -import com.jorisjonkers.personalstack.assistant.domain.model.MessageRole -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.MessageResponse -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.SendMessageRequest -import com.jorisjonkers.personalstack.common.command.CommandBus -import jakarta.validation.Valid -import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestHeader -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController -import java.util.UUID - -@RestController -@RequestMapping("/api/v1/conversations/{conversationId}/messages") -class MessageController( - private val commandBus: CommandBus, - private val getMessageQueryService: GetMessageQueryService, -) { - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - fun send( - @PathVariable conversationId: UUID, - @RequestHeader("X-User-Id") userId: String, - @Valid @RequestBody request: SendMessageRequest, - ): MessageResponse { - val convId = ConversationId(conversationId) - val messageId = MessageId(UUID.randomUUID()) - commandBus.dispatch( - SendMessageCommand( - messageId = messageId, - conversationId = convId, - userId = userId, - content = request.content, - role = MessageRole.USER, - ), - ) - val saved = - getMessageQueryService - .findByConversationId(convId) - .firstOrNull { it.id == messageId } - ?: error("Message not found after saving") - return MessageResponse.from(saved) - } - - @GetMapping - fun list( - @PathVariable conversationId: UUID, - ): List { - val convId = ConversationId(conversationId) - return getMessageQueryService - .findByConversationId(convId) - .map { MessageResponse.from(it) } - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ProjectController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ProjectController.kt deleted file mode 100644 index 368e3a7c..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ProjectController.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.command.AddGithubLinkCommand -import com.jorisjonkers.personalstack.assistant.application.command.AttachDeployKeyCommand -import com.jorisjonkers.personalstack.assistant.application.command.CreateProjectCommand -import com.jorisjonkers.personalstack.assistant.application.command.LinkRepositoryToProjectCommand -import com.jorisjonkers.personalstack.assistant.application.command.RemoveGithubLinkCommand -import com.jorisjonkers.personalstack.assistant.application.command.UnlinkRepositoryFromProjectCommand -import com.jorisjonkers.personalstack.assistant.application.query.ProjectQueryService -import com.jorisjonkers.personalstack.assistant.application.query.RepositoryQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.AddGithubLinkRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.AttachDeployKeyRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.CreateProjectRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.GithubLinkResponse -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.LinkRepositoryRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.ProjectResponse -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.RepositoryResponse -import com.jorisjonkers.personalstack.common.command.CommandBus -import jakarta.validation.Valid -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.util.UUID - -@RestController -@RequestMapping("/api/v1/projects") -class ProjectController( - private val commandBus: CommandBus, - private val projectQuery: ProjectQueryService, - private val repositoryQuery: RepositoryQueryService, -) { - @PostMapping - fun create( - @Valid @RequestBody req: CreateProjectRequest, - ): ResponseEntity { - val id = ProjectId.random() - commandBus.dispatch( - CreateProjectCommand( - projectId = id, - name = req.name, - slug = req.slug, - description = req.description ?: "", - ), - ) - val view = projectQuery.get(id) ?: error("project not visible immediately after create") - return ResponseEntity.status(HttpStatus.CREATED).body(ProjectResponse.of(view.project)) - } - - @GetMapping - fun list(): List = projectQuery.list().map(ProjectResponse::of) - - @GetMapping("/{id}") - fun get( - @PathVariable id: UUID, - ): ResponseEntity> { - val view = projectQuery.get(ProjectId(id)) ?: return ResponseEntity.notFound().build() - val repos = repositoryQuery.listByProject(ProjectId(id)) - return ResponseEntity.ok( - mapOf( - "project" to ProjectResponse.of(view.project), - "links" to view.links.map(GithubLinkResponse::of), - "repositories" to repos.map(RepositoryResponse::of), - ), - ) - } - - @PostMapping("/{id}/repositories") - fun linkRepository( - @PathVariable id: UUID, - @Valid @RequestBody req: LinkRepositoryRequest, - ): ResponseEntity> { - commandBus.dispatch( - LinkRepositoryToProjectCommand( - projectId = ProjectId(id), - repositoryId = RepositoryId(req.repositoryId), - ), - ) - val repos = repositoryQuery.listByProject(ProjectId(id)) - return ResponseEntity.status(HttpStatus.CREATED).body(repos.map(RepositoryResponse::of)) - } - - @DeleteMapping("/{id}/repositories/{repoId}") - fun unlinkRepository( - @PathVariable id: UUID, - @PathVariable repoId: UUID, - ): ResponseEntity { - commandBus.dispatch( - UnlinkRepositoryFromProjectCommand( - projectId = ProjectId(id), - repositoryId = RepositoryId(repoId), - ), - ) - return ResponseEntity.noContent().build() - } - - @PostMapping("/{id}/links") - @Deprecated( - "Use POST /api/v1/repositories and POST /api/v1/projects/{id}/repositories. " + - "Kept until PR F migrates the assistant-ui.", - ) - fun addLink( - @PathVariable id: UUID, - @Valid @RequestBody req: AddGithubLinkRequest, - ): ResponseEntity { - val linkId = GithubLinkId.random() - commandBus.dispatch( - AddGithubLinkCommand( - linkId = linkId, - projectId = ProjectId(id), - name = req.name, - repoUrl = req.repoUrl, - defaultBranch = req.defaultBranch, - ), - ) - val link = - projectQuery - .get(ProjectId(id)) - ?.links - ?.firstOrNull { it.id == linkId } - ?: error("link not visible after add") - return ResponseEntity.status(HttpStatus.CREATED).body(GithubLinkResponse.of(link)) - } - - @PostMapping("/{projectId}/links/{linkId}/key") - @Suppress("UnusedParameter") // projectId carried in the URL only - fun attachKey( - @PathVariable projectId: UUID, - @PathVariable linkId: UUID, - @Valid @RequestBody req: AttachDeployKeyRequest, - ): ResponseEntity { - commandBus.dispatch( - AttachDeployKeyCommand( - linkId = GithubLinkId(linkId), - privateKeyOpenssh = req.privateKeyOpenssh, - publicKeyOpenssh = req.publicKeyOpenssh, - knownHosts = req.knownHosts, - ), - ) - return ResponseEntity.accepted().build() - } - - @DeleteMapping("/{projectId}/links/{linkId}") - @Suppress("UnusedParameter") // projectId carried in the URL only - fun removeLink( - @PathVariable projectId: UUID, - @PathVariable linkId: UUID, - ): ResponseEntity { - commandBus.dispatch(RemoveGithubLinkCommand(GithubLinkId(linkId))) - return ResponseEntity.noContent().build() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/RepositoryAccessDeniedExceptionHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/RepositoryAccessDeniedExceptionHandler.kt deleted file mode 100644 index 3e03a3d2..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/RepositoryAccessDeniedExceptionHandler.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.exception.RepositoryAccessDeniedException -import com.jorisjonkers.personalstack.common.web.ProblemDetail -import org.slf4j.LoggerFactory -import org.slf4j.MDC -import org.springframework.core.Ordered -import org.springframework.core.annotation.Order -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.RestControllerAdvice -import org.springframework.web.context.request.WebRequest -import java.net.URI - -/** - * Maps [RepositoryAccessDeniedException] to a 422 ProblemDetail so a - * workspace create that would have left an un-clonable runner fails - * with an actionable message instead of leaking a 500. - * - * Sits at `HIGHEST_PRECEDENCE` so the generic `Exception → 500` mapper - * never sees this type. - */ -@RestControllerAdvice -@Order(Ordered.HIGHEST_PRECEDENCE) -class RepositoryAccessDeniedExceptionHandler { - private val log = LoggerFactory.getLogger(RepositoryAccessDeniedExceptionHandler::class.java) - - @ExceptionHandler(RepositoryAccessDeniedException::class) - fun handle( - ex: RepositoryAccessDeniedException, - request: WebRequest?, - ): ResponseEntity { - val traceId = MDC.get("traceId") ?: MDC.get("trace_id") - val path = - runCatching { - request?.getDescription(false)?.removePrefix("uri=")?.takeIf { it.isNotBlank() } - }.getOrNull() - log.warn( - "repository access denied traceId={} path={} repository={}", - traceId, - path, - ex.repositoryId.value, - ) - val body = - ProblemDetail( - type = URI.create("https://jorisjonkers.dev/errors/repository-access-denied"), - title = "Deploy key cannot read repository", - status = HttpStatus.UNPROCESSABLE_ENTITY.value(), - detail = ex.message, - instance = path?.let(URI::create), - traceId = traceId, - ) - return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(body) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/RepositoryController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/RepositoryController.kt deleted file mode 100644 index 74fc8f0e..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/RepositoryController.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.RepositoryVerificationService -import com.jorisjonkers.personalstack.assistant.application.command.AttachRepositoryDeployKeyCommand -import com.jorisjonkers.personalstack.assistant.application.command.CreateRepositoryCommand -import com.jorisjonkers.personalstack.assistant.application.command.DeleteRepositoryCommand -import com.jorisjonkers.personalstack.assistant.application.query.RepositoryQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.AttachRepositoryDeployKeyRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.CreateRepositoryRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.ProjectResponse -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.RepositoryResponse -import com.jorisjonkers.personalstack.common.command.CommandBus -import jakarta.validation.Valid -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.util.UUID - -@RestController -@RequestMapping("/api/v1/repositories") -class RepositoryController( - private val commandBus: CommandBus, - private val repositoryQuery: RepositoryQueryService, - private val verificationService: RepositoryVerificationService, -) { - @PostMapping - fun create( - @Valid @RequestBody req: CreateRepositoryRequest, - ): ResponseEntity { - val id = RepositoryId.random() - commandBus.dispatch( - CreateRepositoryCommand( - repositoryId = id, - name = req.name, - repoUrl = req.repoUrl, - defaultBranch = req.defaultBranch, - ), - ) - val detail = - repositoryQuery.get(id) - ?: error("repository not visible immediately after create") - return ResponseEntity.status(HttpStatus.CREATED).body(RepositoryResponse.of(detail.repository)) - } - - @GetMapping - fun list(): List = repositoryQuery.list().map(RepositoryResponse::of) - - @GetMapping("/{id}") - fun get( - @PathVariable id: UUID, - ): ResponseEntity> { - val detail = repositoryQuery.get(RepositoryId(id)) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok( - mapOf( - "repository" to RepositoryResponse.of(detail.repository), - "attachedProjects" to detail.attachedProjects.map(ProjectResponse::of), - ), - ) - } - - @PostMapping("/{id}/key") - fun attachKey( - @PathVariable id: UUID, - @Valid @RequestBody req: AttachRepositoryDeployKeyRequest, - ): ResponseEntity { - commandBus.dispatch( - AttachRepositoryDeployKeyCommand( - repositoryId = RepositoryId(id), - privateKeyOpenssh = req.privateKeyOpenssh, - publicKeyOpenssh = req.publicKeyOpenssh, - knownHosts = req.knownHosts, - ), - ) - return ResponseEntity.accepted().build() - } - - @PostMapping("/{id}/verify") - fun verify( - @PathVariable id: UUID, - ): ResponseEntity { - val updated = verificationService.reverify(RepositoryId(id)) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok(RepositoryResponse.of(updated)) - } - - @DeleteMapping("/{id}") - fun delete( - @PathVariable id: UUID, - ): ResponseEntity { - commandBus.dispatch(DeleteRepositoryCommand(RepositoryId(id))) - return ResponseEntity.noContent().build() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/SetupGuideController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/SetupGuideController.kt deleted file mode 100644 index 41eee8fc..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/SetupGuideController.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.setup.SetupGuideService -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.util.UUID - -@RestController -@RequestMapping("/api/v1/projects/{projectId}/links/{linkId}") -class SetupGuideController( - private val links: GithubLinkRepository, - private val guides: SetupGuideService, -) { - @GetMapping("/setup-guide", produces = ["text/markdown", MediaType.TEXT_PLAIN_VALUE]) - @Suppress("UnusedParameter") // projectId carried in the URL only - fun guide( - @PathVariable projectId: UUID, - @PathVariable linkId: UUID, - ): ResponseEntity { - val link = links.findById(GithubLinkId(linkId)) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok(guides.render(link)) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt deleted file mode 100644 index 1ef2e368..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.command.AttachWorkspaceRepositoryCommand -import com.jorisjonkers.personalstack.assistant.application.command.CreateWorkspaceCommand -import com.jorisjonkers.personalstack.assistant.application.command.DestroyWorkspaceCommand -import com.jorisjonkers.personalstack.assistant.application.command.DetachWorkspaceRepositoryCommand -import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService -import com.jorisjonkers.personalstack.assistant.application.query.ListWorkspacesQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.AttachWorkspaceRepositoryRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.CreateWorkspaceRequest -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.WorkspaceDetailResponse -import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.WorkspaceResponse -import com.jorisjonkers.personalstack.common.command.CommandBus -import io.swagger.v3.oas.annotations.responses.ApiResponse -import jakarta.validation.Valid -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import java.util.UUID - -@RestController -@RequestMapping("/api/v1/workspaces") -class WorkspaceController( - private val commandBus: CommandBus, - private val listQuery: ListWorkspacesQueryService, - private val getQuery: GetWorkspaceQueryService, -) { - @Suppress("DEPRECATION") - @PostMapping - fun create( - @Valid @RequestBody req: CreateWorkspaceRequest, - ): ResponseEntity { - val id = WorkspaceId.random() - val primaryRepositoryId = primaryRepositoryId(req) - commandBus.dispatch( - CreateWorkspaceCommand( - workspaceId = id, - name = req.name, - repoUrl = req.repoUrl, - branch = req.branch, - kind = req.kind, - projectId = req.projectId?.let { ProjectId(it) }, - repositoryId = primaryRepositoryId?.let { RepositoryId(it) }, - repositoryIds = selectedRepositoryIds(req, primaryRepositoryId).map { RepositoryId(it) }, - githubLinkId = req.githubLinkId?.let { GithubLinkId(it) }, - ), - ) - val view = - getQuery.getSummary(id) - ?: throw IllegalStateException( - "Workspace $id was created but not yet visible to the read model; retry the GET in a moment", - ) - return ResponseEntity.status(HttpStatus.CREATED).body(WorkspaceResponse.of(view)) - } - - @GetMapping - fun list(): List = listQuery.listActive().map(WorkspaceResponse::of) - - @GetMapping("/{id}") - fun get( - @PathVariable id: UUID, - ): ResponseEntity { - val view = getQuery.get(WorkspaceId(id)) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok(WorkspaceDetailResponse.of(view.workspace, view.repositories, view.sessions)) - } - - @PostMapping("/{id}/repositories") - @ApiResponse(responseCode = "204", description = "No Content") - fun attachRepository( - @PathVariable id: UUID, - @Valid @RequestBody req: AttachWorkspaceRepositoryRequest, - ): ResponseEntity { - commandBus.dispatch( - AttachWorkspaceRepositoryCommand( - workspaceId = WorkspaceId(id), - repositoryId = RepositoryId(requireNotNull(req.repositoryId)), - ), - ) - return ResponseEntity.noContent().build() - } - - @DeleteMapping("/{id}/repositories/{repoId}") - fun detachRepository( - @PathVariable id: UUID, - @PathVariable repoId: UUID, - ): ResponseEntity { - commandBus.dispatch( - DetachWorkspaceRepositoryCommand( - workspaceId = WorkspaceId(id), - repositoryId = RepositoryId(repoId), - ), - ) - return ResponseEntity.noContent().build() - } - - @DeleteMapping("/{id}") - fun destroy( - @PathVariable id: UUID, - ): ResponseEntity { - commandBus.dispatch(DestroyWorkspaceCommand(WorkspaceId(id))) - return ResponseEntity.noContent().build() - } - - private fun primaryRepositoryId(req: CreateWorkspaceRequest): UUID? { - if (req.repositoryId != null && - req.primaryRepositoryId != null && - req.repositoryId != req.primaryRepositoryId - ) { - throw IllegalArgumentException("repositoryId and primaryRepositoryId must match when both are supplied") - } - return req.repositoryId ?: req.primaryRepositoryId ?: req.repositoryIds.orEmpty().firstOrNull() - } - - private fun selectedRepositoryIds( - req: CreateWorkspaceRequest, - primaryRepositoryId: UUID?, - ): List { - val repositoryIds = req.repositoryIds.orEmpty() - if (repositoryIds.isEmpty()) return emptyList() - return (listOfNotNull(primaryRepositoryId) + repositoryIds) - .distinct() - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/ChatSessionDtos.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/ChatSessionDtos.kt deleted file mode 100644 index 6f439ec6..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/ChatSessionDtos.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessage -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Size -import java.time.Instant -import java.util.UUID - -data class StartChatSessionRequest( - @field:Size(max = 120) val title: String? = null, - // Nullable so an absent field deserializes to null on any Jackson - // setup (a non-null Kotlin default is not honored for a missing - // property); the controller coalesces null to PLAIN. - val kind: ChatSessionKind? = null, -) - -data class ChatSessionResponse( - val id: UUID, - val userId: UUID, - val title: String?, - val status: String, - val kind: String, - val createdAt: Instant, - val updatedAt: Instant, -) { - companion object { - fun of(s: ChatSession) = - ChatSessionResponse( - id = s.id.value, - userId = s.userId, - title = s.title, - status = s.status.name, - kind = s.kind.name, - createdAt = s.createdAt, - updatedAt = s.updatedAt, - ) - } -} - -data class AppendChatMessageRequest( - @field:NotBlank val body: String, - val role: ChatMessageRole = ChatMessageRole.USER, -) - -data class ChatMessageResponse( - val id: UUID, - val sessionId: UUID, - val role: String, - val body: String, - val createdAt: Instant, -) { - companion object { - fun of(m: ChatMessage) = - ChatMessageResponse( - id = m.id.value, - sessionId = m.sessionId.value, - role = m.role.name, - body = m.body, - createdAt = m.createdAt, - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/ConversationResponse.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/ConversationResponse.kt deleted file mode 100644 index a56c300a..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/ConversationResponse.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto - -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import java.time.Instant -import java.util.UUID - -data class ConversationResponse( - val id: UUID, - val userId: UUID, - val title: String, - val status: String, - val createdAt: Instant, - val updatedAt: Instant, -) { - companion object { - fun from(conversation: Conversation): ConversationResponse = - ConversationResponse( - id = conversation.id.value, - userId = conversation.userId, - title = conversation.title, - status = conversation.status.name, - createdAt = conversation.createdAt, - updatedAt = conversation.updatedAt, - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/CreateConversationRequest.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/CreateConversationRequest.kt deleted file mode 100644 index d24895ae..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/CreateConversationRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto - -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Size - -data class CreateConversationRequest( - @field:NotBlank(message = "Title is required") - @field:Size(max = 200, message = "Title must not exceed 200 characters") - val title: String, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/MessageResponse.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/MessageResponse.kt deleted file mode 100644 index becf8410..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/MessageResponse.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto - -import com.jorisjonkers.personalstack.assistant.domain.model.Message -import java.time.Instant -import java.util.UUID - -data class MessageResponse( - val id: UUID, - val conversationId: UUID, - val role: String, - val content: String, - val createdAt: Instant, -) { - companion object { - fun from(message: Message): MessageResponse = - MessageResponse( - id = message.id.value, - conversationId = message.conversationId.value, - role = message.role.name, - content = message.content, - createdAt = message.createdAt, - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/ProjectDtos.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/ProjectDtos.kt deleted file mode 100644 index 1d919438..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/ProjectDtos.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Pattern -import jakarta.validation.constraints.Size -import java.time.Instant -import java.util.UUID - -data class CreateProjectRequest( - @field:NotBlank @field:Size(min = 1, max = 80) val name: String, - @field:Pattern(regexp = "^[a-z0-9][a-z0-9-]{0,62}$") val slug: String, - @field:Size(max = 1_000) val description: String? = null, -) - -data class ProjectResponse( - val id: UUID, - val name: String, - val slug: String, - val description: String, - val createdAt: Instant, - val updatedAt: Instant, -) { - companion object { - fun of(p: Project) = - ProjectResponse( - id = p.id.value, - name = p.name, - slug = p.slug, - description = p.description, - createdAt = p.createdAt, - updatedAt = p.updatedAt, - ) - } -} - -data class AddGithubLinkRequest( - @field:NotBlank val name: String, - @field:NotBlank val repoUrl: String, - val defaultBranch: String = "main", -) - -data class GithubLinkResponse( - val id: UUID, - val projectId: UUID, - val name: String, - val repoUrl: String, - val defaultBranch: String, - val vaultKeyPath: String, - val deployKeyFingerprint: String?, - val deployKeyAddedAt: Instant?, - val createdAt: Instant, - val updatedAt: Instant, -) { - companion object { - fun of(l: GithubLink) = - GithubLinkResponse( - id = l.id.value, - projectId = l.projectId.value, - name = l.name, - repoUrl = l.repoUrl, - defaultBranch = l.defaultBranch, - vaultKeyPath = l.vaultKeyPath, - deployKeyFingerprint = l.deployKeyFingerprint, - deployKeyAddedAt = l.deployKeyAddedAt, - createdAt = l.createdAt, - updatedAt = l.updatedAt, - ) - } -} - -data class AttachDeployKeyRequest( - @field:NotBlank val privateKeyOpenssh: String, - @field:NotBlank val publicKeyOpenssh: String, - val knownHosts: String? = null, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/RepositoryDtos.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/RepositoryDtos.kt deleted file mode 100644 index 8d9d494a..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/RepositoryDtos.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto - -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Size -import java.time.Instant -import java.util.UUID - -data class CreateRepositoryRequest( - @field:NotBlank @field:Size(min = 1, max = 80) val name: String, - @field:NotBlank val repoUrl: String, - val defaultBranch: String = "main", -) - -data class AccessVerificationResponse( - val read: Boolean?, - val write: Boolean?, - val defaultBranchProtected: Boolean?, - val checkedAt: Instant?, - val messages: List, -) { - companion object { - fun of(v: com.jorisjonkers.personalstack.assistant.domain.model.AccessVerification) = - AccessVerificationResponse( - read = v.read, - write = v.write, - defaultBranchProtected = v.defaultBranchProtected, - checkedAt = v.checkedAt, - messages = v.messages, - ) - } -} - -data class RepositoryResponse( - val id: UUID, - val name: String, - val repoUrl: String, - val defaultBranch: String, - val vaultKeyPath: String, - val deployKeyFingerprint: String?, - val deployKeyAddedAt: Instant?, - val createdAt: Instant, - val updatedAt: Instant, - val verification: AccessVerificationResponse?, -) { - companion object { - fun of(r: Repository) = - RepositoryResponse( - id = r.id.value, - name = r.name, - repoUrl = r.repoUrl, - defaultBranch = r.defaultBranch, - vaultKeyPath = r.vaultKeyPath, - deployKeyFingerprint = r.deployKeyFingerprint, - deployKeyAddedAt = r.deployKeyAddedAt, - createdAt = r.createdAt, - updatedAt = r.updatedAt, - verification = r.verification?.let(AccessVerificationResponse::of), - ) - } -} - -data class AttachRepositoryDeployKeyRequest( - @field:NotBlank val privateKeyOpenssh: String, - @field:NotBlank val publicKeyOpenssh: String, - val knownHosts: String? = null, -) - -data class LinkRepositoryRequest( - val repositoryId: UUID, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/SendMessageRequest.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/SendMessageRequest.kt deleted file mode 100644 index 2652069f..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/SendMessageRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto - -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.Size - -data class SendMessageRequest( - @field:NotBlank(message = "Content is required") - @field:Size(max = 10000, message = "Content must not exceed 10000 characters") - val content: String, -) diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt deleted file mode 100644 index 650790fa..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt +++ /dev/null @@ -1,271 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto - -import com.fasterxml.jackson.annotation.JsonProperty -import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService.WorkspaceRepositoryView -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.Turn -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind -import jakarta.validation.constraints.NotBlank -import jakarta.validation.constraints.NotNull -import jakarta.validation.constraints.Size -import java.time.Instant -import java.util.UUID - -data class CreateWorkspaceRequest( - @field:NotBlank - @field:Size(min = 1, max = 80) - val name: String, - val repoUrl: String? = null, - val branch: String? = null, - /** - * Workspace flavour. Defaults to REPO_BACKED so the existing UI - * (which doesn't yet send this field) keeps the previous shape. - */ - val kind: WorkspaceKind = WorkspaceKind.REPO_BACKED, - /** - * Optional project context. Set when the workspace was opened - * from a project's UI; null for ad-hoc work. - */ - val projectId: UUID? = null, - /** - * Preferred way to bind a workspace to a repo + its deploy key. - * When set, `repoUrl` / `branch` are derived from the - * Repository row. - */ - val repositoryId: UUID? = null, - /** - * Primary repo for the multi-repository request shape. Kept - * separate from [repositoryIds] so clients can submit an ordered - * or unordered selected set without relying on list position. - * [repositoryId] remains the backwards-compatible alias and wins - * when both fields match. - */ - val primaryRepositoryId: UUID? = null, - /** - * Selected repositories for a multi-repo workspace. When no - * primary field is set, the first entry becomes the primary and - * the remaining distinct ids are attached as extras. - */ - val repositoryIds: List? = null, - /** - * Deprecated alias for [repositoryId]. Kept until PR F migrates - * the assistant-ui to the new field. The server prefers - * [repositoryId] when both are supplied. - */ - @Deprecated("Use repositoryId", ReplaceWith("repositoryId")) - val githubLinkId: UUID? = null, -) - -data class AttachWorkspaceRepositoryRequest( - @field:NotNull - val repositoryId: UUID? = null, -) - -@Suppress("DEPRECATION") -data class WorkspaceResponse( - val id: UUID, - val name: String, - val repoUrl: String?, - val branch: String?, - val podName: String?, - val gatewayEndpoint: String?, - val status: String, - val kind: String, - val projectId: UUID?, - val repositoryId: UUID?, - val githubLinkId: UUID?, - val createdAt: Instant, - val updatedAt: Instant, -) { - companion object { - fun of(w: Workspace) = - WorkspaceResponse( - id = w.id.value, - name = w.name, - repoUrl = w.repoUrl, - branch = w.branch, - podName = w.podName, - gatewayEndpoint = w.gatewayEndpoint, - status = w.status.name, - kind = w.kind.name, - projectId = w.projectId?.value, - repositoryId = w.repositoryId?.value, - githubLinkId = w.githubLinkId?.value, - createdAt = w.createdAt, - updatedAt = w.updatedAt, - ) - } -} - -data class WorkspaceRepositoryResponse( - val id: UUID, - val name: String, - @get:JsonProperty("isPrimary") - val isPrimary: Boolean, - val attachedAt: Instant, - val repoUrl: String, - val defaultBranch: String, - val vaultKeyPath: String, - val deployKeyFingerprint: String?, - val deployKeyAddedAt: Instant?, - val createdAt: Instant, - val updatedAt: Instant, - val verification: AccessVerificationResponse?, -) { - companion object { - fun of(view: WorkspaceRepositoryView): WorkspaceRepositoryResponse { - val r = view.repository - return WorkspaceRepositoryResponse( - id = r.id.value, - name = r.name, - isPrimary = view.isPrimary, - attachedAt = view.attachedAt, - repoUrl = r.repoUrl, - defaultBranch = r.defaultBranch, - vaultKeyPath = r.vaultKeyPath, - deployKeyFingerprint = r.deployKeyFingerprint, - deployKeyAddedAt = r.deployKeyAddedAt, - createdAt = r.createdAt, - updatedAt = r.updatedAt, - verification = r.verification?.let(AccessVerificationResponse::of), - ) - } - - fun of(r: Repository) = - WorkspaceRepositoryResponse( - id = r.id.value, - name = r.name, - isPrimary = false, - attachedAt = r.createdAt, - repoUrl = r.repoUrl, - defaultBranch = r.defaultBranch, - vaultKeyPath = r.vaultKeyPath, - deployKeyFingerprint = r.deployKeyFingerprint, - deployKeyAddedAt = r.deployKeyAddedAt, - createdAt = r.createdAt, - updatedAt = r.updatedAt, - verification = r.verification?.let(AccessVerificationResponse::of), - ) - } -} - -@Suppress("DEPRECATION") -data class WorkspaceWithRepositoriesResponse( - val id: UUID, - val name: String, - val repoUrl: String?, - val branch: String?, - val podName: String?, - val gatewayEndpoint: String?, - val status: String, - val kind: String, - val projectId: UUID?, - val repositoryId: UUID?, - val githubLinkId: UUID?, - val repositories: List, - val createdAt: Instant, - val updatedAt: Instant, -) { - companion object { - fun of( - w: Workspace, - repositories: List, - ) = WorkspaceWithRepositoriesResponse( - id = w.id.value, - name = w.name, - repoUrl = w.repoUrl, - branch = w.branch, - podName = w.podName, - gatewayEndpoint = w.gatewayEndpoint, - status = w.status.name, - kind = w.kind.name, - projectId = w.projectId?.value, - repositoryId = w.repositoryId?.value, - githubLinkId = w.githubLinkId?.value, - repositories = repositories.map(WorkspaceRepositoryResponse::of), - createdAt = w.createdAt, - updatedAt = w.updatedAt, - ) - } -} - -data class WorkspaceDetailResponse( - val workspace: WorkspaceWithRepositoriesResponse, - val sessions: List, -) { - companion object { - fun of( - workspace: Workspace, - repositories: List, - sessions: List, - ) = WorkspaceDetailResponse( - workspace = WorkspaceWithRepositoriesResponse.of(workspace, repositories), - sessions = sessions.map(WorkspaceAgentSessionResponse::of), - ) - } -} - -data class StartAgentSessionRequest( - val kind: WorkspaceAgentKind, -) - -data class WorkspaceAgentSessionResponse( - val id: UUID, - val workspaceId: UUID, - val kind: WorkspaceAgentKind, - val gatewayAgentId: String?, - val status: String, - val createdAt: Instant, - val updatedAt: Instant, -) { - companion object { - fun of(s: WorkspaceAgentSession) = - WorkspaceAgentSessionResponse( - id = s.id.value, - workspaceId = s.workspaceId.value, - kind = s.kind, - gatewayAgentId = s.gatewayAgentId, - status = s.status.name, - createdAt = s.createdAt, - updatedAt = s.updatedAt, - ) - } -} - -data class SendUserInputRequest( - val text: String, - val enter: Boolean = true, -) - -data class StageInputRequest( - val content: String, - val name: String? = null, -) - -data class StagedInputResponse( - val path: String, - val bytes: Long, - val name: String, -) - -data class TurnResponse( - val id: UUID, - val sessionId: UUID, - val role: String, - val body: String, - val createdAt: Instant, -) { - companion object { - fun of(t: Turn) = - TurnResponse( - id = t.id.value, - sessionId = t.sessionId.value, - role = t.role.name, - body = t.body, - createdAt = t.createdAt, - ) - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandler.kt deleted file mode 100644 index 8dd40d5f..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandler.kt +++ /dev/null @@ -1,194 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.ws - -import com.jorisjonkers.personalstack.assistant.application.idle.ConnectedClientTracker -import com.jorisjonkers.personalstack.assistant.application.idle.WorkspaceActivityTracker -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Component -import org.springframework.web.socket.CloseStatus -import org.springframework.web.socket.TextMessage -import org.springframework.web.socket.WebSocketSession -import org.springframework.web.socket.client.standard.StandardWebSocketClient -import org.springframework.web.socket.handler.TextWebSocketHandler -import java.net.URI -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit - -/** - * WebSocket bridge: browser <-> assistant-api <-> agent-gateway. - * - * Per browser-attach, this handler: - * 1. Opens an upstream WS to the runner's gateway - * `/ws/agents/{gatewayAgentId}/attach`. - * 2. Relays each inbound client frame to the upstream verbatim — - * `{"input"}` keystrokes and `{"resize":{"cols","rows"}}` PTY - * resizes alike. The browser xterm is the source of truth for - * both. - * 3. Relays each upstream frame to the browser verbatim. - * - * The terminal stream is NOT persisted as Turns: the live xterm WS - * already shows it and the terminal holds screen state, so buffering - * raw PTY bytes (including ANSI escapes) into the transcript only - * duplicated the display and wrote escape garbage into the DB. - */ -@Component -class SessionAttachHandler( - private val sessions: WorkspaceAgentSessionRepository, - private val workspaces: WorkspaceRepository, - private val activity: WorkspaceActivityTracker, - private val connected: ConnectedClientTracker, -) : TextWebSocketHandler() { - private val log = LoggerFactory.getLogger(SessionAttachHandler::class.java) - - private data class Bridge( - val sessionId: WorkspaceAgentSessionId, - val workspaceId: com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId, - val upstream: WebSocketSession, - ) - - private val bridges = ConcurrentHashMap() - private val client = StandardWebSocketClient() - - private data class ResolvedAttach( - val sessionId: WorkspaceAgentSessionId, - val workspaceId: com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId, - val upstreamUri: URI, - ) - - /** - * Resolves the client WS into the upstream URI plus the - * workspace id we need to mark "active". A null on any leg of - * the resolution means we close the client WS with a labelled - * status; the chain reads cleaner with explicit guards than - * with a flattened let/Result alternative, so detekt's bounded- - * return rule is suppressed with intent. - */ - @Suppress("ReturnCount") - private fun resolveAttach(clientSession: WebSocketSession): ResolvedAttach? { - val sessionId = - sessionIdOf(clientSession) - ?: return closeAndReturn(clientSession, "missing sessionId", CloseStatus.BAD_DATA) - val agentSession = - sessions.findById(sessionId) - ?: return closeAndReturn(clientSession, "unknown session", CloseStatus.BAD_DATA) - val workspace = - workspaces.findById(agentSession.workspaceId) - ?: return closeAndReturn(clientSession, "workspace gone", CloseStatus.SERVER_ERROR) - val gatewayAgentId = - agentSession.gatewayAgentId - ?: return closeAndReturn( - clientSession, - "session not bound to a gateway agent", - CloseStatus.SERVER_ERROR, - ) - val gatewayBase = - workspace.gatewayEndpoint - ?: return closeAndReturn(clientSession, "workspace has no gateway endpoint", CloseStatus.SERVER_ERROR) - val upstreamUri = URI.create(gatewayBase.replaceFirst("http", "ws") + "/ws/agents/$gatewayAgentId/attach") - return ResolvedAttach(sessionId, workspace.id, upstreamUri) - } - - private fun closeAndReturn( - session: WebSocketSession, - reason: String, - status: CloseStatus, - ): ResolvedAttach? { - session.close(status.withReason(reason)) - return null - } - - override fun afterConnectionEstablished(clientSession: WebSocketSession) { - val resolved = resolveAttach(clientSession) ?: return - val upstreamHandler = UpstreamHandler(clientSession, resolved.workspaceId, activity) - val upstream = - client - .execute(upstreamHandler, resolved.upstreamUri.toString()) - .get(UPSTREAM_HANDSHAKE_SECONDS, TimeUnit.SECONDS) - bridges[clientSession.id] = Bridge(resolved.sessionId, resolved.workspaceId, upstream) - connected.attach(resolved.workspaceId) - activity.touch(resolved.workspaceId) - log.info( - "attached client {} to session {} via {}", - clientSession.id, - resolved.sessionId, - resolved.upstreamUri, - ) - } - - override fun handleTextMessage( - clientSession: WebSocketSession, - message: TextMessage, - ) { - val bridge = bridges[clientSession.id] ?: return - if (bridge.upstream.isOpen) { - synchronized(bridge.upstream) { bridge.upstream.sendMessage(message) } - } else { - clientSession.close(CloseStatus.SERVICE_RESTARTED.withReason("runner attach disconnected")) - } - sessions.findById(bridge.sessionId)?.workspaceId?.let { activity.touch(it) } - } - - override fun afterConnectionClosed( - clientSession: WebSocketSession, - status: CloseStatus, - ) { - val bridge = bridges.remove(clientSession.id) ?: return - connected.detach(bridge.workspaceId) - runCatching { bridge.upstream.close(status) } - } - - private fun sessionIdOf(session: WebSocketSession): WorkspaceAgentSessionId? { - val match = - Regex("/api/v1/ws/sessions/([0-9a-f-]{36})/attach").find(session.uri?.path ?: return null) - ?: return null - return runCatching { WorkspaceAgentSessionId(UUID.fromString(match.groupValues[1])) }.getOrNull() - } - - companion object { - private const val UPSTREAM_HANDSHAKE_SECONDS = 5L - } - - /** - * Inbound from gateway: shovel the frame straight back to the - * browser and record activity so the idle sweep knows the AI is - * still producing output (even if the user is not typing). - */ - private class UpstreamHandler( - private val client: WebSocketSession, - private val workspaceId: com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId, - private val activity: WorkspaceActivityTracker, - ) : TextWebSocketHandler() { - override fun handleTextMessage( - session: WebSocketSession, - message: TextMessage, - ) { - if (client.isOpen) { - synchronized(client) { client.sendMessage(message) } - } - // Touch on upstream output: AI streaming → keeps idle timer alive - // while the agent is working, even with no user keystrokes. - activity.touch(workspaceId) - } - - override fun afterConnectionClosed( - session: WebSocketSession, - status: CloseStatus, - ) { - if (client.isOpen) { - client.close(CloseStatus.SERVICE_RESTARTED.withReason("runner attach disconnected")) - } - } - - override fun handleTransportError( - session: WebSocketSession, - exception: Throwable, - ) { - if (client.isOpen) { - client.close(CloseStatus.SERVICE_RESTARTED.withReason("runner attach error")) - } - } - } -} diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/WebSocketConfig.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/WebSocketConfig.kt deleted file mode 100644 index 0d15b26e..00000000 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/WebSocketConfig.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.ws - -import org.springframework.context.annotation.Configuration -import org.springframework.web.socket.config.annotation.EnableWebSocket -import org.springframework.web.socket.config.annotation.WebSocketConfigurer -import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry - -@Configuration -@EnableWebSocket -class WebSocketConfig( - private val sessionAttachHandler: SessionAttachHandler, -) : WebSocketConfigurer { - override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { - registry - .addHandler(sessionAttachHandler, "/api/v1/ws/sessions/*/attach") - .setAllowedOriginPatterns("*") - } -} diff --git a/services/assistant-api/src/main/resources/application-cds-training.yml b/services/assistant-api/src/main/resources/application-cds-training.yml deleted file mode 100644 index 29dc17e5..00000000 --- a/services/assistant-api/src/main/resources/application-cds-training.yml +++ /dev/null @@ -1,7 +0,0 @@ -# Profile activated only by the AppCDS training stage in the Dockerfile. -# See auth-api/src/main/resources/application-cds-training.yml for the -# full rationale — disable Flyway so the training run can refresh the -# Spring context without a real Postgres in the build environment. -spring: - flyway: - enabled: false diff --git a/services/assistant-api/src/main/resources/application-crac-train.yml b/services/assistant-api/src/main/resources/application-crac-train.yml deleted file mode 100644 index 36ba7fed..00000000 --- a/services/assistant-api/src/main/resources/application-crac-train.yml +++ /dev/null @@ -1,29 +0,0 @@ -# Profile activated by the CRaC training stage in CI. Drives synthetic local -# traffic against the running JVM to JIT-compile the request pipeline before -# `Core.checkpointRestore()` dumps the JVM. See auth-api's -# application-crac-train.yml for the broader rationale; assistant-api's -# warmup endpoint list differs because it serves a conversation/message API -# rather than OAuth2. -# -# Merging only this profile is a no-op in production because -# `crac.train.enabled` defaults to false and is only flipped on by the -# `crac-train` profile in the CI training stage. -crac: - train: - enabled: true - iterations: 200 - checkpoint-after-warmup: true - endpoints: - - /api/actuator/health - - /api/actuator/health/readiness - - /api/v1/health - # Auth-required — returns 401 but exercises the Spring Security filter - # chain + Jackson error rendering, which is the bulk of the read path. - - /api/v1/conversations - -spring: - cloud: - vault: - enabled: false - database: - enabled: false diff --git a/services/assistant-api/src/main/resources/application-prod.yml b/services/assistant-api/src/main/resources/application-prod.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/services/assistant-api/src/main/resources/application.yml b/services/assistant-api/src/main/resources/application.yml deleted file mode 100644 index aec70736..00000000 --- a/services/assistant-api/src/main/resources/application.yml +++ /dev/null @@ -1,168 +0,0 @@ -spring: - application: - name: assistant-api - threads: - virtual: - enabled: true - autoconfigure: - exclude: - - org.springframework.cloud.autoconfigure.LifecycleMvcEndpointAutoConfiguration - cloud: - vault: - enabled: ${VAULT_ENABLED:false} - uri: ${VAULT_ADDR:http://localhost:8200} - authentication: ${VAULT_AUTHENTICATION:TOKEN} - config: - lifecycle: - enabled: true - min-renewal: 10s - expiry-threshold: 60s - lease-endpoints: Leases - database: - enabled: ${VAULT_DB_ENABLED:false} - role: assistant-api - backend: database - datasource: - url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/assistant_db - username: ${DB_USER:assistant_user} - password: ${DB_PASSWORD:assistant_password} - hikari: - pool-name: assistant-api-pool - maximum-pool-size: 10 - minimum-idle: 10 - connection-timeout: 10000 - validation-timeout: 5000 - idle-timeout: 600000 - keepalive-time: 30000 - max-lifetime: 1800000 - leak-detection-threshold: 60000 - initialization-fail-timeout: 60000 - auto-commit: true - data-source-properties: - tcpKeepAlive: true - socketTimeout: 30 - connectTimeout: 10 - loginTimeout: 10 - ApplicationName: assistant-api - flyway: - enabled: true - locations: classpath:db/migration - data: - redis: - host: ${VALKEY_HOST:localhost} - port: ${VALKEY_PORT:6379} - -server: - port: 8082 - -management: - endpoints: - web: - base-path: /api/actuator - exposure: - include: health,info,prometheus,metrics - endpoint: - health: - # Full per-contributor breakdown so a composite 503 is diagnosable - # with one curl, matching auth-api. Actuator is behind forward-auth - # in production and details expose descriptor data only — never - # credentials. - show-details: always - group: - liveness: - include: ping - metrics: - tags: - # Common Micrometer tag — see auth-api/application.yml for the - # rationale; both services standardise on `application=` - # so the spring-boot-jvm and service-template dashboards can - # filter consistently. - application: ${spring.application.name} - # OTLP tracing — see auth-api/application.yml for the rationale, - # including the note about OTel-agent-owned sampling in prod. - tracing: - sampling: - probability: ${TRACING_SAMPLE_PROBABILITY:1.0} - propagation: - type: w3c - otlp: - tracing: - endpoint: ${OTLP_TRACING_ENDPOINT:http://alloy.observability.svc.cluster.local:4318/v1/traces} - observations: - key-values: - service.name: ${spring.application.name} - deployment.environment: ${DEPLOYMENT_ENVIRONMENT:unknown} - -springdoc: - api-docs: - path: /api/v1/api-docs - swagger-ui: - path: /api/v1/swagger-ui - -agent-runtime: - namespace: ${AGENT_RUNTIME_NAMESPACE:agents-system} - image: ${AGENT_RUNTIME_IMAGE:ghcr.io/extratoast/personal-stack/agent-runner:latest} - image-pull-policy: ${AGENT_RUNTIME_IMAGE_PULL_POLICY:Always} - service-account: ${AGENT_RUNTIME_SERVICE_ACCOUNT:agent-runner} - workspace-storage-class: ${AGENT_RUNTIME_WORKSPACE_STORAGE_CLASS:local-path} - workspace-storage-size: ${AGENT_RUNTIME_WORKSPACE_STORAGE_SIZE:8Gi} - gateway-port: ${AGENT_RUNTIME_GATEWAY_PORT:8090} - claude-credentials-pvc: ${AGENT_RUNTIME_CLAUDE_PVC:claude-credentials} - codex-credentials-pvc: ${AGENT_RUNTIME_CODEX_PVC:codex-credentials} - github-deploy-key-secret: ${AGENT_RUNTIME_GH_KEY_SECRET:agents-github-deploy-key} - knowledge-base-url: ${AGENT_RUNTIME_KB_URL:http://knowledge-api.knowledge-system.svc.cluster.local:8080} - knowledge-bearer-secret: ${AGENT_RUNTIME_KB_BEARER_SECRET:agents-kb-bearer} - knowledge-bearer-secret-key: ${AGENT_RUNTIME_KB_BEARER_SECRET_KEY:bearer} - # Runner MCP profile. Keep default tool count low; wider profiles are - # opt-ins via AGENT_RUNTIME_DEFAULT_MCP_PROFILE. - default-mcp-profile: ${AGENT_RUNTIME_DEFAULT_MCP_PROFILE:minimal} - # Runner Pods mount the host Docker socket so future agent sessions can run - # Docker CLI commands and JVM Testcontainers suites. This is host-equivalent - # access. The default gid is pinned in the Nix host definition for the - # Docker-socket-capable runner node; override both values together when - # moving runners. - docker-socket-enabled: ${AGENT_RUNTIME_DOCKER_SOCKET_ENABLED:true} - docker-socket-path: ${AGENT_RUNTIME_DOCKER_SOCKET_PATH:/var/run/docker.sock} - docker-socket-supplemental-groups: ${AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS:131} - # Pinning to a single node is mandatory because the shared - # credential PVCs (claude-credentials / codex-credentials) are - # backed by local-path RWO — every runner Pod must land on the - # node that owns the PV. See platform/cluster/flux/apps/agents/ - # credentials/claude-credentials-pvc.yaml for the rationale. - # Spring Boot's `@ConfigurationProperties` relaxed binding lowercases - # map keys and strips special chars (`-`, `/`, `.`) unless the key - # is bracket-escaped. Without the brackets, `personal-stack/node` - # bound to `personalstacknode` on the runtime side and every - # agent-runner Pod sat Pending forever ("0/7 nodes are available: - # 7 node(s) didn't match Pod's node affinity/selector"). The - # bracket form forces Spring to take the key literally. - # https://docs.spring.io/spring-boot/reference/features/external-config.html#features.external-config.typesafe-configuration-properties.relaxed-binding.maps - node-selector: - '[personal-stack/node]': ${AGENT_RUNTIME_NODE:enschede-gtx-960m-1} - '[personal-stack/capability-docker-socket]': ${AGENT_RUNTIME_DOCKER_SOCKET_CAPABILITY:true} - # Idle scale-down. A workspace whose last observed activity is - # older than `idle-after-seconds` gets its Pod + Service + per- - # workspace Secret deleted; the workspace PVC survives so a - # future wake-up can re-mount the same disk. The sweep runs at - # `idle-sweep-period-ms` intervals. - idle-after-seconds: ${AGENT_RUNTIME_IDLE_AFTER_SECONDS:1800} - idle-sweep-period-ms: ${AGENT_RUNTIME_IDLE_SWEEP_PERIOD_MS:300000} - # Standing agent-gateway used for the workspace-independent - # `/git/verify` deploy-key probe at attach/create time. Empty => - # the probe is skipped and the stored read/write degrade to null. - verify-gateway-base-url: ${AGENT_RUNTIME_VERIFY_GATEWAY_URL:} - # Read-only GitHub token for the branch-protection lookup, projected - # from the github-api-token Secret. Empty => protection reports null - # (never a hard failure). - github-api-token: ${GITHUB_API_TOKEN:} - github-api-base-url: ${GITHUB_API_BASE_URL:https://api.github.com} - # GitHub App used to mint short-lived, single-repo installation tokens - # for runner GitHub writes: git push over HTTPS, gh PR creation, - # PR comments, and Actions re-runs. Both the id and key empty => - # minting disabled and the internal token endpoint returns 503. The - # PEM may be PKCS#1 or PKCS#8. - github-app-id: ${GITHUB_APP_ID:} - github-app-private-key: ${GITHUB_APP_PRIVATE_KEY:} - # Shared bearer the runner presents on the in-cluster installation- - # token endpoint. Empty => that endpoint is fail-closed (rejects all). - github-app-token-bearer: ${GITHUB_APP_TOKEN_BEARER:} diff --git a/services/assistant-api/src/main/resources/db/migration/V10__chat_session_kind.sql b/services/assistant-api/src/main/resources/db/migration/V10__chat_session_kind.sql deleted file mode 100644 index 91ef906c..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V10__chat_session_kind.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Chat sessions gain a `kind` so the redesigned UI can mark a session --- as KB-mode (KNOWLEDGE) vs the existing no-Pod surface (PLAIN). Existing --- rows default to PLAIN, which is exactly their current behaviour. -ALTER TABLE chat_sessions - ADD COLUMN kind VARCHAR(32) NOT NULL DEFAULT 'PLAIN'; diff --git a/services/assistant-api/src/main/resources/db/migration/V11__repository_access_verification.sql b/services/assistant-api/src/main/resources/db/migration/V11__repository_access_verification.sql deleted file mode 100644 index 6b22a693..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V11__repository_access_verification.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Cached deploy-key verification outcome, mirrored onto the row so --- the UI can render read/write/protected without a live probe on --- every page load. Re-run on demand via POST /repositories/{id}/verify --- and refreshed automatically after every AttachRepositoryDeployKey. --- --- All three booleans are nullable: null means "not yet verified" or --- "inconclusive" (gateway unreachable / no GitHub token), distinct --- from an explicit false. Messages are stored as a single newline- --- joined TEXT (not an array) so the jOOQ H2 DDL simulator at codegen --- time stays happy. -ALTER TABLE repositories - ADD COLUMN verify_read BOOLEAN, - ADD COLUMN verify_write BOOLEAN, - ADD COLUMN verify_default_branch_protected BOOLEAN, - ADD COLUMN verify_checked_at TIMESTAMPTZ, - ADD COLUMN verify_messages TEXT; diff --git a/services/assistant-api/src/main/resources/db/migration/V12__workspace_agent_session_cli_session_id.sql b/services/assistant-api/src/main/resources/db/migration/V12__workspace_agent_session_cli_session_id.sql deleted file mode 100644 index 8a51cc93..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V12__workspace_agent_session_cli_session_id.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Store the native CLI session id so a resume flag can be passed on --- wake/re-attach without starting a fresh session. Nullable because: --- - SHELL sessions have no native CLI id --- - Codex session id discovery is async (captured after spawn) --- - Old rows have no id - -ALTER TABLE workspace_agent_sessions - ADD COLUMN cli_session_id TEXT, - ADD COLUMN run_mode TEXT NOT NULL DEFAULT 'INTERACTIVE'; diff --git a/services/assistant-api/src/main/resources/db/migration/V13__workspace_repositories.sql b/services/assistant-api/src/main/resources/db/migration/V13__workspace_repositories.sql deleted file mode 100644 index f73eed89..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V13__workspace_repositories.sql +++ /dev/null @@ -1,21 +0,0 @@ --- A workspace can clone more than one repository. The primary repo stays --- denormalised on workspaces (repository_id / repo_url) and drives REPO_URL; --- this junction carries the additional repos that become REPO_URLS in the --- runner Pod. is_primary marks the backfilled primary so the orchestrator can --- exclude it from REPO_URLS without a second lookup. -CREATE TABLE workspace_repositories ( - workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, - is_primary BOOLEAN NOT NULL DEFAULT FALSE, - attached_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (workspace_id, repository_id) -); - -CREATE INDEX idx_workspace_repositories_repository ON workspace_repositories (repository_id); - --- Backfill the primary repo for existing repo-backed workspaces so the model --- is consistent from day one. -INSERT INTO workspace_repositories (workspace_id, repository_id, is_primary, attached_at) -SELECT id, repository_id, TRUE, created_at -FROM workspaces -WHERE repository_id IS NOT NULL; diff --git a/services/assistant-api/src/main/resources/db/migration/V1__init_assistant.sql b/services/assistant-api/src/main/resources/db/migration/V1__init_assistant.sql deleted file mode 100644 index 288c4365..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V1__init_assistant.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS conversation ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - title VARCHAR(255) NOT NULL DEFAULT 'New Conversation', - status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_conversation_user_id ON conversation(user_id); diff --git a/services/assistant-api/src/main/resources/db/migration/V2__add_messages.sql b/services/assistant-api/src/main/resources/db/migration/V2__add_messages.sql deleted file mode 100644 index 72890a3d..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V2__add_messages.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE IF NOT EXISTS message ( - id UUID PRIMARY KEY, - conversation_id UUID NOT NULL REFERENCES conversation(id), - role VARCHAR(20) NOT NULL, -- 'USER' or 'ASSISTANT' - content TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_message_conversation_id ON message(conversation_id); diff --git a/services/assistant-api/src/main/resources/db/migration/V3__indexes_for_hot_paths.sql b/services/assistant-api/src/main/resources/db/migration/V3__indexes_for_hot_paths.sql deleted file mode 100644 index 993e4b39..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V3__indexes_for_hot_paths.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Composite indexes on the two query shapes that fire on every --- chat page load: --- * "list my conversations, newest first" → conversation (user_id, created_at DESC) --- * "fetch this conversation's messages" → message (conversation_id, created_at) --- --- V1/V2 already index user_id and conversation_id respectively, but --- those are single-column — the DESC / sort order on created_at falls --- off the index and Postgres does a filter+sort. Composite indexes --- let the planner serve both queries index-only. -CREATE INDEX IF NOT EXISTS idx_conversation_user_id_created_at - ON conversation (user_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_message_conversation_id_created_at - ON message (conversation_id, created_at); - --- The single-column indexes from V1/V2 become redundant with these, --- but dropping them here risks blocking the migration on an exclusive --- lock if the table is large. Defer the drop to a follow-up migration --- after we've confirmed the composites are being used (via --- pg_stat_user_indexes). diff --git a/services/assistant-api/src/main/resources/db/migration/V4__workspaces.sql b/services/assistant-api/src/main/resources/db/migration/V4__workspaces.sql deleted file mode 100644 index 19d8d44a..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V4__workspaces.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE workspaces ( - id UUID PRIMARY KEY, - name TEXT NOT NULL, - repo_url TEXT, - branch TEXT, - pod_name TEXT, - pvc_name TEXT, - gateway_endpoint TEXT, - -- VARCHAR(32) instead of TEXT because the column is indexed; H2 - -- (used by jOOQ's DDL simulation at codegen time) cannot put an - -- index on a CLOB-shaped column, and Postgres treats VARCHAR(N) - -- and TEXT identically anyway. - status VARCHAR(32) NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - --- Full index (no WHERE predicate) — jOOQ's H2-backed DDL simulation --- doesn't parse partial indexes, and the cardinality of the status --- column is low enough that the additional storage cost is minimal. -CREATE INDEX idx_workspaces_status ON workspaces (status); diff --git a/services/assistant-api/src/main/resources/db/migration/V5__workspace_agent_sessions.sql b/services/assistant-api/src/main/resources/db/migration/V5__workspace_agent_sessions.sql deleted file mode 100644 index 064abf6a..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V5__workspace_agent_sessions.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE workspace_agent_sessions ( - id UUID PRIMARY KEY, - workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - kind TEXT NOT NULL, - gateway_agent_id TEXT, - status TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_was_workspace_id ON workspace_agent_sessions (workspace_id); diff --git a/services/assistant-api/src/main/resources/db/migration/V6__turns.sql b/services/assistant-api/src/main/resources/db/migration/V6__turns.sql deleted file mode 100644 index 79735069..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V6__turns.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE turns ( - id UUID PRIMARY KEY, - session_id UUID NOT NULL REFERENCES workspace_agent_sessions(id) ON DELETE CASCADE, - role TEXT NOT NULL, - body TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_turns_session_created ON turns (session_id, created_at); diff --git a/services/assistant-api/src/main/resources/db/migration/V7__projects.sql b/services/assistant-api/src/main/resources/db/migration/V7__projects.sql deleted file mode 100644 index ce5bc3b3..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V7__projects.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE projects ( - id UUID PRIMARY KEY, - name TEXT NOT NULL, - -- VARCHAR(64) because `slug` carries a UNIQUE index — H2 (used by - -- jOOQ's DDL simulation at codegen time) can't index a CLOB. - slug VARCHAR(64) NOT NULL UNIQUE, - description TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_projects_created_at ON projects (created_at DESC); diff --git a/services/assistant-api/src/main/resources/db/migration/V8__github_links.sql b/services/assistant-api/src/main/resources/db/migration/V8__github_links.sql deleted file mode 100644 index 11681150..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V8__github_links.sql +++ /dev/null @@ -1,29 +0,0 @@ --- A linked GitHub repository under a Project. Each row points to a --- Vault KV-v2 path that stores the deploy key (private_key, --- public_key, known_hosts) provisioned for this single repo. -CREATE TABLE github_links ( - id UUID PRIMARY KEY, - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - -- VARCHAR(80) because the table carries UNIQUE(project_id, name); - -- H2 (jOOQ's DDL simulation backend) cannot index a CLOB. - name VARCHAR(80) NOT NULL, - repo_url TEXT NOT NULL, - default_branch TEXT NOT NULL DEFAULT 'main', - -- Path under the Vault `secret/data/` mount where the deploy key - -- lives. Stored relative so a future Vault namespace change - -- doesn't require a data migration. - vault_key_path TEXT NOT NULL, - deploy_key_fingerprint TEXT, - deploy_key_added_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE (project_id, name) -); - -CREATE INDEX idx_github_links_project ON github_links (project_id); - --- Workspaces optionally point at a single GithubLink. Adding the --- column nullable so existing ad-hoc workspaces (created with just --- a repoUrl) keep working; new project-backed workspaces fill it in. -ALTER TABLE workspaces ADD COLUMN github_link_id UUID REFERENCES github_links(id) ON DELETE SET NULL; -CREATE INDEX idx_workspaces_github_link ON workspaces (github_link_id); diff --git a/services/assistant-api/src/main/resources/db/migration/V9__repositories_and_session_kinds.sql b/services/assistant-api/src/main/resources/db/migration/V9__repositories_and_session_kinds.sql deleted file mode 100644 index 291d55a8..00000000 --- a/services/assistant-api/src/main/resources/db/migration/V9__repositories_and_session_kinds.sql +++ /dev/null @@ -1,109 +0,0 @@ --- Repository as a first-class entity. Previously a GithubLink row --- was scoped to one Project; the operator's mental model has the --- repository (with its deploy key) shared across many Projects, so --- the table is owner-less and the new project_repositories junction --- carries the M:N membership. --- --- VARCHAR(80) on `name` because the UNIQUE index can't sit on a CLOB --- under H2 (jOOQ's DDL simulation backend at codegen time). -CREATE TABLE repositories ( - id UUID PRIMARY KEY, - name VARCHAR(80) NOT NULL UNIQUE, - repo_url TEXT NOT NULL, - default_branch TEXT NOT NULL DEFAULT 'main', - vault_key_path TEXT NOT NULL, - deploy_key_fingerprint TEXT, - deploy_key_added_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - --- Junction: a project lists repositories, a repository can be in --- many projects. -CREATE TABLE project_repositories ( - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, - linked_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (project_id, repository_id) -); - -CREATE INDEX idx_project_repositories_repository ON project_repositories (repository_id); - --- Data migration: every github_links row becomes a repository plus a --- junction row. The Vault path stays exactly as it was (already --- (project, link)-keyed), so existing secrets remain readable; the --- new per-repository write path coexists in VaultDeployKeyStore for --- the migration window. --- --- NOTE: github_links.name is UNIQUE per project, not globally. Two --- projects may have already added a link called "personal-stack", --- and the repositories table is globally unique on name. The --- migration disambiguates by appending the project id suffix when a --- collision would otherwise occur. -INSERT INTO repositories (id, name, repo_url, default_branch, - vault_key_path, deploy_key_fingerprint, - deploy_key_added_at, created_at, updated_at) -SELECT - gl.id, - CASE - WHEN dup.cnt > 1 - THEN gl.name || '-' || substring(gl.project_id::text, 1, 8) - ELSE gl.name - END, - gl.repo_url, - gl.default_branch, - gl.vault_key_path, - gl.deploy_key_fingerprint, - gl.deploy_key_added_at, - gl.created_at, - gl.updated_at -FROM github_links gl -JOIN ( - SELECT name, COUNT(*) AS cnt FROM github_links GROUP BY name -) dup ON dup.name = gl.name; - -INSERT INTO project_repositories (project_id, repository_id, linked_at) -SELECT project_id, id, COALESCE(deploy_key_added_at, created_at) -FROM github_links; - --- Workspace gains a per-repository pointer + the optional project --- context + a kind discriminator. `kind` defaults to REPO_BACKED so --- existing rows render the same shape they used to under the --- workspace tab. -ALTER TABLE workspaces - ADD COLUMN repository_id UUID REFERENCES repositories(id) ON DELETE SET NULL, - ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE SET NULL, - ADD COLUMN kind VARCHAR(16) NOT NULL DEFAULT 'REPO_BACKED'; - -UPDATE workspaces SET repository_id = github_link_id WHERE github_link_id IS NOT NULL; - -CREATE INDEX idx_workspaces_repository ON workspaces (repository_id); -CREATE INDEX idx_workspaces_project ON workspaces (project_id); --- `kind` cardinality is low; a full index is still cheaper than --- table scans on the dashboard query and H2's DDL simulator (used at --- jOOQ codegen) does not parse partial indexes. -CREATE INDEX idx_workspaces_kind ON workspaces (kind); - --- Chat sessions: independent of workspaces, tied to a user. No Pod --- is spawned — chat is a plain LLM conversation surface. -CREATE TABLE chat_sessions ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - title VARCHAR(120), - status VARCHAR(32) NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_chat_sessions_user_status ON chat_sessions (user_id, status); - -CREATE TABLE chat_session_messages ( - id UUID PRIMARY KEY, - chat_session_id UUID NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, - role VARCHAR(16) NOT NULL, - body TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_chat_session_messages_session_time - ON chat_session_messages (chat_session_id, created_at); diff --git a/services/assistant-api/src/main/resources/logback-spring.xml b/services/assistant-api/src/main/resources/logback-spring.xml deleted file mode 100644 index bad11b73..00000000 --- a/services/assistant-api/src/main/resources/logback-spring.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - trace_id - span_id - trace_flags - {"service":"assistant-api","service.name":"assistant-api","service.version":"${serviceVersion}","deployment.environment":"${deploymentEnvironment}"} - - - - - - - - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - diff --git a/services/assistant-api/src/main/resources/templates/deploy-key-setup.md b/services/assistant-api/src/main/resources/templates/deploy-key-setup.md deleted file mode 100644 index d70f8958..00000000 --- a/services/assistant-api/src/main/resources/templates/deploy-key-setup.md +++ /dev/null @@ -1,93 +0,0 @@ -# Deploy-key setup for `{{LINK_NAME}}` - -This guide walks through provisioning a GitHub deploy key that's -scoped to **only** `{{REPO_URL}}`. The private half lives in Vault -at `{{VAULT_KEY_PATH}}`; agent-runner Pods that mount this Project -can clone, push and (with write access) open PRs against this repo -without ever seeing a personal access token. - -## 1. Generate an ed25519 key locally - -Run this on your laptop (`{{LINK_NAME}}` becomes the file name, no -extension): - -```sh -ssh-keygen -t ed25519 \ - -f ./{{LINK_NAME}}-deploy \ - -C "{{LINK_NAME}}@personal-stack" \ - -N "" -``` - -That produces two files in your current directory: - -- `./{{LINK_NAME}}-deploy` — the **private** key. Keep it. You will - paste it back into the wizard in step 3. -- `./{{LINK_NAME}}-deploy.pub` — the **public** key. You will paste - this both into GitHub (step 2) and back into the wizard - (step 3). - -ed25519 is mandatory here. The runner image only loads ed25519-style -hostkeys by default, and ed25519 is what GitHub recommends as well. - -## 2. Add the public key as a GitHub deploy key - -1. Open the **Deploy keys** page for this repository: - <{{DEPLOY_KEY_PAGE_URL}}> -2. Click **Add deploy key**. -3. **Title:** `personal-stack — {{LINK_NAME}}` -4. **Key:** paste the entire contents of `./{{LINK_NAME}}-deploy.pub`. -5. **Allow write access:** check this box **only** if agents in this - project should be able to push branches and open PRs. Leave it - unchecked for read-only / inspection workspaces. -6. Click **Add key**. - -GitHub will accept the key immediately. You can verify the -fingerprint by running - -```sh -ssh-keygen -lf ./{{LINK_NAME}}-deploy.pub -``` - -locally — the `SHA256:…` value should match the **Fingerprint** -column that the personal-stack UI displays after step 3. - -For the official GitHub reference, see -. - -## 3. Paste both keys into the wizard - -In the personal-stack UI, paste: - -- The **entire** contents of `./{{LINK_NAME}}-deploy` (multiple - lines, including the `-----BEGIN OPENSSH PRIVATE KEY-----` and - `-----END OPENSSH PRIVATE KEY-----` lines) into the **Private - key** textarea. -- The **single line** from `./{{LINK_NAME}}-deploy.pub` into the - **Public key** field. - -Optionally also paste the output of `ssh-keyscan github.com` into -the **known_hosts** field. If you skip it the API uses the bundled -GitHub host-key entries — they rotate rarely (GitHub last rolled -the RSA key in March 2023), and the runner image re-scans on boot -as belt-and-braces. - -## 4. After the wizard finishes - -- The key pair is written to Vault under `{{VAULT_KEY_PATH}}` and - the row for this link gains a `Fingerprint` column. From this - point you can delete the local `./{{LINK_NAME}}-deploy*` files, - or keep them as a personal backup — either way the cluster has - what it needs. -- Workspaces created against this Project will mount the key from - Vault into the runner Pod at - `/var/run/secrets/agents/github-deploy-key/`. `gh` and `git` - commands inside the agent already know to use it; no further - configuration is required. - -## Rotating the key later - -Generate a new keypair, attach it via the wizard (the old one will -be overwritten in Vault), then delete the old public key from -GitHub's Deploy keys page. The personal-stack UI shows the -`fingerprint` so you can match what's in Vault against what's in -GitHub before pulling the rug. diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/RepositoryVerificationServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/RepositoryVerificationServiceTest.kt deleted file mode 100644 index d19176a2..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/RepositoryVerificationServiceTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application - -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant - -class RepositoryVerificationServiceTest { - private val repositories = mockk() - private val verifyAccess = mockk() - private val service = RepositoryVerificationService(repositories, verifyAccess) - - private val sample = - Repository( - id = RepositoryId.random(), - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "x", - deployKeyFingerprint = "SHA256:abc", - deployKeyAddedAt = Instant.now(), - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `reverify persists the fresh outcome and returns the updated row`() { - every { repositories.findById(sample.id) } returns sample - every { verifyAccess.verify(sample.repoUrl, sample.defaultBranch) } returns - VerifyRepositoryAccess.Result( - read = true, - write = true, - defaultBranchProtected = false, - checkedAt = Instant.now(), - messages = listOf("default branch 'main' is NOT protected on GitHub"), - ) - val saved = slot() - every { repositories.save(capture(saved)) } answers { saved.captured } - - val result = service.reverify(sample.id) - - assertThat(result).isNotNull - assertThat(saved.captured.verification?.read).isTrue - assertThat(saved.captured.verification?.defaultBranchProtected).isFalse - assertThat(saved.captured.verification?.messages).anyMatch { it.contains("NOT protected") } - } - - @Test - fun `reverify returns null for an unknown repository`() { - every { repositories.findById(any()) } returns null - assertThat(service.reverify(RepositoryId.random())).isNull() - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/VerifyRepositoryAccessTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/VerifyRepositoryAccessTest.kt deleted file mode 100644 index ac184c9d..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/VerifyRepositoryAccessTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application - -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.BranchProtectionClient -import io.mockk.every -import io.mockk.mockk -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset - -class VerifyRepositoryAccessTest { - private val gateway = mockk() - private val branchProtection = mockk() - private val clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneOffset.UTC) - private val useCase = VerifyRepositoryAccess(gateway, branchProtection, clock) - - private val repoUrl = "git@github.com:ExtraToast/personal-stack.git" - - @Test - fun `flattens read-write + protected into a clean result with no warnings`() { - every { gateway.verifyAccess(repoUrl, "main") } returns - AgentGatewayClient.AccessVerification(read = true, write = true, detail = "ok") - every { branchProtection.isBranchProtected(repoUrl, "main") } returns true - - val result = useCase.verify(repoUrl, "main") - - assertThat(result.read).isTrue - assertThat(result.write).isTrue - assertThat(result.defaultBranchProtected).isTrue - assertThat(result.checkedAt).isEqualTo(Instant.parse("2026-06-01T00:00:00Z")) - assertThat(result.messages).isEmpty() - } - - @Test - fun `read false produces a read message`() { - every { gateway.verifyAccess(repoUrl, "main") } returns - AgentGatewayClient.AccessVerification(read = false, write = false, detail = "Permission denied") - every { branchProtection.isBranchProtected(repoUrl, "main") } returns true - - val result = useCase.verify(repoUrl, "main") - - assertThat(result.read).isFalse - assertThat(result.messages).anyMatch { it.contains("cannot read") } - } - - @Test - fun `write false produces a read-only warning but keeps read true`() { - every { gateway.verifyAccess(repoUrl, "main") } returns - AgentGatewayClient.AccessVerification(read = true, write = false, detail = "denied") - every { branchProtection.isBranchProtected(repoUrl, "main") } returns true - - val result = useCase.verify(repoUrl, "main") - - assertThat(result.read).isTrue - assertThat(result.write).isFalse - assertThat(result.messages).anyMatch { it.contains("read-only") } - } - - @Test - fun `null gateway result degrades to null read-write with a warning`() { - every { gateway.verifyAccess(repoUrl, "main") } returns null - every { branchProtection.isBranchProtected(repoUrl, "main") } returns true - - val result = useCase.verify(repoUrl, "main") - - assertThat(result.read).isNull() - assertThat(result.write).isNull() - assertThat(result.messages).anyMatch { it.contains("could not be verified") } - } - - @Test - fun `unprotected default branch is a loud warning`() { - every { gateway.verifyAccess(repoUrl, "main") } returns - AgentGatewayClient.AccessVerification(read = true, write = true, detail = "ok") - every { branchProtection.isBranchProtected(repoUrl, "main") } returns false - - val result = useCase.verify(repoUrl, "main") - - assertThat(result.defaultBranchProtected).isFalse - assertThat(result.messages).anyMatch { it.contains("NOT protected") } - } - - @Test - fun `null branch protection (no token) degrades to null with a message`() { - every { gateway.verifyAccess(repoUrl, "main") } returns - AgentGatewayClient.AccessVerification(read = true, write = true, detail = "ok") - every { branchProtection.isBranchProtected(repoUrl, "main") } returns null - - val result = useCase.verify(repoUrl, "main") - - assertThat(result.defaultBranchProtected).isNull() - assertThat(result.messages).anyMatch { it.contains("could not be determined") } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/chat/ChatAnswerStreamServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/chat/ChatAnswerStreamServiceTest.kt deleted file mode 100644 index 1c023d2b..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/chat/ChatAnswerStreamServiceTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.chat - -import com.jorisjonkers.personalstack.assistant.application.command.AppendChatMessageCommand -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.assistant.infrastructure.integration.LightRagClient -import com.jorisjonkers.personalstack.common.command.CommandBus -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.runs -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant -import java.util.UUID -import java.util.concurrent.Executor - -class ChatAnswerStreamServiceTest { - private val sessions = mockk() - private val commandBus = mockk() - private val lightRag = mockk() - private val executor = Executor { it.run() } - private val service = ChatAnswerStreamService(sessions, commandBus, lightRag, executor) - - @Test - fun `stream persists assistant message when an answer is produced`() { - val session = session() - val commands = mutableListOf() - every { sessions.findById(session.id) } returns session - every { commandBus.dispatch(capture(commands)) } just runs - every { lightRag.streamQuery("Hi", any()) } answers { - secondArg<(String) -> Unit>().invoke("Hel") - secondArg<(String) -> Unit>().invoke("lo") - "Hello" - } - - service.stream(session.id, "Hi") - - assertThat(commands).hasSize(1) - assertThat(commands[0].sessionId).isEqualTo(session.id) - assertThat(commands[0].role).isEqualTo(ChatMessageRole.ASSISTANT) - assertThat(commands[0].body).isEqualTo("Hello") - } - - @Test - fun `stream persists no assistant message when no answer is produced`() { - val session = session() - val commands = mutableListOf() - every { sessions.findById(session.id) } returns session - every { commandBus.dispatch(capture(commands)) } just runs - every { lightRag.streamQuery("Hi", any()) } returns "" - - service.stream(session.id, "Hi") - - assertThat(commands).isEmpty() - } - - private fun session( - id: ChatSessionId = ChatSessionId.random(), - userId: UUID = UUID.randomUUID(), - ): ChatSession { - val now = Instant.now() - return ChatSession(id, userId, "x", ChatSessionStatus.ACTIVE, ChatSessionKind.PLAIN, now, now) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AddGithubLinkCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AddGithubLinkCommandHandlerTest.kt deleted file mode 100644 index ab12cbc3..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AddGithubLinkCommandHandlerTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant - -class AddGithubLinkCommandHandlerTest { - private val projects = mockk() - private val links = mockk() - private val handler = AddGithubLinkCommandHandler(projects, links) - - private fun project() = - Project( - id = ProjectId.random(), - name = "Personal Stack", - slug = "personal-stack", - description = "", - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `handle creates link with pre-allocated vault path and no fingerprint yet`() { - val proj = project() - every { projects.findById(proj.id) } returns proj - val saved = slot() - every { links.save(capture(saved)) } answers { saved.captured } - - val linkId = GithubLinkId.random() - handler.handle( - AddGithubLinkCommand( - linkId = linkId, - projectId = proj.id, - name = " personal-stack ", - repoUrl = "git@github.com:owner/repo.git", - defaultBranch = "", - ), - ) - - assertThat(saved.captured.id).isEqualTo(linkId) - assertThat(saved.captured.name).isEqualTo("personal-stack") - assertThat(saved.captured.defaultBranch).isEqualTo("main") - assertThat(saved.captured.vaultKeyPath).isEqualTo("secret/data/agents/projects/${proj.id}/repos/$linkId") - assertThat(saved.captured.deployKeyFingerprint).isNull() - assertThat(saved.captured.deployKeyAddedAt).isNull() - } - - @Test - fun `handle errors on unknown project`() { - val missing = ProjectId.random() - every { projects.findById(missing) } returns null - assertThrows { - handler.handle( - AddGithubLinkCommand(GithubLinkId.random(), missing, "x", "git@github.com:o/r.git"), - ) - } - } - - @Test - fun `handle rejects blank name`() { - val proj = project() - every { projects.findById(proj.id) } returns proj - assertThrows { - handler.handle(AddGithubLinkCommand(GithubLinkId.random(), proj.id, " ", "git@x:o/r.git")) - } - } - - @Test - fun `handle rejects blank repoUrl`() { - val proj = project() - every { projects.findById(proj.id) } returns proj - assertThrows { - handler.handle(AddGithubLinkCommand(GithubLinkId.random(), proj.id, "x", "")) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AppendChatMessageCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AppendChatMessageCommandHandlerTest.kt deleted file mode 100644 index 3adadf29..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AppendChatMessageCommandHandlerTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessage -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatMessageRole -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ChatMessageRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant -import java.util.UUID - -class AppendChatMessageCommandHandlerTest { - private val sessions = mockk() - private val messages = mockk() - private val handler = AppendChatMessageCommandHandler(sessions, messages) - - private val session = - ChatSession( - id = ChatSessionId.random(), - userId = UUID.randomUUID(), - title = "x", - status = ChatSessionStatus.ACTIVE, - kind = ChatSessionKind.PLAIN, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `handle persists the message and bumps session updatedAt`() { - every { sessions.findById(session.id) } returns session - val saved = slot() - every { messages.save(capture(saved)) } answers { saved.captured } - every { sessions.save(any()) } answers { firstArg() } - - val mid = ChatMessageId.random() - handler.handle(AppendChatMessageCommand(mid, session.id, ChatMessageRole.USER, "Hello")) - - assertThat(saved.captured.id).isEqualTo(mid) - assertThat(saved.captured.role).isEqualTo(ChatMessageRole.USER) - assertThat(saved.captured.body).isEqualTo("Hello") - verify { sessions.save(any()) } - } - - @Test - fun `handle rejects a blank body`() { - every { sessions.findById(session.id) } returns session - assertThrows { - handler.handle(AppendChatMessageCommand(ChatMessageId.random(), session.id, ChatMessageRole.USER, " ")) - } - } - - @Test - fun `handle throws NotFound for an unknown session`() { - every { sessions.findById(any()) } returns null - assertThrows { - handler.handle( - AppendChatMessageCommand( - messageId = ChatMessageId.random(), - sessionId = ChatSessionId.random(), - role = ChatMessageRole.USER, - body = "hey", - ), - ) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveChatSessionCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveChatSessionCommandHandlerTest.kt deleted file mode 100644 index 79dbe87f..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveChatSessionCommandHandlerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import com.jorisjonkers.personalstack.common.exception.DomainException -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant -import java.util.UUID - -class ArchiveChatSessionCommandHandlerTest { - private val sessions = mockk() - private val handler = ArchiveChatSessionCommandHandler(sessions) - - private fun session(userId: UUID) = - ChatSession( - id = ChatSessionId.random(), - userId = userId, - title = "x", - status = ChatSessionStatus.ACTIVE, - kind = ChatSessionKind.PLAIN, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `handle archives the session when the owner asks`() { - val uid = UUID.randomUUID() - val s = session(uid) - every { sessions.findById(s.id) } returns s - val saved = slot() - every { sessions.save(capture(saved)) } answers { saved.captured } - - handler.handle(ArchiveChatSessionCommand(s.id, uid)) - - assertThat(saved.captured.status).isEqualTo(ChatSessionStatus.ARCHIVED) - verify { sessions.save(any()) } - } - - @Test - fun `handle throws NotFound for an unknown session`() { - every { sessions.findById(any()) } returns null - assertThrows { - handler.handle(ArchiveChatSessionCommand(ChatSessionId.random(), UUID.randomUUID())) - } - } - - @Test - fun `handle rejects a non-owner`() { - val s = session(UUID.randomUUID()) - every { sessions.findById(s.id) } returns s - assertThrows { - handler.handle(ArchiveChatSessionCommand(s.id, UUID.randomUUID())) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveConversationCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveConversationCommandHandlerTest.kt deleted file mode 100644 index 09c03ee9..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/ArchiveConversationCommandHandlerTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import com.jorisjonkers.personalstack.common.exception.DomainException -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test -import java.time.Instant -import java.util.UUID - -class ArchiveConversationCommandHandlerTest { - private val conversationRepository = mockk() - private val handler = ArchiveConversationCommandHandler(conversationRepository) - - @Test - fun `handle archives conversation when owner requests it`() { - val userId = UUID.randomUUID() - val conversationId = ConversationId(UUID.randomUUID()) - val conversation = buildConversation(id = conversationId, userId = userId) - val slot = slot() - - every { conversationRepository.findById(conversationId) } returns conversation - every { conversationRepository.save(capture(slot)) } answers { slot.captured } - - handler.handle(ArchiveConversationCommand(conversationId = conversationId, userId = userId.toString())) - - assertThat(slot.captured.status).isEqualTo(ConversationStatus.ARCHIVED) - } - - @Test - fun `handle throws NotFoundException when conversation does not exist`() { - val conversationId = ConversationId(UUID.randomUUID()) - - every { conversationRepository.findById(conversationId) } returns null - - assertThatThrownBy { - val cmd = ArchiveConversationCommand(conversationId = conversationId, userId = UUID.randomUUID().toString()) - handler.handle(cmd) - }.isInstanceOf(NotFoundException::class.java) - } - - @Test - fun `handle throws DomainException when user does not own the conversation`() { - val ownerId = UUID.randomUUID() - val conversationId = ConversationId(UUID.randomUUID()) - val conversation = buildConversation(id = conversationId, userId = ownerId) - val otherUserId = UUID.randomUUID().toString() - - every { conversationRepository.findById(conversationId) } returns conversation - - assertThatThrownBy { - handler.handle(ArchiveConversationCommand(conversationId = conversationId, userId = otherUserId)) - }.isInstanceOf(DomainException::class.java) - .hasMessageContaining("does not own") - } - - @Test - fun `handle throws DomainException when userId is not a valid UUID`() { - val conversationId = ConversationId(UUID.randomUUID()) - val conversation = buildConversation(id = conversationId) - - every { conversationRepository.findById(conversationId) } returns conversation - - assertThatThrownBy { - handler.handle(ArchiveConversationCommand(conversationId = conversationId, userId = "not-a-uuid")) - }.isInstanceOf(DomainException::class.java) - } - - private fun buildConversation( - id: ConversationId = ConversationId(UUID.randomUUID()), - userId: UUID = UUID.randomUUID(), - ): Conversation { - val now = Instant.now() - return Conversation( - id = id, - userId = userId, - title = "Test", - status = ConversationStatus.ACTIVE, - createdAt = now, - updatedAt = now, - ) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachDeployKeyCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachDeployKeyCommandHandlerTest.kt deleted file mode 100644 index 5d8ffa37..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachDeployKeyCommandHandlerTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant - -class AttachDeployKeyCommandHandlerTest { - private val links = mockk() - private val deployKeys = mockk() - private val deployKeysProvider = - mockk> { - every { ifAvailable } returns deployKeys - } - private val handler = AttachDeployKeyCommandHandler(links, deployKeysProvider) - - private val sampleLink = - GithubLink( - id = GithubLinkId.random(), - projectId = ProjectId.random(), - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/projects/x/repos/y", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private val validPrivate = - """ - -----BEGIN OPENSSH PRIVATE KEY----- - deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef - -----END OPENSSH PRIVATE KEY----- - """.trimIndent() - private val validPublic = - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl test@laptop" - - @Test - fun `handle stores the key in Vault and mirrors the fingerprint`() { - every { links.findById(sampleLink.id) } returns sampleLink - every { - deployKeys.store(any(), any(), any(), any(), any()) - } returns - DeployKeyStore.StoredKey( - fingerprint = "SHA256:abcdef", - vaultPath = sampleLink.vaultKeyPath, - ) - val saved = slot() - every { links.save(capture(saved)) } answers { saved.captured } - - handler.handle( - AttachDeployKeyCommand( - linkId = sampleLink.id, - privateKeyOpenssh = validPrivate, - publicKeyOpenssh = validPublic, - knownHosts = null, - ), - ) - - assertThat(saved.captured.deployKeyFingerprint).isEqualTo("SHA256:abcdef") - assertThat(saved.captured.deployKeyAddedAt).isNotNull - verify { deployKeys.store(sampleLink.projectId, sampleLink.id, validPrivate, validPublic, any()) } - } - - @Test - fun `handle uses static known_hosts fallback when none supplied`() { - every { links.findById(sampleLink.id) } returns sampleLink - val fallback = slot() - every { - deployKeys.store(any(), any(), any(), any(), capture(fallback)) - } returns DeployKeyStore.StoredKey("SHA256:x", sampleLink.vaultKeyPath) - every { links.save(any()) } answers { firstArg() } - - handler.handle( - AttachDeployKeyCommand( - linkId = sampleLink.id, - privateKeyOpenssh = validPrivate, - publicKeyOpenssh = validPublic, - knownHosts = " ", - ), - ) - - assertThat(fallback.captured).contains("github.com ssh-ed25519") - } - - @Test - fun `handle rejects malformed private key`() { - every { links.findById(sampleLink.id) } returns sampleLink - assertThrows { - handler.handle( - AttachDeployKeyCommand( - linkId = sampleLink.id, - privateKeyOpenssh = "not a key", - publicKeyOpenssh = validPublic, - ), - ) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachRepositoryDeployKeyCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachRepositoryDeployKeyCommandHandlerTest.kt deleted file mode 100644 index 784ab561..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachRepositoryDeployKeyCommandHandlerTest.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.VerifyRepositoryAccess -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant - -class AttachRepositoryDeployKeyCommandHandlerTest { - private val repositories = mockk() - private val deployKeys = mockk() - private val deployKeysProvider = - mockk> { - every { ifAvailable } returns deployKeys - } - private val verifyAccess = - mockk { - every { verify(any(), any()) } returns - VerifyRepositoryAccess.Result( - read = true, - write = true, - defaultBranchProtected = true, - checkedAt = Instant.now(), - messages = emptyList(), - ) - } - private val handler = AttachRepositoryDeployKeyCommandHandler(repositories, deployKeysProvider, verifyAccess) - - private val sampleRepo = - Repository( - id = RepositoryId.random(), - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private val validPrivate = - """ - -----BEGIN OPENSSH PRIVATE KEY----- - deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef - -----END OPENSSH PRIVATE KEY----- - """.trimIndent() - private val validPublic = - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl test@laptop" - - @Test - fun `handle stores the key in Vault and mirrors the fingerprint`() { - every { repositories.findById(sampleRepo.id) } returns sampleRepo - every { - deployKeys.store(any(), any(), any(), any()) - } returns - DeployKeyStore.StoredKey( - fingerprint = "SHA256:abcdef", - vaultPath = sampleRepo.vaultKeyPath, - ) - val saved = slot() - every { repositories.save(capture(saved)) } answers { saved.captured } - - handler.handle( - AttachRepositoryDeployKeyCommand( - repositoryId = sampleRepo.id, - privateKeyOpenssh = validPrivate, - publicKeyOpenssh = validPublic, - knownHosts = null, - ), - ) - - assertThat(saved.captured.deployKeyFingerprint).isEqualTo("SHA256:abcdef") - assertThat(saved.captured.deployKeyAddedAt).isNotNull - verify { deployKeys.store(sampleRepo.id, validPrivate, validPublic, any()) } - } - - @Test - fun `handle re-verifies and persists the verification outcome onto the row`() { - every { repositories.findById(sampleRepo.id) } returns sampleRepo - every { - deployKeys.store(any(), any(), any(), any()) - } returns DeployKeyStore.StoredKey("SHA256:abcdef", sampleRepo.vaultKeyPath) - every { verifyAccess.verify(sampleRepo.repoUrl, sampleRepo.defaultBranch) } returns - VerifyRepositoryAccess.Result( - read = true, - write = false, - defaultBranchProtected = false, - checkedAt = Instant.now(), - messages = listOf("deploy key is read-only — agent commits/pushes will fail: denied"), - ) - val saved = slot() - every { repositories.save(capture(saved)) } answers { saved.captured } - - handler.handle( - AttachRepositoryDeployKeyCommand( - repositoryId = sampleRepo.id, - privateKeyOpenssh = validPrivate, - publicKeyOpenssh = validPublic, - knownHosts = null, - ), - ) - - val v = saved.captured.verification - assertThat(v).isNotNull - assertThat(v!!.read).isTrue - assertThat(v.write).isFalse - assertThat(v.defaultBranchProtected).isFalse - assertThat(v.messages).anyMatch { it.contains("read-only") } - verify { verifyAccess.verify(sampleRepo.repoUrl, sampleRepo.defaultBranch) } - } - - @Test - fun `handle uses static known_hosts fallback when none supplied`() { - every { repositories.findById(sampleRepo.id) } returns sampleRepo - val fallback = slot() - every { - deployKeys.store(any(), any(), any(), capture(fallback)) - } returns DeployKeyStore.StoredKey("SHA256:x", sampleRepo.vaultKeyPath) - every { repositories.save(any()) } answers { firstArg() } - - handler.handle( - AttachRepositoryDeployKeyCommand( - repositoryId = sampleRepo.id, - privateKeyOpenssh = validPrivate, - publicKeyOpenssh = validPublic, - knownHosts = " ", - ), - ) - - assertThat(fallback.captured).contains("github.com ssh-ed25519") - } - - @Test - fun `handle rejects malformed private key`() { - every { repositories.findById(sampleRepo.id) } returns sampleRepo - assertThrows { - handler.handle( - AttachRepositoryDeployKeyCommand( - repositoryId = sampleRepo.id, - privateKeyOpenssh = "not a key", - publicKeyOpenssh = validPublic, - ), - ) - } - } - - @Test - fun `handle errors on unknown repository`() { - every { repositories.findById(any()) } returns null - assertThrows { - handler.handle( - AttachRepositoryDeployKeyCommand( - repositoryId = RepositoryId.random(), - privateKeyOpenssh = validPrivate, - publicKeyOpenssh = validPublic, - ), - ) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt deleted file mode 100644 index fe6d2023..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.springframework.web.client.RestClientException -import java.time.Instant - -class AttachWorkspaceRepositoryCommandHandlerTest { - private val workspaces = mockk() - private val repositories = mockk() - private val links = mockk(relaxed = true) - private val gateway = mockk(relaxed = true) - private val handler = AttachWorkspaceRepositoryCommandHandler(workspaces, repositories, links, gateway) - - private val workspaceId = WorkspaceId.random() - private val repositoryId = RepositoryId.random() - private val command = AttachWorkspaceRepositoryCommand(workspaceId, repositoryId) - - @Test - fun `attaches a non-primary link and clones into a running workspace`() { - val workspace = workspace() - val repository = repository() - every { workspaces.findById(workspaceId) } returns workspace - every { repositories.findById(repositoryId) } returns repository - every { gateway.isReady(workspace) } returns true - - handler.handle(command) - - verify { links.attach(workspaceId, repositoryId, isPrimary = false) } - verify { gateway.clone(workspace, repository.repoUrl, repository.defaultBranch) } - } - - @Test - fun `attach persists when the live clone fails`() { - val workspace = workspace() - val repository = repository() - every { workspaces.findById(workspaceId) } returns workspace - every { repositories.findById(repositoryId) } returns repository - every { gateway.isReady(workspace) } returns true - every { - gateway.clone(workspace, repository.repoUrl, repository.defaultBranch) - } throws RestClientException("clone failed") - - handler.handle(command) - - verify { links.attach(workspaceId, repositoryId, isPrimary = false) } - verify { gateway.clone(workspace, repository.repoUrl, repository.defaultBranch) } - } - - @Test - fun `attaches without live clone when workspace has no gateway yet`() { - val workspace = workspace(gatewayEndpoint = null) - every { workspaces.findById(workspaceId) } returns workspace - every { repositories.findById(repositoryId) } returns repository() - - handler.handle(command) - - verify { links.attach(workspaceId, repositoryId, isPrimary = false) } - verify(exactly = 0) { gateway.clone(any(), any(), any()) } - } - - @Test - fun `attaches without live clone when gateway is not ready`() { - val workspace = workspace() - every { workspaces.findById(workspaceId) } returns workspace - every { repositories.findById(repositoryId) } returns repository() - every { gateway.isReady(workspace) } returns false - - handler.handle(command) - - verify { links.attach(workspaceId, repositoryId, isPrimary = false) } - verify(exactly = 0) { gateway.clone(any(), any(), any()) } - } - - @Test - fun `rejects an unknown workspace`() { - every { workspaces.findById(workspaceId) } returns null - - assertThrows { handler.handle(command) } - verify(exactly = 0) { links.attach(any(), any(), any()) } - } - - @Test - fun `rejects an unknown repository`() { - every { workspaces.findById(workspaceId) } returns workspace() - every { repositories.findById(repositoryId) } returns null - - assertThrows { handler.handle(command) } - verify(exactly = 0) { links.attach(any(), any(), any()) } - } - - private fun workspace(gatewayEndpoint: String? = "http://runner:8090") = - Workspace( - id = workspaceId, - name = "workspace", - repoUrl = "git@github.com:o/primary.git", - branch = "main", - podName = "pod", - pvcName = "pvc", - gatewayEndpoint = gatewayEndpoint, - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private fun repository() = - Repository( - id = repositoryId, - name = "website", - repoUrl = "git@github.com:o/website.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/${repositoryId.value}", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateProjectCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateProjectCommandHandlerTest.kt deleted file mode 100644 index 071f4b87..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateProjectCommandHandlerTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant - -class CreateProjectCommandHandlerTest { - private val projects = mockk() - private val handler = CreateProjectCommandHandler(projects) - - @Test - fun `handle persists a project with normalised name`() { - every { projects.findBySlug(any()) } returns null - val saved = slot() - every { projects.save(capture(saved)) } answers { saved.captured } - - handler.handle( - CreateProjectCommand( - projectId = ProjectId.random(), - name = " Personal Stack ", - slug = "personal-stack", - description = " monorepo ", - ), - ) - - assertThat(saved.captured.name).isEqualTo("Personal Stack") - assertThat(saved.captured.slug).isEqualTo("personal-stack") - assertThat(saved.captured.description).isEqualTo("monorepo") - verify { projects.save(any()) } - } - - @Test - fun `handle rejects blank name`() { - every { projects.findBySlug(any()) } returns null - assertThrows { - handler.handle(CreateProjectCommand(ProjectId.random(), " ", "abc")) - } - } - - @Test - fun `handle rejects invalid slug`() { - every { projects.findBySlug(any()) } returns null - assertThrows { - handler.handle(CreateProjectCommand(ProjectId.random(), "X", "NOT lowercase")) - } - } - - @Test - fun `handle rejects slug already in use by another project`() { - val existing = - Project( - id = ProjectId.random(), - name = "other", - slug = "shared", - description = "", - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { projects.findBySlug("shared") } returns existing - assertThrows { - handler.handle(CreateProjectCommand(ProjectId.random(), "Mine", "shared")) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateRepositoryCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateRepositoryCommandHandlerTest.kt deleted file mode 100644 index 76e91759..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateRepositoryCommandHandlerTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant - -class CreateRepositoryCommandHandlerTest { - private val repositories = mockk() - private val handler = CreateRepositoryCommandHandler(repositories) - - @Test - fun `handle creates a repository with per-repo vault path and no fingerprint yet`() { - every { repositories.findByName(any()) } returns null - val saved = slot() - every { repositories.save(capture(saved)) } answers { saved.captured } - - val id = RepositoryId.random() - handler.handle( - CreateRepositoryCommand( - repositoryId = id, - name = " personal-stack ", - repoUrl = " git@github.com:owner/repo.git ", - defaultBranch = "", - ), - ) - - assertThat(saved.captured.id).isEqualTo(id) - assertThat(saved.captured.name).isEqualTo("personal-stack") - assertThat(saved.captured.repoUrl).isEqualTo("git@github.com:owner/repo.git") - assertThat(saved.captured.defaultBranch).isEqualTo("main") - assertThat(saved.captured.vaultKeyPath).isEqualTo("secret/data/agents/repositories/$id") - assertThat(saved.captured.deployKeyFingerprint).isNull() - assertThat(saved.captured.deployKeyAddedAt).isNull() - } - - @Test - fun `handle rejects blank name`() { - every { repositories.findByName(any()) } returns null - assertThrows { - handler.handle( - CreateRepositoryCommand(RepositoryId.random(), " ", "git@github.com:o/r.git"), - ) - } - } - - @Test - fun `handle rejects blank repoUrl`() { - every { repositories.findByName(any()) } returns null - assertThrows { - handler.handle( - CreateRepositoryCommand(RepositoryId.random(), "x", " "), - ) - } - } - - @Test - fun `handle rejects name already in use by another repository`() { - val existing = - Repository( - id = RepositoryId.random(), - name = "personal-stack", - repoUrl = "git@github.com:other/repo.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { repositories.findByName("personal-stack") } returns existing - assertThrows { - handler.handle( - CreateRepositoryCommand(RepositoryId.random(), "personal-stack", "git@github.com:owner/repo.git"), - ) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt deleted file mode 100644 index 21c82cc9..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt +++ /dev/null @@ -1,722 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.VerifyRepositoryAccess -import com.jorisjonkers.personalstack.assistant.application.exception.RepositoryAccessDeniedException -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import io.mockk.verifyOrder -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.springframework.beans.factory.ObjectProvider -import java.time.Instant - -@Suppress("DEPRECATION") -class CreateWorkspaceCommandHandlerTest { - private val workspaces = mockk() - private val orchestrator = mockk() - private val projectRepositoryLinks = mockk(relaxed = true) - private val workspaceRepositoryLinks = mockk(relaxed = true) - private val githubLinks = mockk() - private val linkProvider = - mockk> { - every { ifAvailable } returns githubLinks - } - private val repositories = mockk(relaxed = true) - private val repositoryProvider = - mockk> { - every { ifAvailable } returns repositories - } - private val verifyAccess = - mockk { - // Default: read-write + protected so existing happy-path - // tests don't have to opt in. Individual tests override. - every { verify(any(), any()) } returns - VerifyRepositoryAccess.Result( - read = true, - write = true, - defaultBranchProtected = true, - checkedAt = Instant.now(), - messages = emptyList(), - ) - } - private val handler = - CreateWorkspaceCommandHandler( - workspaces, - orchestrator, - projectRepositoryLinks, - workspaceRepositoryLinks, - linkProvider, - repositoryProvider, - verifyAccess, - ) - - @Test - fun `handle persists workspace and provisions Pod when repo is provided`() { - val saved = slot() - every { workspaces.save(capture(saved)) } answers { saved.captured } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle( - podName = "agent-runner-deadbeef", - pvcName = "workspace-deadbeef", - gatewayEndpoint = "http://agent-runner-deadbeef.agents-system.svc.cluster.local:8090", - ) - - val id = WorkspaceId.random() - handler.handle( - CreateWorkspaceCommand( - workspaceId = id, - name = "demo", - repoUrl = "git@github.com:owner/repo.git", - branch = null, - ), - ) - - verify { orchestrator.provision(any()) } - verify(exactly = 2) { workspaces.save(any()) } - verify(exactly = 0) { workspaceRepositoryLinks.attach(any(), any(), any()) } - } - - @Test - fun `handle without repo still provisions a Pod`() { - every { workspaces.save(any()) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle( - podName = "p", - pvcName = "v", - gatewayEndpoint = "http://p.svc:8090", - ) - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "qa", - repoUrl = null, - branch = null, - ), - ) - - verify { orchestrator.provision(any()) } - } - - @Test - fun `handle resolves repoUrl + branch from a GithubLink when linkId is set`() { - val linkId = GithubLinkId.random() - val link = - GithubLink( - id = linkId, - projectId = ProjectId.random(), - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/projects/x/repos/y", - deployKeyFingerprint = "SHA256:abcd", - deployKeyAddedAt = Instant.now(), - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { githubLinks.findById(linkId) } returns link - val saved = mutableListOf() - every { workspaces.save(capture(saved)) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - githubLinkId = linkId, - ), - ) - - assertThat(saved.first().repoUrl).isEqualTo("git@github.com:ExtraToast/personal-stack.git") - assertThat(saved.first().branch).isEqualTo("main") - assertThat(saved.first().githubLinkId).isEqualTo(linkId) - } - - @Test - fun `handle resolves from repositoryId and mirrors the legacy linkId when a matching github_links row exists`() { - val repoId = RepositoryId.random() - val repository = - Repository( - id = repoId, - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "trunk", - vaultKeyPath = "secret/data/agents/repositories/$repoId", - deployKeyFingerprint = "SHA256:abcd", - deployKeyAddedAt = Instant.now(), - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { repositories.findById(repoId) } returns repository - // Mirror exists in github_links — the legacy per-link Vault - // path is still valid for this repo, so legacyLinkId carries - // through. - every { githubLinks.findById(GithubLinkId(repoId.value)) } returns - GithubLink( - id = GithubLinkId(repoId.value), - projectId = ProjectId.random(), - name = "personal-stack", - repoUrl = repository.repoUrl, - defaultBranch = repository.defaultBranch, - vaultKeyPath = repository.vaultKeyPath, - deployKeyFingerprint = repository.deployKeyFingerprint, - deployKeyAddedAt = repository.deployKeyAddedAt, - createdAt = repository.createdAt, - updatedAt = repository.updatedAt, - ) - val saved = mutableListOf() - every { workspaces.save(capture(saved)) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - repositoryId = repoId, - ), - ) - - assertThat(saved.first().repoUrl).isEqualTo("git@github.com:ExtraToast/personal-stack.git") - assertThat(saved.first().branch).isEqualTo("trunk") - assertThat(saved.first().repositoryId).isEqualTo(repoId) - assertThat(saved.first().githubLinkId?.value).isEqualTo(repoId.value) - } - - @Test - fun `repositoryId create persists then attaches primary before provisioning`() { - val repoId = RepositoryId.random() - val repository = - Repository( - id = repoId, - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/$repoId", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { repositories.findById(repoId) } returns repository - every { githubLinks.findById(GithubLinkId(repoId.value)) } returns null - every { workspaces.save(any()) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - val workspaceId = WorkspaceId.random() - - handler.handle( - CreateWorkspaceCommand( - workspaceId = workspaceId, - name = "demo", - repoUrl = null, - branch = null, - repositoryId = repoId, - ), - ) - - verifyOrder { - workspaces.save(match { it.id == workspaceId && it.status == WorkspaceStatus.PENDING }) - workspaceRepositoryLinks.attach(workspaceId, repoId, isPrimary = true) - orchestrator.provision(match { it.id == workspaceId }) - } - } - - @Test - fun `project repositoryId create attaches primary and project extras before provisioning`() { - val projectId = ProjectId.random() - val primaryRepoId = RepositoryId.random() - val extraRepoId = RepositoryId.random() - val secondExtraRepoId = RepositoryId.random() - val repository = - Repository( - id = primaryRepoId, - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/$primaryRepoId", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { repositories.findById(primaryRepoId) } returns repository - every { repositories.findById(extraRepoId) } returns repository(extraRepoId, "extra-one") - every { repositories.findById(secondExtraRepoId) } returns repository(secondExtraRepoId, "extra-two") - every { githubLinks.findById(GithubLinkId(primaryRepoId.value)) } returns null - every { projectRepositoryLinks.findAllByProjectId(projectId) } returns - listOf( - ProjectRepositoryRepository.Link(projectId, primaryRepoId, Instant.now()), - ProjectRepositoryRepository.Link(projectId, extraRepoId, Instant.now()), - ProjectRepositoryRepository.Link(projectId, secondExtraRepoId, Instant.now()), - ) - every { workspaces.save(any()) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - val workspaceId = WorkspaceId.random() - - handler.handle( - CreateWorkspaceCommand( - workspaceId = workspaceId, - name = "demo", - repoUrl = null, - branch = null, - projectId = projectId, - repositoryId = primaryRepoId, - ), - ) - - verifyOrder { - workspaces.save(match { it.id == workspaceId && it.status == WorkspaceStatus.PENDING }) - workspaceRepositoryLinks.attach(workspaceId, primaryRepoId, isPrimary = true) - workspaceRepositoryLinks.attach(workspaceId, extraRepoId, isPrimary = false) - workspaceRepositoryLinks.attach(workspaceId, secondExtraRepoId, isPrimary = false) - orchestrator.provision(match { it.id == workspaceId }) - } - } - - @Test - fun `repositoryIds-only create treats first selected repository as primary and attaches distinct extras`() { - val primaryRepoId = RepositoryId.random() - val extraRepoId = RepositoryId.random() - val secondExtraRepoId = RepositoryId.random() - every { repositories.findById(primaryRepoId) } returns repository(primaryRepoId, "primary") - every { repositories.findById(extraRepoId) } returns repository(extraRepoId, "extra") - every { repositories.findById(secondExtraRepoId) } returns repository(secondExtraRepoId, "docs") - every { githubLinks.findById(GithubLinkId(primaryRepoId.value)) } returns null - val saved = mutableListOf() - every { workspaces.save(capture(saved)) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - val workspaceId = WorkspaceId.random() - - handler.handle( - CreateWorkspaceCommand( - workspaceId = workspaceId, - name = "demo", - repoUrl = null, - branch = null, - repositoryIds = listOf(primaryRepoId, extraRepoId, primaryRepoId, secondExtraRepoId), - ), - ) - - assertThat(saved.first().repositoryId).isEqualTo(primaryRepoId) - verifyOrder { - workspaces.save(match { it.id == workspaceId && it.repositoryId == primaryRepoId }) - workspaceRepositoryLinks.attach(workspaceId, primaryRepoId, isPrimary = true) - workspaceRepositoryLinks.attach(workspaceId, extraRepoId, isPrimary = false) - workspaceRepositoryLinks.attach(workspaceId, secondExtraRepoId, isPrimary = false) - orchestrator.provision(match { it.id == workspaceId }) - } - } - - @Test - fun `handle leaves legacy linkId null when no github_links mirror exists`() { - // Regression for the production workspace 500: post-V9 - // repositories created directly (no project link first) have - // no github_links row. The handler used to mirror the repo - // id into github_link_id unconditionally; the - // `workspaces.github_link_id → github_links.id` FK then - // violated and the insert failed with - // DataIntegrityViolationException → opaque 500. The fix is - // to leave legacyLinkId null when the mirror row is absent. - val repoId = RepositoryId.random() - val repository = - Repository( - id = repoId, - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/$repoId", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { repositories.findById(repoId) } returns repository - every { githubLinks.findById(GithubLinkId(repoId.value)) } returns null - val saved = mutableListOf() - every { workspaces.save(capture(saved)) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - repositoryId = repoId, - ), - ) - - assertThat(saved.first().repoUrl).isEqualTo("git@github.com:ExtraToast/personal-stack.git") - assertThat(saved.first().repositoryId).isEqualTo(repoId) - assertThat(saved.first().githubLinkId).isNull() - } - - @Test - fun `legacy githubLinkId create does not attach a synthetic repository id`() { - val linkId = GithubLinkId.random() - val link = - GithubLink( - id = linkId, - projectId = ProjectId.random(), - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/projects/x/repos/y", - deployKeyFingerprint = "SHA256:abcd", - deployKeyAddedAt = Instant.now(), - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { githubLinks.findById(linkId) } returns link - every { repositories.findById(RepositoryId(linkId.value)) } returns null - every { workspaces.save(any()) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - githubLinkId = linkId, - ), - ) - - verify(exactly = 0) { workspaceRepositoryLinks.attach(any(), any(), any()) } - } - - @Test - fun `handle SCRATCH kind ignores repoUrl`() { - every { workspaces.save(any()) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - val saved = mutableListOf() - every { workspaces.save(capture(saved)) } answers { firstArg() } - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "playground", - repoUrl = "git@github.com:owner/repo.git", - branch = "main", - kind = WorkspaceKind.SCRATCH, - ), - ) - - verify { orchestrator.provision(any()) } - assertThat(saved.first().repoUrl).isNull() - assertThat(saved.first().kind).isEqualTo(WorkspaceKind.SCRATCH) - verify(exactly = 0) { workspaceRepositoryLinks.attach(any(), any(), any()) } - } - - @Test - fun `handle CHAT kind is rejected — chat lives in ChatSession`() { - assertThrows { - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "chat-but-not", - repoUrl = null, - branch = null, - kind = WorkspaceKind.CHAT, - ), - ) - } - } - - @Test - fun `unknown repositoryId raises NoSuchElementException with the id in the message`() { - val repoId = RepositoryId.random() - every { repositories.findById(repoId) } returns null - - val ex = - assertThrows { - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - repositoryId = repoId, - ), - ) - } - assertThat(ex.message).contains(repoId.value.toString()) - } - - @Test - fun `unknown githubLinkId raises NoSuchElementException with the id in the message`() { - val linkId = GithubLinkId.random() - every { githubLinks.findById(linkId) } returns null - - val ex = - assertThrows { - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - githubLinkId = linkId, - ), - ) - } - assertThat(ex.message).contains(linkId.value.toString()) - } - - @Test - fun `repositoryId with Vault disabled raises IllegalStateException naming the feature`() { - // Simulate the @ConditionalOnProperty-wired feature being absent. - every { repositoryProvider.ifAvailable } returns null - val repoId = RepositoryId.random() - - val ex = - assertThrows { - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - repositoryId = repoId, - ), - ) - } - assertThat(ex.message).contains("Repository feature is not configured") - assertThat(ex.message).contains(repoId.value.toString()) - } - - @Test - fun `githubLinkId with Projects feature disabled raises IllegalStateException`() { - every { linkProvider.ifAvailable } returns null - val linkId = GithubLinkId.random() - - val ex = - assertThrows { - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - githubLinkId = linkId, - ), - ) - } - assertThat(ex.message).contains("Projects feature is not configured") - assertThat(ex.message).contains(linkId.value.toString()) - } - - @Test - fun `handle prefers repositoryId when both fields are set`() { - val repoId = RepositoryId.random() - val repository = - Repository( - id = repoId, - name = "personal-stack", - repoUrl = "git@github.com:ExtraToast/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/$repoId", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { repositories.findById(repoId) } returns repository - // The repository-path branch now also probes github_links for - // the legacy mirror (to decide whether the FK can be set), so - // mock the "no mirror" case to keep this test focused on the - // preference assertion. - every { githubLinks.findById(GithubLinkId(repoId.value)) } returns null - val saved = mutableListOf() - every { workspaces.save(capture(saved)) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - val unrelatedLinkId = GithubLinkId.random() - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - repositoryId = repoId, - githubLinkId = unrelatedLinkId, - ), - ) - - // Repository path was used: the saved row carries the repo's - // URL and the legacy-link id (which the link-path branch - // would have resolved differently) was never consulted. - assertThat(saved.first().repoUrl).isEqualTo(repository.repoUrl) - verify(exactly = 0) { githubLinks.findById(unrelatedLinkId) } - } - - @Test - fun `REPO_BACKED create fails with RepositoryAccessDeniedException when read is false`() { - every { verifyAccess.verify(any(), any()) } returns - VerifyRepositoryAccess.Result( - read = false, - write = false, - defaultBranchProtected = null, - checkedAt = Instant.now(), - messages = listOf("deploy key cannot read the repository: Permission denied (publickey)"), - ) - - val ex = - assertThrows { - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = "git@github.com:owner/repo.git", - branch = null, - ), - ) - } - assertThat(ex.message).contains("Permission denied") - // No workspace row, no Pod — the create aborted before persist. - verify(exactly = 0) { workspaces.save(any()) } - verify(exactly = 0) { orchestrator.provision(any()) } - } - - @Test - fun `REPO_BACKED create proceeds when write is false (read-only key is a warning, not a block)`() { - every { verifyAccess.verify(any(), any()) } returns - VerifyRepositoryAccess.Result( - read = true, - write = false, - defaultBranchProtected = true, - checkedAt = Instant.now(), - messages = listOf("deploy key is read-only — agent commits/pushes will fail: denied"), - ) - every { workspaces.save(any()) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = "git@github.com:owner/repo.git", - branch = null, - ), - ) - - verify { orchestrator.provision(any()) } - } - - @Test - fun `REPO_BACKED create proceeds when default branch is unprotected (loud warning, not a block)`() { - every { verifyAccess.verify(any(), any()) } returns - VerifyRepositoryAccess.Result( - read = true, - write = true, - defaultBranchProtected = false, - checkedAt = Instant.now(), - messages = listOf("default branch 'main' is NOT protected on GitHub"), - ) - every { workspaces.save(any()) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = "git@github.com:owner/repo.git", - branch = null, - ), - ) - - verify { orchestrator.provision(any()) } - } - - @Test - fun `REPO_BACKED create proceeds when verify is inconclusive (read null, gateway unavailable)`() { - every { verifyAccess.verify(any(), any()) } returns - VerifyRepositoryAccess.Result( - read = null, - write = null, - defaultBranchProtected = null, - checkedAt = Instant.now(), - messages = listOf("deploy-key access could not be verified (verify gateway unavailable)"), - ) - every { workspaces.save(any()) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "demo", - repoUrl = "git@github.com:owner/repo.git", - branch = null, - ), - ) - - verify { orchestrator.provision(any()) } - } - - @Test - fun `SCRATCH create never invokes verify`() { - every { workspaces.save(any()) } answers { firstArg() } - every { orchestrator.provision(any()) } returns - AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") - - handler.handle( - CreateWorkspaceCommand( - workspaceId = WorkspaceId.random(), - name = "playground", - repoUrl = "git@github.com:owner/repo.git", - branch = "main", - kind = WorkspaceKind.SCRATCH, - ), - ) - - verify(exactly = 0) { verifyAccess.verify(any(), any()) } - } - - private fun repository( - id: RepositoryId, - name: String, - ) = Repository( - id = id, - name = name, - repoUrl = "git@github.com:ExtraToast/$name.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/$id", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DeleteRepositoryCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DeleteRepositoryCommandHandlerTest.kt deleted file mode 100644 index a5e74d64..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DeleteRepositoryCommandHandlerTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.ObjectProvider -import java.time.Instant - -class DeleteRepositoryCommandHandlerTest { - private val repositories = mockk() - private val deployKeys = mockk() - private val provider = - mockk> { - every { ifAvailable } returns deployKeys - } - private val handler = DeleteRepositoryCommandHandler(repositories, provider) - - private fun repository() = - Repository( - id = RepositoryId.random(), - name = "n", - repoUrl = "u", - defaultBranch = "main", - vaultKeyPath = "x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `handle deletes the repository and best-effort removes the vault key`() { - val r = repository() - every { repositories.findById(r.id) } returns r - every { deployKeys.remove(r.id) } returns Unit - every { repositories.delete(r.id) } returns Unit - - handler.handle(DeleteRepositoryCommand(r.id)) - - verify { deployKeys.remove(r.id) } - verify { repositories.delete(r.id) } - } - - @Test - fun `handle proceeds when vault is offline`() { - val r = repository() - every { repositories.findById(r.id) } returns r - every { deployKeys.remove(r.id) } throws RuntimeException("vault down") - every { repositories.delete(r.id) } returns Unit - - handler.handle(DeleteRepositoryCommand(r.id)) - - verify { repositories.delete(r.id) } - } - - @Test - fun `handle is idempotent on a missing repository`() { - every { repositories.findById(any()) } returns null - handler.handle(DeleteRepositoryCommand(RepositoryId.random())) - verify(exactly = 0) { repositories.delete(any()) } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DestroyWorkspaceCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DestroyWorkspaceCommandHandlerTest.kt deleted file mode 100644 index 760488f4..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DestroyWorkspaceCommandHandlerTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant - -class DestroyWorkspaceCommandHandlerTest { - private val workspaces = mockk() - private val orchestrator = mockk(relaxed = true) - private val handler = DestroyWorkspaceCommandHandler(workspaces, orchestrator) - - @Test - fun `handle destroys the pod and marks workspace destroyed`() { - val id = WorkspaceId.random() - val ws = workspace(id) - every { workspaces.findById(id) } returns ws - val saved = slot() - every { workspaces.save(capture(saved)) } answers { saved.captured } - - handler.handle(DestroyWorkspaceCommand(id)) - - verify { orchestrator.destroy(ws) } - assertThat(saved.captured.status).isEqualTo(WorkspaceStatus.DESTROYED) - } - - @Test - fun `handle is a no-op for unknown workspaces`() { - val id = WorkspaceId.random() - every { workspaces.findById(id) } returns null - handler.handle(DestroyWorkspaceCommand(id)) - verify(exactly = 0) { orchestrator.destroy(any()) } - } - - private fun workspace(id: WorkspaceId) = - Workspace( - id = id, - name = "demo", - repoUrl = null, - branch = null, - podName = "agent-runner-abcdef01", - pvcName = "workspace-abcdef01", - gatewayEndpoint = "http://x:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandlerTest.kt deleted file mode 100644 index 386fad49..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/DetachWorkspaceRepositoryCommandHandlerTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant - -class DetachWorkspaceRepositoryCommandHandlerTest { - private val links = mockk(relaxed = true) - private val handler = DetachWorkspaceRepositoryCommandHandler(links) - - private val workspaceId = WorkspaceId.random() - private val repositoryId = RepositoryId.random() - private val command = DetachWorkspaceRepositoryCommand(workspaceId, repositoryId) - - private fun link(isPrimary: Boolean) = - WorkspaceRepositoryRepository.Link(workspaceId, repositoryId, isPrimary, Instant.now()) - - @Test - fun `detaches a non-primary repository`() { - every { links.findAllByWorkspaceId(workspaceId) } returns listOf(link(isPrimary = false)) - - handler.handle(command) - - verify { links.detach(workspaceId, repositoryId) } - } - - @Test - fun `refuses to detach the primary repository`() { - every { links.findAllByWorkspaceId(workspaceId) } returns listOf(link(isPrimary = true)) - - assertThrows { handler.handle(command) } - verify(exactly = 0) { links.detach(any(), any()) } - } - - @Test - fun `is a no-op-safe detach when no link exists`() { - every { links.findAllByWorkspaceId(workspaceId) } returns emptyList() - - handler.handle(command) - - verify { links.detach(workspaceId, repositoryId) } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/LinkRepositoryToProjectCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/LinkRepositoryToProjectCommandHandlerTest.kt deleted file mode 100644 index cc854a4c..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/LinkRepositoryToProjectCommandHandlerTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectsRepository -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant - -class LinkRepositoryToProjectCommandHandlerTest { - private val projects = mockk() - private val repositories = mockk() - private val junction = mockk() - private val handler = LinkRepositoryToProjectCommandHandler(projects, repositories, junction) - - private fun project(id: ProjectId = ProjectId.random()) = - Project(id, "name", "slug", "", Instant.now(), Instant.now()) - - private fun repository(id: RepositoryId = RepositoryId.random()) = - Repository( - id = id, - name = "name", - repoUrl = "url", - defaultBranch = "main", - vaultKeyPath = "x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `handle inserts a junction row when both ends exist`() { - val p = project() - val r = repository() - every { projects.findById(p.id) } returns p - every { repositories.findById(r.id) } returns r - every { junction.link(p.id, r.id) } returns - ProjectRepositoryRepository.Link(p.id, r.id, Instant.now()) - - handler.handle(LinkRepositoryToProjectCommand(p.id, r.id)) - - verify { junction.link(p.id, r.id) } - } - - @Test - fun `handle errors on missing project`() { - val r = repository() - every { projects.findById(any()) } returns null - assertThrows { - handler.handle(LinkRepositoryToProjectCommand(ProjectId.random(), r.id)) - } - } - - @Test - fun `handle errors on missing repository`() { - val p = project() - every { projects.findById(p.id) } returns p - every { repositories.findById(any()) } returns null - assertThrows { - handler.handle(LinkRepositoryToProjectCommand(p.id, RepositoryId.random())) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/OpenPullRequestCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/OpenPullRequestCommandHandlerTest.kt deleted file mode 100644 index bd30050a..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/OpenPullRequestCommandHandlerTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant - -class OpenPullRequestCommandHandlerTest { - private val workspaces = mockk() - private val gateway = mockk(relaxed = true) - private val handler = OpenPullRequestCommandHandler(workspaces, gateway) - - @Test - fun `handle delegates to the gateway with the resolved workspace`() { - val id = WorkspaceId.random() - val ws = workspace(id) - every { workspaces.findById(id) } returns ws - - handler.handle( - OpenPullRequestCommand( - workspaceId = id, - repoDir = "/workspace/repo", - title = "Fix typo", - body = "Body", - ), - ) - - verify { gateway.openPr(ws, "/workspace/repo", "Fix typo", "Body", "main") } - } - - @Test - fun `handle errors for unknown workspaces`() { - val id = WorkspaceId.random() - every { workspaces.findById(id) } returns null - assertThrows { - handler.handle(OpenPullRequestCommand(id, "/x", "t", "b")) - } - } - - private fun workspace(id: WorkspaceId) = - Workspace( - id = id, - name = "demo", - repoUrl = null, - branch = null, - podName = null, - pvcName = null, - gatewayEndpoint = "http://x:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/RemoveGithubLinkCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/RemoveGithubLinkCommandHandlerTest.kt deleted file mode 100644 index 5a64472d..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/RemoveGithubLinkCommandHandlerTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.port.DeployKeyStore -import com.jorisjonkers.personalstack.assistant.domain.port.GithubLinkRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test -import java.time.Instant - -class RemoveGithubLinkCommandHandlerTest { - private val links = mockk(relaxed = true) - private val deployKeys = mockk(relaxed = true) - private val deployKeysProvider = - mockk> { - every { ifAvailable } returns deployKeys - } - private val handler = RemoveGithubLinkCommandHandler(links, deployKeysProvider) - - @Test - fun `handle removes vault key then deletes the link row`() { - val link = - GithubLink( - id = GithubLinkId.random(), - projectId = ProjectId.random(), - name = "x", - repoUrl = "git@github.com:o/r.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/projects/p/repos/l", - deployKeyFingerprint = "SHA256:abc", - deployKeyAddedAt = Instant.now(), - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { links.findById(link.id) } returns link - - handler.handle(RemoveGithubLinkCommand(link.id)) - - verify { deployKeys.remove(link.projectId, link.id) } - verify { links.delete(link.id) } - } - - @Test - fun `handle is a no-op for unknown link id`() { - val id = GithubLinkId.random() - every { links.findById(id) } returns null - handler.handle(RemoveGithubLinkCommand(id)) - verify(exactly = 0) { deployKeys.remove(any(), any()) } - verify(exactly = 0) { links.delete(any()) } - } - - @Test - fun `handle survives a vault remove failure and still deletes the row`() { - val link = - GithubLink( - id = GithubLinkId.random(), - projectId = ProjectId.random(), - name = "x", - repoUrl = "git@github.com:o/r.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/projects/p/repos/l", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { links.findById(link.id) } returns link - every { deployKeys.remove(any(), any()) } throws RuntimeException("vault unreachable") - - handler.handle(RemoveGithubLinkCommand(link.id)) - - verify { links.delete(link.id) } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendMessageCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendMessageCommandHandlerTest.kt deleted file mode 100644 index 57b6b949..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendMessageCommandHandlerTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.assistant.domain.model.Message -import com.jorisjonkers.personalstack.assistant.domain.model.MessageId -import com.jorisjonkers.personalstack.assistant.domain.model.MessageRole -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import com.jorisjonkers.personalstack.assistant.domain.port.MessageRepository -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test -import java.time.Instant -import java.util.UUID - -class SendMessageCommandHandlerTest { - private val conversationRepository = mockk() - private val messageRepository = mockk() - private val handler = SendMessageCommandHandler(conversationRepository, messageRepository) - - @Test - fun `handle saves a new message when conversation exists`() { - val conversationId = ConversationId(UUID.randomUUID()) - val messageId = MessageId(UUID.randomUUID()) - val command = - SendMessageCommand( - messageId = messageId, - conversationId = conversationId, - userId = UUID.randomUUID().toString(), - content = "Hello world", - role = MessageRole.USER, - ) - val slot = slot() - - every { conversationRepository.findById(conversationId) } returns buildConversation(conversationId) - every { messageRepository.save(capture(slot)) } answers { slot.captured } - - handler.handle(command) - - assertThat(slot.captured.id).isEqualTo(messageId) - assertThat(slot.captured.conversationId).isEqualTo(conversationId) - assertThat(slot.captured.content).isEqualTo("Hello world") - assertThat(slot.captured.role).isEqualTo(MessageRole.USER) - } - - @Test - fun `handle throws NotFoundException when conversation does not exist`() { - val conversationId = ConversationId(UUID.randomUUID()) - val command = - SendMessageCommand( - messageId = MessageId(UUID.randomUUID()), - conversationId = conversationId, - userId = UUID.randomUUID().toString(), - content = "Hello", - role = MessageRole.USER, - ) - - every { conversationRepository.findById(conversationId) } returns null - - assertThatThrownBy { handler.handle(command) } - .isInstanceOf(NotFoundException::class.java) - } - - @Test - fun `handle throws when content is blank`() { - val command = - SendMessageCommand( - messageId = MessageId(UUID.randomUUID()), - conversationId = ConversationId(UUID.randomUUID()), - userId = UUID.randomUUID().toString(), - content = " ", - role = MessageRole.USER, - ) - - assertThatThrownBy { handler.handle(command) } - .isInstanceOf(IllegalArgumentException::class.java) - - verify(exactly = 0) { messageRepository.save(any()) } - verify(exactly = 0) { conversationRepository.findById(any()) } - } - - private fun buildConversation(id: ConversationId): Conversation { - val now = Instant.now() - return Conversation( - id = id, - userId = UUID.randomUUID(), - title = "Test", - status = ConversationStatus.ACTIVE, - createdAt = now, - updatedAt = now, - ) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendUserInputCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendUserInputCommandHandlerTest.kt deleted file mode 100644 index 67e9afdb..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/SendUserInputCommandHandlerTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.rag.ContextBuilder -import com.jorisjonkers.personalstack.assistant.domain.model.Turn -import com.jorisjonkers.personalstack.assistant.domain.model.TurnRole -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.TurnRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant - -class SendUserInputCommandHandlerTest { - private val sessions = mockk() - private val workspaces = mockk() - private val turns = mockk(relaxed = true) - private val gateway = mockk(relaxed = true) - private val contextBuilder = - mockk { - every { augment(any()) } answers { firstArg() } - } - private val handler = SendUserInputCommandHandler(sessions, workspaces, turns, gateway, contextBuilder) - - @Test - fun `handle persists user turn and forwards input to the gateway`() { - val ws = workspace() - val session = session(ws.id, gatewayAgentId = "abc12345") - every { sessions.findById(session.id) } returns session - every { workspaces.findById(ws.id) } returns ws - val savedTurn = slot() - every { turns.save(capture(savedTurn)) } answers { savedTurn.captured } - - handler.handle(SendUserInputCommand(sessionId = session.id, text = "hello", enter = true)) - - assertThat(savedTurn.captured.role).isEqualTo(TurnRole.USER) - assertThat(savedTurn.captured.body).isEqualTo("hello") - verify { gateway.sendInput(ws, "abc12345", "hello", true) } - } - - @Test - fun `handle forwards the augmented prompt while persisting the raw user text`() { - val ws = workspace() - val session = session(ws.id, gatewayAgentId = "abc12345") - every { sessions.findById(session.id) } returns session - every { workspaces.findById(ws.id) } returns ws - val savedTurn = slot() - every { turns.save(capture(savedTurn)) } answers { savedTurn.captured } - every { contextBuilder.augment("hello") } returns "kb hit\n\nhello" - - handler.handle(SendUserInputCommand(sessionId = session.id, text = "hello")) - - // Stored turn is the original user text (no RAG bleed-through into history) - assertThat(savedTurn.captured.body).isEqualTo("hello") - // Gateway gets the augmented form - verify { gateway.sendInput(ws, "abc12345", "kb hit\n\nhello", true) } - } - - @Test - fun `handle throws when the session has not bound a gateway agent yet`() { - val ws = workspace() - val session = session(ws.id, gatewayAgentId = null) - every { sessions.findById(session.id) } returns session - every { workspaces.findById(ws.id) } returns ws - - org.junit.jupiter.api.assertThrows { - handler.handle(SendUserInputCommand(sessionId = session.id, text = "x")) - } - } - - private fun workspace() = - Workspace( - id = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - podName = null, - pvcName = null, - gatewayEndpoint = "http://x:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private fun session( - workspaceId: WorkspaceId, - gatewayAgentId: String?, - ) = WorkspaceAgentSession( - id = WorkspaceAgentSessionId.random(), - workspaceId = workspaceId, - kind = WorkspaceAgentKind.CLAUDE, - gatewayAgentId = gatewayAgentId, - status = - if (gatewayAgentId != null) { - WorkspaceAgentSessionStatus.RUNNING - } else { - WorkspaceAgentSessionStatus.STARTING - }, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandlerTest.kt deleted file mode 100644 index 0e4eddbe..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartAgentSessionCommandHandlerTest.kt +++ /dev/null @@ -1,317 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.exception.AgentRunnerUnavailableException -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.springframework.web.client.ResourceAccessException -import java.time.Instant - -class StartAgentSessionCommandHandlerTest { - private val workspaces = mockk() - - private val sessions = mockk(relaxed = true) - private val gateway = mockk() - private val orchestrator = mockk() - - /** `backoffInitialMs = 0` so the retry-exhaustion test doesn't burn 6 s. */ - private val handler = - StartAgentSessionCommandHandler(workspaces, sessions, gateway, orchestrator, backoffInitialMs = 0) - - @Test - fun `handle spawns gateway agent and persists binding when runner is ready`() { - val ws = workspace() - every { workspaces.findById(ws.id) } returns ws - every { gateway.isReady(ws) } returns true - val saved = mutableListOf() - every { sessions.save(capture(saved)) } answers { firstArg() } - every { gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) } returns - AgentGatewayClient.GatewayAgent(id = "abc12345", kind = WorkspaceAgentKind.CLAUDE, cwd = "/workspace") - - handler.handle( - StartAgentSessionCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - ), - ) - - verify(exactly = 2) { sessions.save(any()) } - assertThat(saved.first().status).isEqualTo(WorkspaceAgentSessionStatus.STARTING) - assertThat(saved.last().gatewayAgentId).isEqualTo("abc12345") - assertThat(saved.last().status).isEqualTo(WorkspaceAgentSessionStatus.RUNNING) - } - - @Test - fun `handle starts fresh even when prior sessions exist for the same workspace and kind`() { - val ws = workspace() - every { workspaces.findById(ws.id) } returns ws - every { gateway.isReady(ws) } returns true - every { sessions.save(any()) } answers { firstArg() } - every { sessions.findAllByWorkspaceId(ws.id) } returns - listOf( - WorkspaceAgentSession( - id = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - gatewayAgentId = "old-agent", - status = WorkspaceAgentSessionStatus.RUNNING, - createdAt = Instant.parse("2026-05-19T10:00:00Z"), - updatedAt = Instant.parse("2026-05-19T10:00:00Z"), - cliSessionId = "old-native-session", - ), - ) - every { gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) } returns - AgentGatewayClient.GatewayAgent(id = "fresh", kind = WorkspaceAgentKind.CLAUDE, cwd = "/workspace") - - handler.handle( - StartAgentSessionCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - ), - ) - - verify(exactly = 0) { sessions.findAllByWorkspaceId(any()) } - verify(exactly = 1) { gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) } - } - - @Test - fun `handle raises NoSuchElementException when workspace does not exist`() { - val missingId = WorkspaceId.random() - every { workspaces.findById(missingId) } returns null - - // 404 via GlobalExceptionHandler — previously this surfaced as a - // 500 IllegalStateException, which read as a backend bug instead - // of "the workspace id is wrong". - val ex = - assertThrows { - handler.handle( - StartAgentSessionCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = missingId, - kind = WorkspaceAgentKind.CODEX, - ), - ) - } - assertThat(ex.message).contains(missingId.value.toString()) - } - - @Test - fun `handle does not touch the orchestrator when the runner is already ready`() { - // A healthy runner must never be destroyed: the self-heal path - // is gated entirely behind a failing /healthz, so a Ready - // runner provisions nothing and spawns straight away. - val ws = workspace() - every { workspaces.findById(ws.id) } returns ws - every { gateway.isReady(ws) } returns true - every { sessions.save(any()) } answers { firstArg() } - every { gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) } returns - AgentGatewayClient.GatewayAgent(id = "ok", kind = WorkspaceAgentKind.CLAUDE, cwd = "/workspace") - - handler.handle( - StartAgentSessionCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - ), - ) - - verify(exactly = 0) { orchestrator.scaleDown(any()) } - verify(exactly = 0) { orchestrator.destroy(any()) } - verify(exactly = 0) { orchestrator.provision(any()) } - } - - @Test - fun `handle re-provisions a fresh Pod when the runner is missing or crash-looping`() { - // Regression: a workspace whose Pod was evicted / built against - // an older image answers nothing on /healthz forever. Instead - // of a permanent 503, the runner is re-provisioned (scaleDown + - // provision lands a fresh Pod on the current image), the PVC is - // preserved, the workspace is re-pointed at the new endpoint, - // and the fresh Pod passes /healthz on the next gate. - val ws = workspace() - every { workspaces.findById(ws.id) } returns ws - // First gate fails (stale/missing Pod); after re-provision the - // fresh Pod answers. - every { gateway.isReady(any()) } returnsMany listOf(false, true) - every { orchestrator.scaleDown(ws) } returns Unit - every { orchestrator.provision(ws) } returns - AgentRunnerOrchestrator.RunnerHandle( - podName = "agent-runner-fresh001", - pvcName = "workspace-abcdef01", - gatewayEndpoint = "http://fresh:8090", - ) - val saved = mutableListOf() - every { workspaces.save(capture(saved)) } answers { firstArg() } - every { sessions.save(any()) } answers { firstArg() } - every { gateway.spawnAgent(any(), WorkspaceAgentKind.CLAUDE) } returns - AgentGatewayClient.GatewayAgent(id = "ok", kind = WorkspaceAgentKind.CLAUDE, cwd = "/workspace") - - handler.handle( - StartAgentSessionCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - ), - ) - - verify(exactly = 1) { orchestrator.scaleDown(ws) } - verify(exactly = 0) { orchestrator.destroy(any()) } - verify(exactly = 1) { orchestrator.provision(ws) } - // The re-pointed workspace is persisted and spawned against. - assertThat(saved.single().podName).isEqualTo("agent-runner-fresh001") - assertThat(saved.single().gatewayEndpoint).isEqualTo("http://fresh:8090") - verify { gateway.spawnAgent(match { it.gatewayEndpoint == "http://fresh:8090" }, WorkspaceAgentKind.CLAUDE) } - } - - @Test - fun `handle surfaces ReprovisionFailed 503 when provision throws`() { - // If re-provision itself can't land a Pod, there is nothing to - // spawn against — surface a typed 503 carrying the provision - // failure as the cause, and never create a session row. - val ws = workspace() - every { workspaces.findById(ws.id) } returns ws - every { gateway.isReady(ws) } returns false - every { orchestrator.scaleDown(ws) } returns Unit - every { orchestrator.provision(ws) } throws IllegalStateException("k8s apply rejected") - - val ex = - assertThrows { - handler.handle( - StartAgentSessionCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - ), - ) - } - assertThat(ex.runnerStatus).isEqualTo("ReprovisionFailed") - assertThat(ex.cause).isInstanceOf(IllegalStateException::class.java) - verify(exactly = 0) { sessions.save(any()) } - verify(exactly = 0) { gateway.spawnAgent(any(), any()) } - } - - @Test - fun `handle surfaces NotReady 503 when the fresh Pod never passes healthz within the budget`() { - // Re-provision succeeded but the fresh Pod stays NotReady for - // the whole retry budget — surface a NotReady 503 so the client - // backs off, without spawning against a Pod that can't answer. - val ws = workspace() - every { workspaces.findById(ws.id) } returns ws - every { gateway.isReady(any()) } returns false - every { orchestrator.scaleDown(ws) } returns Unit - every { orchestrator.provision(ws) } returns - AgentRunnerOrchestrator.RunnerHandle( - podName = "agent-runner-fresh001", - pvcName = "workspace-abcdef01", - gatewayEndpoint = "http://fresh:8090", - ) - every { workspaces.save(any()) } answers { firstArg() } - - val ex = - assertThrows { - handler.handle( - StartAgentSessionCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - ), - ) - } - assertThat(ex.runnerStatus).isEqualTo("NotReady") - assertThat(ex.retryAfterSeconds).isEqualTo(AgentRunnerUnavailableException.DEFAULT_RETRY_AFTER_SECONDS) - verify(exactly = 1) { orchestrator.provision(ws) } - verify(exactly = 0) { sessions.save(any()) } - verify(exactly = 0) { gateway.spawnAgent(any(), any()) } - } - - @Test - fun `handle retries spawn on ResourceAccessException and succeeds when the runner comes up mid-attempt`() { - val ws = workspace() - every { workspaces.findById(ws.id) } returns ws - every { gateway.isReady(ws) } returns true - every { sessions.save(any()) } answers { firstArg() } - // Fail twice with `Connection refused`, then succeed on the - // third attempt — the readiness gate races with the - // runner's HTTP listener binding, so 1-2 attempts of grace - // is realistic in CI + prod. - every { gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) } - .throws(ResourceAccessException("Connection refused")) - .andThenThrows(ResourceAccessException("Connection refused")) - .andThen( - AgentGatewayClient.GatewayAgent(id = "ok", kind = WorkspaceAgentKind.CLAUDE, cwd = "/workspace"), - ) - - handler.handle( - StartAgentSessionCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - ), - ) - - verify(exactly = 3) { gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) } - verify(exactly = 2) { sessions.save(any()) } - } - - @Test - fun `handle surfaces AgentRunnerUnavailable when every spawn retry hits ResourceAccessException`() { - // 3 attempts × ConnectionRefused → typed 503 with the original - // RestClient exception attached as the cause for log - // correlation. The session row is the STARTING placeholder - // that was inserted before the spawn; cleanup is the caller's - // responsibility (no rollback here because @Transactional - // gives us that for free on the rethrown exception). - val ws = workspace() - every { workspaces.findById(ws.id) } returns ws - every { gateway.isReady(ws) } returns true - every { sessions.save(any()) } answers { firstArg() } - every { gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) } throws - ResourceAccessException("Connection refused") - - val ex = - assertThrows { - handler.handle( - StartAgentSessionCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - ), - ) - } - assertThat(ex.runnerStatus).isEqualTo("ConnectionRefused") - assertThat(ex.cause).isInstanceOf(ResourceAccessException::class.java) - verify(exactly = StartAgentSessionCommandHandler.MAX_SPAWN_ATTEMPTS) { - gateway.spawnAgent(ws, WorkspaceAgentKind.CLAUDE) - } - } - - private fun workspace() = - Workspace( - id = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - podName = "agent-runner-abcdef01", - pvcName = "workspace-abcdef01", - gatewayEndpoint = "http://x:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartChatSessionCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartChatSessionCommandHandlerTest.kt deleted file mode 100644 index 1ea9eecf..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartChatSessionCommandHandlerTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ChatSessionRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.util.UUID - -class StartChatSessionCommandHandlerTest { - private val sessions = mockk() - private val handler = StartChatSessionCommandHandler(sessions) - - @Test - fun `handle persists an ACTIVE session with trimmed title`() { - val saved = slot() - every { sessions.save(capture(saved)) } answers { saved.captured } - - val sid = ChatSessionId.random() - val uid = UUID.randomUUID() - handler.handle(StartChatSessionCommand(sid, uid, " My chat ")) - - assertThat(saved.captured.id).isEqualTo(sid) - assertThat(saved.captured.userId).isEqualTo(uid) - assertThat(saved.captured.title).isEqualTo("My chat") - assertThat(saved.captured.status).isEqualTo(ChatSessionStatus.ACTIVE) - } - - @Test - fun `handle treats a blank title as null`() { - val saved = slot() - every { sessions.save(capture(saved)) } answers { saved.captured } - - handler.handle(StartChatSessionCommand(ChatSessionId.random(), UUID.randomUUID(), " ")) - - assertThat(saved.captured.title).isNull() - } - - @Test - fun `handle allows a null title`() { - val saved = slot() - every { sessions.save(capture(saved)) } answers { saved.captured } - - handler.handle(StartChatSessionCommand(ChatSessionId.random(), UUID.randomUUID(), null)) - - assertThat(saved.captured.title).isNull() - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartConversationCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartConversationCommandHandlerTest.kt deleted file mode 100644 index 44363e6d..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartConversationCommandHandlerTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test -import org.springframework.context.ApplicationEventPublisher -import java.util.UUID - -class StartConversationCommandHandlerTest { - private val conversationRepository = mockk() - private val eventPublisher = mockk(relaxed = true) - private val handler = StartConversationCommandHandler(conversationRepository, eventPublisher) - - @Test - fun `handle creates and saves a new conversation`() { - val userId = UUID.randomUUID() - val conversationId = ConversationId(UUID.randomUUID()) - val command = StartConversationCommand(conversationId = conversationId, userId = userId, title = "My Chat") - val slot = slot() - - every { conversationRepository.save(capture(slot)) } answers { slot.captured } - - handler.handle(command) - - assertThat(slot.captured.id).isEqualTo(conversationId) - assertThat(slot.captured.userId).isEqualTo(userId) - assertThat(slot.captured.title).isEqualTo("My Chat") - assertThat(slot.captured.status).isEqualTo(ConversationStatus.ACTIVE) - verify { eventPublisher.publishEvent(any()) } - } - - @Test - fun `handle trims whitespace from title`() { - val conversationId = ConversationId(UUID.randomUUID()) - val command = - StartConversationCommand( - conversationId = conversationId, - userId = UUID.randomUUID(), - title = " Padded Title ", - ) - val slot = slot() - - every { conversationRepository.save(capture(slot)) } answers { slot.captured } - - handler.handle(command) - - assertThat(slot.captured.title).isEqualTo("Padded Title") - } - - @Test - fun `handle throws when title is blank`() { - val command = - StartConversationCommand( - conversationId = ConversationId(UUID.randomUUID()), - userId = UUID.randomUUID(), - title = " ", - ) - - assertThatThrownBy { handler.handle(command) } - .isInstanceOf(IllegalArgumentException::class.java) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartHeadlessJobCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartHeadlessJobCommandHandlerTest.kt deleted file mode 100644 index f8af8987..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StartHeadlessJobCommandHandlerTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Instant - -class StartHeadlessJobCommandHandlerTest { - private val workspaces = mockk() - private val sessions = mockk() - private val gateway = mockk() - private val orchestrator = mockk() - private val handler = StartHeadlessJobCommandHandler(workspaces, sessions, gateway, orchestrator) - - @Test - fun `handle launches headless job and persists session when runner is ready`() { - val ws = workspace() - every { workspaces.findById(ws.id) } returns ws - every { gateway.isReady(ws) } returns true - every { - gateway.startHeadlessJob(ws, WorkspaceAgentKind.CLAUDE, "write tests", null, null) - } returns - AgentGatewayClient.HeadlessJob( - id = "hls-abc123", - status = AgentGatewayClient.HeadlessStatus.RUNNING, - exitCode = null, - output = null, - ) - val saved = slot() - every { sessions.save(capture(saved)) } answers { firstArg() } - - handler.handle( - StartHeadlessJobCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = ws.id, - kind = WorkspaceAgentKind.CLAUDE, - prompt = "write tests", - ), - ) - - assertThat(saved.captured.gatewayAgentId).isEqualTo("hls-abc123") - assertThat(saved.captured.status).isEqualTo(WorkspaceAgentSessionStatus.RUNNING) - verify(exactly = 0) { orchestrator.destroy(any()) } - verify(exactly = 0) { orchestrator.provision(any()) } - } - - @Test - fun `handle raises NoSuchElementException when workspace does not exist`() { - val missingId = WorkspaceId.random() - every { workspaces.findById(missingId) } returns null - - val ex = - assertThrows { - handler.handle( - StartHeadlessJobCommand( - sessionId = WorkspaceAgentSessionId.random(), - workspaceId = missingId, - kind = WorkspaceAgentKind.CLAUDE, - prompt = "hello", - ), - ) - } - assertThat(ex.message).contains(missingId.value.toString()) - } - - private fun workspace() = - Workspace( - id = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - podName = "agent-runner-abcdef01", - pvcName = "workspace-abcdef01", - gatewayEndpoint = "http://x:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StopAgentSessionCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StopAgentSessionCommandHandlerTest.kt deleted file mode 100644 index 552da93f..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/StopAgentSessionCommandHandlerTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.application.rag.LessonAutoCapture -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant - -class StopAgentSessionCommandHandlerTest { - private val workspaces = mockk() - private val sessions = mockk() - private val gateway = mockk(relaxed = true) - private val autoCapture = mockk(relaxed = true) - private val handler = StopAgentSessionCommandHandler(workspaces, sessions, gateway, autoCapture) - - @Test - fun `handle stops gateway agent and persists stopped state`() { - val ws = workspace() - val session = session(ws.id, gatewayAgentId = "abc12345") - every { sessions.findById(session.id) } returns session - every { workspaces.findById(ws.id) } returns ws - val saved = slot() - every { sessions.save(capture(saved)) } answers { saved.captured } - - handler.handle(StopAgentSessionCommand(session.id)) - - verify { gateway.stopAgent(ws, "abc12345") } - verify { autoCapture.capture(session.id) } - assertThat(saved.captured.status).isEqualTo(WorkspaceAgentSessionStatus.STOPPED) - } - - @Test - fun `handle is a no-op for unknown session`() { - val id = WorkspaceAgentSessionId.random() - every { sessions.findById(id) } returns null - handler.handle(StopAgentSessionCommand(id)) - verify(exactly = 0) { gateway.stopAgent(any(), any()) } - } - - private fun workspace() = - Workspace( - id = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - podName = null, - pvcName = null, - gatewayEndpoint = "http://x:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private fun session( - workspaceId: WorkspaceId, - gatewayAgentId: String?, - ) = WorkspaceAgentSession( - id = WorkspaceAgentSessionId.random(), - workspaceId = workspaceId, - kind = WorkspaceAgentKind.CLAUDE, - gatewayAgentId = gatewayAgentId, - status = WorkspaceAgentSessionStatus.RUNNING, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/UnlinkRepositoryFromProjectCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/UnlinkRepositoryFromProjectCommandHandlerTest.kt deleted file mode 100644 index 1a3cb6e3..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/UnlinkRepositoryFromProjectCommandHandlerTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.command - -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.port.ProjectRepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Assertions.assertDoesNotThrow -import org.junit.jupiter.api.Test - -class UnlinkRepositoryFromProjectCommandHandlerTest { - private val junction = mockk() - private val handler = UnlinkRepositoryFromProjectCommandHandler(junction) - - @Test - fun `handle delegates to junction unlink`() { - val pid = ProjectId.random() - val rid = RepositoryId.random() - every { junction.unlink(pid, rid) } returns Unit - handler.handle(UnlinkRepositoryFromProjectCommand(pid, rid)) - verify { junction.unlink(pid, rid) } - } - - @Test - fun `handle is idempotent on a missing junction row`() { - val pid = ProjectId.random() - val rid = RepositoryId.random() - every { junction.unlink(pid, rid) } returns Unit - assertDoesNotThrow { - handler.handle(UnlinkRepositoryFromProjectCommand(pid, rid)) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt deleted file mode 100644 index 43991446..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/IdleScaleDownSchedulerTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.idle - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset - -class IdleScaleDownSchedulerTest { - private class FixedClock( - var now: Instant, - ) : Clock() { - override fun getZone() = ZoneOffset.UTC - - override fun withZone(zone: java.time.ZoneId) = this - - override fun instant(): Instant = now - } - - private val now = Instant.parse("2026-05-19T12:00:00Z") - private val clock = FixedClock(now) - private val workspaces = mockk() - private val agentSessions = mockk() - private val orchestrator = mockk(relaxed = true) - private val tracker = WorkspaceActivityTracker(clock) - private val connected = ConnectedClientTracker() - private val scheduler = - IdleScaleDownScheduler( - workspaces = workspaces, - agentSessions = agentSessions, - orchestrator = orchestrator, - tracker = tracker, - connected = connected, - clock = clock, - idleAfterSeconds = 1_800, - agentIdleAfterSeconds = 14_400, - ) - - private fun noRunningSessions(ws: Workspace) { - every { agentSessions.findAllByWorkspaceId(ws.id) } returns emptyList() - } - - @Test - fun `sweep scales down a READY workspace whose last activity is older than the threshold`() { - val ws = workspace(updatedAt = now.minusSeconds(7_200)) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - noRunningSessions(ws) - val saved = slot() - every { workspaces.save(capture(saved)) } answers { saved.captured } - - scheduler.sweep() - - verify { orchestrator.scaleDown(ws) } - verify(exactly = 0) { orchestrator.destroy(any()) } - assertThat(saved.captured.status).isEqualTo(WorkspaceStatus.IDLE) - assertThat(saved.captured.podName).isNull() - assertThat(saved.captured.gatewayEndpoint).isNull() - } - - @Test - fun `sweep leaves recently active workspaces alone`() { - val ws = workspace(updatedAt = now.minusSeconds(60)) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - noRunningSessions(ws) - tracker.touch(ws.id) - - scheduler.sweep() - - verify(exactly = 0) { orchestrator.scaleDown(any()) } - verify(exactly = 0) { orchestrator.destroy(any()) } - verify(exactly = 0) { workspaces.save(any()) } - } - - @Test - fun `sweep skips workspaces not in READY status`() { - val ws = workspace(updatedAt = now.minusSeconds(7_200), status = WorkspaceStatus.PENDING) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - - scheduler.sweep() - - verify(exactly = 0) { orchestrator.scaleDown(any()) } - verify(exactly = 0) { orchestrator.destroy(any()) } - } - - @Test - fun `tracker timestamp wins over workspace updatedAt when fresher`() { - val ws = workspace(updatedAt = now.minusSeconds(7_200)) - tracker.touch(ws.id) // fresh-as-now - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - noRunningSessions(ws) - - scheduler.sweep() - - verify(exactly = 0) { orchestrator.scaleDown(any()) } - verify(exactly = 0) { orchestrator.destroy(any()) } - } - - @Test - fun `sweep skips a workspace with a connected browser client regardless of idle time`() { - val ws = workspace(updatedAt = now.minusSeconds(7_200)) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - connected.attach(ws.id) - - scheduler.sweep() - - verify(exactly = 0) { orchestrator.scaleDown(any()) } - } - - @Test - fun `sweep skips a workspace with RUNNING sessions inside the agent idle threshold`() { - val ws = workspace(updatedAt = now.minusSeconds(7_200)) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - every { agentSessions.findAllByWorkspaceId(ws.id) } returns listOf(runningSession()) - - scheduler.sweep() - - verify(exactly = 0) { orchestrator.scaleDown(any()) } - } - - @Test - fun `sweep scales down a workspace with RUNNING sessions once agent idle threshold expires`() { - val ws = workspace(updatedAt = now.minusSeconds(14_401)) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - every { agentSessions.findAllByWorkspaceId(ws.id) } returns listOf(runningSession()) - every { workspaces.save(any()) } answers { firstArg() } - - scheduler.sweep() - - verify { orchestrator.scaleDown(ws) } - } - - private fun runningSession() = - mockk { - every { status } returns WorkspaceAgentSessionStatus.RUNNING - } - - private fun workspace( - updatedAt: Instant, - status: WorkspaceStatus = WorkspaceStatus.READY, - ) = Workspace( - id = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - podName = "agent-runner-deadbeef", - pvcName = "workspace-deadbeef", - gatewayEndpoint = "http://x:8090", - status = status, - createdAt = updatedAt.minusSeconds(1), - updatedAt = updatedAt, - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/WorkspaceActivityTrackerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/WorkspaceActivityTrackerTest.kt deleted file mode 100644 index 2b167eb9..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/idle/WorkspaceActivityTrackerTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.idle - -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset - -class WorkspaceActivityTrackerTest { - private class FixedClock( - var now: Instant, - ) : Clock() { - override fun getZone() = ZoneOffset.UTC - - override fun withZone(zone: java.time.ZoneId) = this - - override fun instant(): Instant = now - } - - @Test - fun `touch records the current instant`() { - val clock = FixedClock(Instant.parse("2026-05-19T10:00:00Z")) - val tracker = WorkspaceActivityTracker(clock) - val id = WorkspaceId.random() - tracker.touch(id) - assertThat(tracker.lastSeen(id)).isEqualTo(clock.now) - } - - @Test - fun `touch updates an existing entry to the newer instant`() { - val clock = FixedClock(Instant.parse("2026-05-19T10:00:00Z")) - val tracker = WorkspaceActivityTracker(clock) - val id = WorkspaceId.random() - tracker.touch(id) - clock.now = clock.now.plusSeconds(60) - tracker.touch(id) - assertThat(tracker.lastSeen(id)).isEqualTo(clock.now) - } - - @Test - fun `forget removes the entry`() { - val clock = FixedClock(Instant.parse("2026-05-19T10:00:00Z")) - val tracker = WorkspaceActivityTracker(clock) - val id = WorkspaceId.random() - tracker.touch(id) - tracker.forget(id) - assertThat(tracker.lastSeen(id)).isNull() - } - - @Test - fun `lastSeen for unknown id returns null`() { - val tracker = WorkspaceActivityTracker() - assertThat(tracker.lastSeen(WorkspaceId.random())).isNull() - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceServiceTest.kt deleted file mode 100644 index b7099ea4..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/maintenance/RunnerMaintenanceServiceTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.maintenance - -import com.jorisjonkers.personalstack.assistant.application.idle.WorkspaceActivityTracker -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentRunnerOrchestrator -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Clock -import java.time.Instant -import java.time.ZoneOffset - -class RunnerMaintenanceServiceTest { - private val now = Instant.parse("2026-05-19T12:00:00Z") - private val clock = Clock.fixed(now, ZoneOffset.UTC) - private val workspaces = mockk() - private val orchestrator = mockk(relaxed = true) - private val tracker = WorkspaceActivityTracker(clock) - private val service = - RunnerMaintenanceService( - workspaces = workspaces, - orchestrator = orchestrator, - tracker = tracker, - clock = clock, - ) - - @Test - fun `gracefulScaleDownAll scales down READY workspaces and returns their ids`() { - val ws = workspace(WorkspaceStatus.READY) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - val saved = slot() - every { workspaces.save(capture(saved)) } answers { saved.captured } - - val result = service.gracefulScaleDownAll() - - verify { orchestrator.scaleDown(ws) } - assertThat(saved.captured.status).isEqualTo(WorkspaceStatus.IDLE) - assertThat(saved.captured.podName).isNull() - assertThat(saved.captured.gatewayEndpoint).isNull() - assertThat(result.cycled).isEqualTo(1) - assertThat(result.workspaceIds).containsExactly(ws.id.value.toString()) - } - - @Test - fun `gracefulScaleDownAll also cycles STARTING and FAILED workspaces`() { - val starting = workspace(WorkspaceStatus.STARTING) - val failed = workspace(WorkspaceStatus.FAILED) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(starting, failed) - every { workspaces.save(any()) } answers { firstArg() } - - val result = service.gracefulScaleDownAll() - - verify { orchestrator.scaleDown(starting) } - verify { orchestrator.scaleDown(failed) } - assertThat(result.cycled).isEqualTo(2) - } - - @Test - fun `gracefulScaleDownAll leaves IDLE workspaces untouched`() { - val idle = workspace(WorkspaceStatus.IDLE) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(idle) - - val result = service.gracefulScaleDownAll() - - verify(exactly = 0) { orchestrator.scaleDown(any()) } - verify(exactly = 0) { workspaces.save(any()) } - assertThat(result.cycled).isEqualTo(0) - } - - @Test - fun `gracefulScaleDownAll skips workspace when orchestrator scaleDown throws`() { - val ws = workspace(WorkspaceStatus.READY) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - every { orchestrator.scaleDown(ws) } throws RuntimeException("k8s unreachable") - - val result = service.gracefulScaleDownAll() - - verify(exactly = 0) { workspaces.save(any()) } - assertThat(result.cycled).isEqualTo(0) - } - - @Test - fun `gracefulScaleDownAll forgets workspace from activity tracker`() { - val ws = workspace(WorkspaceStatus.READY) - tracker.touch(ws.id) - every { workspaces.findAllByStatusNot(WorkspaceStatus.DESTROYED) } returns listOf(ws) - every { workspaces.save(any()) } answers { firstArg() } - - service.gracefulScaleDownAll() - - assertThat(tracker.lastSeen(ws.id)).isNull() - } - - private fun workspace(status: WorkspaceStatus) = - Workspace( - id = WorkspaceId.random(), - name = "test", - repoUrl = null, - branch = null, - podName = "agent-runner-deadbeef", - pvcName = "workspace-deadbeef", - gatewayEndpoint = "http://x:8090", - status = status, - createdAt = now.minusSeconds(3600), - updatedAt = now.minusSeconds(60), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetConversationQueryServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetConversationQueryServiceTest.kt deleted file mode 100644 index 7de8fb84..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetConversationQueryServiceTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.assistant.domain.port.ConversationRepository -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import io.mockk.every -import io.mockk.mockk -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test -import java.time.Instant -import java.util.UUID - -class GetConversationQueryServiceTest { - private val conversationRepository = mockk() - private val service = GetConversationQueryService(conversationRepository) - - @Test - fun `findById returns conversation when found`() { - val conversationId = ConversationId(UUID.randomUUID()) - val conversation = - Conversation( - id = conversationId, - userId = UUID.randomUUID(), - title = "Test Conversation", - status = ConversationStatus.ACTIVE, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - every { conversationRepository.findById(conversationId) } returns conversation - - val result = service.findById(conversationId) - - assertThat(result.title).isEqualTo("Test Conversation") - } - - @Test - fun `findById throws NotFoundException when not found`() { - val conversationId = ConversationId(UUID.randomUUID()) - every { conversationRepository.findById(conversationId) } returns null - - assertThatThrownBy { service.findById(conversationId) } - .isInstanceOf(NotFoundException::class.java) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetMessageQueryServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetMessageQueryServiceTest.kt deleted file mode 100644 index 13b2fc55..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetMessageQueryServiceTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.Message -import com.jorisjonkers.personalstack.assistant.domain.model.MessageId -import com.jorisjonkers.personalstack.assistant.domain.model.MessageRole -import com.jorisjonkers.personalstack.assistant.domain.port.MessageRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant -import java.util.UUID - -class GetMessageQueryServiceTest { - private val messageRepository = mockk() - private val service = GetMessageQueryService(messageRepository) - - @Test - fun `findByConversationId returns messages`() { - val conversationId = ConversationId(UUID.randomUUID()) - val messages = - listOf( - buildMessage(conversationId = conversationId, content = "Hello"), - buildMessage(conversationId = conversationId, content = "World"), - ) - every { messageRepository.findByConversationId(conversationId) } returns messages - - val result = service.findByConversationId(conversationId) - - assertThat(result).hasSize(2) - assertThat(result[0].content).isEqualTo("Hello") - assertThat(result[1].content).isEqualTo("World") - } - - @Test - fun `findByConversationId returns empty list when no messages`() { - val conversationId = ConversationId(UUID.randomUUID()) - every { messageRepository.findByConversationId(conversationId) } returns emptyList() - - val result = service.findByConversationId(conversationId) - - assertThat(result).isEmpty() - } - - @Test - fun `findByConversationId delegates to repository`() { - val conversationId = ConversationId(UUID.randomUUID()) - every { messageRepository.findByConversationId(conversationId) } returns emptyList() - - service.findByConversationId(conversationId) - - verify(exactly = 1) { messageRepository.findByConversationId(conversationId) } - } - - private fun buildMessage( - conversationId: ConversationId, - content: String = "Test message", - ): Message = - Message( - id = MessageId(UUID.randomUUID()), - conversationId = conversationId, - role = MessageRole.USER, - content = content, - createdAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt deleted file mode 100644 index 2408b343..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.query - -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test -import java.time.Instant - -class GetWorkspaceQueryServiceTest { - private val workspaces = mockk() - private val sessions = mockk() - private val workspaceRepositories = mockk() - private val repositories = mockk() - private val service = GetWorkspaceQueryService(workspaces, sessions, workspaceRepositories, repositories) - - @Test - fun `getSummary returns workspace without enriching detail`() { - val w = workspace() - every { workspaces.findById(w.id) } returns w - - val result = service.getSummary(w.id) - - assertThat(result).isEqualTo(w) - verify(exactly = 0) { sessions.findAllByWorkspaceId(any()) } - verify(exactly = 0) { workspaceRepositories.findAllByWorkspaceId(any()) } - verify(exactly = 0) { repositories.findById(any()) } - } - - @Test - fun `get returns workspace enriched with sessions and repositories`() { - val w = workspace() - val r1 = repository() - val r2 = repository(name = "docs") - val now = Instant.now() - every { workspaces.findById(w.id) } returns w - every { sessions.findAllByWorkspaceId(w.id) } returns emptyList() - every { workspaceRepositories.findAllByWorkspaceId(w.id) } returns - listOf( - WorkspaceRepositoryRepository.Link(w.id, r1.id, isPrimary = true, now), - WorkspaceRepositoryRepository.Link(w.id, r2.id, isPrimary = false, now), - ) - every { repositories.findById(r1.id) } returns r1 - every { repositories.findById(r2.id) } returns r2 - - val result = service.get(w.id) ?: error("expected workspace detail") - - assertThat(result.workspace).isEqualTo(w) - assertThat(result.sessions).isEmpty() - assertThat(result.repositories.map { it.repository }).containsExactly(r1, r2) - assertThat(result.repositories.map { it.isPrimary }).containsExactly(true, false) - } - - @Test - fun `get includes denormalized primary repository when junction row is missing`() { - val repoId = RepositoryId.random() - val w = workspace(repositoryId = repoId) - val r = repository(id = repoId) - every { workspaces.findById(w.id) } returns w - every { sessions.findAllByWorkspaceId(w.id) } returns emptyList() - every { workspaceRepositories.findAllByWorkspaceId(w.id) } returns emptyList() - every { repositories.findById(repoId) } returns r - - val result = service.get(w.id) ?: error("expected workspace detail") - - assertThat(result.repositories.map { it.repository }).containsExactly(r) - assertThat(result.repositories.single().isPrimary).isTrue - assertThat(result.repositories.single().attachedAt).isEqualTo(w.createdAt) - } - - @Test - fun `get returns null when workspace is absent`() { - val workspaceId = WorkspaceId.random() - every { workspaces.findById(workspaceId) } returns null - - val result = service.get(workspaceId) - - assertThat(result).isNull() - verify(exactly = 0) { sessions.findAllByWorkspaceId(any()) } - verify(exactly = 0) { workspaceRepositories.findAllByWorkspaceId(any()) } - verify(exactly = 0) { repositories.findById(any()) } - } - - @Test - fun `get fails fast when a workspace repository link dangles`() { - val w = workspace() - val missingRepositoryId = RepositoryId.random() - every { workspaces.findById(w.id) } returns w - every { workspaceRepositories.findAllByWorkspaceId(w.id) } returns - listOf(WorkspaceRepositoryRepository.Link(w.id, missingRepositoryId, isPrimary = false, Instant.now())) - every { repositories.findById(missingRepositoryId) } returns null - - assertThatThrownBy { service.get(w.id) } - .isInstanceOf(IllegalStateException::class.java) - .hasMessageContaining("references missing repository ${missingRepositoryId.value}") - } - - private fun workspace( - id: WorkspaceId = WorkspaceId.random(), - repositoryId: RepositoryId? = null, - ) = Workspace( - id = id, - name = "demo", - repoUrl = "git@github.com:owner/repo.git", - branch = "main", - podName = "pod", - pvcName = "pvc", - gatewayEndpoint = "http://endpoint:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - repositoryId = repositoryId, - ) - - private fun repository( - id: RepositoryId = RepositoryId.random(), - name: String = "personal-stack", - ) = Repository( - id = id, - name = name, - repoUrl = "git@github.com:owner/$name.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/${id.value}", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ContextBuilderTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ContextBuilderTest.kt deleted file mode 100644 index 1b448fa4..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ContextBuilderTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import com.jorisjonkers.personalstack.assistant.config.RagProperties -import com.jorisjonkers.personalstack.assistant.domain.port.RetrievalPort -import io.micrometer.core.instrument.simple.SimpleMeterRegistry -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test - -class ContextBuilderTest { - private val registry = SimpleMeterRegistry() - - private fun props( - enabled: Boolean = true, - maxSnippets: Int = 5, - maxContextChars: Int = 4_000, - minScore: Double = 0.0, - recallMode: String = "deep", - ): RagProperties = - RagProperties( - enabled = enabled, - knowledgeMcpUrl = "http://kb:8080", - knowledgeMcpToken = "", - lightragUrl = "http://lightrag:9621", - maxSnippets = maxSnippets, - maxContextChars = maxContextChars, - minScore = minScore, - recallMode = recallMode, - ) - - private class FakeSource( - private val snippets: List, - ) : RetrievalPort { - override fun retrieve( - query: String, - limit: Int, - ): List = snippets - } - - @Test - fun `augment returns plain prompt when RAG disabled`() { - val builder = ContextBuilder(emptyList(), props(enabled = false), registry) - assertThat(builder.augment("hi")).isEqualTo("hi") - } - - @Test - fun `augment returns plain prompt when no sources hit`() { - val builder = ContextBuilder(listOf(FakeSource(emptyList())), props(), registry) - assertThat(builder.augment("hi")).isEqualTo("hi") - } - - @Test - fun `augment wraps merged sorted snippets in a context envelope`() { - val builder = - ContextBuilder( - listOf( - FakeSource(listOf(RetrievalPort.Snippet("kb:a", "first", 0.9))), - FakeSource(listOf(RetrievalPort.Snippet("lightrag", "second", 0.5))), - ), - props(), - registry, - ) - val out = builder.augment("question") - assertThat(out).startsWith("") - assertThat(out).contains("[kb:a] first") - assertThat(out).contains("[lightrag] second") - assertThat(out).contains("question") - // Higher-score snippet comes first - assertThat(out.indexOf("first")).isLessThan(out.indexOf("second")) - } - - @Test - fun `augment dedupes snippets with identical text`() { - val builder = - ContextBuilder( - listOf( - FakeSource(listOf(RetrievalPort.Snippet("kb:a", "same", 0.9))), - FakeSource(listOf(RetrievalPort.Snippet("kb:b", "same", 0.8))), - ), - props(), - registry, - ) - val out = builder.augment("q") - val count = out.split("same").size - 1 - assertThat(count).isEqualTo(1) - } - - @Test - fun `augment dedupes snippets with the same id regardless of text`() { - val builder = - ContextBuilder( - listOf( - FakeSource(listOf(RetrievalPort.Snippet("kb:a", "text-a", 0.9, id = "note-1"))), - FakeSource(listOf(RetrievalPort.Snippet("kb:b", "text-b", 0.8, id = "note-1"))), - ), - props(), - registry, - ) - val out = builder.augment("q") - // Only the first occurrence (higher score) should appear. - assertThat(out).contains("text-a") - assertThat(out).doesNotContain("text-b") - } - - @Test - fun `augment drops snippets below the score floor`() { - val builder = - ContextBuilder( - listOf( - FakeSource( - listOf( - RetrievalPort.Snippet("kb:a", "above", 0.9), - RetrievalPort.Snippet("kb:b", "below", 0.1), - ), - ), - ), - props(minScore = 0.3), - registry, - ) - val out = builder.augment("q") - assertThat(out).contains("above") - assertThat(out).doesNotContain("below") - } - - @Test - fun `augment returns plain prompt when all snippets are below the score floor`() { - val builder = - ContextBuilder( - listOf( - FakeSource(listOf(RetrievalPort.Snippet("kb:a", "low", 0.1))), - ), - props(minScore = 0.5), - registry, - ) - assertThat(builder.augment("q")).isEqualTo("q") - } - - @Test - fun `augment respects maxContextChars`() { - val long = "x".repeat(900) - val builder = - ContextBuilder( - listOf( - FakeSource( - (1..10).map { RetrievalPort.Snippet("kb:$it", long, 1.0 - it * 0.01) }, - ), - ), - props(maxSnippets = 10, maxContextChars = 1_500), - registry, - ) - val out = builder.augment("q") - // At most one full snippet fits before the budget runs out (~900 chars per chunk). - val count = out.split("[kb:").size - 1 - assertThat(count).isLessThanOrEqualTo(2) - } - - @Test - fun `augment increments hit and char counters`() { - val builder = - ContextBuilder( - listOf( - FakeSource(listOf(RetrievalPort.Snippet("kb:a", "hello", 0.9))), - ), - props(), - registry, - ) - builder.augment("q") - assertThat(registry.counter("rag.hits.injected").count()).isEqualTo(1.0) - assertThat(registry.counter("rag.chars.injected").count()).isGreaterThan(0.0) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCaptureTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCaptureTest.kt deleted file mode 100644 index 8d1bcaa9..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonAutoCaptureTest.kt +++ /dev/null @@ -1,196 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import com.jorisjonkers.personalstack.assistant.config.RagProperties -import com.jorisjonkers.personalstack.assistant.domain.model.Turn -import com.jorisjonkers.personalstack.assistant.domain.model.TurnId -import com.jorisjonkers.personalstack.assistant.domain.model.TurnRole -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.KnowledgeWritePort -import com.jorisjonkers.personalstack.assistant.domain.port.TurnRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test -import java.time.Instant - -class LessonAutoCaptureTest { - private val workspaces = mockk() - private val sessions = mockk() - private val turns = mockk() - private val extractor = LessonExtractor() - private val kbWrite = mockk(relaxed = true) - private val rag = - RagProperties( - enabled = true, - knowledgeMcpUrl = "http://kb", - knowledgeMcpToken = "", - lightragUrl = "http://lr", - ) - - private val capture = LessonAutoCapture(workspaces, sessions, turns, extractor, kbWrite, rag) - - @Test - fun `capture ingests one note per extracted candidate`() { - val ws = workspace(repoUrl = "git@github.com:owner/personal-stack.git") - val s = session(ws.id) - every { sessions.findById(s.id) } returns s - every { workspaces.findById(ws.id) } returns ws - every { kbWrite.findDuplicateEvidence(any(), any()) } returns null - every { turns.findBySessionId(s.id, any()) } returns - listOf( - turn(TurnRole.USER, "how does flannel work over tailscale?", 1, s.id), - turn(TurnRole.AGENT, "Lesson: It uses --flannel-iface=tailscale0. ".repeat(40), 2, s.id), - ) - - capture.capture(s.id) - - verify { - kbWrite.ingestNote( - match { - it.title.contains("how does flannel") && - it.body.contains("Trigger:") && - it.body.contains("Capture policy:") && - it.scope == "project:personal-stack" && - it.source == "assistant-ui:auto-capture:${s.id}" && - it.sessionId == s.id.toString() && - (it.confidence ?: 0.0) >= 0.55 && - it.tags.containsAll( - listOf( - "auto-capture", - "assistant-ui", - "agent:claude", - "dedupe:checked", - "repo:personal-stack", - ), - ) - }, - ) - } - } - - @Test - fun `capture is a no-op when RAG is disabled`() { - val disabledRag = rag.copy(enabled = false) - val withDisabled = LessonAutoCapture(workspaces, sessions, turns, extractor, kbWrite, disabledRag) - withDisabled.capture(WorkspaceAgentSessionId.random()) - verify(exactly = 0) { kbWrite.ingestNote(any()) } - } - - @Test - fun `capture bucket caps writes per session`() { - val ws = workspace() - val s = session(ws.id) - every { sessions.findById(s.id) } returns s - every { workspaces.findById(ws.id) } returns ws - every { kbWrite.findDuplicateEvidence(any(), any()) } returns null - // Five capture-worthy pairs in a row, bucket capacity is 3. - val pairs = - (1..5).flatMap { i -> - listOf( - turn(TurnRole.USER, "how about $i?", i * 2L, s.id), - turn(TurnRole.AGENT, "long answer $i. ".repeat(30), i * 2L + 1, s.id), - ) - } - every { turns.findBySessionId(s.id, any()) } returns pairs - - capture.capture(s.id) - - verify(exactly = 3) { kbWrite.ingestNote(any()) } - } - - @Test - fun `capture skips likely duplicates before consuming write budget`() { - val ws = workspace(repoUrl = "git@github.com:owner/personal-stack.git") - val s = session(ws.id) - every { sessions.findById(s.id) } returns s - every { workspaces.findById(ws.id) } returns ws - every { kbWrite.findDuplicateEvidence(any(), 0.86) } returns - KnowledgeWritePort.DuplicateEvidence( - id = "01KDUPLICATE", - source = "kb:project:personal-stack:Existing lesson", - score = 0.91, - ) - every { turns.findBySessionId(s.id, any()) } returns - listOf( - turn(TurnRole.USER, "how does duplicate capture work?", 1, s.id), - turn(TurnRole.AGENT, "Lesson: duplicate answer. ".repeat(40), 2, s.id), - ) - - capture.capture(s.id) - - verify(exactly = 0) { kbWrite.ingestNote(any()) } - } - - @Test - fun `capture routes weak candidates to inbox for curator review`() { - val ws = workspace(repoUrl = "git@github.com:owner/personal-stack.git") - val s = session(ws.id) - every { sessions.findById(s.id) } returns s - every { workspaces.findById(ws.id) } returns ws - every { kbWrite.findDuplicateEvidence(any(), any()) } returns null - every { turns.findBySessionId(s.id, any()) } returns - listOf( - turn(TurnRole.USER, "how does low confidence capture work?", 1, s.id), - turn(TurnRole.AGENT, "short but still capture-worthy explanation. ".repeat(8), 2, s.id), - ) - - capture.capture(s.id) - - verify { - kbWrite.ingestNote( - match { - it.scope == "_inbox" && - it.body.contains("inferred_scope: project:personal-stack") && - it.body.contains("capture_scope: _inbox") && - it.tags.contains("confidence:low") - }, - ) - } - } - - private fun workspace(repoUrl: String? = null) = - Workspace( - id = WorkspaceId.random(), - name = "demo", - repoUrl = repoUrl, - branch = null, - podName = null, - pvcName = null, - gatewayEndpoint = null, - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private fun session(workspaceId: WorkspaceId) = - WorkspaceAgentSession( - id = WorkspaceAgentSessionId.random(), - workspaceId = workspaceId, - kind = WorkspaceAgentKind.CLAUDE, - gatewayAgentId = "abc12345", - status = WorkspaceAgentSessionStatus.RUNNING, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private fun turn( - role: TurnRole, - body: String, - sec: Long, - sid: WorkspaceAgentSessionId, - ) = Turn( - id = TurnId.random(), - sessionId = sid, - role = role, - body = body, - createdAt = Instant.parse("2026-05-19T10:00:00Z").plusSeconds(sec), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractorTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractorTest.kt deleted file mode 100644 index 8f0fed56..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/LessonExtractorTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import com.jorisjonkers.personalstack.assistant.domain.model.Turn -import com.jorisjonkers.personalstack.assistant.domain.model.TurnId -import com.jorisjonkers.personalstack.assistant.domain.model.TurnRole -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant - -class LessonExtractorTest { - private val extractor = LessonExtractor() - private val sessionId = WorkspaceAgentSessionId.random() - - @Test - fun `extracts a question-agent pair when reply is substantive`() { - val turns = - listOf( - turn(TurnRole.USER, "how do I configure flannel over tailscale?", 1), - turn(TurnRole.AGENT, "Use --flannel-iface=tailscale0 on all nodes. ".repeat(20), 2), - ) - val out = extractor.extract(workspace(), turns) - assertThat(out).hasSize(1) - assertThat(out[0].title).contains("how do I configure flannel") - assertThat(out[0].body) - .contains("Trigger:") - .contains("Evidence:") - .contains("Q: how do I") - .contains("A: Use --flannel") - assertThat(out[0].triggerTerms).contains("flannel", "tailscale") - assertThat(out[0].excerpts).hasSize(2) - assertThat(out[0].dedupeQuery).contains("flannel") - assertThat(out[0].tags).contains("auto-capture", "assistant-ui", "kind:turn-pair") - } - - @Test - fun `extracts when agent reply contains a TIL or Lesson marker even without a question`() { - val turns = - listOf( - turn(TurnRole.USER, "running through deploys", 1), - turn(TurnRole.AGENT, "TIL: the keel.sh/match-tag annotation is required. ".repeat(20), 2), - ) - val out = extractor.extract(workspace(), turns) - assertThat(out).hasSize(1) - assertThat(out[0].tags).contains("has-marker") - } - - @Test - fun `skips short agent replies`() { - val turns = - listOf( - turn(TurnRole.USER, "what is flannel?", 1), - turn(TurnRole.AGENT, "A CNI plugin.", 2), - ) - assertThat(extractor.extract(workspace(), turns)).isEmpty() - } - - @Test - fun `skips non-question pairs without a marker`() { - val turns = - listOf( - turn(TurnRole.USER, "list the files", 1), - turn(TurnRole.AGENT, "x\n".repeat(200), 2), - ) - assertThat(extractor.extract(workspace(), turns)).isEmpty() - } - - @Test - fun `confidence scales with reply length and code-fence presence`() { - val plain = - listOf( - turn(TurnRole.USER, "how to test this?", 1), - turn(TurnRole.AGENT, "Use ./gradlew test. ".repeat(15), 2), - ) - val withCode = - listOf( - turn(TurnRole.USER, "how to test this?", 1), - turn(TurnRole.AGENT, "Run:\n```\n./gradlew test\n```\n" + "explanation ".repeat(40), 2), - ) - val plainScore = extractor.extract(workspace(), plain).single().confidence - val codeScore = extractor.extract(workspace(), withCode).single().confidence - assertThat(codeScore).isGreaterThan(plainScore) - } - - @Test - fun `repo-backed workspaces add a repo tag`() { - val ws = workspace(repoUrl = "git@github.com:owner/personal-stack.git") - val turns = - listOf( - turn(TurnRole.USER, "how does services/assistant-api/src/main/App.kt work with kubectl?", 1), - turn(TurnRole.AGENT, "long substantive answer using ./gradlew and kubectl. ".repeat(20), 2), - ) - val out = extractor.extract(ws, turns).single() - assertThat(out.tags).contains("repo:personal-stack") - assertThat(out.triggerTerms) - .contains( - "personal-stack", - "services/assistant-api/src/main/App.kt", - "kubectl", - "./gradlew", - ) - } - - private fun turn( - role: TurnRole, - body: String, - sec: Long, - ) = Turn( - id = TurnId.random(), - sessionId = sessionId, - role = role, - body = body, - createdAt = Instant.parse("2026-05-19T10:00:00Z").plusSeconds(sec), - ) - - private fun workspace(repoUrl: String? = null) = - Workspace( - id = WorkspaceId.random(), - name = "demo", - repoUrl = repoUrl, - branch = null, - podName = null, - pvcName = null, - gatewayEndpoint = null, - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ScopeInferenceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ScopeInferenceTest.kt deleted file mode 100644 index e2c12519..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/ScopeInferenceTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant - -class ScopeInferenceTest { - @Test - fun `repo-backed workspace yields project scope from URL tail without dot-git`() { - val ws = workspace(name = "anything", repoUrl = "git@github.com:owner/personal-stack.git") - assertThat(ScopeInference.scopeFor(ws)).isEqualTo("project:personal-stack") - } - - @Test - fun `https remote URL works the same as ssh`() { - val ws = workspace(repoUrl = "https://github.com/owner/dotfiles") - assertThat(ScopeInference.scopeFor(ws)).isEqualTo("project:dotfiles") - } - - @Test - fun `repo-less workspace yields agent scope from slugged name`() { - val ws = workspace(name = "Migration Plan #1!", repoUrl = null) - assertThat(ScopeInference.scopeFor(ws)).isEqualTo("agent:migration-plan-1") - } - - @Test - fun `empty name falls back to workspace literal`() { - val ws = workspace(name = " ", repoUrl = null) - assertThat(ScopeInference.scopeFor(ws)).isEqualTo("agent:workspace") - } - - @Test - fun `repoSlug strips trailing dot-git and handles bare names`() { - assertThat(ScopeInference.repoSlug("git@github.com:owner/repo.git")).isEqualTo("repo") - assertThat(ScopeInference.repoSlug("https://github.com/owner/repo")).isEqualTo("repo") - assertThat(ScopeInference.repoSlug("repo")).isEqualTo("repo") - assertThat(ScopeInference.repoSlug("")).isEqualTo("unknown") - } - - private fun workspace( - name: String = "demo", - repoUrl: String? = null, - ) = Workspace( - id = WorkspaceId.random(), - name = name, - repoUrl = repoUrl, - branch = null, - podName = null, - pvcName = null, - gatewayEndpoint = null, - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/TokenBucketTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/TokenBucketTest.kt deleted file mode 100644 index b93369c5..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/rag/TokenBucketTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.rag - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Clock -import java.time.Duration -import java.time.Instant -import java.time.ZoneOffset - -class TokenBucketTest { - private class FixedClock( - var now: Instant, - ) : Clock() { - override fun getZone() = ZoneOffset.UTC - - override fun withZone(zone: java.time.ZoneId) = this - - override fun instant(): Instant = now - } - - @Test - fun `tryAcquire drains the bucket and rejects when empty`() { - val clock = FixedClock(Instant.parse("2026-05-19T10:00:00Z")) - val bucket = TokenBucket(capacity = 2, refillInterval = Duration.ofMinutes(15), clock = clock) - assertThat(bucket.tryAcquire("a")).isTrue - assertThat(bucket.tryAcquire("a")).isTrue - assertThat(bucket.tryAcquire("a")).isFalse - } - - @Test - fun `buckets are independent per key`() { - val clock = FixedClock(Instant.parse("2026-05-19T10:00:00Z")) - val bucket = TokenBucket(capacity = 1, refillInterval = Duration.ofMinutes(15), clock = clock) - assertThat(bucket.tryAcquire("a")).isTrue - assertThat(bucket.tryAcquire("a")).isFalse - assertThat(bucket.tryAcquire("b")).isTrue - } - - @Test - fun `refill restores one permit per interval up to capacity`() { - val clock = FixedClock(Instant.parse("2026-05-19T10:00:00Z")) - val bucket = TokenBucket(capacity = 2, refillInterval = Duration.ofMinutes(15), clock = clock) - assertThat(bucket.tryAcquire("a")).isTrue - assertThat(bucket.tryAcquire("a")).isTrue - assertThat(bucket.tryAcquire("a")).isFalse - clock.now = clock.now.plus(Duration.ofMinutes(15)) - assertThat(bucket.tryAcquire("a")).isTrue - clock.now = clock.now.plus(Duration.ofMinutes(45)) // 3 intervals; capped at capacity - assertThat(bucket.snapshot("a")).isLessThanOrEqualTo(2) - assertThat(bucket.tryAcquire("a")).isTrue - assertThat(bucket.tryAcquire("a")).isTrue - assertThat(bucket.tryAcquire("a")).isFalse - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/setup/SetupGuideServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/setup/SetupGuideServiceTest.kt deleted file mode 100644 index 3e41d996..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/setup/SetupGuideServiceTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.application.setup - -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLink -import com.jorisjonkers.personalstack.assistant.domain.model.GithubLinkId -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant - -class SetupGuideServiceTest { - private val service = SetupGuideService() - - @Test - fun `renders all placeholders for an ssh remote`() { - val md = service.render(link(repoUrl = "git@github.com:ExtraToast/personal-stack.git")) - assertThat(md).contains("Deploy-key setup for `auth-service`") - assertThat(md).contains("git@github.com:ExtraToast/personal-stack.git") - assertThat(md).contains("secret/data/agents/projects/") - assertThat(md).contains("https://github.com/ExtraToast/personal-stack/settings/keys") - assertThat(md).doesNotContain("{{") - } - - @Test - fun `renders all placeholders for an https remote with trailing slash`() { - val md = service.render(link(repoUrl = "https://github.com/ExtraToast/dotfiles/")) - assertThat(md).contains("https://github.com/ExtraToast/dotfiles/settings/keys") - assertThat(md).doesNotContain("{{") - } - - @Test - fun `unknown remote shapes leave repo URL unchanged for the deploy page link`() { - val md = service.render(link(repoUrl = "git://other.example.com/r")) - assertThat(md).contains("git://other.example.com/r") - assertThat(md).doesNotContain("{{") - } - - private fun link(repoUrl: String) = - GithubLink( - id = GithubLinkId.random(), - projectId = ProjectId.random(), - name = "auth-service", - repoUrl = repoUrl, - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/projects/abc/repos/def", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/archunit/ArchitectureTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/archunit/ArchitectureTest.kt deleted file mode 100644 index 4cd6e268..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/archunit/ArchitectureTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.archunit - -import com.jorisjonkers.personalstack.common.archunit.HexagonalArchitectureRules -import com.tngtech.archunit.core.importer.ClassFileImporter -import com.tngtech.archunit.core.importer.ImportOption -import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes -import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class ArchitectureTest { - private val importedClasses = - ClassFileImporter() - .withImportOption(ImportOption.DoNotIncludeTests()) - .importPackages("com.jorisjonkers.personalstack.assistant") - - @Test - fun `domain must not depend on Spring framework`() { - HexagonalArchitectureRules.DOMAIN_MUST_NOT_DEPEND_ON_SPRING.check(importedClasses) - } - - @Test - fun `controllers must not access repositories directly`() { - HexagonalArchitectureRules.CONTROLLERS_MUST_NOT_ACCESS_REPOSITORIES.check(importedClasses) - } - - @Test - fun `no field injection allowed`() { - HexagonalArchitectureRules.NO_FIELD_INJECTION.check(importedClasses) - } - - @Test - fun `controllers must follow naming convention`() { - HexagonalArchitectureRules.NAMING_CONTROLLERS.check(importedClasses) - } - - @Test - fun `repositories must follow naming convention`() { - HexagonalArchitectureRules.NAMING_REPOSITORIES.check(importedClasses) - } - - @Test - fun `commands must not depend on infrastructure`() { - HexagonalArchitectureRules.COMMANDS_MUST_NOT_DEPEND_ON_WEB_OR_INFRA.check(importedClasses) - } - - @Test - fun `domain must not depend on infrastructure`() { - HexagonalArchitectureRules.DOMAIN_MUST_NOT_DEPEND_ON_INFRASTRUCTURE.check(importedClasses) - } - - @Test - fun `DTOs are in dto package`() { - // The rule scopes to TOP-LEVEL classes only. Adapter-internal - // `private data class` declarations nested inside an integration - // class (e.g. `HttpAgentGatewayClient.CloneRequest`) live - // alongside their adapter on purpose — promoting them to a - // top-level `dto` package would force them public and split - // the adapter's wire shape from its caller. ADR-013's intent - // is about top-level DTOs the rest of the codebase consumes, - // not file-local helpers. - classes() - .that() - .haveSimpleNameEndingWith("Request") - .or() - .haveSimpleNameEndingWith("Response") - .and() - .areTopLevelClasses() - .should() - .resideInAPackage("..dto..") - .because("DTOs (Request/Response) must reside in a dto package (ADR-013)") - .check(importedClasses) - } - - @Test - fun `command handlers end with CommandHandler`() { - classes() - .that() - .resideInAPackage("..application.command..") - .and() - .areAnnotatedWith("org.springframework.stereotype.Service") - .should() - .haveSimpleNameEndingWith("CommandHandler") - .because("command handlers must follow *CommandHandler naming convention") - .check(importedClasses) - } - - @Test - fun `new redesign entities stay free of Spring`() { - val redesignDomainTypes = - com.tngtech.archunit.base.DescribedPredicate - .describe( - "redesign domain types (Repository*, ChatSession*, ChatMessage*, WorkspaceKind)", - ) { javaClass -> - val inDomainModel = javaClass.packageName.endsWith(".domain.model") - val simpleName = javaClass.simpleName - inDomainModel && - ( - simpleName.startsWith("Repository") || - simpleName.startsWith("ChatSession") || - simpleName.startsWith("ChatMessage") || - simpleName == "WorkspaceKind" - ) - } - noClasses() - .that(redesignDomainTypes) - .should() - .dependOnClassesThat() - .resideInAnyPackage( - "org.springframework..", - "jakarta..", - "org.jooq..", - "com.fasterxml.jackson..", - ).because( - "redesign domain entities (Repository / ChatSession / ChatMessage / " + - "WorkspaceKind) must remain framework-free (ADR-006)", - ).check(importedClasses) - } - - @Test - fun `query services end with QueryService`() { - classes() - .that() - .resideInAPackage("..application.query..") - .and() - .areAnnotatedWith("org.springframework.stereotype.Service") - .should() - .haveSimpleNameEndingWith("QueryService") - .because("query services must follow *QueryService naming convention") - .check(importedClasses) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimePropertiesBindingTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimePropertiesBindingTest.kt deleted file mode 100644 index 7eb8a531..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimePropertiesBindingTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test -import org.springframework.boot.context.properties.bind.Binder -import org.springframework.boot.context.properties.source.MapConfigurationPropertySource - -/** - * Regression coverage for Spring Boot's relaxed-binding behaviour on - * `Map` properties with slash-containing keys. - * - * Production failure (kubectl describe on a Pending agent-runner Pod): - * - * Node-Selectors: personal-stacknode=enschede-gtx-960m-1 - * - * The intended selector is `personal-stack/node=enschede-gtx-960m-1` - * — matching the cluster's node label. Without the bracket escape - * in `application.yml`, Spring's relaxed binding lowercases the key - * and strips special chars (`-`, `/`, `.`), reducing - * `personal-stack/node` to `personalstacknode`. No node carries that - * label, so every agent-runner Pod sits Pending forever and the - * connection-refused 503 PR #428 surfaces is the user-visible tail. - * - * This test binds two property sources representative of the two - * shapes a YAML author might write and asserts only the - * bracket-escaped form preserves the literal key. - */ -class AgentRuntimePropertiesBindingTest { - private val base = - mapOf( - "agent-runtime.namespace" to "agents-system", - "agent-runtime.image" to "ghcr.io/example/agent-runner:latest", - "agent-runtime.service-account" to "agent-runner", - "agent-runtime.claude-credentials-pvc" to "claude-credentials", - "agent-runtime.codex-credentials-pvc" to "codex-credentials", - "agent-runtime.github-deploy-key-secret" to "agents-github-deploy-key", - ) - - @Test - fun `node-selector key surrounded by brackets binds literally — including the slash`() { - // This is the SHAPE the production application.yml now uses. - // The test would fail with `personalstacknode` if the - // bracket-escape were dropped, which is exactly the - // regression we want pre-flight detection for. - val props = - bind( - base + - mapOf("agent-runtime.node-selector.[personal-stack/node]" to "enschede-gtx-960m-1"), - ) - - assertThat(props.nodeSelector).containsExactlyEntriesOf( - mapOf("personal-stack/node" to "enschede-gtx-960m-1"), - ) - } - - @Test - fun `node-selector key without brackets is mangled — documents the trap`() { - // Same logical key written without the bracket escape: this - // is the shape that shipped to production and produced the - // 28-hour Pending Pod. Asserting the mangled output here - // pins the surprising-but-documented Spring behaviour so a - // future YAML edit doesn't silently re-introduce the trap. - val props = - bind( - base + - mapOf("agent-runtime.node-selector.personal-stack/node" to "enschede-gtx-960m-1"), - ) - - // Slash gets dropped — that's the production-breaking part. - // (Dashes survive; the trap is specifically the special-char - // strip, not a wholesale alphanum-only normalisation.) - assertThat(props.nodeSelector.keys).noneMatch { it.contains("/") } - assertThat(props.nodeSelector).containsKey("personal-stacknode") - } - - @Test - fun `default mcp profile accepts every declared runner profile`() { - AgentRuntimeProperties.VALID_MCP_PROFILES.forEach { profile -> - val props = - bind( - base + - mapOf("agent-runtime.default-mcp-profile" to profile), - ) - - assertThat(props.defaultMcpProfile).isEqualTo(profile) - } - } - - @Test - fun `default mcp profile rejects unknown values`() { - assertThatThrownBy { - bind( - base + - mapOf("agent-runtime.default-mcp-profile" to "frontned"), - ) - }.rootCause() - .isInstanceOf(IllegalArgumentException::class.java) - .hasMessageContaining("agent-runtime.default-mcp-profile") - .hasMessageContaining("frontned") - } - - @Test - fun `docker socket defaults keep testcontainers available in runner pods`() { - val props = bind(base) - - assertThat(props.dockerSocketEnabled).isTrue() - assertThat(props.dockerSocketPath).isEqualTo("/var/run/docker.sock") - assertThat(props.dockerSocketSupplementalGroups).containsExactly(131L) - assertThat(props.nodeSelector).containsEntry("personal-stack/capability-docker-socket", "true") - } - - @Test - fun `docker socket supplemental groups bind from comma separated environment value`() { - val props = - bind( - base + - mapOf( - "agent-runtime.docker-socket-supplemental-groups" to "44,45", - ), - ) - - assertThat(props.dockerSocketSupplementalGroups).containsExactly(44L, 45L) - } - - private fun bind(properties: Map): AgentRuntimeProperties { - val source = MapConfigurationPropertySource(properties) - return Binder(source) - .bind("agent-runtime", AgentRuntimeProperties::class.java) - .get() - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/InternalBearerAuthFilterTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/InternalBearerAuthFilterTest.kt deleted file mode 100644 index bc593acf..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/InternalBearerAuthFilterTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import jakarta.servlet.http.HttpServletResponse -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.mock.web.MockFilterChain -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse - -class InternalBearerAuthFilterTest { - private fun run( - configured: String, - header: String?, - ): Pair { - val request = MockHttpServletRequest("POST", "/api/v1/internal/github/installation-token") - header?.let { request.addHeader("Authorization", it) } - val response = MockHttpServletResponse() - val chain = MockFilterChain() - InternalBearerAuthFilter(configured).doFilter(request, response, chain) - return response to chain - } - - @Test - fun `fail-closed — rejects every request when no bearer is configured`() { - val (response, chain) = run(configured = "", header = "Bearer anything") - assertThat(response.status).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED) - assertThat(chain.request).isNull() - } - - @Test - fun `rejects a missing Authorization header`() { - val (response, chain) = run(configured = "s3cret", header = null) - assertThat(response.status).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED) - assertThat(chain.request).isNull() - } - - @Test - fun `rejects a wrong bearer`() { - val (response, chain) = run(configured = "s3cret", header = "Bearer wrong") - assertThat(response.status).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED) - assertThat(chain.request).isNull() - } - - @Test - fun `passes the chain through for the correct bearer`() { - val (response, chain) = run(configured = "s3cret", header = "Bearer s3cret") - assertThat(response.status).isEqualTo(HttpServletResponse.SC_OK) - assertThat(chain.request).isNotNull() - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/XUserIdFilterTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/XUserIdFilterTest.kt deleted file mode 100644 index 6afd40c2..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/XUserIdFilterTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.config - -import jakarta.servlet.FilterChain -import jakarta.servlet.http.HttpServletResponse -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse - -class XUserIdFilterTest { - private val filter = XUserIdFilter() - private val filterChain = - FilterChain { _, _ -> - // no-op - } - - @Test - fun `filter passes when X-User-Id header is present`() { - val request = MockHttpServletRequest("GET", "/api/v1/conversations") - request.addHeader("X-User-Id", "some-user-id") - val response = MockHttpServletResponse() - - filter.doFilter(request, response, filterChain) - - assertThat(response.status).isEqualTo(HttpServletResponse.SC_OK) - } - - @Test - fun `filter returns 401 when X-User-Id header is missing`() { - val request = MockHttpServletRequest("GET", "/api/v1/conversations") - val response = MockHttpServletResponse() - - filter.doFilter(request, response, filterChain) - - assertThat(response.status).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED) - } - - @Test - fun `filter returns 401 when X-User-Id header is blank`() { - val request = MockHttpServletRequest("GET", "/api/v1/conversations") - request.addHeader("X-User-Id", " ") - val response = MockHttpServletResponse() - - filter.doFilter(request, response, filterChain) - - assertThat(response.status).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED) - } - - @Test - fun `filter skips health endpoint`() { - val request = MockHttpServletRequest("GET", "/api/v1/health") - request.servletPath = "/api/v1/health" - val response = MockHttpServletResponse() - - filter.doFilter(request, response, filterChain) - - assertThat(response.status).isEqualTo(HttpServletResponse.SC_OK) - } - - @Test - fun `filter skips actuator endpoints`() { - val request = MockHttpServletRequest("GET", "/api/actuator/health") - request.servletPath = "/api/actuator/health" - val response = MockHttpServletResponse() - - filter.doFilter(request, response, filterChain) - - assertThat(response.status).isEqualTo(HttpServletResponse.SC_OK) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSessionTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSessionTest.kt deleted file mode 100644 index 68c6666a..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceAgentSessionTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant - -class WorkspaceAgentSessionTest { - @Test - fun `bindGatewayAgent flips status and writes gateway id`() { - val s = base() - val bound = s.bindGatewayAgent("abc12345") - assertThat(bound.gatewayAgentId).isEqualTo("abc12345") - assertThat(bound.status).isEqualTo(WorkspaceAgentSessionStatus.RUNNING) - } - - @Test - fun `markStopped and markFailed flip status`() { - val s = base().bindGatewayAgent("abc") - assertThat(s.markStopped().status).isEqualTo(WorkspaceAgentSessionStatus.STOPPED) - assertThat(s.markFailed().status).isEqualTo(WorkspaceAgentSessionStatus.FAILED) - } - - private fun base() = - WorkspaceAgentSession( - id = WorkspaceAgentSessionId.random(), - workspaceId = WorkspaceId.random(), - kind = WorkspaceAgentKind.CLAUDE, - gatewayAgentId = null, - status = WorkspaceAgentSessionStatus.STARTING, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceTest.kt deleted file mode 100644 index 8d50d9e0..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/domain/model/WorkspaceTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.domain.model - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.Instant - -class WorkspaceTest { - private val now = Instant.parse("2026-05-19T10:00:00Z") - - @Test - fun `withPodInfo transitions to STARTING and stamps pod fields`() { - val ws = base() - val updated = - ws.withPodInfo( - podName = "agent-runner-abcdef01", - pvcName = "workspace-abcdef01", - gatewayEndpoint = "http://x:8090", - ) - assertThat(updated.status).isEqualTo(WorkspaceStatus.STARTING) - assertThat(updated.podName).isEqualTo("agent-runner-abcdef01") - assertThat(updated.pvcName).isEqualTo("workspace-abcdef01") - assertThat(updated.gatewayEndpoint).isEqualTo("http://x:8090") - assertThat(updated.updatedAt).isAfterOrEqualTo(updated.createdAt) - } - - @Test - fun `markReady markFailed markDestroyed flip status`() { - val ws = base().withPodInfo("p", "v", "http://x:8090") - assertThat(ws.markReady().status).isEqualTo(WorkspaceStatus.READY) - assertThat(ws.markFailed().status).isEqualTo(WorkspaceStatus.FAILED) - assertThat(ws.markDestroyed().status).isEqualTo(WorkspaceStatus.DESTROYED) - } - - @Test - fun `isRepoBacked reflects repoUrl presence`() { - assertThat(base().isRepoBacked).isFalse - assertThat(base().copy(repoUrl = "git@github.com:owner/repo.git").isRepoBacked).isTrue - } - - private fun base() = - Workspace( - id = WorkspaceId.random(), - name = "demo", - repoUrl = null, - branch = null, - podName = null, - pvcName = null, - gatewayEndpoint = null, - status = WorkspaceStatus.PENDING, - createdAt = now, - updatedAt = now, - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt deleted file mode 100644 index 71054f52..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt +++ /dev/null @@ -1,261 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.jorisjonkers.personalstack.assistant.config.AgentRuntimeProperties -import io.mockk.mockk -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.MediaType -import org.springframework.test.web.client.MockRestServiceServer -import org.springframework.test.web.client.RequestMatcher -import org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath -import org.springframework.test.web.client.match.MockRestRequestMatchers.method -import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo -import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus -import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess -import org.springframework.web.client.RestClient -import java.security.KeyPairGenerator -import java.security.Signature -import java.security.interfaces.RSAPublicKey -import java.time.Instant -import java.util.Base64 - -class GitHubAppInstallationTokenClientTest { - private val keyPair = - KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() - - private fun pkcs8Pem(): String { - val body = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(keyPair.private.encoded) - return "-----BEGIN PRIVATE KEY-----\n$body\n-----END PRIVATE KEY-----\n" - } - - private fun props( - appId: String = "123456", - key: String = pkcs8Pem(), - bearer: String = "", - ) = AgentRuntimeProperties( - namespace = "agents-system", - image = "img", - serviceAccount = "sa", - claudeCredentialsPvc = "c", - codexCredentialsPvc = "x", - githubDeployKeySecret = "k", - githubAppId = appId, - githubAppPrivateKey = key, - githubAppTokenBearer = bearer, - ) - - @Test - fun `enabled reflects whether both id and key are present`() { - val rc = mockk() - assertThat(GitHubAppInstallationTokenClient(rc, props()).enabled).isTrue() - assertThat(GitHubAppInstallationTokenClient(rc, props(appId = "")).enabled).isFalse() - assertThat(GitHubAppInstallationTokenClient(rc, props(key = "")).enabled).isFalse() - } - - @Test - fun `mint returns null and never calls GitHub when disabled`() { - val rc = mockk() // unused: must short-circuit before any HTTP - val client = GitHubAppInstallationTokenClient(rc, props(appId = "")) - assertThat(client.mint("git@github.com:ExtraToast/personal-stack.git")).isNull() - } - - @Test - fun `mint returns null for an unparseable repo URL`() { - val rc = mockk() - val client = GitHubAppInstallationTokenClient(rc, props()) - assertThat(client.mint("not-a-url")).isNull() - } - - @Test - fun `mint sends a valid RS256 app JWT and a repo-scoped token request, then returns the token`() { - val builder = RestClient.builder() - val server = MockRestServiceServer.bindTo(builder).build() - val client = GitHubAppInstallationTokenClient(builder.build(), props(appId = "123456")) - - var capturedAuth: String? = null - server - .expect(requestTo("https://api.github.com/repos/ExtraToast/personal-stack/installation")) - .andExpect(method(HttpMethod.GET)) - .andExpect(RequestMatcher { req -> capturedAuth = req.headers.getFirst(HttpHeaders.AUTHORIZATION) }) - .andRespond(withSuccess("""{"id":777}""", MediaType.APPLICATION_JSON)) - server - .expect(requestTo("https://api.github.com/app/installations/777/access_tokens")) - .andExpect(method(HttpMethod.POST)) - .andExpect(jsonPath("$.repositories[0]").value("personal-stack")) - .andExpect(jsonPath("$.permissions.contents").value("write")) - .andExpect(jsonPath("$.permissions.pull_requests").value("write")) - .andExpect(jsonPath("$.permissions.actions").value("write")) - .andExpect(jsonPath("$.permissions.issues").value("write")) - .andExpect(jsonPath("$.permissions.workflows").value("write")) - .andExpect(jsonPath("$.permissions.packages").value("read")) - .andRespond( - withSuccess( - """{"token":"ghs_abc","expires_at":"2026-06-02T15:00:00Z",""" + - """"permissions":{"contents":"write","pull_requests":"write","actions":"write",""" + - """"issues":"write","workflows":"write","packages":"read"}}""", - MediaType.APPLICATION_JSON, - ), - ) - - val minted = client.mint("git@github.com:ExtraToast/personal-stack.git") - - assertThat(minted).isNotNull - assertThat(minted!!.token).isEqualTo("ghs_abc") - assertThat(minted.expiresAt).isEqualTo(Instant.parse("2026-06-02T15:00:00Z")) - server.verify() - - // The Authorization header must be a Bearer RS256 JWT signed by - // the App key, asserting the App id as issuer. - assertThat(capturedAuth).startsWith("Bearer ") - val jwt = capturedAuth!!.removePrefix("Bearer ") - val parts = jwt.split(".") - assertThat(parts).hasSize(3) - val verifier = - Signature.getInstance("SHA256withRSA").apply { - initVerify(keyPair.public as RSAPublicKey) - update("${parts[0]}.${parts[1]}".toByteArray()) - } - assertThat(verifier.verify(Base64.getUrlDecoder().decode(parts[2]))).isTrue() - val payload = String(Base64.getUrlDecoder().decode(parts[1])) - assertThat(payload).contains("\"iss\":\"123456\"") - } - - @Test - fun `narrowedPermissions flags absent and weaker grants, and passes a full grant`() { - val requested = GitHubAppInstallationTokenClient.REQUESTED_PERMISSIONS - - // A metadata-only install grants none of the requested permissions. - assertThat(GitHubAppInstallationTokenClient.narrowedPermissions(requested, emptyMap())) - .containsExactly("actions", "contents", "issues", "packages", "pull_requests", "workflows") - - // contents granted read-only is weaker than the requested write. - assertThat( - GitHubAppInstallationTokenClient.narrowedPermissions( - requested, - mapOf( - "contents" to "read", - "pull_requests" to "write", - "actions" to "write", - "issues" to "write", - "workflows" to "write", - "packages" to "read", - ), - ), - ).containsExactly("contents") - - // Exactly the requested set — nothing narrowed. - assertThat( - GitHubAppInstallationTokenClient.narrowedPermissions( - requested, - mapOf( - "contents" to "write", - "pull_requests" to "write", - "actions" to "write", - "issues" to "write", - "workflows" to "write", - "packages" to "read", - ), - ), - ).isEmpty() - } - - @Test - fun `mint still returns the token when the App grant is narrower than requested`() { - val builder = RestClient.builder() - val server = MockRestServiceServer.bindTo(builder).build() - val client = GitHubAppInstallationTokenClient(builder.build(), props()) - - server - .expect(requestTo("https://api.github.com/repos/ExtraToast/personal-stack/installation")) - .andRespond(withSuccess("""{"id":777}""", MediaType.APPLICATION_JSON)) - server - .expect(requestTo("https://api.github.com/app/installations/777/access_tokens")) - .andRespond( - withSuccess( - // GitHub narrowed the token to metadata-only — no write perms. - """{"token":"ghs_narrow","expires_at":"2026-06-02T15:00:00Z","permissions":{"metadata":"read"}}""", - MediaType.APPLICATION_JSON, - ), - ) - - val minted = client.mint("git@github.com:ExtraToast/personal-stack.git") - - assertThat(minted).isNotNull - assertThat(minted!!.token).isEqualTo("ghs_narrow") - server.verify() - } - - @Test - fun `mint returns null when the App is not installed on the owner`() { - val builder = RestClient.builder() - val server = MockRestServiceServer.bindTo(builder).build() - val client = GitHubAppInstallationTokenClient(builder.build(), props()) - - server - .expect(requestTo("https://api.github.com/repos/ExtraToast/personal-stack/installation")) - .andRespond(withStatus(org.springframework.http.HttpStatus.NOT_FOUND)) - - assertThat(client.mint("git@github.com:ExtraToast/personal-stack.git")).isNull() - } - - @Test - fun `pkcs8Der accepts a PKCS#1 key (the format GitHub issues) and produces a signable key`() { - val der = GitHubAppInstallationTokenClient.pkcs8Der(PKCS1_FIXTURE) - val key = - java.security.KeyFactory - .getInstance("RSA") - .generatePrivate(java.security.spec.PKCS8EncodedKeySpec(der)) - // Signing must succeed — proves the wrapped DER is a valid key. - val signed = - Signature - .getInstance("SHA256withRSA") - .apply { - initSign(key) - update("payload".toByteArray()) - }.sign() - assertThat(signed).isNotEmpty() - } - - @Test - fun `pkcs8Der passes a PKCS#8 key through unchanged`() { - val der = GitHubAppInstallationTokenClient.pkcs8Der(pkcs8Pem()) - assertThat(der).isEqualTo(keyPair.private.encoded) - } - - companion object { - // Throwaway 2048-bit RSA key in PKCS#1 (`BEGIN RSA PRIVATE KEY`), - // the format GitHub hands out. Used only to prove PKCS#1 parsing. - private val PKCS1_FIXTURE = - """ - -----BEGIN RSA PRIVATE KEY----- - MIIEowIBAAKCAQEAtXLq8eqG5p1rpehLCAx6Af3t1rh7PGQqFp/zP+fpvTfl38Mo - x/ydIzXP4hGFMDxbf+BO852G+l+cJeB6CR9lAejHDpX6SEsxBkIBX5yufQtJ0KGQ - 2/ugYRRFrzGo2A7L/7o1eN2LflaELHJqvbAVb+mo8pscoKo7lbywKjWm13fIctqb - umLPW8A4reYB8OsMgRRgZiwFpBqSqkdy2xdKvUnSo2aOmY9suRt/CUSioFLoP9Lb - uwCCNlP93SuFhqvXJ5GTPXESt81iRjQs+25n2fI3Mzmd1+YyjKRwnQYYNO/sqgiC - Mof4YG17hOS6uWcs1bAQWO3NvNh0dv+ebfLcGQIDAQABAoIBAAQp/vpOR4pDUpUc - H5yvrJ0fFrY2xZ09LzoVsZ9l0xdkkQHxmJ3+Thzgv0SQ4l2ZBQCKRUWR9+cHCq5T - 2HkdH1RL40WSa4v9LcLXAPEQx3BXMfp3urtRqvyPWooKubU7obLcsx1y+CCOG7pp - Zcm0oMlQs2/d32pQfc2R5vkRAiMvspp+SM6TXlxDzinErTAELJHGJDEjLsDlJSLy - 8hj64JPHXKi4KRNo0AkBp25CQ4UX++Y9Rw8Zq4LANOpCPIzVkVsqXAet1IoPfh3V - bLMimR/5GTMJN3gRZKozivZESD7gK8BjLbRK4zcE8sPt+BB8sU88kLH20O/j5ODT - Oaiq0YECgYEA7JmXd7sxVYWLhrdBH12562/p/7zDvmfBAbyRwodENdiGizY/BeH6 - L0h+1MbKYSCh8ASUtEDEtCesMYRx9JAPWf195Xu/XzRdm+2GC1106ovYROLP4pYo - 0n7ULN3wDWuy3Ib0kWj3B8U2TGm4duX97dxfNcsgbu6ypBLeZqMyUp8CgYEAxFOq - EXesjPaEn1AId5A8mpuZBYzrBS4RuyAUJiCYQ2uU6BfolxaUp1fQVdlWjIB04r8g - qBgRJdC/AQiHYtLYtjslhI7+majgxBP+Cl90reOCmRmIn83ZKlXyyj82+eh8nai0 - 8j01yidxbvTrlcpuYxIwLzrSUsUnucz2lRY+zkcCgYEAsOB57daRoR+/GS0ykCJf - dXUq+DbEFzo1ffjc9xJsmdyPaM9a+ijgAi0uNB+Q+F+O8IJcMQ0igJQQFMyw7GYu - M9ZgIgkLHj9lo8ZEKYbqetWlDoqJYxli10pdkFUyurXC9z4k4/gWhUaXuzRl5O03 - knTm8K40RvpHroU0ooJqgn0CgYB6xXMJv1PZRuPCmJLi6gDsEjeMAAaMU7Xk1fej - rChrqOASj7j0mrtVNpXiyanU7ROrJChw1bQLeNGo/MNlKkM5Gh2pGp7eSnxcQcBQ - jkbx4t8tjIkineCbF+pfTU680wTytqiI/3wesbG+2ExmfJOxQpN9RYR3HDFugF0G - +EVISwKBgBfOSd6gTi/sdl3x0tClz8WNdP468HPAIqakYBxIiEbQtsBSAAk9J6T3 - kcX30xuRUQq4xu98uWblvYIlTC25Lul1fw+qkEutGL/62kXfC7ct4vLMmekXrQV+ - AmUYMa7rOGRyEbgyNUvBvvGXap5WRgbpWBBIk17Dh/XYFQeCMqiN - -----END RSA PRIVATE KEY----- - """.trimIndent() - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubBranchProtectionClientTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubBranchProtectionClientTest.kt deleted file mode 100644 index ea83fa0e..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubBranchProtectionClientTest.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.jorisjonkers.personalstack.assistant.config.AgentRuntimeProperties -import io.mockk.mockk -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.CsvSource -import org.springframework.web.client.RestClient - -class GitHubBranchProtectionClientTest { - private fun props( - token: String = "", - base: String = "https://api.github.com", - ) = AgentRuntimeProperties( - namespace = "agents-system", - image = "img", - serviceAccount = "sa", - claudeCredentialsPvc = "c", - codexCredentialsPvc = "x", - githubDeployKeySecret = "k", - githubApiToken = token, - githubApiBaseUrl = base, - ) - - @ParameterizedTest - @CsvSource( - // ssh scp-like, with and without .git - "git@github.com:ExtraToast/personal-stack.git, ExtraToast, personal-stack", - "git@github.com:ExtraToast/personal-stack, ExtraToast, personal-stack", - // ssh url form - "ssh://git@github.com/ExtraToast/personal-stack.git, ExtraToast, personal-stack", - // https with and without .git, trailing slash - "https://github.com/ExtraToast/personal-stack.git, ExtraToast, personal-stack", - "https://github.com/ExtraToast/personal-stack, ExtraToast, personal-stack", - "https://github.com/ExtraToast/personal-stack/, ExtraToast, personal-stack", - // https with embedded credentials - "https://x-access-token:tok@github.com/ExtraToast/repo.git, ExtraToast, repo", - ) - fun `parses owner and repo from ssh and https URLs`( - url: String, - owner: String, - repo: String, - ) { - val parsed = GitHubBranchProtectionClient.parseOwnerRepo(url) - assertThat(parsed).isNotNull - assertThat(parsed!!.owner).isEqualTo(owner) - assertThat(parsed.repo).isEqualTo(repo) - } - - @ParameterizedTest - @CsvSource( - "not-a-url", - "git@github.com:onlyowner", - "https://github.com/onlyowner", - "''", - ) - fun `returns null for unparseable repo URLs`(url: String) { - assertThat(GitHubBranchProtectionClient.parseOwnerRepo(url)).isNull() - } - - @Test - fun `returns null when no token is configured — never hits the API`() { - val restClient = mockk() // unused: must short-circuit before any call - val client = GitHubBranchProtectionClient(restClient, props(token = "")) - assertThat(client.isBranchProtected("git@github.com:ExtraToast/personal-stack.git", "main")).isNull() - } - - @Test - fun `returns null when the repo URL cannot be parsed even with a token`() { - val restClient = mockk() - val client = GitHubBranchProtectionClient(restClient, props(token = "tok")) - assertThat(client.isBranchProtected("not-a-url", "main")).isNull() - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClientTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClientTest.kt deleted file mode 100644 index 03ac70df..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/HttpAgentGatewayClientTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.jorisjonkers.personalstack.assistant.config.AgentRuntimeProperties -import io.mockk.mockk -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.web.client.RestClient - -class HttpAgentGatewayClientTest { - private fun props(verifyBase: String = "") = - AgentRuntimeProperties( - namespace = "agents-system", - image = "img", - serviceAccount = "sa", - claudeCredentialsPvc = "c", - codexCredentialsPvc = "x", - githubDeployKeySecret = "k", - verifyGatewayBaseUrl = verifyBase, - ) - - @Test - fun `verifyAccess returns null and never touches the gateway when no base URL is configured`() { - // A strict (non-relaxed) mock fails the test if any RestClient - // method is invoked, proving the empty-base short-circuit. - val restClient = mockk() - val client = HttpAgentGatewayClient(restClient, props(verifyBase = "")) - - assertThat(client.verifyAccess("git@github.com:o/r.git", "main")).isNull() - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClientTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClientTest.kt deleted file mode 100644 index f4494353..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/KnowledgeMcpClientTest.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.integration - -import com.jorisjonkers.personalstack.assistant.config.RagProperties -import com.jorisjonkers.personalstack.assistant.domain.port.KnowledgeWritePort -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.MediaType -import org.springframework.test.web.client.MockRestServiceServer -import org.springframework.test.web.client.match.MockRestRequestMatchers.header -import org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath -import org.springframework.test.web.client.match.MockRestRequestMatchers.method -import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo -import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess -import org.springframework.web.client.RestClient - -class KnowledgeMcpClientTest { - private fun props(enabled: Boolean = true) = - RagProperties( - enabled = enabled, - knowledgeMcpUrl = "http://kb", - knowledgeMcpToken = "token-123", - lightragUrl = "http://lightrag", - recallMode = "hybrid", - ) - - @Test - fun `retrieve calls canonical dot-form recall tool and parses structured hits`() { - val builder = RestClient.builder() - val server = MockRestServiceServer.bindTo(builder).build() - val client = KnowledgeMcpClient(builder.build(), props()) - - server - .expect(requestTo("http://kb/mcp")) - .andExpect(method(HttpMethod.POST)) - .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123")) - .andExpect(jsonPath("$.method").value("tools/call")) - .andExpect(jsonPath("$.params.name").value("knowledge.recall")) - .andExpect(jsonPath("$.params.arguments.query").value("agent hooks")) - .andExpect(jsonPath("$.params.arguments.limit").value(2)) - .andExpect(jsonPath("$.params.arguments.mode").value("hybrid")) - .andRespond( - withSuccess( - """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "structuredContent": { - "hits": [ - { - "id": "01KTEST0000000000000000000", - "scope": "project:personal-stack", - "title": "Agent hooks use canonical MCP names", - "snippet": "Use knowledge.recall rather than legacy underscore aliases.", - "score": 0.91 - } - ] - } - } - } - """.trimIndent(), - MediaType.APPLICATION_JSON, - ), - ) - - val snippets = client.retrieve("agent hooks", limit = 2) - - assertThat(snippets).singleElement().satisfies({ snippet -> - assertThat(snippet.id).isEqualTo("01KTEST0000000000000000000") - assertThat(snippet.source).isEqualTo("kb:project:personal-stack:Agent hooks use canonical MCP names") - assertThat(snippet.text).isEqualTo("Use knowledge.recall rather than legacy underscore aliases.") - assertThat(snippet.score).isEqualTo(0.91) - }) - server.verify() - } - - @Test - fun `ingestNote calls canonical dot-form capture tool`() { - val builder = RestClient.builder() - val server = MockRestServiceServer.bindTo(builder).build() - val client = KnowledgeMcpClient(builder.build(), props()) - - server - .expect(requestTo("http://kb/mcp")) - .andExpect(method(HttpMethod.POST)) - .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123")) - .andExpect(jsonPath("$.method").value("tools/call")) - .andExpect(jsonPath("$.params.name").value("knowledge.capture_lesson")) - .andExpect(jsonPath("$.params.arguments.title").value("Canonical MCP names")) - .andExpect(jsonPath("$.params.arguments.body").value("Assistant API uses dot-form tool names.")) - .andExpect(jsonPath("$.params.arguments.scope").value("project:personal-stack")) - .andExpect(jsonPath("$.params.arguments.source").value("assistant-ui:auto-capture:session-1")) - .andExpect(jsonPath("$.params.arguments.session_id").value("session-1")) - .andExpect(jsonPath("$.params.arguments.confidence").value(0.74)) - .andExpect(jsonPath("$.params.arguments.tags[0]").value("mcp")) - .andRespond( - withSuccess( - """{"jsonrpc":"2.0","id":1,"result":{"structuredContent":{"id":"01KCAPTURE"}}}""", - MediaType.APPLICATION_JSON, - ), - ) - - client.ingestNote( - KnowledgeWritePort.CaptureRequest( - title = "Canonical MCP names", - body = "Assistant API uses dot-form tool names.", - scope = "project:personal-stack", - tags = listOf("mcp"), - source = "assistant-ui:auto-capture:session-1", - sessionId = "session-1", - confidence = 0.74, - ), - ) - - server.verify() - } - - @Test - fun `findDuplicateEvidence returns the top recall hit when it clears the threshold`() { - val builder = RestClient.builder() - val server = MockRestServiceServer.bindTo(builder).build() - val client = KnowledgeMcpClient(builder.build(), props()) - - server - .expect(requestTo("http://kb/mcp")) - .andExpect(method(HttpMethod.POST)) - .andExpect(jsonPath("$.params.name").value("knowledge.recall")) - .andExpect(jsonPath("$.params.arguments.query").value("duplicate query")) - .andExpect(jsonPath("$.params.arguments.limit").value(1)) - .andRespond( - withSuccess( - """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "structuredContent": { - "hits": [ - { - "id": "01KDUPLICATE", - "scope": "project:personal-stack", - "title": "Existing capture", - "snippet": "Existing body.", - "score": 0.9 - } - ] - } - } - } - """.trimIndent(), - MediaType.APPLICATION_JSON, - ), - ) - - val duplicate = client.findDuplicateEvidence("duplicate query", minScore = 0.86) - - assertThat(duplicate).isNotNull - assertThat(duplicate!!.id).isEqualTo("01KDUPLICATE") - assertThat(duplicate.source).isEqualTo("kb:project:personal-stack:Existing capture") - assertThat(duplicate.score).isEqualTo(0.9) - server.verify() - } - - @Test - fun `disabled client does not call the MCP endpoint`() { - val builder = RestClient.builder() - val server = MockRestServiceServer.bindTo(builder).build() - val client = KnowledgeMcpClient(builder.build(), props(enabled = false)) - - assertThat(client.retrieve("ignored", limit = 1)).isEmpty() - client.ingestNote("ignored", "ignored", "project:personal-stack") - - server.verify() - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentRunnerUnavailableExceptionHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentRunnerUnavailableExceptionHandlerTest.kt deleted file mode 100644 index b60dc3f1..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentRunnerUnavailableExceptionHandlerTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.exception.AgentRunnerUnavailableException -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import io.mockk.every -import io.mockk.mockk -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatus -import org.springframework.web.context.request.WebRequest - -class AgentRunnerUnavailableExceptionHandlerTest { - private val handler = AgentRunnerUnavailableExceptionHandler() - - private fun webRequest(path: String = "/api/v1/workspaces/abc/sessions"): WebRequest = - mockk().also { - every { it.getDescription(false) } returns "uri=$path" - } - - @Test - fun `503 with structured runnerStatus + retryAfterSeconds + Retry-After header`() { - val workspaceId = WorkspaceId.random() - val ex = - AgentRunnerUnavailableException( - workspaceId = workspaceId, - runnerStatus = "ConnectionRefused", - retryAfterSeconds = 5, - ) - - val response = handler.handle(ex, webRequest("/api/v1/workspaces/${workspaceId.value}/sessions")) - - assertThat(response.statusCode).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE) - assertThat(response.headers.getFirst(HttpHeaders.RETRY_AFTER)).isEqualTo("5") - val body = response.body!! - assertThat(body.status).isEqualTo(503) - assertThat(body.title).isEqualTo("Agent runner not ready") - assertThat(body.runnerStatus).isEqualTo("ConnectionRefused") - assertThat(body.retryAfterSeconds).isEqualTo(5) - assertThat(body.detail).contains(workspaceId.value.toString()) - assertThat(body.detail).contains("ConnectionRefused") - assertThat(body.detail).contains("Retry in 5s") - } - - @Test - fun `503 carries the default retry-after when not explicitly set`() { - val ex = - AgentRunnerUnavailableException( - workspaceId = WorkspaceId.random(), - runnerStatus = "NotReady", - ) - - val response = handler.handle(ex, webRequest()) - - assertThat(response.headers.getFirst(HttpHeaders.RETRY_AFTER)) - .isEqualTo(AgentRunnerUnavailableException.DEFAULT_RETRY_AFTER_SECONDS.toString()) - assertThat(response.body!!.retryAfterSeconds) - .isEqualTo(AgentRunnerUnavailableException.DEFAULT_RETRY_AFTER_SECONDS) - } - - @Test - fun `instance URI carries the request path`() { - val workspaceId = WorkspaceId.random() - val ex = AgentRunnerUnavailableException(workspaceId, "NotReady") - - val response = handler.handle(ex, webRequest("/api/v1/workspaces/$workspaceId/sessions")) - - assertThat(response.body!!.instance.toString()).isEqualTo("/api/v1/workspaces/$workspaceId/sessions") - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionControllerTest.kt deleted file mode 100644 index 2249d6ac..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/AgentSessionControllerTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.application.query.GetTurnHistoryQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import com.jorisjonkers.personalstack.common.command.CommandBus -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import java.time.Instant - -class AgentSessionControllerTest { - private val commandBus = mockk(relaxed = true) - private val turnHistory = mockk(relaxed = true) - private val sessions = mockk() - private val workspaces = mockk() - private val gateway = mockk() - private val mockMvc: MockMvc = - MockMvcBuilders - .standaloneSetup(AgentSessionController(commandBus, turnHistory, sessions, workspaces, gateway)) - .build() - - private val workspaceId = WorkspaceId.random() - private val sessionId = WorkspaceAgentSessionId.random() - - @Test - fun `POST staged-inputs forwards to gateway and returns file metadata`() { - val workspace = workspace() - every { sessions.findById(sessionId) } returns agentSession() - every { workspaces.findById(workspaceId) } returns workspace - every { - gateway.stageInput(workspace, "abc12345", "large document", "source.txt") - } returns - AgentGatewayClient.StagedInput( - path = "/workspace/.agent-inputs/20260604-source.txt", - bytes = 14, - name = "source.txt", - ) - - mockMvc - .perform( - post("/api/v1/workspaces/${workspaceId.value}/sessions/${sessionId.value}/staged-inputs") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"content":"large document","name":"source.txt"}"""), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.path").value("/workspace/.agent-inputs/20260604-source.txt")) - .andExpect(jsonPath("$.bytes").value(14)) - .andExpect(jsonPath("$.name").value("source.txt")) - - verify { gateway.stageInput(workspace, "abc12345", "large document", "source.txt") } - } - - private fun agentSession() = - WorkspaceAgentSession( - id = sessionId, - workspaceId = workspaceId, - kind = WorkspaceAgentKind.CLAUDE, - gatewayAgentId = "abc12345", - status = WorkspaceAgentSessionStatus.RUNNING, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private fun workspace() = - Workspace( - id = workspaceId, - name = "demo", - repoUrl = null, - branch = null, - podName = null, - pvcName = null, - gatewayEndpoint = "http://gw:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ChatSessionControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ChatSessionControllerTest.kt deleted file mode 100644 index dd9b1b04..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ChatSessionControllerTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.application.chat.ChatAnswerStreamService -import com.jorisjonkers.personalstack.assistant.application.query.ChatSessionQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSession -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionKind -import com.jorisjonkers.personalstack.assistant.domain.model.ChatSessionStatus -import com.jorisjonkers.personalstack.common.command.CommandBus -import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.request -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter -import java.time.Instant -import java.util.UUID - -class ChatSessionControllerTest { - private val commandBus = mockk(relaxed = true) - private val query = mockk() - private val chatAnswerStream = mockk() - private val objectMapper = ObjectMapper() - private lateinit var mockMvc: MockMvc - - @BeforeEach - fun setUp() { - val controller = ChatSessionController(commandBus, query, chatAnswerStream) - mockMvc = - MockMvcBuilders - .standaloneSetup(controller) - .setControllerAdvice(GlobalExceptionHandler()) - .build() - } - - private fun session( - id: ChatSessionId = ChatSessionId.random(), - userId: UUID = UUID.randomUUID(), - ): ChatSession { - val now = Instant.now() - return ChatSession(id, userId, "x", ChatSessionStatus.ACTIVE, ChatSessionKind.PLAIN, now, now) - } - - @Test - fun `POST creates session and returns 201`() { - val s = session() - every { query.get(any()) } returns ChatSessionQueryService.ChatSessionDetail(s, emptyList()) - mockMvc - .perform( - post("/api/v1/chat-sessions") - .header("X-User-Id", s.userId.toString()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "x", "kind" to "PLAIN"))), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.status").value("ACTIVE")) - .andExpect(jsonPath("$.kind").value("PLAIN")) - verify { commandBus.dispatch(any()) } - } - - @Test - fun `GET list returns sessions for user`() { - val uid = UUID.randomUUID() - every { query.list(uid) } returns listOf(session(userId = uid)) - mockMvc - .perform(get("/api/v1/chat-sessions").header("X-User-Id", uid.toString())) - .andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(1)) - } - - @Test - fun `GET by id returns envelope`() { - val s = session() - every { query.get(s.id) } returns ChatSessionQueryService.ChatSessionDetail(s, emptyList()) - mockMvc - .perform(get("/api/v1/chat-sessions/${s.id.value}")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.session.id").value(s.id.value.toString())) - .andExpect(jsonPath("$.messages").isArray) - } - - @Test - fun `GET by id with unknown returns 404`() { - every { query.get(any()) } returns null - mockMvc - .perform(get("/api/v1/chat-sessions/${UUID.randomUUID()}")) - .andExpect(status().isNotFound) - } - - @Test - fun `POST messages dispatches the append command`() { - val s = session() - // The controller looks up the just-appended message by id after - // dispatch. Return a detail with no matching message so the - // controller takes the error path; the test asserts the - // dispatch happened either way. - every { query.get(any()) } returns - ChatSessionQueryService.ChatSessionDetail(s, emptyList()) - try { - mockMvc - .perform( - post("/api/v1/chat-sessions/${s.id.value}/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), - ) - } catch (_: Throwable) { - // The controller's error("message not visible…") surfaces - // as an unhandled exception in MockMvc; verify the - // dispatch happened regardless. - } - verify { commandBus.dispatch(any()) } - } - - @Test - fun `POST stream messages returns SSE response headers`() { - val s = session() - every { chatAnswerStream.stream(s.id, "hello") } returns SseEmitter() - - mockMvc - .perform( - post("/api/v1/chat-sessions/${s.id.value}/messages/stream") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), - ).andExpect(status().isOk) - .andExpect(request().asyncStarted()) - .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)) - .andExpect(header().string("X-Accel-Buffering", "no")) - .andExpect(header().string("Cache-Control", "no-cache")) - } - - @Test - fun `POST stream messages with blank body returns validation error`() { - mockMvc - .perform( - post("/api/v1/chat-sessions/${UUID.randomUUID()}/messages/stream") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("body" to "", "role" to "USER"))), - ).andExpect(status().isUnprocessableContent) - } - - @Test - fun `POST stream messages with empty request body returns 400`() { - mockMvc - .perform( - post("/api/v1/chat-sessions/${UUID.randomUUID()}/messages/stream") - .contentType(MediaType.APPLICATION_JSON), - ).andExpect(status().isBadRequest) - } - - @Test - fun `POST messages with unknown session returns 404`() { - every { query.get(any()) } returns null - mockMvc - .perform( - post("/api/v1/chat-sessions/${UUID.randomUUID()}/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("body" to "hello", "role" to "USER"))), - ).andExpect(status().isNotFound) - } - - @Test - fun `DELETE archives session and returns 204`() { - mockMvc - .perform( - delete("/api/v1/chat-sessions/${UUID.randomUUID()}") - .header("X-User-Id", UUID.randomUUID().toString()), - ).andExpect(status().isNoContent) - verify { commandBus.dispatch(any()) } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ConversationControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ConversationControllerTest.kt deleted file mode 100644 index b3d0b123..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ConversationControllerTest.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.application.query.GetConversationQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.Conversation -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationStatus -import com.jorisjonkers.personalstack.common.command.CommandBus -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import java.time.Instant -import java.util.UUID - -class ConversationControllerTest { - private val commandBus = mockk(relaxed = true) - private val getConversationQueryService = mockk() - private val objectMapper = ObjectMapper() - private lateinit var mockMvc: MockMvc - - @BeforeEach - fun setUp() { - val controller = ConversationController(commandBus, getConversationQueryService) - mockMvc = - MockMvcBuilders - .standaloneSetup(controller) - .setControllerAdvice(GlobalExceptionHandler()) - .build() - } - - @Test - fun `POST creates conversation and returns 201`() { - val userId = UUID.randomUUID() - every { getConversationQueryService.findById(any()) } returns - buildConversation(userId = userId, title = "My Chat") - - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId.toString()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to "My Chat"))), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.title").value("My Chat")) - .andExpect(jsonPath("$.userId").value(userId.toString())) - .andExpect(jsonPath("$.status").value("ACTIVE")) - - verify { commandBus.dispatch(any()) } - } - - @Test - fun `POST with blank title returns 422`() { - val userId = UUID.randomUUID() - - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId.toString()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to ""))), - ).andExpect(status().isUnprocessableContent) - .andExpect(jsonPath("$.title").value("Validation Error")) - } - - @Test - fun `POST with title exceeding 200 chars returns 422`() { - val userId = UUID.randomUUID() - val longTitle = "a".repeat(201) - - mockMvc - .perform( - post("/api/v1/conversations") - .header("X-User-Id", userId.toString()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("title" to longTitle))), - ).andExpect(status().isUnprocessableContent) - .andExpect(jsonPath("$.title").value("Validation Error")) - } - - @Test - fun `GET by id returns conversation`() { - val conversationId = ConversationId(UUID.randomUUID()) - val conversation = buildConversation(id = conversationId, title = "Found It") - every { getConversationQueryService.findById(conversationId) } returns conversation - - mockMvc - .perform(get("/api/v1/conversations/${conversationId.value}")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.id").value(conversationId.value.toString())) - .andExpect(jsonPath("$.title").value("Found It")) - } - - @Test - fun `GET by id with non-existent returns 404`() { - val conversationId = ConversationId(UUID.randomUUID()) - every { getConversationQueryService.findById(conversationId) } throws - NotFoundException("Conversation", conversationId.value.toString()) - - mockMvc - .perform(get("/api/v1/conversations/${conversationId.value}")) - .andExpect(status().isNotFound) - .andExpect(jsonPath("$.title").value("Resource Not Found")) - } - - @Test - fun `DELETE archives conversation and returns 204`() { - val userId = UUID.randomUUID() - val conversationId = UUID.randomUUID() - - mockMvc - .perform( - delete("/api/v1/conversations/$conversationId") - .header("X-User-Id", userId.toString()), - ).andExpect(status().isNoContent) - - verify { commandBus.dispatch(any()) } - } - - @Test - fun `GET list returns user conversations`() { - val userId = UUID.randomUUID() - val conversations = - listOf( - buildConversation(userId = userId, title = "First"), - buildConversation(userId = userId, title = "Second"), - ) - every { getConversationQueryService.findByUserId(userId) } returns conversations - - mockMvc - .perform( - get("/api/v1/conversations") - .header("X-User-Id", userId.toString()), - ).andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].title").value("First")) - .andExpect(jsonPath("$[1].title").value("Second")) - } - - @Test - fun `GET list without X-User-Id header returns error`() { - mockMvc - .perform(get("/api/v1/conversations")) - .andExpect(status().isInternalServerError) - } - - private fun buildConversation( - id: ConversationId = ConversationId(UUID.randomUUID()), - userId: UUID = UUID.randomUUID(), - title: String = "Test", - ): Conversation { - val now = Instant.now() - return Conversation( - id = id, - userId = userId, - title = title, - status = ConversationStatus.ACTIVE, - createdAt = now, - updatedAt = now, - ) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/HealthControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/HealthControllerTest.kt deleted file mode 100644 index 0c13cae6..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/HealthControllerTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import org.junit.jupiter.api.Test -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get -import org.springframework.test.web.servlet.setup.MockMvcBuilders - -class HealthControllerTest { - private val mockMvc: MockMvc = - MockMvcBuilders - .standaloneSetup(HealthController()) - .build() - - @Test - fun `health returns ok with service name`() { - mockMvc - .get("/api/v1/health") - .andExpect { - status { isOk() } - jsonPath("$.status") { value("ok") } - jsonPath("$.service") { value("assistant-api") } - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/InternalGitHubTokenControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/InternalGitHubTokenControllerTest.kt deleted file mode 100644 index 3d2ea60c..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/InternalGitHubTokenControllerTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.assistant.infrastructure.integration.GitHubAppInstallationTokenClient -import io.mockk.every -import io.mockk.mockk -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.http.HttpStatus -import java.time.Instant - -class InternalGitHubTokenControllerTest { - private val tokens = mockk() - private val controller = InternalGitHubTokenController(tokens) - - private val request = - InternalGitHubTokenController.InstallationTokenRequest(repoUrl = "git@github.com:ExtraToast/personal-stack.git") - - @Test - fun `503 when minting is disabled`() { - every { tokens.enabled } returns false - val resp = controller.installationToken(request) - assertThat(resp.statusCode).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE) - } - - @Test - fun `400 when repoUrl is missing or blank`() { - every { tokens.enabled } returns true - val nullRepo = InternalGitHubTokenController.InstallationTokenRequest(null) - val blankRepo = InternalGitHubTokenController.InstallationTokenRequest(" ") - assertThat(controller.installationToken(nullRepo).statusCode).isEqualTo(HttpStatus.BAD_REQUEST) - assertThat(controller.installationToken(blankRepo).statusCode).isEqualTo(HttpStatus.BAD_REQUEST) - } - - @Test - fun `502 when the App is configured but GitHub minting fails`() { - every { tokens.enabled } returns true - every { tokens.mint(any()) } returns null - val resp = controller.installationToken(request) - assertThat(resp.statusCode).isEqualTo(HttpStatus.BAD_GATEWAY) - } - - @Test - fun `200 with the token and expiry on success`() { - val expiry = Instant.parse("2026-06-02T15:00:00Z") - every { tokens.enabled } returns true - every { tokens.mint(any()) } returns GitHubAppInstallationTokenClient.InstallationToken("ghs_abc", expiry) - val resp = controller.installationToken(request) - assertThat(resp.statusCode).isEqualTo(HttpStatus.OK) - assertThat(resp.body?.token).isEqualTo("ghs_abc") - assertThat(resp.body?.expiresAt).isEqualTo(expiry) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/KubernetesExceptionHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/KubernetesExceptionHandlerTest.kt deleted file mode 100644 index 909cc82a..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/KubernetesExceptionHandlerTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler -import io.fabric8.kubernetes.api.model.Status -import io.fabric8.kubernetes.api.model.StatusCauseBuilder -import io.fabric8.kubernetes.api.model.StatusDetailsBuilder -import io.fabric8.kubernetes.client.KubernetesClientException -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.slf4j.MDC -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -class KubernetesExceptionHandlerTest { - private var throwSupplier: () -> Nothing = { throw KubernetesClientException("boom") } - private lateinit var mockMvc: MockMvc - - @RestController - @RequestMapping("/k8s-test") - inner class TestController { - @GetMapping("/throw") - fun throwIt(): Nothing = throwSupplier() - } - - @BeforeEach - fun setUp() { - mockMvc = - MockMvcBuilders - .standaloneSetup(TestController()) - // Both advices wired so we can verify the Kubernetes - // handler wins by `@Order(HIGHEST_PRECEDENCE)`. - .setControllerAdvice(KubernetesExceptionHandler(), GlobalExceptionHandler()) - .build() - } - - @Test - fun `forbidden status returns 502 with kubernetesCode and kubernetesReason`() { - val status = - Status().apply { - code = 403 - reason = "Forbidden" - message = - "pods is forbidden: User \"system:serviceaccount:agent-runtime:assistant-api\" " + - "cannot create resource \"pods\" in API group \"\" in the namespace \"agent-runtime\"" - } - throwSupplier = { throw KubernetesClientException(status) } - - mockMvc - .perform(get("/k8s-test/throw")) - .andExpect(status().isBadGateway) - .andExpect(jsonPath("$.status").value(502)) - .andExpect(jsonPath("$.title").value("Kubernetes API Error")) - .andExpect(jsonPath("$.kubernetesCode").value(403)) - .andExpect(jsonPath("$.kubernetesReason").value("Forbidden")) - .andExpect(jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("pods is forbidden"))) - .andExpect(jsonPath("$.exception").value("io.fabric8.kubernetes.client.KubernetesClientException")) - } - - @Test - fun `invalid status surfaces field-level causes`() { - val cause = - StatusCauseBuilder() - .withField("spec.containers[0].image") - .withMessage("must be set") - .withReason("FieldValueRequired") - .build() - val details = StatusDetailsBuilder().withCauses(cause).build() - val status = - Status().apply { - code = 422 - reason = "Invalid" - message = "Pod is invalid: spec.containers[0].image: Required value" - this.details = details - } - throwSupplier = { throw KubernetesClientException(status) } - - mockMvc - .perform(get("/k8s-test/throw")) - .andExpect(status().isBadGateway) - .andExpect(jsonPath("$.kubernetesCode").value(422)) - .andExpect(jsonPath("$.kubernetesReason").value("Invalid")) - .andExpect(jsonPath("$.errors[0].field").value("spec.containers[0].image")) - .andExpect(jsonPath("$.errors[0].message").value("must be set")) - } - - @Test - fun `exception without status falls back to exception message`() { - throwSupplier = { throw KubernetesClientException("connection refused") } - - mockMvc - .perform(get("/k8s-test/throw")) - .andExpect(status().isBadGateway) - .andExpect(jsonPath("$.title").value("Kubernetes API Error")) - .andExpect(jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("connection refused"))) - } - - @Test - fun `traceId from MDC is stamped onto the response`() { - val status = - Status().apply { - code = 500 - reason = "InternalError" - message = "etcd unavailable" - } - throwSupplier = { throw KubernetesClientException(status) } - - MDC.put("traceId", "kube-trace-1") - try { - mockMvc - .perform(get("/k8s-test/throw")) - .andExpect(status().isBadGateway) - .andExpect(jsonPath("$.traceId").value("kube-trace-1")) - } finally { - MDC.clear() - } - } - - @Test - fun `KubernetesClientException is preferred over the generic Exception handler`() { - // If KubernetesExceptionHandler didn't have HIGHEST_PRECEDENCE, - // GlobalExceptionHandler.handleUnexpected would catch this as - // a 500. The 502 below proves the ordering holds. - throwSupplier = { throw KubernetesClientException("anything") } - - mockMvc - .perform(get("/k8s-test/throw")) - .andExpect(status().isBadGateway) - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/MessageControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/MessageControllerTest.kt deleted file mode 100644 index 867fd194..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/MessageControllerTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.application.command.SendMessageCommand -import com.jorisjonkers.personalstack.assistant.application.query.GetMessageQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.ConversationId -import com.jorisjonkers.personalstack.assistant.domain.model.Message -import com.jorisjonkers.personalstack.assistant.domain.model.MessageId -import com.jorisjonkers.personalstack.assistant.domain.model.MessageRole -import com.jorisjonkers.personalstack.common.command.CommandBus -import com.jorisjonkers.personalstack.common.exception.NotFoundException -import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.runs -import io.mockk.slot -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import java.time.Instant -import java.util.UUID - -class MessageControllerTest { - private val commandBus = mockk(relaxed = true) - private val getMessageQueryService = mockk() - private val objectMapper = ObjectMapper() - private lateinit var mockMvc: MockMvc - - @BeforeEach - fun setUp() { - val controller = MessageController(commandBus, getMessageQueryService) - mockMvc = - MockMvcBuilders - .standaloneSetup(controller) - .setControllerAdvice(GlobalExceptionHandler()) - .build() - } - - @Test - fun `POST sends message and returns 201`() { - val conversationId = ConversationId(UUID.randomUUID()) - val userId = UUID.randomUUID() - - val commandSlot = slot() - every { commandBus.dispatch(capture(commandSlot)) } just runs - - every { getMessageQueryService.findByConversationId(conversationId) } answers { - val id = commandSlot.captured.messageId - listOf(buildMessage(id = id, conversationId = conversationId, content = "Hello world")) - } - - mockMvc - .perform( - post("/api/v1/conversations/${conversationId.value}/messages") - .header("X-User-Id", userId.toString()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "Hello world"))), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.content").value("Hello world")) - .andExpect(jsonPath("$.role").value("USER")) - } - - @Test - fun `POST with blank content returns 422`() { - val conversationId = UUID.randomUUID() - val userId = UUID.randomUUID() - - mockMvc - .perform( - post("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId.toString()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to ""))), - ).andExpect(status().isUnprocessableContent) - .andExpect(jsonPath("$.title").value("Validation Error")) - } - - @Test - fun `POST with content exceeding 10000 chars returns 422`() { - val conversationId = UUID.randomUUID() - val userId = UUID.randomUUID() - val longContent = "a".repeat(10001) - - mockMvc - .perform( - post("/api/v1/conversations/$conversationId/messages") - .header("X-User-Id", userId.toString()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to longContent))), - ).andExpect(status().isUnprocessableContent) - .andExpect(jsonPath("$.title").value("Validation Error")) - } - - @Test - fun `POST to non-existent conversation returns 404`() { - val conversationId = ConversationId(UUID.randomUUID()) - val userId = UUID.randomUUID() - - every { commandBus.dispatch(any()) } throws - NotFoundException("Conversation", conversationId.value.toString()) - - mockMvc - .perform( - post("/api/v1/conversations/${conversationId.value}/messages") - .header("X-User-Id", userId.toString()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("content" to "Hello"))), - ).andExpect(status().isNotFound) - .andExpect(jsonPath("$.title").value("Resource Not Found")) - } - - @Test - fun `GET returns messages for conversation`() { - val conversationId = ConversationId(UUID.randomUUID()) - val messages = - listOf( - buildMessage(conversationId = conversationId, content = "Hello", role = MessageRole.USER), - buildMessage(conversationId = conversationId, content = "Hi there", role = MessageRole.ASSISTANT), - ) - - every { getMessageQueryService.findByConversationId(conversationId) } returns messages - - mockMvc - .perform(get("/api/v1/conversations/${conversationId.value}/messages")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].content").value("Hello")) - .andExpect(jsonPath("$[0].role").value("USER")) - .andExpect(jsonPath("$[1].content").value("Hi there")) - .andExpect(jsonPath("$[1].role").value("ASSISTANT")) - } - - @Test - fun `GET returns empty list for conversation with no messages`() { - val conversationId = ConversationId(UUID.randomUUID()) - - every { getMessageQueryService.findByConversationId(conversationId) } returns emptyList() - - mockMvc - .perform(get("/api/v1/conversations/${conversationId.value}/messages")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(0)) - } - - private fun buildMessage( - id: MessageId = MessageId(UUID.randomUUID()), - conversationId: ConversationId, - content: String = "Test message", - role: MessageRole = MessageRole.USER, - ): Message = - Message( - id = id, - conversationId = conversationId, - role = role, - content = content, - createdAt = Instant.now(), - ) -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ProjectControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ProjectControllerTest.kt deleted file mode 100644 index e0068bf3..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/ProjectControllerTest.kt +++ /dev/null @@ -1,156 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.application.query.ProjectQueryService -import com.jorisjonkers.personalstack.assistant.application.query.RepositoryQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.Project -import com.jorisjonkers.personalstack.assistant.domain.model.ProjectId -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.common.command.CommandBus -import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import java.time.Instant -import java.util.UUID - -class ProjectControllerTest { - private val commandBus = mockk(relaxed = true) - private val projectQuery = mockk() - private val repositoryQuery = mockk() - private val objectMapper = ObjectMapper() - private lateinit var mockMvc: MockMvc - - @BeforeEach - fun setUp() { - val controller = ProjectController(commandBus, projectQuery, repositoryQuery) - mockMvc = - MockMvcBuilders - .standaloneSetup(controller) - .setControllerAdvice(GlobalExceptionHandler()) - .build() - } - - private fun project(id: ProjectId = ProjectId.random()) = - Project(id, "Personal Stack", "personal-stack", "", Instant.now(), Instant.now()) - - private fun repository(id: RepositoryId = RepositoryId.random()) = - Repository( - id = id, - name = "n", - repoUrl = "u", - defaultBranch = "main", - vaultKeyPath = "x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `POST creates project and returns 201`() { - val p = project() - every { projectQuery.get(any()) } returns ProjectQueryService.ProjectDetail(p, emptyList()) - mockMvc - .perform( - post("/api/v1/projects") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf("name" to "Personal Stack", "slug" to "personal-stack"), - ), - ), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.slug").value("personal-stack")) - verify { commandBus.dispatch(any()) } - } - - @Test - fun `GET list returns projects`() { - every { projectQuery.list() } returns listOf(project(), project()) - mockMvc - .perform(get("/api/v1/projects")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(2)) - } - - @Test - fun `GET by id returns envelope with links and repositories`() { - val p = project() - val r = repository() - every { projectQuery.get(p.id) } returns ProjectQueryService.ProjectDetail(p, emptyList()) - every { repositoryQuery.listByProject(p.id) } returns listOf(r) - mockMvc - .perform(get("/api/v1/projects/${p.id.value}")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.project.id").value(p.id.value.toString())) - .andExpect(jsonPath("$.repositories[0].id").value(r.id.value.toString())) - } - - @Test - fun `GET by id with unknown returns 404`() { - every { projectQuery.get(any()) } returns null - mockMvc - .perform(get("/api/v1/projects/${UUID.randomUUID()}")) - .andExpect(status().isNotFound) - } - - @Test - fun `POST repositories links and returns the linked list`() { - val p = project() - val r = repository() - every { repositoryQuery.listByProject(p.id) } returns listOf(r) - mockMvc - .perform( - post("/api/v1/projects/${p.id.value}/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("repositoryId" to r.id.value.toString()))), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$[0].id").value(r.id.value.toString())) - verify { commandBus.dispatch(any()) } - } - - @Test - fun `DELETE repositories unlinks and returns 204`() { - mockMvc - .perform( - delete("/api/v1/projects/${UUID.randomUUID()}/repositories/${UUID.randomUUID()}"), - ).andExpect(status().isNoContent) - verify { commandBus.dispatch(any()) } - } - - @Test - fun `POST links dispatches the legacy AddGithubLink command`() { - // The deprecated link-flow path stays wired so existing UI - // callers keep working through the V9 window; verify only the - // dispatch hook (the lookup-by-fresh-id path is hard to - // round-trip in a unit test because the generated link id - // doesn't escape the controller). - every { projectQuery.get(any()) } returns ProjectQueryService.ProjectDetail(project(), emptyList()) - try { - mockMvc.perform( - post("/api/v1/projects/${UUID.randomUUID()}/links") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf("name" to "demo", "repoUrl" to "git@x:o/r.git", "defaultBranch" to "main"), - ), - ), - ) - } catch (_: Throwable) { - // controller's error("link not visible after add") may bubble - } - verify { commandBus.dispatch(any()) } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/RepositoryControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/RepositoryControllerTest.kt deleted file mode 100644 index 8cbc00b2..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/RepositoryControllerTest.kt +++ /dev/null @@ -1,203 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.application.RepositoryVerificationService -import com.jorisjonkers.personalstack.assistant.application.query.RepositoryQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.AccessVerification -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.common.command.CommandBus -import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import java.time.Instant -import java.util.UUID - -class RepositoryControllerTest { - private val commandBus = mockk(relaxed = true) - private val query = mockk() - private val verificationService = mockk() - private val objectMapper = ObjectMapper() - private lateinit var mockMvc: MockMvc - - @BeforeEach - fun setUp() { - val controller = RepositoryController(commandBus, query, verificationService) - mockMvc = - MockMvcBuilders - .standaloneSetup(controller) - .setControllerAdvice(GlobalExceptionHandler()) - .build() - } - - private fun repo( - id: RepositoryId = RepositoryId.random(), - verification: AccessVerification? = null, - ) = Repository( - id = id, - name = "personal-stack", - repoUrl = "git@github.com:o/r.git", - defaultBranch = "main", - vaultKeyPath = "x", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - verification = verification, - ) - - @Test - fun `POST creates repository and returns 201`() { - every { query.get(any()) } returns RepositoryQueryService.RepositoryDetail(repo(), emptyList()) - mockMvc - .perform( - post("/api/v1/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "personal-stack", - "repoUrl" to "git@github.com:o/r.git", - "defaultBranch" to "main", - ), - ), - ), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.name").value("personal-stack")) - verify { commandBus.dispatch(any()) } - } - - @Test - fun `POST with blank name returns 422`() { - mockMvc - .perform( - post("/api/v1/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf("name" to "", "repoUrl" to "x", "defaultBranch" to "main"), - ), - ), - ).andExpect(status().isUnprocessableContent) - } - - @Test - fun `GET list returns array`() { - every { query.list() } returns listOf(repo(), repo()) - mockMvc - .perform(get("/api/v1/repositories")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(2)) - } - - @Test - fun `GET by id returns detail envelope`() { - val r = repo() - every { query.get(r.id) } returns RepositoryQueryService.RepositoryDetail(r, emptyList()) - mockMvc - .perform(get("/api/v1/repositories/${r.id.value}")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.repository.id").value(r.id.value.toString())) - .andExpect(jsonPath("$.attachedProjects").isArray) - } - - @Test - fun `GET by id exposes the stored verification result`() { - val r = - repo( - verification = - AccessVerification( - read = true, - write = false, - defaultBranchProtected = false, - checkedAt = Instant.now(), - messages = listOf("deploy key is read-only"), - ), - ) - every { query.get(r.id) } returns RepositoryQueryService.RepositoryDetail(r, emptyList()) - mockMvc - .perform(get("/api/v1/repositories/${r.id.value}")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.repository.verification.read").value(true)) - .andExpect(jsonPath("$.repository.verification.write").value(false)) - .andExpect(jsonPath("$.repository.verification.defaultBranchProtected").value(false)) - .andExpect(jsonPath("$.repository.verification.messages[0]").value("deploy key is read-only")) - } - - @Test - fun `POST verify re-runs the check and returns the refreshed repository`() { - val r = - repo( - verification = - AccessVerification( - read = true, - write = true, - defaultBranchProtected = true, - checkedAt = Instant.now(), - messages = emptyList(), - ), - ) - every { verificationService.reverify(r.id) } returns r - mockMvc - .perform(post("/api/v1/repositories/${r.id.value}/verify")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.verification.read").value(true)) - .andExpect(jsonPath("$.verification.write").value(true)) - verify { verificationService.reverify(r.id) } - } - - @Test - fun `POST verify on unknown id returns 404`() { - every { verificationService.reverify(any()) } returns null - mockMvc - .perform(post("/api/v1/repositories/${UUID.randomUUID()}/verify")) - .andExpect(status().isNotFound) - } - - @Test - fun `GET by id with unknown id returns 404`() { - every { query.get(any()) } returns null - mockMvc - .perform(get("/api/v1/repositories/${UUID.randomUUID()}")) - .andExpect(status().isNotFound) - } - - @Test - fun `POST attach key returns 202`() { - mockMvc - .perform( - post("/api/v1/repositories/${UUID.randomUUID()}/key") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "privateKeyOpenssh" to - "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----", - "publicKeyOpenssh" to "ssh-ed25519 AAAAxxx me", - "knownHosts" to null, - ), - ), - ), - ).andExpect(status().isAccepted) - verify { commandBus.dispatch(any()) } - } - - @Test - fun `DELETE returns 204`() { - mockMvc - .perform(delete("/api/v1/repositories/${UUID.randomUUID()}")) - .andExpect(status().isNoContent) - verify { commandBus.dispatch(any()) } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt deleted file mode 100644 index 6d474bfa..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt +++ /dev/null @@ -1,295 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.web - -import com.fasterxml.jackson.databind.ObjectMapper -import com.jorisjonkers.personalstack.assistant.application.command.AttachWorkspaceRepositoryCommand -import com.jorisjonkers.personalstack.assistant.application.command.CreateWorkspaceCommand -import com.jorisjonkers.personalstack.assistant.application.command.DetachWorkspaceRepositoryCommand -import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService -import com.jorisjonkers.personalstack.assistant.application.query.ListWorkspacesQueryService -import com.jorisjonkers.personalstack.assistant.domain.model.Repository -import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.common.command.CommandBus -import com.jorisjonkers.personalstack.common.web.GlobalExceptionHandler -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import java.time.Instant -import java.util.UUID - -@Suppress("DEPRECATION") -class WorkspaceControllerTest { - private val commandBus = mockk(relaxed = true) - private val listQuery = mockk() - private val getQuery = mockk() - private val objectMapper = ObjectMapper() - private lateinit var mockMvc: MockMvc - - @BeforeEach - fun setUp() { - val controller = WorkspaceController(commandBus, listQuery, getQuery) - mockMvc = - MockMvcBuilders - .standaloneSetup(controller) - .setControllerAdvice(GlobalExceptionHandler()) - .build() - } - - private fun workspace( - id: WorkspaceId = WorkspaceId.random(), - kind: WorkspaceKind = WorkspaceKind.REPO_BACKED, - repositoryId: RepositoryId? = null, - ): Workspace { - val now = Instant.now() - return Workspace( - id = id, - name = "demo", - repoUrl = "git@github.com:owner/repo.git", - branch = "main", - podName = "pod", - pvcName = "pvc", - gatewayEndpoint = "http://endpoint:8090", - status = WorkspaceStatus.READY, - createdAt = now, - updatedAt = now, - kind = kind, - repositoryId = repositoryId, - ) - } - - private fun repository(id: RepositoryId = RepositoryId.random()) = - Repository( - id = id, - name = "personal-stack", - repoUrl = "git@github.com:owner/personal-stack.git", - defaultBranch = "main", - vaultKeyPath = "secret/data/agents/repositories/${id.value}", - deployKeyFingerprint = null, - deployKeyAddedAt = null, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - @Test - fun `POST creates a workspace and returns 201 with the new shape`() { - val w = workspace(kind = WorkspaceKind.REPO_BACKED, repositoryId = RepositoryId.random()) - every { - getQuery.getSummary(any()) - } returns w - - mockMvc - .perform( - post("/api/v1/workspaces") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "demo", - "kind" to "REPO_BACKED", - "repositoryId" to w.repositoryId?.value.toString(), - ), - ), - ), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.kind").value("REPO_BACKED")) - .andExpect(jsonPath("$.status").value("READY")) - .andExpect(jsonPath("$.repositories").doesNotExist()) - - verify { commandBus.dispatch(any()) } - } - - @Test - fun `POST creates a workspace with primary and extra repository ids`() { - val primaryRepositoryId = RepositoryId.random() - val extraRepositoryId = RepositoryId.random() - val w = workspace(kind = WorkspaceKind.REPO_BACKED, repositoryId = primaryRepositoryId) - every { getQuery.getSummary(any()) } returns w - - mockMvc - .perform( - post("/api/v1/workspaces") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "demo", - "kind" to "REPO_BACKED", - "primaryRepositoryId" to primaryRepositoryId.value.toString(), - "repositoryIds" to - listOf( - extraRepositoryId.value.toString(), - primaryRepositoryId.value.toString(), - extraRepositoryId.value.toString(), - ), - ), - ), - ), - ).andExpect(status().isCreated) - .andExpect(jsonPath("$.repositoryId").value(primaryRepositoryId.value.toString())) - - verify { - commandBus.dispatch( - match { - it.repositoryId == primaryRepositoryId && - it.repositoryIds == listOf(primaryRepositoryId, extraRepositoryId) - }, - ) - } - } - - @Test - fun `POST rejects conflicting primary repository fields`() { - mockMvc - .perform( - post("/api/v1/workspaces") - .contentType(MediaType.APPLICATION_JSON) - .content( - objectMapper.writeValueAsString( - mapOf( - "name" to "demo", - "repositoryId" to UUID.randomUUID().toString(), - "primaryRepositoryId" to UUID.randomUUID().toString(), - ), - ), - ), - ).andExpect(status().isBadRequest) - - verify(exactly = 0) { commandBus.dispatch(any()) } - } - - @Test - fun `POST returns error on blank name`() { - mockMvc - .perform( - post("/api/v1/workspaces") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("name" to ""))), - ).andExpect { result -> - // standaloneSetup doesn't wire the same validation - // converter the full app uses; either 4xx is acceptable - // here — the assertion is "the validation kicked in". - require(result.response.status in 400..499) { - "expected client error, got ${result.response.status}" - } - } - } - - @Test - fun `GET list returns active workspaces`() { - every { listQuery.listActive() } returns listOf(workspace(), workspace()) - mockMvc - .perform(get("/api/v1/workspaces")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.length()").value(2)) - } - - @Test - fun `GET by id returns workspace detail with repositories under workspace`() { - val w = workspace() - val r = repository() - every { getQuery.get(w.id) } returns - GetWorkspaceQueryService.WorkspaceView( - w, - emptyList(), - listOf( - GetWorkspaceQueryService.WorkspaceRepositoryView( - repository = r, - isPrimary = true, - attachedAt = Instant.now(), - ), - ), - ) - mockMvc - .perform(get("/api/v1/workspaces/${w.id.value}")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.workspace.id").value(w.id.value.toString())) - .andExpect(jsonPath("$.workspace.repositories[0].id").value(r.id.value.toString())) - .andExpect(jsonPath("$.workspace.repositories[0].name").value("personal-stack")) - .andExpect(jsonPath("$.workspace.repositories[0].isPrimary").value(true)) - .andExpect(jsonPath("$.sessions").isArray) - } - - @Test - fun `GET by id with non-existent returns 404`() { - every { getQuery.get(any()) } returns null - mockMvc - .perform(get("/api/v1/workspaces/${UUID.randomUUID()}")) - .andExpect(status().isNotFound) - } - - @Test - fun `DELETE destroys workspace and returns 204`() { - mockMvc - .perform(delete("/api/v1/workspaces/${UUID.randomUUID()}")) - .andExpect(status().isNoContent) - verify { commandBus.dispatch(any()) } - } - - @Test - fun `POST repositories attaches repository and returns 204`() { - val workspaceId = WorkspaceId.random() - val repositoryId = RepositoryId.random() - - mockMvc - .perform( - post("/api/v1/workspaces/${workspaceId.value}/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(mapOf("repositoryId" to repositoryId.value.toString()))), - ).andExpect(status().isNoContent) - - verify { - commandBus.dispatch( - match { - it.workspaceId == workspaceId && it.repositoryId == repositoryId - }, - ) - } - } - - @Test - fun `POST repositories rejects missing repositoryId`() { - mockMvc - .perform( - post("/api/v1/workspaces/${UUID.randomUUID()}/repositories") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(emptyMap())), - ).andExpect { result -> - require(result.response.status in 400..499) { - "expected client error, got ${result.response.status}" - } - } - - verify(exactly = 0) { commandBus.dispatch(any()) } - } - - @Test - fun `DELETE repositories detaches repository and returns 204`() { - val workspaceId = WorkspaceId.random() - val repositoryId = RepositoryId.random() - - mockMvc - .perform(delete("/api/v1/workspaces/${workspaceId.value}/repositories/${repositoryId.value}")) - .andExpect(status().isNoContent) - - verify { - commandBus.dispatch( - match { - it.workspaceId == workspaceId && it.repositoryId == repositoryId - }, - ) - } - } -} diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandlerTest.kt deleted file mode 100644 index 1b346b3f..00000000 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/ws/SessionAttachHandlerTest.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.jorisjonkers.personalstack.assistant.infrastructure.ws - -import com.jorisjonkers.personalstack.assistant.application.idle.ConnectedClientTracker -import com.jorisjonkers.personalstack.assistant.application.idle.WorkspaceActivityTracker -import com.jorisjonkers.personalstack.assistant.domain.model.Workspace -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentKind -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSession -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceAgentSessionStatus -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId -import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus -import com.jorisjonkers.personalstack.assistant.domain.port.TurnRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessionRepository -import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository -import io.mockk.Runs -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.mockkConstructor -import io.mockk.slot -import io.mockk.unmockkConstructor -import io.mockk.verify -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.web.socket.CloseStatus -import org.springframework.web.socket.TextMessage -import org.springframework.web.socket.WebSocketSession -import org.springframework.web.socket.client.standard.StandardWebSocketClient -import java.net.URI -import java.time.Instant -import java.util.UUID -import java.util.concurrent.CompletableFuture - -class SessionAttachHandlerTest { - private val sessions = mockk() - private val workspaces = mockk() - private val turns = mockk(relaxed = true) - private val activity = mockk(relaxed = true) - - private lateinit var handler: SessionAttachHandler - private lateinit var upstream: WebSocketSession - - private val sessionId = WorkspaceAgentSessionId.random() - private val workspaceId = WorkspaceId.random() - - @BeforeEach - fun setUp() { - // The handler builds its own StandardWebSocketClient; stub the - // outbound attach so afterConnectionEstablished yields a bridge - // whose upstream is a mock we can assert against. - mockkConstructor(StandardWebSocketClient::class) - upstream = mockk(relaxed = true) - every { upstream.isOpen } returns true - every { - anyConstructed().execute(any(), any()) - } returns CompletableFuture.completedFuture(upstream) - - handler = SessionAttachHandler(sessions, workspaces, activity, ConnectedClientTracker()) - - every { sessions.findById(sessionId) } returns agentSession() - every { workspaces.findById(workspaceId) } returns workspace() - } - - @AfterEach - fun tearDown() { - unmockkConstructor(StandardWebSocketClient::class) - } - - @Test - fun `client input frame relays upstream verbatim and persists no turns`() { - val client = clientSession() - handler.afterConnectionEstablished(client) - - val frame = TextMessage("""{"input":"ls -la\r","enter":false}""") - handler.handleMessage(client, frame) - - val sent = slot() - verify { upstream.sendMessage(capture(sent)) } - assertThat(sent.captured.payload).isEqualTo(frame.payload) - verify(exactly = 0) { turns.save(any()) } - } - - @Test - fun `client resize frame is forwarded upstream verbatim`() { - val client = clientSession() - handler.afterConnectionEstablished(client) - - val frame = TextMessage("""{"resize":{"cols":120,"rows":40}}""") - handler.handleMessage(client, frame) - - val sent = slot() - verify { upstream.sendMessage(capture(sent)) } - assertThat(sent.captured.payload).isEqualTo(frame.payload) - verify(exactly = 0) { turns.save(any()) } - } - - @Test - fun `upstream output frame relays to the browser without persisting turns`() { - val client = mockk(relaxed = true) - every { client.id } returns UUID.randomUUID().toString() - every { client.uri } returns attachUri() - every { client.isOpen } returns true - - // Capture the upstream handler the gateway frames arrive on. - val handlerSlot = slot() - every { - anyConstructed().execute(capture(handlerSlot), any()) - } returns CompletableFuture.completedFuture(upstream) - - handler.afterConnectionEstablished(client) - - val frame = TextMessage("""{"output":"hello"}""") - handlerSlot.captured.handleMessage(upstream, frame) - - val sent = slot() - verify { client.sendMessage(capture(sent)) } - assertThat(sent.captured.payload).isEqualTo(frame.payload) - verify(exactly = 0) { turns.save(any()) } - } - - @Test - fun `closing the client closes the upstream and persists no turns`() { - every { upstream.close(any()) } just Runs - val client = clientSession() - handler.afterConnectionEstablished(client) - - handler.afterConnectionClosed(client, CloseStatus.NORMAL) - - verify { upstream.close(CloseStatus.NORMAL) } - verify(exactly = 0) { turns.save(any()) } - // A second relay after close is a no-op (bridge already removed). - handler.handleMessage(client, TextMessage("""{"input":"x"}""")) - verify(exactly = 1) { upstream.close(any()) } - } - - @Test - fun `upstream close closes browser socket so it can reconnect`() { - val client = clientSession() - every { client.close(any()) } just Runs - val handlerSlot = slot() - every { - anyConstructed().execute(capture(handlerSlot), any()) - } returns CompletableFuture.completedFuture(upstream) - - handler.afterConnectionEstablished(client) - handlerSlot.captured.afterConnectionClosed(upstream, CloseStatus.GOING_AWAY) - - val closed = slot() - verify { client.close(capture(closed)) } - assertThat(closed.captured.reason).isEqualTo("runner attach disconnected") - } - - @Test - fun `upstream transport error closes browser socket so it can reconnect`() { - val client = clientSession() - every { client.close(any()) } just Runs - val handlerSlot = slot() - every { - anyConstructed().execute(capture(handlerSlot), any()) - } returns CompletableFuture.completedFuture(upstream) - - handler.afterConnectionEstablished(client) - handlerSlot.captured.handleTransportError(upstream, RuntimeException("boom")) - - val closed = slot() - verify { client.close(capture(closed)) } - assertThat(closed.captured.reason).isEqualTo("runner attach error") - } - - @Test - fun `client input after upstream closed closes browser socket instead of dropping keystrokes`() { - val client = clientSession() - every { client.close(any()) } just Runs - handler.afterConnectionEstablished(client) - every { upstream.isOpen } returns false - - handler.handleMessage(client, TextMessage("""{"input":"x","enter":false}""")) - - val closed = slot() - verify { client.close(capture(closed)) } - assertThat(closed.captured.reason).isEqualTo("runner attach disconnected") - } - - private fun clientSession(): WebSocketSession { - val client = mockk(relaxed = true) - every { client.id } returns UUID.randomUUID().toString() - every { client.uri } returns attachUri() - every { client.isOpen } returns true - return client - } - - private fun attachUri(): URI = URI.create("ws://api/api/v1/ws/sessions/${sessionId.value}/attach") - - private fun agentSession() = - WorkspaceAgentSession( - id = sessionId, - workspaceId = workspaceId, - kind = WorkspaceAgentKind.CLAUDE, - gatewayAgentId = "abc12345", - status = WorkspaceAgentSessionStatus.RUNNING, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) - - private fun workspace() = - Workspace( - id = workspaceId, - name = "demo", - repoUrl = null, - branch = null, - podName = null, - pvcName = null, - gatewayEndpoint = "http://gw:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) -} diff --git a/services/assistant-ui/.dependency-cruiser.cjs b/services/assistant-ui/.dependency-cruiser.cjs deleted file mode 100644 index 935acc2c..00000000 --- a/services/assistant-ui/.dependency-cruiser.cjs +++ /dev/null @@ -1,99 +0,0 @@ -/** @type {import('dependency-cruiser').IConfiguration} */ -module.exports = { - forbidden: [ - { - name: 'no-circular', - severity: 'error', - comment: 'No circular imports allowed (ADR-013)', - from: {}, - to: { - circular: true, - }, - }, - { - name: 'no-components-import-views', - severity: 'error', - comment: 'Components must not import from views (ADR-013)', - from: { - path: '^src/features/.*/components/', - }, - to: { - path: '^src/features/.*/views/', - }, - }, - { - name: 'api-calls-only-from-services', - severity: 'error', - comment: 'API calls only from services layer (ADR-013)', - from: { - path: '^src/features/.*/(?!services/)', - pathNot: '^src/shared/services/', - }, - to: { - path: '^src/shared/services/api/', - }, - }, - { - name: 'stores-through-services', - severity: 'error', - comment: 'Stores must not directly call generated API clients (ADR-013)', - from: { - path: '^src/features/.*/stores/', - }, - to: { - path: '^src/shared/services/api/generated/', - }, - }, - { - name: 'no-cross-feature-deep-import', - severity: 'error', - comment: 'Features must use barrel exports for cross-feature access (ADR-013)', - from: { - path: '^src/features/([^/]+)/', - }, - to: { - path: '^src/features/(?!$1/)[^/]+/(?!index\\.ts$)', - }, - }, - { - name: 'generated-client-isolation', - severity: 'error', - comment: 'Only service adapters may import generated API clients (ADR-016)', - from: { - pathNot: '^src/(features/.*/services/|shared/services/)', - }, - to: { - path: '^src/shared/services/api/generated/', - }, - }, - { - name: 'shared-components-domain-agnostic', - severity: 'error', - comment: 'Shared components must not import from features (ADR-013)', - from: { - path: '^src/shared/components/', - }, - to: { - path: '^src/features/', - }, - }, - ], - options: { - doNotFollow: { - path: 'node_modules', - }, - tsPreCompilationDeps: true, - tsConfig: { - fileName: './tsconfig.json', - }, - enhancedResolveOptions: { - exportsFields: ['exports'], - conditionNames: ['import', 'require', 'node', 'default'], - }, - reporterOptions: { - dot: { - collapsePattern: 'node_modules/(@[^/]+/[^/]+|[^/]+)', - }, - }, - }, -} diff --git a/services/assistant-ui/Dockerfile b/services/assistant-ui/Dockerfile deleted file mode 100644 index a830f304..00000000 --- a/services/assistant-ui/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM node:26-alpine AS build -# Node 25's alpine image dropped corepack from the base layer but still -# ships /usr/local/bin/yarn, which makes `npm install -g corepack` fail -# with EEXIST. Install pnpm directly at the packageManager version in -# package.json instead of going through corepack's shim dance. -RUN npm install -g pnpm@9.15.4 -WORKDIR /app -ARG NODE_AUTH_TOKEN= -COPY .npmrc pnpm-lock.yaml pnpm-workspace.yaml package.json ./ -COPY services/assistant-ui/package.json services/assistant-ui/ -RUN --mount=type=secret,id=github_token \ - NODE_AUTH_TOKEN="${NODE_AUTH_TOKEN:-$(cat /run/secrets/github_token 2>/dev/null || true)}" \ - pnpm install --frozen-lockfile -COPY services/assistant-ui/ services/assistant-ui/ -ARG VITE_AUTH_URL=https://auth.jorisjonkers.dev -ARG VITE_FARO_URL=https://faro.jorisjonkers.dev/collect -ARG VITE_GITHUB_APP_SLUG=personal-stack-agents -RUN VITE_AUTH_URL=${VITE_AUTH_URL} \ - VITE_FARO_URL=${VITE_FARO_URL} \ - VITE_GITHUB_APP_SLUG=${VITE_GITHUB_APP_SLUG} \ - pnpm --filter @personal-stack/assistant-ui build - -FROM nginx:alpine -COPY services/assistant-ui/nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=build /app/services/assistant-ui/dist /usr/share/nginx/html -EXPOSE 80 diff --git a/services/assistant-ui/e2e/chat.spec.ts b/services/assistant-ui/e2e/chat.spec.ts deleted file mode 100644 index 35297183..00000000 --- a/services/assistant-ui/e2e/chat.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { expect, test } from '@playwright/test' - -test('chat page renders', async ({ page }) => { - await page.goto('/chat') - await expect(page.locator('h1')).toContainText('Assistant') -}) - -test('chat page redirects unauthenticated users', async ({ page }) => { - await page.goto('/chat') - // Unauthenticated users should be redirected away from /chat - const url = page.url() - expect(url).toContain('/login') -}) - -test('chat page URL is /chat', async ({ page }) => { - await page.goto('/') - // Root should redirect to /chat - await page.waitForURL('**/chat**') - expect(page.url()).toContain('/chat') -}) diff --git a/services/assistant-ui/env.d.ts b/services/assistant-ui/env.d.ts deleted file mode 100644 index 11f02fe2..00000000 --- a/services/assistant-ui/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/services/assistant-ui/index.html b/services/assistant-ui/index.html deleted file mode 100644 index 00128642..00000000 --- a/services/assistant-ui/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - Assistant - jorisjonkers.dev - - -
- - - diff --git a/services/assistant-ui/nginx.conf b/services/assistant-ui/nginx.conf deleted file mode 100644 index 4045fa7f..00000000 --- a/services/assistant-ui/nginx.conf +++ /dev/null @@ -1,33 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; - - location /assets/ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - location ~* \.(?:jpg|jpeg|png|webp|svg|ico|txt|xml)$ { - expires 30d; - add_header Cache-Control "public, max-age=2592000"; - } - - # index.html is the only way a browser discovers the new - # hashed-asset URLs after a deploy. Pinning it to must-revalidate - # + no-store stops clients from booting an old SPA that then - # requests URLs the new server has already garbage-collected. - location = /index.html { - add_header Cache-Control "no-cache, no-store, must-revalidate" always; - add_header Pragma "no-cache" always; - add_header Expires "0" always; - } - - location / { - try_files $uri $uri/ /index.html; - } -} diff --git a/services/assistant-ui/package.json b/services/assistant-ui/package.json deleted file mode 100644 index cb78a0a3..00000000 --- a/services/assistant-ui/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@personal-stack/assistant-ui", - "type": "module", - "version": "0.0.1", - "private": true, - "scripts": { - "dev": "vite", - "build": "vue-tsc --noEmit && vite build", - "preview": "vite preview", - "lint": "eslint . --max-warnings 0", - "lint:fix": "eslint . --fix", - "typecheck": "vue-tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e": "playwright test", - "depcruise": "depcruise src --config .dependency-cruiser.cjs --output-type err", - "stryker": "stryker run", - "contract:generate": "openapi-typescript ../assistant-api/openapi.json -o src/api/generated.ts && node scripts/contract-banner.mjs src/api/generated.ts", - "contract:check": "openapi-typescript ../assistant-api/openapi.json -o /tmp/assistant-ui-generated.ts && node scripts/contract-banner.mjs /tmp/assistant-ui-generated.ts && diff -u src/api/generated.ts /tmp/assistant-ui-generated.ts" - }, - "dependencies": { - "@extratoast/vue-web-commons": "0.1.1", - "@grafana/faro-web-sdk": "^2.7.1", - "@grafana/faro-web-tracing": "^2.7.1", - "@xterm/addon-fit": "0.11.0", - "@xterm/xterm": "6.0.0", - "pinia": "^3.0.4", - "primevue": "^4.5.5", - "vue": "^3.5.35", - "vue-router": "^5.1.0", - "zod": "^4.4.3" - }, - "devDependencies": { - "@playwright/test": "^1.60.0", - "@stryker-mutator/core": "^9.6.1", - "@stryker-mutator/vitest-runner": "^9.6.1", - "@tailwindcss/vite": "^4.3.0", - "@vitejs/plugin-vue": "^6.0.7", - "@vitest/coverage-v8": "^4.1.7", - "@vue/test-utils": "^2.4.10", - "dependency-cruiser": "^17.4.2", - "jsdom": "^29.1.1", - "msw": "^2.14.6", - "openapi-typescript": "7.13.0", - "tailwindcss": "^4.3.0", - "typescript": "^6.0.3", - "vite": "^8.0.14", - "vitest": "^4.1.7", - "vue-tsc": "^3.3.2" - } -} diff --git a/services/assistant-ui/playwright.config.ts b/services/assistant-ui/playwright.config.ts deleted file mode 100644 index b7f2876c..00000000 --- a/services/assistant-ui/playwright.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from '@playwright/test' - -export default defineConfig({ - testDir: './e2e', - fullyParallel: true, - retries: 1, - reporter: 'html', - use: { - baseURL: 'http://localhost:5174', - trace: 'on-first-retry', - }, - projects: [ - { name: 'chromium', use: { browserName: 'chromium' } }, - { name: 'firefox', use: { browserName: 'firefox' } }, - { name: 'webkit', use: { browserName: 'webkit' } }, - ], -}) diff --git a/services/assistant-ui/public/favicon.svg b/services/assistant-ui/public/favicon.svg deleted file mode 100644 index d57b6277..00000000 --- a/services/assistant-ui/public/favicon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - >_ - JJ - - - .dev - diff --git a/services/assistant-ui/public/robots.txt b/services/assistant-ui/public/robots.txt deleted file mode 100644 index 1f53798b..00000000 --- a/services/assistant-ui/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: / diff --git a/services/assistant-ui/scripts/contract-banner.mjs b/services/assistant-ui/scripts/contract-banner.mjs deleted file mode 100644 index 5df815b2..00000000 --- a/services/assistant-ui/scripts/contract-banner.mjs +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -// Prepends a short provenance banner to the openapi-typescript output. -// Kept in step between `contract:generate` and `contract:check` so the -// diff produced by the latter only flags real schema drift. - -import { readFileSync, writeFileSync } from 'node:fs' -import process from 'node:process' - -const target = process.argv[2] -if (!target) { - console.error('usage: contract-banner.mjs ') - process.exit(2) -} - -const banner = `/** - * AUTO-GENERATED. Do not edit by hand. - * - * Source: services/assistant-api/openapi.json (committed) - * Regenerate with: pnpm --filter @personal-stack/assistant-ui contract:generate - * Drift gate: pnpm --filter @personal-stack/assistant-ui contract:check - * Contract docs: services/assistant-api/CONTRACT.md - */ -` - -const existing = readFileSync(target, 'utf8') -writeFileSync(target, banner + existing) diff --git a/services/assistant-ui/src/App.vue b/services/assistant-ui/src/App.vue deleted file mode 100644 index 0abcfdb6..00000000 --- a/services/assistant-ui/src/App.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/services/assistant-ui/src/__tests__/App.test.ts b/services/assistant-ui/src/__tests__/App.test.ts deleted file mode 100644 index 634af07b..00000000 --- a/services/assistant-ui/src/__tests__/App.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' -import { createMemoryHistory, createRouter } from 'vue-router' -import App from '../App.vue' - -describe('app', () => { - it('renders without crashing', () => { - const router = createRouter({ - history: createMemoryHistory(), - routes: [{ path: '/', name: 'home', component: { template: '
' } }], - }) - const wrapper = mount(App, { - global: { - plugins: [router], - stubs: ['RouterView'], - }, - }) - expect(wrapper.exists()).toBe(true) - }) -}) diff --git a/services/assistant-ui/src/__tests__/router.test.ts b/services/assistant-ui/src/__tests__/router.test.ts deleted file mode 100644 index e5a233ee..00000000 --- a/services/assistant-ui/src/__tests__/router.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/vueWebCommons', () => ({ - useAuth: () => ({ - isAuthenticated: { value: false }, - user: { value: null }, - getAccessToken: () => null, - }), -})) - -describe('router', () => { - it('has /sessions route requiring auth', async () => { - const { router } = await import('../router/index') - const sessionsRoute = router.getRoutes().find((r) => r.path === '/sessions') - expect(sessionsRoute).toBeDefined() - expect(sessionsRoute?.meta?.requiresAuth).toBe(true) - }) - - it('redirects / to /sessions', async () => { - const { router } = await import('../router/index') - const rootRoute = router.getRoutes().find((r) => r.path === '/') - expect(rootRoute).toBeDefined() - expect(rootRoute?.redirect).toBe('/sessions') - }) - - it('exposes the workspace detail surface under /sessions/workspace/:id', async () => { - const { router } = await import('../router/index') - const detailRoute = router.getRoutes().find((r) => r.path === '/sessions/workspace/:id') - expect(detailRoute).toBeDefined() - expect(detailRoute?.name).toBe('workspace-detail') - expect(detailRoute?.meta?.requiresAuth).toBe(true) - }) - - it('legacy /chat and /workspaces routes are gone', async () => { - const { router } = await import('../router/index') - const paths = router.getRoutes().map((r) => r.path) - expect(paths).not.toContain('/chat') - expect(paths).not.toContain('/workspaces') - expect(paths).not.toContain('/workspaces/:id') - }) - - it('sessions route is named sessions', async () => { - const { router } = await import('../router/index') - const sessionsRoute = router.getRoutes().find((r) => r.name === 'sessions') - expect(sessionsRoute).toBeDefined() - expect(sessionsRoute?.path).toBe('/sessions') - }) -}) diff --git a/services/assistant-ui/src/api/generated.ts b/services/assistant-ui/src/api/generated.ts deleted file mode 100644 index b77d15b1..00000000 --- a/services/assistant-ui/src/api/generated.ts +++ /dev/null @@ -1,1681 +0,0 @@ -/** - * AUTO-GENERATED. Do not edit by hand. - * - * Source: services/assistant-api/openapi.json (committed) - * Regenerate with: pnpm --filter @personal-stack/assistant-ui contract:generate - * Drift gate: pnpm --filter @personal-stack/assistant-ui contract:check - * Contract docs: services/assistant-api/CONTRACT.md - */ -/** - * This file was auto-generated by openapi-typescript. - * Do not make direct changes to the file. - */ - -export interface paths { - "/api/v1/workspaces": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["list"]; - put?: never; - post: operations["create"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workspaces/{workspaceId}/sessions": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["start"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/staged-inputs": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["stageInput"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/input": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["send"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workspaces/{workspaceId}/git/open-pr": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["openPr"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workspaces/{id}/repositories": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["attachRepository"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/repositories": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["list_1"]; - put?: never; - post: operations["create_1"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/repositories/{id}/verify": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["verify"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/repositories/{id}/key": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["attachKey"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/projects": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["list_2"]; - put?: never; - post: operations["create_2"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/projects/{projectId}/links/{linkId}/key": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["attachKey_1"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/projects/{id}/repositories": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["linkRepository"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/projects/{id}/links": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** @deprecated */ - post: operations["addLink"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/conversations": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["listByUser"]; - put?: never; - post: operations["create_3"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/conversations/{conversationId}/messages": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["list_3"]; - put?: never; - post: operations["send_1"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/chat-sessions": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["list_4"]; - put?: never; - post: operations["create_4"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/chat-sessions/{id}/messages": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["appendMessage"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}/turns": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["turns"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workspaces/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get"]; - put?: never; - post?: never; - delete: operations["destroy"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/repositories/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get_1"]; - put?: never; - post?: never; - delete: operations["delete"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/projects/{projectId}/links/{linkId}/setup-guide": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["guide"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/projects/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get_2"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/health": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["health"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/conversations/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getById"]; - put?: never; - post?: never; - delete: operations["archive"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/chat-sessions/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get_3"]; - put?: never; - post?: never; - delete: operations["archive_1"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workspaces/{workspaceId}/sessions/{sessionId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete: operations["stop"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/workspaces/{id}/repositories/{repoId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete: operations["detachRepository"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/projects/{projectId}/links/{linkId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete: operations["removeLink"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/projects/{id}/repositories/{repoId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete: operations["unlinkRepository"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - CreateWorkspaceRequest: { - name: string; - repoUrl?: string | null; - branch?: string | null; - /** @enum {string} */ - kind: "REPO_BACKED" | "SCRATCH" | "CHAT"; - /** Format: uuid */ - projectId?: string | null; - /** Format: uuid */ - repositoryId?: string | null; - /** Format: uuid */ - primaryRepositoryId?: string | null; - repositoryIds?: string[] | null; - /** - * Format: uuid - * @deprecated - * @description Use repositoryId - */ - githubLinkId?: string | null; - }; - WorkspaceResponse: { - /** Format: uuid */ - id: string; - name: string; - repoUrl?: string | null; - branch?: string | null; - podName?: string | null; - gatewayEndpoint?: string | null; - status: string; - kind: string; - /** Format: uuid */ - projectId?: string | null; - /** Format: uuid */ - repositoryId?: string | null; - /** Format: uuid */ - githubLinkId?: string | null; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - StartAgentSessionRequest: { - /** @enum {string} */ - kind: "CLAUDE" | "CODEX" | "SHELL"; - }; - StageInputRequest: { - content: string; - name?: string | null; - }; - StagedInputResponse: { - path: string; - /** Format: int64 */ - bytes: number; - name: string; - }; - SendUserInputRequest: { - text: string; - enter: boolean; - }; - OpenPrRequest: { - repoDir: string; - title: string; - body: string; - base: string; - }; - AttachWorkspaceRepositoryRequest: { - /** Format: uuid */ - repositoryId: string | null; - }; - CreateRepositoryRequest: { - name: string; - repoUrl: string; - defaultBranch: string; - }; - AccessVerificationResponse: { - read?: boolean | null; - write?: boolean | null; - defaultBranchProtected?: boolean | null; - /** Format: date-time */ - checkedAt?: string | null; - messages: string[]; - }; - RepositoryResponse: { - /** Format: uuid */ - id: string; - name: string; - repoUrl: string; - defaultBranch: string; - vaultKeyPath: string; - deployKeyFingerprint?: string | null; - /** Format: date-time */ - deployKeyAddedAt?: string | null; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - verification?: components["schemas"]["AccessVerificationResponse"] | null; - }; - AttachRepositoryDeployKeyRequest: { - privateKeyOpenssh: string; - publicKeyOpenssh: string; - knownHosts?: string | null; - }; - CreateProjectRequest: { - name: string; - slug: string; - description?: string | null; - }; - ProjectResponse: { - /** Format: uuid */ - id: string; - name: string; - slug: string; - description: string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - AttachDeployKeyRequest: { - privateKeyOpenssh: string; - publicKeyOpenssh: string; - knownHosts?: string | null; - }; - LinkRepositoryRequest: { - /** Format: uuid */ - repositoryId: string; - }; - AddGithubLinkRequest: { - name: string; - repoUrl: string; - defaultBranch: string; - }; - GithubLinkResponse: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - projectId: string; - name: string; - repoUrl: string; - defaultBranch: string; - vaultKeyPath: string; - deployKeyFingerprint?: string | null; - /** Format: date-time */ - deployKeyAddedAt?: string | null; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - CreateConversationRequest: { - title: string; - }; - ConversationResponse: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - userId: string; - title: string; - status: string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - SendMessageRequest: { - content: string; - }; - MessageResponse: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - conversationId: string; - role: string; - content: string; - /** Format: date-time */ - createdAt: string; - }; - StartChatSessionRequest: { - title?: string | null; - /** @enum {string|null} */ - kind?: "PLAIN" | "KNOWLEDGE" | null; - }; - ChatSessionResponse: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - userId: string; - title?: string | null; - status: string; - kind: string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - AppendChatMessageRequest: { - body: string; - /** @enum {string} */ - role: "USER" | "ASSISTANT" | "SYSTEM"; - }; - ChatMessageResponse: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - sessionId: string; - role: string; - body: string; - /** Format: date-time */ - createdAt: string; - }; - TurnResponse: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - sessionId: string; - role: string; - body: string; - /** Format: date-time */ - createdAt: string; - }; - WorkspaceAgentSessionResponse: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - workspaceId: string; - /** @enum {string} */ - kind: "CLAUDE" | "CODEX" | "SHELL"; - gatewayAgentId?: string | null; - status: string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - WorkspaceDetailResponse: { - workspace: components["schemas"]["WorkspaceWithRepositoriesResponse"]; - sessions: components["schemas"]["WorkspaceAgentSessionResponse"][]; - }; - WorkspaceRepositoryResponse: { - /** Format: uuid */ - id: string; - name: string; - isPrimary: boolean; - /** Format: date-time */ - attachedAt: string; - repoUrl: string; - defaultBranch: string; - vaultKeyPath: string; - deployKeyFingerprint?: string | null; - /** Format: date-time */ - deployKeyAddedAt?: string | null; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - verification?: components["schemas"]["AccessVerificationResponse"] | null; - }; - WorkspaceWithRepositoriesResponse: { - /** Format: uuid */ - id: string; - name: string; - repoUrl?: string | null; - branch?: string | null; - podName?: string | null; - gatewayEndpoint?: string | null; - status: string; - kind: string; - /** Format: uuid */ - projectId?: string | null; - /** Format: uuid */ - repositoryId?: string | null; - /** Format: uuid */ - githubLinkId?: string | null; - repositories: components["schemas"]["WorkspaceRepositoryResponse"][]; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - list: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["WorkspaceResponse"][]; - }; - }; - }; - }; - create: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateWorkspaceRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["WorkspaceResponse"]; - }; - }; - }; - }; - start: { - parameters: { - query?: never; - header?: never; - path: { - workspaceId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["StartAgentSessionRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": { - [key: string]: string; - }; - }; - }; - }; - }; - stageInput: { - parameters: { - query?: never; - header?: never; - path: { - workspaceId: string; - sessionId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["StageInputRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["StagedInputResponse"]; - }; - }; - }; - }; - send: { - parameters: { - query?: never; - header?: never; - path: { - workspaceId: string; - sessionId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SendUserInputRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - openPr: { - parameters: { - query?: never; - header?: never; - path: { - workspaceId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["OpenPrRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - attachRepository: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AttachWorkspaceRepositoryRequest"]; - }; - }; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - list_1: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["RepositoryResponse"][]; - }; - }; - }; - }; - create_1: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateRepositoryRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["RepositoryResponse"]; - }; - }; - }; - }; - verify: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["RepositoryResponse"]; - }; - }; - }; - }; - attachKey: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AttachRepositoryDeployKeyRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - list_2: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["ProjectResponse"][]; - }; - }; - }; - }; - create_2: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateProjectRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["ProjectResponse"]; - }; - }; - }; - }; - attachKey_1: { - parameters: { - query?: never; - header?: never; - path: { - projectId: string; - linkId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AttachDeployKeyRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - linkRepository: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["LinkRepositoryRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["RepositoryResponse"][]; - }; - }; - }; - }; - addLink: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddGithubLinkRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["GithubLinkResponse"]; - }; - }; - }; - }; - listByUser: { - parameters: { - query?: never; - header: { - "X-User-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["ConversationResponse"][]; - }; - }; - }; - }; - create_3: { - parameters: { - query?: never; - header: { - "X-User-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateConversationRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["ConversationResponse"]; - }; - }; - }; - }; - list_3: { - parameters: { - query?: never; - header?: never; - path: { - conversationId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["MessageResponse"][]; - }; - }; - }; - }; - send_1: { - parameters: { - query?: never; - header: { - "X-User-Id": string; - }; - path: { - conversationId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SendMessageRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["MessageResponse"]; - }; - }; - }; - }; - list_4: { - parameters: { - query?: never; - header: { - "X-User-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["ChatSessionResponse"][]; - }; - }; - }; - }; - create_4: { - parameters: { - query?: never; - header: { - "X-User-Id": string; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["StartChatSessionRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["ChatSessionResponse"]; - }; - }; - }; - }; - appendMessage: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AppendChatMessageRequest"]; - }; - }; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["ChatMessageResponse"]; - }; - }; - }; - }; - turns: { - parameters: { - query?: never; - header?: never; - path: { - workspaceId: string; - sessionId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["TurnResponse"][]; - }; - }; - }; - }; - get: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["WorkspaceDetailResponse"]; - }; - }; - }; - }; - destroy: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - get_1: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": { - [key: string]: unknown; - }; - }; - }; - }; - }; - delete: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - guide: { - parameters: { - query?: never; - header?: never; - path: { - projectId: string; - linkId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/markdown": string; - "text/plain": string; - }; - }; - }; - }; - get_2: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": { - [key: string]: unknown; - }; - }; - }; - }; - }; - health: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": { - [key: string]: string; - }; - }; - }; - }; - }; - getById: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["ConversationResponse"]; - }; - }; - }; - }; - archive: { - parameters: { - query?: never; - header: { - "X-User-Id": string; - }; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - get_3: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": { - [key: string]: unknown; - }; - }; - }; - }; - }; - archive_1: { - parameters: { - query?: never; - header: { - "X-User-Id": string; - }; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - stop: { - parameters: { - query?: never; - header?: never; - path: { - workspaceId: string; - sessionId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - detachRepository: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - repoId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - removeLink: { - parameters: { - query?: never; - header?: never; - path: { - projectId: string; - linkId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - unlinkRepository: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - repoId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; -} diff --git a/services/assistant-ui/src/features/projects/__tests__/projects.store.test.ts b/services/assistant-ui/src/features/projects/__tests__/projects.store.test.ts deleted file mode 100644 index 77039ee2..00000000 --- a/services/assistant-ui/src/features/projects/__tests__/projects.store.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { GithubLink, Project, ProjectDetail } from '../types' -import type { Repository } from '@/features/repositories' -import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - addLink, - attachKey, - createProject, - getProject, - linkRepository, - listProjects, - removeLink, - unlinkRepository, -} from '../services/projectsService' -import { useProjectsStore } from '../stores/projects' - -vi.mock('../services/projectsService', () => ({ - listProjects: vi.fn(), - getProject: vi.fn(), - createProject: vi.fn(), - addLink: vi.fn(), - removeLink: vi.fn(), - attachKey: vi.fn(), - linkRepository: vi.fn(), - unlinkRepository: vi.fn(), -})) - -const mocked = { - listProjects: vi.mocked(listProjects), - getProject: vi.mocked(getProject), - createProject: vi.mocked(createProject), - addLink: vi.mocked(addLink), - removeLink: vi.mocked(removeLink), - attachKey: vi.mocked(attachKey), - linkRepository: vi.mocked(linkRepository), - unlinkRepository: vi.mocked(unlinkRepository), -} - -function fakeRepo(over: Partial = {}): Repository { - return { - id: 'rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr', - name: 'demo-repo', - repoUrl: 'git@github.com:owner/demo.git', - defaultBranch: 'main', - vaultKeyPath: 'secret/data/agents/repositories/rrrrrrrr...', - deployKeyFingerprint: null, - deployKeyAddedAt: null, - createdAt: '2026-05-20T10:00:00Z', - updatedAt: '2026-05-20T10:00:00Z', - ...over, - } -} - -function fakeProject(over: Partial = {}): Project { - return { - id: '11111111-1111-1111-1111-111111111111', - name: 'demo', - slug: 'demo', - description: '', - createdAt: '2026-05-19T10:00:00Z', - updatedAt: '2026-05-19T10:00:00Z', - ...over, - } -} - -function fakeLink(over: Partial = {}): GithubLink { - return { - id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - projectId: '11111111-1111-1111-1111-111111111111', - name: 'repo', - repoUrl: 'git@github.com:owner/repo.git', - defaultBranch: 'main', - vaultKeyPath: 'secret/data/agents/projects/p/repos/l', - deployKeyFingerprint: null, - deployKeyAddedAt: null, - createdAt: '2026-05-19T10:00:00Z', - updatedAt: '2026-05-19T10:00:00Z', - ...over, - } -} - -describe('useProjectsStore', () => { - beforeEach(() => { - setActivePinia(createPinia()) - Object.values(mocked).forEach((m) => m.mockReset()) - }) - - it('loadAll populates projects', async () => { - mocked.listProjects.mockResolvedValue([fakeProject()]) - const store = useProjectsStore() - await store.loadAll() - expect(store.projects).toHaveLength(1) - expect(store.error).toBeNull() - }) - - it('loadAll surfaces the Error message and re-throws on failure', async () => { - mocked.listProjects.mockRejectedValue(new Error('boom')) - const store = useProjectsStore() - await expect(store.loadAll()).rejects.toThrow('boom') - expect(store.error).toBe('boom') - }) - - it('open loads detail + links + repositories', async () => { - const detail: ProjectDetail = { - project: fakeProject(), - links: [fakeLink()], - repositories: [], - } - mocked.getProject.mockResolvedValue(detail) - const store = useProjectsStore() - await store.open('11111111-1111-1111-1111-111111111111') - expect(store.activeProject?.id).toBe(detail.project.id) - expect(store.links).toHaveLength(1) - expect(store.repositories).toHaveLength(0) - }) - - it('create unshifts the new project', async () => { - const p = fakeProject({ id: 'new', name: 'fresh' }) - mocked.createProject.mockResolvedValue(p) - const store = useProjectsStore() - store.projects = [fakeProject({ id: 'old' })] - const result = await store.create({ name: 'fresh', slug: 'fresh' }) - expect(result).toEqual(p) - expect(store.projects[0]!.id).toBe('new') - }) - - it('addNewLink no-ops when no project is open', async () => { - const store = useProjectsStore() - const result = await store.addNewLink({ name: 'repo', repoUrl: 'git@x:y/z.git' }) - expect(result).toBeNull() - expect(mocked.addLink).not.toHaveBeenCalled() - }) - - it('addNewLink appends and returns the new link when a project is open', async () => { - const link = fakeLink({ id: 'new-link' }) - mocked.addLink.mockResolvedValue(link) - const store = useProjectsStore() - store.activeProject = fakeProject() - const result = await store.addNewLink({ name: 'repo', repoUrl: 'git@x:y/z.git' }) - expect(result).toEqual(link) - expect(store.links.map((l) => l.id)).toContain('new-link') - }) - - it('dropLink removes from the active project link list', async () => { - mocked.removeLink.mockResolvedValue() - const store = useProjectsStore() - store.activeProject = fakeProject() - store.links = [fakeLink({ id: 'a' }), fakeLink({ id: 'b' })] - await store.dropLink('a') - expect(store.links.map((l) => l.id)).toEqual(['b']) - }) - - it('attach delegates and refreshes the project', async () => { - mocked.attachKey.mockResolvedValue() - mocked.getProject.mockResolvedValue({ project: fakeProject(), links: [], repositories: [] }) - const store = useProjectsStore() - store.activeProject = fakeProject() - await store.attach('link-id', { - privateKeyOpenssh: '-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----', - publicKeyOpenssh: 'ssh-ed25519 AAAA test@laptop', - }) - expect(mocked.attachKey).toHaveBeenCalled() - expect(mocked.getProject).toHaveBeenCalled() - }) - - it('linkRepo no-ops when no project is open', async () => { - const store = useProjectsStore() - await store.linkRepo('any-repo-id') - expect(mocked.linkRepository).not.toHaveBeenCalled() - }) - - it('linkRepo replaces the repositories array with the API response', async () => { - const repo = fakeRepo({ id: 'new-repo' }) - mocked.linkRepository.mockResolvedValue([repo]) - const store = useProjectsStore() - store.activeProject = fakeProject() - store.repositories = [] - await store.linkRepo('new-repo') - expect(mocked.linkRepository).toHaveBeenCalledWith(store.activeProject!.id, 'new-repo') - expect(store.repositories.map((r) => r.id)).toEqual(['new-repo']) - }) - - it('unlinkRepo drops the row from the active project list', async () => { - mocked.unlinkRepository.mockResolvedValue() - const a = fakeRepo({ id: 'a' }) - const b = fakeRepo({ id: 'b' }) - const store = useProjectsStore() - store.activeProject = fakeProject() - store.repositories = [a, b] - await store.unlinkRepo('a') - expect(mocked.unlinkRepository).toHaveBeenCalledWith(store.activeProject!.id, 'a') - expect(store.repositories.map((r) => r.id)).toEqual(['b']) - }) - - it('unlinkRepo no-ops when no project is open', async () => { - const store = useProjectsStore() - await store.unlinkRepo('any') - expect(mocked.unlinkRepository).not.toHaveBeenCalled() - }) -}) diff --git a/services/assistant-ui/src/features/projects/components/AddLinkForm.vue b/services/assistant-ui/src/features/projects/components/AddLinkForm.vue deleted file mode 100644 index f5e7350f..00000000 --- a/services/assistant-ui/src/features/projects/components/AddLinkForm.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/services/assistant-ui/src/features/projects/components/AttachKeyWizard.vue b/services/assistant-ui/src/features/projects/components/AttachKeyWizard.vue deleted file mode 100644 index 20451fa0..00000000 --- a/services/assistant-ui/src/features/projects/components/AttachKeyWizard.vue +++ /dev/null @@ -1,94 +0,0 @@ - - -