From a08f2b37ff4c4828f9d08433be6c2221e91636a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 7 Sep 2026 19:28:31 +0100 Subject: [PATCH] feat(backend): enforce required store parity (#3459) --- .../backend-tests-workflow-contract_test.py | 101 +- .github/workflows/backend-tests.yml | 119 +- .github/workflows/ci-base-image.yml | 18 + .github/workflows/lint-action-pinning.yml | 3 + AGENTS.md | 8 + apps/backend/AGENTS.md | 10 +- apps/backend/Makefile | 6 +- apps/backend/cmd/sqlguard/main.go | 83 + .../internal/agent/settings/store/sqlite.go | 38 +- .../analytics/repository/sqlite/stats.go | 32 +- apps/backend/internal/auth/hostnames/store.go | 6 + apps/backend/internal/auth/store/schema.go | 10 +- apps/backend/internal/auth/store/store.go | 6 + apps/backend/internal/automation/store.go | 54 +- apps/backend/internal/azuredevops/store.go | 11 +- .../internal/backendapp/health_test.go | 45 + apps/backend/internal/backendapp/helpers.go | 12 + apps/backend/internal/backendapp/main.go | 74 +- .../internal/backendapp/orchestrator.go | 36 +- .../backendapp/persistence_middleware.go | 39 + .../backendapp/persistence_middleware_test.go | 166 + .../backendapp/plugins_version_test.go | 82 +- apps/backend/internal/backendapp/services.go | 333 +- apps/backend/internal/backendapp/storage.go | 149 +- .../backendapp/storage_maintenance.go | 184 +- .../internal/backendapp/storage_test.go | 139 + apps/backend/internal/backendapp/types.go | 15 +- .../internal/db/dialect/dialect_test.go | 9 + apps/backend/internal/db/dialect/schema.go | 90 + .../internal/db/dialect/schema_test.go | 82 + apps/backend/internal/db/dialect/time.go | 11 + apps/backend/internal/db/exec.go | 40 + apps/backend/internal/db/exec_test.go | 67 + apps/backend/internal/db/migratelog.go | 46 +- apps/backend/internal/db/migratelog_test.go | 31 + apps/backend/internal/db/postgres.go | 36 +- apps/backend/internal/db/postgres_test.go | 31 + apps/backend/internal/db/schema.go | 45 +- .../internal/db/schema_postgres_test.go | 13 +- apps/backend/internal/db/sqlguard/analyzer.go | 634 +++ .../internal/db/sqlguard/analyzer_test.go | 230 ++ .../internal/db/sqlguard/exemptions.json | 123 + .../internal/db/sqlguard/exemptions_test.go | 17 + .../db/sqlguard/testdata/exemptions.json | 10 + apps/backend/internal/delivery/store.go | 78 +- apps/backend/internal/github/store.go | 85 +- apps/backend/internal/gitlab/store.go | 10 +- apps/backend/internal/jira/store.go | 11 +- apps/backend/internal/linear/store.go | 11 +- .../internal/notifications/store/sqlite.go | 4 +- .../internal/office/repository/sqlite/base.go | 8 +- .../repository/sqlite/base_migrations.go | 323 +- .../sqlite/run_outcome_activation.go | 7 +- .../office/repository/sqlite/tasks.go | 4 + .../messagequeue/repository_sqlite.go | 17 + .../persistence/requiredstores/catalog.go | 221 ++ .../requiredstores/catalog_test.go | 111 + .../persistence/requiredstores/health.go | 233 ++ .../persistence/requiredstores/health_test.go | 99 + .../persistence/requiredstores/tracker.go | 261 ++ .../requiredstores/tracker_test.go | 113 + .../persistence/storeconformance/adapters.go | 505 +++ .../storeconformance/owner_actions.go | 3134 +++++++++++++++ .../persistence/storeconformance/scenarios.go | 907 +++++ .../storeconformance/suite_test.go | 78 + .../testdata/upgrades/v0.93.0/manifest.json | 96 + .../testdata/upgrades/v0.93.0/postgres.sql | 3436 +++++++++++++++++ .../testdata/upgrades/v0.93.0/sqlite.sql | 1503 +++++++ .../storeconformance/upgrade_test.go | 368 ++ apps/backend/internal/plugins/provider.go | 204 +- .../backend/internal/plugins/provider_test.go | 22 + .../internal/plugins/settings_store.go | 65 + apps/backend/internal/runtimeflags/sqlite.go | 26 + apps/backend/internal/secrets/sqlite_store.go | 5 +- apps/backend/internal/sentry/store.go | 10 +- .../internal/system/persistence/handler.go | 79 + .../system/persistence/handler_test.go | 60 + .../backend/internal/system/settings/store.go | 26 + apps/backend/internal/system/system.go | 25 +- .../internal/task/repository/sqlite/base.go | 2 +- .../task/repository/sqlite/base_migrations.go | 18 +- .../task/repository/sqlite/base_schema.go | 17 +- .../repository/sqlite/slack_retirement.go | 20 +- .../sqlite/task_queue_purge_postgres_test.go | 34 + .../sqlite/team_access_migration.go | 8 +- .../sqlite/worktree_ownership_migration.go | 6 +- .../backend/internal/task/share/repository.go | 56 +- .../internal/task/share/repository_test.go | 15 + .../internal/telemetrycontract/store.go | 26 + apps/backend/internal/testutil/postgres.go | 5 +- .../testutil/storeconformance/engine.go | 67 + .../storeconformance/engine_behavior.go | 192 + .../testutil/storeconformance/suite.go | 243 ++ .../testutil/storeconformance/suite_test.go | 102 + apps/backend/internal/user/store/sqlite.go | 14 +- apps/backend/internal/utility/store/sqlite.go | 45 +- .../internal/workflow/repository/sqlite.go | 5 +- apps/backend/internal/workflowsync/store.go | 3 +- .../scripts/update-store-schema-fixtures.sh | 46 + ...026-09-05-required-internal-persistence.md | 55 + docs/decisions/INDEX.md | 1 + .../required-store-drift-prevention/plan.md | 144 + .../task-01-required-store-catalog.md | 87 + .../task-02-shared-sql-rendering.md | 89 + .../task-03-sql-dialect-safety.md | 81 + .../task-04-required-store-bootstrap.md | 98 + .../task-05-conformance-framework.md | 87 + .../task-06-catalog-conformance.md | 92 + .../task-07-upgrade-fixtures.md | 85 + .../task-08-required-store-health.md | 95 + .../task-09-ci-contributor-gates.md | 102 + docs/public/backend-development.md | 24 + docs/public/operations.md | 19 + docs/specs/platform/README.md | 4 +- .../postgres-domain-store-parity.md | 101 +- .../postgres-domain-store-parity.md | 246 +- .../system-design/dirty-worktree-deletion.md | 1 - 117 files changed, 17035 insertions(+), 739 deletions(-) create mode 100644 apps/backend/cmd/sqlguard/main.go create mode 100644 apps/backend/internal/backendapp/persistence_middleware.go create mode 100644 apps/backend/internal/backendapp/persistence_middleware_test.go create mode 100644 apps/backend/internal/db/dialect/schema.go create mode 100644 apps/backend/internal/db/dialect/schema_test.go create mode 100644 apps/backend/internal/db/exec.go create mode 100644 apps/backend/internal/db/exec_test.go create mode 100644 apps/backend/internal/db/postgres_test.go create mode 100644 apps/backend/internal/db/sqlguard/analyzer.go create mode 100644 apps/backend/internal/db/sqlguard/analyzer_test.go create mode 100644 apps/backend/internal/db/sqlguard/exemptions.json create mode 100644 apps/backend/internal/db/sqlguard/exemptions_test.go create mode 100644 apps/backend/internal/db/sqlguard/testdata/exemptions.json create mode 100644 apps/backend/internal/persistence/requiredstores/catalog.go create mode 100644 apps/backend/internal/persistence/requiredstores/catalog_test.go create mode 100644 apps/backend/internal/persistence/requiredstores/health.go create mode 100644 apps/backend/internal/persistence/requiredstores/health_test.go create mode 100644 apps/backend/internal/persistence/requiredstores/tracker.go create mode 100644 apps/backend/internal/persistence/requiredstores/tracker_test.go create mode 100644 apps/backend/internal/persistence/storeconformance/adapters.go create mode 100644 apps/backend/internal/persistence/storeconformance/owner_actions.go create mode 100644 apps/backend/internal/persistence/storeconformance/scenarios.go create mode 100644 apps/backend/internal/persistence/storeconformance/suite_test.go create mode 100644 apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/manifest.json create mode 100644 apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/postgres.sql create mode 100644 apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/sqlite.sql create mode 100644 apps/backend/internal/persistence/storeconformance/upgrade_test.go create mode 100644 apps/backend/internal/system/persistence/handler.go create mode 100644 apps/backend/internal/system/persistence/handler_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/task_queue_purge_postgres_test.go create mode 100644 apps/backend/internal/testutil/storeconformance/engine.go create mode 100644 apps/backend/internal/testutil/storeconformance/engine_behavior.go create mode 100644 apps/backend/internal/testutil/storeconformance/suite.go create mode 100644 apps/backend/internal/testutil/storeconformance/suite_test.go create mode 100644 apps/backend/scripts/update-store-schema-fixtures.sh create mode 100644 docs/decisions/2026-09-05-required-internal-persistence.md create mode 100644 docs/plans/required-store-drift-prevention/plan.md create mode 100644 docs/plans/required-store-drift-prevention/task-01-required-store-catalog.md create mode 100644 docs/plans/required-store-drift-prevention/task-02-shared-sql-rendering.md create mode 100644 docs/plans/required-store-drift-prevention/task-03-sql-dialect-safety.md create mode 100644 docs/plans/required-store-drift-prevention/task-04-required-store-bootstrap.md create mode 100644 docs/plans/required-store-drift-prevention/task-05-conformance-framework.md create mode 100644 docs/plans/required-store-drift-prevention/task-06-catalog-conformance.md create mode 100644 docs/plans/required-store-drift-prevention/task-07-upgrade-fixtures.md create mode 100644 docs/plans/required-store-drift-prevention/task-08-required-store-health.md create mode 100644 docs/plans/required-store-drift-prevention/task-09-ci-contributor-gates.md diff --git a/.github/scripts/backend-tests-workflow-contract_test.py b/.github/scripts/backend-tests-workflow-contract_test.py index 60682cce58..396abccc9a 100644 --- a/.github/scripts/backend-tests-workflow-contract_test.py +++ b/.github/scripts/backend-tests-workflow-contract_test.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 -"""Contract tests for bounded backend test summaries and retained diagnostics.""" +"""Contract tests for persistence gates and bounded backend test summaries.""" from pathlib import Path import unittest REPO_ROOT = Path(__file__).resolve().parents[2] -BACKEND_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "backend-tests.yml" +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "backend-tests.yml" +BACKEND_WORKFLOW = WORKFLOW +BASE_IMAGE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ci-base-image.yml" LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml" +POSTGRES_18_DIGEST = "sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280" + def step_block(workflow: str, name: str) -> str: marker = f" - name: {name}\n" @@ -19,42 +23,65 @@ def step_block(workflow: str, name: str) -> str: class BackendTestsWorkflowContractTest(unittest.TestCase): + def setUp(self) -> None: + self.workflow = WORKFLOW.read_text(encoding="utf-8") + self.base_image_workflow = BASE_IMAGE_WORKFLOW.read_text(encoding="utf-8") + + def test_postgres_16_uses_fixed_catalog_commands(self) -> None: + self.assertNotIn("PostgresDSNFromEnv", self.workflow) + self.assertNotIn("mapfile -t postgres_packages", self.workflow) + self.assertIn("./internal/persistence/storeconformance ./internal/backendapp", self.workflow) + for test_name in ( + "TestStoreCatalogCompleteness", + "TestStoreConformance", + "TestPreviousStableUpgrade", + "TestPostgresBootInitializesRepositories", + ): + self.assertIn(test_name, self.workflow) + + def test_sql_guard_and_local_conformance_are_explicit(self) -> None: + self.assertIn("go run ./cmd/sqlguard ./internal", self.workflow) + self.assertIn("Run catalog and SQLite conformance gates", self.workflow) + self.assertIn("TestUpgradeFixtureManifest", self.workflow) + + def test_postgres_18_is_a_required_pinned_gate(self) -> None: + self.assertIn("postgres-18:", self.workflow) + self.assertIn("postgres-18@" + POSTGRES_18_DIGEST, self.workflow) + self.assertIn("POSTGRES_18_RESULT", self.workflow) + self.assertIn('"postgres-18:${POSTGRES_18_RESULT}"', self.workflow) + self.assertIn("TestPreviousStableUpgrade", self.workflow) + + def test_base_image_mirrors_postgres_18_at_the_same_digest(self) -> None: + self.assertIn("POSTGRES_18_DIGEST: " + POSTGRES_18_DIGEST, self.base_image_workflow) + self.assertIn("POSTGRES_18_SOURCE: docker.io/library/postgres:18@" + POSTGRES_18_DIGEST, self.base_image_workflow) + self.assertIn("POSTGRES_18_TARGET: ghcr.io/kdlbs/kandev-ci:postgres-18", self.base_image_workflow) + self.assertIn("docker buildx imagetools create", self.base_image_workflow) + self.assertIn('--tag "${POSTGRES_18_TARGET}"', self.base_image_workflow) + + def test_contract_is_registered_in_the_unfiltered_lint_workflow(self) -> None: + lint_workflow = LINT_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("python3 .github/scripts/backend-tests-workflow-contract_test.py", lint_workflow) + self.assertIn("python3 .github/scripts/bounded-step-summary_test.py", lint_workflow) + def test_generated_report_is_redirected_then_published_with_a_bound(self) -> None: - workflow = BACKEND_WORKFLOW.read_text(encoding="utf-8") - checkout = step_block(workflow, "Checkout Go test reporter") - generate = step_block(workflow, "Generate test report") - publish = step_block(workflow, "Publish bounded test report summary") + checkout = step_block(self.workflow, "Checkout Go test reporter") + generate = step_block(self.workflow, "Generate test report") + publish = step_block(self.workflow, "Publish bounded test report summary") temporary_summary = "$RUNNER_TEMP/backend-test-summary-${BACKEND_TEST_SHARD}.md" - self.assertIn( - "uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", - checkout, - ) + self.assertIn("uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", checkout) self.assertIn("repository: robherley/go-test-action", checkout) - self.assertIn( - "ref: 2f859e0c8769d755d3174eecb9af8f64660827f3", - checkout, - ) + self.assertIn("ref: 2f859e0c8769d755d3174eecb9af8f64660827f3", checkout) self.assertIn("path: .github/vendor/go-test-action", checkout) self.assertIn(f'report_summary="{temporary_summary}"', generate) self.assertIn("BACKEND_TEST_SHARD: ${{ matrix.shard }}", generate) self.assertNotIn("${{ runner.temp }}", generate) self.assertIn(': > "$report_summary"', generate) self.assertIn('GITHUB_STEP_SUMMARY="$report_summary"', generate) - self.assertIn( - 'INPUT_FROMJSONFILE="$GITHUB_WORKSPACE/apps/backend/' - 'test-results-${BACKEND_TEST_SHARD}.json"', - generate, - ) - self.assertIn( - 'INPUT_MODULEDIRECTORY="$GITHUB_WORKSPACE/apps/backend"', - generate, - ) + self.assertIn('INPUT_FROMJSONFILE="$GITHUB_WORKSPACE/apps/backend/test-results-${BACKEND_TEST_SHARD}.json"', generate) + self.assertIn('INPUT_MODULEDIRECTORY="$GITHUB_WORKSPACE/apps/backend"', generate) self.assertIn('INPUT_OMIT="successful"', generate) - self.assertIn( - 'node ".github/vendor/go-test-action/dist/index.js"', - generate, - ) + self.assertIn('node ".github/vendor/go-test-action/dist/index.js"', generate) self.assertNotIn("uses: robherley/go-test-action", generate) self.assertIn("if: always()", publish) self.assertIn("python3 .github/scripts/bounded-step-summary.py", publish) @@ -65,31 +92,15 @@ class BackendTestsWorkflowContractTest(unittest.TestCase): self.assertIn("$GITHUB_RUN_ID#artifacts", publish) def test_full_json_diagnostics_remain_in_the_named_artifact(self) -> None: - workflow = BACKEND_WORKFLOW.read_text(encoding="utf-8") - upload = step_block(workflow, "Upload test artifacts") - + upload = step_block(self.workflow, "Upload test artifacts") self.assertIn("if: always()", upload) self.assertIn("name: backend-test-results-${{ matrix.shard }}", upload) self.assertIn("apps/backend/test-results-${{ matrix.shard }}.json", upload) def test_summary_writer_changes_run_the_backend_workflow(self) -> None: - workflow = BACKEND_WORKFLOW.read_text(encoding="utf-8") - detect = step_block(workflow, "Detect relevant changes") - + detect = step_block(self.workflow, "Detect relevant changes") self.assertIn(".github/scripts/bounded-step-summary.py", detect) - def test_bounded_summary_contract_runs_in_ci(self) -> None: - workflow = LINT_WORKFLOW.read_text(encoding="utf-8") - - self.assertIn( - "python3 .github/scripts/bounded-step-summary_test.py", - workflow, - ) - self.assertIn( - "python3 .github/scripts/backend-tests-workflow-contract_test.py", - workflow, - ) - if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index c7a85c9d2c..6898067f99 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -178,6 +178,15 @@ jobs: - name: Build run: go build -v ./cmd/kandev + - name: Check required-store SQL portability + run: go run ./cmd/sqlguard ./internal + + - name: Run catalog and SQLite conformance gates + run: | + set -euo pipefail + go test -v -race ./internal/persistence/storeconformance \ + -run '^(TestStoreCatalogCompleteness|TestStoreConformance|TestUpgradeFixtureManifest|TestPreviousStableUpgrade)$' + # Assert the run/dev/start-debug targets expand to the right per-shell # command line (native Windows / Git Bash / Unix). The windows-latest job # below compiles Go but never exercises this Make-target dispatch, so a @@ -475,6 +484,7 @@ jobs: - test_shards - test_ambient_env - postgres-boot + - postgres-18 - test-windows if: ${{ always() }} steps: @@ -486,6 +496,7 @@ jobs: TEST_SHARDS_RESULT: ${{ needs.test_shards.result }} TEST_AMBIENT_ENV_RESULT: ${{ needs.test_ambient_env.result }} POSTGRES_BOOT_RESULT: ${{ needs['postgres-boot'].result }} + POSTGRES_18_RESULT: ${{ needs['postgres-18'].result }} TEST_WINDOWS_RESULT: ${{ needs['test-windows'].result }} run: | set -euo pipefail @@ -496,6 +507,7 @@ jobs: echo "Test shards: ${TEST_SHARDS_RESULT}" echo "Ambient env tests: ${TEST_AMBIENT_ENV_RESULT}" echo "Postgres boot: ${POSTGRES_BOOT_RESULT}" + echo "Postgres 18: ${POSTGRES_18_RESULT}" echo "Windows tests: ${TEST_WINDOWS_RESULT}" # Runner planning must succeed because its outputs select the runner @@ -525,6 +537,7 @@ jobs: "test_shards:${TEST_SHARDS_RESULT}" \ "test_ambient_env:${TEST_AMBIENT_ENV_RESULT}" \ "postgres-boot:${POSTGRES_BOOT_RESULT}" \ + "postgres-18:${POSTGRES_18_RESULT}" \ "test-windows:${TEST_WINDOWS_RESULT}"; do name="${entry%%:*}" result="${entry#*:}" @@ -578,31 +591,103 @@ jobs: with: persist-credentials: false - - name: Run env-gated Postgres tests + - name: Run fixed PostgreSQL 16 persistence gates run: | set -euo pipefail - - mapfile -t postgres_packages < <( - grep -RIl 'PostgresDSNFromEnv' . --include='*_test.go' \ - | xargs -r dirname \ - | sort -u + # Keep this package list explicit. PostgreSQL coverage is a reviewed + # contract, not a grep over test source markers that can silently + # omit a package when a test is renamed or moved. + POSTGRES_PACKAGES=( + ./internal/agent/settings/store + ./internal/analytics/repository/sqlite + ./internal/automation + ./internal/azuredevops + ./internal/backendapp + ./internal/db + ./internal/delivery + ./internal/github + ./internal/gitlab + ./internal/jira + ./internal/linear + ./internal/notifications/store + ./internal/office/configsync + ./internal/office/repository/sqlite + ./internal/orchestrator/messagequeue + ./internal/persistence + ./internal/persistence/storeconformance + ./internal/plugins/state + ./internal/prompts/store + ./internal/runs/service + ./internal/runtimeflags + ./internal/secrets + ./internal/sentry + ./internal/system/settings + ./internal/system/storage + ./internal/task/repository/sqlite + ./internal/telemetrycontract + ./internal/terminal/repository + ./internal/user/store + ./internal/workflow/repository + ./internal/workflowsync + ./internal/worktree ) + go test -v -race "${POSTGRES_PACKAGES[@]}" - if [[ "${#postgres_packages[@]}" -eq 0 ]]; then - echo "::error::No Postgres test packages found" - exit 1 - fi + postgres-18: + name: Backend Postgres 18 + needs: changes + if: needs.changes.outputs.run == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + timeout-minutes: 20 + container: ghcr.io/kdlbs/kandev-ci:build-latest + services: + postgres: + image: ghcr.io/kdlbs/kandev-ci:postgres-18@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280 + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + env: + POSTGRES_USER: kandev + POSTGRES_PASSWORD: kandev + POSTGRES_DB: kandev_admin + options: >- + --health-cmd "pg_isready -U kandev -d kandev_admin" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + defaults: + run: + shell: bash + working-directory: apps/backend + env: + KANDEV_TEST_POSTGRES_DSN: host=postgres port=5432 user=kandev password=kandev dbname=kandev_admin sslmode=disable - printf 'Running Postgres tests in:\n' - printf ' %s\n' "${postgres_packages[@]}" + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + - name: Run PostgreSQL 18 boot and upgrade gates + run: | + set -euo pipefail output_file="$(mktemp)" - go test -v -race "${postgres_packages[@]}" 2>&1 | tee "${output_file}" + go test -v -race \ + ./internal/persistence/storeconformance ./internal/backendapp \ + -run '^(TestPreviousStableUpgrade|TestPostgresBootInitializesRepositories)$' \ + 2>&1 | tee "${output_file}" - if ! grep -q '^=== RUN TestPostgresBootInitializesRepositories$' "${output_file}"; then - echo "::error::Postgres boot smoke test matched no tests" - exit 1 - fi + for test_name in \ + '=== RUN TestPreviousStableUpgrade' \ + '=== RUN TestPostgresBootInitializesRepositories'; do + if ! grep -q "^${test_name}$" "${output_file}"; then + echo "::error::PostgreSQL 18 gate matched no ${test_name#* } test" + exit 1 + fi + done test-windows: name: Backend (windows) diff --git a/.github/workflows/ci-base-image.yml b/.github/workflows/ci-base-image.yml index 955ec76698..c1065d5164 100644 --- a/.github/workflows/ci-base-image.yml +++ b/.github/workflows/ci-base-image.yml @@ -28,6 +28,9 @@ env: POSTGRES_DIGEST: sha256:fe03a7605299a34ddf5e4f285dff78c3d7190a576b3c6b46f2fcff69f4bffd54 POSTGRES_SOURCE: docker.io/library/postgres:16@sha256:fe03a7605299a34ddf5e4f285dff78c3d7190a576b3c6b46f2fcff69f4bffd54 POSTGRES_TARGET: ghcr.io/kdlbs/kandev-ci:postgres-16 + POSTGRES_18_DIGEST: sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280 + POSTGRES_18_SOURCE: docker.io/library/postgres:18@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280 + POSTGRES_18_TARGET: ghcr.io/kdlbs/kandev-ci:postgres-18 jobs: build-and-push: @@ -69,6 +72,10 @@ jobs: --tag "${POSTGRES_TARGET}" \ "${POSTGRES_SOURCE}" + docker buildx imagetools create \ + --tag "${POSTGRES_18_TARGET}" \ + "${POSTGRES_18_SOURCE}" + mirrored_digest="sha256:$( docker buildx imagetools inspect --raw "${POSTGRES_TARGET}" \ | sha256sum \ @@ -79,6 +86,16 @@ jobs: exit 1 fi + mirrored_18_digest="sha256:$( + docker buildx imagetools inspect --raw "${POSTGRES_18_TARGET}" \ + | sha256sum \ + | cut -d ' ' -f 1 + )" + if [[ "${mirrored_18_digest}" != "${POSTGRES_18_DIGEST}" ]]; then + echo "::error::Postgres 18 mirror digest ${mirrored_18_digest} does not match ${POSTGRES_18_DIGEST}" + exit 1 + fi + - name: Compute image tag id: tag run: | @@ -159,6 +176,7 @@ jobs: echo "- build: \`${IMAGE_NAME}:build-sha-${IMAGE_TAG}\` / \`build-latest\`" echo "- desktop: \`${IMAGE_NAME}:desktop-sha-${IMAGE_TAG}\` / \`desktop-latest\`" echo "- postgres: \`${POSTGRES_TARGET}@${POSTGRES_DIGEST}\`" + echo "- postgres 18: \`${POSTGRES_18_TARGET}@${POSTGRES_18_DIGEST}\`" echo "- pushed: \`${PUSHED}\`" } >> "$GITHUB_STEP_SUMMARY" env: diff --git a/.github/workflows/lint-action-pinning.yml b/.github/workflows/lint-action-pinning.yml index ba87cbff2b..d26151539f 100644 --- a/.github/workflows/lint-action-pinning.yml +++ b/.github/workflows/lint-action-pinning.yml @@ -93,6 +93,9 @@ jobs: - name: Test managed runtime pin update workflow contract run: python3 .github/scripts/update-agent-runtime-pins-workflow-contract_test.py + - name: Test backend tests workflow contract + run: python3 .github/scripts/backend-tests-workflow-contract_test.py + - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: diff --git a/AGENTS.md b/AGENTS.md index 2b1ff76c2b..4aede4c3e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,14 @@ Production comments state the invariant, not the argument for it: no `AC-NN` ref Every code change must include tests for new or changed logic. Backend: `*_test.go` files alongside the source. Frontend: `*.test.ts` files for utility functions, hooks, API clients, and store slices. Exceptions: config files, generated code, React component markup. Use `/tdd` for test-driven development. +Every built-in SQL schema owner also belongs in +`apps/backend/internal/persistence/requiredstores` and the fixed +`apps/backend/internal/persistence/storeconformance` adapter set. Persistence +changes must run the SQL guard, fresh/replay conformance, and the relevant +SQLite/PostgreSQL boot or upgrade tests. Disabled features keep their required +schema; external provider credentials and remote APIs remain a separate, +degradable startup phase. + ### Internationalization The web UI is localized with i18next (`namespace:key`). **All new user-facing copy diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 514ce6545d..388ade1a51 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -281,16 +281,16 @@ Prefer stable error codes for new output so the frontend translates it. See `doc **Table-rebuild migrations:** When a legacy or constraint migration recreates a table, mirror every new column in the replacement `CREATE TABLE` and `INSERT ... SELECT` copy list; add a replay regression test proving values, including timestamps, survive. **Destructive cutover migrations:** Build a legacy schema with `NewWithDB` on a fresh database, replace final-shaped tables with legacy-shaped ones, inject a test-only failpoint after each cutover step, and assert byte-equivalent rollback; run the same matrix with `KANDEV_TEST_POSTGRES_DSN`, looking up PostgreSQL constraint names dynamically because they truncate at 63 bytes. +Every built-in SQL schema owner needs a descriptor in `internal/persistence/requiredstores` and a fixed adapter in `internal/persistence/storeconformance`. +Bootstrap records each constructor through the tracker; missing required schema fails before readiness. Provider credentials and remote probes remain independently degradable. +For persistence changes, run `go run ./cmd/sqlguard ./internal` and `go test -race ./internal/persistence/storeconformance -count=1`; set `KANDEV_TEST_POSTGRES_DSN` for PostgreSQL coverage and update the explicit-tag upgrade fixture and manifest when schema history changes. + ## Code-quality limits Enforced by `apps/backend/.golangci.yml` (errors on new code only): - Functions: ≤80 lines, ≤50 statements · Cyclomatic complexity: ≤15 · Cognitive complexity: ≤30 · Nesting depth: ≤5 · Naked returns only in functions ≤30 lines · No duplicated blocks (≥150 tokens) · Repeated strings → constants (≥3 occurrences) · Revive's 800-effective-line file limit also applies to test files; put new tests in a new file instead of appending to an already-large test file. -When a PR fixup touches backend code, run the CI-style changed-file linter locally from `apps/backend` with the PR base SHA before pushing, because CI enforces changed-file complexity thresholds: - -```bash -golangci-lint run ./... --new-from-rev="" --timeout=5m -``` +When a PR fixup touches backend code, run `golangci-lint run ./... --new-from-rev="" --timeout=5m` from `apps/backend` with the PR base SHA before pushing; CI enforces changed-file complexity thresholds. ## Further scoped notes - `internal/launcher/` — native launcher owning every entrypoint (`dev`, `start`, `run`, `service`); `dev` runs `make -C apps/backend dev` with Vite as a supervised child, state under `/.kandev-dev/`. The root `make dev` prebuilds only the copied launcher; the backend dev target builds the native agentctl and a linux/amd64 helper when the host is not Linux/amd64 (`docs/plans/go-dev-launcher/`). - `internal/agentctl/AGENTS.md` — agentctl server route groups, adapter model, ACP protocol diff --git a/apps/backend/Makefile b/apps/backend/Makefile index d6536ec0af..18cbd998c1 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -186,11 +186,15 @@ endif DEADCODE_VERSION ?= v0.48.0 DEADCODE_GO_TOOLCHAIN ?= go1.26.0 -.PHONY: all build build-dev build-runtime build-kandev build-all build-agentctl build-agentctl-dev build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint deadcode fmt vet check-make-shells help +.PHONY: all build build-dev build-runtime build-kandev build-all build-agentctl build-agentctl-dev build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint deadcode fmt vet sqlguard check-make-shells help ## Default target all: build +## Check SQL source for unsafe cross-dialect constructs. +sqlguard: + $(GO) run ./cmd/sqlguard ./internal + ## Build the preview CLI for deploying PR preview environments to Sprites build-preview: @echo "Building preview..." diff --git a/apps/backend/cmd/sqlguard/main.go b/apps/backend/cmd/sqlguard/main.go new file mode 100644 index 0000000000..c71d25664b --- /dev/null +++ b/apps/backend/cmd/sqlguard/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/kandev/kandev/internal/db/sqlguard" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string) error { + flags := flag.NewFlagSet("sqlguard", flag.ContinueOnError) + exemptionsPath := flags.String("exemptions", "internal/db/sqlguard/exemptions.json", "exact exemption registry") + if err := flags.Parse(args); err != nil { + return err + } + paths := flags.Args() + if len(paths) == 0 { + return errors.New("usage: sqlguard [-exemptions path] file-or-directory") + } + exemptions, err := sqlguard.LoadExemptions(*exemptionsPath) + if err != nil { + return err + } + files, err := goFiles(paths) + if err != nil { + return err + } + findings, err := sqlguard.CheckFiles(files, exemptions) + if err != nil { + return err + } + for _, finding := range findings { + fmt.Printf("%s:%d:%d: %s [%s] (%s)\n", finding.File, finding.Line, finding.Column, finding.Message, finding.Rule, finding.Symbol) + } + if len(findings) > 0 { + return fmt.Errorf("sqlguard found %d violation(s)", len(findings)) + } + return nil +} + +func goFiles(paths []string) ([]string, error) { + files := make([]string, 0) + for _, path := range paths { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", path, err) + } + if !info.IsDir() { + if filepath.Ext(path) == ".go" && !strings.HasSuffix(path, "_test.go") { + files = append(files, filepath.ToSlash(path)) + } + continue + } + err = filepath.WalkDir(path, func(candidate string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(candidate) != ".go" || strings.HasSuffix(candidate, "_test.go") { + return nil + } + files = append(files, filepath.ToSlash(candidate)) + return nil + }) + if err != nil { + return nil, fmt.Errorf("walk %s: %w", path, err) + } + } + sort.Strings(files) + return files, nil +} diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index 6cfe4e4bd0..d5f4e0bab4 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -40,7 +40,7 @@ func newSQLiteRepository(writer, reader *sqlx.DB, log *logger.Logger, ownsDB boo ro: reader, ownsDB: ownsDB, log: log, - migrate: db.NewMigrateLogger(writer, log), + migrate: db.NewRequiredMigrateLogger(writer, log), } if err := repo.initSchema(); err != nil { if ownsDB { @@ -176,8 +176,11 @@ func (r *sqliteRepository) initSchema() error { } // Migration: ADR 0005 Wave A — enrich agent_profiles with office columns. - // Each ALTER is idempotent — duplicate-column errors are swallowed. - r.migrateOfficeEnrichmentColumns() + // Duplicate-column errors are the only replay result tolerated by the + // required migration logger. + if err := r.migrateOfficeEnrichmentColumns(); err != nil { + return fmt.Errorf("failed to migrate agent profile office columns: %w", err) + } // command_prefix is added after the table-recreation migration above so a // legacy DB that recreates agent_profiles (to drop the model CHECK) still @@ -190,14 +193,17 @@ func (r *sqliteRepository) initSchema() error { // recreates agent_profiles would otherwise lose columns added before it. r.migrate.Apply("agent_profiles.fallback_model", `ALTER TABLE agent_profiles ADD COLUMN fallback_model TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("agent_profiles.auto_fallback", `ALTER TABLE agent_profiles ADD COLUMN auto_fallback INTEGER NOT NULL DEFAULT 0`) + if err := r.migrate.Err(); err != nil { + return fmt.Errorf("required agent settings migration: %w", err) + } return nil } // migrateOfficeEnrichmentColumns adds the office-enrichment columns introduced -// in ADR 0005 Wave A. Each ALTER is idempotent: errors raised when the column -// already exists are swallowed. -func (r *sqliteRepository) migrateOfficeEnrichmentColumns() { +// in ADR 0005 Wave A. Each ALTER is idempotent: only errors raised when the +// column already exists are tolerated. +func (r *sqliteRepository) migrateOfficeEnrichmentColumns() error { type migration struct { name string stmt string @@ -225,14 +231,26 @@ func (r *sqliteRepository) migrateOfficeEnrichmentColumns() { {"agent_profiles.permissions", `ALTER TABLE agent_profiles ADD COLUMN permissions TEXT NOT NULL DEFAULT '{}'`}, } for _, m := range migrations { - r.migrate.Apply(m.name, m.stmt) + if err := r.migrate.Apply(m.name, m.stmt); err != nil { + return err + } } // Indexes are idempotent via IF NOT EXISTS, but the partial-index variant // required for role/reports_to needs the column to exist first - so create // here after the ALTERs to handle databases upgraded from older schemas. - _, _ = r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_agent_profiles_workspace ON agent_profiles(workspace_id)`) - _, _ = r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_agent_profiles_role ON agent_profiles(workspace_id, role) WHERE role != ''`) - _, _ = r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_agent_profiles_reports_to ON agent_profiles(reports_to) WHERE reports_to != ''`) + if err := r.migrate.Apply("idx_agent_profiles_workspace", + `CREATE INDEX IF NOT EXISTS idx_agent_profiles_workspace ON agent_profiles(workspace_id)`); err != nil { + return err + } + if err := r.migrate.Apply("idx_agent_profiles_role", + `CREATE INDEX IF NOT EXISTS idx_agent_profiles_role ON agent_profiles(workspace_id, role) WHERE role != ''`); err != nil { + return err + } + if err := r.migrate.Apply("idx_agent_profiles_reports_to", + `CREATE INDEX IF NOT EXISTS idx_agent_profiles_reports_to ON agent_profiles(reports_to) WHERE reports_to != ''`); err != nil { + return err + } + return nil } // migrateDropModelCheckConstraint recreates agent_profiles without the legacy diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index f3a87d90ff..aa02d39659 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -65,6 +65,10 @@ func rangeStartArg(start *time.Time) any { return start.UTC() } +func rangeStartPredicate(driver, column string) string { + return fmt.Sprintf("(%s IS NULL OR %s >= ?)", dialect.NullableTimestamp(driver, "?"), column) +} + // GetTaskStats retrieves aggregated statistics for tasks in a workspace. func (r *Repository) GetTaskStats( ctx context.Context, @@ -104,7 +108,7 @@ func (r *Repository) GetTaskStats( FROM task_sessions s LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id - WHERE (? IS NULL OR s.started_at >= ?) + WHERE `+rangeStartPredicate(drv, "s.started_at")+` GROUP BY s.task_id ) session_stats ON session_stats.task_id = t.id LEFT JOIN ( @@ -113,10 +117,10 @@ func (r *Repository) GetTaskStats( %s as elapsed_span_ms FROM task_sessions s LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id - WHERE (? IS NULL OR s.started_at >= ?) + WHERE `+rangeStartPredicate(drv, "s.started_at")+` GROUP BY s.task_id ) turn_stats ON turn_stats.task_id = t.id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR t.created_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "t.created_at")+` ORDER BY t.updated_at DESC LIMIT ? `, dur, dialect.DurationMs( @@ -207,13 +211,13 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta SUM(CASE WHEN t.state = 'IN_PROGRESS' AND t.archived_at IS NULL THEN 1 ELSE 0 END) AS in_progress_tasks FROM tasks t LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR t.created_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "t.created_at")+` ), session_agg AS ( SELECT COUNT(*) AS total_sessions FROM task_sessions s JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "s.started_at")+` ), turn_agg AS ( SELECT @@ -222,7 +226,7 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta FROM task_session_turns turn JOIN task_sessions s ON s.id = turn.task_session_id JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "s.started_at")+` ), clean_turn_agg AS ( SELECT @@ -235,7 +239,7 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta FROM task_session_turns turn JOIN task_sessions s ON s.id = turn.task_session_id JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "s.started_at")+` AND turn.completed_at IS NOT NULL ) clean WHERE dur_ms >= %d AND dur_ms < %d AND msg_count >= %d @@ -248,7 +252,7 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta FROM task_session_messages msg JOIN task_sessions s ON s.id = msg.task_session_id JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "s.started_at")+` ) SELECT task_agg.total_tasks, task_agg.completed_tasks, task_agg.in_progress_tasks, @@ -469,7 +473,7 @@ func buildRepositoryStatsQuery(drv string) string { FROM task_repositories tr JOIN tasks t ON t.id = tr.task_id LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id - WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR t.created_at >= ?) + WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "t.created_at")+` GROUP BY tr.repository_id ) task_stats ON task_stats.repository_id = r.id LEFT JOIN ( @@ -484,7 +488,7 @@ func buildRepositoryStatsQuery(drv string) string { JOIN task_sessions s ON s.task_id = tr.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id - WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) + WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "s.started_at")+` GROUP BY tr.repository_id ) session_stats ON session_stats.repository_id = r.id LEFT JOIN ( @@ -494,7 +498,7 @@ func buildRepositoryStatsQuery(drv string) string { JOIN tasks t ON t.id = tr.task_id JOIN task_sessions s ON s.task_id = tr.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id - WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) + WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "s.started_at")+` GROUP BY tr.repository_id ) duration_stats ON duration_stats.repository_id = r.id LEFT JOIN ( @@ -506,7 +510,7 @@ func buildRepositoryStatsQuery(drv string) string { FROM task_session_commits c JOIN task_sessions s ON s.id = c.session_id JOIN tasks t ON t.id = s.task_id - WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND s.repository_id != '' AND (? IS NULL OR c.committed_at >= ?) + WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND s.repository_id != '' AND `+rangeStartPredicate(drv, "c.committed_at")+` GROUP BY s.repository_id ) git_stats ON git_stats.repository_id = r.id WHERE r.workspace_id = ? AND r.deleted_at IS NULL @@ -541,7 +545,7 @@ func (r *Repository) GetModelUsage(ctx context.Context, workspaceID string, limi COALESCE(%s, %s, %s, '') as model FROM task_sessions s JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND `+rangeStartPredicate(drv, "s.started_at")+` ) SELECT scoped.model, @@ -592,7 +596,7 @@ func (r *Repository) GetGitStats(ctx context.Context, workspaceID string, start FROM task_session_commits c JOIN task_sessions s ON s.id = c.session_id JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0` + andNotAutomationOriginT + ` AND (? IS NULL OR c.committed_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0` + andNotAutomationOriginT + ` AND ` + rangeStartPredicate(r.ro.DriverName(), "c.committed_at") + ` ` var stats models.GitStats diff --git a/apps/backend/internal/auth/hostnames/store.go b/apps/backend/internal/auth/hostnames/store.go index 3157520d0b..5890ee0b21 100644 --- a/apps/backend/internal/auth/hostnames/store.go +++ b/apps/backend/internal/auth/hostnames/store.go @@ -122,3 +122,9 @@ func (s *Store) Set(ctx context.Context, ip, hostname string, resolvedAt time.Ti ON CONFLICT(ip) DO UPDATE SET hostname = excluded.hostname, resolved_at = excluded.resolved_at`), ip, hostname, FormatTimestamp(resolvedAt)) return err } + +// Delete removes one cached hostname entry. +func (s *Store) Delete(ctx context.Context, ip string) error { + _, err := s.writer.ExecContext(ctx, s.writer.Rebind(`DELETE FROM auth_hostname_cache WHERE ip = ?`), ip) + return err +} diff --git a/apps/backend/internal/auth/store/schema.go b/apps/backend/internal/auth/store/schema.go index d01e2bfbf7..0528c27930 100644 --- a/apps/backend/internal/auth/store/schema.go +++ b/apps/backend/internal/auth/store/schema.go @@ -69,9 +69,15 @@ func (s *Store) initSchema() error { // An invite mints a member of exactly one organization, so it carries the // minting admin's org. CREATE TABLE IF NOT EXISTS is a no-op on an // existing database, so the column also needs an ADD COLUMN (ADR 0027). - db.NewMigrateLogger(s.db, nil).Apply( + migrate := db.NewRequiredMigrateLogger(s.db, nil) + if err := migrate.Apply( "auth_invites.org_id", `ALTER TABLE auth_invites ADD COLUMN org_id TEXT NOT NULL DEFAULT ''`, - ) + ); err != nil { + return fmt.Errorf("auth schema migration: %w", err) + } + if err := migrate.Err(); err != nil { + return fmt.Errorf("auth schema migration: %w", err) + } return nil } diff --git a/apps/backend/internal/auth/store/store.go b/apps/backend/internal/auth/store/store.go index 6d8f6146f2..9f82da6cb3 100644 --- a/apps/backend/internal/auth/store/store.go +++ b/apps/backend/internal/auth/store/store.go @@ -92,6 +92,12 @@ func (s *Store) UpdatePasswordHash(ctx context.Context, userID, passwordHash str return nil } +// DeleteIdentity removes one persisted login identity by its stable ID. +func (s *Store) DeleteIdentity(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM auth_identities WHERE id = ?`), id) + return err +} + // CountAdminIdentities reports how many active admin users have a local // identity. Zero means the instance is in setup mode when auth is required. func (s *Store) CountAdminIdentities(ctx context.Context) (int, error) { diff --git a/apps/backend/internal/automation/store.go b/apps/backend/internal/automation/store.go index 397971632f..06041212e6 100644 --- a/apps/backend/internal/automation/store.go +++ b/apps/backend/internal/automation/store.go @@ -135,8 +135,9 @@ const createTablesSQL = ` // In-branch column additions. The canonical CREATE TABLE covers fresh // installs; these ALTERs cover DBs already initialised from an earlier -// commit on this branch (the original PR #406 schema). SQLite returns a -// duplicate-column error when the column already exists, which we swallow. +// commit on this branch (the original PR #406 schema). Duplicate-column +// errors are the only replay result that the required migration logger +// tolerates; all other migration errors stop boot. // // automations.repository_id is retained as a legacy, write-once column: it // is never read or written by current code (repository selection now lives @@ -191,19 +192,33 @@ func (s *Store) initSchema() error { if _, err := s.db.Exec(schemaSQLForDriver(createTablesSQL, s.db.DriverName())); err != nil { return err } - s.db.Exec(schemaSQLForDriver(migrateTaskTitleSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateExecutionModeSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateRepositoryIDSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateContinuationPolicySQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateContinuationTaskSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateTaskModeSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateRepositoryModeSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateRepositoryBranchSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateRunSessionSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateRunTurnSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateRunThreadActionSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateRunThreadReasonSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(schemaSQLForDriver(migrateRunDisplayTitleSQL, s.db.DriverName())) //nolint:errcheck // duplicate-column on existing DBs + migrate := db.NewRequiredMigrateLogger(s.db, nil) + migrations := []struct { + name string + stmt string + }{ + {"automations.task_title_template", schemaSQLForDriver(migrateTaskTitleSQL, s.db.DriverName())}, + {"automations.execution_mode", schemaSQLForDriver(migrateExecutionModeSQL, s.db.DriverName())}, + {"automations.repository_id", schemaSQLForDriver(migrateRepositoryIDSQL, s.db.DriverName())}, + {"automations.continuation_policy", schemaSQLForDriver(migrateContinuationPolicySQL, s.db.DriverName())}, + {"automations.continuation_task_id", schemaSQLForDriver(migrateContinuationTaskSQL, s.db.DriverName())}, + {"automations.task_mode", schemaSQLForDriver(migrateTaskModeSQL, s.db.DriverName())}, + {"automations.repository_mode", schemaSQLForDriver(migrateRepositoryModeSQL, s.db.DriverName())}, + {"automation_repositories.base_branch", schemaSQLForDriver(migrateRepositoryBranchSQL, s.db.DriverName())}, + {"automation_runs.session_id", schemaSQLForDriver(migrateRunSessionSQL, s.db.DriverName())}, + {"automation_runs.turn_id", schemaSQLForDriver(migrateRunTurnSQL, s.db.DriverName())}, + {"automation_runs.thread_action", schemaSQLForDriver(migrateRunThreadActionSQL, s.db.DriverName())}, + {"automation_runs.thread_reason", schemaSQLForDriver(migrateRunThreadReasonSQL, s.db.DriverName())}, + {"automation_runs.display_title", schemaSQLForDriver(migrateRunDisplayTitleSQL, s.db.DriverName())}, + } + for _, migration := range migrations { + if err := migrate.Apply(migration.name, migration.stmt); err != nil { + return fmt.Errorf("required automation migration: %w", err) + } + } + if err := migrate.Err(); err != nil { + return fmt.Errorf("required automation migration: %w", err) + } if err := s.backfillLegacyRepositoryIDs(); err != nil { return err } @@ -1825,14 +1840,7 @@ func (s *Store) DeleteAutomationsByWorkspace(ctx context.Context, workspaceID st } func schemaSQLForDriver(schema, driver string) string { - schema = strings.ReplaceAll(schema, "DATETIME", dialect.TimestampType(driver)) - if dialect.IsPostgres(driver) { - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 1", "BOOLEAN NOT NULL DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 0", "BOOLEAN NOT NULL DEFAULT FALSE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE") - } - return schema + return dialect.MustRenderSchema(driver, schema) } // generateSecret creates a random hex string for webhook authentication. diff --git a/apps/backend/internal/azuredevops/store.go b/apps/backend/internal/azuredevops/store.go index b0cf59fd98..7a9eab007d 100644 --- a/apps/backend/internal/azuredevops/store.go +++ b/apps/backend/internal/azuredevops/store.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "time" "github.com/jmoiron/sqlx" @@ -434,13 +433,5 @@ func workspaceSettingsRowsUpdated(result sql.Result) (bool, error) { } func schemaSQLForDriver(schema, driver string) string { - schema = strings.ReplaceAll(schema, "TIMESTAMP", dialect.TimestampType(driver)) - schema = strings.ReplaceAll(schema, "DATETIME", dialect.TimestampType(driver)) - if dialect.IsPostgres(driver) { - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 1", "BOOLEAN NOT NULL DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 0", "BOOLEAN NOT NULL DEFAULT FALSE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE") - } - return schema + return dialect.MustRenderSchema(driver, schema) } diff --git a/apps/backend/internal/backendapp/health_test.go b/apps/backend/internal/backendapp/health_test.go index 24045373bb..b460e3cb66 100644 --- a/apps/backend/internal/backendapp/health_test.go +++ b/apps/backend/internal/backendapp/health_test.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" authhttpmw "github.com/kandev/kandev/internal/auth/httpmw" + "github.com/kandev/kandev/internal/persistence/requiredstores" "github.com/kandev/kandev/internal/system/info" ) @@ -112,6 +113,50 @@ func TestReadyHandlerBodyShapesByReadiness(t *testing.T) { } } +func TestReadyHandlerReportsPersistenceFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + setReadyForTest(t, true) + tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ + ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + }}) + if err != nil { + t.Fatalf("NewTracker: %v", err) + } + if err := tracker.RecordSuccess("task"); err != nil { + t.Fatalf("RecordSuccess: %v", err) + } + if err := tracker.RecordProbe("task", errTestPersistence); err != nil { + t.Fatalf("RecordProbe: %v", err) + } + + router := gin.New() + router.GET("/ready", readyHandler(routeParams{ + version: "1.2.3", + persistenceHealth: requiredstores.NewHealth(tracker, nil, nil), + })) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/ready", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("persistence status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode persistence body: %v", err) + } + if body["reason"] != "persistence" { + t.Fatalf("persistence reason = %v, want persistence", body["reason"]) + } + if body["store_ids"].([]interface{})[0] != "task" { + t.Fatalf("persistence store_ids = %v, want task", body["store_ids"]) + } +} + +var errTestPersistence = persistenceTestError("probe failed") + +type persistenceTestError string + +func (e persistenceTestError) Error() string { return string(e) } + // TestHealthHandlerDesktopTokenHeaderAlwaysSetReadyEndpointNever covers AC-21: // the desktop health-token header is a /health-only concern, unaffected by // the liveness/readiness split — /health sets it on every request regardless diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index da3365e5b8..b80ab4356a 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -69,6 +69,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/org" "github.com/kandev/kandev/internal/orgunit" + "github.com/kandev/kandev/internal/persistence/requiredstores" "github.com/kandev/kandev/internal/plugins" pluginstore "github.com/kandev/kandev/internal/plugins/store" "github.com/kandev/kandev/internal/profiles" @@ -694,6 +695,7 @@ type routeParams struct { temporaryArtifacts *tempartifacts.Registry runtimeFlagsSvc *runtimeflags.Service dbPool *db.Pool + persistenceHealth *requiredstores.Health agentSettingsController *agentsettingscontroller.Controller agentSettingsRepo settingsstore.Repository agentList taskhandlers.AgentLister @@ -986,6 +988,16 @@ func readyHandler(p routeParams) gin.HandlerFunc { }) return } + if p.persistenceHealth != nil && !p.persistenceHealth.Healthy() { + c.JSON(http.StatusServiceUnavailable, gin.H{ + statusKey: startingStatus, + serviceFieldKey: kandevName, + versionFieldKey: version, + "reason": "persistence", + "store_ids": p.persistenceHealth.UnhealthyStoreIDs(), + }) + return + } c.JSON(http.StatusOK, gin.H{ statusKey: "ok", serviceFieldKey: kandevName, diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 0ae508e9fd..e6be0a70e4 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -27,6 +27,7 @@ import ( "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/entityrefs" "github.com/kandev/kandev/internal/org" + "github.com/kandev/kandev/internal/persistence/requiredstores" "go.uber.org/zap" // Common packages @@ -451,9 +452,9 @@ func startServices( //nolint:cyclop return err == nil && record.Active() }) } - hostnameStore, err := hostnames.NewStore(dbPool.Writer(), dbPool.Reader()) - if err != nil { - log.Error("Failed to initialize session hostname cache", zap.Error(err)) + hostnameStore := repos.HostnameCache + if hostnameStore == nil { + log.Error("Failed to initialize session hostname cache: required store is unavailable") return false } hostnameResolver := hostnames.NewResolver( @@ -674,7 +675,8 @@ func startAgentInfrastructure( log.Info("Initializing Orchestrator...") orchestratorSvc, msgCreator, err := provideOrchestrator(cfg, log, dbPool, eventBus, repos.Task, services.Task, services.User, - lifecycleMgr, agentRegistry, services.Workflow, userSecretStore, repoCloner, services.Prompts, services.GitHub, services.GitCredentials) + lifecycleMgr, agentRegistry, services.Workflow, userSecretStore, repoCloner, services.Prompts, services.GitHub, services.GitCredentials, + repos.SystemSettings, repos.RequiredStores) if err != nil { log.Error("Failed to initialize orchestrator", zap.Error(err)) return false @@ -843,15 +845,16 @@ func startAgentInfrastructure( // tables exist (already true here, provided in provideRepositories) — // the ledger's foreign keys require them present at CREATE TABLE time // on PostgreSQL. services.Task satisfies delivery.CheckoutResolver. - if _, deliveryCleanup, err := delivery.Provide(dbPool.Writer(), dbPool.Reader(), services.Task, log); err != nil { - log.Warn("delivery ledger sweep unavailable", zap.Error(err)) - } else { - // Must be addRuntimeCleanup, not addCleanup: RestoreQuiesce only - // stops workers registered here, and a restore checkpoints, closes, - // and replaces the shared database pool. A five-minute sweep pass - // overlapping that would race the pool swap. - databaseQuiesce = addRuntimeCleanup(deliveryCleanup) + _, deliveryCleanup, deliveryErr := delivery.Provide(dbPool.Writer(), dbPool.Reader(), services.Task, log) + if recordErr := recordRequiredStore(repos.RequiredStores, "delivery", deliveryErr); recordErr != nil { + log.Error("delivery ledger initialization failed", zap.Error(recordErr)) + return false } + // Must be addRuntimeCleanup, not addCleanup: RestoreQuiesce only + // stops workers registered here, and a restore checkpoints, closes, + // and replaces the shared database pool. A five-minute sweep pass + // overlapping that would race the pool swap. + databaseQuiesce = addRuntimeCleanup(deliveryCleanup) return startGatewayAndServe(ctx, cfg, log, eventBus, agentRuntimeAvailability, dbPool, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, agentctlBinaryPath, @@ -1129,6 +1132,7 @@ func startGatewayAndServe( // Composed before HTTP routes so the registration pass below can mount // the /api/v1/system/* group; started before the listener so the // updates poller is alive as soon as we accept connections. + persistenceHealth := requiredstores.NewHealth(repos.RequiredStores, dbPool, log) systemSvc := systemsvc.Provide(cfg, log, dbPool, eventBus, systemsvc.BuildInfo{ Version: Version, Commit: Commit, @@ -1137,20 +1141,35 @@ func startGatewayAndServe( OrchestratorShutdown: func() { _ = orchestratorSvc.Stop() }, DatabaseQuiesce: databaseQuiesce, RestoreQuiesce: restoreQuiesce, + SystemSettings: repos.SystemSettings, + RequiredStores: repos.RequiredStores, + PersistenceHealth: persistenceHealth, MessageQueue: orchestratorSvc.GetMessageQueue(), MessageQueueConfig: queueConfiguration(cfg), TaskSessions: repos.Task, }) - storageComposition, err := provideStorageComposition( + storageComposition, err := provideStorageCompositionWithDependencies( cfg, dbPool, systemSvc.Jobs, eventBus, lifecycleMgr, services.WorktreeMgr, services.Task, log, func(message string, err error) { log.Error(message, zap.Error(err)) }, + repos.RequiredStores, repos.SystemSettings, ) if err != nil { log.Error("Failed to initialize storage maintenance", zap.Error(err)) closeBoundListeners(server, listeners, log) return false } + if err := repos.RequiredStores.ValidateComplete(); err != nil { + log.Error("Required-store bootstrap is incomplete", zap.Error(err)) + closeBoundListeners(server, listeners, log) + return false + } + if err := persistenceHealth.Check(ctx); err != nil { + log.Error("Required-store health check failed", zap.Error(err)) + closeBoundListeners(server, listeners, log) + return false + } + addCleanup(persistenceHealth.Start(ctx)) hostUtilityMgr.SetTemporaryArtifactRegistry(storageComposition.tempArtifacts) hostUtilityCtx, hostUtilityCancel := context.WithCancel(ctx) var hostUtilityWG sync.WaitGroup @@ -1220,7 +1239,7 @@ func startGatewayAndServe( builtServer, err := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController, lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr, addCleanup, repoCloner, systemSvc, storageComposition.workspaceRestorer, - storageComposition.tempArtifacts, dbPool, agentRuntimeAvailability) + storageComposition.tempArtifacts, dbPool, agentRuntimeAvailability, persistenceHealth) if err != nil { log.Error("Failed to build HTTP server", zap.Error(err)) closeBoundListeners(server, listeners, log) @@ -1232,6 +1251,19 @@ func startGatewayAndServe( zap.String("health", "/health"), zap.String("http", "/api/v1"), ) + // Record the version only after every required store, the immediate health + // probe, and the complete HTTP wiring have succeeded. A late startup + // failure must leave the previous version marker intact so the next boot + // still takes the upgrade backup. + if err := recordSchemaVersionAfterPersistence( + repos.RequiredStores.ValidateComplete, + func() error { return persistenceHealth.Check(ctx) }, + func() { recordSchemaVersion(dbPool.Writer(), cfg.Database.Driver, Version, log) }, + ); err != nil { + log.Error("Required persistence changed before readiness", zap.Error(err)) + closeBoundListeners(server, listeners, log) + return false + } // Flip readiness before swapping in the fully wired router — see // publishReadiness for why the order matters and @@ -2258,6 +2290,7 @@ func buildHTTPServer( temporaryArtifacts *tempartifacts.Registry, dbPool *db.Pool, agentRuntimeAvailability *agentctlclient.Availability, + persistenceHealth ...*requiredstores.Health, ) (*http.Server, error) { gin.SetMode(gin.ReleaseMode) router := gin.New() @@ -2298,8 +2331,16 @@ func buildHTTPServer( addCleanup, ) - // Opt-in authentication. Runs after CORS; in disabled mode it only - // injects the synthetic single-user identity (behavior unchanged). + var requiredHealth *requiredstores.Health + if len(persistenceHealth) > 0 { + requiredHealth = persistenceHealth[0] + } + // Gate stateful requests before authentication can query the required + // stores. This preserves the actionable persistence_unavailable 503 when + // auth is enabled and the database is unhealthy. + router.Use(requiredPersistenceMiddleware(requiredHealth)) + // Opt-in authentication. In disabled mode it only injects the synthetic + // single-user identity (behavior unchanged). router.Use(authhttpmw.Middleware(services.Auth)) // Per-user workspace ownership on the third-party integration route // groups (jira/gitlab/github/...), which resolve a caller-supplied @@ -2347,6 +2388,7 @@ func buildHTTPServer( temporaryArtifacts: temporaryArtifacts, runtimeFlagsSvc: services.RuntimeFlags, dbPool: dbPool, + persistenceHealth: requiredHealth, agentSettingsController: agentSettingsController, agentSettingsRepo: repos.AgentSettings, agentList: agentRegistry, diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index e1f9326731..a6e0f226c7 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -31,6 +31,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator" executorpkg "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/persistence/requiredstores" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" @@ -70,6 +71,8 @@ func provideOrchestrator( promptSvc *promptservice.Service, githubSvc *githubpkg.Service, gitCredentialBroker *gitcredentials.Broker, + settingsStore *systemsettings.Store, + trackers ...*requiredstores.Tracker, ) (*orchestrator.Service, *messageCreatorAdapter, error) { if lifecycleMgr == nil { return nil, nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)") @@ -98,10 +101,15 @@ func provideOrchestrator( zap.Int("agent_standalone_port", cfg.Agent.StandalonePort)) queueRepo, err := messagequeue.NewSQLiteRepository(pool.Writer(), pool.Reader()) + if len(trackers) > 0 && trackers[0] != nil { + if recordErr := recordRequiredStore(trackers[0], "message-queue", err); recordErr != nil { + return nil, nil, fmt.Errorf("message queue store: %w", recordErr) + } + } if err != nil { return nil, nil, fmt.Errorf("init message queue repo: %w", err) } - queueSettings := resolveQueueSettings(pool, log, queueConfiguration(cfg)).Effective + queueSettings := resolveQueueSettingsWithStore(settingsStore, pool, log, queueConfiguration(cfg)).Effective maxPerSession := queueSettings.MaxPerSession mergeEnabled := queueSettings.MergeEnabled autoMergeEnabled := queueSettings.AutoMergeEnabled @@ -345,18 +353,30 @@ func resolveQueueSettings( pool *db.Pool, log *logger.Logger, startup ...queuesettings.Configuration, +) queuesettings.Resolution { + return resolveQueueSettingsWithStore(nil, pool, log, startup...) +} + +func resolveQueueSettingsWithStore( + settingsStore *systemsettings.Store, + pool *db.Pool, + log *logger.Logger, + startup ...queuesettings.Configuration, ) queuesettings.Resolution { var configured *queuesettings.Settings - if pool != nil { - rawStore, err := systemsettings.NewStore(pool) + if settingsStore == nil && pool != nil { + var err error + settingsStore, err = systemsettings.NewStore(pool) if err != nil { log.Warn("Failed to initialize message queue settings store", zap.Error(err)) + } + } + if settingsStore != nil { + loaded, err := queuesettings.NewStore(settingsStore).Load(context.Background()) + if err != nil { + log.Warn("Ignoring invalid persisted message queue settings", zap.Error(err)) } else { - configured, err = queuesettings.NewStore(rawStore).Load(context.Background()) - if err != nil { - log.Warn("Ignoring invalid persisted message queue settings", zap.Error(err)) - configured = nil - } + configured = loaded } } resolution, err := queuesettings.Resolve(configured, queuesettings.ReadEnvironment(), startup...) diff --git a/apps/backend/internal/backendapp/persistence_middleware.go b/apps/backend/internal/backendapp/persistence_middleware.go new file mode 100644 index 0000000000..77a79f05a4 --- /dev/null +++ b/apps/backend/internal/backendapp/persistence_middleware.go @@ -0,0 +1,39 @@ +package backendapp + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/persistence/requiredstores" +) + +const persistenceDiagnosticsPath = "/api/v1/system/diagnostics/persistence" + +// requiredPersistenceMiddleware prevents stateful requests from reaching +// handlers while required local persistence is unavailable. Liveness, +// readiness, static assets, and the authenticated diagnostics endpoint remain +// reachable so operators can observe and recover the process. +func requiredPersistenceMiddleware(health *requiredstores.Health) gin.HandlerFunc { + return func(c *gin.Context) { + if health == nil || health.Healthy() || persistencePathExcluded(c.Request.URL.Path) { + c.Next() + return + } + c.Abort() + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "required persistence is unavailable", + "code": "persistence_unavailable", + "store_ids": health.UnavailableStoreIDs(), + "action": "Check the database connection and the persistence diagnostics, then retry.", + }) + } +} + +func persistencePathExcluded(path string) bool { + if path == "/health" || path == "/ready" || path == persistenceDiagnosticsPath { + return true + } + return !strings.HasPrefix(path, "/api/") && !strings.HasPrefix(path, "/mcp") && path != "/ws" +} diff --git a/apps/backend/internal/backendapp/persistence_middleware_test.go b/apps/backend/internal/backendapp/persistence_middleware_test.go new file mode 100644 index 0000000000..c7d843ba33 --- /dev/null +++ b/apps/backend/internal/backendapp/persistence_middleware_test.go @@ -0,0 +1,166 @@ +package backendapp + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/auth" + authhttpmw "github.com/kandev/kandev/internal/auth/httpmw" + authstore "github.com/kandev/kandev/internal/auth/store" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/persistence/requiredstores" + userstore "github.com/kandev/kandev/internal/user/store" +) + +func TestRequiredPersistenceMiddlewareBlocksStatefulTrafficButAllowsDiagnostics(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ + ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + }}) + if err != nil { + t.Fatalf("NewTracker: %v", err) + } + if err := tracker.RecordSuccess("task"); err != nil { + t.Fatalf("RecordSuccess: %v", err) + } + if err := tracker.RecordProbe("task", errTestPersistence); err != nil { + t.Fatalf("RecordProbe: %v", err) + } + + router := gin.New() + router.Use(requiredPersistenceMiddleware(requiredstores.NewHealth(tracker, nil, nil))) + router.GET("/api/v1/tasks", func(c *gin.Context) { c.Status(http.StatusOK) }) + router.GET("/api/v1/system/diagnostics/persistence", func(c *gin.Context) { c.Status(http.StatusOK) }) + router.GET("/health", func(c *gin.Context) { c.Status(http.StatusOK) }) + router.GET("/ready", func(c *gin.Context) { c.Status(http.StatusOK) }) + router.GET("/ws", func(c *gin.Context) { c.Status(http.StatusOK) }) + router.GET("/mcp", func(c *gin.Context) { c.Status(http.StatusOK) }) + + blocked := httptest.NewRecorder() + router.ServeHTTP(blocked, httptest.NewRequest(http.MethodPost, "/api/v1/tasks", nil)) + if blocked.Code != http.StatusServiceUnavailable { + t.Fatalf("blocked status = %d, want %d", blocked.Code, http.StatusServiceUnavailable) + } + if blocked.Header().Get("Content-Type") == "" { + t.Fatal("blocked response has no content type") + } + + for _, path := range []string{"/ws", "/mcp"} { + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusServiceUnavailable) + } + } + + for _, path := range []string{ + "/api/v1/system/diagnostics/persistence", "/health", "/ready", + } { + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusOK) + } + } +} + +func TestRequiredPersistenceMiddlewareRunsBeforeAuthentication(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ + ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + }}) + if err != nil { + t.Fatalf("NewTracker: %v", err) + } + if err := tracker.RecordSuccess("task"); err != nil { + t.Fatalf("RecordSuccess: %v", err) + } + if err := tracker.RecordProbe("task", errTestPersistence); err != nil { + t.Fatalf("RecordProbe: %v", err) + } + + authResolved := false + router := gin.New() + // This middleware stands in for auth-enabled token resolution. If the + // persistence gate is registered after it, a failed DB lookup becomes 401. + router.Use(requiredPersistenceMiddleware(requiredstores.NewHealth(tracker, nil, nil))) + router.Use(func(c *gin.Context) { + authResolved = true + c.AbortWithStatus(http.StatusUnauthorized) + }) + router.GET("/api/v1/tasks", func(c *gin.Context) { c.Status(http.StatusOK) }) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable) + } + if authResolved { + t.Fatal("authentication middleware ran before the persistence gate") + } +} + +func TestRequiredPersistenceMiddlewarePrecedesDatabaseBackedAuthentication(t *testing.T) { + gin.SetMode(gin.TestMode) + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + users, cleanup, err := userstore.Provide(conn, conn) + if err != nil { + _ = conn.Close() + t.Fatalf("user store: %v", err) + } + t.Cleanup(func() { + _ = cleanup() + _ = conn.Close() + }) + store, err := authstore.New(conn, conn) + if err != nil { + t.Fatalf("auth store: %v", err) + } + cfg := &config.Config{} + cfg.Features.Auth = true + svc, err := auth.NewService(context.Background(), auth.Deps{Cfg: cfg, Store: store, Users: users}) + if err != nil { + t.Fatalf("auth service: %v", err) + } + _, token, err := svc.Setup(context.Background(), "admin@example.test", "adminpass123", "Admin", "", "") + if err != nil { + t.Fatalf("setup auth session: %v", err) + } + if err := conn.Close(); err != nil { + t.Fatalf("close database: %v", err) + } + + tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ + ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + }}) + if err != nil { + t.Fatalf("NewTracker: %v", err) + } + if err := tracker.RecordSuccess("task"); err != nil { + t.Fatalf("RecordSuccess: %v", err) + } + if err := tracker.RecordProbe("task", errTestPersistence); err != nil { + t.Fatalf("RecordProbe: %v", err) + } + + router := gin.New() + router.Use(requiredPersistenceMiddleware(requiredstores.NewHealth(tracker, nil, nil))) + router.Use(authhttpmw.Middleware(svc)) + router.GET("/api/v1/tasks", func(c *gin.Context) { c.Status(http.StatusOK) }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil) + req.AddCookie(&http.Cookie{Name: svc.CookieName(), Value: token}) + router.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d (not authentication failure)", rec.Code, http.StatusServiceUnavailable) + } +} diff --git a/apps/backend/internal/backendapp/plugins_version_test.go b/apps/backend/internal/backendapp/plugins_version_test.go index 6b833d37f9..bdeab15908 100644 --- a/apps/backend/internal/backendapp/plugins_version_test.go +++ b/apps/backend/internal/backendapp/plugins_version_test.go @@ -2,11 +2,16 @@ package backendapp import ( "context" + "errors" + "reflect" + "strings" "testing" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/secrets" ) // TestProvideServicesWiresPluginsKandevVersion pins the production wiring of @@ -21,7 +26,7 @@ import ( func TestProvideServicesWiresPluginsKandevVersion(t *testing.T) { const wantVersion = "9.8.7" - services, _ := provideTestServices(t, wantVersion) + services, _, _ := provideTestServices(t, wantVersion) if services.Plugins == nil { t.Fatal("provideServices returned a nil Plugins service") } @@ -30,9 +35,71 @@ func TestProvideServicesWiresPluginsKandevVersion(t *testing.T) { } } +func TestRequiredStoreBootstrapCompleteness(t *testing.T) { + _, _, repos := provideTestServices(t, "test-bootstrap-completeness") + + // Repository and service construction cover every catalog entry except the + // three stores deliberately initialized in later startup phases. + wantPending := []string{"message-queue", "delivery", "storage"} + gotPending := repos.RequiredStores.UnavailableStoreIDs() + if !reflect.DeepEqual(gotPending, wantPending) { + t.Fatalf("pending required stores = %v, want %v", gotPending, wantPending) + } +} + +func TestRequiredStoreFailure(t *testing.T) { + tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ + ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + }}) + if err != nil { + t.Fatalf("NewTracker: %v", err) + } + constructorErr := errors.New("schema constructor failed") + got := recordRequiredStore(tracker, "task", constructorErr) + if !errors.Is(got, constructorErr) { + t.Fatalf("recordRequiredStore() error = %v, want %v", got, constructorErr) + } + if !strings.Contains(got.Error(), `required store "task"`) { + t.Fatalf("recordRequiredStore() error = %q, want store ID", got) + } + if err := tracker.ValidateComplete(); err == nil || !strings.Contains(err.Error(), "task") { + t.Fatalf("ValidateComplete() error = %v, want failed task store", err) + } +} + +func TestExternalProviderFailureIsolation(t *testing.T) { + t.Setenv("KANDEV_MOCK_GITHUB", "") + t.Setenv("GH_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("PATH", t.TempDir()) + + pool := newMessageQueueSettingsTestPool(t) + log := newTestLogger() + externalErr := errors.New("credential backend unavailable") + service, cleanup, err := initGitHubServiceRequired( + &config.Config{}, + pool, + bus.NewMemoryEventBus(log), + failingExternalSecretStore{err: externalErr}, + log, + ) + if err != nil { + t.Fatalf("external credential failure made local store fatal: %v", err) + } + if service == nil { + t.Fatal("GitHub service is nil after external credential failure") + } + if cleanup != nil { + t.Cleanup(func() { _ = cleanup() }) + } + if _, err := service.GetWorkspaceSettings(context.Background(), "external-failure-workspace"); err != nil { + t.Fatalf("local GitHub store unavailable after credential failure: %v", err) + } +} + // provideTestServices boots the real service graph over a temp-dir SQLite // database, the same way Run does, and returns the resulting services. -func provideTestServices(t *testing.T, version string) (*Services, *config.Config) { +func provideTestServices(t *testing.T, version string) (*Services, *config.Config, *Repositories) { t.Helper() cfg := &config.Config{ @@ -70,5 +137,14 @@ func provideTestServices(t *testing.T, version string) (*Services, *config.Confi if services.Workflow != nil { t.Cleanup(func() { _ = services.Workflow.Close() }) } - return services, cfg + return services, cfg, repos +} + +type failingExternalSecretStore struct { + emptySecretStore + err error +} + +func (s failingExternalSecretStore) List(context.Context) ([]*secrets.SecretListItem, error) { + return nil, s.err } diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 53fb5a60e7..8b745cb78a 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -38,6 +38,8 @@ import ( "github.com/kandev/kandev/internal/linear" "github.com/kandev/kandev/internal/mcp/canvasskill" "github.com/kandev/kandev/internal/mentions" + officeconfigsync "github.com/kandev/kandev/internal/office/configsync" + "github.com/kandev/kandev/internal/persistence/requiredstores" "github.com/kandev/kandev/internal/plugins" promptservice "github.com/kandev/kandev/internal/prompts/service" "github.com/kandev/kandev/internal/repoclone" @@ -68,6 +70,10 @@ const ( ) func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, dbPool *db.Pool, eventBus bus.EventBus, agentRegistry *registry.Registry, version string) (*Services, *agentsettingscontroller.Controller, error) { + if repos == nil || repos.RequiredStores == nil { + return nil, nil, errors.New("required-store tracker is unavailable") + } + storeTracker := repos.RequiredStores if cfg.Features.Canvases { if err := canvasskill.EnsureMaterialized(cfg.ResolvedHomeDir()); err != nil { return nil, nil, fmt.Errorf("materialize canvas authoring skill: %w", err) @@ -76,9 +82,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // Load custom TUI agents from DB into registry before discovery loadCustomTUIAgents(context.Background(), repos, agentRegistry, log) - managedRuntimeSettings, err := systemsettings.NewStore(dbPool) - if err != nil { - return nil, nil, fmt.Errorf("initialize managed runtime settings: %w", err) + managedRuntimeSettings := repos.SystemSettings + if managedRuntimeSettings == nil { + return nil, nil, fmt.Errorf("initialize managed runtime settings: required store is unavailable") } managedRuntimeSelections := managedruntime.NewStore(managedRuntimeSettings) if err := reconcileManagedRuntimeDefaults(context.Background(), managedRuntimeSelections, agentRegistry, log); err != nil { @@ -180,8 +186,8 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // company name to borrow, and an operator renames it in one click. const defaultOrgName = "Default organization" orgSvc, orgErr := buildOrgService(cfg, dbPool, repos, taskSvc, log) - if orgErr != nil { - return nil, nil, fmt.Errorf("initialize organizations: %w", orgErr) + if recordErr := recordRequiredStore(storeTracker, "organizations", orgErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize organizations: %w", recordErr) } // The tenancy migration runs once at boot: it creates the default // organization and puts every pre-tenancy user and workspace into it. @@ -192,8 +198,8 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // organization ids: placing a workspace before it knows its organization // would put it under the wrong root. unitSvc, unitErr := buildOrgUnitService(dbPool, repos.Task, repos.UserAccounts, log) - if unitErr != nil { - return nil, nil, fmt.Errorf("initialize organization units: %w", unitErr) + if recordErr := recordRequiredStore(storeTracker, "organization-units", unitErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize organization units: %w", recordErr) } taskSvc.SetUnitPlacer(unitSvc) taskSvc.SetUnitReach(unitSvc) @@ -244,7 +250,10 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories buildAgentProfileMatcher(repos, log), ) - githubSvc := initGitHubService(cfg, dbPool, eventBus, repos.Secrets, log) + githubSvc, _, githubErr := initGitHubServiceRequired(cfg, dbPool, eventBus, repos.Secrets, log) + if recordErr := recordRequiredStore(storeTracker, "github", githubErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize github: %w", recordErr) + } if githubSvc != nil { taskSvc.SetTaskStatusSummaryPRReader(&githubTaskStatusSummaryPRReader{gh: githubSvc}) githubSvc.SetComparisonTargetObserver(taskSvc) @@ -254,21 +263,42 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories log.Warn("GitHub credential broker initialization failed", zap.Error(brokerErr)) } } - gitlabSvc, gitlabCleanup := initGitLabService(dbPool, eventBus, repos.Secrets, log) + gitlabSvc, gitlabCleanup, gitlabErr := initGitLabServiceRequiredWithSettings(repos.SystemSettings, dbPool, eventBus, repos.Secrets, log) + if recordErr := recordRequiredStore(storeTracker, "gitlab", gitlabErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize gitlab: %w", recordErr) + } if gitlabSvc != nil { gitlabSvc.SetPromptResolver(promptSvc) gitlabSvc.SetComparisonTargetObserver(taskSvc) } - azureDevOpsSvc := initAzureDevOpsService(dbPool, eventBus, repos.Secrets, log) + azureDevOpsSvc, _, azureDevOpsErr := initAzureDevOpsServiceRequired(dbPool, eventBus, repos.Secrets, log) + if recordErr := recordRequiredStore(storeTracker, "azure-devops", azureDevOpsErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize azure devops: %w", recordErr) + } if azureDevOpsSvc != nil { azureDevOpsSvc.SetRepositoryLookup(&repositoryLookupAdapter{svc: taskSvc}) azureDevOpsSvc.SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess) } - jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log) - linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log) - sentrySvc := initSentryService(dbPool, eventBus, repos.Secrets, log) - workflowSyncSvc := initWorkflowSyncService(dbPool, githubSvc, gitlabSvc, workflowSvc, taskSvc, log) - pluginsSvc := initPluginsService(cfg, dbPool, eventBus, repos.Secrets, log) + jiraSvc, _, jiraErr := initJiraServiceRequired(dbPool, eventBus, repos.Secrets, log) + if recordErr := recordRequiredStore(storeTracker, "jira", jiraErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize jira: %w", recordErr) + } + linearSvc, _, linearErr := initLinearServiceRequired(dbPool, eventBus, repos.Secrets, log) + if recordErr := recordRequiredStore(storeTracker, "linear", linearErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize linear: %w", recordErr) + } + sentrySvc, _, sentryErr := initSentryServiceRequired(dbPool, eventBus, repos.Secrets, log) + if recordErr := recordRequiredStore(storeTracker, "sentry", sentryErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize sentry: %w", recordErr) + } + workflowSyncSvc, _, workflowSyncErr := initWorkflowSyncServiceRequired(dbPool, githubSvc, gitlabSvc, workflowSvc, taskSvc, log) + if recordErr := recordRequiredStore(storeTracker, "workflow-sync", workflowSyncErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize workflow sync: %w", recordErr) + } + pluginsSvc, _, pluginStoreErrors := initPluginsServiceRequired(cfg, dbPool, eventBus, repos.Secrets, log) + if recordErr := recordPluginStores(storeTracker, pluginStoreErrors); recordErr != nil { + return nil, nil, fmt.Errorf("initialize plugins: %w", recordErr) + } if pluginsSvc != nil { // The ldflags-injected build version, so Install can enforce a // package's manifest.min_kandev_version. This is the only production @@ -284,7 +314,11 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories } taskSvc.SetRepositorySelectionResolver(pluginRepositorySelectionResolver{inspector: pluginsSvc}) } - canvasSvc, err := initCanvasService(cfg.Features.Canvases, dbPool, eventBus, pluginsSvc, taskSvc, log) + canvasRepo, canvasErr := canvasservice.NewRepository(dbPool) + if recordErr := recordRequiredStore(storeTracker, "canvas", canvasErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize canvas: %w", recordErr) + } + canvasSvc, err := initCanvasServiceWithRepository(cfg.Features.Canvases, canvasRepo, eventBus, pluginsSvc, taskSvc, log) if err != nil { return nil, nil, fmt.Errorf("initialize canvas service: %w", err) } @@ -295,7 +329,14 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories if githubSvc != nil { githubSvc.SetCredentialBroker(github.NewCredentialBrokerFromBroker(gitCredentialBroker)) } - shareHTTP := initShareHandlers(dbPool, repos.Task, taskSvc, githubSvc, log, version) + shareHTTP, _, shareErr := initShareHandlersRequired(dbPool, repos.Task, taskSvc, githubSvc, log, version) + if recordErr := recordRequiredStore(storeTracker, "task-share", shareErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize task share: %w", recordErr) + } + _, officeConfigErr := officeconfigsync.NewStore(dbPool.Writer(), dbPool.Reader()) + if recordErr := recordRequiredStore(storeTracker, "office-config-sync", officeConfigErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize office config sync: %w", recordErr) + } // Plumb code-host branch listing into the task service so provider-backed // ("Remote") repos serve branches from their owning provider rather than relying @@ -318,8 +359,8 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // Initialize Automation service automationComponents, automationErr := automation.Provide(dbPool.Writer(), dbPool.Reader(), eventBus, githubSvc, log) - if automationErr != nil { - log.Warn("Automation service initialization failed (non-fatal)", zap.Error(automationErr)) + if recordErr := recordRequiredStore(storeTracker, "automation", automationErr); recordErr != nil { + return nil, nil, fmt.Errorf("initialize automation: %w", recordErr) } if automationComponents != nil { automationComponents.Service.SetTaskDeleter(&automationTaskDeleterAdapter{svc: taskSvc}) @@ -402,13 +443,33 @@ func initCanvasService(enabled bool, dbPool *db.Pool, eventBus bus.EventBus, plu } return nil, nil } - if taskSvc == nil { - return nil, errors.New("canvas: task service is required") - } canvasRepo, err := canvasservice.NewRepository(dbPool) if err != nil { return nil, fmt.Errorf("canvas repository: %w", err) } + return initCanvasServiceWithRepository(enabled, canvasRepo, eventBus, pluginsSvc, taskSvc, log) +} + +func initCanvasServiceWithRepository( + enabled bool, + canvasRepo *canvasservice.Repository, + eventBus bus.EventBus, + pluginsSvc *plugins.Service, + taskSvc *taskservice.Service, + log *logger.Logger, +) (*canvasservice.Service, error) { + if !enabled || pluginsSvc == nil { + if enabled && pluginsSvc == nil { + return nil, errors.New("canvas: plugin service is required") + } + return nil, nil + } + if taskSvc == nil { + return nil, errors.New("canvas: task service is required") + } + if canvasRepo == nil { + return nil, errors.New("canvas: repository is required") + } canvasSvc := canvasservice.NewService(canvasRepo, pluginsSvc.Instances()) canvasSvc.SetInstanceStateCleanup(func(ctx context.Context, instanceID string) error { instanceState := pluginsSvc.InstanceState() @@ -937,11 +998,25 @@ func initGitHubService( secretsStore secrets.SecretStore, log *logger.Logger, ) *github.Service { - adapter := &githubSecretAdapter{store: secretsStore} - svc, _, err := github.Provide(dbPool.Writer(), dbPool.Reader(), adapter, eventBus, log) + svc, _, err := initGitHubServiceRequired(cfg, dbPool, eventBus, secretsStore, log) if err != nil { log.Warn("GitHub service initialization failed (non-fatal)", zap.Error(err)) } + return svc +} + +func initGitHubServiceRequired( + cfg *config.Config, + dbPool *db.Pool, + eventBus bus.EventBus, + secretsStore secrets.SecretStore, + log *logger.Logger, +) (*github.Service, func() error, error) { + adapter := &githubSecretAdapter{store: secretsStore} + svc, cleanup, err := github.Provide(dbPool.Writer(), dbPool.Reader(), adapter, eventBus, log) + if err != nil { + return nil, nil, fmt.Errorf("github store: %w", err) + } if svc != nil { // GitHub takes both a SecretProvider (read-only) and a SecretManager // (mutating) — same adapter satisfies both interfaces, but the @@ -955,7 +1030,7 @@ func initGitHubService( log.Warn("one or more GitHub App registrations failed to initialize", zap.Error(authErr)) } } - return svc + return svc, cleanup, nil } // gitlabSecretAdapter adapts secrets.SecretStore to the GitLab integration's @@ -1010,10 +1085,9 @@ type gitlabHostStore struct { settings *systemsettings.Store } -func newGitLabHostStore(dbPool *db.Pool) (gitlab.HostStore, error) { - settingsStore, err := systemsettings.NewStore(dbPool) - if err != nil { - return nil, err +func newGitLabHostStore(settingsStore *systemsettings.Store) (gitlab.HostStore, error) { + if settingsStore == nil { + return nil, errors.New("system settings store is required") } return &gitlabHostStore{settings: settingsStore}, nil } @@ -1030,46 +1104,96 @@ func (s *gitlabHostStore) SetHost(ctx context.Context, host string) error { return s.settings.Save(ctx, gitlabHostSettingKey, []byte(host)) } -// initGitLabService wires up the GitLab integration. Failures are non-fatal: -// the rest of the backend still boots without GitLab configured. -func initGitLabService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) (*gitlab.Service, func() error) { - adapter := &gitlabSecretAdapter{store: secretsStore} - hostStore, hostStoreErr := newGitLabHostStore(dbPool) - if hostStoreErr != nil { - log.Warn("GitLab host store unavailable (non-fatal)", zap.Error(hostStoreErr)) +// initGitLabService keeps the package-level test seam that owns its settings +// store. Production startup uses initGitLabServiceWithSettings so the +// required store is initialized exactly once during repository bootstrap. +func initGitLabService( + dbPool *db.Pool, + eventBus bus.EventBus, + secretsStore secrets.SecretStore, + log *logger.Logger, +) (*gitlab.Service, func() error) { + settingsStore, err := systemsettings.NewStore(dbPool) + if err != nil { + log.Warn("GitLab host store unavailable (non-fatal)", zap.Error(err)) return nil, nil } + return initGitLabServiceWithSettings(settingsStore, dbPool, eventBus, secretsStore, log) +} + +// initGitLabServiceWithSettings is the compatibility seam used by tests and +// callers that already have a settings store. Required production startup +// uses initGitLabServiceRequiredWithSettings so local schema errors reach the +// bootstrap tracker. +func initGitLabServiceWithSettings( + settingsStore *systemsettings.Store, + dbPool *db.Pool, + eventBus bus.EventBus, + secretsStore secrets.SecretStore, + log *logger.Logger, +) (*gitlab.Service, func() error) { + svc, cleanup, err := initGitLabServiceRequiredWithSettings(settingsStore, dbPool, eventBus, secretsStore, log) + if err != nil { + log.Warn("GitLab service initialization failed (non-fatal)", zap.Error(err)) + return nil, nil + } + return svc, cleanup +} + +func initGitLabServiceRequiredWithSettings( + settingsStore *systemsettings.Store, + dbPool *db.Pool, + eventBus bus.EventBus, + secretsStore secrets.SecretStore, + log *logger.Logger, +) (*gitlab.Service, func() error, error) { + if settingsStore == nil { + return nil, nil, errors.New("GitLab host store requires system settings") + } + adapter := &gitlabSecretAdapter{store: secretsStore} + hostStore, err := newGitLabHostStore(settingsStore) + if err != nil { + return nil, nil, fmt.Errorf("gitlab host store: %w", err) + } + store, err := gitlab.NewStore(dbPool.Writer(), dbPool.Reader()) + if err != nil { + return nil, nil, fmt.Errorf("gitlab store: %w", err) + } svc, cleanup, err := gitlab.Provide(context.Background(), adapter, hostStore, log) if err != nil { - log.Warn("GitLab service initialization failed (non-fatal)", zap.Error(err)) + return nil, nil, fmt.Errorf("gitlab service: %w", err) } if svc != nil { svc.SetSecretManager(adapter) svc.SetWorkspaceSecretStore(secretadapter.New(secretsStore)) svc.SetEventBus(eventBus) - if store, storeErr := gitlab.NewStore(dbPool.Writer(), dbPool.Reader()); storeErr == nil { - svc.SetStore(store) - if migrationErr := gitlab.MigrateLegacyConnection( - context.Background(), store, secretadapter.New(secretsStore), adapter, adapter, hostStore, log, - ); migrationErr != nil { - log.Warn("GitLab legacy connection migration failed (non-fatal)", zap.Error(migrationErr)) - } - } else { - log.Warn("GitLab task-mr store unavailable (non-fatal)", zap.Error(storeErr)) + svc.SetStore(store) + if migrationErr := gitlab.MigrateLegacyConnection( + context.Background(), store, secretadapter.New(secretsStore), adapter, adapter, hostStore, log, + ); migrationErr != nil { + log.Warn("GitLab legacy connection migration failed (non-fatal)", zap.Error(migrationErr)) } } - return svc, cleanup + return svc, cleanup, nil } // initJiraService wires up the Jira integration. Failures are non-fatal. func initJiraService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *jira.Service { - svc, _, err := jira.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log) + svc, _, err := initJiraServiceRequired(dbPool, eventBus, secretsStore, log) if err != nil { log.Warn("JIRA service initialization failed (non-fatal)", zap.Error(err)) } return svc } +func initJiraServiceRequired(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) (*jira.Service, func() error, error) { + svc, cleanup, err := jira.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log) + if err != nil { + return nil, nil, fmt.Errorf("jira store: %w", err) + } + return svc, cleanup, nil +} + // initAzureDevOpsService wires the workspace-scoped Azure integration. // Failures are non-fatal so unrelated providers and the backend keep working. func initAzureDevOpsService( @@ -1078,9 +1202,7 @@ func initAzureDevOpsService( secretsStore secrets.SecretStore, log *logger.Logger, ) *azuredevops.Service { - svc, _, err := azuredevops.Provide( - dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log, - ) + svc, _, err := initAzureDevOpsServiceRequired(dbPool, eventBus, secretsStore, log) if err != nil { log.Warn("Azure DevOps service initialization failed (non-fatal)", zap.Error(err)) return nil @@ -1088,6 +1210,21 @@ func initAzureDevOpsService( return svc } +func initAzureDevOpsServiceRequired( + dbPool *db.Pool, + eventBus bus.EventBus, + secretsStore secrets.SecretStore, + log *logger.Logger, +) (*azuredevops.Service, func() error, error) { + svc, cleanup, err := azuredevops.Provide( + dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log, + ) + if err != nil { + return nil, nil, fmt.Errorf("azure-devops store: %w", err) + } + return svc, cleanup, nil +} + // initWorkflowSyncService wires the workflow-sync service. Either integration // may be nil; a workspace configured for the unavailable one gets an // actionable failure at sync time rather than the service failing to boot. @@ -1098,8 +1235,19 @@ func initWorkflowSyncService( ) *workflowsync.Service { if githubSvc == nil && gitlabSvc == nil { log.Warn("workflow sync disabled: no GitHub or GitLab service available") + } + svc, _, err := initWorkflowSyncServiceRequired(dbPool, githubSvc, gitlabSvc, workflowSvc, taskSvc, log) + if err != nil { + log.Warn("workflow sync service initialization failed (non-fatal)", zap.Error(err)) return nil } + return svc +} + +func initWorkflowSyncServiceRequired( + dbPool *db.Pool, githubSvc *github.Service, gitlabSvc *gitlab.Service, + workflowSvc *workflowservice.Service, taskSvc *taskservice.Service, log *logger.Logger, +) (*workflowsync.Service, func() error, error) { workflowSvc.SetSyncWorkflowOps(taskSvc) var githubClients workflowsync.GitHubClientProvider if githubSvc != nil { @@ -1109,33 +1257,48 @@ func initWorkflowSyncService( if gitlabSvc != nil { gitlabClients = gitlabSvc } - svc, _, err := workflowsync.Provide(dbPool.Writer(), dbPool.Reader(), githubClients, gitlabClients, workflowSvc, log) + svc, cleanup, err := workflowsync.Provide(dbPool.Writer(), dbPool.Reader(), githubClients, gitlabClients, workflowSvc, log) if err != nil { - log.Warn("workflow sync service initialization failed (non-fatal)", zap.Error(err)) - return nil + return nil, nil, fmt.Errorf("workflow-sync store: %w", err) } svc.SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess) - return svc + return svc, cleanup, nil } // initLinearService wires up the Linear integration. Failures are non-fatal. func initLinearService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *linear.Service { - svc, _, err := linear.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log) + svc, _, err := initLinearServiceRequired(dbPool, eventBus, secretsStore, log) if err != nil { log.Warn("Linear service initialization failed (non-fatal)", zap.Error(err)) } return svc } +func initLinearServiceRequired(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) (*linear.Service, func() error, error) { + svc, cleanup, err := linear.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log) + if err != nil { + return nil, nil, fmt.Errorf("linear store: %w", err) + } + return svc, cleanup, nil +} + // initSentryService wires up the Sentry integration. Failures are non-fatal. func initSentryService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) *sentry.Service { - svc, _, err := sentry.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log) + svc, _, err := initSentryServiceRequired(dbPool, eventBus, secretsStore, log) if err != nil { log.Warn("Sentry service initialization failed (non-fatal)", zap.Error(err)) } return svc } +func initSentryServiceRequired(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secrets.SecretStore, log *logger.Logger) (*sentry.Service, func() error, error) { + svc, cleanup, err := sentry.Provide(dbPool.Writer(), dbPool.Reader(), secretadapter.New(secretsStore), eventBus, log) + if err != nil { + return nil, nil, fmt.Errorf("sentry store: %w", err) + } + return svc, cleanup, nil +} + // initShareHandlers wires up the public-share-links HTTP surface. Failures // are non-fatal: the rest of the backend boots without the share endpoints. // GitHub access resolves from the owning task workspace on every operation. @@ -1147,10 +1310,7 @@ func initShareHandlers( log *logger.Logger, version string, ) *share.HTTPHandlers { - h, _, err := share.Provide( - dbPool.Writer(), dbPool.Reader(), taskRepo, authorizer, githubSvc, log, - share.Config{KandevVersion: version}, - ) + h, _, err := initShareHandlersRequired(dbPool, taskRepo, authorizer, githubSvc, log, version) if err != nil { log.Warn("Share handlers initialization failed (non-fatal)", zap.Error(err)) return nil @@ -1158,6 +1318,24 @@ func initShareHandlers( return h } +func initShareHandlersRequired( + dbPool *db.Pool, + taskRepo share.TaskReader, + authorizer share.TaskAccessAuthorizer, + githubSvc *github.Service, + log *logger.Logger, + version string, +) (*share.HTTPHandlers, func() error, error) { + h, cleanup, err := share.Provide( + dbPool.Writer(), dbPool.Reader(), taskRepo, authorizer, githubSvc, log, + share.Config{KandevVersion: version}, + ) + if err != nil { + return nil, nil, fmt.Errorf("task-share store: %w", err) + } + return h, cleanup, nil +} + // portsBackendDefault is the default backend HTTP port. We don't import // internal/common/ports here to avoid pulling its transitive deps into // services.go's import graph; the value is a fallback for when @@ -1180,14 +1358,41 @@ func initPluginsService( secretsStore secrets.SecretStore, log *logger.Logger, ) *plugins.Service { - svc, _, err := plugins.Provide(cfg, dbPool, secretadapter.New(secretsStore), eventBus, log) - if err != nil { - log.Warn("Plugins service initialization failed (non-fatal)", zap.Error(err)) + svc, _, storeErrors := initPluginsServiceRequired(cfg, dbPool, eventBus, secretsStore, log) + if err := storeErrors.CombinedError(); err != nil && log != nil { + log.Warn("Plugin SQL store initialization failed", zap.Error(err)) return nil } return svc } +func initPluginsServiceRequired( + cfg *config.Config, + dbPool *db.Pool, + eventBus bus.EventBus, + secretsStore secrets.SecretStore, + log *logger.Logger, +) (*plugins.Service, func() error, plugins.StoreInitErrors) { + return plugins.ProvideWithStoreErrors(cfg, dbPool, secretadapter.New(secretsStore), eventBus, log) +} + +func recordPluginStores(tracker *requiredstores.Tracker, initErrors plugins.StoreInitErrors) error { + var failures []error + for _, id := range []string{ + "plugin-instances", + "plugin-marketplace", + "plugin-settings", + "plugin-state", + "plugin-instance-state", + "plugin-user-state", + } { + if err := recordRequiredStore(tracker, id, initErrors[id]); err != nil { + failures = append(failures, err) + } + } + return errors.Join(failures...) +} + type pluginsHostUtilityAdapter struct { mgr *hostutility.Manager } diff --git a/apps/backend/internal/backendapp/storage.go b/apps/backend/internal/backendapp/storage.go index 670a2ead08..3ea8cd2fc2 100644 --- a/apps/backend/internal/backendapp/storage.go +++ b/apps/backend/internal/backendapp/storage.go @@ -7,21 +7,24 @@ import ( "github.com/jmoiron/sqlx" "go.uber.org/zap" + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" + "github.com/kandev/kandev/internal/auth/hostnames" + authstore "github.com/kandev/kandev/internal/auth/store" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/persistence" + "github.com/kandev/kandev/internal/persistence/requiredstores" quickterminalrepository "github.com/kandev/kandev/internal/quickterminal/repository" "github.com/kandev/kandev/internal/secrets" + systemsettings "github.com/kandev/kandev/internal/system/settings" "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/telemetrycontract" terminalrepo "github.com/kandev/kandev/internal/terminal/repository" utilitystore "github.com/kandev/kandev/internal/utility/store" workflowrepository "github.com/kandev/kandev/internal/workflow/repository" - settingsstore "github.com/kandev/kandev/internal/agent/settings/store" - authstore "github.com/kandev/kandev/internal/auth/store" editorstore "github.com/kandev/kandev/internal/editors/store" notificationstore "github.com/kandev/kandev/internal/notifications/store" "github.com/kandev/kandev/internal/office" @@ -32,59 +35,66 @@ import ( func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Logger, version string) (*db.Pool, *Repositories, []func() error, error) { cleanups := make([]func() error, 0, 12) + tracker, err := requiredstores.NewCatalogTracker() + if err != nil { + return nil, nil, nil, err + } pool, cleanup, err := persistence.Provide(cfg, log, version) if err != nil { return nil, nil, nil, err } cleanups = append(cleanups, cleanup) writer, reader := pool.Writer(), pool.Reader() + if err := recordRequiredStore(tracker, "schema-meta", nil); err != nil { + return nil, nil, nil, err + } taskRepoImpl, cleanup, err := repository.Provide(writer, reader, log) - if err != nil { - return nil, nil, nil, err + if err := recordRequiredStore(tracker, "task", err); err != nil { + return nil, nil, nil, fmt.Errorf("task store: %w", err) } cleanups = append(cleanups, cleanup) // Workflow repo must be initialized before analytics repo because // analytics creates indexes on the workflow_steps table. workflowRepo, err := workflowrepository.NewWithDB(writer, reader, log) - if err != nil { - return nil, nil, nil, err + if err := recordRequiredStore(tracker, "workflow", err); err != nil { + return nil, nil, nil, fmt.Errorf("workflow store: %w", err) } analyticsRepo, cleanup, err := analyticsrepository.Provide(writer, reader) - if err != nil { - return nil, nil, nil, err + if err := recordRequiredStore(tracker, "analytics", err); err != nil { + return nil, nil, nil, fmt.Errorf("analytics store: %w", err) } cleanups = append(cleanups, cleanup) agentSettingsRepo, cleanup, err := settingsstore.Provide(writer, reader, log) - if err != nil { - return nil, nil, nil, err + if err := recordRequiredStore(tracker, "agent-settings", err); err != nil { + return nil, nil, nil, fmt.Errorf("agent settings store: %w", err) } cleanups = append(cleanups, cleanup) - supportRepos, supportCleanups, err := provideSupportRepos(ctx, writer, reader) + supportRepos, supportCleanups, err := provideSupportRepos(ctx, writer, reader, tracker) if err != nil { return nil, nil, nil, err } cleanups = append(cleanups, supportCleanups...) officeRepo, officeCleanup, err := office.Provide(writer, reader, log) - if err != nil { + if err := recordRequiredStore(tracker, "office", err); err != nil { return nil, nil, nil, fmt.Errorf("office repo: %w", err) } cleanups = append(cleanups, officeCleanup) terminalRepoImpl, err := terminalrepo.NewWithDB(writer, reader, log) - if err != nil { + if err := recordRequiredStore(tracker, "terminal", err); err != nil { return nil, nil, nil, fmt.Errorf("terminal repo: %w", err) } quickTerminalRepoImpl, err := quickterminalrepository.NewWithDB(writer, reader) - if err != nil { + if err := recordRequiredStore(tracker, "quick-terminal", err); err != nil { return nil, nil, nil, fmt.Errorf("quick terminal repo: %w", err) } runtimeFlagsStore, err := runtimeflags.NewSQLiteStore(writer, reader) - if err != nil { + if err := recordRequiredStore(tracker, "runtime-flags", err); err != nil { return nil, nil, nil, fmt.Errorf("runtime flags store: %w", err) } authRepo, err := authstore.New(writer, reader) - if err != nil { + if err := recordRequiredStore(tracker, "auth", err); err != nil { return nil, nil, nil, fmt.Errorf("auth store: %w", err) } masterKeyProvider, err := secrets.NewMasterKeyProvider(cfg.ResolvedDataDir()) @@ -92,36 +102,46 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, fmt.Errorf("master key: %w", err) } secretStore, cleanup, err := secrets.Provide(writer, reader, masterKeyProvider) - if err != nil { + if err := recordRequiredStore(tracker, "secrets", err); err != nil { return nil, nil, nil, fmt.Errorf("secret store: %w", err) } cleanups = append(cleanups, cleanup) - // All repositories have finished their initSchema calls, so every - // telemetry contract's backing objects are known to exist (or not). - activateTelemetryContracts(ctx, writer, reader, log) + systemSettings, err := systemsettings.NewStore(pool) + if err := recordRequiredStore(tracker, "system-settings", err); err != nil { + return nil, nil, nil, fmt.Errorf("system settings store: %w", err) + } + hostnameCache, err := hostnames.NewStore(writer, reader) + if err := recordRequiredStore(tracker, "auth-hostnames", err); err != nil { + return nil, nil, nil, fmt.Errorf("hostname cache store: %w", err) + } - // All repositories have finished their initSchema calls. Record the - // current binary version so the next boot can detect upgrades correctly. - recordSchemaVersion(writer, cfg.Database.Driver, version, log) + telemetryStore, err := telemetrycontract.NewWithDB(writer, reader) + if err := recordRequiredStore(tracker, "telemetry-contract", err); err != nil { + return nil, nil, nil, fmt.Errorf("telemetry contract store: %w", err) + } + activateTelemetryContracts(ctx, telemetryStore, log) repos := &Repositories{ - Task: taskRepoImpl, - Analytics: analyticsRepo, - AgentSettings: agentSettingsRepo, - User: supportRepos.user, - UserAccounts: supportRepos.userAccounts, - Notification: supportRepos.notification, - Editor: supportRepos.editor, - Prompts: supportRepos.prompts, - Utility: supportRepos.utility, - Workflow: workflowRepo, - Secrets: secretStore, - Office: officeRepo, - Terminal: terminalRepoImpl, - QuickTerminal: quickTerminalRepoImpl, - RuntimeFlags: runtimeFlagsStore, - Auth: authRepo, + RequiredStores: tracker, + Task: taskRepoImpl, + Analytics: analyticsRepo, + AgentSettings: agentSettingsRepo, + User: supportRepos.user, + UserAccounts: supportRepos.userAccounts, + Notification: supportRepos.notification, + Editor: supportRepos.editor, + Prompts: supportRepos.prompts, + Utility: supportRepos.utility, + Workflow: workflowRepo, + Secrets: secretStore, + Office: officeRepo, + Terminal: terminalRepoImpl, + QuickTerminal: quickTerminalRepoImpl, + RuntimeFlags: runtimeFlagsStore, + Auth: authRepo, + HostnameCache: hostnameCache, + SystemSettings: systemSettings, } return pool, repos, cleanups, nil } @@ -140,12 +160,12 @@ type supportRepositorySet struct { // provideSupportRepos wires up user, notification, editor, prompt, and utility // repositories. Extracted from provideRepositories to keep its statement count // within the funlen limit. -func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB) (supportRepositorySet, []func() error, error) { +func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB, tracker *requiredstores.Tracker) (supportRepositorySet, []func() error, error) { var cleanups []func() error var repos supportRepositorySet userRepo, cleanup, err := userstore.Provide(writer, reader) - if err != nil { + if err := recordRequiredStore(tracker, "user", err); err != nil { return repos, nil, err } cleanups = append(cleanups, cleanup) @@ -154,28 +174,28 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB) (supportR repos.userAccounts = userRepo notificationRepo, cleanup, err := notificationstore.Provide(ctx, writer, reader) - if err != nil { + if err := recordRequiredStore(tracker, "notification", err); err != nil { return repos, nil, err } cleanups = append(cleanups, cleanup) repos.notification = notificationRepo editorRepo, cleanup, err := editorstore.Provide(writer, reader) - if err != nil { + if err := recordRequiredStore(tracker, "editor", err); err != nil { return repos, nil, err } cleanups = append(cleanups, cleanup) repos.editor = editorRepo promptRepo, cleanup, err := promptstore.Provide(writer, reader) - if err != nil { + if err := recordRequiredStore(tracker, "prompts", err); err != nil { return repos, nil, err } cleanups = append(cleanups, cleanup) repos.prompts = promptRepo utilityRepo, cleanup, err := utilitystore.Provide(writer, reader) - if err != nil { + if err := recordRequiredStore(tracker, "utility", err); err != nil { return repos, nil, err } cleanups = append(cleanups, cleanup) @@ -184,18 +204,23 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB) (supportR return repos, cleanups, nil } +// recordRequiredStore records a constructor result before the process can +// expose readiness. Initialization failures remain fatal to the caller. +func recordRequiredStore(tracker *requiredstores.Tracker, id string, initErr error) error { + if trackerErr := tracker.Record(id, initErr); trackerErr != nil { + return trackerErr + } + if initErr != nil { + return fmt.Errorf("required store %q: %w", id, initErr) + } + return nil +} + // activateTelemetryContracts writes the boot activation row for any // newly-active telemetry contract and logs one health line per registered // contract. Neither step is fatal: a failure logs and boot continues, // matching recordSchemaVersion's contract directly below. -func activateTelemetryContracts(ctx context.Context, writer, reader *sqlx.DB, log *logger.Logger) { - store, err := telemetrycontract.NewWithDB(writer, reader) - if err != nil { - if log != nil { - log.Warn("failed to initialize telemetry_activations", zap.Error(err)) - } - return - } +func activateTelemetryContracts(ctx context.Context, store *telemetrycontract.Store, log *logger.Logger) { if err := store.Activate(ctx); err != nil { if log != nil { log.Warn("failed to activate telemetry contracts", zap.Error(err)) @@ -222,3 +247,21 @@ func recordSchemaVersion(writer *sqlx.DB, _ string, version string, log *logger. log.Info("schema version recorded", zap.String(versionFieldKey, version)) } } + +// recordSchemaVersionAfterPersistence keeps the version marker behind the +// final required-store and health gates. Callers can therefore safely use it +// as the last startup action before readiness publication. +func recordSchemaVersionAfterPersistence( + validate func() error, + healthCheck func() error, + record func(), +) error { + if err := validate(); err != nil { + return fmt.Errorf("required-store validation before readiness: %w", err) + } + if err := healthCheck(); err != nil { + return fmt.Errorf("required-store health check before readiness: %w", err) + } + record() + return nil +} diff --git a/apps/backend/internal/backendapp/storage_maintenance.go b/apps/backend/internal/backendapp/storage_maintenance.go index 90f32a2601..248e4a4d72 100644 --- a/apps/backend/internal/backendapp/storage_maintenance.go +++ b/apps/backend/internal/backendapp/storage_maintenance.go @@ -16,6 +16,7 @@ import ( "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/persistence/requiredstores" "github.com/kandev/kandev/internal/system/jobs" systemmetrics "github.com/kandev/kandev/internal/system/metrics" systemsettings "github.com/kandev/kandev/internal/system/settings" @@ -39,6 +40,18 @@ type storageComposition struct { tempArtifacts *tempartifacts.Registry } +type storageDependencies struct { + settings *storagepkg.SettingsStore + store *storagepkg.Store + tempArtifacts *tempartifacts.Registry + coordinator *activity.Coordinator + goCache *gocache.Provider + workspaceFactory workspaceFactory + cachedOverview *storagepkg.OverviewCache + quarantine *workspaceQuarantineController + providers []storagepkg.CleanupProvider +} + func provideStorageComposition( cfg *config.Config, pool *db.Pool, @@ -50,18 +63,101 @@ func provideStorageComposition( log *logger.Logger, logError func(string, error), ) (*storageComposition, error) { - rawSettings, err := systemsettings.NewStore(pool) + return provideStorageCompositionWithDependencies( + cfg, pool, tracker, eventBus, lifecycleMgr, worktreeMgr, taskSvc, log, logError, nil, nil, + ) +} + +func provideStorageCompositionWithDependencies( + cfg *config.Config, + pool *db.Pool, + tracker *jobs.Tracker, + eventBus bus.EventBus, + lifecycleMgr *lifecycle.Manager, + worktreeMgr *worktree.Manager, + taskSvc *taskservice.Service, + log *logger.Logger, + logError func(string, error), + requiredTracker *requiredstores.Tracker, + providedSettings *systemsettings.Store, +) (*storageComposition, error) { + dependencies, err := prepareStorageDependencies( + cfg, pool, requiredTracker, eventBus, lifecycleMgr, worktreeMgr, taskSvc, log, logError, providedSettings, + ) + if err != nil { + return nil, err + } + if err := attachPromptAttachmentStorage(taskSvc, cfg, lifecycleMgr, log, &dependencies.providers); err != nil { + return nil, err + } + runner := storagepkg.NewRunner(storagepkg.RunnerConfig{ + Activity: dependencies.coordinator, Store: dependencies.store, + Providers: dependencies.providers, Overview: dependencies.cachedOverview, + }) + scheduler := storagepkg.NewScheduler(dependencies.settings, runner, storagepkg.SchedulerOptions{}) + runtime := storagepkg.NewRuntime(storagepkg.RuntimeConfig{ + Scheduler: scheduler, Settings: dependencies.settings, Worker: taskSvc, + Reconciler: &workspaceReconciler{settings: dependencies.settings, factory: dependencies.workspaceFactory}, + }) + operations := storagepkg.NewOperations(storagepkg.OperationsConfig{ + Settings: dependencies.settings, Store: dependencies.store, Jobs: tracker, + Activity: dependencies.coordinator, Providers: dependencies.providers, + Overview: dependencies.cachedOverview, GoCache: dependencies.goCache, + Quarantine: dependencies.quarantine, + }) + handler := storagepkg.NewHandler(storagepkg.HandlerConfig{ + Settings: dependencies.settings, Runs: dependencies.store, + Quarantine: dependencies.store, Overview: dependencies.cachedOverview, + DiskCapacity: func(ctx context.Context, path string) (storagepkg.DiskCapacity, error) { + capacity, err := systemmetrics.DiskUsage(ctx, path) + if err != nil { + return storagepkg.DiskCapacity{}, err + } + return storagepkg.DiskCapacity{ + TotalBytes: capacity.TotalBytes, UsedBytes: capacity.UsedBytes, + AvailableBytes: capacity.AvailableBytes, UsedPercent: capacity.UsedPercent, + }, nil + }, + DiskPath: cfg.ResolvedHomeDir(), + Mutations: operations, OnSettingsChanged: runtime.ApplySettings, LogError: logError, + }) + return &storageComposition{ + handler: handler, runtime: runtime, workspaceRestorer: dependencies.quarantine, + tempArtifacts: dependencies.tempArtifacts, + }, nil +} + +func prepareStorageDependencies( + cfg *config.Config, + pool *db.Pool, + requiredTracker *requiredstores.Tracker, + eventBus bus.EventBus, + lifecycleMgr *lifecycle.Manager, + worktreeMgr *worktree.Manager, + taskSvc *taskservice.Service, + log *logger.Logger, + logError func(string, error), + providedSettings *systemsettings.Store, +) (*storageDependencies, error) { + rawSettings := providedSettings + var err error + if rawSettings == nil { + rawSettings, err = systemsettings.NewStore(pool) + } if err != nil { return nil, fmt.Errorf("initialize storage settings: %w", err) } settings := storagepkg.NewSettingsStore(rawSettings) store, err := storagepkg.NewStore(pool) + if requiredTracker != nil { + if recordErr := recordRequiredStore(requiredTracker, "storage", err); recordErr != nil { + return nil, fmt.Errorf("initialize storage store: %w", recordErr) + } + } if err != nil { return nil, fmt.Errorf("initialize storage store: %w", err) } - tempArtifacts := tempartifacts.NewRegistry(tempartifacts.Config{ - Store: store, TempRoot: os.TempDir(), - }) + tempArtifacts := tempartifacts.NewRegistry(tempartifacts.Config{Store: store, TempRoot: os.TempDir()}) if err := tempArtifacts.Reconcile(context.Background()); err != nil { logError("reconcile temporary artifact registry", err) } @@ -85,19 +181,36 @@ func provideStorageComposition( if worktreeMgr != nil { worktreeMgr.SetScriptEnvironmentProvider(goCache) } - inventory := &storageInventory{reader: pool.Reader(), worktrees: worktreeMgr, lifecycle: lifecycleMgr} workspaceFactory := newWorkspaceFactory(cfg, store, inventory, worktreeMgr, scanner) dockerClient := &lazyStorageDocker{provider: lifecycleMgr.DockerClientProvider(), activity: coordinator} - dockerProvider := dockerstore.NewProvider( - dockerClient, &containerInventory{reader: pool.Reader()}, settings, - ) + dockerProvider := dockerstore.NewProvider(dockerClient, &containerInventory{reader: pool.Reader()}, settings) overview := &storageOverview{ settings: settings, quarantine: store, workspaceFactory: workspaceFactory, goCache: goCache, docker: dockerProvider, dockerClient: dockerClient, dockerHost: cfg.Docker.Host, homeDir: cfg.ResolvedHomeDir(), tempArtifacts: tempProvider, } - cachedOverview := storagepkg.NewOverviewCacheWithOptions(overview, storagepkg.OverviewCacheOptions{ + cachedOverview := newStorageOverviewCache(overview, eventBus, log, logError) + quarantine := &workspaceQuarantineController{ + settings: settings, store: store, factory: workspaceFactory, homeDir: cfg.ResolvedHomeDir(), + activity: coordinator, temporary: tempProvider, + } + return &storageDependencies{ + settings: settings, store: store, tempArtifacts: tempArtifacts, + coordinator: coordinator, goCache: goCache, workspaceFactory: workspaceFactory, + cachedOverview: cachedOverview, + quarantine: quarantine, + providers: storageCleanupProviders(settings, workspaceFactory, goCache, dockerProvider, quarantine, tempProvider), + }, nil +} + +func newStorageOverviewCache( + overview *storageOverview, + eventBus bus.EventBus, + log *logger.Logger, + logError func(string, error), +) *storagepkg.OverviewCache { + return storagepkg.NewOverviewCacheWithOptions(overview, storagepkg.OverviewCacheOptions{ Publisher: func(update storagepkg.StorageAnalysisUpdated) { if eventBus == nil { return @@ -113,17 +226,21 @@ func provideStorageComposition( logStorageAnalysisCompletion(log, completion) }, }) - quarantine := &workspaceQuarantineController{ - settings: settings, store: store, factory: workspaceFactory, homeDir: cfg.ResolvedHomeDir(), - activity: coordinator, temporary: tempProvider, - } - providers := storageCleanupProviders(settings, workspaceFactory, goCache, dockerProvider, quarantine, tempProvider) +} + +func attachPromptAttachmentStorage( + taskSvc *taskservice.Service, + cfg *config.Config, + lifecycleMgr *lifecycle.Manager, + log *logger.Logger, + providers *[]storagepkg.CleanupProvider, +) error { if taskSvc.AttachmentService() == nil && taskSvc.AttachmentRepository() != nil { - attachmentSvc, attachmentErr := taskservice.NewAttachmentService( + attachmentSvc, err := taskservice.NewAttachmentService( taskSvc.AttachmentRepository(), cfg.ResolvedHomeDir(), taskSvc.AuthorizeWorkspaceAccess, log, ) - if attachmentErr != nil { - return nil, fmt.Errorf("initialize prompt attachment storage: %w", attachmentErr) + if err != nil { + return fmt.Errorf("initialize prompt attachment storage: %w", err) } taskSvc.SetAttachmentService(attachmentSvc) } @@ -131,38 +248,9 @@ func provideStorageComposition( if lifecycleMgr != nil { lifecycleMgr.SetAttachmentReader(attachmentSvc) } - providers = append(providers, attachmentCleanupProvider{service: attachmentSvc}) + *providers = append(*providers, attachmentCleanupProvider{service: attachmentSvc}) } - runner := storagepkg.NewRunner(storagepkg.RunnerConfig{ - Activity: coordinator, Store: store, Providers: providers, Overview: cachedOverview, - }) - scheduler := storagepkg.NewScheduler(settings, runner, storagepkg.SchedulerOptions{}) - runtime := storagepkg.NewRuntime(storagepkg.RuntimeConfig{ - Scheduler: scheduler, Settings: settings, Worker: taskSvc, - Reconciler: &workspaceReconciler{settings: settings, factory: workspaceFactory}, - }) - operations := storagepkg.NewOperations(storagepkg.OperationsConfig{ - Settings: settings, Store: store, Jobs: tracker, Activity: coordinator, - Providers: providers, Overview: cachedOverview, GoCache: goCache, Quarantine: quarantine, - }) - handler := storagepkg.NewHandler(storagepkg.HandlerConfig{ - Settings: settings, Runs: store, Quarantine: store, Overview: cachedOverview, - DiskCapacity: func(ctx context.Context, path string) (storagepkg.DiskCapacity, error) { - capacity, err := systemmetrics.DiskUsage(ctx, path) - if err != nil { - return storagepkg.DiskCapacity{}, err - } - return storagepkg.DiskCapacity{ - TotalBytes: capacity.TotalBytes, UsedBytes: capacity.UsedBytes, - AvailableBytes: capacity.AvailableBytes, UsedPercent: capacity.UsedPercent, - }, nil - }, - DiskPath: cfg.ResolvedHomeDir(), - Mutations: operations, OnSettingsChanged: runtime.ApplySettings, LogError: logError, - }) - return &storageComposition{ - handler: handler, runtime: runtime, workspaceRestorer: quarantine, tempArtifacts: tempArtifacts, - }, nil + return nil } type taskCleanupActivityGate struct { diff --git a/apps/backend/internal/backendapp/storage_test.go b/apps/backend/internal/backendapp/storage_test.go index 90d8494d24..c10093fde9 100644 --- a/apps/backend/internal/backendapp/storage_test.go +++ b/apps/backend/internal/backendapp/storage_test.go @@ -1,11 +1,17 @@ package backendapp import ( + "context" + "errors" + "os" "path/filepath" + "reflect" + "strings" "testing" "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/db" ) @@ -39,3 +45,136 @@ func TestRecordSchemaVersionIgnoresDriver(t *testing.T) { t.Fatalf("kandev_version = %q, want v9.9.9", version) } } + +func TestRecordSchemaVersionAfterPersistenceDoesNotRecordOnLateFailure(t *testing.T) { + var recorded bool + lateErr := errors.New("late required store failed") + + err := recordSchemaVersionAfterPersistence( + func() error { return lateErr }, + func() error { t.Fatal("health check ran after validation failure"); return nil }, + func() { recorded = true }, + ) + if !errors.Is(err, lateErr) { + t.Fatalf("error = %v, want %v", err, lateErr) + } + if recorded { + t.Fatal("schema version was recorded after a late required-store failure") + } +} + +func TestRecordSchemaVersionAfterPersistencePreservesPreviousVersionOnLateFailure(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "kandev.db") + raw, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + writer := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = writer.Close() }) + if _, err := writer.Exec(` + CREATE TABLE kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT ''); + INSERT INTO kandev_meta (key, value) VALUES ('kandev_version', 'v0.93.0') + `); err != nil { + t.Fatalf("seed kandev_meta: %v", err) + } + + err = recordSchemaVersionAfterPersistence( + func() error { return errors.New("late required store failed") }, + func() error { return nil }, + func() { recordSchemaVersion(writer, "sqlite", "current", nil) }, + ) + if err == nil { + t.Fatal("late required-store failure was not returned") + } + var version string + if err := writer.QueryRow(`SELECT value FROM kandev_meta WHERE key = ?`, "kandev_version").Scan(&version); err != nil { + t.Fatalf("read kandev_version: %v", err) + } + if version != "v0.93.0" { + t.Fatalf("kandev_version = %q, want previous version v0.93.0", version) + } +} + +func TestRecordSchemaVersionAfterPersistenceRecordsAfterBothGates(t *testing.T) { + var order []string + err := recordSchemaVersionAfterPersistence( + func() error { order = append(order, "validate"); return nil }, + func() error { order = append(order, "health"); return nil }, + func() { order = append(order, "record") }, + ) + if err != nil { + t.Fatalf("recordSchemaVersionAfterPersistence: %v", err) + } + want := []string{"validate", "health", "record"} + if !reflect.DeepEqual(order, want) { + t.Fatalf("order = %v, want %v", order, want) + } +} + +func TestProvideRepositoriesStopsOnRequiredMigrationFailure(t *testing.T) { + home := t.TempDir() + dataDir := filepath.Join(home, "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatalf("create data dir: %v", err) + } + dbPath := filepath.Join(dataDir, "kandev.db") + raw, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + _, err = raw.Exec(` + CREATE TABLE kandev_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + ); + INSERT INTO kandev_meta (key, value) VALUES ('kandev_version', 'v0.93.0'); + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + INSERT INTO users (id, email, created_at, updated_at) + VALUES ('legacy-a', 'same@example.test', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('legacy-b', 'same@example.test', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `) + if err != nil { + _ = raw.Close() + t.Fatalf("seed malformed users schema: %v", err) + } + if err := raw.Close(); err != nil { + t.Fatalf("close seed database: %v", err) + } + + cfg := &config.Config{ + HomeDir: home, + Database: config.DatabaseConfig{ + Driver: "sqlite", + Path: dbPath, + }, + Source: config.ConfigSource{Values: map[string]config.SettingSource{ + "database.path": config.SourceConfiguration, + }}, + } + _, _, _, err = provideRepositories(context.Background(), cfg, newTestLogger(), "test") + if err == nil { + t.Fatal("provideRepositories succeeded despite an injected required migration failure") + } + if !strings.Contains(err.Error(), "required user migration") || + !strings.Contains(err.Error(), "users.email_unique") { + t.Fatalf("error = %v, want required users.email_unique migration failure", err) + } + + check, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("reopen failed-upgrade database: %v", err) + } + defer func() { _ = check.Close() }() + var version string + if err := check.QueryRow(`SELECT value FROM kandev_meta WHERE key = ?`, "kandev_version").Scan(&version); err != nil { + t.Fatalf("read previous schema version: %v", err) + } + if version != "v0.93.0" { + t.Fatalf("kandev_version after late migration failure = %q, want v0.93.0", version) + } +} diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index 58dd5a9554..366f48ed32 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -28,6 +28,7 @@ import ( officeservice "github.com/kandev/kandev/internal/office/service" "github.com/kandev/kandev/internal/org" "github.com/kandev/kandev/internal/orgunit" + "github.com/kandev/kandev/internal/persistence/requiredstores" "github.com/kandev/kandev/internal/plugins" promptservice "github.com/kandev/kandev/internal/prompts/service" promptstore "github.com/kandev/kandev/internal/prompts/store" @@ -35,6 +36,7 @@ import ( "github.com/kandev/kandev/internal/runtimeflags" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/sentry" + systemsettings "github.com/kandev/kandev/internal/system/settings" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/task/share" @@ -51,10 +53,11 @@ import ( ) type Repositories struct { - Task *sqliterepo.Repository - Analytics analyticsrepository.Repository - AgentSettings settingsstore.Repository - User userstore.Repository + RequiredStores *requiredstores.Tracker + Task *sqliterepo.Repository + Analytics analyticsrepository.Repository + AgentSettings settingsstore.Repository + User userstore.Repository // UserAccounts is the account-management view of the same user store // (list/create/role/status), consumed by the auth service. UserAccounts userstore.AccountRepository @@ -69,7 +72,9 @@ type Repositories struct { QuickTerminal *quickterminalrepository.Repository RuntimeFlags *runtimeflags.SQLiteStore // Auth persists login identities, sessions, PATs, and invites. - Auth *authstore.Store + Auth *authstore.Store + HostnameCache *hostnames.Store + SystemSettings *systemsettings.Store } type Services struct { diff --git a/apps/backend/internal/db/dialect/dialect_test.go b/apps/backend/internal/db/dialect/dialect_test.go index 6b12aedfa0..3b94cc802a 100644 --- a/apps/backend/internal/db/dialect/dialect_test.go +++ b/apps/backend/internal/db/dialect/dialect_test.go @@ -162,6 +162,15 @@ func TestDateTimeOf(t *testing.T) { } } +func TestNullableTimestamp(t *testing.T) { + if got := NullableTimestamp(SQLite3, "?"); got != "?" { + t.Errorf("sqlite: got %q", got) + } + if got := NullableTimestamp(PGX, "?"); got != "(?)::timestamptz" { + t.Errorf("pgx: got %q", got) + } +} + func TestNaiveUTCTimestampOf(t *testing.T) { got := NaiveUTCTimestampOf(SQLite3, "ts.started_at") if got != "datetime(ts.started_at)" { diff --git a/apps/backend/internal/db/dialect/schema.go b/apps/backend/internal/db/dialect/schema.go new file mode 100644 index 0000000000..f131a0dea1 --- /dev/null +++ b/apps/backend/internal/db/dialect/schema.go @@ -0,0 +1,90 @@ +package dialect + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + schemaTokenTimestamp = "{{timestamp}}" + schemaTokenBool = "{{bool}}" + schemaTokenBoolean = "{{boolean}}" + schemaTokenIdentity = "{{identity}}" + schemaTokenCurrentTime = "{{current_time}}" + schemaTokenNow = "{{now}}" +) + +var schemaTimestampTypePattern = regexp.MustCompile(`(?i)\b(?:TIMESTAMPTZ|TIMESTAMP|DATETIME)\b`) + +// SchemaTokenTimestamp is the portable timestamp type token for schema text. +const SchemaTokenTimestamp = schemaTokenTimestamp + +// SchemaTokenBoolean is the portable boolean type token for schema text. +const SchemaTokenBoolean = schemaTokenBoolean + +// SchemaTokenIdentity is the portable integer identity type token for schema +// text. It is used after a column name and before its constraints. +const SchemaTokenIdentity = schemaTokenIdentity + +// SchemaTokenCurrentTime is the portable current-time default token. +const SchemaTokenCurrentTime = schemaTokenCurrentTime + +// RenderSchema expands the small, explicit set of portable schema tokens for a +// supported driver. Unknown or unexpanded tokens are rejected so a new schema +// cannot silently ship with the wrong engine-specific type. +func RenderSchema(driver, schema string) (string, error) { + if driver != SQLite3 && driver != PGX { + return "", fmt.Errorf("unsupported database driver %q", driver) + } + timestamp := TimestampType(driver) + boolean := "INTEGER" + identity := "INTEGER" + if IsPostgres(driver) { + boolean = "BOOLEAN" + identity = "BIGSERIAL" + } + rendered := strings.NewReplacer( + schemaTokenTimestamp, timestamp, + schemaTokenBool, boolean, + schemaTokenBoolean, boolean, + schemaTokenIdentity, identity, + schemaTokenCurrentTime, "CURRENT_TIMESTAMP", + schemaTokenNow, "CURRENT_TIMESTAMP", + ).Replace(schema) + // Existing stores used these generic type spellings before the token + // contract was introduced. Keep their rendering centralized while those + // schema constants migrate to explicit tokens. Word boundaries are + // important here: CURRENT_TIMESTAMP is an expression, not a type. + rendered = schemaTimestampTypePattern.ReplaceAllString(rendered, timestamp) + if IsPostgres(driver) { + rendered = strings.ReplaceAll(rendered, "BOOLEAN NOT NULL DEFAULT 1", "BOOLEAN NOT NULL DEFAULT TRUE") + rendered = strings.ReplaceAll(rendered, "BOOLEAN NOT NULL DEFAULT 0", "BOOLEAN NOT NULL DEFAULT FALSE") + rendered = strings.ReplaceAll(rendered, "BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE") + rendered = strings.ReplaceAll(rendered, "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE") + } + if IsPostgres(driver) { + // Apply the boolean default normalization after token expansion too. + // A tokenized BOOLEAN column cannot be normalized by the legacy pass + // above because its type is not known until this point. + rendered = strings.ReplaceAll(rendered, "BOOLEAN NOT NULL DEFAULT 1", "BOOLEAN NOT NULL DEFAULT TRUE") + rendered = strings.ReplaceAll(rendered, "BOOLEAN NOT NULL DEFAULT 0", "BOOLEAN NOT NULL DEFAULT FALSE") + rendered = strings.ReplaceAll(rendered, "BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE") + rendered = strings.ReplaceAll(rendered, "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE") + } + if strings.Contains(rendered, "{{") || strings.Contains(rendered, "}}") { + return "", fmt.Errorf("schema contains unknown or unexpanded token") + } + return rendered, nil +} + +// MustRenderSchema is the startup convenience for static schema constants. +// A schema token error is a programming/configuration error and must not be +// silently ignored by a constructor. +func MustRenderSchema(driver, schema string) string { + rendered, err := RenderSchema(driver, schema) + if err != nil { + panic(err) + } + return rendered +} diff --git a/apps/backend/internal/db/dialect/schema_test.go b/apps/backend/internal/db/dialect/schema_test.go new file mode 100644 index 0000000000..26ead02199 --- /dev/null +++ b/apps/backend/internal/db/dialect/schema_test.go @@ -0,0 +1,82 @@ +package dialect + +import ( + "strings" + "testing" +) + +func TestRenderSchema(t *testing.T) { + schema := `CREATE TABLE example ( + id {{identity}} PRIMARY KEY, + enabled {{bool}} NOT NULL DEFAULT 0, + created_at {{timestamp}} NOT NULL DEFAULT {{current_time}} + )` + + sqlite, err := RenderSchema(SQLite3, schema) + if err != nil { + t.Fatalf("RenderSchema(sqlite) error = %v", err) + } + if !strings.Contains(sqlite, "id INTEGER PRIMARY KEY") || + !strings.Contains(sqlite, "enabled INTEGER") || + !strings.Contains(sqlite, "created_at DATETIME") || + !strings.Contains(sqlite, "DEFAULT CURRENT_TIMESTAMP") { + t.Errorf("RenderSchema(sqlite) = %q", sqlite) + } + + postgres, err := RenderSchema(PGX, schema) + if err != nil { + t.Fatalf("RenderSchema(postgres) error = %v", err) + } + if !strings.Contains(postgres, "id BIGSERIAL PRIMARY KEY") || + !strings.Contains(postgres, "enabled BOOLEAN") || + !strings.Contains(postgres, "enabled BOOLEAN NOT NULL DEFAULT FALSE") || + !strings.Contains(postgres, "created_at TIMESTAMPTZ") { + t.Errorf("RenderSchema(postgres) = %q", postgres) + } +} + +func TestRenderSchemaRejectsUnknownAndUnexpandedTokens(t *testing.T) { + for _, test := range []struct { + name string + driver string + schema string + }{ + {name: "unknown token", driver: SQLite3, schema: "CREATE TABLE x (v {{uuid}})"}, + {name: "unexpanded token", driver: SQLite3, schema: "CREATE TABLE x (v {{timestamp}} {{other}})"}, + {name: "unknown driver", driver: "mysql", schema: "CREATE TABLE x (v {{timestamp}})"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := RenderSchema(test.driver, test.schema); err == nil { + t.Fatal("RenderSchema() error = nil, want error") + } + }) + } +} + +func TestRenderSchemaSupportsBooleanAndCurrentTimeAliases(t *testing.T) { + for _, token := range []string{schemaTokenBool, schemaTokenBoolean} { + query, err := RenderSchema(SQLite3, "CREATE TABLE x (enabled "+token+" DEFAULT "+schemaTokenNow+")") + if err != nil { + t.Fatalf("RenderSchema(%q) error = %v", token, err) + } + if strings.Contains(query, "{{") { + t.Fatalf("RenderSchema(%q) left token in %q", token, query) + } + } +} + +func TestRenderSchemaPreservesCurrentTimestampExpression(t *testing.T) { + schema := "CREATE TABLE example (created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)" + for _, driver := range []string{SQLite3, PGX} { + rendered, err := RenderSchema(driver, schema) + if err != nil { + t.Fatalf("RenderSchema(%q) error = %v", driver, err) + } + if strings.Contains(rendered, "CURRENT_TIMESTAMPTZ") || strings.Contains(rendered, "CURRENT_DATETIME") { + t.Fatalf("RenderSchema(%q) rewrote current-time expression: %q", driver, rendered) + } + if !strings.Contains(rendered, "DEFAULT CURRENT_TIMESTAMP") { + t.Fatalf("RenderSchema(%q) dropped current-time expression: %q", driver, rendered) + } + } +} diff --git a/apps/backend/internal/db/dialect/time.go b/apps/backend/internal/db/dialect/time.go index af9a1bbe12..15dd323b1a 100644 --- a/apps/backend/internal/db/dialect/time.go +++ b/apps/backend/internal/db/dialect/time.go @@ -2,6 +2,17 @@ package dialect import "fmt" +// NullableTimestamp renders a nullable timestamp parameter for predicates +// that use the parameter both as a NULL sentinel and as a timestamp value. +// PostgreSQL cannot infer the type of a parameter used only in `? IS NULL`, +// so the sentinel occurrence must carry an explicit timestamptz cast. +func NullableTimestamp(driver, placeholder string) string { + if IsPostgres(driver) { + return fmt.Sprintf("(%s)::timestamptz", placeholder) + } + return placeholder +} + // DurationMs returns the SQL expression for the difference between two timestamps in milliseconds. // // SQLite: (julianday(end) - julianday(start)) * 86400000 diff --git a/apps/backend/internal/db/exec.go b/apps/backend/internal/db/exec.go new file mode 100644 index 0000000000..a13924f86c --- /dev/null +++ b/apps/backend/internal/db/exec.go @@ -0,0 +1,40 @@ +package db + +import ( + "context" + "database/sql" + + "github.com/jmoiron/sqlx" +) + +// Rebinder is implemented by sqlx.DB and sqlx.Tx. Queries remain in the +// common question-mark form until they reach the final database boundary. +type Rebinder interface { + Rebind(string) string +} + +// ContextExecer is the final writer boundary used by ExecContext. +type ContextExecer interface { + Rebinder + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +// Bind expands sqlx.In arguments and then rebinds placeholders exactly once. +// Callers must pass the returned query directly to the same database or +// transaction boundary; do not call Rebind on it again. +func Bind(rebinder Rebinder, query string, args ...any) (string, []any, error) { + expanded, expandedArgs, err := sqlx.In(query, args...) + if err != nil { + return "", nil, err + } + return rebinder.Rebind(expanded), expandedArgs, nil +} + +// ExecContext expands, rebinds, and executes a query at its final boundary. +func ExecContext(ctx context.Context, execer ContextExecer, query string, args ...any) (sql.Result, error) { + bound, boundArgs, err := Bind(execer, query, args...) + if err != nil { + return nil, err + } + return execer.ExecContext(ctx, bound, boundArgs...) +} diff --git a/apps/backend/internal/db/exec_test.go b/apps/backend/internal/db/exec_test.go new file mode 100644 index 0000000000..2655c076f3 --- /dev/null +++ b/apps/backend/internal/db/exec_test.go @@ -0,0 +1,67 @@ +package db + +import ( + "context" + "database/sql" + "testing" + + "github.com/jmoiron/sqlx" +) + +func TestBindRebindsAfterInExpansion(t *testing.T) { + fake := &rebindRecorder{placeholder: "$"} + query, args, err := Bind(fake, "SELECT * FROM tasks WHERE id IN (?) AND state = ?", []string{"a", "b"}, "open") + if err != nil { + t.Fatalf("Bind() error = %v", err) + } + if query != "SELECT * FROM tasks WHERE id IN ($1, $2) AND state = $3" { + t.Fatalf("Bind() query = %q", query) + } + if len(args) != 3 || args[0] != "a" || args[1] != "b" || args[2] != "open" { + t.Fatalf("Bind() args = %#v", args) + } + if fake.calls != 1 { + t.Fatalf("Rebind() calls = %d, want 1", fake.calls) + } +} + +func TestExecContextBindsAtFinalBoundary(t *testing.T) { + fake := &execRecorder{rebindRecorder: rebindRecorder{placeholder: "$"}} + _, err := ExecContext(context.Background(), fake, "UPDATE tasks SET state = ? WHERE id = ?", "done", "task-1") + if err != nil { + t.Fatalf("ExecContext() error = %v", err) + } + if fake.query != "UPDATE tasks SET state = $1 WHERE id = $2" { + t.Fatalf("ExecContext() query = %q", fake.query) + } + if fake.calls != 1 { + t.Fatalf("Rebind() calls = %d, want 1", fake.calls) + } +} + +type rebindRecorder struct { + placeholder string + calls int +} + +func (r *rebindRecorder) Rebind(query string) string { + r.calls++ + return sqlx.Rebind(sqlx.DOLLAR, query) +} + +type execRecorder struct { + rebindRecorder + query string + args []any +} + +func (r *execRecorder) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) { + r.query = query + r.args = args + return fakeResult{}, nil +} + +type fakeResult struct{} + +func (fakeResult) LastInsertId() (int64, error) { return 0, nil } +func (fakeResult) RowsAffected() (int64, error) { return 0, nil } diff --git a/apps/backend/internal/db/migratelog.go b/apps/backend/internal/db/migratelog.go index 679425e7e3..0bf38aea6b 100644 --- a/apps/backend/internal/db/migratelog.go +++ b/apps/backend/internal/db/migratelog.go @@ -1,6 +1,8 @@ package db import ( + "fmt" + "github.com/jmoiron/sqlx" "go.uber.org/zap" @@ -12,15 +14,27 @@ import ( // calls while adding observability: applied migrations log at INFO, idempotent // no-ops are silent, and unexpected failures log at WARN. type MigrateLogger struct { - db *sqlx.DB - log *logger.Logger + db *sqlx.DB + log *logger.Logger + strict bool + firstErr error } // NewMigrateLogger creates a MigrateLogger for the given writer connection. // log may be nil, in which case all output is suppressed (matches the existing // no-op pattern used in tests). func NewMigrateLogger(db *sqlx.DB, log *logger.Logger) *MigrateLogger { - return &MigrateLogger{db: db, log: log} + return newMigrateLogger(db, log, false) +} + +// NewRequiredMigrateLogger creates a migration logger whose unexpected +// failures are fatal to the owning required store. +func NewRequiredMigrateLogger(db *sqlx.DB, log *logger.Logger) *MigrateLogger { + return newMigrateLogger(db, log, true) +} + +func newMigrateLogger(db *sqlx.DB, log *logger.Logger, strict bool) *MigrateLogger { + return &MigrateLogger{db: db, log: log, strict: strict} } // Apply executes stmt and classifies the result: @@ -28,20 +42,34 @@ func NewMigrateLogger(db *sqlx.DB, log *logger.Logger) *MigrateLogger { // - "already exists" error: silent (idempotent re-run) // - anything else: logs "migration failed" at WARN // -// The error is never returned - this matches the contract of the legacy -// `_, _ = db.Exec(...)` pattern, with observability added. -func (m *MigrateLogger) Apply(name, stmt string) { +// In strict mode, the first unexpected error is retained and returned. In +// compatibility mode, unexpected errors are logged and swallowed as before. +func (m *MigrateLogger) Apply(name, stmt string) error { if _, err := m.db.Exec(stmt); err != nil { if IsAlreadyExistsError(err) { - return + return nil + } + wrapped := fmt.Errorf("migration %q failed: %w", name, err) + if m.strict && m.firstErr == nil { + m.firstErr = wrapped } if m.log != nil { m.log.Warn("migration failed", - zap.String("name", name), zap.Error(err)) + zap.String("name", name), zap.Error(wrapped)) } - return + if m.strict { + return wrapped + } + return nil } if m.log != nil { m.log.Info("migration applied", zap.String("name", name)) } + return nil +} + +// Err returns the first unexpected migration failure observed by this +// logger. Idempotent duplicate errors are not reported. +func (m *MigrateLogger) Err() error { + return m.firstErr } diff --git a/apps/backend/internal/db/migratelog_test.go b/apps/backend/internal/db/migratelog_test.go index f874ce72a1..98ff53d87b 100644 --- a/apps/backend/internal/db/migratelog_test.go +++ b/apps/backend/internal/db/migratelog_test.go @@ -96,6 +96,37 @@ func TestMigrateLogger_Apply_BrokenStatement(t *testing.T) { } } +func TestRequiredMigrateLogger_Apply_ReturnsUnexpectedFailure(t *testing.T) { + db := memDB(t) + m := NewRequiredMigrateLogger(db, nil) + + err := m.Apply("no_such.col", `ALTER TABLE no_such_table ADD COLUMN col TEXT`) + if err == nil { + t.Fatal("expected required migration failure") + } + if m.Err() == nil { + t.Fatal("expected required migration logger to retain the failure") + } +} + +func TestRequiredMigrateLogger_Apply_AllowsIdempotentFailure(t *testing.T) { + db := memDB(t) + if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY)`); err != nil { + t.Fatalf("create table: %v", err) + } + if _, err := db.Exec(`ALTER TABLE t ADD COLUMN col TEXT DEFAULT ''`); err != nil { + t.Fatalf("initial alter: %v", err) + } + + m := NewRequiredMigrateLogger(db, nil) + if err := m.Apply("t.col", `ALTER TABLE t ADD COLUMN col TEXT DEFAULT ''`); err != nil { + t.Fatalf("idempotent migration: %v", err) + } + if m.Err() != nil { + t.Fatalf("idempotent migration recorded an error: %v", m.Err()) + } +} + // TestMigrateLogger_Apply_NilLog verifies that a nil logger does not panic. func TestMigrateLogger_Apply_NilLog(t *testing.T) { db := memDB(t) diff --git a/apps/backend/internal/db/postgres.go b/apps/backend/internal/db/postgres.go index dcde3ef9fc..34b15366a7 100644 --- a/apps/backend/internal/db/postgres.go +++ b/apps/backend/internal/db/postgres.go @@ -1,18 +1,28 @@ package db import ( + "context" "database/sql" "fmt" + "time" - _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/stdlib" ) // OpenPostgres opens a PostgreSQL database connection using pgx. // If maxConns or minConns are 0, they default to 25 and 5 respectively. func OpenPostgres(dsn string, maxConns, minConns int) (*sql.DB, error) { - db, err := sql.Open("pgx", dsn) + config, err := pgx.ParseConfig(dsn) if err != nil { - return nil, fmt.Errorf("failed to open postgres database: %w", err) + return nil, fmt.Errorf("failed to parse postgres configuration: %w", err) + } + db := sql.OpenDB(stdlib.GetConnector(*config, stdlib.OptionAfterConnect(func(_ context.Context, conn *pgx.Conn) error { + return configurePostgresTimeTypes(conn.TypeMap()) + }))) + if db == nil { + return nil, fmt.Errorf("failed to open postgres database") } if maxConns <= 0 { @@ -32,3 +42,23 @@ func OpenPostgres(dsn string, maxConns, minConns int) (*sql.DB, error) { return db, nil } + +// configurePostgresTimeTypes makes database/sql scans deterministic across +// hosts. pgx's default timestamptz and timestamp codecs use time.Local when no +// scan location is configured, even though Kandev stores both forms as UTC. +func configurePostgresTimeTypes(typeMap *pgtype.Map) error { + for _, typeName := range []string{"timestamp", "timestamptz"} { + registered, ok := typeMap.TypeForName(typeName) + if !ok { + return fmt.Errorf("postgres type map has no %s type", typeName) + } + replacement := *registered + if typeName == "timestamp" { + replacement.Codec = &pgtype.TimestampCodec{ScanLocation: time.UTC} + } else { + replacement.Codec = &pgtype.TimestamptzCodec{ScanLocation: time.UTC} + } + typeMap.RegisterType(&replacement) + } + return nil +} diff --git a/apps/backend/internal/db/postgres_test.go b/apps/backend/internal/db/postgres_test.go new file mode 100644 index 0000000000..73cf984cd9 --- /dev/null +++ b/apps/backend/internal/db/postgres_test.go @@ -0,0 +1,31 @@ +package db + +import ( + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestConfigurePostgresTimeTypesUsesUTCScans(t *testing.T) { + typeMap := pgtype.NewMap() + if err := configurePostgresTimeTypes(typeMap); err != nil { + t.Fatalf("configurePostgresTimeTypes: %v", err) + } + + timestamp, ok := typeMap.TypeForName("timestamp") + if !ok { + t.Fatal("timestamp type is not registered") + } + if codec, ok := timestamp.Codec.(*pgtype.TimestampCodec); !ok || codec.ScanLocation != time.UTC { + t.Fatalf("timestamp scan location = %#v, want UTC", timestamp.Codec) + } + + timestamptz, ok := typeMap.TypeForName("timestamptz") + if !ok { + t.Fatal("timestamptz type is not registered") + } + if codec, ok := timestamptz.Codec.(*pgtype.TimestamptzCodec); !ok || codec.ScanLocation != time.UTC { + t.Fatalf("timestamptz scan location = %#v, want UTC", timestamptz.Codec) + } +} diff --git a/apps/backend/internal/db/schema.go b/apps/backend/internal/db/schema.go index 8504146bed..c6e37d9d95 100644 --- a/apps/backend/internal/db/schema.go +++ b/apps/backend/internal/db/schema.go @@ -1,6 +1,9 @@ package db -import "database/sql" +import ( + "context" + "database/sql" +) // SchemaQuerier is the subset of sqlx database and transaction methods used // by the portable schema probes. @@ -11,22 +14,44 @@ type SchemaQuerier interface { Rebind(string) string } +// ContextSchemaQuerier is the context-aware subset used by probes that must +// stop when their caller's deadline expires. +type ContextSchemaQuerier interface { + SchemaQuerier + QueryRowContext(context.Context, string, ...any) *sql.Row +} + // TableExists reports whether table exists in the active database schema. // Missing tables are reported as false without an error. func TableExists(conn SchemaQuerier, table string) (bool, error) { - query := `SELECT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = current_schema() AND table_name = ? - )` - if !isPostgresSchema(conn) { - query = `SELECT EXISTS ( - SELECT 1 FROM sqlite_master - WHERE type = 'table' AND name = ? + return scanTableExists(tableExistsQuery(conn), table, func(query string, args ...any) *sql.Row { + return conn.QueryRow(query, args...) + }, conn.Rebind) +} + +// TableExistsContext is the deadline-aware form of TableExists. +func TableExistsContext(ctx context.Context, conn ContextSchemaQuerier, table string) (bool, error) { + return scanTableExists(tableExistsQuery(conn), table, func(query string, args ...any) *sql.Row { + return conn.QueryRowContext(ctx, query, args...) + }, conn.Rebind) +} + +func tableExistsQuery(conn SchemaQuerier) string { + if isPostgresSchema(conn) { + return `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = ? )` } + return `SELECT EXISTS ( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = ? + )` +} +func scanTableExists(query, table string, queryRow func(string, ...any) *sql.Row, rebind func(string) string) (bool, error) { var exists bool - if err := conn.QueryRow(conn.Rebind(query), table).Scan(&exists); err != nil { + if err := queryRow(rebind(query), table).Scan(&exists); err != nil { return false, err } return exists, nil diff --git a/apps/backend/internal/db/schema_postgres_test.go b/apps/backend/internal/db/schema_postgres_test.go index 1c000d8fed..c14420244b 100644 --- a/apps/backend/internal/db/schema_postgres_test.go +++ b/apps/backend/internal/db/schema_postgres_test.go @@ -1,8 +1,9 @@ -package db +package db_test import ( "testing" + "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/testutil" ) @@ -15,7 +16,7 @@ func TestTableColumnsAndExistsPostgres(t *testing.T) { t.Fatalf("create table: %v", err) } - exists, err := TableExists(database, "sample_schema") + exists, err := db.TableExists(database, "sample_schema") if err != nil { t.Fatalf("table exists: %v", err) } @@ -23,7 +24,7 @@ func TestTableColumnsAndExistsPostgres(t *testing.T) { t.Fatal("TableExists = false, want true") } - columns, err := TableColumns(database, "sample_schema") + columns, err := db.TableColumns(database, "sample_schema") if err != nil { t.Fatalf("table columns: %v", err) } @@ -31,7 +32,7 @@ func TestTableColumnsAndExistsPostgres(t *testing.T) { t.Fatalf("columns = %#v, want created_at and not missing", columns) } - got, err := ColumnExists(database, "sample_schema", "missing") + got, err := db.ColumnExists(database, "sample_schema", "missing") if err != nil { t.Fatalf("missing column exists: %v", err) } @@ -39,7 +40,7 @@ func TestTableColumnsAndExistsPostgres(t *testing.T) { t.Fatal("ColumnExists for missing column = true, want false") } - missingTable, err := TableExists(database, "missing_schema") + missingTable, err := db.TableExists(database, "missing_schema") if err != nil { t.Fatalf("missing table exists: %v", err) } @@ -47,7 +48,7 @@ func TestTableColumnsAndExistsPostgres(t *testing.T) { t.Fatal("TableExists for missing table = true, want false") } - missingColumns, err := TableColumns(database, "missing_schema") + missingColumns, err := db.TableColumns(database, "missing_schema") if err != nil { t.Fatalf("missing table columns: %v", err) } diff --git a/apps/backend/internal/db/sqlguard/analyzer.go b/apps/backend/internal/db/sqlguard/analyzer.go new file mode 100644 index 0000000000..5097236948 --- /dev/null +++ b/apps/backend/internal/db/sqlguard/analyzer.go @@ -0,0 +1,634 @@ +// Package sqlguard provides a small AST-based check for SQL constructs that +// are unsafe across Kandev's SQLite and PostgreSQL engines. +package sqlguard + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// Rule identifies one SQL portability rule. +type Rule string + +const ( + RuleSQLiteCatalog Rule = "sqlite-catalog" + RuleConflictSyntax Rule = "conflict-syntax" + RuleRawPlaceholder Rule = "raw-placeholder" + RuleBooleanInteger Rule = "boolean-integer" + RuleSQLiteDateFunction Rule = "sqlite-date-function" + RuleDateTimeType Rule = "datetime-type" +) + +var knownRules = map[Rule]string{ + RuleSQLiteCatalog: "SQLite catalog or PRAGMA syntax", + RuleConflictSyntax: "SQLite conflict syntax", + RuleRawPlaceholder: "raw question-mark placeholder", + RuleBooleanInteger: "integer literal for a SQL boolean column", + RuleSQLiteDateFunction: "SQLite-only date function", + RuleDateTimeType: "direct DATETIME type", +} + +// Exemption allows one exact source file, symbol, and rule to use an +// intentional SQLite-only construct. +type Exemption struct { + File string `json:"file"` + Symbol string `json:"symbol"` + Rule Rule `json:"rule"` + Reason string `json:"reason"` +} + +// Finding is one portability violation. +type Finding struct { + File string + Line int + Column int + Symbol string + Rule Rule + Message string +} + +var ( + sqliteCatalogPattern = regexp.MustCompile(`(?i)\b(?:sqlite_master|sqlite_schema|pragma(?:_[a-z0-9_]+)?)\b`) + conflictPattern = regexp.MustCompile(`(?i)\bINSERT\s+OR\s+IGNORE\b`) + booleanIntegerPattern = regexp.MustCompile(`(?is)\bBOOLEAN\b.{0,100}?\bDEFAULT\s+[01]\b`) + // These names are the boolean columns used by the built-in SQL schemas. + // The list is deliberately explicit: fields such as status, priority, and + // is_ephemeral are integer state values, not SQL BOOLEAN columns. + booleanComparisonPattern = regexp.MustCompile(`(?i)\b(?:enabled|is_enabled|active|is_active|hidden|is_hidden|deleted|is_deleted|builtin|is_builtin|installed|is_installed|last_ok|poll_enabled|user_modified|cli_passthrough|auto_approve)\b\s*(?:<>|!=|<=|>=|=|<|>)\s*[01]\b`) + sqliteDatePattern = regexp.MustCompile(`(?i)\b(?:date|datetime|strftime|julianday)\s*\(`) + datetimeTypePattern = regexp.MustCompile(`(?i)(?:^|[\s(,])DATETIME(?:\s|[,);]|$)`) + pragmaPattern = regexp.MustCompile(`(?i)\bpragma(?:_[a-z0-9_]+)?\b`) + sqlPattern = regexp.MustCompile(`(?i)\b(?:SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|WITH)\b`) +) + +type analysisResult struct { + findings []Finding + used map[string]struct{} +} + +// AnalyzeSource checks one Go source file. filename participates in exact +// exemption matching and should be the repository-relative path used by the +// caller. +func AnalyzeSource(filename string, source []byte, exemptions []Exemption) ([]Finding, error) { + if err := ValidateExemptions(exemptions); err != nil { + return nil, err + } + result, err := analyzeSource(filename, source, exemptions) + if err != nil { + return nil, err + } + return result.findings, nil +} + +func analyzeSource(filename string, source []byte, exemptions []Exemption) (analysisResult, error) { + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, filename, source, 0) + if err != nil { + return analysisResult{}, fmt.Errorf("parse %s: %w", filename, err) + } + parents := parentNodes(file) + values := topLevelStringValues(file) + result := analysisResult{used: make(map[string]struct{})} + analyzeStringLiterals(filename, file, fileSet, parents, exemptions, &result) + analyzeExecutorCalls(filename, file, fileSet, parents, values, exemptions, &result) + return result, nil +} + +func analyzeStringLiterals( + filename string, + file *ast.File, + fileSet *token.FileSet, + parents map[ast.Node]ast.Node, + exemptions []Exemption, + result *analysisResult, +) { + seen := make(map[string]struct{}) + ast.Inspect(file, func(node ast.Node) bool { + literal, ok := node.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(literal.Value) + if err != nil { + return true + } + symbol := sourceSymbol(literal, parents) + analyzeSQLLiteral(filename, value, symbol, fileSet.Position(literal.Pos()), exemptions, seen, result) + return true + }) +} + +func analyzeSQLLiteral( + filename, value, symbol string, + position token.Position, + exemptions []Exemption, + seen map[string]struct{}, + result *analysisResult, + forceSQL ...bool, +) { + sqlText := sqlPattern.MatchString(value) + if len(forceSQL) > 0 && forceSQL[0] { + sqlText = true + } + checks := []struct { + rule Rule + match bool + message string + }{ + {RuleSQLiteCatalog, sqlText && sqliteCatalogPattern.MatchString(value), "SQLite catalog or PRAGMA syntax must stay behind a dialect boundary"}, + {RuleConflictSyntax, sqlText && conflictPattern.MatchString(value), "use portable conflict syntax"}, + {RuleBooleanInteger, sqlText && (booleanIntegerPattern.MatchString(value) || booleanComparisonPattern.MatchString(value)), "use a boolean value or a dialect-rendered boolean default"}, + {RuleSQLiteDateFunction, sqlText && sqliteDatePattern.MatchString(value), "use an internal/db/dialect date helper"}, + {RuleDateTimeType, sqlText && datetimeTypePattern.MatchString(value), "use a dialect-rendered timestamp type"}, + } + for _, check := range checks { + if !check.match { + continue + } + key := findingKey(filename, symbol, check.rule, position.Line, position.Column) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + if matchesExemption(filename, symbol, check.rule, exemptions) { + result.used[exemptionKey(Exemption{File: filename, Symbol: symbol, Rule: check.rule})] = struct{}{} + continue + } + result.findings = append(result.findings, Finding{ + File: position.Filename, Line: position.Line, Column: position.Column, + Symbol: symbol, Rule: check.rule, Message: check.message, + }) + } +} + +func analyzeExecutorCalls( + filename string, + file *ast.File, + fileSet *token.FileSet, + parents map[ast.Node]ast.Node, + values map[string]queryValue, + exemptions []Exemption, + result *analysisResult, +) { + seen := make(map[string]struct{}) + scopes := make([]functionScope, 0, len(file.Decls)) + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + scopes = append(scopes, functionScope{body: function.Body, params: function.Type.Params}) + } + ast.Inspect(file, func(node ast.Node) bool { + if literal, ok := node.(*ast.FuncLit); ok { + scopes = append(scopes, functionScope{body: literal.Body, params: literal.Type.Params}) + return false + } + return true + }) + for _, scope := range scopes { + functionValues := cloneQueryValues(values) + for name := range localStringNames(scope.body) { + delete(functionValues, name) + } + for _, field := range scope.params.List { + for _, name := range field.Names { + delete(functionValues, name.Name) + } + } + ast.Inspect(scope.body, func(node ast.Node) bool { + // A function literal introduces its own parameter and assignment + // scope. It is analyzed separately when it is a top-level value; + // treating its parameters as the enclosing function's values would + // make an unrelated package-level query look executable here. + if literal, ok := node.(*ast.FuncLit); ok { + _ = literal + return false + } + switch node := node.(type) { + case *ast.ValueSpec: + recordStringValueSpec(node, functionValues) + case *ast.AssignStmt: + recordStringAssignments(node, functionValues) + case *ast.RangeStmt: + analyzeRangeSQLValues(node, functionValues) + case *ast.CallExpr: + analyzeExecutorCall(filename, node, fileSet, parents, functionValues, exemptions, seen, result) + } + return true + }) + } +} + +type functionScope struct { + body *ast.BlockStmt + params *ast.FieldList +} + +func analyzeRangeSQLValues( + rangeStmt *ast.RangeStmt, + values map[string]queryValue, +) { + valueName, ok := rangeStmt.Value.(*ast.Ident) + if !ok { + return + } + composite, ok := rangeStmt.X.(*ast.CompositeLit) + if !ok { + return + } + // Keep one unsafe representative for the loop variable. The eventual + // executor call still decides whether that value is wrapped by Rebind, so + // a loop over raw query strings does not produce a false positive when the + // body correctly rebinds each query. + for _, element := range composite.Elts { + query, ok := resolveQueryValue(element, values) + if ok && !query.bound && rawPlaceholder(query.value) { + query.expression = element + values[valueName.Name] = query + return + } + } +} + +func analyzeExecutorCall( + filename string, + call *ast.CallExpr, + fileSet *token.FileSet, + parents map[ast.Node]ast.Node, + values map[string]queryValue, + exemptions []Exemption, + seen map[string]struct{}, + result *analysisResult, +) { + if !isExecutorCall(call) { + return + } + query, value, bound, ok := queryArgument(call, values) + if !ok || bound || isBoundQuery(query) { + return + } + if pragmaPattern.MatchString(value) { + position := fileSet.Position(query.Pos()) + symbol := sourceSymbol(query, parents) + analyzeSQLLiteral(filename, value, symbol, position, exemptions, seen, result, true) + } + if rawPlaceholder(value) { + analyzeRawPlaceholder(filename, query, fileSet, parents, exemptions, seen, result) + } +} + +func analyzeRawPlaceholder( + filename string, + query ast.Expr, + fileSet *token.FileSet, + parents map[ast.Node]ast.Node, + exemptions []Exemption, + seen map[string]struct{}, + result *analysisResult, +) { + position := fileSet.Position(query.Pos()) + symbol := sourceSymbol(query, parents) + key := findingKey(filename, symbol, RuleRawPlaceholder, position.Line, position.Column) + if _, exists := seen[key]; exists { + return + } + seen[key] = struct{}{} + if matchesExemption(filename, symbol, RuleRawPlaceholder, exemptions) { + result.used[exemptionKey(Exemption{File: filename, Symbol: symbol, Rule: RuleRawPlaceholder})] = struct{}{} + return + } + result.findings = append(result.findings, Finding{ + File: position.Filename, Line: position.Line, Column: position.Column, + Symbol: symbol, Rule: RuleRawPlaceholder, + Message: "rebind SQL placeholders at the final database boundary", + }) +} + +// CheckFiles checks a fixed set of Go files and rejects exemptions that did not +// match a finding. Directory traversal belongs to the command, so callers +// cannot accidentally create an implicit directory-wide exemption. +func CheckFiles(files []string, exemptions []Exemption) ([]Finding, error) { + if err := ValidateExemptions(exemptions); err != nil { + return nil, err + } + findings := make([]Finding, 0) + used := make(map[string]struct{}) + for _, filename := range files { + source, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("read %s: %w", filename, err) + } + result, err := analyzeSource(filepath.ToSlash(filename), source, exemptions) + if err != nil { + return nil, err + } + findings = append(findings, result.findings...) + for key := range result.used { + used[key] = struct{}{} + } + } + for _, exemption := range exemptions { + if _, ok := used[exemptionKey(exemption)]; !ok { + return nil, fmt.Errorf("unused SQL guard exemption %s", exemptionKey(exemption)) + } + } + return findings, nil +} + +// ValidateExemptions rejects broad paths, unknown rules, missing reasons, and +// duplicate exact entries. +func ValidateExemptions(exemptions []Exemption) error { + seen := make(map[string]struct{}, len(exemptions)) + for _, exemption := range exemptions { + if exemption.File == "" || filepath.IsAbs(exemption.File) || strings.ContainsAny(exemption.File, "*?") || strings.Contains(exemption.File, "..") { + return fmt.Errorf("SQL guard exemption file must be an exact relative path: %q", exemption.File) + } + if strings.Contains(exemption.File, "\\") || exemption.Symbol == "" || strings.ContainsAny(exemption.Symbol, "*?") { + return fmt.Errorf("SQL guard exemption must have an exact file and symbol: %q", exemption.File) + } + if _, ok := knownRules[exemption.Rule]; !ok { + return fmt.Errorf("SQL guard exemption has unknown rule %q", exemption.Rule) + } + if strings.TrimSpace(exemption.Reason) == "" { + return fmt.Errorf("SQL guard exemption %s has no reason", exemptionKey(exemption)) + } + key := exemptionKey(exemption) + if _, ok := seen[key]; ok { + return fmt.Errorf("duplicate SQL guard exemption %s", key) + } + seen[key] = struct{}{} + } + return nil +} + +// LoadExemptions loads the exact exemption registry from JSON. +func LoadExemptions(filename string) ([]Exemption, error) { + source, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("read SQL guard exemptions: %w", err) + } + var document struct { + Exemptions []Exemption `json:"exemptions"` + } + if err := json.Unmarshal(source, &document); err != nil { + return nil, fmt.Errorf("parse SQL guard exemptions: %w", err) + } + if err := ValidateExemptions(document.Exemptions); err != nil { + return nil, err + } + return document.Exemptions, nil +} + +func parentNodes(root ast.Node) map[ast.Node]ast.Node { + parents := make(map[ast.Node]ast.Node) + var walk func(ast.Node) + walk = func(parent ast.Node) { + ast.Inspect(parent, func(node ast.Node) bool { + if node == nil || node == parent { + return true + } + parents[node] = parent + walk(node) + return false + }) + } + walk(root) + return parents +} + +type queryValue struct { + expression ast.Expr + value string + bound bool +} + +func topLevelStringValues(file *ast.File) map[string]queryValue { + values := make(map[string]queryValue) + for _, declaration := range file.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok { + continue + } + for _, specification := range general.Specs { + if valueSpec, ok := specification.(*ast.ValueSpec); ok { + recordStringValueSpec(valueSpec, values) + } + } + } + return values +} + +func cloneQueryValues(values map[string]queryValue) map[string]queryValue { + clone := make(map[string]queryValue, len(values)) + for name, value := range values { + clone[name] = value + } + return clone +} + +func localStringNames(root ast.Node) map[string]struct{} { + names := make(map[string]struct{}) + ast.Inspect(root, func(node ast.Node) bool { + if _, ok := node.(*ast.FuncLit); ok { + return false + } + switch node := node.(type) { + case *ast.ValueSpec: + for _, name := range node.Names { + names[name.Name] = struct{}{} + } + case *ast.AssignStmt: + for _, target := range node.Lhs { + if name, ok := target.(*ast.Ident); ok { + names[name.Name] = struct{}{} + } + } + } + return true + }) + return names +} + +func recordStringValueSpec(specification *ast.ValueSpec, values map[string]queryValue) { + for index, name := range specification.Names { + if index >= len(specification.Values) { + continue + } + if value, ok := resolveQueryValue(specification.Values[index], values); ok { + value.expression = specification.Values[index] + values[name.Name] = value + } + } +} + +func recordStringAssignments(assignment *ast.AssignStmt, values map[string]queryValue) { + for index, target := range assignment.Lhs { + name, ok := target.(*ast.Ident) + if !ok || index >= len(assignment.Rhs) { + continue + } + if value, ok := resolveQueryValue(assignment.Rhs[index], values); ok { + value.expression = assignment.Rhs[index] + values[name.Name] = value + } + } +} + +func resolveQueryValue(expression ast.Expr, values map[string]queryValue) (queryValue, bool) { + switch expression := expression.(type) { + case *ast.BasicLit: + return resolveBasicLiteral(expression) + case *ast.Ident: + return resolveIdentifier(expression, values) + case *ast.BinaryExpr: + return resolveBinaryQueryValue(expression, values) + case *ast.ParenExpr: + return resolveParenthesizedQueryValue(expression, values) + case *ast.CallExpr: + return resolveCallQueryValue(expression, values) + default: + return queryValue{}, false + } +} + +func resolveBasicLiteral(expression *ast.BasicLit) (queryValue, bool) { + if expression.Kind != token.STRING { + return queryValue{}, false + } + value, err := strconv.Unquote(expression.Value) + return queryValue{expression: expression, value: value}, err == nil +} + +func resolveIdentifier(expression *ast.Ident, values map[string]queryValue) (queryValue, bool) { + value, ok := values[expression.Name] + if ok { + value.expression = expression + } + return value, ok +} + +func resolveBinaryQueryValue(expression *ast.BinaryExpr, values map[string]queryValue) (queryValue, bool) { + if expression.Op != token.ADD { + return queryValue{}, false + } + left, leftOK := resolveQueryValue(expression.X, values) + right, rightOK := resolveQueryValue(expression.Y, values) + if !leftOK || !rightOK { + return queryValue{}, false + } + return queryValue{expression: expression, value: left.value + right.value, bound: left.bound && right.bound}, true +} + +func resolveParenthesizedQueryValue(expression *ast.ParenExpr, values map[string]queryValue) (queryValue, bool) { + value, ok := resolveQueryValue(expression.X, values) + if ok { + value.expression = expression + } + return value, ok +} + +func resolveCallQueryValue(expression *ast.CallExpr, values map[string]queryValue) (queryValue, bool) { + if len(expression.Args) == 0 { + return queryValue{}, false + } + value, ok := resolveQueryValue(expression.Args[0], values) + if !ok { + return queryValue{}, false + } + switch functionName(expression.Fun) { + case "Rebind": + value.bound = true + case "In": + // sqlx.In expands '?' placeholders but does not rebind them to + // the target driver's syntax. It is safe only when wrapped by + // Rebind at the final database boundary. + value.bound = false + default: + return queryValue{}, false + } + value.expression = expression + return value, true +} + +func sourceSymbol(node ast.Node, parents map[ast.Node]ast.Node) string { + for current := node; current != nil; current = parents[current] { + switch current := current.(type) { + case *ast.FuncDecl: + return current.Name.Name + case *ast.ValueSpec: + for _, name := range current.Names { + return name.Name + } + } + } + return "file" +} + +func isExecutorCall(call *ast.CallExpr) bool { + switch functionName(call.Fun) { + case "Exec", "ExecContext", "Query", "QueryContext", "QueryRow", "QueryRowContext", "Get", "Select", "NamedExec", "Prepare", "PrepareContext": + return true + default: + return false + } +} + +func queryArgument(call *ast.CallExpr, values map[string]queryValue) (ast.Expr, string, bool, bool) { + for _, argument := range call.Args { + resolved, ok := resolveQueryValue(argument, values) + if ok && (sqlPattern.MatchString(resolved.value) || pragmaPattern.MatchString(resolved.value)) { + return resolved.expression, resolved.value, resolved.bound, true + } + } + return nil, "", false, false +} + +func isBoundQuery(expression ast.Expr) bool { + call, ok := expression.(*ast.CallExpr) + if !ok { + return false + } + name := functionName(call.Fun) + return name == "Rebind" +} + +func functionName(expression ast.Expr) string { + switch expression := expression.(type) { + case *ast.Ident: + return expression.Name + case *ast.SelectorExpr: + return expression.Sel.Name + default: + return "" + } +} + +func rawPlaceholder(value string) bool { + return strings.Contains(value, "?") && sqlPattern.MatchString(value) +} + +func matchesExemption(filename, symbol string, rule Rule, exemptions []Exemption) bool { + for _, exemption := range exemptions { + if exemption.File == filename && exemption.Symbol == symbol && exemption.Rule == rule { + return true + } + } + return false +} + +func exemptionKey(exemption Exemption) string { + return filepath.ToSlash(exemption.File) + ":" + exemption.Symbol + ":" + string(exemption.Rule) +} + +func findingKey(filename, symbol string, rule Rule, line, column int) string { + return fmt.Sprintf("%s:%s:%s:%d:%d", filename, symbol, rule, line, column) +} diff --git a/apps/backend/internal/db/sqlguard/analyzer_test.go b/apps/backend/internal/db/sqlguard/analyzer_test.go new file mode 100644 index 0000000000..c1a6d0c31a --- /dev/null +++ b/apps/backend/internal/db/sqlguard/analyzer_test.go @@ -0,0 +1,230 @@ +package sqlguard + +import ( + "strings" + "testing" +) + +func TestAnalyzeSourceFindsEveryRiskClass(t *testing.T) { + source := `package fixture + +func unsafe(db interface{ Exec(string, ...any) }) { + db.Exec("SELECT * FROM items WHERE id = ?", "id") + db.Exec("INSERT OR IGNORE INTO items (id) VALUES (?)", "id") +} + +var schema = "CREATE TABLE items (enabled BOOLEAN DEFAULT 1, created_at DATETIME)" +var sqlite = "SELECT datetime('now') FROM sqlite_master WHERE name = 'items'" +` + + findings, err := AnalyzeSource("testdata/unsafe.go", []byte(source), nil) + if err != nil { + t.Fatalf("AnalyzeSource() error = %v", err) + } + wantRules := []Rule{ + RuleRawPlaceholder, + RuleConflictSyntax, + RuleBooleanInteger, + RuleDateTimeType, + RuleSQLiteDateFunction, + RuleSQLiteCatalog, + } + for _, rule := range wantRules { + if !hasRule(findings, rule) { + t.Errorf("AnalyzeSource() did not report %q; findings = %#v", rule, findings) + } + } +} + +func TestAnalyzeSourceAcceptsPortableQueries(t *testing.T) { + source := `package fixture + +func safe(db interface { + Exec(string, ...any) + Rebind(string) string +}) { + db.Exec(db.Rebind("SELECT * FROM items WHERE id = ?"), "id") +} + +var schema = "CREATE TABLE items (enabled INTEGER DEFAULT 0, created_at {{timestamp}})" +` + findings, err := AnalyzeSource("testdata/safe.go", []byte(source), nil) + if err != nil { + t.Fatalf("AnalyzeSource() error = %v", err) + } + if len(findings) != 0 { + t.Fatalf("AnalyzeSource() findings = %#v, want none", findings) + } +} + +func TestAnalyzeSourceAppliesExactExemptions(t *testing.T) { + source := `package fixture + +var sqlite = "SELECT name FROM sqlite_master WHERE type = 'table'" +` + exemptions := []Exemption{{ + File: "internal/db/schema.go", + Symbol: "sqlite", + Rule: RuleSQLiteCatalog, + Reason: "the central schema probe is the dialect boundary", + }} + findings, err := AnalyzeSource("internal/db/schema.go", []byte(source), exemptions) + if err != nil { + t.Fatalf("AnalyzeSource() error = %v", err) + } + if len(findings) != 0 { + t.Fatalf("AnalyzeSource() findings = %#v, want exemption", findings) + } + + findings, err = AnalyzeSource("internal/db/other.go", []byte(source), exemptions) + if err != nil { + t.Fatalf("AnalyzeSource(other) error = %v", err) + } + if len(findings) == 0 { + t.Fatal("AnalyzeSource(other) findings = none, want exact-file exemption mismatch") + } +} + +func TestValidateExemptionsRejectsBroadAndDuplicateEntries(t *testing.T) { + for _, exemptions := range [][]Exemption{ + {{File: "internal/*.go", Symbol: "x", Rule: RuleSQLiteCatalog, Reason: "too broad"}}, + {{File: "internal/x.go", Symbol: "x", Rule: "unknown", Reason: "unknown rule"}}, + { + {File: "internal/x.go", Symbol: "x", Rule: RuleSQLiteCatalog, Reason: "one"}, + {File: "internal/x.go", Symbol: "x", Rule: RuleSQLiteCatalog, Reason: "two"}, + }, + } { + if err := ValidateExemptions(exemptions); err == nil { + t.Fatalf("ValidateExemptions(%#v) error = nil", exemptions) + } + } +} + +func hasRule(findings []Finding, want Rule) bool { + for _, finding := range findings { + if finding.Rule == want { + return true + } + } + return false +} + +func TestAnalyzeSourceReportsUsefulLocations(t *testing.T) { + findings, err := AnalyzeSource("fixture.go", []byte("package p\nfunc f(db interface{ Exec(string, ...any) }) { db.Exec(\"SELECT ?\") }\n"), nil) + if err != nil { + t.Fatalf("AnalyzeSource() error = %v", err) + } + if len(findings) == 0 || findings[0].Line == 0 || !strings.Contains(findings[0].Message, "placeholder") { + t.Fatalf("finding = %#v, want line and placeholder message", findings) + } +} + +func TestAnalyzeSourceFindsStandalonePragma(t *testing.T) { + source := "package p\nfunc unsafe(db interface{ Exec(string, ...any) }) { db.Exec(\"PRAGMA table_info(items)\") }\n" + findings, err := AnalyzeSource("fixture.go", []byte(source), nil) + if err != nil { + t.Fatalf("AnalyzeSource() error = %v", err) + } + if !hasRule(findings, RuleSQLiteCatalog) { + t.Fatalf("AnalyzeSource() findings = %#v, want sqlite-catalog finding", findings) + } +} + +func TestAnalyzeSourceFindsAssignedRawPlaceholder(t *testing.T) { + source := `package fixture +func unsafe(db interface{ Exec(string, ...any) }) { + query := "SELECT value FROM settings WHERE key = ?" + db.Exec(query) +} +` + findings, err := AnalyzeSource("fixture.go", []byte(source), nil) + if err != nil { + t.Fatalf("AnalyzeSource() error = %v", err) + } + if !hasRule(findings, RuleRawPlaceholder) { + t.Fatalf("AnalyzeSource() findings = %#v, want assigned raw-placeholder finding", findings) + } +} + +func TestAnalyzeSourceRequiresRebindAfterSQLxIn(t *testing.T) { + unsafeSource := `package fixture +func unsafe(db interface{ Exec(string, ...any) }) { + db.Exec(sqlx.In("SELECT value FROM settings WHERE key IN (?)", []string{"x"})) +} +` + findings, err := AnalyzeSource("fixture.go", []byte(unsafeSource), nil) + if err != nil { + t.Fatalf("AnalyzeSource(unsafe) error = %v", err) + } + if !hasRule(findings, RuleRawPlaceholder) { + t.Fatalf("AnalyzeSource(unsafe) findings = %#v, want raw-placeholder finding", findings) + } + + safeSource := `package fixture +func safe(db interface{ Exec(string, ...any) }) { + db.Exec(db.Rebind(sqlx.In("SELECT value FROM settings WHERE key IN (?)", []string{"x"}))) +} +` + findings, err = AnalyzeSource("fixture.go", []byte(safeSource), nil) + if err != nil { + t.Fatalf("AnalyzeSource(safe) error = %v", err) + } + if hasRule(findings, RuleRawPlaceholder) { + t.Fatalf("AnalyzeSource(safe) findings = %#v, want no raw-placeholder finding", findings) + } + + assignedSafeSource := `package fixture +func safeAssigned(db interface{ Exec(string, ...any) }) { + query, args, err := sqlx.In("SELECT value FROM settings WHERE key IN (?)", []string{"x"}) + if err != nil { return } + db.Exec(db.Rebind(query), args...) +} +` + findings, err = AnalyzeSource("fixture.go", []byte(assignedSafeSource), nil) + if err != nil { + t.Fatalf("AnalyzeSource(assigned safe) error = %v", err) + } + if hasRule(findings, RuleRawPlaceholder) { + t.Fatalf("AnalyzeSource(assigned safe) findings = %#v, want no raw-placeholder finding", findings) + } +} + +func TestAnalyzeSourceFindsBooleanIntegerComparison(t *testing.T) { + source := `package fixture +var query = "UPDATE settings SET enabled = 1 WHERE key = 'x'" +` + findings, err := AnalyzeSource("fixture.go", []byte(source), nil) + if err != nil { + t.Fatalf("AnalyzeSource() error = %v", err) + } + if !hasRule(findings, RuleBooleanInteger) { + t.Fatalf("AnalyzeSource() findings = %#v, want boolean-integer comparison finding", findings) + } +} + +func TestAnalyzeSourceIgnoresPortableAndNonBooleanComparisons(t *testing.T) { + source := `package fixture +var portable = "UPDATE settings SET enabled = TRUE WHERE status = 1" +var numeric = "UPDATE settings SET retry_count = 1 WHERE status = 0" +` + findings, err := AnalyzeSource("fixture.go", []byte(source), nil) + if err != nil { + t.Fatalf("AnalyzeSource() error = %v", err) + } + if hasRule(findings, RuleBooleanInteger) { + t.Fatalf("AnalyzeSource() findings = %#v, want no boolean-integer finding", findings) + } +} + +func TestAnalyzeSourceFindsKnownBooleanColumnComparison(t *testing.T) { + source := `package fixture +var query = "SELECT id FROM settings WHERE is_enabled != 0" +` + findings, err := AnalyzeSource("fixture.go", []byte(source), nil) + if err != nil { + t.Fatalf("AnalyzeSource() error = %v", err) + } + if !hasRule(findings, RuleBooleanInteger) { + t.Fatalf("AnalyzeSource() findings = %#v, want boolean-integer comparison finding", findings) + } +} diff --git a/apps/backend/internal/db/sqlguard/exemptions.json b/apps/backend/internal/db/sqlguard/exemptions.json new file mode 100644 index 0000000000..272d27c920 --- /dev/null +++ b/apps/backend/internal/db/sqlguard/exemptions.json @@ -0,0 +1,123 @@ +{ + "exemptions": [ + {"file": "internal/agent/settings/store/sqlite.go", "symbol": "migrateDropModelCheckConstraint", "rule": "sqlite-catalog", "reason": "SQLite-only migration for a legacy CHECK constraint"}, + {"file": "internal/agent/settings/store/sqlite.go", "symbol": "recreateAgentProfilesWithoutModelCheck", "rule": "sqlite-catalog", "reason": "SQLite-only table-recreation migration"}, + {"file": "internal/automation/store.go", "symbol": "createTablesSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/automation/store.go", "symbol": "createTablesSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/azuredevops/store.go", "symbol": "createConfigTableSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/azuredevops/store.go", "symbol": "createConfigTableSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/azuredevops/store.go", "symbol": "createTaskPRTableSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/azuredevops/store.go", "symbol": "createTaskPRTableSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/azuredevops/store.go", "symbol": "createTaskWorkItemTableSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/azuredevops/store.go", "symbol": "createWatchTablesSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/azuredevops/store.go", "symbol": "createWatchTablesSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/backendapp/e2e_reset.go", "symbol": "handleE2EReset", "rule": "raw-placeholder", "reason": "The E2E reset endpoint operates on the SQLite task repository"}, + {"file": "internal/backendapp/e2e_reset.go", "symbol": "listE2ETaskIDs", "rule": "raw-placeholder", "reason": "The E2E reset endpoint operates on the SQLite task repository"}, + {"file": "internal/backendapp/e2e_reset.go", "symbol": "deleteGitHubAuthForReset", "rule": "raw-placeholder", "reason": "The E2E reset endpoint operates on the SQLite task repository"}, + {"file": "internal/backendapp/e2e_reset.go", "symbol": "handleE2ECreateAutomation", "rule": "raw-placeholder", "reason": "The E2E seed endpoint operates on the SQLite task repository"}, + {"file": "internal/db/schema.go", "symbol": "tableExistsQuery", "rule": "sqlite-catalog", "reason": "This is the central dialect-aware schema inspection helper"}, + {"file": "internal/db/schema.go", "symbol": "TableColumns", "rule": "sqlite-catalog", "reason": "This is the central dialect-aware schema inspection helper"}, + {"file": "internal/delivery/store.go", "symbol": "tableExists", "rule": "sqlite-catalog", "reason": "The SQLite branch is the explicit dialect boundary for the schema probe"}, + {"file": "internal/delivery/store.go", "symbol": "tableExists", "rule": "raw-placeholder", "reason": "The placeholder is in the explicit SQLite branch of the schema probe"}, + {"file": "internal/github/store.go", "symbol": "createTablesSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/github/store.go", "symbol": "createTablesSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/github/store.go", "symbol": "applyIdempotentSchemaColumns", "rule": "datetime-type", "reason": "This is a legacy SQLite migration guarded by the provider schema dialect"}, + {"file": "internal/github/store.go", "symbol": "unpublishedGitHubAuthSchemaNeedsReset", "rule": "sqlite-catalog", "reason": "The reset detector intentionally inspects only legacy SQLite schemas"}, + {"file": "internal/github/store.go", "symbol": "unpublishedGitHubAuthSchemaNeedsReset", "rule": "raw-placeholder", "reason": "The reset detector intentionally queries only legacy SQLite schemas"}, + {"file": "internal/github/store.go", "symbol": "addTaskPRAgentAutomationColumns", "rule": "boolean-integer", "reason": "This legacy SQLite migration uses integer storage for SQLite booleans"}, + {"file": "internal/github/store.go", "symbol": "addTaskPRAgentAutomationColumns", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/github/store.go", "symbol": "addTaskCIRoundColumns", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/github/store.go", "symbol": "addPRScopeMigrationColumn", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/github/store.go", "symbol": "migratePRTablesForMultiRepo", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/github/store.go", "symbol": "rebuildIfHasLegacyConstraint", "rule": "sqlite-catalog", "reason": "SQLite cannot alter this legacy constraint in place"}, + {"file": "internal/github/store.go", "symbol": "rebuildIfHasLegacyConstraint", "rule": "raw-placeholder", "reason": "SQLite-only table rebuild migration"}, + {"file": "internal/gitlab/store.go", "symbol": "createTablesSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/gitlab/store.go", "symbol": "createTablesSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/gitlab/store.go", "symbol": "migrateMRWatchUniqueKey", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/gitlab/store.go", "symbol": "rebuildIfHasLegacyConstraint", "rule": "sqlite-catalog", "reason": "SQLite cannot alter this legacy constraint in place"}, + {"file": "internal/gitlab/store.go", "symbol": "rebuildIfHasLegacyConstraint", "rule": "raw-placeholder", "reason": "SQLite-only table rebuild migration"}, + {"file": "internal/gitlab/store_mr_automation.go", "symbol": "createMRAutomationTablesSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/gitlab/store_mr_automation.go", "symbol": "createMRAutomationTablesSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/integrations/workspacescope/workspacescope.go", "symbol": "tableExists", "rule": "sqlite-catalog", "reason": "Legacy workspace migration currently probes the SQLite catalog"}, + {"file": "internal/integrations/workspacescope/workspacescope.go", "symbol": "tableExists", "rule": "raw-placeholder", "reason": "Legacy workspace migration currently uses a SQLite-only probe"}, + {"file": "internal/integrations/workspacescope/workspacescope.go", "symbol": "workspaceExists", "rule": "raw-placeholder", "reason": "Legacy workspace migration runs against the SQLite database"}, + {"file": "internal/jira/store.go", "symbol": "createTablesSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/jira/store.go", "symbol": "createTablesSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/jira/store.go", "symbol": "addConfigHealthColumns", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/jira/store.go", "symbol": "addIssueWatchLastErrorColumns", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/jira/store.go", "symbol": "addOAuthColumns", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/jira/store.go", "symbol": "migrateLegacySingletonTable", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/jira/store.go", "symbol": "migrateLegacySingletonTable", "rule": "raw-placeholder", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/linear/store.go", "symbol": "createTablesSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/linear/store.go", "symbol": "createTablesSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/linear/store.go", "symbol": "addConfigColumns", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/linear/store.go", "symbol": "addIssueWatchLastErrorColumns", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/linear/store.go", "symbol": "migrateLegacySingletonTable", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/linear/store.go", "symbol": "migrateLegacySingletonTable", "rule": "raw-placeholder", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/notifications/store/sqlite.go", "symbol": "migrateDeliveries", "rule": "sqlite-catalog", "reason": "SQLite-only notification migration"}, + {"file": "internal/office/repository/sqlite/agent_summary.go", "symbol": "RunCountsByDayForAgent", "rule": "sqlite-date-function", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/agent_summary.go", "symbol": "TasksByPriorityByDayForAgent", "rule": "sqlite-date-function", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/agent_summary.go", "symbol": "TasksByStatusByDayForAgent", "rule": "sqlite-date-function", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/agent_summary.go", "symbol": "RecentRunCostsForAgent", "rule": "sqlite-date-function", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/base_migrations.go", "symbol": "migrateTaskFTS", "rule": "sqlite-catalog", "reason": "SQLite-only FTS migration"}, + {"file": "internal/office/repository/sqlite/base_migrations.go", "symbol": "maybeDropLegacyExternalContentFTS", "rule": "sqlite-catalog", "reason": "SQLite-only FTS migration"}, + {"file": "internal/office/repository/sqlite/base_migrations.go", "symbol": "backfillFTS", "rule": "conflict-syntax", "reason": "SQLite FTS backfill uses SQLite conflict semantics"}, + {"file": "internal/office/repository/sqlite/base_migrations.go", "symbol": "runTaskPriorityRecreate", "rule": "sqlite-catalog", "reason": "SQLite-only table-recreation migration"}, + {"file": "internal/office/repository/sqlite/base_migrations.go", "symbol": "tasksTableExists", "rule": "sqlite-catalog", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/dashboard.go", "symbol": "QueryRunActivity", "rule": "sqlite-date-function", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/labels.go", "symbol": "GetOrCreateLabel", "rule": "conflict-syntax", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/labels.go", "symbol": "AddLabelToTask", "rule": "conflict-syntax", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/tasks.go", "symbol": "hasFTSTable", "rule": "sqlite-catalog", "reason": "SQLite-only FTS inspection"}, + {"file": "internal/office/repository/sqlite/tasks.go", "symbol": "CheckoutTask", "rule": "sqlite-date-function", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/tasks.go", "symbol": "CheckoutTaskForRun", "rule": "sqlite-date-function", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/tasks.go", "symbol": "ListUnstartedTasks", "rule": "sqlite-date-function", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/office/repository/sqlite/workspace_groups.go", "symbol": "AddWorkspaceGroupMember", "rule": "conflict-syntax", "reason": "The Office repository is explicitly SQLite-only"}, + {"file": "internal/orchestrator/messagequeue/repository_sqlite.go", "symbol": "NewSQLiteRepository", "rule": "sqlite-catalog", "reason": "The SQLite message queue repository is explicitly SQLite-only"}, + {"file": "internal/persistence/meta.go", "symbol": "hasUserTables", "rule": "sqlite-catalog", "reason": "This helper inspects a SQLite database candidate"}, + {"file": "internal/persistence/provider.go", "symbol": "provideSQLite", "rule": "sqlite-catalog", "reason": "SQLite-only database cleanup runs PRAGMA optimize before close"}, + {"file": "internal/persistence/sqlite_selection.go", "symbol": "inspectSQLiteCandidateWithMode", "rule": "sqlite-catalog", "reason": "This helper inspects a SQLite database candidate"}, + {"file": "internal/sentry/store.go", "symbol": "sentryWatchesColumns", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/sentry/store.go", "symbol": "sentryWatchesColumns", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/sentry/store.go", "symbol": "createTablesSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/sentry/store.go", "symbol": "migrateLegacySingletonTable", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/sentry/store.go", "symbol": "migrateLegacySingletonTable", "rule": "raw-placeholder", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/sentry/store.go", "symbol": "migrateConfigsTableToInstances", "rule": "raw-placeholder", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/sentry/store.go", "symbol": "addIssueWatchLastErrorColumns", "rule": "datetime-type", "reason": "This legacy SQLite migration is skipped for PostgreSQL"}, + {"file": "internal/sentry/store.go", "symbol": "migrateWatchesAddInstanceColumn", "rule": "sqlite-catalog", "reason": "SQLite-only table-recreation migration"}, + {"file": "internal/system/database/maintenance.go", "symbol": "runOptimize", "rule": "sqlite-catalog", "reason": "SQLite-only maintenance operation"}, + {"file": "internal/system/database/stats.go", "symbol": "readSQLiteDBSize", "rule": "sqlite-catalog", "reason": "SQLite-only database size probe"}, + {"file": "internal/system/database/reset.go", "symbol": "dropUserTables", "rule": "sqlite-catalog", "reason": "Database reset is explicitly a SQLite maintenance operation"}, + {"file": "internal/system/settings/store.go", "symbol": "migrateLegacySystemSettings", "rule": "sqlite-catalog", "reason": "This legacy settings migration is SQLite-only"}, + {"file": "internal/system/settings/store.go", "symbol": "migrateLegacySystemSettings", "rule": "conflict-syntax", "reason": "This legacy settings migration is SQLite-only"}, + {"file": "internal/task/repository/sqlite/base_migrations.go", "symbol": "recreateTable", "rule": "sqlite-catalog", "reason": "SQLite cannot alter this legacy table in place"}, + {"file": "internal/task/repository/sqlite/worktree_ownership_git_snapshot.go", "symbol": "prepareTaskWorktreeCutoverForeignKeys", "rule": "sqlite-catalog", "reason": "SQLite-only foreign-key cutover migration"}, + {"file": "internal/task/repository/sqlite/base_migrations.go", "symbol": "recreateTable", "rule": "raw-placeholder", "reason": "SQLite-only table rebuild migration"}, + {"file": "internal/task/repository/sqlite/base_migrations.go", "symbol": "backfillExecutorsRunningFromTaskSessions", "rule": "sqlite-catalog", "reason": "This backfill is a SQLite-only legacy migration"}, + {"file": "internal/task/repository/sqlite/base_migrations.go", "symbol": "backfillExecutorsRunningFromTaskSessions", "rule": "raw-placeholder", "reason": "This backfill is a SQLite-only legacy migration"}, + {"file": "internal/task/repository/sqlite/base_migrations.go", "symbol": "healDuplicateTaskEnvForTask", "rule": "raw-placeholder", "reason": "This repair is a SQLite-only legacy migration"}, + {"file": "internal/task/repository/sqlite/session.go", "symbol": "DismissLastAgentError", "rule": "sqlite-date-function", "reason": "The task SQLite repository owns this SQLite date expression"}, + {"file": "internal/task/repository/sqlite/worktree_ownership_migration.go", "symbol": "tableExists", "rule": "sqlite-catalog", "reason": "SQLite-only worktree ownership migration"}, + {"file": "internal/task/repository/sqlite/worktree_ownership_migration.go", "symbol": "tableExists", "rule": "raw-placeholder", "reason": "SQLite-only worktree ownership migration"}, + {"file": "internal/task/repository/sqlite/worktree_ownership_migration.go", "symbol": "columnExists", "rule": "sqlite-catalog", "reason": "SQLite-only worktree ownership migration"}, + {"file": "internal/task/repository/sqlite/worktree_ownership_migration.go", "symbol": "columnExists", "rule": "raw-placeholder", "reason": "SQLite-only worktree ownership migration"}, + {"file": "internal/task/repository/sqlite/worktree_ownership_targets.go", "symbol": "tableColumns", "rule": "sqlite-catalog", "reason": "SQLite-only worktree ownership migration"}, + {"file": "internal/task/repository/sqlite/worktree_ownership_targets.go", "symbol": "tableColumns", "rule": "raw-placeholder", "reason": "SQLite-only worktree ownership migration"}, + {"file": "internal/workflow/repository/sqlite.go", "symbol": "DeleteStep", "rule": "sqlite-catalog", "reason": "The workflow SQLite repository owns this catalog probe"}, + {"file": "internal/workflowsync/store.go", "symbol": "createTablesSQL", "rule": "datetime-type", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, + {"file": "internal/agent/settings/store/sqlite.go", "symbol": "UpdateAgentProfileEnabled", "rule": "boolean-integer", "reason": "The agent settings schema stores user_modified as an INTEGER flag"}, + {"file": "internal/office/configsync/store.go", "symbol": "UpsertConfigForWorkspace", "rule": "boolean-integer", "reason": "The Office config-sync schema stores last_ok as an INTEGER flag"}, + {"file": "internal/office/repository/sqlite/routines.go", "symbol": "GetDueTriggers", "rule": "boolean-integer", "reason": "The Office SQLite schema stores trigger enabled as an INTEGER flag"}, + {"file": "internal/plugins/marketplace/sourcestore.go", "symbol": "EnsureBuiltin", "rule": "boolean-integer", "reason": "The marketplace source schema stores enabled and builtin as INTEGER flags"}, + {"file": "internal/plugins/state/instance_store.go", "symbol": "List", "rule": "boolean-integer", "reason": "The plugin instance-state schema stores deleted as an INTEGER tombstone"}, + {"file": "internal/plugins/state/instance_store.go", "symbol": "Set", "rule": "boolean-integer", "reason": "The plugin instance-state schema stores deleted as an INTEGER tombstone"}, + {"file": "internal/plugins/state/instance_store.go", "symbol": "Delete", "rule": "boolean-integer", "reason": "The plugin instance-state schema stores deleted as an INTEGER tombstone"}, + {"file": "internal/prompts/store/sqlite.go", "symbol": "refreshLegacyChangesWalkthroughPrompt", "rule": "boolean-integer", "reason": "The prompts SQLite schema stores builtin as an INTEGER flag"}, + {"file": "internal/prompts/store/sqlite.go", "symbol": "refreshLegacyCIAutoFixPrompt", "rule": "boolean-integer", "reason": "The prompts SQLite schema stores builtin as an INTEGER flag"}, + {"file": "internal/task/repository/sqlite/defaults.go", "symbol": "hideBuiltinWorkflows", "rule": "boolean-integer", "reason": "The task SQLite schema stores hidden as an INTEGER flag"}, + {"file": "internal/utility/store/sqlite.go", "symbol": "initSchema", "rule": "boolean-integer", "reason": "The utility SQLite schema stores builtin and enabled as INTEGER flags"}, + {"file": "internal/utility/store/sqlite.go", "symbol": "migrateTemplatePlaceholders", "rule": "boolean-integer", "reason": "The utility SQLite schema stores builtin as an INTEGER flag"}, + {"file": "internal/utility/store/sqlite.go", "symbol": "NormalizeEmptyBuiltinBinding", "rule": "boolean-integer", "reason": "The utility SQLite schema stores builtin as an INTEGER flag"}, + {"file": "internal/utility/store/sqlite.go", "symbol": "DeleteAgent", "rule": "boolean-integer", "reason": "The utility SQLite schema stores builtin as an INTEGER flag"}, + {"file": "internal/workflowsync/store.go", "symbol": "UpsertConfigForWorkspace", "rule": "boolean-integer", "reason": "The workflow-sync schema stores last_ok as an INTEGER flag"} + ] +} diff --git a/apps/backend/internal/db/sqlguard/exemptions_test.go b/apps/backend/internal/db/sqlguard/exemptions_test.go new file mode 100644 index 0000000000..0d89616e32 --- /dev/null +++ b/apps/backend/internal/db/sqlguard/exemptions_test.go @@ -0,0 +1,17 @@ +package sqlguard + +import ( + "path/filepath" + "testing" +) + +func TestLoadExemptions(t *testing.T) { + path := filepath.Join("testdata", "exemptions.json") + exemptions, err := LoadExemptions(path) + if err != nil { + t.Fatalf("LoadExemptions() error = %v", err) + } + if len(exemptions) != 1 || exemptions[0].Rule != RuleSQLiteCatalog { + t.Fatalf("LoadExemptions() = %#v", exemptions) + } +} diff --git a/apps/backend/internal/db/sqlguard/testdata/exemptions.json b/apps/backend/internal/db/sqlguard/testdata/exemptions.json new file mode 100644 index 0000000000..5283275c6d --- /dev/null +++ b/apps/backend/internal/db/sqlguard/testdata/exemptions.json @@ -0,0 +1,10 @@ +{ + "exemptions": [ + { + "file": "internal/db/schema.go", + "symbol": "sqlite", + "rule": "sqlite-catalog", + "reason": "the central schema probe is the dialect boundary" + } + ] +} diff --git a/apps/backend/internal/delivery/store.go b/apps/backend/internal/delivery/store.go index 3fcc4e3478..9cd64a0c29 100644 --- a/apps/backend/internal/delivery/store.go +++ b/apps/backend/internal/delivery/store.go @@ -1,6 +1,7 @@ package delivery import ( + "context" "database/sql" "errors" "fmt" @@ -26,6 +27,68 @@ type Repository struct { migrate *db.MigrateLogger } +// LedgerRecord is the persisted evaluation state for one task/repository +// pair. It is intentionally read-only from the public API; Upsert remains the +// sole writer of the ledger row. +type LedgerRecord struct { + TaskID string + RepositoryID string + WorkspaceID string + Outcome *Outcome + Basis *Basis + EvidenceRank int + LastEvaluatedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// Get reads one ledger row by its owning task and repository pair. +func (r *Repository) Get(ctx context.Context, taskID, repositoryID string) (LedgerRecord, error) { + var row struct { + TaskID string `db:"task_id"` + RepositoryID string `db:"repository_id"` + WorkspaceID string `db:"workspace_id"` + Outcome sql.NullString `db:"delivery_outcome"` + Basis sql.NullString `db:"delivery_basis"` + EvidenceRank int `db:"evidence_rank"` + LastEvaluatedAt time.Time `db:"last_evaluated_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + err := r.ro.GetContext(ctx, &row, r.ro.Rebind(` + SELECT task_id, repository_id, workspace_id, delivery_outcome, + delivery_basis, evidence_rank, last_evaluated_at, created_at, updated_at + FROM task_delivery_ledger WHERE task_id = ? AND repository_id = ? + `), taskID, repositoryID) + if err != nil { + return LedgerRecord{}, err + } + record := LedgerRecord{ + TaskID: row.TaskID, RepositoryID: row.RepositoryID, WorkspaceID: row.WorkspaceID, + EvidenceRank: row.EvidenceRank, LastEvaluatedAt: row.LastEvaluatedAt, + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + } + if row.Outcome.Valid { + value := Outcome(row.Outcome.String) + record.Outcome = &value + } + if row.Basis.Valid { + value := Basis(row.Basis.String) + record.Basis = &value + } + return record, nil +} + +// Delete removes one ledger pair. Normal delivery sweeps only upsert rows; +// this method exists for explicit task cleanup and conformance lifecycle +// probes. +func (r *Repository) Delete(ctx context.Context, taskID, repositoryID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_delivery_ledger WHERE task_id = ? AND repository_id = ? + `), taskID, repositoryID) + return err +} + // activationKey is the kandev_meta key published once a boot has verified // task_delivery_ledger is present and queryable. See spec "Activation // points". @@ -39,9 +102,10 @@ const activationKey = "telemetry.delivery_ledger.activated_at" // production path (internal/task/repository/sqlite/workspace.go). A bare // FK (dialect default NO ACTION) would make workspace deletion start // failing the moment any ledger row exists, invisibly, because the -// migration runner swallows errors. CASCADE matches task_id's own +// migration runner used to swallow errors. CASCADE matches task_id's own // documented clause and the spec's persistence guarantee that deleting a -// task deletes its ledger rows. +// task deletes its ledger rows. Required-store migration errors are now +// surfaced by initSchema. // // Both this table and its FK targets (tasks, repositories) must already // exist at CREATE TABLE time on PostgreSQL (unlike SQLite, which allows @@ -83,7 +147,7 @@ func NewWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) db: writer, ro: reader, log: log, - migrate: db.NewMigrateLogger(writer, log), + migrate: db.NewRequiredMigrateLogger(writer, log), } if err := r.initSchema(); err != nil { return nil, fmt.Errorf("delivery ledger schema init: %w", err) @@ -94,15 +158,17 @@ func NewWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) func (r *Repository) initSchema() error { r.migrate.Apply("task_delivery_ledger.table", createTableSQL) r.migrate.Apply("task_delivery_ledger.idx_workspace_evaluated", createIndexSQL) + if err := r.migrate.Err(); err != nil { + return fmt.Errorf("required delivery migration: %w", err) + } r.activate() return nil } // activate writes activationKey only after a positive probe confirms // task_delivery_ledger exists. See spec "Activation points": the -// migration runner swallows failures at WARN, so this probe is the only -// thing standing between a failed migration and a consumer wrongly -// believing the mechanism is live. WriteMetaKeyIfAbsent makes the write +// required migration runner fails startup for unexpected schema errors, so +// this probe runs only after the schema contract is complete. WriteMetaKeyIfAbsent makes the write // replay-safe. Any failure here is logged and swallowed — activation is a // published fact for consumers, never a boot-blocking requirement, and it // does not gate the sweep. diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index 66358f24e9..d914432dec 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -514,7 +514,9 @@ func (s *Store) initSchema(legacyUpgrade bool) error { if err := s.initSchemaFoundations(); err != nil { return err } - s.applyIdempotentSchemaColumns() + if err := s.applyIdempotentSchemaColumns(); err != nil { + return err + } if err := s.initSchemaUpgrades(); err != nil { return err } @@ -524,7 +526,9 @@ func (s *Store) initSchema(legacyUpgrade bool) error { if err := s.initSchemaData(legacyUpgrade); err != nil { return err } - s.applyIdempotentSchemaIndexes() + if err := s.applyIdempotentSchemaIndexes(); err != nil { + return err + } return s.ensureWorkspaceOwnershipIndexes() } @@ -571,30 +575,56 @@ func (s *Store) initSchemaFoundations() error { return nil } -func (s *Store) applyIdempotentSchemaColumns() { +func (s *Store) applyIdempotentSchemaColumns() error { // Idempotent migrations for existing databases. - exec := func(statement string) { - _, _ = s.db.Exec(schemaSQLForDriver(statement, s.db.DriverName())) + exec := func(name, statement string) error { + if _, err := s.db.Exec(schemaSQLForDriver(statement, s.db.DriverName())); err != nil && !dbutil.IsDuplicateColumnError(err) { + return fmt.Errorf("add %s: %w", name, err) + } + return nil + } + if err := exec("github_pr_watches.last_review_state", `ALTER TABLE github_pr_watches ADD COLUMN last_review_state TEXT DEFAULT ''`); err != nil { + return err + } + if err := exec("github_task_prs.mergeable_state", `ALTER TABLE github_task_prs ADD COLUMN mergeable_state TEXT NOT NULL DEFAULT ''`); err != nil { + return err } - exec(`ALTER TABLE github_pr_watches ADD COLUMN last_review_state TEXT DEFAULT ''`) - exec(`ALTER TABLE github_task_prs ADD COLUMN mergeable_state TEXT NOT NULL DEFAULT ''`) // Phase 4 (multi-repo): per-repo PR association on github_task_prs. - exec(`ALTER TABLE github_task_prs ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''`) - exec(`ALTER TABLE github_pr_watches ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''`) + if err := exec("github_task_prs.repository_id", `ALTER TABLE github_task_prs ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''`); err != nil { + return err + } + if err := exec("github_pr_watches.repository_id", `ALTER TABLE github_pr_watches ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''`); err != nil { + return err + } // CI popover: aggregate counts + branch protection's required_approving_review_count // + unresolved review-threads, surfaced in the PR top-bar hover popover so the // frontend can render the counts row without a second round-trip. - exec(`ALTER TABLE github_task_prs ADD COLUMN required_reviews INTEGER`) - exec(`ALTER TABLE github_task_prs ADD COLUMN unresolved_review_threads INTEGER DEFAULT 0`) - exec(`ALTER TABLE github_task_prs ADD COLUMN checks_total INTEGER DEFAULT 0`) - exec(`ALTER TABLE github_task_prs ADD COLUMN checks_passing INTEGER DEFAULT 0`) - exec(`ALTER TABLE github_task_prs ADD COLUMN detached_at DATETIME`) + if err := exec("github_task_prs.required_reviews", `ALTER TABLE github_task_prs ADD COLUMN required_reviews INTEGER`); err != nil { + return err + } + if err := exec("github_task_prs.unresolved_review_threads", `ALTER TABLE github_task_prs ADD COLUMN unresolved_review_threads INTEGER DEFAULT 0`); err != nil { + return err + } + if err := exec("github_task_prs.checks_total", `ALTER TABLE github_task_prs ADD COLUMN checks_total INTEGER DEFAULT 0`); err != nil { + return err + } + if err := exec("github_task_prs.checks_passing", `ALTER TABLE github_task_prs ADD COLUMN checks_passing INTEGER DEFAULT 0`); err != nil { + return err + } + if err := exec("github_task_prs.detached_at", `ALTER TABLE github_task_prs ADD COLUMN detached_at DATETIME`); err != nil { + return err + } // Per-watch cleanup policy for review/issue watches: controls whether the // poller deletes auto-created tasks when the underlying PR/issue reaches // a terminal state. Values: 'auto' (default — preserve only when user // engaged), 'always' (delete on terminal state), 'never' (manual only). - exec(`ALTER TABLE github_review_watches ADD COLUMN cleanup_policy TEXT NOT NULL DEFAULT 'auto'`) - exec(`ALTER TABLE github_issue_watches ADD COLUMN cleanup_policy TEXT NOT NULL DEFAULT 'auto'`) + if err := exec("github_review_watches.cleanup_policy", `ALTER TABLE github_review_watches ADD COLUMN cleanup_policy TEXT NOT NULL DEFAULT 'auto'`); err != nil { + return err + } + if err := exec("github_issue_watches.cleanup_policy", `ALTER TABLE github_issue_watches ADD COLUMN cleanup_policy TEXT NOT NULL DEFAULT 'auto'`); err != nil { + return err + } + return nil } func (s *Store) initSchemaUpgrades() error { @@ -882,18 +912,23 @@ func (s *Store) clearLifecyclePromptOverrides() error { return err } -func (s *Store) applyIdempotentSchemaIndexes() { +func (s *Store) applyIdempotentSchemaIndexes() error { // pr_number is the 3rd column of UNIQUE(task_id, repository_id, pr_number), // so SQLite can't use that index for the PR-number task search. Add a // dedicated leading-key index so lookups by PR number stay index-backed. - _, _ = s.db.Exec(schemaSQLForDriver( + if _, err := s.db.Exec(schemaSQLForDriver( `CREATE INDEX IF NOT EXISTS idx_github_task_prs_pr_number ON github_task_prs (pr_number)`, s.db.DriverName(), - )) - _, _ = s.db.Exec(schemaSQLForDriver( + )); err != nil { + return fmt.Errorf("create idx_github_task_prs_pr_number: %w", err) + } + if _, err := s.db.Exec(schemaSQLForDriver( `CREATE INDEX IF NOT EXISTS idx_github_task_ci_pr_state_task ON github_task_ci_pr_state (task_id)`, s.db.DriverName(), - )) + )); err != nil { + return fmt.Errorf("create idx_github_task_ci_pr_state_task: %w", err) + } + return nil } func (s *Store) resetUnpublishedGitHubAuthSchema() error { @@ -1008,14 +1043,8 @@ func (s *Store) initAppRegistrationSchema() error { } func schemaSQLForDriver(schema, driver string) string { - timestampType := dialect.TimestampType(driver) - schema = strings.ReplaceAll(schema, "TIMESTAMP", timestampType) - schema = strings.ReplaceAll(schema, "DATETIME", timestampType) + schema = dialect.MustRenderSchema(driver, schema) if dialect.IsPostgres(driver) { - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE") - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 1", "BOOLEAN NOT NULL DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 0", "BOOLEAN NOT NULL DEFAULT FALSE") if strings.Contains(schema, "CREATE TRIGGER IF NOT EXISTS github_user_connections_registration_insert") { schema = withoutSQLiteGitHubAuthTriggers(schema) schema += postgresGitHubAuthTriggers() diff --git a/apps/backend/internal/gitlab/store.go b/apps/backend/internal/gitlab/store.go index 429e1d9d51..8b0668d32f 100644 --- a/apps/backend/internal/gitlab/store.go +++ b/apps/backend/internal/gitlab/store.go @@ -531,15 +531,7 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { } func gitlabSchemaSQLForDriver(schema, driver string) string { - schema = strings.ReplaceAll(schema, "TIMESTAMP", dialect.TimestampType(driver)) - schema = strings.ReplaceAll(schema, "DATETIME", dialect.TimestampType(driver)) - if dialect.IsPostgres(driver) { - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE") - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 1", "BOOLEAN NOT NULL DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 0", "BOOLEAN NOT NULL DEFAULT FALSE") - } - return schema + return dialect.MustRenderSchema(driver, schema) } // UpsertMentionScope explicitly binds one workspace to a GitLab host and diff --git a/apps/backend/internal/jira/store.go b/apps/backend/internal/jira/store.go index ba30b9b4ae..d64d9b5981 100644 --- a/apps/backend/internal/jira/store.go +++ b/apps/backend/internal/jira/store.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "time" "github.com/google/uuid" @@ -395,15 +394,7 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { } func schemaSQLForDriver(schema, driver string) string { - schema = strings.ReplaceAll(schema, "TIMESTAMP", dialect.TimestampType(driver)) - schema = strings.ReplaceAll(schema, "DATETIME", dialect.TimestampType(driver)) - if dialect.IsPostgres(driver) { - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 1", "BOOLEAN NOT NULL DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 0", "BOOLEAN NOT NULL DEFAULT FALSE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE") - } - return schema + return dialect.MustRenderSchema(driver, schema) } const selectConfigColumns = `workspace_id, site_url, email, auth_method, instance_type, default_project_key, diff --git a/apps/backend/internal/linear/store.go b/apps/backend/internal/linear/store.go index 034824cf91..7b2f803f53 100644 --- a/apps/backend/internal/linear/store.go +++ b/apps/backend/internal/linear/store.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "time" "github.com/jmoiron/sqlx" @@ -385,15 +384,7 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { } func schemaSQLForDriver(schema, driver string) string { - schema = strings.ReplaceAll(schema, "TIMESTAMP", dialect.TimestampType(driver)) - schema = strings.ReplaceAll(schema, "DATETIME", dialect.TimestampType(driver)) - if dialect.IsPostgres(driver) { - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 1", "BOOLEAN NOT NULL DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 0", "BOOLEAN NOT NULL DEFAULT FALSE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE") - } - return schema + return dialect.MustRenderSchema(driver, schema) } const selectConfigColumns = `workspace_id, auth_method, default_team_key, org_slug, diff --git a/apps/backend/internal/notifications/store/sqlite.go b/apps/backend/internal/notifications/store/sqlite.go index bf567bc38f..2b1db3a3b4 100644 --- a/apps/backend/internal/notifications/store/sqlite.go +++ b/apps/backend/internal/notifications/store/sqlite.go @@ -292,7 +292,9 @@ func (r *sqliteRepository) migrateLegacySubscriptions() error { } func (r *sqliteRepository) CreateProvider(ctx context.Context, provider *models.Provider) error { - provider.ID = uuid.New().String() + if provider.ID == "" { + provider.ID = uuid.New().String() + } now := time.Now().UTC() provider.CreatedAt = now provider.UpdatedAt = now diff --git a/apps/backend/internal/office/repository/sqlite/base.go b/apps/backend/internal/office/repository/sqlite/base.go index 126fb9d9c3..0127cebf2d 100644 --- a/apps/backend/internal/office/repository/sqlite/base.go +++ b/apps/backend/internal/office/repository/sqlite/base.go @@ -45,7 +45,7 @@ func RunnerProjection(alias string) string { NULLIF((SELECT wsp.agent_profile_id FROM workflow_step_participants wsp WHERE wsp.task_id = ` + alias + `.id AND wsp.role = 'runner' - ORDER BY wsp.rowid DESC LIMIT 1), ''), + ORDER BY wsp.created_at DESC, wsp.agent_profile_id ASC, wsp.id ASC LIMIT 1), ''), '' )` } @@ -73,7 +73,7 @@ func NewWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) db: writer, ro: reader, log: log, - migrate: db.NewMigrateLogger(writer, log), + migrate: db.NewRequiredMigrateLogger(writer, log), } if err := repo.initSchema(); err != nil { return nil, fmt.Errorf("failed to initialize office schema: %w", err) @@ -117,7 +117,9 @@ func (r *Repository) initSchema() error { if err := r.createExtensionTables(); err != nil { return err } - r.runMigrations() + if err := r.runMigrations(); err != nil { + return fmt.Errorf("required office migration: %w", err) + } r.activateRunOutcome() return nil } diff --git a/apps/backend/internal/office/repository/sqlite/base_migrations.go b/apps/backend/internal/office/repository/sqlite/base_migrations.go index 2fbc6cf19d..2d950c25eb 100644 --- a/apps/backend/internal/office/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/office/repository/sqlite/base_migrations.go @@ -8,6 +8,8 @@ import ( "go.uber.org/zap" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/office/models" ) @@ -20,45 +22,72 @@ import ( // fold office_agent_instances, drop dead task columns, etc.) have been // collapsed: those rows never landed on main so the migrations were // pure no-ops at first boot. -func (r *Repository) runMigrations() { - r.migrateSchedulerColumns() - r.migrateFailureColumns() +func (r *Repository) runMigrations() error { + if err := r.migrateSchedulerColumns(); err != nil { + return err + } + if err := r.migrateFailureColumns(); err != nil { + return err + } r.migrateRunPayloadIndexes() r.migrateCostEventContract() - if err := r.migrateTaskPriorityToText(); err != nil { - // Surface to stderr; this stage runs from initSchema which doesn't - // have a logger handle. The recreate is wrapped in a transaction - // so a failure leaves the DB intact. - fmt.Println("office sqlite migrate priority:", err) + // These migrations implement SQLite-only task search and table-rebuild + // behavior. PostgreSQL has no rowid or FTS5 virtual tables, and its task + // repository already creates the portable task shape. Keep the branch + // explicit so an office store opened on PostgreSQL never probes SQLite's + // catalog or executes SQLite DDL. + if !dialect.IsPostgres(r.db.DriverName()) { + if err := r.migrateTaskPriorityToText(); err != nil { + return fmt.Errorf("tasks.priority_text_rebuild: %w", err) + } + // Run migrateTaskFTS LAST so its triggers survive any subsequent + // recreate-table migrations (notably migrateTaskPriorityToText, which + // drops + rebuilds `tasks` and would otherwise wipe the FTS triggers). + if err := r.migrateTaskFTS(); err != nil { + return err + } } - // Run migrateTaskFTS LAST so its triggers survive any subsequent - // recreate-table migrations (notably migrateTaskPriorityToText, which - // drops + rebuilds `tasks` and would otherwise wipe the FTS triggers). - r.migrateTaskFTS() // Provider routing tables and replayable column migrations. Fresh // schemas include the columns inline; ALTERs converge existing databases. - r.migrateProviderRouting() - r.migrateContinuationScope() + if err := r.migrateProviderRouting(); err != nil { + return err + } + if err := r.migrateContinuationScope(); err != nil { + return err + } r.migrateRunOutcome() - r.migrateParentWakeIndexes() + if err := r.migrateParentWakeIndexes(); err != nil { + return err + } r.migrateParentWakeReceiptColumns() r.migrate.Apply("task_workspace_groups.ownership_generation", `ALTER TABLE task_workspace_groups ADD COLUMN ownership_generation INTEGER NOT NULL DEFAULT 1`) + if err := r.migrate.Err(); err != nil { + return err + } + return nil } // migrateContinuationScope adds runs.continuation_scope for databases // created before WO-16's claim-time scope persistence. Existing rows receive // a scope from their stored context snapshot so queued or claimed taskless // runs keep their continuation chain after an upgrade. -func (r *Repository) migrateContinuationScope() { - r.migrate.Apply("runs.continuation_scope", - `ALTER TABLE runs ADD COLUMN continuation_scope TEXT NOT NULL DEFAULT ''`) - r.backfillContinuationScopes() - r.migrate.Apply("runs.failure_scope_status_index", - `CREATE INDEX IF NOT EXISTS idx_run_failure_scope_status ON runs(agent_profile_id, continuation_scope, status)`) +func (r *Repository) migrateContinuationScope() error { + if err := r.migrate.Apply("runs.continuation_scope", + `ALTER TABLE runs ADD COLUMN continuation_scope TEXT NOT NULL DEFAULT ''`); err != nil { + return err + } + if err := r.backfillContinuationScopes(); err != nil { + return err + } + if err := r.migrate.Apply("runs.failure_scope_status_index", + `CREATE INDEX IF NOT EXISTS idx_run_failure_scope_status ON runs(agent_profile_id, continuation_scope, status)`); err != nil { + return err + } + return nil } -func (r *Repository) backfillContinuationScopes() { +func (r *Repository) backfillContinuationScopes() error { var legacyRuns []struct { ID string `db:"id"` AgentProfileID string `db:"agent_profile_id"` @@ -66,11 +95,8 @@ func (r *Repository) backfillContinuationScopes() { } if err := r.db.Select(&legacyRuns, `SELECT id, agent_profile_id, context_snapshot - FROM runs WHERE continuation_scope = ''`); err != nil { - if r.log != nil { - r.log.Warn("continuation scope backfill query failed", zap.Error(err)) - } - return + FROM runs WHERE continuation_scope = ''`); err != nil { + return fmt.Errorf("runs.continuation_scope backfill query: %w", err) } for _, legacyRun := range legacyRuns { scope := models.ContinuationScopeForRun( @@ -81,12 +107,10 @@ func (r *Repository) backfillContinuationScopes() { UPDATE runs SET continuation_scope = ? WHERE id = ? AND continuation_scope = '' `), scope, legacyRun.ID); err != nil { - if r.log != nil { - r.log.Warn("continuation scope backfill update failed", - zap.String("run_id", legacyRun.ID), zap.Error(err)) - } + return fmt.Errorf("runs.continuation_scope backfill update %q: %w", legacyRun.ID, err) } } + return nil } // migrateCostEventContract adds the cache read/write split, turn @@ -137,13 +161,26 @@ func (r *Repository) migrateRunPayloadIndexes() { // child, no-non-terminal-child, the child-set-key GROUP_CONCAT) need to // avoid a full tasks scan on every 30-second reconciler tick. Also // benefits AreAllChildrenTerminal and GetChildSummaries (blockers.go), -// which query the same shape. Applied via r.migrate (non-fatal) rather -// than createParentChildWakeReceiptsTable's r.db.Exec: tasks belongs to -// a different package's schema, so a plain Exec against it is fatal in -// the minimal single-domain test repos that don't create tasks until -// after NewWithDB returns. -func (r *Repository) migrateParentWakeIndexes() { - r.migrate.Apply( +// which query the same shape. The office repository is also used in isolated +// tests before the task schema is installed, so the migration is skipped when +// tasks is absent. Once the table exists, an unexpected index failure is fatal +// to the required office store. +func (r *Repository) migrateParentWakeIndexes() error { + exists, err := db.TableExists(r.db, "tasks") + if err != nil { + return fmt.Errorf("inspect tasks table for parent wake index: %w", err) + } + if !exists { + return nil + } + columnExists, err := db.ColumnExists(r.db, "tasks", "parent_id") + if err != nil { + return fmt.Errorf("inspect tasks.parent_id for parent wake index: %w", err) + } + if !columnExists { + return nil + } + return r.migrate.Apply( "idx_tasks_parent_id", `CREATE INDEX IF NOT EXISTS idx_tasks_parent_id ON tasks(parent_id)`, ) @@ -164,8 +201,8 @@ func (r *Repository) migrateParentWakeReceiptColumns() { // office_run_route_attempts, and office_provider_health tables. The // routing columns are also added with replayable ALTER statements so // databases created before a column shipped converge to the fresh schema. -func (r *Repository) migrateProviderRouting() { - _, _ = r.db.Exec(` +func (r *Repository) migrateProviderRouting() error { + if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS office_workspace_routing ( workspace_id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, @@ -175,9 +212,11 @@ func (r *Repository) migrateProviderRouting() { tier_per_reason TEXT NOT NULL DEFAULT '{}', role_tiers TEXT NOT NULL DEFAULT '{}', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - )`) + )`); err != nil { + return fmt.Errorf("office_workspace_routing table: %w", err) + } - _, _ = r.db.Exec(` + if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS office_run_route_attempts ( run_id TEXT NOT NULL, seq INTEGER NOT NULL, @@ -198,7 +237,9 @@ func (r *Repository) migrateProviderRouting() { finished_at TIMESTAMP, PRIMARY KEY (run_id, seq), FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE - )`) + )`); err != nil { + return fmt.Errorf("office_run_route_attempts table: %w", err) + } r.migrate.Apply("runs.resolved_execution_profile_id", `ALTER TABLE runs ADD COLUMN resolved_execution_profile_id TEXT`) @@ -209,7 +250,7 @@ func (r *Repository) migrateProviderRouting() { r.migrate.Apply("office_run_route_attempts.tier_source", `ALTER TABLE office_run_route_attempts ADD COLUMN tier_source TEXT NOT NULL DEFAULT ''`) - _, _ = r.db.Exec(` + if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS office_provider_health ( workspace_id TEXT NOT NULL, provider_id TEXT NOT NULL, @@ -224,7 +265,10 @@ func (r *Repository) migrateProviderRouting() { raw_excerpt TEXT, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (workspace_id, provider_id, scope, scope_value) - )`) + )`); err != nil { + return fmt.Errorf("office_provider_health table: %w", err) + } + return nil } // migrateFailureColumns creates the auxiliary failure-handling tables: @@ -232,33 +276,43 @@ func (r *Repository) migrateProviderRouting() { // snapshots. // The runs.error_message column is part of the canonical CREATE TABLE // in base.go, so no ALTER is needed here. -func (r *Repository) migrateFailureColumns() { - _, _ = r.db.Exec(` +func (r *Repository) migrateFailureColumns() error { + if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS office_workspace_settings ( workspace_id TEXT PRIMARY KEY, agent_failure_threshold INTEGER NOT NULL DEFAULT 3, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - )`) + )`); err != nil { + return fmt.Errorf("office_workspace_settings table: %w", err) + } - _, _ = r.db.Exec(` + if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS office_inbox_dismissals ( user_id TEXT NOT NULL, item_kind TEXT NOT NULL, item_id TEXT NOT NULL, dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, item_kind, item_id) - )`) - _, _ = r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_office_inbox_dismissals_kind ON office_inbox_dismissals(item_kind, item_id)`) - - _, _ = r.db.Exec(` + )`); err != nil { + return fmt.Errorf("office_inbox_dismissals table: %w", err) + } + if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_office_inbox_dismissals_kind ON office_inbox_dismissals(item_kind, item_id)`); err != nil { + return fmt.Errorf("idx_office_inbox_dismissals_kind: %w", err) + } + if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS office_agent_pause_recoveries ( agent_id TEXT NOT NULL, task_id TEXT NOT NULL, failed_run_id TEXT NOT NULL, captured_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (agent_id, task_id) - )`) - _, _ = r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_office_agent_pause_recoveries_agent ON office_agent_pause_recoveries(agent_id)`) + )`); err != nil { + return fmt.Errorf("office_agent_pause_recoveries table: %w", err) + } + if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_office_agent_pause_recoveries_agent ON office_agent_pause_recoveries(agent_id)`); err != nil { + return fmt.Errorf("idx_office_agent_pause_recoveries_agent: %w", err) + } + return nil } // migrateSchedulerColumns adds the atomic task-checkout columns to the @@ -267,21 +321,45 @@ func (r *Repository) migrateFailureColumns() { // live here have been folded into the canonical CREATE TABLE statements // (base.go) — office is new, so main upgrades pick up the final shape // directly without an interim ALTER step. -func (r *Repository) migrateSchedulerColumns() { - r.migrate.Apply("tasks.checkout_agent_id", `ALTER TABLE tasks ADD COLUMN checkout_agent_id TEXT`) - r.migrate.Apply("tasks.checkout_at", `ALTER TABLE tasks ADD COLUMN checkout_at TIMESTAMP`) - r.migrate.Apply("tasks.checkout_run_id", `ALTER TABLE tasks ADD COLUMN checkout_run_id TEXT`) +func (r *Repository) migrateSchedulerColumns() error { + // These columns belong to the task repository. They are replayed here for + // deployments that initialize the office package first, so skip them until + // that owner has created tasks. Once the table exists, the required migration + // logger makes every unexpected ALTER failure fatal. + exists, err := db.TableExists(r.db, "tasks") + if err != nil { + return fmt.Errorf("inspect tasks table for scheduler migration: %w", err) + } + if !exists { + return nil + } + for _, migration := range []struct { + name string + stmt string + }{ + {"tasks.checkout_agent_id", `ALTER TABLE tasks ADD COLUMN checkout_agent_id TEXT`}, + {"tasks.checkout_at", `ALTER TABLE tasks ADD COLUMN checkout_at TIMESTAMP`}, + {"tasks.checkout_run_id", `ALTER TABLE tasks ADD COLUMN checkout_run_id TEXT`}, + } { + if err := r.migrate.Apply(migration.name, migration.stmt); err != nil { + return err + } + } + return nil } // migrateTaskFTS creates the FTS5 virtual table and triggers for full-text task search. // Skips entirely when the tasks table does not exist or when the SQLite build lacks FTS5. -func (r *Repository) migrateTaskFTS() { +func (r *Repository) migrateTaskFTS() error { // Guard: tasks table may not exist yet (office schema runs before task schema). var exists int if err := r.db.QueryRow( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='tasks'", ).Scan(&exists); err != nil { - return + if err == sql.ErrNoRows { + return nil + } + return fmt.Errorf("inspect tasks table for FTS: %w", err) } // Attempt to create the FTS5 virtual table. If the SQLite build lacks FTS5, @@ -295,17 +373,27 @@ func (r *Repository) migrateTaskFTS() { // seeded onboarding tasks). Internal mode duplicates the text into the // FTS table itself, so the trigger does a real INSERT and the index // stays in sync. The storage cost is negligible for our task volumes. - r.maybeDropLegacyExternalContentFTS() + if err := r.maybeDropLegacyExternalContentFTS(); err != nil { + return err + } if _, err := r.db.Exec(` CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5( title, description, identifier ) `); err != nil { - return // FTS5 module not available + if strings.Contains(strings.ToLower(err.Error()), "no such module: fts5") { + return nil + } + return fmt.Errorf("create tasks FTS table: %w", err) } - r.createFTSTriggers() - r.backfillFTS() + if err := r.createFTSTriggers(); err != nil { + return err + } + if err := r.backfillFTS(); err != nil { + return err + } + return nil } // maybeDropLegacyExternalContentFTS drops the legacy external-content @@ -315,22 +403,32 @@ func (r *Repository) migrateTaskFTS() { // never populated the index in this codebase. Dropping lets migrateTaskFTS // recreate it in internal-content mode and backfill from `tasks`. No-op when // the table doesn't exist or is already internal-content. -func (r *Repository) maybeDropLegacyExternalContentFTS() { +func (r *Repository) maybeDropLegacyExternalContentFTS() error { var sqlText string err := r.db.QueryRow( `SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks_fts'`, ).Scan(&sqlText) if err != nil { - return // no table to drop + if err == sql.ErrNoRows { + return nil + } + return fmt.Errorf("inspect legacy tasks FTS table: %w", err) } if !strings.Contains(sqlText, "content='tasks'") && !strings.Contains(sqlText, `content="tasks"`) { - return // already internal-content + return nil // already internal-content } - _, _ = r.db.Exec(`DROP TRIGGER IF EXISTS tasks_fts_insert`) - _, _ = r.db.Exec(`DROP TRIGGER IF EXISTS tasks_fts_update`) - _, _ = r.db.Exec(`DROP TRIGGER IF EXISTS tasks_fts_delete`) - _, _ = r.db.Exec(`DROP TABLE IF EXISTS tasks_fts`) + for _, statement := range []string{ + `DROP TRIGGER IF EXISTS tasks_fts_insert`, + `DROP TRIGGER IF EXISTS tasks_fts_update`, + `DROP TRIGGER IF EXISTS tasks_fts_delete`, + `DROP TABLE IF EXISTS tasks_fts`, + } { + if _, err := r.db.Exec(statement); err != nil { + return fmt.Errorf("drop legacy tasks FTS object: %w", err) + } + } + return nil } // createFTSTriggers installs INSERT/UPDATE/DELETE triggers to keep the FTS @@ -338,32 +436,40 @@ func (r *Repository) maybeDropLegacyExternalContentFTS() { // INSERT/DELETE statements drive the index — the 'delete' command literal // is reserved for external-content mode and raises "SQL logic error" here // (the prior implementation broke every UPDATE on tasks). -func (r *Repository) createFTSTriggers() { - _, _ = r.db.Exec(`DROP TRIGGER IF EXISTS tasks_fts_insert`) - _, _ = r.db.Exec(`DROP TRIGGER IF EXISTS tasks_fts_update`) - _, _ = r.db.Exec(`DROP TRIGGER IF EXISTS tasks_fts_delete`) - _, _ = r.db.Exec(`CREATE TRIGGER IF NOT EXISTS tasks_fts_insert AFTER INSERT ON tasks BEGIN +func (r *Repository) createFTSTriggers() error { + for _, statement := range []string{ + `DROP TRIGGER IF EXISTS tasks_fts_insert`, + `DROP TRIGGER IF EXISTS tasks_fts_update`, + `DROP TRIGGER IF EXISTS tasks_fts_delete`, + `CREATE TRIGGER IF NOT EXISTS tasks_fts_insert AFTER INSERT ON tasks BEGIN INSERT INTO tasks_fts(rowid, title, description, identifier) VALUES (new.rowid, new.title, COALESCE(new.description,''), COALESCE(new.identifier,'')); - END`) - - _, _ = r.db.Exec(`CREATE TRIGGER IF NOT EXISTS tasks_fts_update AFTER UPDATE ON tasks BEGIN + END`, + `CREATE TRIGGER IF NOT EXISTS tasks_fts_update AFTER UPDATE ON tasks BEGIN DELETE FROM tasks_fts WHERE rowid = old.rowid; INSERT INTO tasks_fts(rowid, title, description, identifier) VALUES (new.rowid, new.title, COALESCE(new.description,''), COALESCE(new.identifier,'')); - END`) - - _, _ = r.db.Exec(`CREATE TRIGGER IF NOT EXISTS tasks_fts_delete AFTER DELETE ON tasks BEGIN + END`, + `CREATE TRIGGER IF NOT EXISTS tasks_fts_delete AFTER DELETE ON tasks BEGIN DELETE FROM tasks_fts WHERE rowid = old.rowid; - END`) + END`, + } { + if _, err := r.db.Exec(statement); err != nil { + return fmt.Errorf("create tasks FTS trigger: %w", err) + } + } + return nil } // backfillFTS populates the FTS index from existing task rows. -func (r *Repository) backfillFTS() { - _, _ = r.db.Exec(` +func (r *Repository) backfillFTS() error { + if _, err := r.db.Exec(` INSERT OR IGNORE INTO tasks_fts(rowid, title, description, identifier) SELECT rowid, title, COALESCE(description,''), COALESCE(identifier,'') FROM tasks - `) + `); err != nil { + return fmt.Errorf("backfill tasks FTS: %w", err) + } + return nil } // migrateTaskPriorityToText converts the tasks.priority column from INTEGER to @@ -446,28 +552,27 @@ func (r *Repository) runTaskPriorityRecreate() error { // AFTER initTaskSchema creates the legacy INTEGER-priority table // but BEFORE the office migrations run. On test fixtures that seed // a pre-cascade legacy schema (see TestMigrate_PriorityIntegerToText) - // the column is absent — we add it idempotently here so the recreate - // SELECT below can reference it. Errors are swallowed because the - // most common cause is "column already exists" on real installs. - _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN archived_by_cascade_id TEXT DEFAULT ''`) - // Older priority-migration fixtures predate the WIP queue columns. Add - // them before the recreate SELECT so the migration remains compatible with - // those schemas as well as with real pre-queue installations. - _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN wip_admitted INTEGER NOT NULL DEFAULT 1`) - _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN queued_for_step_id TEXT NOT NULL DEFAULT ''`) - _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN queued_at TIMESTAMP`) - _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN autopilot_enabled INTEGER NOT NULL DEFAULT 0`) - // Same defensive add for external_id/external_id_settled_at - // (docs/specs/tasks/system-design/external-id-idempotency.md): real installs already have - // these from task/repository/sqlite/base.go runMigrations(), but older - // priority-migration fixtures predate them too. - _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN external_id TEXT COLLATE BINARY`) - _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN external_id_settled_at TIMESTAMP`) - // Same defensive add for the human assignee (docs/specs/tasks/requirements/human-assignee.md). - // It is applied by task/repository/sqlite ensureTeamAccessSchema() before - // the office migrations run on a real install, but priority-migration - // fixtures seed their own legacy tasks table and never see it. - _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN assignee_user_id TEXT NOT NULL DEFAULT ''`) + // the column is absent, add it before the recreate SELECT. The legacy + // fixtures used by the priority migration predate several of these columns, + // while current installs have already received them from the task owner. + legacyColumns := []struct { + name string + stmt string + }{ + {"archived_by_cascade_id", `ALTER TABLE tasks ADD COLUMN archived_by_cascade_id TEXT DEFAULT ''`}, + {"wip_admitted", `ALTER TABLE tasks ADD COLUMN wip_admitted INTEGER NOT NULL DEFAULT 1`}, + {"queued_for_step_id", `ALTER TABLE tasks ADD COLUMN queued_for_step_id TEXT NOT NULL DEFAULT ''`}, + {"queued_at", `ALTER TABLE tasks ADD COLUMN queued_at TIMESTAMP`}, + {"autopilot_enabled", `ALTER TABLE tasks ADD COLUMN autopilot_enabled INTEGER NOT NULL DEFAULT 0`}, + {"external_id", `ALTER TABLE tasks ADD COLUMN external_id TEXT COLLATE BINARY`}, + {"external_id_settled_at", `ALTER TABLE tasks ADD COLUMN external_id_settled_at TIMESTAMP`}, + {"assignee_user_id", `ALTER TABLE tasks ADD COLUMN assignee_user_id TEXT NOT NULL DEFAULT ''`}, + } + for _, column := range legacyColumns { + if _, err := conn.ExecContext(ctx, column.stmt); err != nil && !db.IsDuplicateColumnError(err) { + return fmt.Errorf("add legacy tasks.%s: %w", column.name, err) + } + } for _, stmt := range taskPriorityMigrationStatements() { if _, err := conn.ExecContext(ctx, stmt); err != nil { diff --git a/apps/backend/internal/office/repository/sqlite/run_outcome_activation.go b/apps/backend/internal/office/repository/sqlite/run_outcome_activation.go index 200c6622f1..52972c1969 100644 --- a/apps/backend/internal/office/repository/sqlite/run_outcome_activation.go +++ b/apps/backend/internal/office/repository/sqlite/run_outcome_activation.go @@ -23,10 +23,9 @@ func (r *Repository) migrateRunOutcome() { } // activateRunOutcome writes telemetry.run_outcome.activated_at only after a -// positive probe confirms runs.outcome exists. The migration runner -// (MigrateLogger.Apply) swallows failures at WARN, so this probe is the -// only thing standing between a failed migration and a consumer wrongly -// believing the mechanism is live. WriteMetaKeyIfAbsent makes the write +// positive probe confirms runs.outcome exists. The required office migration +// runner fails startup for unexpected migration errors, so this probe runs +// only after the schema contract is complete. WriteMetaKeyIfAbsent makes the write // replay-safe: the instant is never overwritten once set. Any failure here // is logged and swallowed — activation is a best-effort published fact, // never a boot-blocking requirement, and the sweep-equivalent writer path diff --git a/apps/backend/internal/office/repository/sqlite/tasks.go b/apps/backend/internal/office/repository/sqlite/tasks.go index d480f8e998..a9f1c8ee89 100644 --- a/apps/backend/internal/office/repository/sqlite/tasks.go +++ b/apps/backend/internal/office/repository/sqlite/tasks.go @@ -10,6 +10,7 @@ import ( "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db/dialect" taskmodels "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" ) @@ -594,6 +595,9 @@ func (r *Repository) SearchTasks(ctx context.Context, workspaceID, query string, // hasFTSTable checks whether the tasks_fts virtual table exists. func (r *Repository) hasFTSTable() bool { + if dialect.IsPostgres(r.ro.DriverName()) { + return false + } var exists int err := r.ro.QueryRow( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='tasks_fts'", diff --git a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go index 95ad0de6e7..edf65902e0 100644 --- a/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go +++ b/apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go @@ -742,6 +742,23 @@ func purgeQueueRowSessions(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, taskID // caller's transaction on PostgreSQL. The standalone PurgeTask passes nil and // locks only the sessions that currently hold queue rows. func PurgeTaskInTransaction(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, taskID string, taskSessions []string) (int, error) { + // Task lifecycle operations can run before the optional queue repository has + // been initialized. PostgreSQL aborts a transaction on a missing-table + // statement, so inspect every queue table before issuing any queue query. + for _, table := range []string{ + "queued_messages", + "lifecycle_queue_generations", + "queue_session_locks", + } { + present, err := internaldb.TableExists(tx, table) + if err != nil { + return 0, fmt.Errorf("check %s table: %w", table, err) + } + if !present { + return 0, nil + } + } + // Serialize with per-session tail operations: a purge that races a fold // or insert could otherwise delete a row an admission just accepted, or // admit into a queue being purged. Lock the AUTHORITATIVE session set — diff --git a/apps/backend/internal/persistence/requiredstores/catalog.go b/apps/backend/internal/persistence/requiredstores/catalog.go new file mode 100644 index 0000000000..b3744a37fb --- /dev/null +++ b/apps/backend/internal/persistence/requiredstores/catalog.go @@ -0,0 +1,221 @@ +// Package requiredstores defines the inventory and lifecycle contract for +// Kandev's built-in SQL schema owners. +package requiredstores + +import ( + "fmt" + "strings" +) + +// Capability identifies a SQL behavior that a store conformance adapter must +// exercise on every supported database engine. +type Capability string + +const ( + CapabilityBoolean Capability = "boolean" + CapabilityTimestamp Capability = "timestamp" + CapabilityConflict Capability = "conflict" + CapabilityTransaction Capability = "transaction" +) + +var validCapabilities = map[Capability]struct{}{ + CapabilityBoolean: {}, + CapabilityTimestamp: {}, + CapabilityConflict: {}, + CapabilityTransaction: {}, +} + +// Descriptor is the immutable metadata for one built-in SQL schema owner. +// RequiredTables are the table names used by runtime persistence probes. +type Descriptor struct { + ID string + OwnerPackage string + RequiredTables []string + DependsOn []string + Capabilities []Capability +} + +// catalog is ordered in the same dependency order used by backend startup. +// Keep entries for stores whose feature is disabled: their schema remains part +// of the database contract and must be initialized before readiness. +var catalog = []Descriptor{ + {ID: "schema-meta", OwnerPackage: "internal/persistence", RequiredTables: []string{"kandev_meta"}}, + {ID: "task", OwnerPackage: "internal/task/repository/sqlite", RequiredTables: []string{"workspaces", "tasks"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "workflow", OwnerPackage: "internal/workflow/repository", RequiredTables: []string{"workflow_templates", "workflow_steps"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "analytics", OwnerPackage: "internal/analytics/repository", RequiredTables: []string{"tasks"}, DependsOn: []string{"task", "workflow"}, Capabilities: []Capability{CapabilityTimestamp}}, + {ID: "agent-settings", OwnerPackage: "internal/agent/settings/store", RequiredTables: []string{"agents", "agent_profiles"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "user", OwnerPackage: "internal/user/store", RequiredTables: []string{"users"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "notification", OwnerPackage: "internal/notifications/store", RequiredTables: []string{"notification_providers"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "editor", OwnerPackage: "internal/editors/store", RequiredTables: []string{"editors"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, + {ID: "prompts", OwnerPackage: "internal/prompts/store", RequiredTables: []string{"custom_prompts"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, + {ID: "utility", OwnerPackage: "internal/utility/store", RequiredTables: []string{"utility_agents"}, DependsOn: []string{"agent-settings"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "office", OwnerPackage: "internal/office/repository/sqlite", RequiredTables: []string{"office_projects", "runs"}, DependsOn: []string{"task", "agent-settings"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "terminal", OwnerPackage: "internal/terminal/repository", RequiredTables: []string{"user_terminals"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "quick-terminal", OwnerPackage: "internal/quickterminal/repository", RequiredTables: []string{"quick_terminal_tabs"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, + {ID: "runtime-flags", OwnerPackage: "internal/runtimeflags", RequiredTables: []string{"runtime_flag_overrides"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "auth", OwnerPackage: "internal/auth/store", RequiredTables: []string{"auth_identities", "auth_sessions"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "secrets", OwnerPackage: "internal/secrets", RequiredTables: []string{"secrets"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "system-settings", OwnerPackage: "internal/system/settings", RequiredTables: []string{"settings"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, + {ID: "auth-hostnames", OwnerPackage: "internal/auth/hostnames", RequiredTables: []string{"auth_hostname_cache"}, DependsOn: []string{"auth"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, + {ID: "organizations", OwnerPackage: "internal/org", RequiredTables: []string{"orgs"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "organization-units", OwnerPackage: "internal/orgunit", RequiredTables: []string{"org_units"}, DependsOn: []string{"organizations"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, + {ID: "message-queue", OwnerPackage: "internal/orchestrator/messagequeue", RequiredTables: []string{"queued_messages"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "task-share", OwnerPackage: "internal/task/share", RequiredTables: []string{"task_shares"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, + {ID: "telemetry-contract", OwnerPackage: "internal/telemetrycontract", RequiredTables: []string{"telemetry_activations"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, + {ID: "delivery", OwnerPackage: "internal/delivery", RequiredTables: []string{"task_delivery_ledger"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "storage", OwnerPackage: "internal/system/storage", RequiredTables: []string{"storage_maintenance_runs", "storage_quarantine_entries"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "plugin-instances", OwnerPackage: "internal/plugins/instances", RequiredTables: []string{"plugin_instances"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "plugin-marketplace", OwnerPackage: "internal/plugins/marketplace", RequiredTables: []string{"plugin_marketplace_source"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "plugin-settings", OwnerPackage: "internal/plugins", RequiredTables: []string{"plugin_settings"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "plugin-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_state"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "plugin-instance-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_instance_state"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "plugin-user-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_user_state"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "canvas", OwnerPackage: "internal/canvas", RequiredTables: []string{"canvas_lifecycle_metadata"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: "github", OwnerPackage: "internal/github", RequiredTables: []string{"github_pr_watches"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "gitlab", OwnerPackage: "internal/gitlab", RequiredTables: []string{"gitlab_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "jira", OwnerPackage: "internal/jira", RequiredTables: []string{"jira_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "linear", OwnerPackage: "internal/linear", RequiredTables: []string{"linear_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "sentry", OwnerPackage: "internal/sentry", RequiredTables: []string{"sentry_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "azure-devops", OwnerPackage: "internal/azuredevops", RequiredTables: []string{"azure_devops_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "workflow-sync", OwnerPackage: "internal/workflowsync", RequiredTables: []string{"workflow_sync_configs"}, DependsOn: []string{"workflow"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "office-config-sync", OwnerPackage: "internal/office/configsync", RequiredTables: []string{"office_config_sync_configs"}, DependsOn: []string{"office"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, + {ID: "automation", OwnerPackage: "internal/automation", RequiredTables: []string{"automations", "automation_runs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, +} + +// Catalog returns a deep copy of the authoritative store catalog. +func Catalog() []Descriptor { + return cloneDescriptors(catalog) +} + +// Descriptors is an alias for Catalog for callers that prefer collection +// terminology. +func Descriptors() []Descriptor { + return Catalog() +} + +// ValidateCatalog checks descriptor identity, dependencies, and capabilities. +func ValidateCatalog(descriptors []Descriptor) error { + if len(descriptors) == 0 { + return fmt.Errorf("catalog is empty") + } + positions := make(map[string]int, len(descriptors)) + for index, descriptor := range descriptors { + if !validIdentifier(descriptor.ID) { + return fmt.Errorf("catalog entry %q has invalid ID", descriptor.ID) + } + if _, exists := positions[descriptor.ID]; exists { + return fmt.Errorf("catalog contains duplicate ID %q", descriptor.ID) + } + positions[descriptor.ID] = index + if strings.TrimSpace(descriptor.OwnerPackage) == "" { + return fmt.Errorf("catalog entry %q has no owner package", descriptor.ID) + } + if len(descriptor.RequiredTables) == 0 { + return fmt.Errorf("catalog entry %q has no required tables", descriptor.ID) + } + if err := validateNames(descriptor); err != nil { + return err + } + } + + for index, descriptor := range descriptors { + seenDependencies := make(map[string]struct{}, len(descriptor.DependsOn)) + for _, dependency := range descriptor.DependsOn { + if dependency == descriptor.ID { + return fmt.Errorf("catalog entry %q depends on itself", descriptor.ID) + } + if _, duplicate := seenDependencies[dependency]; duplicate { + return fmt.Errorf("catalog entry %q lists duplicate dependency %q", descriptor.ID, dependency) + } + seenDependencies[dependency] = struct{}{} + dependencyIndex, exists := positions[dependency] + if !exists { + return fmt.Errorf("catalog entry %q depends on unknown store %q", descriptor.ID, dependency) + } + if dependencyIndex >= index { + return fmt.Errorf("catalog entry %q dependency %q is out of order", descriptor.ID, dependency) + } + } + } + + if err := validateCycles(descriptors); err != nil { + return err + } + return nil +} + +func validateNames(descriptor Descriptor) error { + seen := make(map[string]struct{}, len(descriptor.RequiredTables)) + for _, table := range descriptor.RequiredTables { + if !validIdentifier(table) { + return fmt.Errorf("catalog entry %q has invalid required table %q", descriptor.ID, table) + } + if _, exists := seen[table]; exists { + return fmt.Errorf("catalog entry %q lists duplicate table %q", descriptor.ID, table) + } + seen[table] = struct{}{} + } + for _, capability := range descriptor.Capabilities { + if _, ok := validCapabilities[capability]; !ok { + return fmt.Errorf("catalog entry %q has invalid capability %q", descriptor.ID, capability) + } + } + return nil +} + +func validateCycles(descriptors []Descriptor) error { + dependencies := make(map[string][]string, len(descriptors)) + for _, descriptor := range descriptors { + dependencies[descriptor.ID] = descriptor.DependsOn + } + visiting := make(map[string]bool, len(descriptors)) + visited := make(map[string]bool, len(descriptors)) + var visit func(string) error + visit = func(id string) error { + if visiting[id] { + return fmt.Errorf("catalog dependency cycle includes %q", id) + } + if visited[id] { + return nil + } + visiting[id] = true + for _, dependency := range dependencies[id] { + if err := visit(dependency); err != nil { + return err + } + } + visiting[id] = false + visited[id] = true + return nil + } + for _, descriptor := range descriptors { + if err := visit(descriptor.ID); err != nil { + return err + } + } + return nil +} + +func validIdentifier(value string) bool { + if value == "" || strings.Contains(value, "--") { + return false + } + for index, character := range value { + valid := character == '_' || character == '-' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' + if !valid || index == 0 && (character == '-' || character == '_') { + return false + } + } + return !strings.HasSuffix(value, "-") && !strings.HasSuffix(value, "_") +} + +func cloneDescriptors(descriptors []Descriptor) []Descriptor { + cloned := make([]Descriptor, len(descriptors)) + for index, descriptor := range descriptors { + cloned[index] = descriptor + cloned[index].RequiredTables = append([]string(nil), descriptor.RequiredTables...) + cloned[index].DependsOn = append([]string(nil), descriptor.DependsOn...) + cloned[index].Capabilities = append([]Capability(nil), descriptor.Capabilities...) + } + return cloned +} diff --git a/apps/backend/internal/persistence/requiredstores/catalog_test.go b/apps/backend/internal/persistence/requiredstores/catalog_test.go new file mode 100644 index 0000000000..764a829d51 --- /dev/null +++ b/apps/backend/internal/persistence/requiredstores/catalog_test.go @@ -0,0 +1,111 @@ +package requiredstores + +import "testing" + +func TestCatalog(t *testing.T) { + catalog := Catalog() + if err := ValidateCatalog(catalog); err != nil { + t.Fatalf("ValidateCatalog() error = %v", err) + } + + wantIDs := map[string]struct{}{ + "agent-settings": {}, "analytics": {}, "auth": {}, "auth-hostnames": {}, "automation": {}, + "azure-devops": {}, "canvas": {}, "delivery": {}, "editor": {}, + "github": {}, "gitlab": {}, "jira": {}, "linear": {}, "message-queue": {}, + "notification": {}, "office": {}, "office-config-sync": {}, "organization-units": {}, + "organizations": {}, "plugin-instance-state": {}, "plugin-instances": {}, + "plugin-marketplace": {}, "plugin-settings": {}, "plugin-state": {}, + "plugin-user-state": {}, "prompts": {}, "quick-terminal": {}, "runtime-flags": {}, + "schema-meta": {}, "secrets": {}, "sentry": {}, "storage": {}, "system-settings": {}, + "task": {}, "task-share": {}, "telemetry-contract": {}, "terminal": {}, + "user": {}, "utility": {}, "workflow": {}, "workflow-sync": {}, + } + seen := make(map[string]struct{}, len(catalog)) + for _, descriptor := range catalog { + if _, exists := seen[descriptor.ID]; exists { + t.Fatalf("catalog contains duplicate ID %q", descriptor.ID) + } + seen[descriptor.ID] = struct{}{} + if descriptor.OwnerPackage == "" { + t.Errorf("catalog entry %q has no owner package", descriptor.ID) + } + if len(descriptor.RequiredTables) == 0 { + t.Errorf("catalog entry %q has no required tables", descriptor.ID) + } + } + if len(seen) != len(wantIDs) { + t.Fatalf("catalog has %d IDs, want exact set of %d", len(seen), len(wantIDs)) + } + for id := range wantIDs { + if _, exists := seen[id]; !exists { + t.Errorf("catalog is missing expected ID %q", id) + } + } + for id := range seen { + if _, expected := wantIDs[id]; !expected { + t.Errorf("catalog contains unexpected ID %q", id) + } + } + + first := Catalog() + first[0].ID = "changed" + first[0].RequiredTables[0] = "changed" + second := Catalog() + if second[0].ID == "changed" || second[0].RequiredTables[0] == "changed" { + t.Fatal("Catalog() exposes mutable internal data") + } +} + +func TestValidateCatalogRejectsInvalidDescriptors(t *testing.T) { + tests := []struct { + name string + catalog []Descriptor + }{ + { + name: "duplicate ID", + catalog: []Descriptor{ + {ID: "one", OwnerPackage: "owner/one", RequiredTables: []string{"one"}}, + {ID: "one", OwnerPackage: "owner/two", RequiredTables: []string{"two"}}, + }, + }, + { + name: "missing dependency", + catalog: []Descriptor{{ + ID: "one", OwnerPackage: "owner/one", RequiredTables: []string{"one"}, DependsOn: []string{"missing"}, + }}, + }, + { + name: "dependency cycle", + catalog: []Descriptor{ + {ID: "one", OwnerPackage: "owner/one", RequiredTables: []string{"one"}, DependsOn: []string{"two"}}, + {ID: "two", OwnerPackage: "owner/two", RequiredTables: []string{"two"}, DependsOn: []string{"one"}}, + }, + }, + { + name: "invalid ID", + catalog: []Descriptor{{ID: "One", OwnerPackage: "owner/one", RequiredTables: []string{"one"}}}, + }, + { + name: "missing owner", + catalog: []Descriptor{{ID: "one", RequiredTables: []string{"one"}}}, + }, + { + name: "missing table", + catalog: []Descriptor{{ID: "one", OwnerPackage: "owner/one"}}, + }, + { + name: "invalid capability", + catalog: []Descriptor{ + {ID: "one", OwnerPackage: "owner/one", RequiredTables: []string{"one"}, Capabilities: []Capability{"unknown"}}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := ValidateCatalog(test.catalog); err == nil { + t.Fatal("ValidateCatalog() error = nil, want error") + } + }) + } +} diff --git a/apps/backend/internal/persistence/requiredstores/health.go b/apps/backend/internal/persistence/requiredstores/health.go new file mode 100644 index 0000000000..c4325103c5 --- /dev/null +++ b/apps/backend/internal/persistence/requiredstores/health.go @@ -0,0 +1,233 @@ +package requiredstores + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" +) + +const ( + defaultProbeInterval = 15 * time.Second + probeTimeout = 2 * time.Second +) + +// Health probes the shared database and every table declared by the catalog. +// It owns the periodic probe lifecycle but delegates state storage to Tracker. +type Health struct { + tracker *Tracker + pool *db.Pool + log *logger.Logger + + mu sync.Mutex + interval time.Duration + cancel context.CancelFunc + done chan struct{} +} + +// NewHealth creates a runtime health probe for a completed store tracker. +func NewHealth(tracker *Tracker, pool *db.Pool, log *logger.Logger) *Health { + return &Health{tracker: tracker, pool: pool, log: log, interval: defaultProbeInterval} +} + +// SetInterval changes the periodic interval. It is intended for tests and +// controlled embedders; callers must set it before Start. +func (h *Health) SetInterval(interval time.Duration) { + h.mu.Lock() + defer h.mu.Unlock() + if interval > 0 { + h.interval = interval + } +} + +// Check runs one synchronous probe. It returns an aggregate error when one or +// more stores are unavailable, while recording an independent result for each +// catalog entry. +func (h *Health) Check(ctx context.Context) error { + if h == nil || h.tracker == nil { + return errors.New("required-store health tracker is unavailable") + } + if h.pool == nil || h.pool.Writer() == nil || h.pool.Reader() == nil { + return h.recordUnavailable(errors.New("database pool is unavailable")) + } + probeErr := h.ping(ctx) + results := make([]error, len(h.tracker.catalog)) + for index, descriptor := range h.tracker.catalog { + results[index] = probeErr + if probeErr == nil { + results[index] = h.probeTables(ctx, descriptor) + } + } + var failures []error + for index, result := range results { + if err := h.tracker.RecordProbe(h.tracker.catalog[index].ID, result); err != nil { + failures = append(failures, err) + continue + } + if result != nil { + failures = append(failures, fmt.Errorf("%s: %w", h.tracker.catalog[index].ID, result)) + } + } + if len(failures) == 0 { + h.logTransition() + return nil + } + h.logTransition() + return errors.Join(failures...) +} + +func (h *Health) ping(ctx context.Context) error { + if err := h.pool.Writer().PingContext(ctx); err != nil { + return fmt.Errorf("writer ping failed: %w", err) + } + if err := h.pool.Reader().PingContext(ctx); err != nil { + return fmt.Errorf("reader ping failed: %w", err) + } + return nil +} + +func (h *Health) probeTables(ctx context.Context, descriptor Descriptor) error { + for _, table := range descriptor.RequiredTables { + exists, err := db.TableExistsContext(ctx, h.pool.Writer(), table) + if err != nil { + return fmt.Errorf("table probe failed: %w", err) + } + if !exists { + return fmt.Errorf("required table %q is missing", table) + } + } + return nil +} + +func (h *Health) recordUnavailable(err error) error { + for _, descriptor := range h.tracker.catalog { + if recordErr := h.tracker.RecordProbe(descriptor.ID, err); recordErr != nil { + return recordErr + } + } + h.logTransition() + return err +} + +// Start begins the periodic probe loop. The returned cleanup is idempotent. +func (h *Health) Start(ctx context.Context) func() error { + if h == nil { + return func() error { return nil } + } + h.mu.Lock() + if h.cancel != nil { + h.mu.Unlock() + return h.Stop + } + loopCtx, cancel := context.WithCancel(ctx) + h.cancel = cancel + h.done = make(chan struct{}) + interval := h.interval + done := h.done + h.mu.Unlock() + go h.run(loopCtx, interval, done) + return h.Stop +} + +func (h *Health) run(ctx context.Context, interval time.Duration, done chan struct{}) { + defer close(done) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + checkCtx, cancel := context.WithTimeout(ctx, probeTimeout) + if err := h.Check(checkCtx); err != nil && h.log != nil { + h.log.Warn("required persistence probe failed", zap.Error(err)) + } + cancel() + } + } +} + +// Stop terminates the periodic probe loop. +func (h *Health) Stop() error { + if h == nil { + return nil + } + h.mu.Lock() + cancel := h.cancel + done := h.done + h.cancel = nil + h.done = nil + h.mu.Unlock() + if cancel == nil { + return nil + } + cancel() + <-done + return nil +} + +// Healthy reports the current aggregate runtime state. +func (h *Health) Healthy() bool { + return h != nil && h.tracker != nil && h.tracker.Healthy() +} + +// State returns the current aggregate state. +func (h *Health) State() State { + if h == nil || h.tracker == nil { + return StateUnhealthy + } + return h.tracker.AggregateState() +} + +// UnhealthyStoreIDs returns the stable list used by readiness and caller +// errors. +func (h *Health) UnhealthyStoreIDs() []string { + if h == nil || h.tracker == nil { + return nil + } + return h.tracker.UnhealthyStoreIDs() +} + +// UnavailableStoreIDs returns all stores that are not currently healthy. +func (h *Health) UnavailableStoreIDs() []string { + if h == nil || h.tracker == nil { + return nil + } + return h.tracker.UnavailableStoreIDs() +} + +func (h *Health) logTransition() { + if h.log == nil { + return + } + state := h.State() + h.log.Info("required persistence state updated", + zap.String("state", string(state)), + zap.Strings("store_ids", h.UnhealthyStoreIDs()), + zap.String("error_class", publicErrorClass(state))) +} + +// PublicError returns a stable, non-sensitive error for diagnostics. +func PublicError(status Status) string { + if status.Error == "" { + return "" + } + if strings.Contains(status.Error, "required table") && strings.Contains(status.Error, "missing") { + return status.Error + } + return "database probe failed" +} + +func publicErrorClass(state State) string { + if state == StateUnhealthy { + return "database_probe" + } + return "none" +} diff --git a/apps/backend/internal/persistence/requiredstores/health_test.go b/apps/backend/internal/persistence/requiredstores/health_test.go new file mode 100644 index 0000000000..b0d6171042 --- /dev/null +++ b/apps/backend/internal/persistence/requiredstores/health_test.go @@ -0,0 +1,99 @@ +package requiredstores + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" +) + +func TestHealthCheckMarksMissingTableUnhealthyAndRecovers(t *testing.T) { + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + pool := db.NewPool(conn, conn) + t.Cleanup(func() { _ = pool.Close() }) + if _, err := conn.Exec("CREATE TABLE first (id TEXT PRIMARY KEY)"); err != nil { + t.Fatalf("create first table: %v", err) + } + + tracker, err := NewTracker([]Descriptor{{ + ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, + }}) + if err != nil { + t.Fatalf("NewTracker: %v", err) + } + if err := tracker.RecordSuccess("first"); err != nil { + t.Fatalf("RecordSuccess: %v", err) + } + health := NewHealth(tracker, pool, nil) + if err := health.Check(context.Background()); err != nil { + t.Fatalf("initial Check: %v", err) + } + if !health.Healthy() { + t.Fatal("health is unhealthy after a successful check") + } + if _, err := conn.Exec("DROP TABLE first"); err != nil { + t.Fatalf("drop first table: %v", err) + } + if err := health.Check(context.Background()); err == nil { + t.Fatal("Check after dropping table returned nil") + } + if health.Healthy() || tracker.AggregateState() != StateUnhealthy { + t.Fatalf("health did not become unhealthy: health=%v state=%q", health.Healthy(), tracker.AggregateState()) + } + if _, err := conn.Exec("CREATE TABLE first (id TEXT PRIMARY KEY)"); err != nil { + t.Fatalf("recreate first table: %v", err) + } + if err := health.Check(context.Background()); err != nil { + t.Fatalf("recovery Check: %v", err) + } + if !health.Healthy() { + t.Fatal("health did not recover") + } +} + +func TestProbeTablesHonorsContextDeadline(t *testing.T) { + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + if _, err := conn.Exec("CREATE TABLE first (id TEXT PRIMARY KEY)"); err != nil { + t.Fatalf("create first table: %v", err) + } + + tracker, err := NewTracker([]Descriptor{{ + ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, + }}) + if err != nil { + t.Fatalf("NewTracker: %v", err) + } + health := NewHealth(tracker, db.NewPool(conn, conn), nil) + tx, err := conn.Beginx() + if err != nil { + t.Fatalf("begin transaction: %v", err) + } + t.Cleanup(func() { _ = tx.Rollback() }) + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + started := time.Now() + err = health.probeTables(ctx, tracker.catalog[0]) + if err == nil { + t.Fatal("probeTables() error = nil, want context deadline error") + } + if ctx.Err() == nil { + t.Fatalf("probeTables() error = %v, context did not expire", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("probeTables() took %s after deadline", elapsed) + } +} diff --git a/apps/backend/internal/persistence/requiredstores/tracker.go b/apps/backend/internal/persistence/requiredstores/tracker.go new file mode 100644 index 0000000000..a09eae5de5 --- /dev/null +++ b/apps/backend/internal/persistence/requiredstores/tracker.go @@ -0,0 +1,261 @@ +package requiredstores + +import ( + "fmt" + "sync" + "time" +) + +// State is the initialization or probe state of a required store. +type State string + +const ( + StateInitializing State = "initializing" + StateHealthy State = "healthy" + StateUnhealthy State = "unhealthy" +) + +// Status is a point-in-time copy of a catalog entry and its lifecycle state. +type Status struct { + ID string + OwnerPackage string + RequiredTables []string + Capabilities []Capability + State State + Error string + InitializedAt time.Time + LastCheckedAt time.Time +} + +// Tracker records required-store initialization while enforcing dependency +// order. Independent stores may be initialized in different bootstrap phases, +// but every declared dependency must be recorded first. +type Tracker struct { + mu sync.RWMutex + catalog []Descriptor + byID map[string]int + statuses []Status + recorded []bool +} + +// NewTracker validates and copies a catalog before tracking it. +func NewTracker(descriptors []Descriptor) (*Tracker, error) { + if err := ValidateCatalog(descriptors); err != nil { + return nil, fmt.Errorf("validate required-store catalog: %w", err) + } + cloned := cloneDescriptors(descriptors) + byID := make(map[string]int, len(cloned)) + statuses := make([]Status, len(cloned)) + for index, descriptor := range cloned { + byID[descriptor.ID] = index + statuses[index] = statusFor(descriptor, StateInitializing) + } + return &Tracker{catalog: cloned, byID: byID, statuses: statuses, recorded: make([]bool, len(cloned))}, nil +} + +// NewCatalogTracker creates a tracker for the built-in catalog. +func NewCatalogTracker() (*Tracker, error) { + return NewTracker(Catalog()) +} + +// Record records one store initialization result. Results must be reported +// after all declared dependencies, which makes omissions and accidental phase +// reordering visible before readiness. +func (t *Tracker) Record(id string, initErr error) error { + t.mu.Lock() + defer t.mu.Unlock() + + index, exists := t.byID[id] + if !exists { + return fmt.Errorf("unknown required store %q", id) + } + if t.recorded[index] { + return fmt.Errorf("duplicate required store result for %q", id) + } + for _, dependency := range t.catalog[index].DependsOn { + dependencyIndex := t.byID[dependency] + if !t.recorded[dependencyIndex] { + return fmt.Errorf("required store %q is out of order, waiting for %q", id, dependency) + } + } + state := StateHealthy + message := "" + if initErr != nil { + state = StateUnhealthy + message = initErr.Error() + } + now := time.Now().UTC() + t.statuses[index].State = state + t.statuses[index].Error = message + t.statuses[index].InitializedAt = now + t.statuses[index].LastCheckedAt = now + t.recorded[index] = true + return nil +} + +// RecordSuccess is a convenience wrapper for successful initialization. +func (t *Tracker) RecordSuccess(id string) error { + return t.Record(id, nil) +} + +// RecordFailure is a convenience wrapper for failed initialization. +func (t *Tracker) RecordFailure(id string, initErr error) error { + return t.Record(id, initErr) +} + +// RecordProbe updates the runtime result for an already initialized store. +// Probe results may transition between healthy and unhealthy for the lifetime +// of the process, unlike initialization results which are recorded once. +func (t *Tracker) RecordProbe(id string, probeErr error) error { + t.mu.Lock() + defer t.mu.Unlock() + + index, exists := t.byID[id] + if !exists { + return fmt.Errorf("unknown required store %q", id) + } + if !t.recorded[index] { + return fmt.Errorf("required store %q has not been initialized", id) + } + status := &t.statuses[index] + status.State = StateHealthy + status.Error = "" + if probeErr != nil { + status.State = StateUnhealthy + status.Error = probeErr.Error() + } + status.LastCheckedAt = time.Now().UTC() + return nil +} + +// AggregateState returns the worst state currently observed across the +// catalog. Initialization is incomplete until every descriptor is recorded. +func (t *Tracker) AggregateState() State { + t.mu.RLock() + defer t.mu.RUnlock() + for index, status := range t.statuses { + if !t.recorded[index] || status.State == StateInitializing { + return StateInitializing + } + if status.State == StateUnhealthy { + return StateUnhealthy + } + } + return StateHealthy +} + +// Healthy reports whether initialization is complete and every runtime probe +// currently succeeds. +func (t *Tracker) Healthy() bool { + return t.AggregateState() == StateHealthy +} + +// UnhealthyStoreIDs returns unhealthy stores in catalog order. +func (t *Tracker) UnhealthyStoreIDs() []string { + t.mu.RLock() + defer t.mu.RUnlock() + ids := make([]string, 0) + for _, status := range t.statuses { + if status.State == StateUnhealthy { + ids = append(ids, status.ID) + } + } + return ids +} + +// UnavailableStoreIDs returns every store that is not currently healthy, +// including stores that have not completed initialization yet. +func (t *Tracker) UnavailableStoreIDs() []string { + t.mu.RLock() + defer t.mu.RUnlock() + ids := make([]string, 0) + for index, status := range t.statuses { + if !t.recorded[index] || status.State != StateHealthy { + ids = append(ids, status.ID) + } + } + return ids +} + +// ValidateComplete verifies that every catalog entry was reported and that no +// required store reported an initialization failure. +func (t *Tracker) ValidateComplete() error { + t.mu.RLock() + defer t.mu.RUnlock() + missing := 0 + firstMissing := "" + for index, recorded := range t.recorded { + if recorded { + continue + } + missing++ + if firstMissing == "" { + firstMissing = t.catalog[index].ID + } + } + if missing > 0 { + return fmt.Errorf("required-store catalog is missing %d result(s), next %q", missing, firstMissing) + } + for _, status := range t.statuses { + if status.State == StateUnhealthy { + return fmt.Errorf("required store %q is unhealthy: %s", status.ID, status.Error) + } + } + return nil +} + +// Complete is an alias for ValidateComplete. +func (t *Tracker) Complete() error { + return t.ValidateComplete() +} + +// NextID returns the next catalog ID that must be reported, or an empty string +// after all entries have been recorded. +func (t *Tracker) NextID() string { + t.mu.RLock() + defer t.mu.RUnlock() + for index, recorded := range t.recorded { + if !recorded { + return t.catalog[index].ID + } + } + return "" +} + +// Snapshot returns statuses in catalog order. Returned slices are independent +// copies and can be safely inspected by diagnostics or health probes. +func (t *Tracker) Snapshot() []Status { + t.mu.RLock() + defer t.mu.RUnlock() + result := make([]Status, len(t.statuses)) + for index, status := range t.statuses { + result[index] = status + result[index].RequiredTables = append([]string(nil), status.RequiredTables...) + result[index].Capabilities = append([]Capability(nil), status.Capabilities...) + } + return result +} + +// Status returns a status copy for one catalog ID. +func (t *Tracker) Status(id string) (Status, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + index, ok := t.byID[id] + if !ok { + return Status{}, false + } + status := t.statuses[index] + status.RequiredTables = append([]string(nil), status.RequiredTables...) + status.Capabilities = append([]Capability(nil), status.Capabilities...) + return status, true +} + +func statusFor(descriptor Descriptor, state State) Status { + return Status{ + ID: descriptor.ID, + OwnerPackage: descriptor.OwnerPackage, + RequiredTables: append([]string(nil), descriptor.RequiredTables...), + Capabilities: append([]Capability(nil), descriptor.Capabilities...), + State: state, + } +} diff --git a/apps/backend/internal/persistence/requiredstores/tracker_test.go b/apps/backend/internal/persistence/requiredstores/tracker_test.go new file mode 100644 index 0000000000..060f548ffc --- /dev/null +++ b/apps/backend/internal/persistence/requiredstores/tracker_test.go @@ -0,0 +1,113 @@ +package requiredstores + +import ( + "strings" + "testing" +) + +func TestTrackerRejectsUnknownDuplicateAndOutOfOrderResults(t *testing.T) { + tracker, err := NewTracker([]Descriptor{ + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, DependsOn: []string{"first"}}, + }) + if err != nil { + t.Fatalf("NewTracker() error = %v", err) + } + + assertTrackerError(t, tracker.Record("missing", nil), "unknown") + assertTrackerError(t, tracker.Record("second", nil), "out of order") + if err := tracker.Record("first", nil); err != nil { + t.Fatalf("Record(first) error = %v", err) + } + assertTrackerError(t, tracker.Record("first", nil), "duplicate") +} + +func TestTrackerSnapshotUsesCatalogOrderAndDetectsFailures(t *testing.T) { + tracker, err := NewTracker([]Descriptor{ + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, DependsOn: []string{"first"}}, + }) + if err != nil { + t.Fatalf("NewTracker() error = %v", err) + } + if err := tracker.Record("first", nil); err != nil { + t.Fatalf("Record(first) error = %v", err) + } + if err := tracker.Record("second", errTestStore); err != nil { + t.Fatalf("Record(second) error = %v", err) + } + + snapshot := tracker.Snapshot() + if len(snapshot) != 2 || snapshot[0].ID != "first" || snapshot[1].ID != "second" { + t.Fatalf("Snapshot() IDs = %#v, want catalog order", snapshot) + } + if snapshot[0].State != StateHealthy { + t.Errorf("first state = %q, want %q", snapshot[0].State, StateHealthy) + } + if snapshot[1].State != StateUnhealthy || snapshot[1].Error != errTestStore.Error() { + t.Errorf("second status = %#v, want unhealthy error", snapshot[1]) + } + if err := tracker.ValidateComplete(); err == nil || !strings.Contains(err.Error(), "unhealthy") { + t.Fatalf("ValidateComplete() error = %v, want unhealthy error", err) + } +} + +func TestTrackerDetectsMissingResults(t *testing.T) { + tracker, err := NewTracker([]Descriptor{ + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}}, + }) + if err != nil { + t.Fatalf("NewTracker() error = %v", err) + } + if err := tracker.Record("first", nil); err != nil { + t.Fatalf("Record(first) error = %v", err) + } + if err := tracker.ValidateComplete(); err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("ValidateComplete() error = %v, want missing error", err) + } +} + +func TestTrackerProbeStateRecoversAndReportsUnavailableStores(t *testing.T) { + tracker, err := NewTracker([]Descriptor{ + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}}, + }) + if err != nil { + t.Fatalf("NewTracker() error = %v", err) + } + if err := tracker.RecordSuccess("first"); err != nil { + t.Fatalf("RecordSuccess(first) error = %v", err) + } + if err := tracker.RecordSuccess("second"); err != nil { + t.Fatalf("RecordSuccess(second) error = %v", err) + } + if err := tracker.RecordProbe("first", errTestStore); err != nil { + t.Fatalf("RecordProbe(first) error = %v", err) + } + if tracker.AggregateState() != StateUnhealthy { + t.Fatalf("AggregateState() = %q, want %q", tracker.AggregateState(), StateUnhealthy) + } + if got := tracker.UnhealthyStoreIDs(); len(got) != 1 || got[0] != "first" { + t.Fatalf("UnhealthyStoreIDs() = %#v, want [first]", got) + } + if err := tracker.RecordProbe("first", nil); err != nil { + t.Fatalf("RecordProbe recovery error = %v", err) + } + if !tracker.Healthy() || tracker.AggregateState() != StateHealthy { + t.Fatalf("tracker did not recover: state=%q healthy=%v", tracker.AggregateState(), tracker.Healthy()) + } +} + +var errTestStore = testError("store initialization failed") + +type testError string + +func (e testError) Error() string { return string(e) } + +func assertTrackerError(t *testing.T, err error, want string) { + t.Helper() + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("tracker error = %v, want %q", err, want) + } +} diff --git a/apps/backend/internal/persistence/storeconformance/adapters.go b/apps/backend/internal/persistence/storeconformance/adapters.go new file mode 100644 index 0000000000..128c99b831 --- /dev/null +++ b/apps/backend/internal/persistence/storeconformance/adapters.go @@ -0,0 +1,505 @@ +// Package storeconformance contains the fixed, catalog-driven persistence +// conformance suite. It is kept separate from backendapp so the test package +// cannot accidentally use a partially initialized production process. +package storeconformance + +import ( + "context" + "fmt" + + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/analytics/repository" + "github.com/kandev/kandev/internal/auth/hostnames" + authstore "github.com/kandev/kandev/internal/auth/store" + "github.com/kandev/kandev/internal/automation" + "github.com/kandev/kandev/internal/azuredevops" + "github.com/kandev/kandev/internal/canvas" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/delivery" + editorstore "github.com/kandev/kandev/internal/editors/store" + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/gitlab" + "github.com/kandev/kandev/internal/jira" + "github.com/kandev/kandev/internal/linear" + "github.com/kandev/kandev/internal/notifications/store" + "github.com/kandev/kandev/internal/office/configsync" + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/org" + "github.com/kandev/kandev/internal/orgunit" + "github.com/kandev/kandev/internal/persistence" + "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/plugins" + "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/marketplace" + pluginstate "github.com/kandev/kandev/internal/plugins/state" + promptstore "github.com/kandev/kandev/internal/prompts/store" + quickterminalrepo "github.com/kandev/kandev/internal/quickterminal/repository" + "github.com/kandev/kandev/internal/runtimeflags" + "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/sentry" + "github.com/kandev/kandev/internal/system/settings" + storagepkg "github.com/kandev/kandev/internal/system/storage" + taskrepository "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/task/share" + "github.com/kandev/kandev/internal/telemetrycontract" + terminalrepo "github.com/kandev/kandev/internal/terminal/repository" + testconformance "github.com/kandev/kandev/internal/testutil/storeconformance" + userstore "github.com/kandev/kandev/internal/user/store" + utilitystore "github.com/kandev/kandev/internal/utility/store" + workflowrepository "github.com/kandev/kandev/internal/workflow/repository" + "github.com/kandev/kandev/internal/workflowsync" +) + +// Adapters returns one fixed adapter for every catalog descriptor. Each +// adapter invokes the owning package's schema constructor; provider adapters +// stop before credential discovery or remote API setup. +func Adapters() []testconformance.Adapter { + descriptors := requiredstores.Catalog() + adapters := make([]testconformance.Adapter, 0, len(descriptors)) + for _, descriptor := range descriptors { + adapters = append(adapters, adapterFor(descriptor)) + } + return adapters +} + +func adapterFor(descriptor requiredstores.Descriptor) testconformance.Adapter { + rawInitializer := schemaInitializerFor(descriptor) + initializer := func(s testconformance.ScenarioContext) error { + if err := rawInitializer(s); err != nil { + return err + } + return validateRequiredTables(s, descriptor) + } + return testconformance.Adapter{ + ID: descriptor.ID, + Engines: map[testconformance.EngineName]testconformance.EngineAdapter{ + testconformance.EngineSQLite: {Fresh: initializer, Replay: initializer}, + testconformance.EnginePostgres: {Fresh: initializer, Replay: initializer}, + }, + Scenarios: behaviorScenarios(descriptor), + } +} + +var schemaInitializers = map[string]testconformance.Scenario{ + "schema-meta": schemaMeta, + "task": taskSchema, + "workflow": workflowSchema, + "analytics": analyticsSchema, + "agent-settings": agentSettingsSchema, + "user": userSchema, + "notification": notificationSchema, + "editor": editorSchema, + "prompts": promptSchema, + "utility": utilitySchema, + "office": officeSchema, + "terminal": terminalSchema, + "quick-terminal": quickTerminalSchema, + "runtime-flags": runtimeFlagsSchema, + "auth": authSchema, + "secrets": secretsSchema, + "system-settings": systemSettingsSchema, + "auth-hostnames": hostnameSchema, + "organizations": organizationSchema, + "organization-units": organizationUnitSchema, + "message-queue": messageQueueSchema, + "task-share": shareSchema, + "telemetry-contract": telemetrySchema, + "delivery": deliverySchema, + "storage": storageSchema, + "plugin-instances": pluginInstancesSchema, + "plugin-marketplace": pluginMarketplaceSchema, + "plugin-settings": pluginSettingsSchema, + "plugin-state": pluginStateSchema, + "plugin-instance-state": pluginInstanceStateSchema, + "plugin-user-state": pluginUserStateSchema, + "canvas": canvasSchema, + "github": githubSchema, + "gitlab": gitlabSchema, + "jira": jiraSchema, + "linear": linearSchema, + "sentry": sentrySchema, + "azure-devops": azureDevOpsSchema, + "workflow-sync": workflowSyncSchema, + "office-config-sync": officeConfigSyncSchema, + "automation": automationSchema, +} + +func schemaInitializerFor(descriptor requiredstores.Descriptor) testconformance.Scenario { + if initializer, ok := schemaInitializers[descriptor.ID]; ok { + return initializer + } + return func(testconformance.ScenarioContext) error { + return fmt.Errorf("store adapter %q has no fixed schema initializer", descriptor.ID) + } +} + +func schemaMeta(s testconformance.ScenarioContext) error { + if err := persistence.EnsureMetaTable(s.DB); err != nil { + return fmt.Errorf("schema metadata: %w", err) + } + return nil +} + +func taskSchema(s testconformance.ScenarioContext) error { + if _, _, err := taskrepository.Provide(s.DB, s.DB, logger.Default()); err != nil { + return fmt.Errorf("task schema: %w", err) + } + return nil +} + +func workflowSchema(s testconformance.ScenarioContext) error { + if err := taskSchema(s); err != nil { + return err + } + if _, err := workflowrepository.NewWithDB(s.DB, s.DB, logger.Default()); err != nil { + return fmt.Errorf("workflow schema: %w", err) + } + return nil +} + +func analyticsSchema(s testconformance.ScenarioContext) error { + if err := workflowSchema(s); err != nil { + return err + } + if _, _, err := repository.Provide(s.DB, s.DB); err != nil { + return fmt.Errorf("analytics schema: %w", err) + } + return nil +} + +func agentSettingsSchema(s testconformance.ScenarioContext) error { + if _, _, err := settingsstore.Provide(s.DB, s.DB, logger.Default()); err != nil { + return fmt.Errorf("agent settings schema: %w", err) + } + return nil +} + +func systemSettingsSchema(s testconformance.ScenarioContext) error { + if _, err := settings.NewStore(db.NewPool(s.DB, s.DB)); err != nil { + return fmt.Errorf("system settings schema: %w", err) + } + return nil +} + +func userSchema(s testconformance.ScenarioContext) error { + if _, _, err := userstore.Provide(s.DB, s.DB); err != nil { + return fmt.Errorf("user schema: %w", err) + } + return nil +} + +func notificationSchema(s testconformance.ScenarioContext) error { + if _, _, err := userstore.Provide(s.DB, s.DB); err != nil { + return fmt.Errorf("notification dependency schema: %w", err) + } + if _, _, err := store.Provide(context.Background(), s.DB, s.DB); err != nil { + return fmt.Errorf("notification schema: %w", err) + } + return nil +} + +func editorSchema(s testconformance.ScenarioContext) error { + if _, _, err := editorstore.Provide(s.DB, s.DB); err != nil { + return fmt.Errorf("editor schema: %w", err) + } + return nil +} + +func promptSchema(s testconformance.ScenarioContext) error { + if _, _, err := promptstore.Provide(s.DB, s.DB); err != nil { + return fmt.Errorf("prompt schema: %w", err) + } + return nil +} + +func utilitySchema(s testconformance.ScenarioContext) error { + if _, _, err := utilitystore.Provide(s.DB, s.DB); err != nil { + return fmt.Errorf("utility schema: %w", err) + } + return nil +} + +func officeSchema(s testconformance.ScenarioContext) error { + if err := workflowSchema(s); err != nil { + return err + } + if _, err := officesqlite.NewWithDB(s.DB, s.DB, logger.Default()); err != nil { + return fmt.Errorf("office schema: %w", err) + } + return nil +} + +func terminalSchema(s testconformance.ScenarioContext) error { + if _, err := terminalrepo.NewWithDB(s.DB, s.DB, logger.Default()); err != nil { + return fmt.Errorf("terminal schema: %w", err) + } + return nil +} + +func quickTerminalSchema(s testconformance.ScenarioContext) error { + if _, err := quickterminalrepo.NewWithDB(s.DB, s.DB); err != nil { + return fmt.Errorf("quick terminal schema: %w", err) + } + return nil +} + +func runtimeFlagsSchema(s testconformance.ScenarioContext) error { + if _, err := runtimeflags.NewSQLiteStore(s.DB, s.DB); err != nil { + return fmt.Errorf("runtime flags schema: %w", err) + } + return nil +} + +func authSchema(s testconformance.ScenarioContext) error { + if err := userSchema(s); err != nil { + return err + } + if _, err := authstore.New(s.DB, s.DB); err != nil { + return fmt.Errorf("auth schema: %w", err) + } + return nil +} + +func secretsSchema(s testconformance.ScenarioContext) error { + if _, _, err := secrets.Provide(s.DB, s.DB, nil); err != nil { + return fmt.Errorf("secrets schema: %w", err) + } + return nil +} + +func hostnameSchema(s testconformance.ScenarioContext) error { + if _, err := hostnames.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("hostname schema: %w", err) + } + return nil +} + +func organizationSchema(s testconformance.ScenarioContext) error { + if _, err := org.NewStore(db.NewPool(s.DB, s.DB)); err != nil { + return fmt.Errorf("organization schema: %w", err) + } + return nil +} + +func organizationUnitSchema(s testconformance.ScenarioContext) error { + if err := organizationSchema(s); err != nil { + return err + } + if _, err := orgunit.NewStore(db.NewPool(s.DB, s.DB)); err != nil { + return fmt.Errorf("organization unit schema: %w", err) + } + return nil +} + +func messageQueueSchema(s testconformance.ScenarioContext) error { + if err := taskSchema(s); err != nil { + return err + } + if _, err := messagequeue.NewSQLiteRepository(s.DB, s.DB); err != nil { + return fmt.Errorf("message queue schema: %w", err) + } + return nil +} + +func shareSchema(s testconformance.ScenarioContext) error { + if err := taskSchema(s); err != nil { + return err + } + if _, err := share.NewRepository(s.DB, s.DB, logger.Default()); err != nil { + return fmt.Errorf("share schema: %w", err) + } + return nil +} + +func telemetrySchema(s testconformance.ScenarioContext) error { + if _, err := telemetrycontract.NewWithDB(s.DB, s.DB); err != nil { + return fmt.Errorf("telemetry schema: %w", err) + } + return nil +} + +func deliverySchema(s testconformance.ScenarioContext) error { + if err := taskSchema(s); err != nil { + return err + } + if _, err := delivery.NewWithDB(s.DB, s.DB, logger.Default()); err != nil { + return fmt.Errorf("delivery schema: %w", err) + } + return nil +} + +func storageSchema(s testconformance.ScenarioContext) error { + if err := systemSettingsSchema(s); err != nil { + return err + } + if _, err := storagepkg.NewStore(db.NewPool(s.DB, s.DB)); err != nil { + return fmt.Errorf("storage schema: %w", err) + } + return nil +} + +func pluginPool(s testconformance.ScenarioContext) *db.Pool { return db.NewPool(s.DB, s.DB) } + +func pluginInstancesSchema(s testconformance.ScenarioContext) error { + if _, err := instances.NewStore(pluginPool(s)); err != nil { + return fmt.Errorf("plugin instances schema: %w", err) + } + return nil +} + +func pluginMarketplaceSchema(s testconformance.ScenarioContext) error { + if _, err := marketplace.NewSourceStore(pluginPool(s)); err != nil { + return fmt.Errorf("plugin marketplace schema: %w", err) + } + return nil +} + +func pluginSettingsSchema(s testconformance.ScenarioContext) error { + if err := plugins.InitializeSettingsStore(pluginPool(s)); err != nil { + return fmt.Errorf("plugin settings schema: %w", err) + } + return nil +} + +func pluginStateSchema(s testconformance.ScenarioContext) error { + if _, err := pluginstate.NewStore(pluginPool(s)); err != nil { + return fmt.Errorf("plugin state schema: %w", err) + } + return nil +} + +func pluginInstanceStateSchema(s testconformance.ScenarioContext) error { + if _, err := pluginstate.NewInstanceStore(pluginPool(s)); err != nil { + return fmt.Errorf("plugin instance state schema: %w", err) + } + return nil +} + +func pluginUserStateSchema(s testconformance.ScenarioContext) error { + if err := userSchema(s); err != nil { + return err + } + if _, err := pluginstate.NewUserStore(pluginPool(s)); err != nil { + return fmt.Errorf("plugin user state schema: %w", err) + } + return nil +} + +func canvasSchema(s testconformance.ScenarioContext) error { + if err := pluginInstancesSchema(s); err != nil { + return err + } + if _, err := canvas.NewRepository(pluginPool(s)); err != nil { + return fmt.Errorf("canvas schema: %w", err) + } + return nil +} + +func providerDependencies(s testconformance.ScenarioContext) error { + if err := taskSchema(s); err != nil { + return err + } + return userSchema(s) +} + +func githubSchema(s testconformance.ScenarioContext) error { + if err := providerDependencies(s); err != nil { + return err + } + if _, err := github.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("github schema: %w", err) + } + return nil +} + +func gitlabSchema(s testconformance.ScenarioContext) error { + if err := providerDependencies(s); err != nil { + return err + } + if _, err := gitlab.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("gitlab schema: %w", err) + } + return nil +} + +func jiraSchema(s testconformance.ScenarioContext) error { + if err := providerDependencies(s); err != nil { + return err + } + if _, err := jira.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("jira schema: %w", err) + } + return nil +} + +func linearSchema(s testconformance.ScenarioContext) error { + if err := providerDependencies(s); err != nil { + return err + } + if _, err := linear.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("linear schema: %w", err) + } + return nil +} + +func sentrySchema(s testconformance.ScenarioContext) error { + if err := providerDependencies(s); err != nil { + return err + } + if _, err := sentry.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("sentry schema: %w", err) + } + return nil +} + +func azureDevOpsSchema(s testconformance.ScenarioContext) error { + if err := providerDependencies(s); err != nil { + return err + } + if _, err := azuredevops.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("azure devops schema: %w", err) + } + return nil +} + +func workflowSyncSchema(s testconformance.ScenarioContext) error { + if err := workflowSchema(s); err != nil { + return err + } + if _, err := workflowsync.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("workflow sync schema: %w", err) + } + return nil +} + +func officeConfigSyncSchema(s testconformance.ScenarioContext) error { + if _, err := configsync.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("office config sync schema: %w", err) + } + return nil +} + +func automationSchema(s testconformance.ScenarioContext) error { + if err := providerDependencies(s); err != nil { + return err + } + if _, err := automation.NewStore(s.DB, s.DB); err != nil { + return fmt.Errorf("automation schema: %w", err) + } + return nil +} + +func validateRequiredTables(s testconformance.ScenarioContext, descriptor requiredstores.Descriptor) error { + for _, table := range descriptor.RequiredTables { + exists, err := db.TableExists(s.DB, table) + if err != nil { + return fmt.Errorf("probe catalog table %s: %w", table, err) + } + if !exists { + return fmt.Errorf("catalog table %s was not initialized", table) + } + } + return nil +} diff --git a/apps/backend/internal/persistence/storeconformance/owner_actions.go b/apps/backend/internal/persistence/storeconformance/owner_actions.go new file mode 100644 index 0000000000..50a5401562 --- /dev/null +++ b/apps/backend/internal/persistence/storeconformance/owner_actions.go @@ -0,0 +1,3134 @@ +//nolint:revive // Each catalog owner has an explicit API adapter in this test contract. +package storeconformance + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "time" + + analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" + "github.com/kandev/kandev/internal/auth/hostnames" + authstore "github.com/kandev/kandev/internal/auth/store" + "github.com/kandev/kandev/internal/automation" + "github.com/kandev/kandev/internal/azuredevops" + "github.com/kandev/kandev/internal/canvas" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/delivery" + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/gitlab" + "github.com/kandev/kandev/internal/jira" + "github.com/kandev/kandev/internal/linear" + "github.com/kandev/kandev/internal/office/configsync" + officemodels "github.com/kandev/kandev/internal/office/models" + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/org" + "github.com/kandev/kandev/internal/orgunit" + "github.com/kandev/kandev/internal/persistence" + "github.com/kandev/kandev/internal/plugins" + "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/marketplace" + pluginstate "github.com/kandev/kandev/internal/plugins/state" + quickterminalrepo "github.com/kandev/kandev/internal/quickterminal/repository" + "github.com/kandev/kandev/internal/runtimeflags" + "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/sentry" + "github.com/kandev/kandev/internal/system/settings" + storagepkg "github.com/kandev/kandev/internal/system/storage" + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/share" + "github.com/kandev/kandev/internal/telemetrycontract" + terminalrepo "github.com/kandev/kandev/internal/terminal/repository" + testconformance "github.com/kandev/kandev/internal/testutil/storeconformance" + userstore "github.com/kandev/kandev/internal/user/store" + "github.com/kandev/kandev/internal/workflowsync" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +const defaultUserID = userstore.DefaultUserID + +type metaBehaviorRecord struct { + ID string + Value string + CreatedAt time.Time + UpdatedAt time.Time +} + +//nolint:funlen // The catalog-to-owner mapping is deliberately explicit. +func buildOwnerBehaviors() map[string]ownerBehavior { + behaviors := map[string]ownerBehavior{ + "schema-meta": {actions: []apiAction{metaAction()}}, + "task": {actions: []apiAction{ + standardAction(reflectedSpec{ + name: "workspaces", factory: taskFactory, + createMethod: "CreateWorkspace", readMethod: "GetWorkspace", + updateMethod: "UpdateWorkspace", deleteMethod: "DeleteWorkspace", + }), + taskAction(), + }}, + "workflow": {actions: []apiAction{ + standardAction(reflectedSpec{ + name: "workflow_templates", factory: workflowFactory, + createMethod: "CreateTemplate", readMethod: "GetTemplate", + updateMethod: "UpdateTemplate", deleteMethod: "DeleteTemplate", + }), + }}, + "agent-settings": {actions: []apiAction{ + standardAction(reflectedSpec{ + name: "agents", factory: settingsFactory, + createMethod: "CreateAgent", readMethod: "GetAgent", + updateMethod: "UpdateAgent", deleteMethod: "DeleteAgent", + booleanFields: []string{"SupportsMCP"}, + updateMutation: func(record any, _ string) { + value := reflectValue(record) + setBoolField(value, "SupportsMCP", true) + }, + updateAssertion: func(_ any, after any, _ string) error { + enabled, ok := recordBoolean(after, []string{"SupportsMCP"}) + if !ok || !enabled { + return fmt.Errorf("agent SupportsMCP = %t, want true", enabled) + } + return nil + }, + }), + }}, + "user": {actions: []apiAction{userAction()}}, + "notification": {actions: []apiAction{ + standardAction(reflectedSpec{ + name: "notification_providers", factory: notificationFactory, + createMethod: "CreateProvider", readMethod: "GetProvider", + updateMethod: "UpdateProvider", deleteMethod: "DeleteProvider", + readArgs: func(id string) []any { return []any{defaultUserID, id} }, + updateArgs: func(_ string, record any) []any { return []any{record} }, + deleteArgs: func(id string) []any { return []any{defaultUserID, id} }, + booleanFields: []string{"Enabled"}, + updateMutation: func(record any, id string) { + setStringField(reflectValue(record), "Name", "updated-"+id) + }, + }), + }}, + "editor": {actions: []apiAction{ + standardAction(reflectedSpec{ + name: "editors", factory: editorFactory, + createMethod: "CreateEditor", readMethod: "GetEditorByID", + updateMethod: "UpdateEditor", deleteMethod: "DeleteEditor", + populate: func(record any, id string) { + populateRecord(record, id) + setStringField(reflectValue(record), "Type", "conformance-editor-"+id) + }, + booleanFields: []string{"Enabled", "Installed"}, + }), + }}, + "prompts": {actions: []apiAction{ + standardAction(reflectedSpec{ + name: "custom_prompts", factory: promptFactory, + createMethod: "CreatePrompt", readMethod: "GetPromptByID", + updateMethod: "UpdatePrompt", deleteMethod: "DeletePrompt", + }), + }}, + "utility": {actions: []apiAction{ + standardAction(reflectedSpec{ + name: "utility_agents", factory: utilityFactory, + createMethod: "CreateAgent", readMethod: "GetAgentByID", + updateMethod: "UpdateAgent", deleteMethod: "DeleteAgent", + booleanFields: []string{"Enabled", "Builtin"}, + }), + }}, + } + + behaviors["office"] = ownerBehavior{actions: []apiAction{officeAction()}} + behaviors["terminal"] = ownerBehavior{actions: []apiAction{terminalAction()}} + behaviors["quick-terminal"] = ownerBehavior{actions: []apiAction{quickTerminalAction()}} + behaviors["runtime-flags"] = ownerBehavior{actions: []apiAction{runtimeFlagsAction()}} + behaviors["auth"] = ownerBehavior{actions: []apiAction{authIdentityAction()}} + behaviors["secrets"] = ownerBehavior{actions: []apiAction{secretAction()}} + behaviors["system-settings"] = ownerBehavior{actions: []apiAction{systemSettingsAction()}} + behaviors["auth-hostnames"] = ownerBehavior{actions: []apiAction{hostnameAction()}} + behaviors["organizations"] = ownerBehavior{actions: []apiAction{organizationAction()}} + behaviors["organization-units"] = ownerBehavior{actions: []apiAction{organizationUnitAction()}} + behaviors["message-queue"] = ownerBehavior{actions: []apiAction{messageQueueAction()}} + behaviors["task-share"] = ownerBehavior{actions: []apiAction{shareAction()}} + behaviors["telemetry-contract"] = ownerBehavior{actions: []apiAction{telemetryAction()}} + behaviors["delivery"] = ownerBehavior{actions: []apiAction{deliveryAction()}} + behaviors["storage"] = ownerBehavior{actions: []apiAction{storageAction()}} + behaviors["plugin-instances"] = ownerBehavior{actions: []apiAction{pluginInstanceAction()}} + behaviors["plugin-marketplace"] = ownerBehavior{actions: []apiAction{marketplaceAction()}} + behaviors["plugin-settings"] = ownerBehavior{actions: []apiAction{pluginSettingsAction()}} + behaviors["plugin-state"] = ownerBehavior{actions: []apiAction{pluginStateAction()}} + behaviors["plugin-instance-state"] = ownerBehavior{actions: []apiAction{pluginInstanceStateAction()}} + behaviors["plugin-user-state"] = ownerBehavior{actions: []apiAction{pluginUserStateAction()}} + behaviors["canvas"] = ownerBehavior{actions: []apiAction{canvasAction()}} + behaviors["github"] = ownerBehavior{actions: []apiAction{githubAction()}} + behaviors["gitlab"] = ownerBehavior{actions: []apiAction{gitlabAction()}} + behaviors["jira"] = ownerBehavior{actions: []apiAction{jiraAction()}} + behaviors["linear"] = ownerBehavior{actions: []apiAction{linearAction()}} + behaviors["sentry"] = ownerBehavior{actions: []apiAction{sentryAction()}} + behaviors["azure-devops"] = ownerBehavior{actions: []apiAction{azureDevOpsAction()}} + behaviors["workflow-sync"] = ownerBehavior{actions: []apiAction{workflowSyncAction()}} + behaviors["office-config-sync"] = ownerBehavior{actions: []apiAction{officeConfigSyncAction()}} + behaviors["automation"] = ownerBehavior{actions: []apiAction{automationAction()}} + + analytics := standardAction(reflectedSpec{ + name: "analytics-task-view", factory: taskFactory, + createMethod: "CreateWorkspace", readMethod: "GetWorkspace", + updateMethod: "UpdateWorkspace", deleteMethod: "DeleteWorkspace", + }) + baseAnalyticsRead := analytics.read + analytics.read = func(s testconformance.ScenarioContext, id string) (any, error) { + stats, err := analyticsRepository(s) + if err != nil { + return nil, err + } + if _, err := invoke(stats, "GetGlobalStats", s.Context, id, nil); err != nil { + return nil, fmt.Errorf("analytics read: %w", err) + } + return baseAnalyticsRead(s, id) + } + behaviors["analytics"] = ownerBehavior{actions: []apiAction{analytics}} + + return behaviors +} + +func metaAction() apiAction { + return apiAction{ + name: "kandev_meta", + create: func(s testconformance.ScenarioContext, id string) (any, error) { + if err := persistence.WriteVersion(s.DB, id); err != nil { + return nil, err + } + now := time.Now().UTC() + return &metaBehaviorRecord{ID: id, Value: id, CreatedAt: now, UpdatedAt: now}, nil + }, + read: func(s testconformance.ScenarioContext, _ string) (any, error) { + value, err := persistence.ReadMetaKey(s.DB, "kandev_version") + if err != nil { + return nil, err + } + if value == "" { + return nil, fmt.Errorf("meta version is empty") + } + return &metaBehaviorRecord{ID: value, Value: value, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}, nil + }, + update: func(s testconformance.ScenarioContext, id string, _ any) error { + return persistence.WriteVersion(s.DB, id+"-updated") + }, + delete: func(s testconformance.ScenarioContext, _ string) error { + return persistence.DeleteKeys(s.DB, "kandev_version") + }, + } +} + +func officeFactory(s testconformance.ScenarioContext) (any, error) { + return officesqlite.NewWithDB(s.DB, s.DB, logger.Default()) +} + +func terminalFactory(s testconformance.ScenarioContext) (any, error) { + return terminalrepo.NewWithDB(s.DB, s.DB, logger.Default()) +} + +func quickTerminalFactory(s testconformance.ScenarioContext) (any, error) { + return quickterminalrepo.NewWithDB(s.DB, s.DB) +} + +func runtimeFlagsFactory(s testconformance.ScenarioContext) (any, error) { + return runtimeflags.NewSQLiteStore(s.DB, s.DB) +} + +func authFactory(s testconformance.ScenarioContext) (any, error) { + return authstore.New(s.DB, s.DB) +} + +func secretFactory(s testconformance.ScenarioContext) (any, error) { + keyDir := filepath.Join(os.TempDir(), "kandev-store-conformance", string(s.Engine)) + key, err := secrets.NewMasterKeyProvider(keyDir) + if err != nil { + return nil, err + } + store, _, err := secrets.Provide(s.DB, s.DB, key) + return store, err +} + +func systemSettingsFactory(s testconformance.ScenarioContext) (any, error) { + return settings.NewStore(ownerPool(s)) +} + +func hostnameFactory(s testconformance.ScenarioContext) (any, error) { + return hostnames.NewStore(s.DB, s.DB) +} + +func organizationFactory(s testconformance.ScenarioContext) (any, error) { + return org.NewStore(ownerPool(s)) +} + +func organizationUnitFactory(s testconformance.ScenarioContext) (any, error) { + return orgunit.NewStore(ownerPool(s)) +} + +func messageQueueFactory(s testconformance.ScenarioContext) (any, error) { + return messagequeue.NewSQLiteRepository(s.DB, s.DB) +} + +func shareFactory(s testconformance.ScenarioContext) (any, error) { + return share.NewRepository(s.DB, s.DB, logger.Default()) +} + +func telemetryFactory(s testconformance.ScenarioContext) (any, error) { + return telemetrycontract.NewWithDB(s.DB, s.DB) +} + +func deliveryFactory(s testconformance.ScenarioContext) (any, error) { + return delivery.NewWithDB(s.DB, s.DB, logger.Default()) +} + +func storageFactory(s testconformance.ScenarioContext) (any, error) { + return storagepkg.NewStore(ownerPool(s)) +} + +func pluginInstancesFactory(s testconformance.ScenarioContext) (any, error) { + return instances.NewStore(ownerPool(s)) +} + +func marketplaceFactory(s testconformance.ScenarioContext) (any, error) { + return marketplace.NewSourceStore(ownerPool(s)) +} + +func pluginSettingsFactory(s testconformance.ScenarioContext) (any, error) { + return plugins.NewSettingsStore(ownerPool(s)) +} + +func pluginStateFactory(s testconformance.ScenarioContext) (any, error) { + return pluginstate.NewStore(ownerPool(s)) +} + +func pluginInstanceStateFactory(s testconformance.ScenarioContext) (any, error) { + return pluginstate.NewInstanceStore(ownerPool(s)) +} + +func pluginUserStateFactory(s testconformance.ScenarioContext) (any, error) { + return pluginstate.NewUserStore(ownerPool(s)) +} + +func canvasFactory(s testconformance.ScenarioContext) (any, error) { + return canvas.NewRepository(ownerPool(s)) +} + +func githubFactory(s testconformance.ScenarioContext) (any, error) { + return github.NewStore(s.DB, s.DB) +} + +func gitlabFactory(s testconformance.ScenarioContext) (any, error) { + return gitlab.NewStore(s.DB, s.DB) +} + +func jiraFactory(s testconformance.ScenarioContext) (any, error) { + return jira.NewStore(s.DB, s.DB) +} + +func linearFactory(s testconformance.ScenarioContext) (any, error) { + return linear.NewStore(s.DB, s.DB) +} + +func sentryFactory(s testconformance.ScenarioContext) (any, error) { + return sentry.NewStore(s.DB, s.DB) +} + +func azureDevOpsFactory(s testconformance.ScenarioContext) (any, error) { + return azuredevops.NewStore(s.DB, s.DB) +} + +func workflowSyncFactory(s testconformance.ScenarioContext) (any, error) { + return workflowsync.NewStore(s.DB, s.DB) +} + +func officeConfigSyncFactory(s testconformance.ScenarioContext) (any, error) { + return configsync.NewStore(s.DB, s.DB) +} + +func automationFactory(s testconformance.ScenarioContext) (any, error) { + return automation.NewStore(s.DB, s.DB) +} + +func userAction() apiAction { + action := standardAction(reflectedSpec{ + name: "users", factory: userFactory, + createMethod: "CreateUser", readMethod: "GetUser", + updateMethod: "UpdateUserProfile", deleteMethod: "DeleteUser", + updateArgs: func(id string, _ any) []any { + return []any{id, id + "-updated@example.test", "Updated User", "member"} + }, + }) + action.assertUpdated = func(_ any, after any, _ string) error { + name, nameOK := recordStringField(after, "DisplayName") + role, roleOK := recordStringField(after, "Role") + if !nameOK || !roleOK || name != "Updated User" || role != "member" { + return fmt.Errorf("updated user = %#v, want member/Updated User", after) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := userFactory(s) + if err != nil { + return nil, err + } + if _, err := invoke(store, "SetOperator", s.Context, id, enabled); err != nil { + return nil, err + } + return action.read(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + return recordBoolean(record, []string{"IsOperator"}) + } + return action +} + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete task repository contract. +func taskAction() apiAction { + action := apiAction{name: "tasks", key: recordIdentifier} + createTask := func(s testconformance.ScenarioContext, id string, ephemeral bool) (any, error) { + store, err := taskFactory(s) + if err != nil { + return nil, err + } + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return nil, err + } + task := &taskmodels.Task{ + ID: id, WorkspaceID: workspaceID, Title: "Conformance task", + State: v1.TaskStateTODO, Priority: "medium", Labels: "[]", + Metadata: map[string]interface{}{}, IsEphemeral: ephemeral, + } + if err := invokeError(store, "CreateTask", s.Context, task); err != nil { + return nil, err + } + return task, nil + } + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + return createTask(s, id, false) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := taskFactory(s) + if err != nil { + return nil, err + } + return invokeRecord(store, "GetTask", s.Context, id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := taskFactory(s) + if err != nil { + return err + } + value, err := invokeRecord(store, "GetTask", s.Context, id) + if err != nil { + return err + } + task, ok := value.(*taskmodels.Task) + if !ok { + return fmt.Errorf("GetTask returned %T", value) + } + task.Title = "Updated " + id + return invokeError(store, "UpdateTask", s.Context, task) + } + action.assertUpdated = func(_ any, after any, id string) error { + task, ok := after.(*taskmodels.Task) + if !ok || task == nil { + return fmt.Errorf("GetTask returned %T", after) + } + if task.Title != "Updated "+id { + return fmt.Errorf("task title = %q, want %q", task.Title, "Updated "+id) + } + return nil + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := taskFactory(s) + if err != nil { + return err + } + return invokeError(store, "DeleteTask", s.Context, id) + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := taskFactory(s) + if err != nil { + return nil, err + } + // IsEphemeral is immutable after creation. Recreate the valid task through + // the owning repository so both persisted boolean values are exercised. + if err := invokeError(store, "DeleteTask", s.Context, id); err != nil { + return nil, err + } + if _, err := createTask(s, id, enabled); err != nil { + return nil, err + } + return invokeRecord(store, "GetTask", s.Context, id) + } + action.readBoolean = func(record any) (bool, bool) { + task, ok := record.(*taskmodels.Task) + return ok && task.IsEphemeral, ok + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := taskFactory(s) + if err != nil { + return err + } + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return err + } + duplicate := &taskmodels.Task{ID: id, WorkspaceID: workspaceID, Title: "Duplicate", State: v1.TaskStateTODO, Priority: "medium", Labels: "[]", Metadata: map[string]interface{}{}} + if err := invokeError(store, "CreateTask", s.Context, duplicate); err == nil { + return fmt.Errorf("duplicate task succeeded") + } + return nil + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + return transactionAPICheck(action, s, id) + } + return action +} + +func officeAction() apiAction { + action := standardAction(reflectedSpec{ + name: "office_projects", factory: officeFactory, + createMethod: "CreateProject", readMethod: "GetProject", + updateMethod: "UpdateProject", deleteMethod: "DeleteProject", + }) + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, active bool) (any, error) { + store, err := officeFactory(s) + if err != nil { + return nil, err + } + value, err := invokeRecord(store, "GetProject", s.Context, id) + if err != nil { + return nil, err + } + project, ok := value.(*officemodels.Project) + if !ok { + return nil, fmt.Errorf("GetProject returned %T", value) + } + if active { + project.Status = officemodels.ProjectStatusActive + } else { + project.Status = officemodels.ProjectStatusArchived + } + if err := action.update(s, id, project); err != nil { + return nil, err + } + return action.read(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + project, ok := record.(*officemodels.Project) + return ok && project.Status == officemodels.ProjectStatusActive, ok + } + return action +} + +func terminalAction() apiAction { + action := standardAction(reflectedSpec{ + name: "user_terminals", + factory: terminalFactory, + createMethod: "Create", + readMethod: "Get", + updateMethod: "Rename", + deleteMethod: "Delete", + createArgs: func(id string, _ any) []any { + return []any{"conformance-task", "conformance-environment", id, "echo conformance"} + }, + updateArgs: func(id string, _ any) []any { + name := "updated terminal" + return []any{id, &name} + }, + }) + action.assertUpdated = func(_ any, after any, _ string) error { + name, ok := recordStringField(after, "CustomName") + if !ok || name != "updated terminal" { + return fmt.Errorf("terminal custom name = %q, want %q", name, "updated terminal") + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, open bool) (any, error) { + state := "parked" + if open { + state = "open" + } + store, err := terminalFactory(s) + if err != nil { + return nil, err + } + if _, err := invoke(store, "SetState", s.Context, id, state); err != nil { + return nil, err + } + return action.read(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer && !value.IsNil() { + value = value.Elem() + } + field := value.FieldByName("State") + if !field.IsValid() || field.Kind() != reflect.String { + return false, false + } + return field.String() == "open", true + } + return action +} + +func quickTerminalAction() apiAction { + action := standardAction(reflectedSpec{ + name: "quick_terminal_tabs", + factory: quickTerminalFactory, + createMethod: "Create", + readMethod: "Get", + updateMethod: "UpdateLifecycle", + deleteMethod: "Delete", + createArgs: func(id string, _ any) []any { + return []any{defaultUserID, "conformance-workspace", id} + }, + readArgs: func(id string) []any { + return []any{defaultUserID, id} + }, + updateArgs: func(id string, _ any) []any { + return []any{defaultUserID, id, "conformance-session", "closed", nil, ""} + }, + deleteArgs: func(id string) []any { + return []any{defaultUserID, id} + }, + }) + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := quickTerminalFactory(s) + if err != nil { + return err + } + if _, err := invoke(store, "Create", s.Context, "another-user", "conformance-workspace", id); err == nil { + return fmt.Errorf("duplicate quick terminal tab succeeded") + } + return nil + } + return action +} + +type runtimeFlagRecord struct { + ID string + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time +} + +//nolint:funlen // This adapter enumerates every runtime-flag API assertion. +func runtimeFlagsAction() apiAction { + action := apiAction{name: "runtime_flag_overrides", key: recordIdentifier} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := runtimeFlagsFactory(s) + if err != nil { + return nil, err + } + if err := store.(*runtimeflags.SQLiteStore).SetOverride(s.Context, id, false); err != nil { + return nil, err + } + override, err := store.(*runtimeflags.SQLiteStore).GetOverride(s.Context, id) + if err != nil { + return nil, err + } + return &runtimeFlagRecord{ID: override.Key, Enabled: override.Value, CreatedAt: override.CreatedAt, UpdatedAt: override.UpdatedAt}, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := runtimeFlagsFactory(s) + if err != nil { + return nil, err + } + override, err := store.(*runtimeflags.SQLiteStore).GetOverride(s.Context, id) + if err != nil { + return nil, err + } + return &runtimeFlagRecord{ID: override.Key, Enabled: override.Value, CreatedAt: override.CreatedAt, UpdatedAt: override.UpdatedAt}, nil + } + action.update = func(s testconformance.ScenarioContext, id string, record any) error { + enabled, _ := recordBoolean(record, nil) + return runtimeFlagsFactorySet(s, id, !enabled) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := runtimeFlagsFactory(s) + if err != nil { + return err + } + return store.(*runtimeflags.SQLiteStore).DeleteOverride(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := runtimeFlagsFactory(s) + if err != nil { + return err + } + overrides, err := store.(*runtimeflags.SQLiteStore).ListOverrides(s.Context) + if err != nil { + return err + } + if _, ok := overrides[id]; ok { + return fmt.Errorf("runtime flag %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + if err := runtimeFlagsFactorySet(s, id, enabled); err != nil { + return nil, err + } + return action.read(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*runtimeFlagRecord) + if !ok { + return false, false + } + return value.Enabled, true + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + if err := runtimeFlagsFactorySet(s, id, true); err != nil { + return err + } + read, err := action.read(s, id) + if err != nil { + return err + } + if enabled, _ := action.readBoolean(read); !enabled { + return fmt.Errorf("runtime flag upsert did not resolve conflict") + } + return nil + } + return action +} + +func runtimeFlagsFactorySet(s testconformance.ScenarioContext, id string, enabled bool) error { + store, err := runtimeFlagsFactory(s) + if err != nil { + return err + } + return store.(*runtimeflags.SQLiteStore).SetOverride(s.Context, id, enabled) +} + +func authIdentityAction() apiAction { + action := apiAction{name: "auth_identities"} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := authFactory(s) + if err != nil { + return nil, err + } + identity := &authstore.LoginIdentity{ + ID: id, UserID: defaultUserID, Provider: authstore.ProviderLocal, + Subject: id, PasswordHash: "initial-hash", + } + if err := store.(*authstore.Store).CreateIdentity(s.Context, identity); err != nil { + return nil, err + } + return identity, nil + } + action.read = func(s testconformance.ScenarioContext, _ string) (any, error) { + store, err := authFactory(s) + if err != nil { + return nil, err + } + return store.(*authstore.Store).GetLocalIdentity(s.Context, defaultUserID) + } + action.update = func(s testconformance.ScenarioContext, _ string, _ any) error { + store, err := authFactory(s) + if err != nil { + return err + } + return store.(*authstore.Store).UpdatePasswordHash(s.Context, defaultUserID, "updated-hash") + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := authFactory(s) + if err != nil { + return err + } + return store.(*authstore.Store).DeleteIdentity(s.Context, id) + } + action.setBoolean = func(s testconformance.ScenarioContext, _ string, _ any, enabled bool) (any, error) { + store, err := authFactory(s) + if err != nil { + return nil, err + } + hash := "" + if enabled { + hash = "enabled-hash" + } + if err := store.(*authstore.Store).UpdatePasswordHash(s.Context, defaultUserID, hash); err != nil { + return nil, err + } + return action.read(s, "") + } + action.readBoolean = func(record any) (bool, bool) { + identity, ok := record.(*authstore.LoginIdentity) + if !ok { + return false, false + } + return identity.PasswordHash != "", true + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := authFactory(s) + if err != nil { + return err + } + duplicate := &authstore.LoginIdentity{ID: id, UserID: defaultUserID, Provider: authstore.ProviderLocal, Subject: id, PasswordHash: "duplicate-hash"} + if err := store.(*authstore.Store).CreateIdentity(s.Context, duplicate); err == nil { + return fmt.Errorf("duplicate auth identity succeeded") + } + return nil + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + return transactionAPICheck(action, s, id) + } + return action +} + +func secretAction() apiAction { + action := apiAction{name: "secrets"} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := secretFactory(s) + if err != nil { + return nil, err + } + secret := &secrets.SecretWithValue{ + Secret: secrets.Secret{ID: id, Name: "secret-" + id, Scope: secrets.ScopeGlobal}, + Value: "conformance-secret-value", + } + if err := store.(secrets.SecretStore).Create(s.Context, secret); err != nil { + return nil, err + } + return secret, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := secretFactory(s) + if err != nil { + return nil, err + } + return store.(secrets.SecretStore).Get(s.Context, id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := secretFactory(s) + if err != nil { + return err + } + name := "updated-secret-" + id + return store.(secrets.SecretStore).Update(s.Context, id, &secrets.UpdateSecretRequest{Name: &name}) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := secretFactory(s) + if err != nil { + return err + } + return store.(secrets.SecretStore).Delete(s.Context, id) + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := secretFactory(s) + if err != nil { + return err + } + duplicate := &secrets.SecretWithValue{ + Secret: secrets.Secret{ID: id, Name: "duplicate-" + id, Scope: secrets.ScopeGlobal}, + Value: "duplicate-value", + } + if err := store.(secrets.SecretStore).Create(s.Context, duplicate); err == nil { + return fmt.Errorf("duplicate secret succeeded") + } + return nil + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + return transactionAPICheck(action, s, id) + } + return action +} + +type settingBehaviorRecord struct { + ID string + Value []byte + CreatedAt time.Time + UpdatedAt time.Time +} + +func systemSettingsAction() apiAction { + action := apiAction{name: "settings"} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := systemSettingsFactory(s) + if err != nil { + return nil, err + } + value := []byte("initial-" + id) + if err := store.(*settings.Store).Save(s.Context, id, value); err != nil { + return nil, err + } + entry, found, err := store.(*settings.Store).GetEntry(s.Context, id) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("setting %q was not persisted", id) + } + return &settingBehaviorRecord{ID: entry.Key, Value: entry.Value, CreatedAt: entry.UpdatedAt, UpdatedAt: entry.UpdatedAt}, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := systemSettingsFactory(s) + if err != nil { + return nil, err + } + entry, found, err := store.(*settings.Store).GetEntry(s.Context, id) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("setting %q not found", id) + } + return &settingBehaviorRecord{ID: entry.Key, Value: entry.Value, CreatedAt: entry.UpdatedAt, UpdatedAt: entry.UpdatedAt}, nil + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := systemSettingsFactory(s) + if err != nil { + return err + } + return store.(*settings.Store).Save(s.Context, id, []byte("updated-"+id)) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := systemSettingsFactory(s) + if err != nil { + return err + } + return store.(*settings.Store).Delete(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := systemSettingsFactory(s) + if err != nil { + return err + } + _, found, err := store.(*settings.Store).Get(s.Context, id) + if err != nil { + return err + } + if found { + return fmt.Errorf("setting %q remains after delete", id) + } + return nil + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + return action.update(s, id, nil) + } + return action +} + +func analyticsRepository(s testconformance.ScenarioContext) (any, error) { + // The analytics owner is intentionally read-only. Its table is task-owned, + // so the adapter performs the write lifecycle through the task repository + // and proves the analytics repository can read the resulting task scope. + return analyticsFactory(s) +} + +func analyticsFactory(s testconformance.ScenarioContext) (any, error) { + repo, _, err := analyticsrepository.Provide(s.DB, s.DB) + return repo, err +} + +type hostnameBehaviorRecord struct { + ID string + Hostname string + ResolvedAt time.Time +} + +func hostnameAction() apiAction { + action := apiAction{name: "hostname_cache", key: func(record any, fallback string) string { + if value, ok := record.(*hostnameBehaviorRecord); ok { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := hostnameFactory(s) + if err != nil { + return nil, err + } + at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + if err := store.(*hostnames.Store).Set(s.Context, id, "host-"+id, at); err != nil { + return nil, err + } + return &hostnameBehaviorRecord{ID: id, Hostname: "host-" + id, ResolvedAt: at}, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := hostnameFactory(s) + if err != nil { + return nil, err + } + entry, err := store.(*hostnames.Store).Get(s.Context, id) + if err != nil { + return nil, err + } + if entry.ResolvedAt == nil { + return nil, fmt.Errorf("hostname %q has no resolved timestamp", id) + } + return &hostnameBehaviorRecord{ID: id, Hostname: entry.Hostname, ResolvedAt: entry.ResolvedAt.UTC()}, nil + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := hostnameFactory(s) + if err != nil { + return err + } + return store.(*hostnames.Store).Set(s.Context, id, "updated-"+id, time.Date(2024, 1, 2, 4, 4, 5, 0, time.UTC)) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := hostnameFactory(s) + if err != nil { + return err + } + return store.(*hostnames.Store).Delete(s.Context, id) + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + return action.update(s, id, nil) + } + return action +} + +func organizationAction() apiAction { + action := apiAction{name: "organizations", key: func(record any, fallback string) string { + if value, ok := record.(*org.Org); ok { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := organizationFactory(s) + if err != nil { + return nil, err + } + return store.(*org.Store).Create(s.Context, "Conformance "+id, id, false) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := organizationFactory(s) + if err != nil { + return nil, err + } + return store.(*org.Store).Get(s.Context, id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := organizationFactory(s) + if err != nil { + return err + } + _, err = store.(*org.Store).UpdateNameStatus(s.Context, id, "Updated "+id, string(org.StatusActive)) + return err + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := organizationFactory(s) + if err != nil { + return err + } + return store.(*org.Store).Delete(s.Context, id) + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, active bool) (any, error) { + store, err := organizationFactory(s) + if err != nil { + return nil, err + } + status := string(org.StatusSuspended) + if active { + status = string(org.StatusActive) + } + if _, err := store.(*org.Store).UpdateNameStatus(s.Context, id, "Boolean "+id, status); err != nil { + return nil, err + } + return action.read(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*org.Org) + return ok && value.Active(), ok + } + action.conflict = func(s testconformance.ScenarioContext, id string, record any) error { + store, err := organizationFactory(s) + if err != nil { + return err + } + slug := id + if value, ok := record.(*org.Org); ok { + slug = value.Slug + } + if _, err := store.(*org.Store).Create(s.Context, "Duplicate "+id, slug, false); err == nil { + return fmt.Errorf("duplicate organization slug succeeded") + } + return nil + } + return action +} + +func organizationUnitAction() apiAction { + action := apiAction{name: "organization_units", key: func(record any, fallback string) string { + if value, ok := record.(*orgunit.Unit); ok { + return value.ID + } + return fallback + }} + createOrg := func(s testconformance.ScenarioContext, id string) (*org.Org, error) { + store, err := organizationFactory(s) + if err != nil { + return nil, err + } + return store.(*org.Store).Create(s.Context, "Unit Org "+id, "unit-org-"+id, false) + } + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + organization, err := createOrg(s, id) + if err != nil { + return nil, err + } + store, err := organizationUnitFactory(s) + if err != nil { + return nil, err + } + unit, err := store.(*orgunit.Store).Insert(s.Context, &orgunit.Unit{ + ID: id, OrgID: organization.ID, Kind: orgunit.KindStandard, Name: "Unit " + id, + }) + if err != nil { + return nil, err + } + return unit, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := organizationUnitFactory(s) + if err != nil { + return nil, err + } + return store.(*orgunit.Store).Get(s.Context, id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := organizationUnitFactory(s) + if err != nil { + return err + } + return store.(*orgunit.Store).Rename(s.Context, id, "Updated "+id) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := organizationUnitFactory(s) + if err != nil { + return err + } + return store.(*orgunit.Store).Delete(s.Context, id) + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := organizationUnitFactory(s) + if err != nil { + return err + } + if _, err := store.(*orgunit.Store).Insert(s.Context, &orgunit.Unit{ID: id, OrgID: "duplicate", Kind: orgunit.KindStandard, Name: "Duplicate"}); err == nil { + return fmt.Errorf("duplicate organization unit succeeded") + } + return nil + } + return action +} + +func conformanceWorkspaceID(s testconformance.ScenarioContext) (string, error) { + repo, err := taskFactory(s) + if err != nil { + return "", err + } + outputs, err := invoke(repo, "ListWorkspaces", s.Context) + if err != nil { + return "", err + } + if len(outputs) == 0 { + return "", fmt.Errorf("ListWorkspaces returned no values") + } + value := outputs[0].Interface() + items := reflect.ValueOf(value) + if items.Kind() != reflect.Slice || items.Len() == 0 { + return "", fmt.Errorf("task schema has no workspace") + } + return recordIdentifier(items.Index(0).Interface(), ""), nil +} + +func ensureConformanceTaskAndSession(s testconformance.ScenarioContext, id string) (string, string, error) { + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return "", "", err + } + repo, err := taskFactory(s) + if err != nil { + return "", "", err + } + taskID := id + "-task" + if _, err := invoke(repo, "CreateTask", s.Context, &taskmodels.Task{ + ID: taskID, WorkspaceID: workspaceID, Title: "Conformance task", IsEphemeral: true, + }); err != nil { + return "", "", err + } + sessionID := id + "-session" + now := time.Now().UTC() + if _, err := invoke(repo, "CreateTaskSession", s.Context, &taskmodels.TaskSession{ + ID: sessionID, TaskID: taskID, State: taskmodels.TaskSessionStateCreated, StartedAt: now, UpdatedAt: now, + }); err != nil { + return "", "", err + } + return taskID, sessionID, nil +} + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete message-queue contract. +func messageQueueAction() apiAction { + type queueRecord struct { + ID string + SessionID string + TaskID string + Content string + QueuedAt time.Time + PlanMode bool + } + action := apiAction{name: "queued_messages", key: func(record any, fallback string) string { + if value, ok := record.(*queueRecord); ok { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + taskID, sessionID, err := ensureConformanceTaskAndSession(s, id) + if err != nil { + return nil, err + } + store, err := messageQueueFactory(s) + if err != nil { + return nil, err + } + msg := &messagequeue.QueuedMessage{ID: id, SessionID: sessionID, TaskID: taskID, Content: "initial", Model: "model", QueuedBy: "conformance", Metadata: map[string]interface{}{}} + if err := store.(messagequeue.Repository).Insert(s.Context, msg, 10); err != nil { + return nil, err + } + return &queueRecord{ID: msg.ID, SessionID: msg.SessionID, TaskID: msg.TaskID, Content: msg.Content, QueuedAt: msg.QueuedAt, PlanMode: msg.PlanMode}, nil + } + findQueueMessage := func(s testconformance.ScenarioContext, id string) (*messagequeue.QueuedMessage, error) { + store, err := messageQueueFactory(s) + if err != nil { + return nil, err + } + sessionID := id + "-session" + entries, err := store.(messagequeue.Repository).ListBySession(s.Context, sessionID) + if err != nil { + return nil, err + } + for index := range entries { + if entries[index].ID == id { + return &entries[index], nil + } + } + return nil, fmt.Errorf("queued message %q not found", id) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + msg, err := findQueueMessage(s, id) + if err != nil { + return nil, err + } + return &queueRecord{ID: msg.ID, SessionID: msg.SessionID, TaskID: msg.TaskID, Content: msg.Content, QueuedAt: msg.QueuedAt, PlanMode: msg.PlanMode}, nil + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := messageQueueFactory(s) + if err != nil { + return err + } + return store.(messagequeue.Repository).UpdateContent(s.Context, id+"-session", id, "updated", nil, "conformance") + } + action.assertUpdated = func(_ any, after any, _ string) error { + message, ok := after.(*queueRecord) + if !ok || message == nil || message.Content != "updated" { + return fmt.Errorf("queued message after update = %#v, want content updated", after) + } + return nil + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := messageQueueFactory(s) + if err != nil { + return err + } + return store.(messagequeue.Repository).DeleteByID(s.Context, id+"-session", id) + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := messageQueueFactory(s) + if err != nil { + return nil, err + } + if err := store.(messagequeue.Repository).SetAutoRun(s.Context, id+"-session", enabled); err != nil { + return nil, err + } + return &queueRecord{ID: id, SessionID: id + "-session", PlanMode: enabled}, nil + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*queueRecord) + return value != nil && value.PlanMode, ok + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := messageQueueFactory(s) + if err != nil { + return err + } + msg := &messagequeue.QueuedMessage{ID: id, SessionID: id + "-session", TaskID: id + "-task", Content: "duplicate", QueuedBy: "conformance"} + if err := store.(messagequeue.Repository).Insert(s.Context, msg, 10); err == nil { + return fmt.Errorf("duplicate queued message succeeded") + } + return nil + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + return transactionAPICheck(action, s, id) + } + return action +} + +func shareAction() apiAction { + action := apiAction{name: "task_shares", nullableTimestampUpdates: []string{"RevokedAt"}, key: recordIdentifier} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := shareFactory(s) + if err != nil { + return nil, err + } + value := &share.Share{ID: id, TaskSessionID: id + "-session", Backend: share.BackendGitHubGist, ExternalID: id + "-external", ExternalURL: "https://example.test/share/" + id, SnapshotSizeBytes: 12} + if err := store.(*share.Repository).Create(s.Context, value); err != nil { + return nil, err + } + return value, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := shareFactory(s) + if err != nil { + return nil, err + } + return store.(*share.Repository).GetByID(s.Context, id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := shareFactory(s) + if err != nil { + return err + } + return store.(*share.Repository).MarkRevoked(s.Context, id, time.Now().UTC()) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + return action.update(s, id, nil) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + value, err := action.read(s, id) + if err != nil { + return err + } + if !value.(*share.Share).IsRevoked() { + return fmt.Errorf("share %q is not revoked", id) + } + return nil + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := shareFactory(s) + if err != nil { + return err + } + if err := store.(*share.Repository).Create(s.Context, &share.Share{ID: id, TaskSessionID: id + "-session", Backend: share.BackendGitHubGist, ExternalID: "duplicate", ExternalURL: "https://example.test/duplicate"}); err == nil { + return fmt.Errorf("duplicate share succeeded") + } + return nil + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + return transactionAPICheck(action, s, id) + } + return action +} + +func telemetryAction() apiAction { + contract := telemetrycontract.Registry()[0] + action := apiAction{name: "telemetry_activations", key: func(_ any, _ string) string { return contract.Key }} + action.create = func(s testconformance.ScenarioContext, _ string) (any, error) { + store, err := telemetryFactory(s) + if err != nil { + return nil, err + } + if err := store.(*telemetrycontract.Store).Activate(s.Context); err != nil { + return nil, err + } + return activationRecord(s, contract) + } + action.read = func(s testconformance.ScenarioContext, _ string) (any, error) { + return activationRecord(s, contract) + } + action.update = func(s testconformance.ScenarioContext, _ string, _ any) error { + store, err := telemetryFactory(s) + if err != nil { + return err + } + if err := store.(*telemetrycontract.Store).DeleteActivation(s.Context, contract.Key, contract.Version); err != nil { + return err + } + return store.(*telemetrycontract.Store).Activate(s.Context) + } + action.assertUpdated = func(before, after any, _ string) error { + beforeRecord, beforeOK := before.(*activationBehaviorRecord) + afterRecord, afterOK := after.(*activationBehaviorRecord) + if !beforeOK || !afterOK || beforeRecord == nil || afterRecord == nil { + return fmt.Errorf("activation records have unexpected types: before=%T after=%T", before, after) + } + if !afterRecord.CreatedAt.After(beforeRecord.CreatedAt) { + return fmt.Errorf("activation timestamp = %v, want later than %v", afterRecord.CreatedAt, beforeRecord.CreatedAt) + } + return nil + } + action.delete = func(s testconformance.ScenarioContext, _ string) error { + store, err := telemetryFactory(s) + if err != nil { + return err + } + return store.(*telemetrycontract.Store).DeleteActivation(s.Context, contract.Key, contract.Version) + } + action.assertDeleted = func(s testconformance.ScenarioContext, _ string, _ any) error { + _, err := activationRecord(s, contract) + if err == nil { + return fmt.Errorf("telemetry activation remains after delete") + } + return nil + } + action.conflict = func(s testconformance.ScenarioContext, _ string, _ any) error { + store, err := telemetryFactory(s) + if err != nil { + return err + } + return store.(*telemetrycontract.Store).Activate(s.Context) + } + return action +} + +type activationBehaviorRecord struct { + ID string + CreatedAt time.Time + UpdatedAt time.Time +} + +func activationRecord(s testconformance.ScenarioContext, contract telemetrycontract.Contract) (any, error) { + store, err := telemetryFactory(s) + if err != nil { + return nil, err + } + activation, err := store.(*telemetrycontract.Store).GetActivation(s.Context, contract.Key, contract.Version) + if err != nil { + return nil, err + } + return &activationBehaviorRecord{ID: activation.Key, CreatedAt: activation.ActivatedAt.UTC(), UpdatedAt: activation.ActivatedAt.UTC()}, nil +} + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete delivery contract. +func deliveryAction() apiAction { + type deliveryRecord struct { + ID string + TaskID string + RepositoryID string + CreatedAt time.Time + UpdatedAt time.Time + } + action := apiAction{name: "task_delivery_ledger", key: func(record any, fallback string) string { + if value, ok := record.(*deliveryRecord); ok { + return value.ID + } + return fallback + }} + prepare := func(s testconformance.ScenarioContext, id string) (string, string, string, error) { + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return "", "", "", err + } + repo, err := taskFactory(s) + if err != nil { + return "", "", "", err + } + taskID, repositoryID := id+"-task", id+"-repository" + if _, err := invoke(repo, "CreateTask", s.Context, &taskmodels.Task{ID: taskID, WorkspaceID: workspaceID, Title: "Delivery task", IsEphemeral: true}); err != nil { + return "", "", "", err + } + if _, err := invoke(repo, "CreateRepository", s.Context, &taskmodels.Repository{ID: repositoryID, WorkspaceID: workspaceID, Name: "Delivery repository", SourceType: "local", DefaultBranch: "main"}); err != nil { + return "", "", "", err + } + if _, err := invoke(repo, "CreateTaskRepository", s.Context, &taskmodels.TaskRepository{ID: id + "-link", TaskID: taskID, RepositoryID: repositoryID, BaseBranch: "main"}); err != nil { + return "", "", "", err + } + return workspaceID, taskID, repositoryID, nil + } + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + workspaceID, taskID, repositoryID, err := prepare(s, id) + if err != nil { + return nil, err + } + store, err := deliveryFactory(s) + if err != nil { + return nil, err + } + at := time.Date(2024, 1, 3, 4, 5, 6, 0, time.UTC) + _, err = store.(*delivery.Repository).Upsert(s.Context, delivery.UpsertInput{TaskID: taskID, RepositoryID: repositoryID, WorkspaceID: workspaceID, Classification: delivery.Classification{Outcome: delivery.OutcomeDirectCommit, Basis: delivery.BasisDefaultBranchCommit, Rank: 7}, EvaluatedAt: at}) + if err != nil { + return nil, err + } + row, err := store.(*delivery.Repository).Get(s.Context, taskID, repositoryID) + if err != nil { + return nil, err + } + return &deliveryRecord{ID: id, TaskID: taskID, RepositoryID: repositoryID, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := deliveryFactory(s) + if err != nil { + return nil, err + } + row, err := store.(*delivery.Repository).Get(s.Context, id+"-task", id+"-repository") + if err != nil { + return nil, err + } + return &deliveryRecord{ID: id, TaskID: row.TaskID, RepositoryID: row.RepositoryID, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := deliveryFactory(s) + if err != nil { + return err + } + _, err = store.(*delivery.Repository).Upsert(s.Context, delivery.UpsertInput{TaskID: id + "-task", RepositoryID: id + "-repository", WorkspaceID: "", Classification: delivery.Classification{Outcome: delivery.OutcomePRMerge, Basis: delivery.BasisProviderPRMerged, Rank: 8}, EvaluatedAt: time.Date(2024, 1, 3, 5, 5, 6, 0, time.UTC)}) + return err + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := deliveryFactory(s) + if err != nil { + return err + } + return store.(*delivery.Repository).Delete(s.Context, id+"-task", id+"-repository") + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + return action.update(s, id, nil) + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + store, err := deliveryFactory(s) + if err != nil { + return err + } + repository := store.(*delivery.Repository) + before, err := repository.Get(s.Context, id+"-task", id+"-repository") + if err != nil { + return fmt.Errorf("read before transaction: %w", err) + } + // A failed upsert must leave the existing pair untouched. The invalid + // task reference reaches the repository's atomic INSERT ... ON CONFLICT + // statement and exercises its rollback path. + if _, err := repository.Upsert(s.Context, delivery.UpsertInput{ + TaskID: id + "-missing-task", RepositoryID: id + "-repository", + WorkspaceID: before.WorkspaceID, + Classification: delivery.Classification{Outcome: delivery.OutcomeDirectCommit, Basis: delivery.BasisDefaultBranchCommit, Rank: 9}, + EvaluatedAt: time.Date(2024, 1, 3, 6, 5, 6, 0, time.UTC), + }); err == nil { + return fmt.Errorf("invalid delivery upsert succeeded") + } + retained, err := repository.Get(s.Context, id+"-task", id+"-repository") + if err != nil { + return fmt.Errorf("read after rolled-back upsert: %w", err) + } + if retained.EvidenceRank != before.EvidenceRank || retained.LastEvaluatedAt.UTC() != before.LastEvaluatedAt.UTC() { + return fmt.Errorf("rolled-back upsert changed the committed ledger row") + } + if _, err := repository.Upsert(s.Context, delivery.UpsertInput{ + TaskID: id + "-task", RepositoryID: id + "-repository", WorkspaceID: before.WorkspaceID, + Classification: delivery.Classification{Outcome: delivery.OutcomePRMerge, Basis: delivery.BasisProviderPRMerged, Rank: 9}, + EvaluatedAt: time.Date(2024, 1, 3, 6, 5, 6, 0, time.UTC), + }); err != nil { + return fmt.Errorf("committed delivery upsert: %w", err) + } + committed, err := repository.Get(s.Context, id+"-task", id+"-repository") + if err != nil { + return fmt.Errorf("read committed delivery upsert: %w", err) + } + if committed.EvidenceRank != 9 { + return fmt.Errorf("committed delivery rank = %d, want 9", committed.EvidenceRank) + } + return nil + } + return action +} + +func storageAction() apiAction { + action := apiAction{name: "storage_temp_artifacts", nullableTimestampUpdates: []string{"LastHeartbeatAt"}, key: func(record any, fallback string) string { + if value, ok := record.(*storagepkg.TemporaryArtifact); ok { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := storageFactory(s) + if err != nil { + return nil, err + } + artifact := &storagepkg.TemporaryArtifact{ID: id, Kind: storagepkg.TemporaryArtifactKindImproveBundle, Path: "/tmp/" + id, MarkerToken: id + "-marker", State: storagepkg.TemporaryArtifactStateActive, OwnerPID: 1, Metadata: json.RawMessage(`{}`)} + if err := store.(*storagepkg.Store).CreateTemporaryArtifact(s.Context, artifact); err != nil { + return nil, err + } + return artifact, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := storageFactory(s) + if err != nil { + return nil, err + } + artifact, err := store.(*storagepkg.Store).GetTemporaryArtifact(s.Context, id) + if err != nil { + return nil, err + } + return &artifact, nil + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := storageFactory(s) + if err != nil { + return err + } + return store.(*storagepkg.Store).HeartbeatTemporaryArtifact(s.Context, id, time.Now().UTC()) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := storageFactory(s) + if err != nil { + return err + } + return store.(*storagepkg.Store).TransitionTemporaryArtifact(s.Context, id, storagepkg.TemporaryArtifactStateDeleted, "", time.Now().UTC()) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + value, err := action.read(s, id) + if err != nil { + return err + } + if value.(*storagepkg.TemporaryArtifact).State != storagepkg.TemporaryArtifactStateDeleted { + return fmt.Errorf("temporary artifact %q was not deleted", id) + } + return nil + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := storageFactory(s) + if err != nil { + return err + } + if err := store.(*storagepkg.Store).CreateTemporaryArtifact(s.Context, &storagepkg.TemporaryArtifact{ID: id, Kind: storagepkg.TemporaryArtifactKindImproveBundle, Path: "/tmp/duplicate", MarkerToken: "duplicate", State: storagepkg.TemporaryArtifactStateActive}); err == nil { + return fmt.Errorf("duplicate temporary artifact succeeded") + } + return nil + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + return transactionAPICheck(action, s, id) + } + return action +} + +type pluginStateBehaviorRecord struct { + ID string + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time +} + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete plugin-state contract. +func pluginStateAction() apiAction { + const scope = "instance" + action := apiAction{name: "plugin_state", key: func(record any, fallback string) string { + if value, ok := record.(*pluginStateBehaviorRecord); ok { + return value.ID + } + return fallback + }} + pluginID := func(id string) string { return "plugin-" + id } + readEntry := func(s testconformance.ScenarioContext, id string) (*pluginStateBehaviorRecord, error) { + store, err := pluginStateFactory(s) + if err != nil { + return nil, err + } + value, found, err := store.(*pluginstate.Store).Get(s.Context, pluginID(id), scope, "", "value") + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("plugin state %q not found", id) + } + var enabled bool + if err := json.Unmarshal(value, &enabled); err != nil { + return nil, err + } + entries, err := store.(*pluginstate.Store).List(s.Context, pluginID(id), scope, "") + if err != nil { + return nil, err + } + if len(entries) != 1 { + return nil, fmt.Errorf("plugin state list returned %d entries", len(entries)) + } + return &pluginStateBehaviorRecord{ID: id, Enabled: enabled, CreatedAt: entries[0].UpdatedAt, UpdatedAt: entries[0].UpdatedAt}, nil + } + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := pluginStateFactory(s) + if err != nil { + return nil, err + } + if err := store.(*pluginstate.Store).Set(s.Context, pluginID(id), scope, "", "value", json.RawMessage(`false`)); err != nil { + return nil, err + } + return readEntry(s, id) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + return readEntry(s, id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginStateFactory(s) + if err != nil { + return err + } + return store.(*pluginstate.Store).Set(s.Context, pluginID(id), scope, "", "value", json.RawMessage(`true`)) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := pluginStateFactory(s) + if err != nil { + return err + } + return store.(*pluginstate.Store).Delete(s.Context, pluginID(id), scope, "", "value") + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginStateFactory(s) + if err != nil { + return err + } + _, found, err := store.(*pluginstate.Store).Get(s.Context, pluginID(id), scope, "", "value") + if err != nil { + return err + } + if found { + return fmt.Errorf("plugin state %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := pluginStateFactory(s) + if err != nil { + return nil, err + } + value, err := json.Marshal(enabled) + if err != nil { + return nil, err + } + if err := store.(*pluginstate.Store).Set(s.Context, pluginID(id), scope, "", "value", value); err != nil { + return nil, err + } + return readEntry(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*pluginStateBehaviorRecord) + return value != nil && value.Enabled, ok + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + return action.update(s, id, nil) + } + return action +} + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete plugin-instance-state contract. +func pluginInstanceStateAction() apiAction { + action := apiAction{name: "plugin_instance_state", key: func(record any, fallback string) string { + if value, ok := record.(*pluginStateBehaviorRecord); ok { + return value.ID + } + return fallback + }} + instanceID := func(id string) string { return "instance-" + id } + readEntry := func(s testconformance.ScenarioContext, id string) (*pluginStateBehaviorRecord, error) { + store, err := pluginInstanceStateFactory(s) + if err != nil { + return nil, err + } + entry, found, err := store.(*pluginstate.InstanceStore).Get(s.Context, instanceID(id), "value") + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("plugin instance state %q not found", id) + } + var enabled bool + if err := json.Unmarshal(entry.Value, &enabled); err != nil { + return nil, err + } + return &pluginStateBehaviorRecord{ID: id, Enabled: enabled, CreatedAt: entry.UpdatedAt, UpdatedAt: entry.UpdatedAt}, nil + } + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := pluginInstanceStateFactory(s) + if err != nil { + return nil, err + } + if _, err := store.(*pluginstate.InstanceStore).Set(s.Context, instanceID(id), "value", json.RawMessage(`false`), nil, "conformance"); err != nil { + return nil, err + } + return readEntry(s, id) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { return readEntry(s, id) } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginInstanceStateFactory(s) + if err != nil { + return err + } + _, err = store.(*pluginstate.InstanceStore).Set(s.Context, instanceID(id), "value", json.RawMessage(`true`), nil, "conformance") + return err + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := pluginInstanceStateFactory(s) + if err != nil { + return err + } + _, err = store.(*pluginstate.InstanceStore).Delete(s.Context, instanceID(id), "value", nil, "conformance") + return err + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginInstanceStateFactory(s) + if err != nil { + return err + } + _, found, err := store.(*pluginstate.InstanceStore).Get(s.Context, instanceID(id), "value") + if err != nil { + return err + } + if found { + return fmt.Errorf("plugin instance state %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := pluginInstanceStateFactory(s) + if err != nil { + return nil, err + } + value, err := json.Marshal(enabled) + if err != nil { + return nil, err + } + if _, err := store.(*pluginstate.InstanceStore).Set(s.Context, instanceID(id), "value", value, nil, "conformance"); err != nil { + return nil, err + } + return readEntry(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*pluginStateBehaviorRecord) + return value != nil && value.Enabled, ok + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginInstanceStateFactory(s) + if err != nil { + return err + } + stale := int64(0) + if _, err := store.(*pluginstate.InstanceStore).Set(s.Context, instanceID(id), "value", json.RawMessage(`true`), &stale, "conformance"); err == nil { + return fmt.Errorf("stale plugin instance state write succeeded") + } + return nil + } + return action +} + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete plugin-user-state contract. +func pluginUserStateAction() apiAction { + const scope = "instance" + action := apiAction{name: "plugin_user_state", key: func(record any, fallback string) string { + if value, ok := record.(*pluginStateBehaviorRecord); ok { + return value.ID + } + return fallback + }} + pluginID := func(id string) string { return "user-plugin-" + id } + readEntry := func(s testconformance.ScenarioContext, id string) (*pluginStateBehaviorRecord, error) { + store, err := pluginUserStateFactory(s) + if err != nil { + return nil, err + } + value, updatedAt, found, err := store.(*pluginstate.UserStore).Get(s.Context, pluginID(id), defaultUserID, scope, "", "value") + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("plugin user state %q not found", id) + } + var enabled bool + if err := json.Unmarshal(value, &enabled); err != nil { + return nil, err + } + return &pluginStateBehaviorRecord{ID: id, Enabled: enabled, CreatedAt: updatedAt, UpdatedAt: updatedAt}, nil + } + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := pluginUserStateFactory(s) + if err != nil { + return nil, err + } + if _, err := store.(*pluginstate.UserStore).Set(s.Context, pluginID(id), defaultUserID, scope, "", "value", json.RawMessage(`false`), nil); err != nil { + return nil, err + } + return readEntry(s, id) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { return readEntry(s, id) } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginUserStateFactory(s) + if err != nil { + return err + } + _, err = store.(*pluginstate.UserStore).Set(s.Context, pluginID(id), defaultUserID, scope, "", "value", json.RawMessage(`true`), nil) + return err + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := pluginUserStateFactory(s) + if err != nil { + return err + } + return store.(*pluginstate.UserStore).Delete(s.Context, pluginID(id), defaultUserID, scope, "", "value") + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginUserStateFactory(s) + if err != nil { + return err + } + _, _, found, err := store.(*pluginstate.UserStore).Get(s.Context, pluginID(id), defaultUserID, scope, "", "value") + if err != nil { + return err + } + if found { + return fmt.Errorf("plugin user state %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := pluginUserStateFactory(s) + if err != nil { + return nil, err + } + value, err := json.Marshal(enabled) + if err != nil { + return nil, err + } + if _, err := store.(*pluginstate.UserStore).Set(s.Context, pluginID(id), defaultUserID, scope, "", "value", value, nil); err != nil { + return nil, err + } + return readEntry(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*pluginStateBehaviorRecord) + return value != nil && value.Enabled, ok + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginUserStateFactory(s) + if err != nil { + return err + } + old, _, found, err := store.(*pluginstate.UserStore).Get(s.Context, pluginID(id), defaultUserID, scope, "", "value") + if err != nil || !found { + return fmt.Errorf("read user state for conflict: %w", err) + } + _ = old + stale := time.Unix(0, 0).UTC() + if _, err := store.(*pluginstate.UserStore).Set(s.Context, pluginID(id), defaultUserID, scope, "", "value", json.RawMessage(`true`), &stale); err == nil { + return fmt.Errorf("stale plugin user state write succeeded") + } + return nil + } + return action +} + +func pluginSettingsAction() apiAction { + action := apiAction{name: "plugin_settings", key: func(_ any, fallback string) string { return fallback }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := pluginSettingsFactory(s) + if err != nil { + return nil, err + } + owner := store.(plugins.SettingsStore) + if err := owner.SetAutoUpdateDefault(false); err != nil { + return nil, err + } + return pluginSettingsRecord(owner, id) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := pluginSettingsFactory(s) + if err != nil { + return nil, err + } + return pluginSettingsRecord(store.(plugins.SettingsStore), id) + } + action.update = func(s testconformance.ScenarioContext, _ string, _ any) error { + store, err := pluginSettingsFactory(s) + if err != nil { + return err + } + return store.(plugins.SettingsStore).SetAutoUpdateDefault(true) + } + action.delete = func(s testconformance.ScenarioContext, _ string) error { + store, err := pluginSettingsFactory(s) + if err != nil { + return err + } + return store.(plugins.SettingsStore).Delete() + } + action.assertDeleted = func(s testconformance.ScenarioContext, _ string, _ any) error { + store, err := pluginSettingsFactory(s) + if err != nil { + return err + } + _, found, err := store.(plugins.SettingsStore).Snapshot() + if err != nil { + return err + } + if found { + return fmt.Errorf("plugin settings remains after delete") + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := pluginSettingsFactory(s) + if err != nil { + return nil, err + } + if err := store.(plugins.SettingsStore).SetAutoUpdateDefault(enabled); err != nil { + return nil, err + } + return pluginSettingsRecord(store.(plugins.SettingsStore), id) + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*pluginStateBehaviorRecord) + return value != nil && value.Enabled, ok + } + action.conflict = func(s testconformance.ScenarioContext, _ string, _ any) error { + store, err := pluginSettingsFactory(s) + if err != nil { + return err + } + return store.(plugins.SettingsStore).SetAutoUpdateDefault(false) + } + return action +} + +func pluginSettingsRecord(store plugins.SettingsStore, id string) (any, error) { + snapshot, found, err := store.Snapshot() + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("plugin settings row is absent") + } + return &pluginStateBehaviorRecord{ID: id, Enabled: snapshot.AutoUpdateDefault, CreatedAt: snapshot.UpdatedAt, UpdatedAt: snapshot.UpdatedAt}, nil +} + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete plugin-instance contract. +func pluginInstanceAction() apiAction { + action := apiAction{name: "plugin_instances", key: func(record any, fallback string) string { + if value, ok := record.(*instances.Instance); ok { + return value.ID + } + if value, ok := record.(instances.Instance); ok { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := pluginInstancesFactory(s) + if err != nil { + return nil, err + } + value := instances.Instance{ID: id, PluginID: "plugin-" + id, SourceKind: instances.SourceInstalled, ScopeKind: instances.ScopeInstance, Status: instances.StatusActive} + if err := store.(*instances.Store).Create(s.Context, value); err != nil { + return nil, err + } + read, err := store.(*instances.Store).Get(s.Context, id) + if err != nil { + return nil, err + } + return &read, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := pluginInstancesFactory(s) + if err != nil { + return nil, err + } + read, err := store.(*instances.Store).Get(s.Context, id) + if err != nil { + return nil, err + } + return &read, nil + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginInstancesFactory(s) + if err != nil { + return err + } + return store.(*instances.Store).SetPluginID(s.Context, id, "updated-plugin-"+id) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := pluginInstancesFactory(s) + if err != nil { + return err + } + return store.(*instances.Store).RemoveInstance(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + value, err := action.read(s, id) + if err != nil { + return err + } + if value.(*instances.Instance).Status != instances.StatusRemoved { + return fmt.Errorf("plugin instance %q was not removed", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, active bool) (any, error) { + store, err := pluginInstancesFactory(s) + if err != nil { + return nil, err + } + if active { + err = store.(*instances.Store).Restore(s.Context, id) + } else { + err = store.(*instances.Store).Archive(s.Context, id) + } + if err != nil { + return nil, err + } + return action.read(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*instances.Instance) + return ok && value.Status == instances.StatusActive, ok + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := pluginInstancesFactory(s) + if err != nil { + return err + } + if err := store.(*instances.Store).Create(s.Context, instances.Instance{ID: id, PluginID: "duplicate", SourceKind: instances.SourceInstalled, ScopeKind: instances.ScopeInstance, Status: instances.StatusActive}); err == nil { + return fmt.Errorf("duplicate plugin instance succeeded") + } + return nil + } + return action +} + +func marketplaceAction() apiAction { + action := apiAction{name: "plugin_marketplace_source", key: func(record any, fallback string) string { + if value, ok := record.(*marketplace.SourceRecord); ok { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := marketplaceFactory(s) + if err != nil { + return nil, err + } + return store.(*marketplace.SourceStore).Add("Conformance "+id, "https://example.test/marketplace/"+id) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := marketplaceFactory(s) + if err != nil { + return nil, err + } + return store.(*marketplace.SourceStore).Get(id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := marketplaceFactory(s) + if err != nil { + return err + } + _, err = store.(*marketplace.SourceStore).Update(id, nil, boolPointer(false)) + return err + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := marketplaceFactory(s) + if err != nil { + return err + } + return store.(*marketplace.SourceStore).Delete(id) + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := marketplaceFactory(s) + if err != nil { + return nil, err + } + value, err := store.(*marketplace.SourceStore).Update(id, nil, boolPointer(enabled)) + return value, err + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*marketplace.SourceRecord) + return ok && value.Enabled, ok + } + action.conflict = func(s testconformance.ScenarioContext, id string, record any) error { + store, err := marketplaceFactory(s) + if err != nil { + return err + } + url := "https://example.test/marketplace/" + id + if value, ok := record.(*marketplace.SourceRecord); ok { + url = value.URL + } + if _, err := store.(*marketplace.SourceStore).Add("Duplicate", url); err == nil { + return fmt.Errorf("duplicate marketplace source succeeded") + } + return nil + } + return action +} + +func boolPointer(value bool) *bool { return &value } + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete canvas contract. +func canvasAction() apiAction { + action := apiAction{name: "canvas_lifecycle_metadata", nullableTimestampUpdates: []string{"PromotedAt"}, key: func(record any, fallback string) string { + if value, ok := record.(*canvas.CanvasMetadata); ok { + return value.ID + } + if value, ok := record.(canvas.CanvasMetadata); ok { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := canvasFactory(s) + if err != nil { + return nil, err + } + value := canvas.CanvasMetadata{ID: id, PluginInstanceID: "instance-" + id, WorkspaceID: "workspace-" + id, TaskID: "task-" + id, Title: "Conformance canvas", CreatedBySessionID: "session-" + id} + if err := store.(*canvas.Repository).Create(s.Context, value); err != nil { + return nil, err + } + read, err := store.(*canvas.Repository).Get(s.Context, id) + if err != nil { + return nil, err + } + return &read, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := canvasFactory(s) + if err != nil { + return nil, err + } + read, err := store.(*canvas.Repository).Get(s.Context, id) + if err != nil { + return nil, err + } + return &read, nil + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := canvasFactory(s) + if err != nil { + return err + } + return store.(*canvas.Repository).Promote(s.Context, id, defaultUserID, time.Now().UTC()) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := canvasFactory(s) + if err != nil { + return err + } + return store.(*canvas.Repository).Delete(s.Context, id) + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, promoted bool) (any, error) { + store, err := canvasFactory(s) + if err != nil { + return nil, err + } + if promoted { + err = store.(*canvas.Repository).Promote(s.Context, id, defaultUserID, time.Now().UTC()) + } else { + // New metadata starts task-owned, so the false case is asserted by + // reading that real initial value. Promotion is the owning API's + // reversible boolean transition exposed by this repository. + return action.read(s, id) + } + if err != nil { + return nil, err + } + return action.read(s, id) + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*canvas.CanvasMetadata) + return ok && value.PromotedAt != nil, ok + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := canvasFactory(s) + if err != nil { + return err + } + if err := store.(*canvas.Repository).Create(s.Context, canvas.CanvasMetadata{ID: id, PluginInstanceID: "duplicate", WorkspaceID: "duplicate", TaskID: "duplicate", Title: "Duplicate"}); err == nil { + return fmt.Errorf("duplicate canvas metadata succeeded") + } + return nil + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := canvasFactory(s) + if err != nil { + return err + } + if _, err := store.(*canvas.Repository).Get(s.Context, id); err == nil { + return fmt.Errorf("canvas metadata %q remains after delete", id) + } + return nil + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + return transactionAPICheck(action, s, id) + } + return action +} + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete GitHub store contract. +func githubAction() apiAction { + action := apiAction{name: "github_pr_watches", nullableTimestampUpdates: []string{"LastCheckedAt"}, key: func(record any, fallback string) string { + if value, ok := record.(*github.PRWatch); ok { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := githubFactory(s) + if err != nil { + return nil, err + } + ws, err := conformanceWorkspaceID(s) + if err != nil { + return nil, err + } + taskID, sessionID, err := ensureConformanceTaskAndSession(s, id) + if err != nil { + return nil, err + } + value := &github.PRWatch{ID: id, WorkspaceID: ws, SessionID: sessionID, TaskID: taskID, Owner: "owner", Repo: "repo", PRNumber: 1, Branch: "main", LastCheckStatus: "pending", LastReviewState: "pending"} + if err := store.(*github.Store).CreatePRWatch(s.Context, value); err != nil { + return nil, err + } + return value, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := githubFactory(s) + if err != nil { + return nil, err + } + return store.(*github.Store).GetPRWatch(s.Context, id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := githubFactory(s) + if err != nil { + return err + } + return store.(*github.Store).UpdatePRWatchTimestamps(s.Context, id, time.Now().UTC(), nil, "success", "approved") + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := githubFactory(s) + if err != nil { + return err + } + return store.(*github.Store).DeletePRWatch(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := githubFactory(s) + if err != nil { + return err + } + value, err := store.(*github.Store).GetPRWatch(s.Context, id) + if err != nil { + return err + } + if value != nil { + return fmt.Errorf("GitHub PR watch %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := githubFactory(s) + if err != nil { + return nil, err + } + status := "disabled" + if enabled { + status = "success" + } + if err := store.(*github.Store).UpdatePRWatchTimestamps(s.Context, id, time.Now().UTC(), nil, status, ""); err != nil { + return nil, err + } + read, err := action.read(s, id) + if err != nil { + return nil, err + } + if value, ok := read.(*github.PRWatch); !ok || value == nil { + return nil, fmt.Errorf("read after boolean update returned no GitHub PR watch (status=%q, id=%q)", status, id) + } + return read, nil + } + action.readBoolean = func(record any) (bool, bool) { + value, ok := record.(*github.PRWatch) + if !ok || value == nil { + return false, false + } + return value.LastCheckStatus != "disabled", true + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := githubFactory(s) + if err != nil { + return err + } + duplicate := &github.PRWatch{ID: id, WorkspaceID: "duplicate", SessionID: "duplicate", TaskID: "duplicate", Owner: "owner", Repo: "repo", PRNumber: 2, Branch: "main", LastCheckStatus: "pending", LastReviewState: "pending"} + if err := store.(*github.Store).CreatePRWatch(s.Context, duplicate); err == nil { + return fmt.Errorf("duplicate GitHub PR watch succeeded") + } + return nil + } + return action +} + +func providerConfigKey(record any, fallback string) string { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer { + if value.IsNil() { + return fallback + } + value = value.Elem() + } + if value.IsValid() && value.Kind() == reflect.Struct { + for _, fieldName := range []string{"WorkspaceID", "ID"} { + field := value.FieldByName(fieldName) + if field.IsValid() && field.Kind() == reflect.String && field.String() != "" { + return field.String() + } + } + } + return fallback +} + +func providerBoolean(record any) (bool, bool) { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer { + if value.IsNil() { + return false, false + } + value = value.Elem() + } + if !value.IsValid() || value.Kind() != reflect.Struct { + return false, false + } + for _, fieldName := range []string{"LastOK", "LastOk", "Enabled"} { + field := value.FieldByName(fieldName) + if field.IsValid() && field.Kind() == reflect.Bool { + return field.Bool(), true + } + } + return false, false +} + +func requireProviderConfig[T any](cfg *T, owner string) (*T, error) { + if cfg == nil { + return nil, fmt.Errorf("%s config is absent", owner) + } + return cfg, nil +} + +//nolint:funlen // This adapter enumerates every GitLab store assertion. +func gitlabAction() apiAction { + action := apiAction{name: "gitlab_configs", key: providerConfigKey} + workspace := conformanceWorkspaceID + action.create = func(s testconformance.ScenarioContext, _ string) (any, error) { + store, err := gitlabFactory(s) + if err != nil { + return nil, err + } + workspaceID, err := workspace(s) + if err != nil { + return nil, err + } + cfg := &gitlab.GitLabConfig{WorkspaceID: workspaceID, Host: "https://gitlab.example.test", AuthMethod: "pat", Username: "conformance"} + if err := store.(*gitlab.Store).UpsertConfigForWorkspace(s.Context, workspaceID, cfg); err != nil { + return nil, err + } + return gitlabConfigRead(s, store.(*gitlab.Store), workspaceID) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := gitlabFactory(s) + if err != nil { + return nil, err + } + return gitlabConfigRead(s, store.(*gitlab.Store), id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := gitlabFactory(s) + if err != nil { + return err + } + cfg, err := gitlabConfigRead(s, store.(*gitlab.Store), id) + if err != nil { + return err + } + cfg.Host = "https://updated.gitlab.example.test" + return store.(*gitlab.Store).UpsertConfigForWorkspace(s.Context, id, cfg) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := gitlabFactory(s) + if err != nil { + return err + } + return store.(*gitlab.Store).DeleteConfigForWorkspace(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := gitlabFactory(s) + if err != nil { + return err + } + cfg, err := store.(*gitlab.Store).GetConfigForWorkspace(s.Context, id) + if err != nil { + return err + } + if cfg != nil { + return fmt.Errorf("gitlab config %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := gitlabFactory(s) + if err != nil { + return nil, err + } + cfg, err := gitlabConfigRead(s, store.(*gitlab.Store), id) + if err != nil { + return nil, err + } + updated, err := store.(*gitlab.Store).UpdateConfigHealthForRevision(s.Context, id, cfg.Username, enabled, "", time.Now().UTC(), cfg.Revision) + if err != nil { + return nil, err + } + if !updated { + return nil, fmt.Errorf("GitLab health update did not affect config %q", id) + } + return gitlabConfigRead(s, store.(*gitlab.Store), id) + } + action.readBoolean = providerBoolean + action.conflict = action.update + return action +} + +func gitlabConfigRead(s testconformance.ScenarioContext, store *gitlab.Store, workspaceID string) (*gitlab.GitLabConfig, error) { + cfg, err := store.GetConfigForWorkspace(s.Context, workspaceID) + if err != nil { + return nil, err + } + return requireProviderConfig(cfg, "GitLab") +} + +//nolint:funlen // This adapter enumerates every Jira store assertion. +func jiraAction() apiAction { + action := apiAction{name: "jira_configs", key: providerConfigKey} + action.create = func(s testconformance.ScenarioContext, _ string) (any, error) { + store, err := jiraFactory(s) + if err != nil { + return nil, err + } + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return nil, err + } + cfg := &jira.JiraConfig{WorkspaceID: workspaceID, SiteURL: "https://jira.example.test", Email: "conformance@example.test", AuthMethod: jira.AuthMethodAPIToken, InstanceType: jira.InstanceTypeCloud, DefaultProjectKey: "CONF"} + if err := store.(*jira.Store).UpsertConfigForWorkspace(s.Context, workspaceID, cfg); err != nil { + return nil, err + } + return jiraConfigRead(s, store.(*jira.Store), workspaceID) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := jiraFactory(s) + if err != nil { + return nil, err + } + return jiraConfigRead(s, store.(*jira.Store), id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := jiraFactory(s) + if err != nil { + return err + } + cfg, err := jiraConfigRead(s, store.(*jira.Store), id) + if err != nil { + return err + } + cfg.SiteURL = "https://updated.jira.example.test" + return store.(*jira.Store).UpsertConfigForWorkspace(s.Context, id, cfg) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := jiraFactory(s) + if err != nil { + return err + } + return store.(*jira.Store).DeleteConfigForWorkspace(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := jiraFactory(s) + if err != nil { + return err + } + cfg, err := store.(*jira.Store).GetConfigForWorkspace(s.Context, id) + if err != nil { + return err + } + if cfg != nil { + return fmt.Errorf("jira config %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := jiraFactory(s) + if err != nil { + return nil, err + } + if err := store.(*jira.Store).UpdateAuthHealthForWorkspace(s.Context, id, enabled, "", time.Now().UTC()); err != nil { + return nil, err + } + return jiraConfigRead(s, store.(*jira.Store), id) + } + action.readBoolean = providerBoolean + action.conflict = action.update + return action +} + +func jiraConfigRead(s testconformance.ScenarioContext, store *jira.Store, workspaceID string) (*jira.JiraConfig, error) { + cfg, err := store.GetConfigForWorkspace(s.Context, workspaceID) + if err != nil { + return nil, err + } + return requireProviderConfig(cfg, "Jira") +} + +//nolint:funlen // This adapter enumerates every Linear store assertion. +func linearAction() apiAction { + action := apiAction{name: "linear_configs", key: providerConfigKey} + action.create = func(s testconformance.ScenarioContext, _ string) (any, error) { + store, err := linearFactory(s) + if err != nil { + return nil, err + } + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return nil, err + } + cfg := &linear.LinearConfig{WorkspaceID: workspaceID, AuthMethod: linear.AuthMethodAPIKey, DefaultTeamKey: "CONF"} + if err := store.(*linear.Store).UpsertConfigForWorkspace(s.Context, workspaceID, cfg); err != nil { + return nil, err + } + return linearConfigRead(s, store.(*linear.Store), workspaceID) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := linearFactory(s) + if err != nil { + return nil, err + } + return linearConfigRead(s, store.(*linear.Store), id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := linearFactory(s) + if err != nil { + return err + } + cfg, err := linearConfigRead(s, store.(*linear.Store), id) + if err != nil { + return err + } + cfg.DefaultTeamKey = "UPDATED" + return store.(*linear.Store).UpsertConfigForWorkspace(s.Context, id, cfg) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := linearFactory(s) + if err != nil { + return err + } + return store.(*linear.Store).DeleteConfigForWorkspace(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := linearFactory(s) + if err != nil { + return err + } + cfg, err := store.(*linear.Store).GetConfigForWorkspace(s.Context, id) + if err != nil { + return err + } + if cfg != nil { + return fmt.Errorf("linear config %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := linearFactory(s) + if err != nil { + return nil, err + } + if err := store.(*linear.Store).UpdateAuthHealthForWorkspace(s.Context, id, enabled, "", "", time.Now().UTC()); err != nil { + return nil, err + } + return linearConfigRead(s, store.(*linear.Store), id) + } + action.readBoolean = providerBoolean + action.conflict = action.update + return action +} + +func linearConfigRead(s testconformance.ScenarioContext, store *linear.Store, workspaceID string) (*linear.LinearConfig, error) { + cfg, err := store.GetConfigForWorkspace(s.Context, workspaceID) + if err != nil { + return nil, err + } + return requireProviderConfig(cfg, "Linear") +} + +//nolint:cyclop,gocognit,funlen // This adapter asserts the complete Sentry store contract. +func sentryAction() apiAction { + action := apiAction{name: "sentry_configs", key: func(record any, fallback string) string { + if value, ok := record.(*sentry.SentryConfig); ok && value.ID != "" { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := sentryFactory(s) + if err != nil { + return nil, err + } + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return nil, err + } + cfg := &sentry.SentryConfig{ID: id, WorkspaceID: workspaceID, Name: "Conformance " + id, AuthMethod: sentry.AuthMethodAuthToken, URL: "https://sentry.example.test"} + if err := store.(*sentry.Store).CreateInstance(s.Context, cfg); err != nil { + return nil, err + } + return sentryConfigRead(s, store.(*sentry.Store), id) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := sentryFactory(s) + if err != nil { + return nil, err + } + return sentryConfigRead(s, store.(*sentry.Store), id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := sentryFactory(s) + if err != nil { + return err + } + cfg, err := sentryConfigRead(s, store.(*sentry.Store), id) + if err != nil { + return err + } + cfg.Name = "Updated " + id + return store.(*sentry.Store).UpdateInstance(s.Context, cfg) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := sentryFactory(s) + if err != nil { + return err + } + return store.(*sentry.Store).DeleteInstance(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := sentryFactory(s) + if err != nil { + return err + } + cfg, err := store.(*sentry.Store).GetInstance(s.Context, id) + if err != nil { + return err + } + if cfg != nil { + return fmt.Errorf("sentry config %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := sentryFactory(s) + if err != nil { + return nil, err + } + if err := store.(*sentry.Store).UpdateAuthHealthForInstance(s.Context, id, enabled, "", time.Now().UTC()); err != nil { + return nil, err + } + return sentryConfigRead(s, store.(*sentry.Store), id) + } + action.readBoolean = providerBoolean + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := sentryFactory(s) + if err != nil { + return err + } + if err := store.(*sentry.Store).CreateInstance(s.Context, &sentry.SentryConfig{ID: id, WorkspaceID: "duplicate", Name: "Duplicate", AuthMethod: sentry.AuthMethodAuthToken, URL: "https://duplicate.example.test"}); err == nil { + return fmt.Errorf("duplicate Sentry instance succeeded") + } + return nil + } + return action +} + +func sentryConfigRead(s testconformance.ScenarioContext, store *sentry.Store, id string) (*sentry.SentryConfig, error) { + cfg, err := store.GetInstance(s.Context, id) + if err != nil { + return nil, err + } + return requireProviderConfig(cfg, "Sentry") +} + +//nolint:funlen // This adapter enumerates every Azure DevOps store assertion. +func azureDevOpsAction() apiAction { + action := apiAction{name: "azure_devops_configs", key: providerConfigKey} + action.create = func(s testconformance.ScenarioContext, _ string) (any, error) { + store, err := azureDevOpsFactory(s) + if err != nil { + return nil, err + } + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return nil, err + } + cfg := &azuredevops.Config{WorkspaceID: workspaceID, OrganizationURL: "https://dev.azure.example.test", DefaultProjectID: "project", DefaultProjectName: "Conformance", AuthMethod: azuredevops.AuthMethodPAT} + if err := store.(*azuredevops.Store).UpsertConfig(s.Context, cfg); err != nil { + return nil, err + } + return azureDevOpsConfigRead(s, store.(*azuredevops.Store), workspaceID) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := azureDevOpsFactory(s) + if err != nil { + return nil, err + } + return azureDevOpsConfigRead(s, store.(*azuredevops.Store), id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := azureDevOpsFactory(s) + if err != nil { + return err + } + cfg, err := azureDevOpsConfigRead(s, store.(*azuredevops.Store), id) + if err != nil { + return err + } + cfg.DefaultProjectName = "Updated" + return store.(*azuredevops.Store).UpsertConfig(s.Context, cfg) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := azureDevOpsFactory(s) + if err != nil { + return err + } + return store.(*azuredevops.Store).DeleteConfig(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := azureDevOpsFactory(s) + if err != nil { + return err + } + cfg, err := store.(*azuredevops.Store).GetConfig(s.Context, id) + if err != nil { + return err + } + if cfg != nil { + return fmt.Errorf("azure DevOps config %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := azureDevOpsFactory(s) + if err != nil { + return nil, err + } + if err := store.(*azuredevops.Store).UpdateAuthHealth(s.Context, id, enabled, "", time.Now().UTC()); err != nil { + return nil, err + } + return azureDevOpsConfigRead(s, store.(*azuredevops.Store), id) + } + action.readBoolean = providerBoolean + action.conflict = action.update + return action +} + +func azureDevOpsConfigRead(s testconformance.ScenarioContext, store *azuredevops.Store, workspaceID string) (*azuredevops.Config, error) { + cfg, err := store.GetConfig(s.Context, workspaceID) + if err != nil { + return nil, err + } + return requireProviderConfig(cfg, "Azure DevOps") +} + +//nolint:funlen // This adapter enumerates every workflow-sync store assertion. +func workflowSyncAction() apiAction { + action := apiAction{name: "workflow_sync_configs", key: providerConfigKey} + request := func(owner, repo string, enabled bool) *workflowsync.SetConfigRequest { + return &workflowsync.SetConfigRequest{ + Provider: workflowsync.ProviderGitHub, + RepoOwner: owner, + RepoName: repo, + Branch: workflowsync.DefaultBranch, + Path: workflowsync.DefaultPath, + IntervalSeconds: workflowsync.DefaultIntervalSeconds, + PollEnabled: boolPointer(enabled), + } + } + readConfig := func(s testconformance.ScenarioContext, store *workflowsync.Store, workspaceID string) (*workflowsync.Config, error) { + cfg, err := store.GetConfigForWorkspace(s.Context, workspaceID) + if err != nil { + return nil, err + } + return requireProviderConfig(cfg, "workflow sync") + } + action.create = func(s testconformance.ScenarioContext, _ string) (any, error) { + store, err := workflowSyncFactory(s) + if err != nil { + return nil, err + } + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return nil, err + } + req := request("conformance", "workflow-sync", true) + if err := req.Normalize(); err != nil { + return nil, err + } + cfg, err := store.(*workflowsync.Store).UpsertConfigForWorkspace(s.Context, workspaceID, req) + if err != nil { + return nil, err + } + return requireProviderConfig(cfg, "workflow sync") + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := workflowSyncFactory(s) + if err != nil { + return nil, err + } + return readConfig(s, store.(*workflowsync.Store), id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := workflowSyncFactory(s) + if err != nil { + return err + } + req := request("updated", "workflow-sync", true) + if err := req.Normalize(); err != nil { + return err + } + _, err = store.(*workflowsync.Store).UpsertConfigForWorkspace(s.Context, id, req) + return err + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := workflowSyncFactory(s) + if err != nil { + return err + } + return store.(*workflowsync.Store).DeleteConfigForWorkspace(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := workflowSyncFactory(s) + if err != nil { + return err + } + cfg, err := store.(*workflowsync.Store).GetConfigForWorkspace(s.Context, id) + if err != nil { + return err + } + if cfg != nil { + return fmt.Errorf("workflow sync config %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := workflowSyncFactory(s) + if err != nil { + return nil, err + } + if err := store.(*workflowsync.Store).RecordSyncStatus(s.Context, id, enabled, "", nil, "hash", time.Now().UTC()); err != nil { + return nil, err + } + return readConfig(s, store.(*workflowsync.Store), id) + } + action.readBoolean = providerBoolean + action.conflict = action.update + return action +} + +//nolint:funlen // This adapter enumerates every office-config-sync assertion. +func officeConfigSyncAction() apiAction { + action := apiAction{name: "office_config_sync_configs", key: providerConfigKey} + path := "" + request := func(owner, repo string, enabled bool) *configsync.SetConfigRequest { + return &configsync.SetConfigRequest{ + Provider: configsync.ProviderGitHub, + RepoOwner: owner, + RepoName: repo, + Branch: configsync.DefaultBranch, + Path: &path, + IntervalSeconds: configsync.DefaultIntervalSeconds, + PollEnabled: boolPointer(enabled), + } + } + readConfig := func(s testconformance.ScenarioContext, store *configsync.Store, workspaceID string) (*configsync.Config, error) { + cfg, err := store.GetConfigForWorkspace(s.Context, workspaceID) + if err != nil { + return nil, err + } + return requireProviderConfig(cfg, "Office config sync") + } + action.create = func(s testconformance.ScenarioContext, _ string) (any, error) { + store, err := officeConfigSyncFactory(s) + if err != nil { + return nil, err + } + workspaceID := "conformance-office-workspace" + req := request("conformance", "office-sync", true) + if err := req.Normalize(); err != nil { + return nil, err + } + cfg, err := store.(*configsync.Store).UpsertConfigForWorkspace(s.Context, workspaceID, req) + if err != nil { + return nil, err + } + return requireProviderConfig(cfg, "Office config sync") + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := officeConfigSyncFactory(s) + if err != nil { + return nil, err + } + return readConfig(s, store.(*configsync.Store), id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := officeConfigSyncFactory(s) + if err != nil { + return err + } + req := request("updated", "office-sync", true) + if err := req.Normalize(); err != nil { + return err + } + _, err = store.(*configsync.Store).UpsertConfigForWorkspace(s.Context, id, req) + return err + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := officeConfigSyncFactory(s) + if err != nil { + return err + } + return store.(*configsync.Store).DeleteConfigForWorkspace(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := officeConfigSyncFactory(s) + if err != nil { + return err + } + cfg, err := store.(*configsync.Store).GetConfigForWorkspace(s.Context, id) + if err != nil { + return err + } + if cfg != nil { + return fmt.Errorf("office config sync config %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := officeConfigSyncFactory(s) + if err != nil { + return nil, err + } + if err := store.(*configsync.Store).RecordSyncStatus(s.Context, id, enabled, "", nil, "hash", time.Now().UTC()); err != nil { + return nil, err + } + return readConfig(s, store.(*configsync.Store), id) + } + action.readBoolean = providerBoolean + action.conflict = action.update + return action +} + +//nolint:cyclop,funlen // This adapter asserts the complete automation store contract. +func automationAction() apiAction { + action := apiAction{name: "automations", key: func(record any, fallback string) string { + if value, ok := record.(*automation.Automation); ok && value.ID != "" { + return value.ID + } + return fallback + }} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := automationFactory(s) + if err != nil { + return nil, err + } + workspaceID, err := conformanceWorkspaceID(s) + if err != nil { + return nil, err + } + record := &automation.Automation{ + ID: id, WorkspaceID: workspaceID, Name: "Conformance " + id, + TaskMode: automation.TaskModeAutomationRun, RepositoryMode: automation.RepositoryModeNone, + AgentProfileID: "conformance-agent", ExecutorProfileID: "conformance-executor", + Prompt: "conformance prompt", TaskTitleTemplate: "Conformance task", + MaxConcurrentRuns: 1, ContinuationPolicy: automation.ContinuationPolicyNewTask, + } + if err := store.(*automation.Store).CreateAutomation(s.Context, record); err != nil { + return nil, err + } + return automationRead(s, store.(*automation.Store), id) + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := automationFactory(s) + if err != nil { + return nil, err + } + return automationRead(s, store.(*automation.Store), id) + } + action.update = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := automationFactory(s) + if err != nil { + return err + } + name := "Updated " + id + return store.(*automation.Store).UpdateAutomation(s.Context, id, &automation.UpdateAutomationRequest{Name: &name}) + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := automationFactory(s) + if err != nil { + return err + } + return store.(*automation.Store).DeleteAutomation(s.Context, id) + } + action.assertDeleted = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := automationFactory(s) + if err != nil { + return err + } + value, err := store.(*automation.Store).GetAutomation(s.Context, id) + if err != nil { + return err + } + if value != nil { + return fmt.Errorf("automation %q remains after delete", id) + } + return nil + } + action.setBoolean = func(s testconformance.ScenarioContext, id string, _ any, enabled bool) (any, error) { + store, err := automationFactory(s) + if err != nil { + return nil, err + } + if err := store.(*automation.Store).UpdateAutomation(s.Context, id, &automation.UpdateAutomationRequest{Enabled: boolPointer(enabled)}); err != nil { + return nil, err + } + return automationRead(s, store.(*automation.Store), id) + } + action.readBoolean = providerBoolean + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + store, err := automationFactory(s) + if err != nil { + return err + } + if err := store.(*automation.Store).CreateAutomation(s.Context, &automation.Automation{ID: id, WorkspaceID: "duplicate", Name: "Duplicate", TaskMode: automation.TaskModeAutomationRun, RepositoryMode: automation.RepositoryModeNone, AgentProfileID: "a", ExecutorProfileID: "e", MaxConcurrentRuns: 1, ContinuationPolicy: automation.ContinuationPolicyNewTask}); err == nil { + return fmt.Errorf("duplicate automation succeeded") + } + return nil + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + return transactionAPICheck(action, s, id) + } + return action +} + +func automationRead(s testconformance.ScenarioContext, store *automation.Store, id string) (*automation.Automation, error) { + value, err := store.GetAutomation(s.Context, id) + if err != nil { + return nil, err + } + return requireProviderConfig(value, "automation") +} + +func reflectValue(record any) reflect.Value { + return reflect.ValueOf(record).Elem() +} diff --git a/apps/backend/internal/persistence/storeconformance/scenarios.go b/apps/backend/internal/persistence/storeconformance/scenarios.go new file mode 100644 index 0000000000..fcd24236c3 --- /dev/null +++ b/apps/backend/internal/persistence/storeconformance/scenarios.go @@ -0,0 +1,907 @@ +//nolint:revive // The shared scenario runner keeps all cross-owner assertions together. +package storeconformance + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strings" + "time" + + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/persistence/requiredstores" + taskrepository "github.com/kandev/kandev/internal/task/repository" + testconformance "github.com/kandev/kandev/internal/testutil/storeconformance" + userstore "github.com/kandev/kandev/internal/user/store" + workflowrepository "github.com/kandev/kandev/internal/workflow/repository" + + editorstore "github.com/kandev/kandev/internal/editors/store" + notificationstore "github.com/kandev/kandev/internal/notifications/store" + promptstore "github.com/kandev/kandev/internal/prompts/store" + utilitystore "github.com/kandev/kandev/internal/utility/store" +) + +// apiAction is a behavior probe for one real owner API. The conformance suite +// deliberately keeps the schema initializer and the behavior API separate: +// an adapter must both create the schema and prove that its owning repository +// can persist a valid record in it. +type apiAction struct { + name string + key func(any, string) string + create func(testconformance.ScenarioContext, string) (any, error) + read func(testconformance.ScenarioContext, string) (any, error) + update func(testconformance.ScenarioContext, string, any) error + assertUpdated func(before, after any, id string) error + delete func(testconformance.ScenarioContext, string) error + assertDeleted func(testconformance.ScenarioContext, string, any) error + setBoolean func(testconformance.ScenarioContext, string, any, bool) (any, error) + readBoolean func(any) (bool, bool) + transaction func(testconformance.ScenarioContext, string) error + conflict func(testconformance.ScenarioContext, string, any) error + // nullableTimestampUpdates lists fields that this owner's update API is + // expected to populate or clear. All other nullable timestamps must retain + // their NULL state across the update. + nullableTimestampUpdates []string +} + +type ownerBehavior struct { + actions []apiAction +} + +type storeFactory func(testconformance.ScenarioContext) (any, error) + +// reflectedSpec describes the common repository shape used by the small +// stores: Create(ctx, *Record), Get(ctx, id), Update(ctx, *Record), and +// Delete(ctx, id). Reflection is confined to this test adapter so production +// stores remain strongly typed while every adapter still calls their real API. +type reflectedSpec struct { + name string + factory storeFactory + createMethod string + readMethod string + updateMethod string + deleteMethod string + createArgs func(string, any) []any + readArgs func(string) []any + updateArgs func(string, any) []any + deleteArgs func(string) []any + populate func(any, string) + booleanFields []string + updateMutation func(any, string) + updateAssertion func(before, after any, id string) error +} + +func behaviorScenarios(descriptor requiredstores.Descriptor) testconformance.Scenarios { + behavior, ok := ownerBehaviors[descriptor.ID] + if !ok { + panic(fmt.Sprintf("no real behavior adapter for %q", descriptor.ID)) + } + scenarios := testconformance.Scenarios{CRUD: ownerCRUDScenario(descriptor, behavior)} + for _, capability := range descriptor.Capabilities { + scenarios.Capabilities = append(scenarios.Capabilities, testconformance.CapabilityScenario{ + Capability: capability, + Run: ownerCapabilityScenario(descriptor, behavior, capability), + }) + } + return scenarios +} + +func ownerCRUDScenario(descriptor requiredstores.Descriptor, behavior ownerBehavior) testconformance.Scenario { + return func(s testconformance.ScenarioContext) error { + if len(behavior.actions) == 0 { + return fmt.Errorf("%s has no owning API actions", descriptor.ID) + } + for index, action := range behavior.actions { + if err := exerciseAction(s, action, fmt.Sprintf("%s-%d", descriptor.ID, index)); err != nil { + return fmt.Errorf("%s CRUD (%s): %w", descriptor.ID, action.name, err) + } + } + return nil + } +} + +func ownerCapabilityScenario( + descriptor requiredstores.Descriptor, + behavior ownerBehavior, + capability requiredstores.Capability, +) testconformance.Scenario { + return func(s testconformance.ScenarioContext) error { + ran := false + for index, action := range behavior.actions { + if !supportsCapability(action, capability) { + continue + } + ran = true + if err := exerciseCapability(s, action, capability, fmt.Sprintf("%s-%d", descriptor.ID, index)); err != nil { + return fmt.Errorf("%s %s (%s): %w", descriptor.ID, capability, action.name, err) + } + } + if !ran { + return fmt.Errorf("%s has no owning API action for %s", descriptor.ID, capability) + } + return nil + } +} + +func supportsCapability(action apiAction, capability requiredstores.Capability) bool { + switch capability { + case requiredstores.CapabilityBoolean: + return action.setBoolean != nil && action.readBoolean != nil + case requiredstores.CapabilityTransaction: + return action.transaction != nil + default: + return true + } +} + +// transactionAPICheck proves both sides of the owning repository's write +// boundary. The update must be visible after the repository commits, while a +// duplicate create must fail without removing the already committed record. +// This deliberately calls the owner API instead of issuing a synthetic SQL +// statement against an empty test table. +func transactionAPICheck(action apiAction, s testconformance.ScenarioContext, id string) error { + before, err := action.read(s, id) + if err != nil { + return fmt.Errorf("read before transaction: %w", err) + } + if !recordPresent(before) { + return fmt.Errorf("read before transaction returned no record") + } + if err := action.update(s, id, before); err != nil { + return fmt.Errorf("committed update: %w", err) + } + committed, err := action.read(s, id) + if err != nil { + return fmt.Errorf("read committed update: %w", err) + } + if !recordPresent(committed) { + return fmt.Errorf("committed update cannot be read") + } + if _, err := action.create(s, id); err == nil { + return fmt.Errorf("duplicate create succeeded") + } + retained, err := action.read(s, id) + if err != nil { + return fmt.Errorf("read after rolled-back duplicate: %w", err) + } + if !recordPresent(retained) { + return fmt.Errorf("rolled-back duplicate removed the committed record") + } + return nil +} + +func recordPresent(record any) bool { + if record == nil { + return false + } + value := reflect.ValueOf(record) + switch value.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice: + return !value.IsNil() + default: + return true + } +} + +//nolint:cyclop,gocognit // The scenario verifies the complete CRUD lifecycle. +func exerciseAction(s testconformance.ScenarioContext, action apiAction, suffix string) error { + if action.create == nil || action.read == nil || action.update == nil || action.delete == nil { + return fmt.Errorf("incomplete API action") + } + id := behaviorID(s, action.name+"-"+suffix) + record, err := action.create(s, id) + if err != nil { + return fmt.Errorf("create: %w", err) + } + if record == nil { + return fmt.Errorf("create returned no record") + } + key := id + if action.key != nil { + key = action.key(record, id) + } + read, err := action.read(s, key) + if err != nil { + return fmt.Errorf("read: %w", err) + } + if read == nil { + return fmt.Errorf("read returned no record") + } + if err := action.update(s, key, record); err != nil { + return fmt.Errorf("update: %w", err) + } + updated, err := action.read(s, key) + if err != nil { + return fmt.Errorf("read after update: %w", err) + } else if updated == nil { + return fmt.Errorf("read after update returned no record") + } + if action.assertUpdated != nil { + if err := action.assertUpdated(read, updated, key); err != nil { + return fmt.Errorf("assert update: %w", err) + } + } + if err := action.delete(s, key); err != nil { + return fmt.Errorf("delete: %w", err) + } + if action.assertDeleted != nil { + if err := action.assertDeleted(s, key, record); err != nil { + return fmt.Errorf("assert delete: %w", err) + } + return nil + } + if _, err := action.read(s, key); err == nil { + return fmt.Errorf("read after delete succeeded") + } + return nil +} + +//nolint:cyclop,gocognit,funlen // Capability probes intentionally cover every failure branch. +func exerciseCapability( + s testconformance.ScenarioContext, + action apiAction, + capability requiredstores.Capability, + suffix string, +) error { + id := behaviorID(s, action.name+"-"+string(capability)+"-"+suffix) + record, err := action.create(s, id) + if err != nil { + return fmt.Errorf("create: %w", err) + } + if record == nil { + return fmt.Errorf("create returned no record") + } + key := id + if action.key != nil { + key = action.key(record, id) + } + defer func() { _ = action.delete(s, key) }() + + switch capability { + case requiredstores.CapabilityBoolean: + if action.setBoolean == nil || action.readBoolean == nil { + return fmt.Errorf("action has no boolean API assertion") + } + for _, want := range []bool{false, true} { + record, err = action.setBoolean(s, key, record, want) + if err != nil { + return fmt.Errorf("set %t: %w", want, err) + } + got, ok := action.readBoolean(record) + if !ok { + return fmt.Errorf("boolean value is not exposed by the owning API") + } + if got != want { + return fmt.Errorf("boolean = %t, want %t", got, want) + } + } + case requiredstores.CapabilityTimestamp: + before, err := action.read(s, key) + if err != nil { + return fmt.Errorf("read timestamp record: %w", err) + } + created, updatedAt, ok := recordTimestamps(before) + if !ok { + return fmt.Errorf("timestamp fields are not exposed by the owning API") + } + if created.IsZero() || updatedAt.IsZero() { + return fmt.Errorf("timestamp values are zero: created=%v updated=%v", created, updatedAt) + } + if created.Location() != time.UTC || updatedAt.Location() != time.UTC { + return fmt.Errorf("timestamps are not UTC: created=%s updated=%s", created.Location(), updatedAt.Location()) + } + if updatedAt.Before(created) { + return fmt.Errorf("updated timestamp %v precedes created timestamp %v", updatedAt, created) + } + // Compare the owning API's read before and after a real update. This + // catches stores that expose timestamps but never persist updates. + nullBefore := nilTimestampFields(before) + if err := action.update(s, key, before); err != nil { + return fmt.Errorf("update timestamp record: %w", err) + } + after, err := action.read(s, key) + if err != nil { + return fmt.Errorf("read timestamp record after update: %w", err) + } + createdAfter, updatedAfter, ok := recordTimestamps(after) + if !ok { + return fmt.Errorf("updated record does not expose timestamps") + } + if createdAfter.Location() != time.UTC || updatedAfter.Location() != time.UTC { + return fmt.Errorf("updated timestamps are not UTC: created=%s updated=%s", createdAfter.Location(), updatedAfter.Location()) + } + if updatedAfter.Before(updatedAt) { + return fmt.Errorf("updated timestamp %v regressed from %v", updatedAfter, updatedAt) + } + if got := nilTimestampFields(after); !sameStrings( + withoutFields(nullBefore, action.nullableTimestampUpdates), + withoutFields(got, action.nullableTimestampUpdates), + ) { + return fmt.Errorf("nullable timestamp fields were not preserved on update: before=%v after=%v", nullBefore, got) + } + case requiredstores.CapabilityConflict: + if action.conflict != nil { + if err := action.conflict(s, key, record); err != nil { + return err + } + } else if _, err := action.create(s, id); err == nil { + return fmt.Errorf("duplicate create succeeded") + } + case requiredstores.CapabilityTransaction: + if action.transaction == nil { + return fmt.Errorf("action has no transaction API assertion") + } + if err := action.transaction(s, id); err != nil { + return err + } + default: + return fmt.Errorf("unsupported capability %q", capability) + } + return nil +} + +//nolint:cyclop,gocognit,funlen // Reflection adapters must cover each repository method boundary. +func standardAction(spec reflectedSpec) apiAction { + populate := spec.populate + if populate == nil { + populate = populateRecord + } + defaultUpdate := spec.updateArgs == nil + createArgs := spec.createArgs + if createArgs == nil { + createArgs = func(_ string, record any) []any { return []any{record} } + } + readArgs := spec.readArgs + if readArgs == nil { + readArgs = func(id string) []any { return []any{id} } + } + updateArgs := spec.updateArgs + if updateArgs == nil { + updateArgs = func(_ string, record any) []any { return []any{record} } + } + deleteArgs := spec.deleteArgs + if deleteArgs == nil { + deleteArgs = func(id string) []any { return []any{id} } + } + action := apiAction{name: spec.name, key: recordIdentifier} + action.create = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := spec.factory(s) + if err != nil { + return nil, err + } + record, err := newRecordForMethod(store, spec.createMethod) + if err != nil { + return nil, err + } + populate(record, id) + outputs, err := invoke(store, spec.createMethod, s.Context, createArgs(id, record)...) + if err != nil { + return nil, err + } + for _, output := range outputs { + if output.IsValid() && output.CanInterface() && output.Type() != reflect.TypeOf((*error)(nil)).Elem() { + return output.Interface(), nil + } + } + return record, nil + } + action.read = func(s testconformance.ScenarioContext, id string) (any, error) { + store, err := spec.factory(s) + if err != nil { + return nil, err + } + out, err := invoke(store, spec.readMethod, s.Context, readArgs(id)...) + if err != nil { + return nil, err + } + if len(out) == 0 { + return nil, fmt.Errorf("%s returned no record", spec.readMethod) + } + return out[0].Interface(), nil + } + action.update = func(s testconformance.ScenarioContext, id string, record any) error { + store, err := spec.factory(s) + if err != nil { + return err + } + if spec.updateMutation != nil { + spec.updateMutation(record, id) + } else if defaultUpdate { + markRecordUpdated(record, id) + } + _, err = invoke(store, spec.updateMethod, s.Context, updateArgs(id, record)...) + return err + } + if spec.updateAssertion != nil { + action.assertUpdated = spec.updateAssertion + } else if defaultUpdate { + action.assertUpdated = func(_ any, after any, id string) error { + name, ok := recordStringField(after, "Name") + if !ok || name != "updated-"+id { + return fmt.Errorf("name = %q, want %q", name, "updated-"+id) + } + return nil + } + } + action.delete = func(s testconformance.ScenarioContext, id string) error { + store, err := spec.factory(s) + if err != nil { + return err + } + _, err = invoke(store, spec.deleteMethod, s.Context, deleteArgs(id)...) + return err + } + if len(spec.booleanFields) > 0 { + action.setBoolean = func(s testconformance.ScenarioContext, id string, record any, enabled bool) (any, error) { + if !setRecordBoolean(record, spec.booleanFields, enabled) { + return record, fmt.Errorf("no boolean field in %s", spec.name) + } + store, err := spec.factory(s) + if err != nil { + return record, err + } + if _, err := invoke(store, spec.updateMethod, s.Context, updateArgs(id, record)...); err != nil { + return record, err + } + read, err := action.read(s, id) + return read, err + } + action.readBoolean = func(record any) (bool, bool) { + return recordBoolean(record, spec.booleanFields) + } + } + action.conflict = func(s testconformance.ScenarioContext, id string, _ any) error { + if _, err := action.create(s, id); err == nil { + return fmt.Errorf("duplicate create succeeded") + } + return nil + } + action.transaction = func(s testconformance.ScenarioContext, id string) error { + return transactionAPICheck(action, s, id) + } + return action +} + +func invoke(store any, methodName string, ctx context.Context, args ...any) ([]reflect.Value, error) { + method := reflect.ValueOf(store).MethodByName(methodName) + if !method.IsValid() { + return nil, fmt.Errorf("store %T has no method %s", store, methodName) + } + methodType := method.Type() + if methodType.NumIn() != len(args)+1 { + return nil, fmt.Errorf("%s expects %d arguments after context, got %d", methodName, methodType.NumIn()-1, len(args)) + } + values := make([]reflect.Value, 0, len(args)+1) + values = append(values, reflect.ValueOf(ctx)) + for index, arg := range args { + value, err := reflectArgument(arg, methodType.In(index+1)) + if err != nil { + return nil, fmt.Errorf("%s argument %d: %w", methodName, index, err) + } + values = append(values, value) + } + outputs := method.Call(values) + if len(outputs) > 0 { + errorType := reflect.TypeOf((*error)(nil)).Elem() + last := outputs[len(outputs)-1] + if last.Type().Implements(errorType) && !last.IsNil() { + return nil, last.Interface().(error) + } + } + return outputs, nil +} + +func invokeError(store any, methodName string, ctx context.Context, args ...any) error { + _, err := invoke(store, methodName, ctx, args...) + return err +} + +func invokeRecord(store any, methodName string, ctx context.Context, args ...any) (any, error) { + outputs, err := invoke(store, methodName, ctx, args...) + if err != nil { + return nil, err + } + if len(outputs) == 0 || !outputs[0].IsValid() || !outputs[0].CanInterface() { + return nil, fmt.Errorf("%s returned no record", methodName) + } + return outputs[0].Interface(), nil +} + +func reflectArgument(arg any, target reflect.Type) (reflect.Value, error) { + if arg == nil { + return reflect.Zero(target), nil + } + value := reflect.ValueOf(arg) + if value.Type().AssignableTo(target) { + return value, nil + } + if value.Kind() == reflect.Pointer && target.Kind() == reflect.Struct && value.Elem().IsValid() && value.Elem().Type().AssignableTo(target) { + return value.Elem(), nil + } + if value.Kind() == reflect.Struct && target.Kind() == reflect.Pointer && value.Type().AssignableTo(target.Elem()) { + copy := reflect.New(target.Elem()) + copy.Elem().Set(value) + return copy, nil + } + if value.Type().ConvertibleTo(target) { + return value.Convert(target), nil + } + return reflect.Value{}, fmt.Errorf("%T is not assignable to %s", arg, target) +} + +func newRecordForMethod(store any, methodName string) (any, error) { + method := reflect.ValueOf(store).MethodByName(methodName) + if !method.IsValid() { + return nil, fmt.Errorf("store %T has no method %s", store, methodName) + } + for index := 1; index < method.Type().NumIn(); index++ { + argument := method.Type().In(index) + if argument.Kind() == reflect.Pointer && argument.Elem().Kind() == reflect.Struct { + return reflect.New(argument.Elem()).Interface(), nil + } + if argument.Kind() == reflect.Struct { + return reflect.New(argument).Interface(), nil + } + } + for index := 0; index < method.Type().NumOut(); index++ { + output := method.Type().Out(index) + if output.Kind() == reflect.Pointer && output.Elem().Kind() == reflect.Struct { + return reflect.New(output.Elem()).Interface(), nil + } + if output.Kind() == reflect.Struct { + return reflect.New(output).Interface(), nil + } + } + return nil, fmt.Errorf("%s has no pointer record argument", methodName) +} + +//nolint:funlen // The reflection fallback populates the varied record shapes used by stores. +func populateRecord(record any, id string) { + value := reflect.ValueOf(record) + if value.Kind() != reflect.Pointer || value.IsNil() { + return + } + value = value.Elem() + if value.Kind() != reflect.Struct { + return + } + now := time.Now().UTC() + setStringField(value, "ID", id) + setStringField(value, "Name", "conformance-"+id) + setStringField(value, "Description", "conformance record") + setStringField(value, "Content", "conformance content") + setStringField(value, "Prompt", "conformance prompt") + setStringField(value, "Email", id+"@example.test") + setStringField(value, "DisplayName", "Conformance Record") + setStringField(value, "OwnerID", userstore.DefaultUserID) + setStringField(value, "UserID", userstore.DefaultUserID) + setStringField(value, "Type", "local") + setStringField(value, "Kind", "conformance") + setStringField(value, "Command", "conformance") + setStringField(value, "Scheme", "conformance") + setStringField(value, "Model", "conformance-model") + setStringField(value, "Role", "member") + setStringField(value, "Status", "active") + setStringField(value, "Provider", "local") + setStringField(value, "Backend", "conformance") + setStringField(value, "State", "active") + setStringField(value, "CreatedBy", userstore.DefaultUserID) + setStringField(value, "URL", "https://example.test/"+id) + setStringField(value, "Path", "/"+id+"/") + setTimeField(value, "CreatedAt", now) + setTimeField(value, "UpdatedAt", now) + setTimeField(value, "ExpiresAt", now.Add(time.Hour)) + setTimeField(value, "LastSeenAt", now) + setTimeField(value, "ResolvedAt", now) + setTimeField(value, "OccurredAt", now) + setTimeField(value, "CheckedAt", now) + setStringField(value, "WorkspaceID", "conformance-workspace") + setStringField(value, "OrgID", "conformance-org") + setStringField(value, "AgentID", "conformance-agent") + setStringField(value, "PluginID", "conformance-plugin") + setStringField(value, "SourceKind", "installed") + setStringField(value, "ScopeKind", "instance") + setStringField(value, "ContinuationPolicy", "new_task") + setStringField(value, "TaskMode", "automation_run") + setStringField(value, "RepositoryMode", "none") + setStringField(value, "ValidationStatus", "valid") + setStringField(value, "Config", "{}") + setStringField(value, "Settings", "{}") + setBoolField(value, "Enabled", false) + setBoolField(value, "Active", false) + setBoolField(value, "Builtin", false) + setBoolField(value, "IsDefault", false) + setBoolField(value, "SupportsMCP", false) + setBoolField(value, "AutoApprove", false) +} + +func markRecordUpdated(record any, id string) { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer && !value.IsNil() { + value = value.Elem() + } + if value.IsValid() && value.Kind() == reflect.Struct { + setStringField(value, "Name", "updated-"+id) + } +} + +func setStringField(value reflect.Value, name, text string) { + field := value.FieldByName(name) + if field.IsValid() && field.CanSet() && field.Kind() == reflect.String && text != "" { + field.SetString(text) + } +} + +func setTimeField(value reflect.Value, name string, at time.Time) { + field := value.FieldByName(name) + if field.IsValid() && field.CanSet() && field.Type() == reflect.TypeOf(time.Time{}) { + field.Set(reflect.ValueOf(at)) + } +} + +func setBoolField(value reflect.Value, name string, enabled bool) { + field := value.FieldByName(name) + if field.IsValid() && field.CanSet() && field.Kind() == reflect.Bool { + field.SetBool(enabled) + } +} + +func setRecordBoolean(record any, preferred []string, enabled bool) bool { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer && value.IsNil() { + return false + } + if value.Kind() == reflect.Pointer { + value = value.Elem() + } + if value.Kind() != reflect.Struct { + return false + } + for _, name := range preferred { + field := value.FieldByName(name) + if field.IsValid() && field.CanSet() && field.Kind() == reflect.Bool { + field.SetBool(enabled) + return true + } + } + for index := 0; index < value.NumField(); index++ { + field := value.Field(index) + if field.CanSet() && field.Kind() == reflect.Bool { + field.SetBool(enabled) + return true + } + } + return false +} + +func recordBoolean(record any, preferred []string) (bool, bool) { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer && value.IsNil() { + return false, false + } + if value.Kind() == reflect.Pointer { + value = value.Elem() + } + if value.Kind() != reflect.Struct { + return false, false + } + for _, name := range preferred { + field := value.FieldByName(name) + if field.IsValid() && field.Kind() == reflect.Bool { + return field.Bool(), true + } + } + for index := 0; index < value.NumField(); index++ { + field := value.Field(index) + if field.Kind() == reflect.Bool { + return field.Bool(), true + } + } + return false, false +} + +func recordIdentifier(record any, fallback string) string { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer && !value.IsNil() { + value = value.Elem() + } + if value.IsValid() && value.Kind() == reflect.Struct { + field := value.FieldByName("ID") + if field.IsValid() && field.Kind() == reflect.String && field.String() != "" { + return field.String() + } + } + return fallback +} + +func recordStringField(record any, name string) (string, bool) { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer { + if value.IsNil() { + return "", false + } + value = value.Elem() + } + if !value.IsValid() || value.Kind() != reflect.Struct { + return "", false + } + field := value.FieldByName(name) + if !field.IsValid() || !field.CanInterface() { + return "", false + } + if field.Kind() == reflect.String { + return field.String(), true + } + if field.Kind() == reflect.Pointer && !field.IsNil() && field.Elem().Kind() == reflect.String { + return field.Elem().String(), true + } + return "", false +} + +func recordTimestamps(record any) (time.Time, time.Time, bool) { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer && value.IsNil() { + return time.Time{}, time.Time{}, false + } + if value.Kind() == reflect.Pointer { + value = value.Elem() + } + if value.Kind() != reflect.Struct { + return time.Time{}, time.Time{}, false + } + created, createdOK := firstTimeField(value, "CreatedAt", "ActivatedAt", "QueuedAt", "ResolvedAt", "OccurredAt", "StartedAt", "LastCheckedAt", "UpdatedAt") + updated, updatedOK := firstTimeField(value, "UpdatedAt", "LastCheckedAt", "ResolvedAt", "OccurredAt", "QueuedAt", "ActivatedAt", "CreatedAt") + return created, updated, createdOK && updatedOK +} + +func firstTimeField(value reflect.Value, names ...string) (time.Time, bool) { + for _, name := range names { + if at, ok := timeField(value, name); ok { + return at, true + } + } + return time.Time{}, false +} + +func timeField(value reflect.Value, name string) (time.Time, bool) { + field := value.FieldByName(name) + if !field.IsValid() || !field.CanInterface() { + return time.Time{}, false + } + if field.Type() == reflect.TypeOf(time.Time{}) { + return field.Interface().(time.Time), true + } + if field.Kind() == reflect.Pointer && field.Type().Elem() == reflect.TypeOf(time.Time{}) && !field.IsNil() { + return field.Elem().Interface().(time.Time), true + } + return time.Time{}, false +} + +// nilTimestampFields lists nullable time values that the owning API exposed +// as NULL after creation. Keeping this check at the API boundary catches a +// driver that silently turns SQL NULL into a zero or local timestamp. +func nilTimestampFields(record any) []string { + value := reflect.ValueOf(record) + if value.Kind() == reflect.Pointer { + if value.IsNil() { + return nil + } + value = value.Elem() + } + if !value.IsValid() || value.Kind() != reflect.Struct { + return nil + } + nullTimeType := reflect.TypeOf(sql.NullTime{}) + timeType := reflect.TypeOf(time.Time{}) + var fields []string + for index := 0; index < value.NumField(); index++ { + field := value.Field(index) + fieldType := field.Type() + if fieldType.Kind() == reflect.Pointer && fieldType.Elem() == timeType && field.IsNil() { + fields = append(fields, value.Type().Field(index).Name) + continue + } + if fieldType == nullTimeType && field.FieldByName("Valid").IsValid() && !field.FieldByName("Valid").Bool() { + fields = append(fields, value.Type().Field(index).Name) + } + } + return fields +} + +func sameStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + counts := make(map[string]int, len(left)) + for _, value := range left { + counts[value]++ + } + for _, value := range right { + counts[value]-- + if counts[value] < 0 { + return false + } + } + for _, count := range counts { + if count != 0 { + return false + } + } + return true +} + +func withoutFields(fields, excluded []string) []string { + if len(excluded) == 0 { + return fields + } + set := make(map[string]struct{}, len(excluded)) + for _, field := range excluded { + set[field] = struct{}{} + } + result := make([]string, 0, len(fields)) + for _, field := range fields { + if _, ok := set[field]; !ok { + result = append(result, field) + } + } + return result +} + +func behaviorID(s testconformance.ScenarioContext, suffix string) string { + engine := strings.ReplaceAll(string(s.Engine), "-", "_") + return "conformance-" + engine + "-" + strings.ReplaceAll(suffix, " ", "-") +} + +func taskFactory(s testconformance.ScenarioContext) (any, error) { + repo, _, err := taskrepository.Provide(s.DB, s.DB, logger.Default()) + return repo, err +} + +func workflowFactory(s testconformance.ScenarioContext) (any, error) { + if _, _, err := taskrepository.Provide(s.DB, s.DB, logger.Default()); err != nil { + return nil, err + } + return workflowrepository.NewWithDB(s.DB, s.DB, logger.Default()) +} + +func settingsFactory(s testconformance.ScenarioContext) (any, error) { + repo, _, err := settingsstore.Provide(s.DB, s.DB, logger.Default()) + return repo, err +} + +func userFactory(s testconformance.ScenarioContext) (any, error) { + repo, _, err := userstore.Provide(s.DB, s.DB) + return repo, err +} + +func notificationFactory(s testconformance.ScenarioContext) (any, error) { + repo, _, err := notificationstore.Provide(s.Context, s.DB, s.DB) + return repo, err +} + +func editorFactory(s testconformance.ScenarioContext) (any, error) { + repo, _, err := editorstore.Provide(s.DB, s.DB) + return repo, err +} + +func promptFactory(s testconformance.ScenarioContext) (any, error) { + repo, _, err := promptstore.Provide(s.DB, s.DB) + return repo, err +} + +func utilityFactory(s testconformance.ScenarioContext) (any, error) { + repo, _, err := utilitystore.Provide(s.DB, s.DB) + return repo, err +} + +var ownerBehaviors = buildOwnerBehaviors() + +// Keep the database pool constructor referenced in this package. More complex +// owner adapters below use it; this helper also documents that all stores are +// opened over the same writer/reader boundary as production. +func ownerPool(s testconformance.ScenarioContext) *db.Pool { + return db.NewPool(s.DB, s.DB) +} diff --git a/apps/backend/internal/persistence/storeconformance/suite_test.go b/apps/backend/internal/persistence/storeconformance/suite_test.go new file mode 100644 index 0000000000..6092f630f9 --- /dev/null +++ b/apps/backend/internal/persistence/storeconformance/suite_test.go @@ -0,0 +1,78 @@ +package storeconformance + +import ( + "testing" + + "github.com/kandev/kandev/internal/persistence/requiredstores" + testconformance "github.com/kandev/kandev/internal/testutil/storeconformance" +) + +func TestStoreCatalogCompleteness(t *testing.T) { + if err := testconformance.ValidateAdapters(requiredstores.Catalog(), Adapters()); err != nil { + t.Fatalf("store catalog completeness: %v", err) + } +} + +func TestAdapterIDsMatchExpectedCatalogSet(t *testing.T) { + want := map[string]struct{}{ + "agent-settings": {}, "analytics": {}, "auth": {}, "auth-hostnames": {}, "automation": {}, + "azure-devops": {}, "canvas": {}, "delivery": {}, "editor": {}, + "github": {}, "gitlab": {}, "jira": {}, "linear": {}, "message-queue": {}, + "notification": {}, "office": {}, "office-config-sync": {}, "organization-units": {}, + "organizations": {}, "plugin-instance-state": {}, "plugin-instances": {}, + "plugin-marketplace": {}, "plugin-settings": {}, "plugin-state": {}, + "plugin-user-state": {}, "prompts": {}, "quick-terminal": {}, "runtime-flags": {}, + "schema-meta": {}, "secrets": {}, "sentry": {}, "storage": {}, "system-settings": {}, + "task": {}, "task-share": {}, "telemetry-contract": {}, "terminal": {}, + "user": {}, "utility": {}, "workflow": {}, "workflow-sync": {}, + } + seen := make(map[string]struct{}) + for _, adapter := range Adapters() { + seen[adapter.ID] = struct{}{} + } + if len(seen) != len(want) { + t.Fatalf("adapter set has %d IDs, want exact set of %d", len(seen), len(want)) + } + for id := range want { + if _, ok := seen[id]; !ok { + t.Errorf("adapter set is missing expected ID %q", id) + } + } + for id := range seen { + if _, ok := want[id]; !ok { + t.Errorf("adapter set contains unexpected ID %q", id) + } + } +} + +func TestSchemaInitializerRejectsUnmappedDescriptor(t *testing.T) { + descriptor := requiredstores.Descriptor{ + ID: "unmapped", OwnerPackage: "test/unmapped", RequiredTables: []string{"unmapped"}, + } + if err := schemaInitializerFor(descriptor)(testconformance.ScenarioContext{}); err == nil { + t.Fatal("schemaInitializerFor() error = nil, want unmapped descriptor error") + } +} + +func TestOwnerBehaviorCoverage(t *testing.T) { + for _, descriptor := range requiredstores.Catalog() { + behavior, ok := ownerBehaviors[descriptor.ID] + if !ok { + t.Errorf("store %q has no owning API behavior", descriptor.ID) + continue + } + if len(behavior.actions) == 0 { + t.Errorf("store %q has no owning API actions", descriptor.ID) + } + for _, action := range behavior.actions { + if action.create == nil || action.read == nil || action.update == nil || action.delete == nil { + t.Errorf("store %q action %q is incomplete", descriptor.ID, action.name) + } + } + } +} + +func TestStoreConformance(t *testing.T) { + testconformance.RunHarnessSelfTest(t, testconformance.RunOptions{}) + testconformance.Run(t, requiredstores.Catalog(), Adapters(), testconformance.RunOptions{}) +} diff --git a/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/manifest.json b/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/manifest.json new file mode 100644 index 0000000000..eed07fa838 --- /dev/null +++ b/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/manifest.json @@ -0,0 +1,96 @@ +{ + "tag": "v0.93.0", + "source_commit": "866bd324855fda1f71e13b8863d2c71d4a11237e", + "fixtures": [ + { + "engine": "sqlite3", + "file": "sqlite.sql", + "sha256": "e1b490607af347314fce11bb3352bf91599eab4c651a1521a60142e73a3b2d5d", + "sentinels": { + "fixture.sentinel": "v0.93.0-sqlite-sentinel" + }, + "owner_rows": [ + {"owner": "schema-meta", "table": "kandev_meta", "key_column": "key", "key_value": "fixture.sentinel", "values": {"value": "v0.93.0-sqlite-sentinel"}}, + {"owner": "task", "table": "workspaces", "key_column": "id", "key_value": "fixture-v0930-task-workspace", "values": {"description": "preserved task workspace", "name": "Fixture Task Workspace"}}, + {"owner": "task", "table": "tasks", "key_column": "id", "key_value": "fixture-v0930-task", "values": {"description": "preserved task row", "title": "Fixture Task", "workspace_id": "fixture-v0930-task-workspace"}}, + {"owner": "analytics", "table": "tasks", "key_column": "id", "key_value": "fixture-v0930-analytics-task", "values": {"description": "preserved analytics row", "title": "Fixture Analytics Task", "workspace_id": "fixture-v0930-task-workspace"}}, + {"owner": "workflow", "table": "workflow_templates", "key_column": "id", "key_value": "fixture-v0930-workflow-template", "values": {"description": "preserved workflow template", "name": "Fixture Workflow Template", "steps": "[]"}}, + {"owner": "workflow", "table": "workflow_steps", "key_column": "id", "key_value": "fixture-v0930-workflow-step", "values": {"name": "Fixture Workflow Step", "workflow_id": "fixture-v0930-workflow"}}, + {"owner": "agent-settings", "table": "agents", "key_column": "id", "key_value": "fixture-v0930-agent", "values": {"name": "Fixture Agent"}}, + {"owner": "agent-settings", "table": "agent_profiles", "key_column": "id", "key_value": "fixture-v0930-agent-profile", "values": {"agent_display_name": "Fixture Agent Profile", "agent_id": "fixture-v0930-agent", "name": "fixture-profile"}}, + {"owner": "user", "table": "users", "key_column": "id", "key_value": "fixture-v0930-user", "values": {"display_name": "Fixture User", "email": "fixture-v0930@example.test", "role": "admin", "status": "active"}}, + {"owner": "notification", "table": "notification_providers", "key_column": "id", "key_value": "fixture-v0930-notification-provider", "values": {"enabled": "1", "name": "Fixture Notification Provider", "type": "webhook", "user_id": "fixture-v0930-user"}}, + {"owner": "editor", "table": "editors", "key_column": "id", "key_value": "fixture-v0930-editor", "values": {"command": "fixture-editor", "name": "Fixture Editor", "scheme": "fixture", "type": "fixture-editor"}}, + {"owner": "prompts", "table": "custom_prompts", "key_column": "id", "key_value": "fixture-v0930-prompt", "values": {"content": "preserved prompt content", "name": "Fixture Prompt"}}, + {"owner": "utility", "table": "utility_agents", "key_column": "id", "key_value": "fixture-v0930-utility", "values": {"name": "Fixture Utility", "prompt": "preserved utility prompt"}}, + {"owner": "office", "table": "office_projects", "key_column": "id", "key_value": "fixture-v0930-office-project", "values": {"description": "preserved office project", "name": "Fixture Office Project", "status": "active", "workspace_id": "fixture-v0930-task-workspace"}}, + {"owner": "office", "table": "runs", "key_column": "id", "key_value": "fixture-v0930-run", "values": {"agent_profile_id": "fixture-v0930-agent-profile", "reason": "fixture run", "status": "queued"}}, + {"owner": "terminal", "table": "user_terminals", "key_column": "id", "key_value": "fixture-v0930-terminal", "values": {"custom_name": "Fixture Terminal", "environment_id": "fixture-v0930-environment", "state": "open", "task_id": "fixture-v0930-task"}}, + {"owner": "quick-terminal", "table": "quick_terminal_tabs", "key_column": "tab_id", "key_value": "fixture-v0930-quick-terminal", "values": {"status": "connecting", "user_id": "fixture-v0930-user", "workspace_id": "fixture-v0930-task-workspace"}}, + {"owner": "runtime-flags", "table": "runtime_flag_overrides", "key_column": "key", "key_value": "fixture.v0930.flag", "values": {"value": "1"}}, + {"owner": "auth", "table": "auth_identities", "key_column": "id", "key_value": "fixture-v0930-identity", "values": {"provider": "local", "subject": "fixture-v0930-subject", "user_id": "fixture-v0930-user"}}, + {"owner": "auth", "table": "auth_sessions", "key_column": "id", "key_value": "fixture-v0930-session", "values": {"token_sha256": "fixture-v0930-token-hash", "user_id": "fixture-v0930-user"}}, + {"owner": "secrets", "table": "secrets", "key_column": "id", "key_value": "fixture-v0930-secret", "values": {"name": "Fixture Secret", "scope": "global", "workspace_id": ""}}, + {"owner": "telemetry-contract", "table": "telemetry_activations", "key_column": "contract_key", "key_value": "fixture.v0930.contract", "values": {"contract_version": "7"}} + ] + }, + { + "engine": "pgx", + "file": "postgres.sql", + "sha256": "f4f26d8d67610ef94a2d5cc308392be7a353efdd3e36e8469fb5ed11d774d165", + "sentinels": { + "fixture.sentinel": "v0.93.0-postgres-sentinel" + }, + "owner_rows": [ + {"owner": "schema-meta", "table": "kandev_meta", "key_column": "key", "key_value": "fixture.sentinel", "values": {"value": "v0.93.0-postgres-sentinel"}}, + {"owner": "task", "table": "workspaces", "key_column": "id", "key_value": "fixture-v0930-task-workspace", "values": {"description": "preserved task workspace", "name": "Fixture Task Workspace"}}, + {"owner": "task", "table": "tasks", "key_column": "id", "key_value": "fixture-v0930-task", "values": {"description": "preserved task row", "title": "Fixture Task", "workspace_id": "fixture-v0930-task-workspace"}}, + {"owner": "analytics", "table": "tasks", "key_column": "id", "key_value": "fixture-v0930-analytics-task", "values": {"description": "preserved analytics row", "title": "Fixture Analytics Task", "workspace_id": "fixture-v0930-task-workspace"}}, + {"owner": "workflow", "table": "workflow_templates", "key_column": "id", "key_value": "fixture-v0930-workflow-template", "values": {"description": "preserved workflow template", "name": "Fixture Workflow Template", "steps": "[]"}}, + {"owner": "workflow", "table": "workflow_steps", "key_column": "id", "key_value": "fixture-v0930-workflow-step", "values": {"name": "Fixture Workflow Step", "workflow_id": "fixture-v0930-workflow"}}, + {"owner": "agent-settings", "table": "agents", "key_column": "id", "key_value": "fixture-v0930-agent", "values": {"name": "Fixture Agent"}}, + {"owner": "agent-settings", "table": "agent_profiles", "key_column": "id", "key_value": "fixture-v0930-agent-profile", "values": {"agent_display_name": "Fixture Agent Profile", "agent_id": "fixture-v0930-agent", "name": "fixture-profile"}}, + {"owner": "user", "table": "users", "key_column": "id", "key_value": "fixture-v0930-user", "values": {"display_name": "Fixture User", "email": "fixture-v0930@example.test", "role": "admin", "status": "active"}}, + {"owner": "notification", "table": "notification_providers", "key_column": "id", "key_value": "fixture-v0930-notification-provider", "values": {"enabled": "1", "name": "Fixture Notification Provider", "type": "webhook", "user_id": "fixture-v0930-user"}}, + {"owner": "editor", "table": "editors", "key_column": "id", "key_value": "fixture-v0930-editor", "values": {"command": "fixture-editor", "name": "Fixture Editor", "scheme": "fixture", "type": "fixture-editor"}}, + {"owner": "prompts", "table": "custom_prompts", "key_column": "id", "key_value": "fixture-v0930-prompt", "values": {"content": "preserved prompt content", "name": "Fixture Prompt"}}, + {"owner": "utility", "table": "utility_agents", "key_column": "id", "key_value": "fixture-v0930-utility", "values": {"name": "Fixture Utility", "prompt": "preserved utility prompt"}}, + {"owner": "office", "table": "office_projects", "key_column": "id", "key_value": "fixture-v0930-office-project", "values": {"description": "preserved office project", "name": "Fixture Office Project", "status": "active", "workspace_id": "fixture-v0930-task-workspace"}}, + {"owner": "office", "table": "runs", "key_column": "id", "key_value": "fixture-v0930-run", "values": {"agent_profile_id": "fixture-v0930-agent-profile", "reason": "fixture run", "status": "queued"}}, + {"owner": "terminal", "table": "user_terminals", "key_column": "id", "key_value": "fixture-v0930-terminal", "values": {"custom_name": "Fixture Terminal", "environment_id": "fixture-v0930-environment", "state": "open", "task_id": "fixture-v0930-task"}}, + {"owner": "quick-terminal", "table": "quick_terminal_tabs", "key_column": "tab_id", "key_value": "fixture-v0930-quick-terminal", "values": {"status": "connecting", "user_id": "fixture-v0930-user", "workspace_id": "fixture-v0930-task-workspace"}}, + {"owner": "runtime-flags", "table": "runtime_flag_overrides", "key_column": "key", "key_value": "fixture.v0930.flag", "values": {"value": "1"}}, + {"owner": "auth", "table": "auth_identities", "key_column": "id", "key_value": "fixture-v0930-identity", "values": {"provider": "local", "subject": "fixture-v0930-subject", "user_id": "fixture-v0930-user"}}, + {"owner": "auth", "table": "auth_sessions", "key_column": "id", "key_value": "fixture-v0930-session", "values": {"token_sha256": "fixture-v0930-token-hash", "user_id": "fixture-v0930-user"}}, + {"owner": "secrets", "table": "secrets", "key_column": "id", "key_value": "fixture-v0930-secret", "values": {"name": "Fixture Secret", "scope": "global", "workspace_id": ""}}, + {"owner": "telemetry-contract", "table": "telemetry_activations", "key_column": "contract_key", "key_value": "fixture.v0930.contract", "values": {"contract_version": "7"}} + ] + } + ], + "known_missing_required_stores": [ + "system-settings", + "auth-hostnames", + "organizations", + "organization-units", + "message-queue", + "task-share", + "delivery", + "storage", + "plugin-instances", + "plugin-marketplace", + "plugin-settings", + "plugin-state", + "plugin-instance-state", + "plugin-user-state", + "canvas", + "github", + "gitlab", + "jira", + "linear", + "sentry", + "azure-devops", + "workflow-sync", + "office-config-sync", + "automation" + ] +} diff --git a/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/postgres.sql b/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/postgres.sql new file mode 100644 index 0000000000..5a5e37e676 --- /dev/null +++ b/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/postgres.sql @@ -0,0 +1,3436 @@ + + + + + + + +CREATE TABLE agent_continuation_summaries ( + agent_profile_id text NOT NULL, + scope text NOT NULL, + content text DEFAULT ''::text NOT NULL, + content_tokens integer DEFAULT 0 NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_by_run_id text DEFAULT ''::text NOT NULL +); + + + +CREATE TABLE agent_profile_mcp_configs ( + profile_id text NOT NULL, + enabled integer DEFAULT 0 NOT NULL, + servers_json text DEFAULT '{}'::text NOT NULL, + meta_json text DEFAULT '{}'::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE agent_profiles ( + id text NOT NULL, + agent_id text NOT NULL, + name text NOT NULL, + agent_display_name text NOT NULL, + model text DEFAULT ''::text NOT NULL, + mode text, + migrated_from text, + auto_approve integer DEFAULT 0 NOT NULL, + dangerously_skip_permissions integer DEFAULT 0 NOT NULL, + allow_indexing integer DEFAULT 1 NOT NULL, + cli_passthrough integer DEFAULT 0 NOT NULL, + enabled integer DEFAULT 1 NOT NULL, + user_modified integer DEFAULT 0 NOT NULL, + plan text DEFAULT ''::text, + cli_flags text, + env_vars text DEFAULT '[]'::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + deleted_at timestamp without time zone, + workspace_id text DEFAULT ''::text NOT NULL, + role text DEFAULT ''::text NOT NULL, + icon text DEFAULT ''::text NOT NULL, + reports_to text DEFAULT ''::text NOT NULL, + skill_ids text DEFAULT '[]'::text NOT NULL, + desired_skills text DEFAULT '[]'::text NOT NULL, + custom_prompt text DEFAULT ''::text NOT NULL, + status text DEFAULT 'idle'::text NOT NULL, + pause_reason text DEFAULT ''::text NOT NULL, + last_run_finished_at timestamp without time zone, + max_concurrent_sessions integer DEFAULT 1 NOT NULL, + cooldown_sec integer DEFAULT 0 NOT NULL, + skip_idle_runs integer DEFAULT 0 NOT NULL, + consecutive_failures integer DEFAULT 0 NOT NULL, + failure_threshold integer DEFAULT 3 NOT NULL, + executor_preference text DEFAULT ''::text NOT NULL, + budget_monthly_cents integer DEFAULT 0 NOT NULL, + settings text DEFAULT '{}'::text NOT NULL, + permissions text DEFAULT '{}'::text NOT NULL, + command_prefix text DEFAULT ''::text NOT NULL, + fallback_model text DEFAULT ''::text NOT NULL, + auto_fallback integer DEFAULT 0 NOT NULL, + CONSTRAINT agent_profiles_status_check CHECK ((status = ANY (ARRAY['idle'::text, 'working'::text, 'paused'::text, 'stopped'::text, 'pending_approval'::text]))) +); + + + +CREATE TABLE agent_wakeup_requests ( + id text NOT NULL, + agent_profile_id text NOT NULL, + source text NOT NULL, + reason text DEFAULT ''::text NOT NULL, + payload text DEFAULT '{}'::text NOT NULL, + status text NOT NULL, + coalesced_count integer DEFAULT 1 NOT NULL, + idempotency_key text, + run_id text DEFAULT ''::text NOT NULL, + requested_at timestamp without time zone NOT NULL, + claimed_at timestamp without time zone, + finished_at timestamp without time zone +); + + + +CREATE TABLE agents ( + id text NOT NULL, + name text NOT NULL, + workspace_id text, + supports_mcp integer DEFAULT 0 NOT NULL, + mcp_config_path text DEFAULT ''::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + tui_config text +); + + + +CREATE TABLE auth_api_tokens ( + id text NOT NULL, + user_id text NOT NULL, + name text NOT NULL, + prefix text NOT NULL, + token_sha256 text NOT NULL, + created_at timestamp with time zone NOT NULL, + last_used_at timestamp with time zone, + expires_at timestamp with time zone +); + + + +CREATE TABLE auth_identities ( + id text NOT NULL, + user_id text NOT NULL, + provider text DEFAULT 'local'::text NOT NULL, + subject text DEFAULT ''::text NOT NULL, + password_hash text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + + +CREATE TABLE auth_invites ( + id text NOT NULL, + token_sha256 text NOT NULL, + email text DEFAULT ''::text NOT NULL, + role text DEFAULT 'member'::text NOT NULL, + created_by text NOT NULL, + created_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone NOT NULL, + used_by text, + used_at timestamp with time zone +); + + + +CREATE TABLE auth_sessions ( + id text NOT NULL, + user_id text NOT NULL, + token_sha256 text NOT NULL, + created_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone NOT NULL, + last_seen_at timestamp with time zone NOT NULL, + user_agent text DEFAULT ''::text NOT NULL, + ip text DEFAULT ''::text NOT NULL +); + + + +CREATE TABLE custom_prompts ( + id text NOT NULL, + name text NOT NULL, + content text NOT NULL, + builtin integer DEFAULT 0 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE dynamic_agent_profiles ( + profile_id text NOT NULL, + version integer DEFAULT 1 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE dynamic_agent_routes ( + dynamic_profile_id text NOT NULL, + "position" integer NOT NULL, + execution_profile_id text NOT NULL, + enabled integer DEFAULT 1 NOT NULL, + rules_json text DEFAULT '{}'::text NOT NULL +); + + + +CREATE TABLE dynamic_installation_keys ( + id integer NOT NULL, + key_bytes bytea NOT NULL, + created_at timestamp without time zone NOT NULL, + CONSTRAINT dynamic_installation_keys_id_check CHECK ((id = 1)) +); + + + +CREATE TABLE dynamic_resource_circuits ( + resource_key text NOT NULL, + state text NOT NULL, + until_at timestamp without time zone, + code text DEFAULT ''::text NOT NULL, + probe_until timestamp without time zone, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE dynamic_route_attempts ( + id text NOT NULL, + session_id text NOT NULL, + logical_profile_id text NOT NULL, + execution_profile_id text DEFAULT ''::text NOT NULL, + route_generation bigint NOT NULL, + profile_version bigint NOT NULL, + reason text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE dynamic_route_states ( + session_id text NOT NULL, + logical_profile_id text NOT NULL, + execution_profile_id text DEFAULT ''::text NOT NULL, + route_generation bigint DEFAULT 0 NOT NULL, + profile_version bigint DEFAULT 0 NOT NULL, + state text DEFAULT 'selecting'::text NOT NULL, + continuation_json text DEFAULT ''::text NOT NULL, + policy_state_json text DEFAULT ''::text NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE editors ( + id text NOT NULL, + type text NOT NULL, + name text NOT NULL, + kind text NOT NULL, + command text NOT NULL, + scheme text NOT NULL, + config text, + installed integer DEFAULT 0 NOT NULL, + enabled integer DEFAULT 1 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE environments ( + id text NOT NULL, + name text NOT NULL, + kind text NOT NULL, + is_system integer DEFAULT 0 NOT NULL, + worktree_root text DEFAULT ''::text, + image_tag text DEFAULT ''::text, + dockerfile text DEFAULT ''::text, + build_config text DEFAULT '{}'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + deleted_at timestamp without time zone +); + + + +CREATE TABLE executor_profiles ( + id text NOT NULL, + executor_id text NOT NULL, + name text NOT NULL, + mcp_policy text DEFAULT ''::text, + config text DEFAULT '{}'::text, + prepare_script text DEFAULT ''::text, + cleanup_script text DEFAULT ''::text, + env_vars text DEFAULT '[]'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE executors ( + id text NOT NULL, + name text NOT NULL, + type text NOT NULL, + status text DEFAULT 'active'::text NOT NULL, + is_system integer DEFAULT 0 NOT NULL, + resumable integer DEFAULT 1 NOT NULL, + config text DEFAULT '{}'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + deleted_at timestamp without time zone +); + + + +CREATE TABLE executors_running ( + id text NOT NULL, + session_id text NOT NULL, + task_id text NOT NULL, + execution_profile_id text DEFAULT ''::text NOT NULL, + executor_id text NOT NULL, + runtime text DEFAULT ''::text, + status text DEFAULT 'starting'::text NOT NULL, + resumable integer DEFAULT 0 NOT NULL, + resume_token text DEFAULT ''::text, + agent_execution_id text DEFAULT ''::text, + container_id text DEFAULT ''::text, + agentctl_url text DEFAULT ''::text, + agentctl_port integer DEFAULT 0, + pid integer DEFAULT 0, + local_pid integer DEFAULT 0, + worktree_id text DEFAULT ''::text, + worktree_path text DEFAULT ''::text, + worktree_branch text DEFAULT ''::text, + last_seen_at timestamp without time zone, + error_message text DEFAULT ''::text, + metadata text DEFAULT '{}'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + last_message_uuid text DEFAULT ''::text +); + + + +CREATE TABLE kandev_meta ( + key text NOT NULL, + value text DEFAULT ''::text NOT NULL +); + + + +CREATE TABLE notification_deliveries ( + id text NOT NULL, + user_id text NOT NULL, + provider_id text NOT NULL, + event_type text NOT NULL, + task_session_id text NOT NULL, + occurrence_id text NOT NULL, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE notification_migrations ( + name text NOT NULL, + completed_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE notification_providers ( + id text NOT NULL, + user_id text NOT NULL, + name text NOT NULL, + type text NOT NULL, + config text DEFAULT '{}'::text, + enabled integer DEFAULT 1 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE notification_subscriptions ( + id text NOT NULL, + user_id text NOT NULL, + provider_id text NOT NULL, + event_type text NOT NULL, + enabled integer DEFAULT 1 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_activity_log ( + id text NOT NULL, + workspace_id text NOT NULL, + actor_type text NOT NULL, + actor_id text NOT NULL, + action text NOT NULL, + target_type text DEFAULT ''::text, + target_id text DEFAULT ''::text, + details text DEFAULT '{}'::text, + run_id text DEFAULT ''::text NOT NULL, + session_id text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_agent_instructions ( + id text NOT NULL, + agent_profile_id text NOT NULL, + filename text NOT NULL, + content text DEFAULT ''::text NOT NULL, + is_entry integer DEFAULT 0, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + + +CREATE TABLE office_agent_memory ( + id text NOT NULL, + agent_profile_id text NOT NULL, + layer text NOT NULL, + key text NOT NULL, + content text DEFAULT ''::text, + metadata text DEFAULT '{}'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_agent_runtime ( + agent_id text NOT NULL, + status text DEFAULT 'idle'::text NOT NULL, + pause_reason text DEFAULT ''::text, + last_run_finished_at timestamp without time zone, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + + +CREATE TABLE office_approvals ( + id text NOT NULL, + workspace_id text NOT NULL, + type text NOT NULL, + requested_by_agent_profile_id text DEFAULT ''::text, + status text DEFAULT 'pending'::text NOT NULL, + payload text DEFAULT '{}'::text, + decision_note text DEFAULT ''::text, + decided_by text DEFAULT ''::text, + decided_at timestamp without time zone, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_budget_policies ( + id text NOT NULL, + workspace_id text NOT NULL, + scope_type text NOT NULL, + scope_id text NOT NULL, + limit_subcents integer NOT NULL, + period text NOT NULL, + alert_threshold_pct integer DEFAULT 80, + action_on_exceed text DEFAULT 'notify_only'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_channels ( + id text NOT NULL, + workspace_id text NOT NULL, + agent_profile_id text NOT NULL, + platform text NOT NULL, + config text DEFAULT '{}'::text, + webhook_secret text DEFAULT ''::text NOT NULL, + status text DEFAULT 'active'::text, + task_id text DEFAULT ''::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_cost_events ( + id text NOT NULL, + session_id text DEFAULT ''::text, + task_id text DEFAULT ''::text, + agent_profile_id text DEFAULT ''::text, + project_id text DEFAULT ''::text, + model text DEFAULT ''::text, + provider text DEFAULT ''::text, + tokens_in integer DEFAULT 0, + tokens_cached_in integer DEFAULT 0, + tokens_cached_read integer, + tokens_cached_write integer, + tokens_out integer DEFAULT 0, + cost_subcents integer DEFAULT 0 NOT NULL, + estimated integer DEFAULT 0 NOT NULL, + turn_id text, + usage_event_id text, + cost_source text, + rate_input_per_million integer, + rate_cached_read_per_million integer, + rate_cached_write_per_million integer, + rate_output_per_million integer, + pricing_catalog_version text, + cost_contract_version integer, + occurred_at timestamp without time zone NOT NULL, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_inbox_dismissals ( + user_id text NOT NULL, + item_kind text NOT NULL, + item_id text NOT NULL, + dismissed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + + +CREATE TABLE office_labels ( + id text NOT NULL, + workspace_id text NOT NULL, + name text NOT NULL, + color text DEFAULT '#6b7280'::text NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + + +CREATE TABLE office_onboarding ( + workspace_id text NOT NULL, + completed integer DEFAULT 0 NOT NULL, + ceo_agent_id text DEFAULT ''::text, + first_task_id text DEFAULT ''::text, + completed_at timestamp without time zone, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + + +CREATE TABLE office_projects ( + id text NOT NULL, + workspace_id text NOT NULL, + name text NOT NULL, + description text DEFAULT ''::text, + status text DEFAULT 'active'::text NOT NULL, + lead_agent_profile_id text DEFAULT ''::text, + color text DEFAULT ''::text, + budget_cents integer DEFAULT 0, + repositories text DEFAULT '[]'::text, + executor_config text DEFAULT '{}'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_provider_health ( + workspace_id text NOT NULL, + provider_id text NOT NULL, + scope text NOT NULL, + scope_value text NOT NULL, + state text NOT NULL, + error_code text, + retry_at timestamp without time zone, + backoff_step integer DEFAULT 0 NOT NULL, + last_failure timestamp without time zone, + last_success timestamp without time zone, + raw_excerpt text, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + + +CREATE TABLE office_routine_runs ( + id text NOT NULL, + routine_id text NOT NULL, + trigger_id text DEFAULT ''::text, + source text NOT NULL, + status text DEFAULT 'received'::text NOT NULL, + trigger_payload text DEFAULT '{}'::text, + linked_task_id text DEFAULT ''::text, + coalesced_into_run_id text DEFAULT ''::text, + dispatch_fingerprint text DEFAULT ''::text, + started_at timestamp without time zone, + completed_at timestamp without time zone, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_routine_triggers ( + id text NOT NULL, + routine_id text NOT NULL, + kind text NOT NULL, + cron_expression text DEFAULT ''::text, + timezone text DEFAULT ''::text, + public_id text DEFAULT ''::text, + signing_mode text DEFAULT ''::text, + secret text DEFAULT ''::text, + next_run_at timestamp without time zone, + last_fired_at timestamp without time zone, + enabled integer DEFAULT 1, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_routines ( + id text NOT NULL, + workspace_id text NOT NULL, + name text NOT NULL, + description text DEFAULT ''::text, + task_template text DEFAULT '{}'::text NOT NULL, + assignee_agent_profile_id text DEFAULT ''::text, + status text DEFAULT 'active'::text NOT NULL, + concurrency_policy text DEFAULT 'skip_if_active'::text, + catch_up_policy text DEFAULT 'enqueue_missed_with_cap'::text NOT NULL, + catch_up_max integer DEFAULT 25 NOT NULL, + variables text DEFAULT '{}'::text, + last_run_at timestamp without time zone, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_run_route_attempts ( + run_id text NOT NULL, + seq integer NOT NULL, + execution_profile_id text DEFAULT ''::text NOT NULL, + provider_id text NOT NULL, + model text NOT NULL, + tier text NOT NULL, + tier_source text DEFAULT ''::text NOT NULL, + outcome text NOT NULL, + error_code text, + error_confidence text, + adapter_phase text, + classifier_rule text, + exit_code integer, + raw_excerpt text, + reset_hint timestamp without time zone, + started_at timestamp without time zone NOT NULL, + finished_at timestamp without time zone +); + + + +CREATE TABLE office_run_skills ( + run_id text NOT NULL, + skill_id text NOT NULL, + version text NOT NULL, + content_hash text NOT NULL, + materialized_path text NOT NULL +); + + + +CREATE TABLE office_skills ( + id text NOT NULL, + workspace_id text NOT NULL, + name text NOT NULL, + slug text NOT NULL, + description text DEFAULT ''::text, + source_type text DEFAULT 'inline'::text NOT NULL, + source_locator text DEFAULT ''::text, + content text DEFAULT ''::text, + file_inventory text DEFAULT '[]'::text, + version text DEFAULT ''::text NOT NULL, + content_hash text DEFAULT ''::text NOT NULL, + approval_state text DEFAULT 'approved'::text NOT NULL, + created_by_agent_profile_id text DEFAULT ''::text, + is_system integer DEFAULT 0 NOT NULL, + system_version text DEFAULT ''::text NOT NULL, + default_for_roles text DEFAULT '[]'::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_task_labels ( + task_id text NOT NULL, + label_id text NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + + +CREATE TABLE office_task_tree_hold_members ( + hold_id text NOT NULL, + task_id text NOT NULL, + depth integer NOT NULL, + task_status text DEFAULT ''::text NOT NULL, + skip_reason text DEFAULT ''::text NOT NULL +); + + + +CREATE TABLE office_task_tree_holds ( + id text NOT NULL, + workspace_id text NOT NULL, + root_task_id text NOT NULL, + mode text NOT NULL, + release_policy text DEFAULT '{"strategy":"manual"}'::text NOT NULL, + released_at timestamp without time zone, + released_by text DEFAULT ''::text, + released_reason text DEFAULT ''::text, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE office_workspace_governance ( + workspace_id text NOT NULL, + key text NOT NULL, + value integer DEFAULT 0 NOT NULL +); + + + +CREATE TABLE office_workspace_routing ( + workspace_id text NOT NULL, + enabled integer DEFAULT 0 NOT NULL, + default_tier text DEFAULT 'balanced'::text NOT NULL, + provider_order text DEFAULT '[]'::text NOT NULL, + provider_profiles text DEFAULT '{}'::text NOT NULL, + tier_per_reason text DEFAULT '{}'::text NOT NULL, + role_tiers text DEFAULT '{}'::text NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + + +CREATE TABLE office_workspace_settings ( + workspace_id text NOT NULL, + agent_failure_threshold integer DEFAULT 3 NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + + +CREATE TABLE parent_child_wake_receipts ( + parent_task_id text NOT NULL, + child_set_key text NOT NULL, + delivered_run_id text DEFAULT ''::text NOT NULL, + delivery_operation_id text DEFAULT ''::text NOT NULL, + delivered_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE quick_terminal_tabs ( + tab_id text NOT NULL, + user_id text NOT NULL, + workspace_id text NOT NULL, + session_id text, + sequence integer NOT NULL, + status text DEFAULT 'connecting'::text NOT NULL, + exit_code integer, + error text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + + +CREATE TABLE quick_terminal_workspace_sequences ( + user_id text NOT NULL, + workspace_id text NOT NULL, + next_sequence integer NOT NULL +); + + + +CREATE TABLE repositories ( + id text NOT NULL, + workspace_id text NOT NULL, + name text NOT NULL, + source_type text DEFAULT 'local'::text NOT NULL, + local_path text DEFAULT ''::text, + provider text DEFAULT ''::text, + provider_repo_id text DEFAULT ''::text, + provider_host text DEFAULT ''::text, + provider_scope text DEFAULT ''::text, + provider_owner text DEFAULT ''::text, + provider_name text DEFAULT ''::text, + remote_url text DEFAULT ''::text, + default_branch text DEFAULT ''::text, + worktree_branch_prefix text DEFAULT 'feature/'::text, + worktree_branch_template text DEFAULT 'feature/{title}-{suffix}'::text, + pull_before_worktree integer DEFAULT 1 NOT NULL, + setup_script text DEFAULT ''::text, + cleanup_script text DEFAULT ''::text, + dev_script text DEFAULT ''::text, + copy_files text DEFAULT ''::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + deleted_at timestamp without time zone +); + + + +CREATE TABLE repository_branch_policies ( + id text NOT NULL, + repository_id text NOT NULL, + name text NOT NULL, + description text DEFAULT ''::text NOT NULL, + base_branch text NOT NULL, + branch_template text NOT NULL, + pull_request_target text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE repository_scripts ( + id text NOT NULL, + repository_id text NOT NULL, + name text NOT NULL, + command text NOT NULL, + "position" integer DEFAULT 0, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE repository_secret_bindings ( + repository_id text NOT NULL, + key text NOT NULL, + secret_id text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE repository_set_items ( + id text NOT NULL, + repository_set_id text NOT NULL, + repository_id text NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE repository_sets ( + id text NOT NULL, + workspace_id text NOT NULL, + name text NOT NULL, + description text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE run_events ( + run_id text NOT NULL, + seq integer NOT NULL, + event_type text NOT NULL, + level text DEFAULT 'info'::text NOT NULL, + payload text DEFAULT '{}'::text NOT NULL, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE runs ( + id text NOT NULL, + agent_profile_id text NOT NULL, + reason text NOT NULL, + payload text DEFAULT '{}'::text, + status text DEFAULT 'queued'::text NOT NULL, + coalesced_count integer DEFAULT 1, + idempotency_key text, + context_snapshot text DEFAULT '{}'::text, + capabilities text DEFAULT '{}'::text NOT NULL, + input_snapshot text DEFAULT '{}'::text NOT NULL, + output_summary text DEFAULT ''::text NOT NULL, + failure_reason text DEFAULT ''::text NOT NULL, + session_id text DEFAULT ''::text NOT NULL, + retry_count integer DEFAULT 0, + scheduled_retry_at timestamp without time zone, + error_message text DEFAULT ''::text NOT NULL, + cancel_reason text, + outcome text, + logical_provider_order text, + requested_tier text, + resolved_execution_profile_id text, + resolved_provider_id text, + resolved_model text, + current_route_attempt_seq integer DEFAULT 0 NOT NULL, + routing_blocked_status text, + earliest_retry_at timestamp without time zone, + route_cycle_baseline_seq integer DEFAULT 0 NOT NULL, + result_json text DEFAULT '{}'::text NOT NULL, + assembled_prompt text DEFAULT ''::text NOT NULL, + summary_injected text DEFAULT ''::text NOT NULL, + continuation_scope text DEFAULT ''::text NOT NULL, + requested_at timestamp without time zone NOT NULL, + claimed_at timestamp without time zone, + finished_at timestamp without time zone +); + + + +CREATE TABLE runtime_flag_overrides ( + key text NOT NULL, + value integer NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT runtime_flag_overrides_value_check CHECK ((value = ANY (ARRAY[0, 1]))) +); + + + +CREATE TABLE secrets ( + id text NOT NULL, + name text NOT NULL, + user_id text DEFAULT ''::text NOT NULL, + scope text DEFAULT 'global'::text NOT NULL, + workspace_id text DEFAULT ''::text NOT NULL, + encrypted_value bytea NOT NULL, + nonce bytea NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + + +CREATE TABLE session_file_reviews ( + id text NOT NULL, + session_id text NOT NULL, + file_path text NOT NULL, + reviewed integer DEFAULT 0 NOT NULL, + diff_hash text DEFAULT ''::text NOT NULL, + reviewed_at timestamp without time zone, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE session_step_history ( + id bigint NOT NULL, + session_id text NOT NULL, + from_step_id text, + to_step_id text NOT NULL, + trigger text NOT NULL, + actor_id text, + metadata text, + created_at timestamp without time zone NOT NULL +); + + + +CREATE SEQUENCE session_step_history_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + + +ALTER SEQUENCE session_step_history_id_seq OWNED BY session_step_history.id; + + + +CREATE TABLE task_blockers ( + task_id text NOT NULL, + blocker_task_id text NOT NULL, + created_at timestamp without time zone NOT NULL, + CONSTRAINT task_blockers_check CHECK ((task_id <> blocker_task_id)) +); + + + +CREATE TABLE task_comments ( + id text NOT NULL, + task_id text NOT NULL, + author_type text NOT NULL, + author_id text NOT NULL, + body text NOT NULL, + source text DEFAULT 'user'::text NOT NULL, + reply_channel_id text DEFAULT ''::text, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_document_revisions ( + id text NOT NULL, + task_id text NOT NULL, + document_key text DEFAULT 'plan'::text NOT NULL, + revision_number integer NOT NULL, + title text DEFAULT 'Plan'::text NOT NULL, + content text DEFAULT ''::text NOT NULL, + author_kind text DEFAULT 'agent'::text NOT NULL, + author_name text DEFAULT ''::text NOT NULL, + revert_of_revision_id text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_documents ( + id text NOT NULL, + task_id text NOT NULL, + key text DEFAULT 'plan'::text NOT NULL, + type text DEFAULT 'plan'::text NOT NULL, + title text DEFAULT 'Plan'::text NOT NULL, + content text DEFAULT ''::text NOT NULL, + author_kind text DEFAULT 'agent'::text NOT NULL, + author_name text DEFAULT ''::text NOT NULL, + filename text DEFAULT ''::text NOT NULL, + mime_type text DEFAULT ''::text NOT NULL, + size_bytes integer DEFAULT 0 NOT NULL, + disk_path text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_environment_repos ( + id text NOT NULL, + task_environment_id text NOT NULL, + repository_id text NOT NULL, + branch_slug text DEFAULT ''::text NOT NULL, + worktree_id text DEFAULT ''::text, + worktree_path text DEFAULT ''::text, + worktree_branch text DEFAULT ''::text, + "position" integer DEFAULT 0, + error_message text DEFAULT ''::text, + status text DEFAULT 'active'::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + merged_at timestamp without time zone, + deleted_at timestamp without time zone +); + + + +CREATE TABLE task_environments ( + id text NOT NULL, + task_id text NOT NULL, + executor_type text DEFAULT ''::text NOT NULL, + executor_id text DEFAULT ''::text, + executor_profile_id text DEFAULT ''::text, + control_port integer DEFAULT 0, + status text DEFAULT 'creating'::text NOT NULL, + materialization_session_id text DEFAULT ''::text, + workspace_path text DEFAULT ''::text, + container_id text DEFAULT ''::text, + container_bootstrap_nonce_secret_id text DEFAULT ''::text, + container_control_auth_token_secret_id text DEFAULT ''::text, + sandbox_id text DEFAULT ''::text, + task_dir_name text DEFAULT ''::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_message_attachments ( + id text NOT NULL, + owner_id text DEFAULT ''::text NOT NULL, + workspace_id text NOT NULL, + task_id text DEFAULT ''::text NOT NULL, + session_id text DEFAULT ''::text NOT NULL, + message_id text DEFAULT ''::text NOT NULL, + queue_id text DEFAULT ''::text NOT NULL, + name text DEFAULT ''::text NOT NULL, + mime_type text DEFAULT 'application/octet-stream'::text NOT NULL, + kind text DEFAULT 'resource'::text NOT NULL, + delivery_mode text DEFAULT 'prompt'::text NOT NULL, + size_bytes integer NOT NULL, + storage_key text NOT NULL, + state text DEFAULT 'staged'::text NOT NULL, + expires_at timestamp without time zone NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_plan_revisions ( + id text NOT NULL, + task_id text NOT NULL, + revision_number integer NOT NULL, + title text DEFAULT 'Plan'::text NOT NULL, + content text DEFAULT ''::text NOT NULL, + author_kind text DEFAULT 'agent'::text NOT NULL, + author_name text DEFAULT ''::text NOT NULL, + revert_of_revision_id text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_plans ( + id text NOT NULL, + task_id text NOT NULL, + title text DEFAULT 'Plan'::text NOT NULL, + content text DEFAULT ''::text NOT NULL, + created_by text DEFAULT 'agent'::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + implementation_started_at timestamp without time zone, + implementation_started_session_id text, + implementation_started_by text +); + + + +CREATE TABLE task_repositories ( + id text NOT NULL, + task_id text NOT NULL, + repository_id text NOT NULL, + base_branch text DEFAULT ''::text, + checkout_branch text DEFAULT ''::text, + branch_policy_id text DEFAULT ''::text, + branch_policy_name text DEFAULT ''::text, + branch_policy_base_branch text DEFAULT ''::text, + branch_policy_branch_template text DEFAULT ''::text, + branch_policy_pull_request_target text DEFAULT ''::text, + "position" integer DEFAULT 0, + metadata text DEFAULT '{}'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_resource_cleanup_jobs ( + id text NOT NULL, + operation_id text NOT NULL, + task_id text NOT NULL, + trigger text NOT NULL, + state text DEFAULT 'pending'::text NOT NULL, + resource_snapshot text DEFAULT '{}'::text NOT NULL, + attempts integer DEFAULT 0 NOT NULL, + next_attempt_at timestamp without time zone, + last_error text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + completed_at timestamp without time zone +); + + + +CREATE TABLE task_review_findings ( + id text NOT NULL, + run_id text NOT NULL, + task_id text NOT NULL, + repository_id text DEFAULT ''::text NOT NULL, + repository_name text DEFAULT ''::text NOT NULL, + file_path text NOT NULL, + start_line integer NOT NULL, + end_line integer NOT NULL, + side text DEFAULT 'additions'::text NOT NULL, + severity text DEFAULT 'minor'::text NOT NULL, + category text DEFAULT ''::text NOT NULL, + title text NOT NULL, + body text DEFAULT ''::text NOT NULL, + suggestion text DEFAULT ''::text NOT NULL, + anchor_text text DEFAULT ''::text NOT NULL, + file_diff_hash text DEFAULT ''::text NOT NULL, + status text DEFAULT 'open'::text NOT NULL, + resolved_at timestamp without time zone, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_review_runs ( + id text NOT NULL, + task_id text NOT NULL, + session_id text DEFAULT ''::text NOT NULL, + trigger text DEFAULT 'manual'::text NOT NULL, + workflow_step_id text DEFAULT ''::text NOT NULL, + agent_id text DEFAULT ''::text NOT NULL, + model text DEFAULT ''::text NOT NULL, + status text DEFAULT 'pending'::text NOT NULL, + error_code text DEFAULT ''::text NOT NULL, + error_message text DEFAULT ''::text NOT NULL, + summary text DEFAULT ''::text NOT NULL, + finding_count integer DEFAULT 0 NOT NULL, + file_count integer DEFAULT 0 NOT NULL, + repository_count integer DEFAULT 0 NOT NULL, + prompt_tokens integer DEFAULT 0 NOT NULL, + response_tokens integer DEFAULT 0 NOT NULL, + duration_ms integer DEFAULT 0 NOT NULL, + entry_id text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone NOT NULL, + completed_at timestamp without time zone +); + + + +CREATE TABLE task_session_commits ( + id text NOT NULL, + session_id text NOT NULL, + commit_sha text NOT NULL, + parent_sha text DEFAULT ''::text, + author_name text DEFAULT ''::text, + author_email text DEFAULT ''::text, + commit_message text DEFAULT ''::text, + committed_at timestamp without time zone NOT NULL, + pre_commit_snapshot_id text DEFAULT ''::text, + post_commit_snapshot_id text DEFAULT ''::text, + files_changed integer DEFAULT 0, + insertions integer DEFAULT 0, + deletions integer DEFAULT 0, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_session_git_snapshots ( + id text NOT NULL, + task_environment_id text NOT NULL, + session_id text, + snapshot_type text NOT NULL, + branch text NOT NULL, + remote_branch text DEFAULT ''::text, + head_commit text DEFAULT ''::text, + base_commit text DEFAULT ''::text, + ahead integer DEFAULT 0, + behind integer DEFAULT 0, + files text DEFAULT '{}'::text, + triggered_by text DEFAULT ''::text, + metadata text DEFAULT '{}'::text, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_session_messages ( + id text NOT NULL, + task_session_id text NOT NULL, + task_id text DEFAULT ''::text, + turn_id text NOT NULL, + author_type text DEFAULT 'user'::text NOT NULL, + author_id text DEFAULT ''::text, + content text NOT NULL, + requests_input integer DEFAULT 0, + type text DEFAULT 'message'::text NOT NULL, + metadata text DEFAULT '{}'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + prompt_seq integer DEFAULT 0 NOT NULL +); + + + +CREATE TABLE task_session_prompt_seq ( + task_session_id text NOT NULL, + last_seq integer NOT NULL +); + + + +CREATE TABLE task_session_subagents ( + id text NOT NULL, + task_session_id text NOT NULL, + task_id text NOT NULL, + turn_id text, + tool_call_id text NOT NULL, + agent_execution_id text DEFAULT 'unknown'::text NOT NULL, + parent_tool_call_id text, + subagent_type text, + description text, + agent_id text, + child_session_id text, + model text, + agent_status text, + tool_status text, + is_async integer DEFAULT 0 NOT NULL, + total_tokens integer, + tool_use_count integer, + duration_ms integer, + source text DEFAULT 'live'::text NOT NULL, + observed_at timestamp without time zone NOT NULL, + settled_at timestamp without time zone, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_session_turns ( + id text NOT NULL, + task_session_id text NOT NULL, + task_id text NOT NULL, + started_at timestamp without time zone NOT NULL, + completed_at timestamp without time zone, + execution_profile_id text DEFAULT ''::text NOT NULL, + route_generation bigint DEFAULT 0 NOT NULL, + metadata text DEFAULT '{}'::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_sessions ( + id text NOT NULL, + task_id text NOT NULL, + agent_execution_id text DEFAULT ''::text NOT NULL, + container_id text DEFAULT ''::text NOT NULL, + agent_profile_id text, + execution_profile_id text DEFAULT ''::text NOT NULL, + route_generation integer DEFAULT 0 NOT NULL, + route_state text DEFAULT ''::text NOT NULL, + route_reason text DEFAULT ''::text NOT NULL, + downstream_acp_session_id text DEFAULT ''::text NOT NULL, + executor_id text DEFAULT ''::text, + executor_profile_id text DEFAULT ''::text, + environment_id text DEFAULT ''::text, + repository_id text DEFAULT ''::text, + base_branch text DEFAULT ''::text, + agent_profile_snapshot text DEFAULT '{}'::text, + executor_snapshot text DEFAULT '{}'::text, + environment_snapshot text DEFAULT '{}'::text, + repository_snapshot text DEFAULT '{}'::text, + state text DEFAULT 'CREATED'::text NOT NULL, + error_message text DEFAULT ''::text, + metadata text DEFAULT '{}'::text, + started_at timestamp without time zone NOT NULL, + completed_at timestamp without time zone, + updated_at timestamp without time zone NOT NULL, + is_primary integer DEFAULT 0, + is_passthrough integer DEFAULT 0, + review_status text DEFAULT ''::text, + base_commit_sha text DEFAULT ''::text, + task_environment_id text DEFAULT ''::text, + cost_subcents bigint DEFAULT 0 NOT NULL, + tokens_in bigint DEFAULT 0 NOT NULL, + tokens_cached_in bigint DEFAULT 0 NOT NULL, + tokens_out bigint DEFAULT 0 NOT NULL, + workspace_path text DEFAULT ''::text, + name text DEFAULT ''::text, + last_read_message_id text DEFAULT ''::text +); + + + +CREATE TABLE task_status_summaries ( + task_id text NOT NULL, + workspace_id text NOT NULL, + revision integer DEFAULT 0 NOT NULL, + summary text DEFAULT '{}'::text NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_step_transitions ( + id bigint NOT NULL, + task_id text NOT NULL, + session_id text, + from_workflow_id text, + from_workflow_step_id text, + to_workflow_id text, + to_workflow_step_id text, + trigger text NOT NULL, + actor_kind text NOT NULL, + actor_id text, + contract_version integer NOT NULL, + occurred_at timestamp without time zone NOT NULL +); + + + +CREATE SEQUENCE task_step_transitions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + + +ALTER SEQUENCE task_step_transitions_id_seq OWNED BY task_step_transitions.id; + + + +CREATE TABLE task_usage_events ( + id bigint NOT NULL, + usage_event_id text NOT NULL, + task_id text NOT NULL, + session_id text, + turn_id text, + agent_profile_id text DEFAULT ''::text NOT NULL, + agent_type text DEFAULT ''::text NOT NULL, + model text DEFAULT ''::text NOT NULL, + provider text DEFAULT ''::text NOT NULL, + tokens_in bigint DEFAULT 0 NOT NULL, + tokens_cached_read bigint, + tokens_cached_write bigint, + tokens_out bigint, + tokens_thought bigint, + tokens_total bigint DEFAULT 0 NOT NULL, + cost_subcents bigint DEFAULT 0 NOT NULL, + cost_source text NOT NULL, + estimated integer DEFAULT 0 NOT NULL, + rate_input_per_million bigint, + rate_cached_read_per_million bigint, + rate_cached_write_per_million bigint, + rate_output_per_million bigint, + pricing_catalog_version text, + contract_version integer NOT NULL, + occurred_at timestamp without time zone NOT NULL, + created_at timestamp without time zone NOT NULL +); + + + +CREATE SEQUENCE task_usage_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + + +ALTER SEQUENCE task_usage_events_id_seq OWNED BY task_usage_events.id; + + + +CREATE TABLE task_walkthroughs ( + id text NOT NULL, + task_id text NOT NULL, + title text DEFAULT 'Walkthrough'::text NOT NULL, + steps text DEFAULT '[]'::text NOT NULL, + created_by text DEFAULT 'agent'::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_workspace_folders ( + id text NOT NULL, + task_id text NOT NULL, + local_path text NOT NULL, + display_name text NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_workspace_group_members ( + workspace_group_id text NOT NULL, + task_id text NOT NULL, + role text DEFAULT 'member'::text NOT NULL, + released_at timestamp without time zone, + release_reason text DEFAULT ''::text NOT NULL, + released_by_cascade_id text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE task_workspace_groups ( + id text NOT NULL, + workspace_id text NOT NULL, + owner_task_id text NOT NULL, + materialized_path text DEFAULT ''::text NOT NULL, + materialized_environment_id text DEFAULT ''::text NOT NULL, + materialized_kind text NOT NULL, + owned_by_kandev integer DEFAULT 0 NOT NULL, + cleanup_policy text DEFAULT 'never_delete'::text NOT NULL, + cleanup_status text DEFAULT 'active'::text NOT NULL, + cleaned_at timestamp without time zone, + cleanup_error text DEFAULT ''::text NOT NULL, + restore_status text DEFAULT 'not_needed'::text NOT NULL, + restore_error text DEFAULT ''::text NOT NULL, + restore_config_json text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE tasks ( + id text NOT NULL, + workspace_id text DEFAULT ''::text NOT NULL, + workflow_id text DEFAULT ''::text NOT NULL, + workflow_step_id text DEFAULT ''::text NOT NULL, + title text NOT NULL, + description text DEFAULT ''::text, + state text DEFAULT 'TODO'::text, + priority text DEFAULT 'medium'::text NOT NULL, + "position" integer DEFAULT 0, + wip_admitted integer DEFAULT 1 NOT NULL, + queued_for_step_id text DEFAULT ''::text NOT NULL, + queued_at timestamp without time zone, + metadata text DEFAULT '{}'::text, + autopilot_enabled integer DEFAULT 0 NOT NULL, + archived_at timestamp without time zone, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + is_ephemeral integer DEFAULT 0 NOT NULL, + parent_id text DEFAULT ''::text, + origin text DEFAULT 'manual'::text, + project_id text DEFAULT ''::text, + labels text DEFAULT '[]'::text, + identifier text, + archived_by_cascade_id text DEFAULT ''::text, + external_id text COLLATE pg_catalog."C", + external_id_settled_at timestamp without time zone, + checkout_agent_id text, + checkout_at timestamp without time zone, + checkout_run_id text, + CONSTRAINT tasks_priority_check CHECK ((priority = ANY (ARRAY['critical'::text, 'high'::text, 'medium'::text, 'low'::text]))) +); + + + +CREATE TABLE telemetry_activations ( + contract_key text NOT NULL, + contract_version integer NOT NULL, + activated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE user_agent_profile_recent_use ( + user_id text NOT NULL, + context text NOT NULL, + profile_ids text DEFAULT '[]'::text NOT NULL, + revision bigint DEFAULT 0 NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE user_terminals ( + id text NOT NULL, + task_id text NOT NULL, + environment_id text NOT NULL, + seq integer NOT NULL, + custom_name text, + state text DEFAULT 'open'::text NOT NULL, + initial_command text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + + +CREATE TABLE users ( + id text NOT NULL, + email text NOT NULL, + display_name text DEFAULT ''::text NOT NULL, + role text DEFAULT 'admin'::text NOT NULL, + status text DEFAULT 'active'::text NOT NULL, + settings text DEFAULT '{}'::text NOT NULL, + settings_revision bigint DEFAULT 0 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE utility_agent_calls ( + id text NOT NULL, + utility_id text NOT NULL, + session_id text DEFAULT ''::text NOT NULL, + resolved_prompt text DEFAULT ''::text NOT NULL, + response text DEFAULT ''::text NOT NULL, + model text DEFAULT ''::text NOT NULL, + agent_profile_id text DEFAULT ''::text NOT NULL, + execution_profile_id text DEFAULT ''::text NOT NULL, + prompt_tokens integer DEFAULT 0 NOT NULL, + response_tokens integer DEFAULT 0 NOT NULL, + duration_ms integer DEFAULT 0 NOT NULL, + status text DEFAULT 'pending'::text NOT NULL, + error_message text DEFAULT ''::text NOT NULL, + created_at timestamp without time zone NOT NULL, + completed_at timestamp without time zone +); + + + +CREATE TABLE utility_agents ( + id text NOT NULL, + name text NOT NULL, + description text DEFAULT ''::text NOT NULL, + prompt text NOT NULL, + agent_id text DEFAULT 'claude-acp'::text NOT NULL, + model text DEFAULT ''::text NOT NULL, + agent_profile_id text DEFAULT ''::text NOT NULL, + profile_binding_state text DEFAULT 'explicit'::text NOT NULL, + builtin integer DEFAULT 0 NOT NULL, + enabled integer DEFAULT 1 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE workflow_step_decisions ( + id text NOT NULL, + task_id text NOT NULL, + step_id text NOT NULL, + participant_id text NOT NULL, + decision text NOT NULL, + note text DEFAULT ''::text, + decided_at timestamp without time zone NOT NULL, + superseded_at timestamp without time zone, + decider_type text DEFAULT ''::text NOT NULL, + decider_id text DEFAULT ''::text NOT NULL, + role text DEFAULT ''::text NOT NULL, + comment text DEFAULT ''::text NOT NULL +); + + + +CREATE TABLE workflow_step_entries ( + id bigint NOT NULL, + task_id text NOT NULL, + step_id text NOT NULL, + entry_seq integer NOT NULL, + digest text NOT NULL, + created_at timestamp without time zone NOT NULL +); + + + +CREATE SEQUENCE workflow_step_entries_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + + +ALTER SEQUENCE workflow_step_entries_id_seq OWNED BY workflow_step_entries.id; + + + +CREATE TABLE workflow_step_entry_markers ( + id bigint NOT NULL, + entry_id integer NOT NULL, + "position" integer NOT NULL, + kind text NOT NULL, + state text NOT NULL, + operation_id text, + error text, + claimed_at timestamp without time zone, + completed_at timestamp without time zone +); + + + +CREATE SEQUENCE workflow_step_entry_markers_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + + +ALTER SEQUENCE workflow_step_entry_markers_id_seq OWNED BY workflow_step_entry_markers.id; + + + +CREATE TABLE workflow_step_participants ( + id text NOT NULL, + step_id text DEFAULT ''::text NOT NULL, + task_id text DEFAULT ''::text NOT NULL, + role text DEFAULT ''::text NOT NULL, + agent_profile_id text DEFAULT ''::text NOT NULL, + decision_required integer DEFAULT 0 NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + created_at timestamp without time zone DEFAULT '1970-01-01 00:00:00'::timestamp without time zone NOT NULL, + provenance text DEFAULT 'manual'::text NOT NULL +); + + + +CREATE TABLE workflow_steps ( + id text NOT NULL, + workflow_id text DEFAULT ''::text NOT NULL, + name text DEFAULT ''::text NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + color text, + prompt text, + events text, + allow_manual_move integer DEFAULT 1, + is_start_step integer DEFAULT 0, + show_in_command_panel integer DEFAULT 1, + auto_archive_after_hours integer DEFAULT 0, + agent_profile_id text DEFAULT ''::text NOT NULL, + stage_type text DEFAULT 'custom'::text NOT NULL, + auto_advance_requires_signal integer DEFAULT 0 NOT NULL, + cancel_triggers_turn_complete integer DEFAULT 0 NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + wip_limit integer DEFAULT 0 NOT NULL, + pull_from_step_id text DEFAULT ''::text NOT NULL +); + + + +CREATE TABLE workflow_templates ( + id text NOT NULL, + name text NOT NULL, + description text, + is_system integer DEFAULT 0, + steps text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + + +CREATE TABLE workflows ( + id text NOT NULL, + workspace_id text DEFAULT ''::text NOT NULL, + workflow_template_id text DEFAULT ''::text, + name text NOT NULL, + description text DEFAULT ''::text, + prompt text DEFAULT ''::text NOT NULL, + hidden integer DEFAULT 0 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + sort_order integer DEFAULT 0 NOT NULL, + agent_profile_id text DEFAULT ''::text, + is_system integer DEFAULT 0, + style text DEFAULT 'kanban'::text NOT NULL, + source text DEFAULT 'manual'::text NOT NULL, + source_path text DEFAULT ''::text NOT NULL +); + + + +CREATE TABLE workspaces ( + id text NOT NULL, + name text NOT NULL, + description text DEFAULT ''::text, + owner_id text DEFAULT ''::text, + default_executor_id text DEFAULT ''::text, + default_environment_id text DEFAULT ''::text, + default_agent_profile_id text DEFAULT ''::text, + default_config_agent_profile_id text DEFAULT ''::text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + task_prefix text DEFAULT 'KAN'::text, + task_sequence integer DEFAULT 0, + office_workflow_id text DEFAULT ''::text +); + + + +ALTER TABLE ONLY session_step_history ALTER COLUMN id SET DEFAULT nextval('session_step_history_id_seq'::regclass); + + + +ALTER TABLE ONLY task_step_transitions ALTER COLUMN id SET DEFAULT nextval('task_step_transitions_id_seq'::regclass); + + + +ALTER TABLE ONLY task_usage_events ALTER COLUMN id SET DEFAULT nextval('task_usage_events_id_seq'::regclass); + + + +ALTER TABLE ONLY workflow_step_entries ALTER COLUMN id SET DEFAULT nextval('workflow_step_entries_id_seq'::regclass); + + + +ALTER TABLE ONLY workflow_step_entry_markers ALTER COLUMN id SET DEFAULT nextval('workflow_step_entry_markers_id_seq'::regclass); + + + +ALTER TABLE ONLY agent_continuation_summaries + ADD CONSTRAINT agent_continuation_summaries_pkey PRIMARY KEY (agent_profile_id, scope); + + + +ALTER TABLE ONLY agent_profile_mcp_configs + ADD CONSTRAINT agent_profile_mcp_configs_pkey PRIMARY KEY (profile_id); + + + +ALTER TABLE ONLY agent_profiles + ADD CONSTRAINT agent_profiles_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY agent_wakeup_requests + ADD CONSTRAINT agent_wakeup_requests_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY agents + ADD CONSTRAINT agents_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY auth_api_tokens + ADD CONSTRAINT auth_api_tokens_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY auth_api_tokens + ADD CONSTRAINT auth_api_tokens_token_sha256_key UNIQUE (token_sha256); + + + +ALTER TABLE ONLY auth_identities + ADD CONSTRAINT auth_identities_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY auth_identities + ADD CONSTRAINT auth_identities_provider_subject_key UNIQUE (provider, subject); + + + +ALTER TABLE ONLY auth_identities + ADD CONSTRAINT auth_identities_user_id_provider_key UNIQUE (user_id, provider); + + + +ALTER TABLE ONLY auth_invites + ADD CONSTRAINT auth_invites_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY auth_invites + ADD CONSTRAINT auth_invites_token_sha256_key UNIQUE (token_sha256); + + + +ALTER TABLE ONLY auth_sessions + ADD CONSTRAINT auth_sessions_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY auth_sessions + ADD CONSTRAINT auth_sessions_token_sha256_key UNIQUE (token_sha256); + + + +ALTER TABLE ONLY custom_prompts + ADD CONSTRAINT custom_prompts_name_key UNIQUE (name); + + + +ALTER TABLE ONLY custom_prompts + ADD CONSTRAINT custom_prompts_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY dynamic_agent_profiles + ADD CONSTRAINT dynamic_agent_profiles_pkey PRIMARY KEY (profile_id); + + + +ALTER TABLE ONLY dynamic_agent_routes + ADD CONSTRAINT dynamic_agent_routes_pkey PRIMARY KEY (dynamic_profile_id, "position"); + + + +ALTER TABLE ONLY dynamic_installation_keys + ADD CONSTRAINT dynamic_installation_keys_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY dynamic_resource_circuits + ADD CONSTRAINT dynamic_resource_circuits_pkey PRIMARY KEY (resource_key); + + + +ALTER TABLE ONLY dynamic_route_attempts + ADD CONSTRAINT dynamic_route_attempts_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY dynamic_route_attempts + ADD CONSTRAINT dynamic_route_attempts_session_id_route_generation_key UNIQUE (session_id, route_generation); + + + +ALTER TABLE ONLY dynamic_route_states + ADD CONSTRAINT dynamic_route_states_pkey PRIMARY KEY (session_id); + + + +ALTER TABLE ONLY editors + ADD CONSTRAINT editors_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY editors + ADD CONSTRAINT editors_type_key UNIQUE (type); + + + +ALTER TABLE ONLY environments + ADD CONSTRAINT environments_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY executor_profiles + ADD CONSTRAINT executor_profiles_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY executors + ADD CONSTRAINT executors_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY executors_running + ADD CONSTRAINT executors_running_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY executors_running + ADD CONSTRAINT executors_running_session_id_key UNIQUE (session_id); + + + +ALTER TABLE ONLY kandev_meta + ADD CONSTRAINT kandev_meta_pkey PRIMARY KEY (key); + + + +ALTER TABLE ONLY notification_deliveries + ADD CONSTRAINT notification_deliveries_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY notification_deliveries + ADD CONSTRAINT notification_deliveries_provider_id_event_type_occurrence_i_key UNIQUE (provider_id, event_type, occurrence_id); + + + +ALTER TABLE ONLY notification_migrations + ADD CONSTRAINT notification_migrations_pkey PRIMARY KEY (name); + + + +ALTER TABLE ONLY notification_providers + ADD CONSTRAINT notification_providers_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY notification_subscriptions + ADD CONSTRAINT notification_subscriptions_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY notification_subscriptions + ADD CONSTRAINT notification_subscriptions_provider_id_event_type_key UNIQUE (provider_id, event_type); + + + +ALTER TABLE ONLY office_activity_log + ADD CONSTRAINT office_activity_log_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_agent_instructions + ADD CONSTRAINT office_agent_instructions_agent_profile_id_filename_key UNIQUE (agent_profile_id, filename); + + + +ALTER TABLE ONLY office_agent_instructions + ADD CONSTRAINT office_agent_instructions_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_agent_memory + ADD CONSTRAINT office_agent_memory_agent_profile_id_layer_key_key UNIQUE (agent_profile_id, layer, key); + + + +ALTER TABLE ONLY office_agent_memory + ADD CONSTRAINT office_agent_memory_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_agent_runtime + ADD CONSTRAINT office_agent_runtime_pkey PRIMARY KEY (agent_id); + + + +ALTER TABLE ONLY office_approvals + ADD CONSTRAINT office_approvals_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_budget_policies + ADD CONSTRAINT office_budget_policies_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_channels + ADD CONSTRAINT office_channels_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_cost_events + ADD CONSTRAINT office_cost_events_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_inbox_dismissals + ADD CONSTRAINT office_inbox_dismissals_pkey PRIMARY KEY (user_id, item_kind, item_id); + + + +ALTER TABLE ONLY office_labels + ADD CONSTRAINT office_labels_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_labels + ADD CONSTRAINT office_labels_workspace_id_name_key UNIQUE (workspace_id, name); + + + +ALTER TABLE ONLY office_onboarding + ADD CONSTRAINT office_onboarding_pkey PRIMARY KEY (workspace_id); + + + +ALTER TABLE ONLY office_projects + ADD CONSTRAINT office_projects_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_provider_health + ADD CONSTRAINT office_provider_health_pkey PRIMARY KEY (workspace_id, provider_id, scope, scope_value); + + + +ALTER TABLE ONLY office_routine_runs + ADD CONSTRAINT office_routine_runs_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_routine_triggers + ADD CONSTRAINT office_routine_triggers_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_routines + ADD CONSTRAINT office_routines_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_run_route_attempts + ADD CONSTRAINT office_run_route_attempts_pkey PRIMARY KEY (run_id, seq); + + + +ALTER TABLE ONLY office_run_skills + ADD CONSTRAINT office_run_skills_pkey PRIMARY KEY (run_id, skill_id); + + + +ALTER TABLE ONLY office_skills + ADD CONSTRAINT office_skills_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_skills + ADD CONSTRAINT office_skills_workspace_id_slug_key UNIQUE (workspace_id, slug); + + + +ALTER TABLE ONLY office_task_labels + ADD CONSTRAINT office_task_labels_pkey PRIMARY KEY (task_id, label_id); + + + +ALTER TABLE ONLY office_task_tree_hold_members + ADD CONSTRAINT office_task_tree_hold_members_pkey PRIMARY KEY (hold_id, task_id); + + + +ALTER TABLE ONLY office_task_tree_holds + ADD CONSTRAINT office_task_tree_holds_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY office_workspace_governance + ADD CONSTRAINT office_workspace_governance_pkey PRIMARY KEY (workspace_id, key); + + + +ALTER TABLE ONLY office_workspace_routing + ADD CONSTRAINT office_workspace_routing_pkey PRIMARY KEY (workspace_id); + + + +ALTER TABLE ONLY office_workspace_settings + ADD CONSTRAINT office_workspace_settings_pkey PRIMARY KEY (workspace_id); + + + +ALTER TABLE ONLY parent_child_wake_receipts + ADD CONSTRAINT parent_child_wake_receipts_pkey PRIMARY KEY (parent_task_id); + + + +ALTER TABLE ONLY quick_terminal_tabs + ADD CONSTRAINT quick_terminal_tabs_pkey PRIMARY KEY (tab_id); + + + +ALTER TABLE ONLY quick_terminal_tabs + ADD CONSTRAINT quick_terminal_tabs_user_id_workspace_id_sequence_key UNIQUE (user_id, workspace_id, sequence); + + + +ALTER TABLE ONLY quick_terminal_workspace_sequences + ADD CONSTRAINT quick_terminal_workspace_sequences_pkey PRIMARY KEY (user_id, workspace_id); + + + +ALTER TABLE ONLY repositories + ADD CONSTRAINT repositories_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY repository_branch_policies + ADD CONSTRAINT repository_branch_policies_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY repository_scripts + ADD CONSTRAINT repository_scripts_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY repository_secret_bindings + ADD CONSTRAINT repository_secret_bindings_pkey PRIMARY KEY (repository_id, key); + + + +ALTER TABLE ONLY repository_set_items + ADD CONSTRAINT repository_set_items_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY repository_set_items + ADD CONSTRAINT repository_set_items_repository_set_id_repository_id_key UNIQUE (repository_set_id, repository_id); + + + +ALTER TABLE ONLY repository_sets + ADD CONSTRAINT repository_sets_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY repository_sets + ADD CONSTRAINT repository_sets_workspace_id_name_key UNIQUE (workspace_id, name); + + + +ALTER TABLE ONLY run_events + ADD CONSTRAINT run_events_pkey PRIMARY KEY (run_id, seq); + + + +ALTER TABLE ONLY runs + ADD CONSTRAINT runs_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY runtime_flag_overrides + ADD CONSTRAINT runtime_flag_overrides_pkey PRIMARY KEY (key); + + + +ALTER TABLE ONLY secrets + ADD CONSTRAINT secrets_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY session_file_reviews + ADD CONSTRAINT session_file_reviews_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY session_file_reviews + ADD CONSTRAINT session_file_reviews_session_id_file_path_key UNIQUE (session_id, file_path); + + + +ALTER TABLE ONLY session_step_history + ADD CONSTRAINT session_step_history_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_blockers + ADD CONSTRAINT task_blockers_pkey PRIMARY KEY (task_id, blocker_task_id); + + + +ALTER TABLE ONLY task_comments + ADD CONSTRAINT task_comments_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_document_revisions + ADD CONSTRAINT task_document_revisions_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_document_revisions + ADD CONSTRAINT task_document_revisions_task_id_document_key_revision_numbe_key UNIQUE (task_id, document_key, revision_number); + + + +ALTER TABLE ONLY task_documents + ADD CONSTRAINT task_documents_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_documents + ADD CONSTRAINT task_documents_task_id_key_key UNIQUE (task_id, key); + + + +ALTER TABLE ONLY task_environment_repos + ADD CONSTRAINT task_environment_repos_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_environment_repos + ADD CONSTRAINT task_environment_repos_task_environment_id_repository_id_br_key UNIQUE (task_environment_id, repository_id, branch_slug); + + + +ALTER TABLE ONLY task_environments + ADD CONSTRAINT task_environments_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_message_attachments + ADD CONSTRAINT task_message_attachments_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_message_attachments + ADD CONSTRAINT task_message_attachments_storage_key_key UNIQUE (storage_key); + + + +ALTER TABLE ONLY task_plan_revisions + ADD CONSTRAINT task_plan_revisions_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_plan_revisions + ADD CONSTRAINT task_plan_revisions_task_id_revision_number_key UNIQUE (task_id, revision_number); + + + +ALTER TABLE ONLY task_plans + ADD CONSTRAINT task_plans_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_plans + ADD CONSTRAINT task_plans_task_id_key UNIQUE (task_id); + + + +ALTER TABLE ONLY task_repositories + ADD CONSTRAINT task_repositories_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_repositories + ADD CONSTRAINT task_repositories_task_id_repository_id_base_branch_checkou_key UNIQUE (task_id, repository_id, base_branch, checkout_branch); + + + +ALTER TABLE ONLY task_resource_cleanup_jobs + ADD CONSTRAINT task_resource_cleanup_jobs_operation_id_key UNIQUE (operation_id); + + + +ALTER TABLE ONLY task_resource_cleanup_jobs + ADD CONSTRAINT task_resource_cleanup_jobs_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_review_findings + ADD CONSTRAINT task_review_findings_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_review_runs + ADD CONSTRAINT task_review_runs_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_session_commits + ADD CONSTRAINT task_session_commits_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_session_git_snapshots + ADD CONSTRAINT task_session_git_snapshots_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_session_messages + ADD CONSTRAINT task_session_messages_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_session_prompt_seq + ADD CONSTRAINT task_session_prompt_seq_pkey PRIMARY KEY (task_session_id); + + + +ALTER TABLE ONLY task_session_subagents + ADD CONSTRAINT task_session_subagents_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_session_subagents + ADD CONSTRAINT task_session_subagents_task_session_id_agent_execution_id_t_key UNIQUE (task_session_id, agent_execution_id, tool_call_id); + + + +ALTER TABLE ONLY task_session_turns + ADD CONSTRAINT task_session_turns_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_sessions + ADD CONSTRAINT task_sessions_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_status_summaries + ADD CONSTRAINT task_status_summaries_pkey PRIMARY KEY (task_id); + + + +ALTER TABLE ONLY task_step_transitions + ADD CONSTRAINT task_step_transitions_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_usage_events + ADD CONSTRAINT task_usage_events_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_walkthroughs + ADD CONSTRAINT task_walkthroughs_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_walkthroughs + ADD CONSTRAINT task_walkthroughs_task_id_key UNIQUE (task_id); + + + +ALTER TABLE ONLY task_workspace_folders + ADD CONSTRAINT task_workspace_folders_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY task_workspace_folders + ADD CONSTRAINT task_workspace_folders_task_id_display_name_key UNIQUE (task_id, display_name); + + + +ALTER TABLE ONLY task_workspace_folders + ADD CONSTRAINT task_workspace_folders_task_id_local_path_key UNIQUE (task_id, local_path); + + + +ALTER TABLE ONLY task_workspace_group_members + ADD CONSTRAINT task_workspace_group_members_pkey PRIMARY KEY (workspace_group_id, task_id); + + + +ALTER TABLE ONLY task_workspace_groups + ADD CONSTRAINT task_workspace_groups_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY tasks + ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY telemetry_activations + ADD CONSTRAINT telemetry_activations_pkey PRIMARY KEY (contract_key, contract_version); + + + +ALTER TABLE ONLY workflow_step_entry_markers + ADD CONSTRAINT uniq_workflow_step_entry_markers_entry_position UNIQUE (entry_id, "position"); + + + +ALTER TABLE ONLY user_agent_profile_recent_use + ADD CONSTRAINT user_agent_profile_recent_use_pkey PRIMARY KEY (user_id, context); + + + +ALTER TABLE ONLY user_terminals + ADD CONSTRAINT user_terminals_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY user_terminals + ADD CONSTRAINT user_terminals_task_id_seq_key UNIQUE (task_id, seq); + + + +ALTER TABLE ONLY users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY utility_agent_calls + ADD CONSTRAINT utility_agent_calls_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY utility_agents + ADD CONSTRAINT utility_agents_name_key UNIQUE (name); + + + +ALTER TABLE ONLY utility_agents + ADD CONSTRAINT utility_agents_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY workflow_step_decisions + ADD CONSTRAINT workflow_step_decisions_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY workflow_step_entries + ADD CONSTRAINT workflow_step_entries_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY workflow_step_entries + ADD CONSTRAINT workflow_step_entries_task_id_step_id_entry_seq_key UNIQUE (task_id, step_id, entry_seq); + + + +ALTER TABLE ONLY workflow_step_entry_markers + ADD CONSTRAINT workflow_step_entry_markers_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY workflow_step_participants + ADD CONSTRAINT workflow_step_participants_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY workflow_steps + ADD CONSTRAINT workflow_steps_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY workflow_templates + ADD CONSTRAINT workflow_templates_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY workflows + ADD CONSTRAINT workflows_pkey PRIMARY KEY (id); + + + +ALTER TABLE ONLY workspaces + ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); + + + +CREATE INDEX idx_activity_run_id ON office_activity_log USING btree (run_id) WHERE (run_id <> ''::text); + + + +CREATE INDEX idx_activity_session_id ON office_activity_log USING btree (session_id) WHERE (session_id <> ''::text); + + + +CREATE INDEX idx_activity_workspace_created ON office_activity_log USING btree (workspace_id, created_at DESC); + + + +CREATE INDEX idx_agent_profiles_agent_id ON agent_profiles USING btree (agent_id); + + + +CREATE INDEX idx_agent_profiles_reports_to ON agent_profiles USING btree (reports_to) WHERE (reports_to <> ''::text); + + + +CREATE INDEX idx_agent_profiles_role ON agent_profiles USING btree (workspace_id, role) WHERE (role <> ''::text); + + + +CREATE INDEX idx_agent_profiles_workspace ON agent_profiles USING btree (workspace_id); + + + +CREATE UNIQUE INDEX idx_agents_name ON agents USING btree (name); + + + +CREATE INDEX idx_auth_api_tokens_user ON auth_api_tokens USING btree (user_id); + + + +CREATE INDEX idx_auth_sessions_user ON auth_sessions USING btree (user_id); + + + +CREATE INDEX idx_commits_session_time ON task_session_commits USING btree (session_id, committed_at); + + + +CREATE INDEX idx_dynamic_agent_routes_execution_profile ON dynamic_agent_routes USING btree (execution_profile_id); + + + +CREATE INDEX idx_dynamic_route_attempts_session ON dynamic_route_attempts USING btree (session_id, route_generation); + + + +CREATE INDEX idx_git_snapshots_environment ON task_session_git_snapshots USING btree (task_environment_id, created_at DESC); + + + +CREATE INDEX idx_git_snapshots_environment_type ON task_session_git_snapshots USING btree (task_environment_id, snapshot_type, created_at DESC); + + + +CREATE INDEX idx_git_snapshots_session ON task_session_git_snapshots USING btree (session_id, created_at DESC); + + + +CREATE INDEX idx_git_snapshots_type ON task_session_git_snapshots USING btree (session_id, snapshot_type); + + + +CREATE INDEX idx_messages_author_type ON task_session_messages USING btree (task_session_id, author_type, type); + + + +CREATE INDEX idx_messages_created_at ON task_session_messages USING btree (created_at); + + + +CREATE INDEX idx_messages_metadata_pending_id ON task_session_messages USING btree (task_session_id, (((metadata)::jsonb ->> 'pending_id'::text))); + + + +CREATE INDEX idx_messages_metadata_tool_call_id ON task_session_messages USING btree (task_session_id, (((metadata)::jsonb ->> 'tool_call_id'::text))); + + + +CREATE INDEX idx_messages_prompt_order ON task_session_messages USING btree (task_session_id, created_at, id); + + + +CREATE INDEX idx_messages_prompt_user_order ON task_session_messages USING btree (task_session_id, author_type, created_at, id); + + + +CREATE INDEX idx_messages_session_created ON task_session_messages USING btree (task_session_id, created_at); + + + +CREATE INDEX idx_messages_session_id ON task_session_messages USING btree (task_session_id); + + + +CREATE INDEX idx_messages_session_updated ON task_session_messages USING btree (task_session_id, updated_at); + + + +CREATE INDEX idx_messages_task_author_created ON task_session_messages USING btree (task_id, author_type, type, created_at DESC); + + + +CREATE INDEX idx_messages_turn ON task_session_messages USING btree (turn_id); + + + +CREATE INDEX idx_messages_turn_id ON task_session_messages USING btree (turn_id); + + + +CREATE INDEX idx_notification_deliveries_session_id ON notification_deliveries USING btree (task_session_id); + + + +CREATE INDEX idx_notification_providers_user_id ON notification_providers USING btree (user_id); + + + +CREATE INDEX idx_notification_subscriptions_provider_id ON notification_subscriptions USING btree (provider_id); + + + +CREATE INDEX idx_notification_subscriptions_user_id ON notification_subscriptions USING btree (user_id); + + + +CREATE INDEX idx_office_cost_agent ON office_cost_events USING btree (agent_profile_id); + + + +CREATE INDEX idx_office_cost_occurred ON office_cost_events USING btree (occurred_at DESC); + + + +CREATE INDEX idx_office_cost_task ON office_cost_events USING btree (task_id); + + + +CREATE INDEX idx_office_inbox_dismissals_kind ON office_inbox_dismissals USING btree (item_kind, item_id); + + + +CREATE INDEX idx_office_labels_workspace ON office_labels USING btree (workspace_id); + + + +CREATE INDEX idx_office_task_labels_label ON office_task_labels USING btree (label_id); + + + +CREATE INDEX idx_quick_terminal_tabs_session ON quick_terminal_tabs USING btree (session_id); + + + +CREATE INDEX idx_quick_terminal_tabs_workspace ON quick_terminal_tabs USING btree (user_id, workspace_id, sequence); + + + +CREATE INDEX idx_repos_workspace ON repositories USING btree (workspace_id) WHERE (deleted_at IS NULL); + + + +CREATE INDEX idx_repositories_workspace_id ON repositories USING btree (workspace_id); + + + +CREATE INDEX idx_repository_branch_policies_repository_id ON repository_branch_policies USING btree (repository_id); + + + +CREATE INDEX idx_repository_scripts_repo_id ON repository_scripts USING btree (repository_id); + + + +CREATE INDEX idx_repository_secret_bindings_repository ON repository_secret_bindings USING btree (repository_id); + + + +CREATE INDEX idx_repository_set_items_repository_id ON repository_set_items USING btree (repository_id); + + + +CREATE INDEX idx_repository_set_items_set_position ON repository_set_items USING btree (repository_set_id, "position"); + + + +CREATE INDEX idx_repository_sets_workspace_id ON repository_sets USING btree (workspace_id); + + + +CREATE INDEX idx_run_events_run_created ON run_events USING btree (run_id, created_at); + + + +CREATE INDEX idx_run_failure_scope_status ON runs USING btree (agent_profile_id, continuation_scope, status); + + + +CREATE UNIQUE INDEX idx_run_idempotency ON runs USING btree (idempotency_key) WHERE (idempotency_key IS NOT NULL); + + + +CREATE INDEX idx_run_payload_comment_id ON runs USING btree ((((payload)::jsonb ->> 'comment_id'::text))); + + + +CREATE INDEX idx_run_status_requested ON runs USING btree (status, requested_at); + + + +CREATE INDEX idx_session_commits_session ON task_session_commits USING btree (session_id, committed_at DESC); + + + +CREATE INDEX idx_session_commits_sha ON task_session_commits USING btree (commit_sha); + + + +CREATE INDEX idx_session_file_reviews_session ON session_file_reviews USING btree (session_id); + + + +CREATE INDEX idx_session_step_history_session ON session_step_history USING btree (session_id); + + + +CREATE INDEX idx_sessions_agent_started ON task_sessions USING btree (agent_profile_id, started_at); + + + +CREATE INDEX idx_sessions_repo ON task_sessions USING btree (repository_id); + + + +CREATE INDEX idx_sessions_task_started ON task_sessions USING btree (task_id, started_at); + + + +CREATE INDEX idx_subagents_session_id ON task_session_subagents USING btree (task_session_id); + + + +CREATE INDEX idx_subagents_task_id ON task_session_subagents USING btree (task_id); + + + +CREATE INDEX idx_subagents_turn_id ON task_session_subagents USING btree (turn_id); + + + +CREATE INDEX idx_task_comments_task_created ON task_comments USING btree (task_id, created_at); + + + +CREATE INDEX idx_task_document_revisions_task_key ON task_document_revisions USING btree (task_id, document_key, revision_number DESC); + + + +CREATE INDEX idx_task_documents_task_id ON task_documents USING btree (task_id); + + + +CREATE INDEX idx_task_environment_repos_env_id ON task_environment_repos USING btree (task_environment_id); + + + +CREATE INDEX idx_task_environment_repos_repository_id ON task_environment_repos USING btree (repository_id); + + + +CREATE INDEX idx_task_environments_status ON task_environments USING btree (status); + + + +CREATE INDEX idx_task_environments_task_id ON task_environments USING btree (task_id); + + + +CREATE INDEX idx_task_message_attachments_owner_state ON task_message_attachments USING btree (owner_id, state, expires_at); + + + +CREATE INDEX idx_task_message_attachments_scope ON task_message_attachments USING btree (workspace_id, task_id, session_id, state); + + + +CREATE INDEX idx_task_plan_revisions_task_created ON task_plan_revisions USING btree (task_id, created_at DESC); + + + +CREATE INDEX idx_task_plan_revisions_task_number ON task_plan_revisions USING btree (task_id, revision_number DESC); + + + +CREATE INDEX idx_task_plans_task_id ON task_plans USING btree (task_id); + + + +CREATE INDEX idx_task_repos_repo ON task_repositories USING btree (repository_id, task_id); + + + +CREATE INDEX idx_task_repos_task ON task_repositories USING btree (task_id, repository_id); + + + +CREATE INDEX idx_task_repositories_repository_id ON task_repositories USING btree (repository_id); + + + +CREATE INDEX idx_task_repositories_task_id ON task_repositories USING btree (task_id); + + + +CREATE INDEX idx_task_resource_cleanup_jobs_due ON task_resource_cleanup_jobs USING btree (state, next_attempt_at, created_at); + + + +CREATE INDEX idx_task_resource_cleanup_jobs_task_id ON task_resource_cleanup_jobs USING btree (task_id); + + + +CREATE INDEX idx_task_review_findings_anchor ON task_review_findings USING btree (task_id, repository_name, file_path); + + + +CREATE INDEX idx_task_review_findings_run ON task_review_findings USING btree (run_id); + + + +CREATE INDEX idx_task_review_findings_task_status ON task_review_findings USING btree (task_id, status); + + + +CREATE UNIQUE INDEX idx_task_review_runs_entry_id ON task_review_runs USING btree (entry_id) WHERE (entry_id <> ''::text); + + + +CREATE INDEX idx_task_review_runs_task ON task_review_runs USING btree (task_id, created_at); + + + +CREATE INDEX idx_task_sessions_state ON task_sessions USING btree (state); + + + +CREATE INDEX idx_task_sessions_task_id ON task_sessions USING btree (task_id); + + + +CREATE INDEX idx_task_sessions_task_state ON task_sessions USING btree (task_id, state); + + + +CREATE INDEX idx_task_status_summaries_workspace ON task_status_summaries USING btree (workspace_id); + + + +CREATE INDEX idx_task_step_transitions_occurred ON task_step_transitions USING btree (occurred_at); + + + +CREATE INDEX idx_task_step_transitions_task ON task_step_transitions USING btree (task_id, occurred_at, id); + + + +CREATE INDEX idx_task_usage_events_session_turn ON task_usage_events USING btree (session_id, turn_id); + + + +CREATE INDEX idx_task_usage_events_task ON task_usage_events USING btree (task_id, occurred_at, id); + + + +CREATE INDEX idx_task_walkthroughs_task_id ON task_walkthroughs USING btree (task_id); + + + +CREATE INDEX idx_task_workspace_folders_task_position ON task_workspace_folders USING btree (task_id, "position"); + + + +CREATE INDEX idx_task_workspace_group_members_cascade ON task_workspace_group_members USING btree (released_by_cascade_id); + + + +CREATE INDEX idx_task_workspace_group_members_task ON task_workspace_group_members USING btree (task_id); + + + +CREATE INDEX idx_task_workspace_groups_owner ON task_workspace_groups USING btree (owner_task_id); + + + +CREATE INDEX idx_task_workspace_groups_workspace ON task_workspace_groups USING btree (workspace_id); + + + +CREATE INDEX idx_tasks_archived_at ON tasks USING btree (archived_at); + + + +CREATE INDEX idx_tasks_parent_id ON tasks USING btree (parent_id); + + + +CREATE INDEX idx_tasks_project_id ON tasks USING btree (project_id); + + + +CREATE INDEX idx_tasks_queued_for_step ON tasks USING btree (queued_for_step_id, queued_at); + + + +CREATE INDEX idx_tasks_updated ON tasks USING btree (workspace_id, updated_at); + + + +CREATE INDEX idx_tasks_workflow_id ON tasks USING btree (workflow_id); + + + +CREATE INDEX idx_tasks_workflow_state ON tasks USING btree (workflow_step_id, state); + + + +CREATE INDEX idx_tasks_workflow_step_id ON tasks USING btree (workflow_step_id); + + + +CREATE INDEX idx_tasks_workspace_archived ON tasks USING btree (workspace_id, archived_at); + + + +CREATE INDEX idx_tasks_workspace_created ON tasks USING btree (workspace_id, created_at); + + + +CREATE INDEX idx_tasks_workspace_id ON tasks USING btree (workspace_id); + + + +CREATE INDEX idx_tree_hold_members_task ON office_task_tree_hold_members USING btree (task_id); + + + +CREATE INDEX idx_tree_holds_root_mode ON office_task_tree_holds USING btree (root_task_id, mode, released_at); + + + +CREATE INDEX idx_turns_session_id ON task_session_turns USING btree (task_session_id); + + + +CREATE INDEX idx_turns_session_started ON task_session_turns USING btree (task_session_id, started_at); + + + +CREATE INDEX idx_turns_session_times ON task_session_turns USING btree (task_session_id, started_at, completed_at); + + + +CREATE INDEX idx_turns_task ON task_session_turns USING btree (task_id); + + + +CREATE INDEX idx_turns_task_id ON task_session_turns USING btree (task_id); + + + +CREATE INDEX idx_user_terminals_env ON user_terminals USING btree (environment_id); + + + +CREATE INDEX idx_user_terminals_task ON user_terminals USING btree (task_id); + + + +CREATE UNIQUE INDEX idx_users_email ON users USING btree (email); + + + +CREATE INDEX idx_utility_calls_created_at ON utility_agent_calls USING btree (created_at); + + + +CREATE INDEX idx_utility_calls_session_id ON utility_agent_calls USING btree (session_id); + + + +CREATE INDEX idx_utility_calls_utility_id ON utility_agent_calls USING btree (utility_id); + + + +CREATE INDEX idx_wakeup_agent_status ON agent_wakeup_requests USING btree (agent_profile_id, status); + + + +CREATE UNIQUE INDEX idx_wakeup_idempotency ON agent_wakeup_requests USING btree (idempotency_key) WHERE ((idempotency_key IS NOT NULL) AND (idempotency_key <> ''::text)); + + + +CREATE INDEX idx_workflow_step_decisions_active ON workflow_step_decisions USING btree (task_id, role) WHERE (superseded_at IS NULL); + + + +CREATE INDEX idx_workflow_step_decisions_participant ON workflow_step_decisions USING btree (participant_id); + + + +CREATE INDEX idx_workflow_step_decisions_task_step ON workflow_step_decisions USING btree (task_id, step_id); + + + +CREATE INDEX idx_workflow_step_entries_task_step ON workflow_step_entries USING btree (task_id, step_id); + + + +CREATE INDEX idx_workflow_step_entry_markers_entry ON workflow_step_entry_markers USING btree (entry_id); + + + +CREATE UNIQUE INDEX idx_workflow_step_participants_natural_key ON workflow_step_participants USING btree (step_id, task_id, role, agent_profile_id); + + + +CREATE INDEX idx_workflow_step_participants_role ON workflow_step_participants USING btree (step_id, role); + + + +CREATE INDEX idx_workflow_step_participants_step ON workflow_step_participants USING btree (step_id); + + + +CREATE INDEX idx_workflow_step_participants_task ON workflow_step_participants USING btree (task_id) WHERE (task_id <> ''::text); + + + +CREATE INDEX idx_workflow_step_participants_task_role ON workflow_step_participants USING btree (task_id, role) WHERE (task_id <> ''::text); + + + +CREATE INDEX idx_workflow_steps_pull_from ON workflow_steps USING btree (pull_from_step_id); + + + +CREATE UNIQUE INDEX idx_workflow_steps_single_start ON workflow_steps USING btree (workflow_id) WHERE (is_start_step = 1); + + + +CREATE INDEX idx_workflow_steps_workflow ON workflow_steps USING btree (workflow_id); + + + +CREATE INDEX idx_workflows_workspace_id ON workflows USING btree (workspace_id); + + + +CREATE UNIQUE INDEX uniq_improve_kandev_workflows ON workflows USING btree (workspace_id, workflow_template_id, hidden) WHERE (workflow_template_id = ANY (ARRAY['improve-kandev'::text, 'report-kandev-issue'::text])); + + + +CREATE UNIQUE INDEX uniq_office_cost_usage_event ON office_cost_events USING btree (usage_event_id) WHERE (usage_event_id IS NOT NULL); + + + +CREATE UNIQUE INDEX uniq_repository_branch_policies_repository_lower_name ON repository_branch_policies USING btree (repository_id, lower(name)); + + + +CREATE UNIQUE INDEX uniq_repository_sets_workspace_lower_name ON repository_sets USING btree (workspace_id, lower(name)); + + + +CREATE UNIQUE INDEX uniq_session_commits_session_sha ON task_session_commits USING btree (session_id, commit_sha); + + + +CREATE UNIQUE INDEX uniq_task_environments_task_id ON task_environments USING btree (task_id); + + + +CREATE UNIQUE INDEX uniq_task_usage_events_usage_event_id ON task_usage_events USING btree (usage_event_id); + + + +CREATE UNIQUE INDEX uniq_tasks_external_id ON tasks USING btree (workspace_id, external_id) WHERE (external_id IS NOT NULL); + + + +CREATE UNIQUE INDEX uniq_workflow_step_decisions_active_decider ON workflow_step_decisions USING btree (task_id, step_id, decider_id, role) WHERE ((superseded_at IS NULL) AND (decider_id <> ''::text) AND (role <> ''::text)); + + + +ALTER TABLE ONLY agent_profile_mcp_configs + ADD CONSTRAINT agent_profile_mcp_configs_profile_id_fkey FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY agent_profiles + ADD CONSTRAINT agent_profiles_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY dynamic_agent_profiles + ADD CONSTRAINT dynamic_agent_profiles_profile_id_fkey FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY dynamic_agent_routes + ADD CONSTRAINT dynamic_agent_routes_dynamic_profile_id_fkey FOREIGN KEY (dynamic_profile_id) REFERENCES dynamic_agent_profiles(profile_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY dynamic_agent_routes + ADD CONSTRAINT dynamic_agent_routes_execution_profile_id_fkey FOREIGN KEY (execution_profile_id) REFERENCES agent_profiles(id) ON DELETE RESTRICT; + + + +ALTER TABLE ONLY dynamic_route_attempts + ADD CONSTRAINT dynamic_route_attempts_session_id_fkey FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY dynamic_route_states + ADD CONSTRAINT dynamic_route_states_session_id_fkey FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY executor_profiles + ADD CONSTRAINT executor_profiles_executor_id_fkey FOREIGN KEY (executor_id) REFERENCES executors(id); + + + +ALTER TABLE ONLY notification_subscriptions + ADD CONSTRAINT notification_subscriptions_provider_id_fkey FOREIGN KEY (provider_id) REFERENCES notification_providers(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY office_routine_runs + ADD CONSTRAINT office_routine_runs_routine_id_fkey FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY office_routine_triggers + ADD CONSTRAINT office_routine_triggers_routine_id_fkey FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY office_run_route_attempts + ADD CONSTRAINT office_run_route_attempts_run_id_fkey FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY office_task_labels + ADD CONSTRAINT office_task_labels_label_id_fkey FOREIGN KEY (label_id) REFERENCES office_labels(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY office_task_labels + ADD CONSTRAINT office_task_labels_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY office_task_tree_hold_members + ADD CONSTRAINT office_task_tree_hold_members_hold_id_fkey FOREIGN KEY (hold_id) REFERENCES office_task_tree_holds(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY office_task_tree_hold_members + ADD CONSTRAINT office_task_tree_hold_members_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY office_task_tree_holds + ADD CONSTRAINT office_task_tree_holds_root_task_id_fkey FOREIGN KEY (root_task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY repositories + ADD CONSTRAINT repositories_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY repository_branch_policies + ADD CONSTRAINT repository_branch_policies_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY repository_scripts + ADD CONSTRAINT repository_scripts_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY repository_secret_bindings + ADD CONSTRAINT repository_secret_bindings_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY repository_set_items + ADD CONSTRAINT repository_set_items_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY repository_set_items + ADD CONSTRAINT repository_set_items_repository_set_id_fkey FOREIGN KEY (repository_set_id) REFERENCES repository_sets(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY repository_sets + ADD CONSTRAINT repository_sets_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY session_file_reviews + ADD CONSTRAINT session_file_reviews_session_id_fkey FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY session_step_history + ADD CONSTRAINT session_step_history_session_id_fkey FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_document_revisions + ADD CONSTRAINT task_document_revisions_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_documents + ADD CONSTRAINT task_documents_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_environment_repos + ADD CONSTRAINT task_environment_repos_task_environment_id_fkey FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_environments + ADD CONSTRAINT task_environments_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_message_attachments + ADD CONSTRAINT task_message_attachments_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_plan_revisions + ADD CONSTRAINT task_plan_revisions_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_plans + ADD CONSTRAINT task_plans_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_repositories + ADD CONSTRAINT task_repositories_repository_id_fkey FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_repositories + ADD CONSTRAINT task_repositories_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_review_findings + ADD CONSTRAINT task_review_findings_run_id_fkey FOREIGN KEY (run_id) REFERENCES task_review_runs(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_review_findings + ADD CONSTRAINT task_review_findings_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_review_runs + ADD CONSTRAINT task_review_runs_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_session_commits + ADD CONSTRAINT task_session_commits_session_id_fkey FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_session_git_snapshots + ADD CONSTRAINT task_session_git_snapshots_session_id_fkey FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE SET NULL; + + + +ALTER TABLE ONLY task_session_git_snapshots + ADD CONSTRAINT task_session_git_snapshots_task_environment_id_fkey FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_session_messages + ADD CONSTRAINT task_session_messages_task_session_id_fkey FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_session_messages + ADD CONSTRAINT task_session_messages_turn_id_fkey FOREIGN KEY (turn_id) REFERENCES task_session_turns(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_session_subagents + ADD CONSTRAINT task_session_subagents_task_session_id_fkey FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_session_turns + ADD CONSTRAINT task_session_turns_task_session_id_fkey FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_sessions + ADD CONSTRAINT task_sessions_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_status_summaries + ADD CONSTRAINT task_status_summaries_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_step_transitions + ADD CONSTRAINT task_step_transitions_session_id_fkey FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE SET NULL; + + + +ALTER TABLE ONLY task_step_transitions + ADD CONSTRAINT task_step_transitions_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_usage_events + ADD CONSTRAINT task_usage_events_session_id_fkey FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE SET NULL; + + + +ALTER TABLE ONLY task_usage_events + ADD CONSTRAINT task_usage_events_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_walkthroughs + ADD CONSTRAINT task_walkthroughs_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_workspace_folders + ADD CONSTRAINT task_workspace_folders_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY task_workspace_group_members + ADD CONSTRAINT task_workspace_group_members_workspace_group_id_fkey FOREIGN KEY (workspace_group_id) REFERENCES task_workspace_groups(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY user_agent_profile_recent_use + ADD CONSTRAINT user_agent_profile_recent_use_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY utility_agent_calls + ADD CONSTRAINT utility_agent_calls_utility_id_fkey FOREIGN KEY (utility_id) REFERENCES utility_agents(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY workflow_step_entries + ADD CONSTRAINT workflow_step_entries_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY workflow_step_entry_markers + ADD CONSTRAINT workflow_step_entry_markers_entry_id_fkey FOREIGN KEY (entry_id) REFERENCES workflow_step_entries(id) ON DELETE CASCADE; + +INSERT INTO kandev_meta (key, value) +VALUES ('fixture.sentinel', 'v0.93.0-postgres-sentinel') +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + +INSERT INTO workspaces (id, name, description, created_at, updated_at) +VALUES ('fixture-v0930-task-workspace', 'Fixture Task Workspace', 'preserved task workspace', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO tasks (id, workspace_id, title, description, created_at, updated_at) +VALUES ('fixture-v0930-task', 'fixture-v0930-task-workspace', 'Fixture Task', 'preserved task row', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO tasks (id, workspace_id, title, description, created_at, updated_at) +VALUES ('fixture-v0930-analytics-task', 'fixture-v0930-task-workspace', 'Fixture Analytics Task', 'preserved analytics row', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at) +VALUES ('fixture-v0930-workflow-template', 'Fixture Workflow Template', 'preserved workflow template', 0, '[]', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO workflow_steps (id, workflow_id, name, position, created_at, updated_at) +VALUES ('fixture-v0930-workflow-step', 'fixture-v0930-workflow', 'Fixture Workflow Step', 0, '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO agents (id, name, created_at, updated_at) +VALUES ('fixture-v0930-agent', 'Fixture Agent', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, created_at, updated_at) +VALUES ('fixture-v0930-agent-profile', 'fixture-v0930-agent', 'fixture-profile', 'Fixture Agent Profile', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO users (id, email, display_name, role, status, created_at, updated_at) +VALUES ('fixture-v0930-user', 'fixture-v0930@example.test', 'Fixture User', 'admin', 'active', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at) +VALUES ('fixture-v0930-notification-provider', 'fixture-v0930-user', 'Fixture Notification Provider', 'webhook', '{}', 1, '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO editors (id, type, name, kind, command, scheme, created_at, updated_at) +VALUES ('fixture-v0930-editor', 'fixture-editor', 'Fixture Editor', 'local', 'fixture-editor', 'fixture', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at) +VALUES ('fixture-v0930-prompt', 'Fixture Prompt', 'preserved prompt content', 0, '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO utility_agents (id, name, description, prompt, created_at, updated_at) +VALUES ('fixture-v0930-utility', 'Fixture Utility', 'preserved utility', 'preserved utility prompt', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO office_projects (id, workspace_id, name, description, status, created_at, updated_at) +VALUES ('fixture-v0930-office-project', 'fixture-v0930-task-workspace', 'Fixture Office Project', 'preserved office project', 'active', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO runs (id, agent_profile_id, reason, status, requested_at) +VALUES ('fixture-v0930-run', 'fixture-v0930-agent-profile', 'fixture run', 'queued', '2024-01-02 03:04:05+00:00'); +INSERT INTO user_terminals (id, task_id, environment_id, seq, custom_name) +VALUES ('fixture-v0930-terminal', 'fixture-v0930-task', 'fixture-v0930-environment', 0, 'Fixture Terminal'); +INSERT INTO quick_terminal_tabs (tab_id, user_id, workspace_id, sequence, status) +VALUES ('fixture-v0930-quick-terminal', 'fixture-v0930-user', 'fixture-v0930-task-workspace', 0, 'connecting'); +INSERT INTO runtime_flag_overrides (key, value, created_at, updated_at) +VALUES ('fixture.v0930.flag', 1, '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO auth_identities (id, user_id, provider, subject, password_hash, created_at, updated_at) +VALUES ('fixture-v0930-identity', 'fixture-v0930-user', 'local', 'fixture-v0930-subject', 'fixture-v0930-hash', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO auth_sessions (id, user_id, token_sha256, created_at, expires_at, last_seen_at) +VALUES ('fixture-v0930-session', 'fixture-v0930-user', 'fixture-v0930-token-hash', '2024-01-02 03:04:05+00:00', '2034-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO secrets (id, name, scope, workspace_id, encrypted_value, nonce, created_at, updated_at) +VALUES ('fixture-v0930-secret', 'Fixture Secret', 'global', '', '', '', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO telemetry_activations (contract_key, contract_version, activated_at) +VALUES ('fixture.v0930.contract', 7, '2024-01-02 03:04:05+00:00'); diff --git a/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/sqlite.sql b/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/sqlite.sql new file mode 100644 index 0000000000..bba5d37111 --- /dev/null +++ b/apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/sqlite.sql @@ -0,0 +1,1503 @@ +CREATE TABLE agent_continuation_summaries ( + agent_profile_id TEXT NOT NULL, + scope TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + content_tokens INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by_run_id TEXT NOT NULL DEFAULT '', + PRIMARY KEY (agent_profile_id, scope) + ); +CREATE TABLE agent_profile_mcp_configs ( + profile_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + servers_json TEXT NOT NULL DEFAULT '{}', + meta_json TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE + ); +CREATE TABLE agent_profiles ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + name TEXT NOT NULL, + agent_display_name TEXT NOT NULL, + model TEXT NOT NULL DEFAULT '', + mode TEXT DEFAULT NULL, + migrated_from TEXT DEFAULT NULL, + auto_approve INTEGER NOT NULL DEFAULT 0, + dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0, + allow_indexing INTEGER NOT NULL DEFAULT 1, + cli_passthrough INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + user_modified INTEGER NOT NULL DEFAULT 0, + plan TEXT DEFAULT '', + cli_flags TEXT DEFAULT NULL, + env_vars TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + workspace_id TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + icon TEXT NOT NULL DEFAULT '', + reports_to TEXT NOT NULL DEFAULT '', + skill_ids TEXT NOT NULL DEFAULT '[]', + desired_skills TEXT NOT NULL DEFAULT '[]', + custom_prompt TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'idle' + CHECK (status IN ('idle','working','paused','stopped','pending_approval')), + pause_reason TEXT NOT NULL DEFAULT '', + last_run_finished_at TIMESTAMP, + max_concurrent_sessions INTEGER NOT NULL DEFAULT 1, + cooldown_sec INTEGER NOT NULL DEFAULT 0, + skip_idle_runs INTEGER NOT NULL DEFAULT 0, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + failure_threshold INTEGER NOT NULL DEFAULT 3, + executor_preference TEXT NOT NULL DEFAULT '', + budget_monthly_cents INTEGER NOT NULL DEFAULT 0, + settings TEXT NOT NULL DEFAULT '{}', + permissions TEXT NOT NULL DEFAULT '{}', + command_prefix TEXT NOT NULL DEFAULT '', fallback_model TEXT NOT NULL DEFAULT '', auto_fallback INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); +CREATE TABLE agent_wakeup_requests ( + id TEXT PRIMARY KEY, + agent_profile_id TEXT NOT NULL, + source TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + payload TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL, + coalesced_count INTEGER NOT NULL DEFAULT 1, + idempotency_key TEXT, + run_id TEXT NOT NULL DEFAULT '', + requested_at TIMESTAMP NOT NULL, + claimed_at TIMESTAMP, + finished_at TIMESTAMP + ); +CREATE TABLE agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + workspace_id TEXT DEFAULT NULL, + supports_mcp INTEGER NOT NULL DEFAULT 0, + mcp_config_path TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + , tui_config TEXT DEFAULT NULL); +CREATE TABLE auth_api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + prefix TEXT NOT NULL, + token_sha256 TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL, + last_used_at DATETIME, + expires_at DATETIME + ); +CREATE TABLE auth_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'local', + subject TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE (user_id, provider), + UNIQUE (provider, subject) + ); +CREATE TABLE auth_invites ( + id TEXT PRIMARY KEY, + token_sha256 TEXT NOT NULL UNIQUE, + email TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'member', + created_by TEXT NOT NULL, + created_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + used_by TEXT, + used_at DATETIME + ); +CREATE TABLE auth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token_sha256 TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + last_seen_at DATETIME NOT NULL, + user_agent TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '' + ); +CREATE TABLE custom_prompts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + content TEXT NOT NULL, + builtin INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE dynamic_agent_profiles ( + profile_id TEXT PRIMARY KEY, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE + ); +CREATE TABLE dynamic_agent_routes ( + dynamic_profile_id TEXT NOT NULL, + position INTEGER NOT NULL, + execution_profile_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + rules_json TEXT NOT NULL DEFAULT '{}', + PRIMARY KEY (dynamic_profile_id, position), + FOREIGN KEY (dynamic_profile_id) REFERENCES dynamic_agent_profiles(profile_id) ON DELETE CASCADE, + FOREIGN KEY (execution_profile_id) REFERENCES agent_profiles(id) ON DELETE RESTRICT + ); +CREATE TABLE dynamic_installation_keys ( + id INTEGER PRIMARY KEY CHECK (id = 1), + key_bytes BLOB NOT NULL, + created_at TIMESTAMP NOT NULL + ); +CREATE TABLE dynamic_resource_circuits ( + resource_key TEXT PRIMARY KEY, + state TEXT NOT NULL, + until_at TIMESTAMP, + code TEXT NOT NULL DEFAULT '', + probe_until TIMESTAMP, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE dynamic_route_attempts ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + logical_profile_id TEXT NOT NULL, + execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation BIGINT NOT NULL, + profile_version BIGINT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + UNIQUE (session_id, route_generation), + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); +CREATE TABLE dynamic_route_states ( + session_id TEXT PRIMARY KEY, + logical_profile_id TEXT NOT NULL, + execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation BIGINT NOT NULL DEFAULT 0, + profile_version BIGINT NOT NULL DEFAULT 0, + state TEXT NOT NULL DEFAULT 'selecting', + continuation_json TEXT NOT NULL DEFAULT '', + policy_state_json TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); +CREATE TABLE editors ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + kind TEXT NOT NULL, + command TEXT NOT NULL, + scheme TEXT NOT NULL, + config TEXT, + installed INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE environments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, + is_system INTEGER NOT NULL DEFAULT 0, + worktree_root TEXT DEFAULT '', + image_tag TEXT DEFAULT '', + dockerfile TEXT DEFAULT '', + build_config TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP + ); +CREATE TABLE executor_profiles ( + id TEXT PRIMARY KEY, + executor_id TEXT NOT NULL, + name TEXT NOT NULL, + mcp_policy TEXT DEFAULT '', + config TEXT DEFAULT '{}', + prepare_script TEXT DEFAULT '', + cleanup_script TEXT DEFAULT '', + env_vars TEXT DEFAULT '[]', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (executor_id) REFERENCES executors(id) + ); +CREATE TABLE executors ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + is_system INTEGER NOT NULL DEFAULT 0, + resumable INTEGER NOT NULL DEFAULT 1, + config TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP + ); +CREATE TABLE executors_running ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + task_id TEXT NOT NULL, + execution_profile_id TEXT NOT NULL DEFAULT '', + executor_id TEXT NOT NULL, + runtime TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'starting', + resumable INTEGER NOT NULL DEFAULT 0, + resume_token TEXT DEFAULT '', + agent_execution_id TEXT DEFAULT '', + container_id TEXT DEFAULT '', + agentctl_url TEXT DEFAULT '', + agentctl_port INTEGER DEFAULT 0, + pid INTEGER DEFAULT 0, + local_pid INTEGER DEFAULT 0, + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + last_seen_at TIMESTAMP, + error_message TEXT DEFAULT '', + metadata TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + , last_message_uuid TEXT DEFAULT ''); +CREATE TABLE kandev_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' +); +CREATE TABLE notification_deliveries ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + event_type TEXT NOT NULL, + task_session_id TEXT NOT NULL, + occurrence_id TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + UNIQUE(provider_id, event_type, occurrence_id) + ); +CREATE TABLE notification_migrations ( + name TEXT PRIMARY KEY, + completed_at TIMESTAMP NOT NULL + ); +CREATE TABLE notification_providers ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + config TEXT DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE notification_subscriptions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + event_type TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(provider_id, event_type), + FOREIGN KEY (provider_id) REFERENCES notification_providers(id) ON DELETE CASCADE + ); +CREATE TABLE office_activity_log ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + actor_type TEXT NOT NULL, + actor_id TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT DEFAULT '', + target_id TEXT DEFAULT '', + details TEXT DEFAULT '{}', + run_id TEXT NOT NULL DEFAULT '', + session_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL + ); +CREATE TABLE office_agent_instructions ( + id TEXT PRIMARY KEY, + agent_profile_id TEXT NOT NULL, + filename TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + is_entry INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(agent_profile_id, filename) + ); +CREATE TABLE office_agent_memory ( + id TEXT PRIMARY KEY, + agent_profile_id TEXT NOT NULL, + layer TEXT NOT NULL, + key TEXT NOT NULL, + content TEXT DEFAULT '', + metadata TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(agent_profile_id, layer, key) + ); +CREATE TABLE office_agent_runtime ( + agent_id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'idle', + pause_reason TEXT DEFAULT '', + last_run_finished_at TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); +CREATE TABLE office_approvals ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + type TEXT NOT NULL, + requested_by_agent_profile_id TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + payload TEXT DEFAULT '{}', + decision_note TEXT DEFAULT '', + decided_by TEXT DEFAULT '', + decided_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE office_budget_policies ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + scope_type TEXT NOT NULL, + scope_id TEXT NOT NULL, + limit_subcents INTEGER NOT NULL, + period TEXT NOT NULL, + alert_threshold_pct INTEGER DEFAULT 80, + action_on_exceed TEXT DEFAULT 'notify_only', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE office_channels ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + agent_profile_id TEXT NOT NULL, + platform TEXT NOT NULL, + config TEXT DEFAULT '{}', + webhook_secret TEXT NOT NULL DEFAULT '', + status TEXT DEFAULT 'active', + task_id TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE office_cost_events ( + id TEXT PRIMARY KEY, + session_id TEXT DEFAULT '', + task_id TEXT DEFAULT '', + agent_profile_id TEXT DEFAULT '', + project_id TEXT DEFAULT '', + model TEXT DEFAULT '', + provider TEXT DEFAULT '', + tokens_in INTEGER DEFAULT 0, + tokens_cached_in INTEGER DEFAULT 0, + tokens_cached_read INTEGER, + tokens_cached_write INTEGER, + tokens_out INTEGER DEFAULT 0, + cost_subcents INTEGER NOT NULL DEFAULT 0, + estimated INTEGER NOT NULL DEFAULT 0, + turn_id TEXT, + usage_event_id TEXT, + cost_source TEXT, + rate_input_per_million INTEGER, + rate_cached_read_per_million INTEGER, + rate_cached_write_per_million INTEGER, + rate_output_per_million INTEGER, + pricing_catalog_version TEXT, + cost_contract_version INTEGER, + occurred_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL + ); +CREATE TABLE office_inbox_dismissals ( + user_id TEXT NOT NULL, + item_kind TEXT NOT NULL, + item_id TEXT NOT NULL, + dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, item_kind, item_id) + ); +CREATE TABLE office_labels ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#6b7280', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(workspace_id, name) + ); +CREATE TABLE office_onboarding ( + workspace_id TEXT PRIMARY KEY, + completed INTEGER NOT NULL DEFAULT 0, + ceo_agent_id TEXT DEFAULT '', + first_task_id TEXT DEFAULT '', + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); +CREATE TABLE office_projects ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + lead_agent_profile_id TEXT DEFAULT '', + color TEXT DEFAULT '', + budget_cents INTEGER DEFAULT 0, + repositories TEXT DEFAULT '[]', + executor_config TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE office_provider_health ( + workspace_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + scope TEXT NOT NULL, + scope_value TEXT NOT NULL, + state TEXT NOT NULL, + error_code TEXT, + retry_at TIMESTAMP, + backoff_step INTEGER NOT NULL DEFAULT 0, + last_failure TIMESTAMP, + last_success TIMESTAMP, + raw_excerpt TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (workspace_id, provider_id, scope, scope_value) + ); +CREATE TABLE office_routine_runs ( + id TEXT PRIMARY KEY, + routine_id TEXT NOT NULL, + trigger_id TEXT DEFAULT '', + source TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'received', + trigger_payload TEXT DEFAULT '{}', + linked_task_id TEXT DEFAULT '', + coalesced_into_run_id TEXT DEFAULT '', + dispatch_fingerprint TEXT DEFAULT '', + started_at TIMESTAMP, + completed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE + ); +CREATE TABLE office_routine_triggers ( + id TEXT PRIMARY KEY, + routine_id TEXT NOT NULL, + kind TEXT NOT NULL, + cron_expression TEXT DEFAULT '', + timezone TEXT DEFAULT '', + public_id TEXT DEFAULT '', + signing_mode TEXT DEFAULT '', + secret TEXT DEFAULT '', + next_run_at TIMESTAMP, + last_fired_at TIMESTAMP, + enabled INTEGER DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE + ); +CREATE TABLE office_routines ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + task_template TEXT NOT NULL DEFAULT '{}', + assignee_agent_profile_id TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + concurrency_policy TEXT DEFAULT 'skip_if_active', + catch_up_policy TEXT NOT NULL DEFAULT 'enqueue_missed_with_cap', + catch_up_max INTEGER NOT NULL DEFAULT 25, + variables TEXT DEFAULT '{}', + last_run_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE office_run_route_attempts ( + run_id TEXT NOT NULL, + seq INTEGER NOT NULL, + execution_profile_id TEXT NOT NULL DEFAULT '', + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + tier TEXT NOT NULL, + tier_source TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL, + error_code TEXT, + error_confidence TEXT, + adapter_phase TEXT, + classifier_rule TEXT, + exit_code INTEGER, + raw_excerpt TEXT, + reset_hint TIMESTAMP, + started_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP, + PRIMARY KEY (run_id, seq), + FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE + ); +CREATE TABLE office_run_skills ( + run_id TEXT NOT NULL, + skill_id TEXT NOT NULL, + version TEXT NOT NULL, + content_hash TEXT NOT NULL, + materialized_path TEXT NOT NULL, + PRIMARY KEY (run_id, skill_id) + ); +CREATE TABLE office_skills ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'inline', + source_locator TEXT DEFAULT '', + content TEXT DEFAULT '', + file_inventory TEXT DEFAULT '[]', + version TEXT NOT NULL DEFAULT '', + content_hash TEXT NOT NULL DEFAULT '', + approval_state TEXT NOT NULL DEFAULT 'approved', + created_by_agent_profile_id TEXT DEFAULT '', + is_system INTEGER NOT NULL DEFAULT 0, + system_version TEXT NOT NULL DEFAULT '', + default_for_roles TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE(workspace_id, slug) + ); +CREATE TABLE office_task_labels ( + task_id TEXT NOT NULL, + label_id TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (task_id, label_id), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (label_id) REFERENCES office_labels(id) ON DELETE CASCADE + ); +CREATE TABLE office_task_tree_hold_members ( + hold_id TEXT NOT NULL, + task_id TEXT NOT NULL, + depth INTEGER NOT NULL, + task_status TEXT NOT NULL DEFAULT '', + skip_reason TEXT NOT NULL DEFAULT '', + PRIMARY KEY (hold_id, task_id), + FOREIGN KEY (hold_id) REFERENCES office_task_tree_holds(id) ON DELETE CASCADE, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); +CREATE TABLE office_task_tree_holds ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + root_task_id TEXT NOT NULL, + mode TEXT NOT NULL, + release_policy TEXT NOT NULL DEFAULT '{"strategy":"manual"}', + released_at TIMESTAMP, + released_by TEXT DEFAULT '', + released_reason TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (root_task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); +CREATE TABLE office_workspace_governance ( + workspace_id TEXT NOT NULL, + key TEXT NOT NULL, + value INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (workspace_id, key) + ); +CREATE TABLE office_workspace_routing ( + workspace_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + default_tier TEXT NOT NULL DEFAULT 'balanced', + provider_order TEXT NOT NULL DEFAULT '[]', + provider_profiles TEXT NOT NULL DEFAULT '{}', + tier_per_reason TEXT NOT NULL DEFAULT '{}', + role_tiers TEXT NOT NULL DEFAULT '{}', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); +CREATE TABLE office_workspace_settings ( + workspace_id TEXT PRIMARY KEY, + agent_failure_threshold INTEGER NOT NULL DEFAULT 3, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); +CREATE TABLE parent_child_wake_receipts ( + parent_task_id TEXT PRIMARY KEY, + child_set_key TEXT NOT NULL, + delivered_run_id TEXT NOT NULL DEFAULT '', + delivery_operation_id TEXT NOT NULL DEFAULT '', + delivered_at TIMESTAMP NOT NULL + ); +CREATE TABLE quick_terminal_tabs ( + tab_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + session_id TEXT, + sequence INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'connecting', + exit_code INTEGER, + error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, workspace_id, sequence) + ); +CREATE TABLE quick_terminal_workspace_sequences ( + user_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + next_sequence INTEGER NOT NULL, + PRIMARY KEY(user_id, workspace_id) + ); +CREATE TABLE repositories ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + source_type TEXT NOT NULL DEFAULT 'local', + local_path TEXT DEFAULT '', + provider TEXT DEFAULT '', + provider_repo_id TEXT DEFAULT '', + provider_host TEXT DEFAULT '', + provider_scope TEXT DEFAULT '', + provider_owner TEXT DEFAULT '', + provider_name TEXT DEFAULT '', + remote_url TEXT DEFAULT '', + default_branch TEXT DEFAULT '', + worktree_branch_prefix TEXT DEFAULT 'feature/', + worktree_branch_template TEXT DEFAULT 'feature/{title}-{suffix}', + pull_before_worktree INTEGER NOT NULL DEFAULT 1, + setup_script TEXT DEFAULT '', + cleanup_script TEXT DEFAULT '', + dev_script TEXT DEFAULT '', + copy_files TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); +CREATE TABLE repository_branch_policies ( + id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + base_branch TEXT NOT NULL, + branch_template TEXT NOT NULL, + pull_request_target TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE + ); +CREATE TABLE repository_scripts ( + id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + name TEXT NOT NULL, + command TEXT NOT NULL, + position INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE + ); +CREATE TABLE repository_secret_bindings ( + repository_id TEXT NOT NULL, + key TEXT NOT NULL, + secret_id TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (repository_id, key), + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE + ); +CREATE TABLE repository_set_items ( + id TEXT PRIMARY KEY, + repository_set_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (repository_set_id) REFERENCES repository_sets(id) ON DELETE CASCADE, + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE, + UNIQUE(repository_set_id, repository_id) + ); +CREATE TABLE repository_sets ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + UNIQUE(workspace_id, name) + ); +CREATE TABLE run_events ( + run_id TEXT NOT NULL, + seq INTEGER NOT NULL, + event_type TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + payload TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (run_id, seq) + ); +CREATE TABLE runs ( + id TEXT PRIMARY KEY, + agent_profile_id TEXT NOT NULL, + reason TEXT NOT NULL, + payload TEXT DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'queued', + coalesced_count INTEGER DEFAULT 1, + idempotency_key TEXT, + context_snapshot TEXT DEFAULT '{}', + capabilities TEXT NOT NULL DEFAULT '{}', + input_snapshot TEXT NOT NULL DEFAULT '{}', + output_summary TEXT NOT NULL DEFAULT '', + failure_reason TEXT NOT NULL DEFAULT '', + session_id TEXT NOT NULL DEFAULT '', + retry_count INTEGER DEFAULT 0, + scheduled_retry_at TIMESTAMP, + error_message TEXT NOT NULL DEFAULT '', + cancel_reason TEXT, + -- outcome (task-delivery-ledger): nullable, one of the eight + -- Office run outcome values on the finished path, NULL on failed + -- and on every pre-activation row. See migrateRunOutcome for the + -- ADD COLUMN that converges existing databases. + outcome TEXT, + -- Provider routing (office-provider-routing). + logical_provider_order TEXT, + requested_tier TEXT, + resolved_execution_profile_id TEXT, + resolved_provider_id TEXT, + resolved_model TEXT, + current_route_attempt_seq INTEGER NOT NULL DEFAULT 0, + routing_blocked_status TEXT, + earliest_retry_at TIMESTAMP, + -- route_cycle_baseline_seq marks the floor at which the current + -- retry cycle began. excludedFromAttempts filters prior attempt + -- rows with seq <= baseline so a parked-then-lifted run gets a + -- fresh exclusion list instead of re-inheriting every provider + -- that failed in the previous cycle. + route_cycle_baseline_seq INTEGER NOT NULL DEFAULT 0, + -- Heartbeat-rework run inspection columns: structured adapter + -- output, the assembled prompt the agent received, and the + -- continuation summary that was prepended (if any). + result_json TEXT NOT NULL DEFAULT '{}', + assembled_prompt TEXT NOT NULL DEFAULT '', + summary_injected TEXT NOT NULL DEFAULT '', + -- continuation_scope is the continuation-summary scope key + -- (models.ContinuationScopeForRun) decided once at run creation + -- and persisted so every later reader/writer of this run's + -- continuation summary uses the same value instead of + -- re-deriving it against a context_snapshot a coalesced wakeup + -- may have since patched. + continuation_scope TEXT NOT NULL DEFAULT '', + requested_at TIMESTAMP NOT NULL, + claimed_at TIMESTAMP, + finished_at TIMESTAMP + ); +CREATE TABLE runtime_flag_overrides ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL CHECK (value IN (0, 1)), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); +CREATE TABLE secrets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + user_id TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'global', + workspace_id TEXT NOT NULL DEFAULT '', + encrypted_value BLOB NOT NULL, + nonce BLOB NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); +CREATE TABLE session_file_reviews ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + file_path TEXT NOT NULL, + reviewed INTEGER NOT NULL DEFAULT 0, + diff_hash TEXT NOT NULL DEFAULT '', + reviewed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, + UNIQUE(session_id, file_path) + ); +CREATE TABLE session_step_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + from_step_id TEXT, + to_step_id TEXT NOT NULL, + trigger TEXT NOT NULL, + actor_id TEXT, + metadata TEXT, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); +CREATE TABLE task_blockers ( + task_id TEXT NOT NULL, + blocker_task_id TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (task_id, blocker_task_id), + CHECK (task_id != blocker_task_id) + ); +CREATE TABLE task_comments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + author_type TEXT NOT NULL, + author_id TEXT NOT NULL, + body TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'user', + reply_channel_id TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL + ); +CREATE TABLE task_document_revisions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + document_key TEXT NOT NULL DEFAULT 'plan', + revision_number INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT 'Plan', + content TEXT NOT NULL DEFAULT '', + author_kind TEXT NOT NULL DEFAULT 'agent', + author_name TEXT NOT NULL DEFAULT '', + revert_of_revision_id TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + UNIQUE (task_id, document_key, revision_number) + ); +CREATE TABLE task_documents ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + key TEXT NOT NULL DEFAULT 'plan', + type TEXT NOT NULL DEFAULT 'plan', + title TEXT NOT NULL DEFAULT 'Plan', + content TEXT NOT NULL DEFAULT '', + author_kind TEXT NOT NULL DEFAULT 'agent', + author_name TEXT NOT NULL DEFAULT '', + filename TEXT NOT NULL DEFAULT '', + mime_type TEXT NOT NULL DEFAULT '', + size_bytes INTEGER NOT NULL DEFAULT 0, + disk_path TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + UNIQUE(task_id, key) + ); +CREATE TABLE task_environment_repos ( + id TEXT PRIMARY KEY, + task_environment_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + branch_slug TEXT NOT NULL DEFAULT '', + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + position INTEGER DEFAULT 0, + error_message TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + merged_at TIMESTAMP, + deleted_at TIMESTAMP, + FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE, + UNIQUE(task_environment_id, repository_id, branch_slug) + ); +CREATE TABLE task_environments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + executor_type TEXT NOT NULL DEFAULT '', + executor_id TEXT DEFAULT '', + executor_profile_id TEXT DEFAULT '', + control_port INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'creating', + materialization_session_id TEXT DEFAULT '', + workspace_path TEXT DEFAULT '', + container_id TEXT DEFAULT '', + container_bootstrap_nonce_secret_id TEXT DEFAULT '', + container_control_auth_token_secret_id TEXT DEFAULT '', + sandbox_id TEXT DEFAULT '', + task_dir_name TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); +CREATE TABLE task_message_attachments ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT '', + workspace_id TEXT NOT NULL, + task_id TEXT NOT NULL DEFAULT '', + session_id TEXT NOT NULL DEFAULT '', + message_id TEXT NOT NULL DEFAULT '', + queue_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + mime_type TEXT NOT NULL DEFAULT 'application/octet-stream', + kind TEXT NOT NULL DEFAULT 'resource', + delivery_mode TEXT NOT NULL DEFAULT 'prompt', + size_bytes INTEGER NOT NULL, + storage_key TEXT NOT NULL UNIQUE, + state TEXT NOT NULL DEFAULT 'staged', + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + ); +CREATE TABLE task_plan_revisions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + revision_number INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT 'Plan', + content TEXT NOT NULL DEFAULT '', + author_kind TEXT NOT NULL DEFAULT 'agent', + author_name TEXT NOT NULL DEFAULT '', + revert_of_revision_id TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + UNIQUE (task_id, revision_number) + ); +CREATE TABLE task_plans ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL DEFAULT 'Plan', + content TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT 'agent', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + implementation_started_at TIMESTAMP, + implementation_started_session_id TEXT, + implementation_started_by TEXT, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); +CREATE TABLE task_repositories ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + base_branch TEXT DEFAULT '', + checkout_branch TEXT DEFAULT '', + branch_policy_id TEXT DEFAULT '', + branch_policy_name TEXT DEFAULT '', + branch_policy_base_branch TEXT DEFAULT '', + branch_policy_branch_template TEXT DEFAULT '', + branch_policy_pull_request_target TEXT DEFAULT '', + position INTEGER DEFAULT 0, + metadata TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE, + UNIQUE(task_id, repository_id, base_branch, checkout_branch) + ); +CREATE TABLE task_resource_cleanup_jobs ( + id TEXT PRIMARY KEY, + operation_id TEXT NOT NULL UNIQUE, + task_id TEXT NOT NULL, + trigger TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + resource_snapshot TEXT NOT NULL DEFAULT '{}', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMP, + last_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP + ); +CREATE TABLE task_review_findings ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + task_id TEXT NOT NULL, + repository_id TEXT NOT NULL DEFAULT '', + repository_name TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + side TEXT NOT NULL DEFAULT 'additions', + severity TEXT NOT NULL DEFAULT 'minor', + category TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + suggestion TEXT NOT NULL DEFAULT '', + anchor_text TEXT NOT NULL DEFAULT '', + file_diff_hash TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + resolved_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (run_id) REFERENCES task_review_runs(id) ON DELETE CASCADE, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); +CREATE TABLE task_review_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + session_id TEXT NOT NULL DEFAULT '', + trigger TEXT NOT NULL DEFAULT 'manual', + workflow_step_id TEXT NOT NULL DEFAULT '', + agent_id TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + error_code TEXT NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + finding_count INTEGER NOT NULL DEFAULT 0, + file_count INTEGER NOT NULL DEFAULT 0, + repository_count INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + response_tokens INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + entry_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); +CREATE TABLE task_session_commits ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + commit_sha TEXT NOT NULL, + parent_sha TEXT DEFAULT '', + author_name TEXT DEFAULT '', + author_email TEXT DEFAULT '', + commit_message TEXT DEFAULT '', + committed_at TIMESTAMP NOT NULL, + pre_commit_snapshot_id TEXT DEFAULT '', + post_commit_snapshot_id TEXT DEFAULT '', + files_changed INTEGER DEFAULT 0, + insertions INTEGER DEFAULT 0, + deletions INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); +CREATE TABLE task_session_git_snapshots ( + id TEXT PRIMARY KEY, + task_environment_id TEXT NOT NULL, + session_id TEXT, + snapshot_type TEXT NOT NULL, + branch TEXT NOT NULL, + remote_branch TEXT DEFAULT '', + head_commit TEXT DEFAULT '', + base_commit TEXT DEFAULT '', + ahead INTEGER DEFAULT 0, + behind INTEGER DEFAULT 0, + files TEXT DEFAULT '{}', + triggered_by TEXT DEFAULT '', + metadata TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE SET NULL + ); +CREATE TABLE task_session_messages ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + task_id TEXT DEFAULT '', + turn_id TEXT NOT NULL, + author_type TEXT NOT NULL DEFAULT 'user', + author_id TEXT DEFAULT '', + content TEXT NOT NULL, + requests_input INTEGER DEFAULT 0, + type TEXT NOT NULL DEFAULT 'message', + metadata TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, prompt_seq INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (turn_id) REFERENCES task_session_turns(id) ON DELETE CASCADE + ); +CREATE TABLE task_session_prompt_seq ( + task_session_id TEXT PRIMARY KEY, + last_seq INTEGER NOT NULL + ); +CREATE TABLE task_session_subagents ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + task_id TEXT NOT NULL, + turn_id TEXT, + tool_call_id TEXT NOT NULL, + agent_execution_id TEXT NOT NULL DEFAULT 'unknown', + parent_tool_call_id TEXT, + subagent_type TEXT, + description TEXT, + agent_id TEXT, + child_session_id TEXT, + model TEXT, + agent_status TEXT, + tool_status TEXT, + is_async INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER, + tool_use_count INTEGER, + duration_ms INTEGER, + source TEXT NOT NULL DEFAULT 'live', + observed_at TIMESTAMP NOT NULL, + settled_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL, + UNIQUE (task_session_id, agent_execution_id, tool_call_id), + FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); +CREATE TABLE task_session_turns ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + task_id TEXT NOT NULL, + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation BIGINT NOT NULL DEFAULT 0, + metadata TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE + ); +CREATE TABLE "task_sessions" ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + agent_profile_id TEXT, + execution_profile_id TEXT NOT NULL DEFAULT '', + route_generation INTEGER NOT NULL DEFAULT 0, + route_state TEXT NOT NULL DEFAULT '', + route_reason TEXT NOT NULL DEFAULT '', + downstream_acp_session_id TEXT NOT NULL DEFAULT '', + executor_id TEXT DEFAULT '', + executor_profile_id TEXT DEFAULT '', + environment_id TEXT DEFAULT '', + repository_id TEXT DEFAULT '', + base_branch TEXT DEFAULT '', + agent_profile_snapshot TEXT DEFAULT '{}', + executor_snapshot TEXT DEFAULT '{}', + environment_snapshot TEXT DEFAULT '{}', + repository_snapshot TEXT DEFAULT '{}', + state TEXT NOT NULL DEFAULT 'CREATED', + error_message TEXT DEFAULT '', + metadata TEXT DEFAULT '{}', + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL, + is_primary INTEGER DEFAULT 0, + is_passthrough INTEGER DEFAULT 0, + review_status TEXT DEFAULT '', + base_commit_sha TEXT DEFAULT '', + task_environment_id TEXT DEFAULT '', + cost_subcents INTEGER NOT NULL DEFAULT 0, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_cached_in BIGINT NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, workspace_path TEXT DEFAULT '', name TEXT DEFAULT '', last_read_message_id TEXT DEFAULT '', + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); +CREATE TABLE task_status_summaries ( + task_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 0, + summary TEXT NOT NULL DEFAULT '{}', + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); +CREATE TABLE task_step_transitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + session_id TEXT REFERENCES task_sessions(id) ON DELETE SET NULL, + from_workflow_id TEXT, + from_workflow_step_id TEXT, + to_workflow_id TEXT, + to_workflow_step_id TEXT, + trigger TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_id TEXT, + contract_version INTEGER NOT NULL, + occurred_at TIMESTAMP NOT NULL + ); +CREATE TABLE task_usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + usage_event_id TEXT NOT NULL, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + session_id TEXT REFERENCES task_sessions(id) ON DELETE SET NULL, + turn_id TEXT, + agent_profile_id TEXT NOT NULL DEFAULT '', + agent_type TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL DEFAULT '', + tokens_in BIGINT NOT NULL DEFAULT 0, + tokens_cached_read BIGINT, + tokens_cached_write BIGINT, + tokens_out BIGINT, + tokens_thought BIGINT, + tokens_total BIGINT NOT NULL DEFAULT 0, + cost_subcents BIGINT NOT NULL DEFAULT 0, + cost_source TEXT NOT NULL, + estimated INTEGER NOT NULL DEFAULT 0, + rate_input_per_million BIGINT, + rate_cached_read_per_million BIGINT, + rate_cached_write_per_million BIGINT, + rate_output_per_million BIGINT, + pricing_catalog_version TEXT, + contract_version INTEGER NOT NULL, + occurred_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL + ); +CREATE TABLE task_walkthroughs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL DEFAULT 'Walkthrough', + steps TEXT NOT NULL DEFAULT '[]', + created_by TEXT NOT NULL DEFAULT 'agent', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); +CREATE TABLE task_workspace_folders ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + local_path TEXT NOT NULL, + display_name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + UNIQUE(task_id, local_path), + UNIQUE(task_id, display_name) + ); +CREATE TABLE task_workspace_group_members ( + workspace_group_id TEXT NOT NULL, + task_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + released_at TIMESTAMP, + release_reason TEXT NOT NULL DEFAULT '', + released_by_cascade_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (workspace_group_id, task_id), + FOREIGN KEY (workspace_group_id) REFERENCES task_workspace_groups(id) ON DELETE CASCADE + ); +CREATE TABLE task_workspace_groups ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + owner_task_id TEXT NOT NULL, + materialized_path TEXT NOT NULL DEFAULT '', + materialized_environment_id TEXT NOT NULL DEFAULT '', + materialized_kind TEXT NOT NULL, + owned_by_kandev INTEGER NOT NULL DEFAULT 0, + cleanup_policy TEXT NOT NULL DEFAULT 'never_delete', + cleanup_status TEXT NOT NULL DEFAULT 'active', + cleaned_at TIMESTAMP, + cleanup_error TEXT NOT NULL DEFAULT '', + restore_status TEXT NOT NULL DEFAULT 'not_needed', + restore_error TEXT NOT NULL DEFAULT '', + restore_config_json TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE "tasks" ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + workflow_id TEXT NOT NULL DEFAULT '', + workflow_step_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + description TEXT DEFAULT '', + state TEXT DEFAULT 'TODO', + priority TEXT NOT NULL DEFAULT 'medium' + CHECK (priority IN ('critical','high','medium','low')), + position INTEGER DEFAULT 0, + wip_admitted INTEGER NOT NULL DEFAULT 1, + queued_for_step_id TEXT NOT NULL DEFAULT '', + queued_at TIMESTAMP, + metadata TEXT DEFAULT '{}', + is_ephemeral INTEGER NOT NULL DEFAULT 0, + parent_id TEXT DEFAULT '', + autopilot_enabled INTEGER NOT NULL DEFAULT 0, + archived_at TIMESTAMP, + archived_by_cascade_id TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + origin TEXT DEFAULT 'manual', + project_id TEXT DEFAULT '', + labels TEXT DEFAULT '[]', + identifier TEXT, + checkout_agent_id TEXT, + checkout_at TIMESTAMP, + checkout_run_id TEXT, + external_id TEXT COLLATE BINARY, + external_id_settled_at TIMESTAMP + ); +CREATE TABLE telemetry_activations ( + contract_key TEXT NOT NULL, + contract_version INTEGER NOT NULL, + activated_at TIMESTAMP NOT NULL, + PRIMARY KEY (contract_key, contract_version) + ); +CREATE TABLE user_agent_profile_recent_use ( + user_id TEXT NOT NULL, + context TEXT NOT NULL, + profile_ids TEXT NOT NULL DEFAULT '[]', + revision BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (user_id, context), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); +CREATE TABLE user_terminals ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + environment_id TEXT NOT NULL, + seq INTEGER NOT NULL, + custom_name TEXT, + state TEXT NOT NULL DEFAULT 'open', + initial_command TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(task_id, seq) + ); +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'admin', + status TEXT NOT NULL DEFAULT 'active', + settings TEXT NOT NULL DEFAULT '{}', + settings_revision BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE utility_agent_calls ( + id TEXT PRIMARY KEY, + utility_id TEXT NOT NULL, + session_id TEXT NOT NULL DEFAULT '', + resolved_prompt TEXT NOT NULL DEFAULT '', + response TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + agent_profile_id TEXT NOT NULL DEFAULT '', + execution_profile_id TEXT NOT NULL DEFAULT '', + prompt_tokens INTEGER NOT NULL DEFAULT 0, + response_tokens INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + FOREIGN KEY (utility_id) REFERENCES utility_agents(id) ON DELETE CASCADE + ); +CREATE TABLE utility_agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT 'claude-acp', + model TEXT NOT NULL DEFAULT '', + agent_profile_id TEXT NOT NULL DEFAULT '', + profile_binding_state TEXT NOT NULL DEFAULT 'explicit', + builtin INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE workflow_step_decisions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + step_id TEXT NOT NULL, + participant_id TEXT NOT NULL, + decision TEXT NOT NULL, + note TEXT DEFAULT '', + decided_at TIMESTAMP NOT NULL, + superseded_at TIMESTAMP NULL, + decider_type TEXT NOT NULL DEFAULT '', + decider_id TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '' + ); +CREATE TABLE workflow_step_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + step_id TEXT NOT NULL, + entry_seq INTEGER NOT NULL, + digest TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + UNIQUE(task_id, step_id, entry_seq) + ); +CREATE TABLE workflow_step_entry_markers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL REFERENCES workflow_step_entries(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + kind TEXT NOT NULL, + state TEXT NOT NULL, + operation_id TEXT, + error TEXT, + claimed_at TIMESTAMP, + completed_at TIMESTAMP, + CONSTRAINT uniq_workflow_step_entry_markers_entry_position UNIQUE(entry_id, position) + ); +CREATE TABLE workflow_step_participants ( + id TEXT PRIMARY KEY, + step_id TEXT NOT NULL DEFAULT '', + task_id TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + agent_profile_id TEXT NOT NULL DEFAULT '', + decision_required INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0 + , created_at TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00', provenance TEXT NOT NULL DEFAULT 'manual'); +CREATE TABLE workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + position INTEGER NOT NULL DEFAULT 0, + color TEXT, + prompt TEXT, + events TEXT, + allow_manual_move INTEGER DEFAULT 1, + is_start_step INTEGER DEFAULT 0, + show_in_command_panel INTEGER DEFAULT 1, + auto_archive_after_hours INTEGER DEFAULT 0, + agent_profile_id TEXT NOT NULL DEFAULT '', + stage_type TEXT NOT NULL DEFAULT 'custom', + auto_advance_requires_signal INTEGER NOT NULL DEFAULT 0, + cancel_triggers_turn_complete INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + , wip_limit INTEGER NOT NULL DEFAULT 0, pull_from_step_id TEXT NOT NULL DEFAULT ''); +CREATE TABLE workflow_templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + is_system INTEGER DEFAULT 0, + steps TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); +CREATE TABLE workflows ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + workflow_template_id TEXT DEFAULT '', + name TEXT NOT NULL, + description TEXT DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + hidden INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + , sort_order INTEGER NOT NULL DEFAULT 0, agent_profile_id TEXT DEFAULT '', is_system INTEGER DEFAULT 0, style TEXT NOT NULL DEFAULT 'kanban', source TEXT NOT NULL DEFAULT 'manual', source_path TEXT NOT NULL DEFAULT ''); +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + owner_id TEXT DEFAULT '', + default_executor_id TEXT DEFAULT '', + default_environment_id TEXT DEFAULT '', + default_agent_profile_id TEXT DEFAULT '', + default_config_agent_profile_id TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + , task_prefix TEXT DEFAULT 'KAN', task_sequence INTEGER DEFAULT 0, office_workflow_id TEXT DEFAULT ''); +INSERT INTO kandev_meta (key, value) +VALUES ('fixture.sentinel', 'v0.93.0-sqlite-sentinel') +ON CONFLICT (key) DO UPDATE SET value = excluded.value; + +INSERT INTO workspaces (id, name, description, created_at, updated_at) +VALUES ('fixture-v0930-task-workspace', 'Fixture Task Workspace', 'preserved task workspace', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO tasks (id, workspace_id, title, description, created_at, updated_at) +VALUES ('fixture-v0930-task', 'fixture-v0930-task-workspace', 'Fixture Task', 'preserved task row', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO tasks (id, workspace_id, title, description, created_at, updated_at) +VALUES ('fixture-v0930-analytics-task', 'fixture-v0930-task-workspace', 'Fixture Analytics Task', 'preserved analytics row', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at) +VALUES ('fixture-v0930-workflow-template', 'Fixture Workflow Template', 'preserved workflow template', 0, '[]', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO workflow_steps (id, workflow_id, name, position, created_at, updated_at) +VALUES ('fixture-v0930-workflow-step', 'fixture-v0930-workflow', 'Fixture Workflow Step', 0, '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO agents (id, name, created_at, updated_at) +VALUES ('fixture-v0930-agent', 'Fixture Agent', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, created_at, updated_at) +VALUES ('fixture-v0930-agent-profile', 'fixture-v0930-agent', 'fixture-profile', 'Fixture Agent Profile', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO users (id, email, display_name, role, status, created_at, updated_at) +VALUES ('fixture-v0930-user', 'fixture-v0930@example.test', 'Fixture User', 'admin', 'active', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at) +VALUES ('fixture-v0930-notification-provider', 'fixture-v0930-user', 'Fixture Notification Provider', 'webhook', '{}', 1, '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO editors (id, type, name, kind, command, scheme, created_at, updated_at) +VALUES ('fixture-v0930-editor', 'fixture-editor', 'Fixture Editor', 'local', 'fixture-editor', 'fixture', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at) +VALUES ('fixture-v0930-prompt', 'Fixture Prompt', 'preserved prompt content', 0, '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO utility_agents (id, name, description, prompt, created_at, updated_at) +VALUES ('fixture-v0930-utility', 'Fixture Utility', 'preserved utility', 'preserved utility prompt', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO office_projects (id, workspace_id, name, description, status, created_at, updated_at) +VALUES ('fixture-v0930-office-project', 'fixture-v0930-task-workspace', 'Fixture Office Project', 'preserved office project', 'active', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO runs (id, agent_profile_id, reason, status, requested_at) +VALUES ('fixture-v0930-run', 'fixture-v0930-agent-profile', 'fixture run', 'queued', '2024-01-02 03:04:05+00:00'); +INSERT INTO user_terminals (id, task_id, environment_id, seq, custom_name) +VALUES ('fixture-v0930-terminal', 'fixture-v0930-task', 'fixture-v0930-environment', 0, 'Fixture Terminal'); +INSERT INTO quick_terminal_tabs (tab_id, user_id, workspace_id, sequence, status) +VALUES ('fixture-v0930-quick-terminal', 'fixture-v0930-user', 'fixture-v0930-task-workspace', 0, 'connecting'); +INSERT INTO runtime_flag_overrides (key, value, created_at, updated_at) +VALUES ('fixture.v0930.flag', 1, '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO auth_identities (id, user_id, provider, subject, password_hash, created_at, updated_at) +VALUES ('fixture-v0930-identity', 'fixture-v0930-user', 'local', 'fixture-v0930-subject', 'fixture-v0930-hash', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO auth_sessions (id, user_id, token_sha256, created_at, expires_at, last_seen_at) +VALUES ('fixture-v0930-session', 'fixture-v0930-user', 'fixture-v0930-token-hash', '2024-01-02 03:04:05+00:00', '2034-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO secrets (id, name, scope, workspace_id, encrypted_value, nonce, created_at, updated_at) +VALUES ('fixture-v0930-secret', 'Fixture Secret', 'global', '', '', '', '2024-01-02 03:04:05+00:00', '2024-01-02 03:04:05+00:00'); +INSERT INTO telemetry_activations (contract_key, contract_version, activated_at) +VALUES ('fixture.v0930.contract', 7, '2024-01-02 03:04:05+00:00'); diff --git a/apps/backend/internal/persistence/storeconformance/upgrade_test.go b/apps/backend/internal/persistence/storeconformance/upgrade_test.go new file mode 100644 index 0000000000..52279e3507 --- /dev/null +++ b/apps/backend/internal/persistence/storeconformance/upgrade_test.go @@ -0,0 +1,368 @@ +package storeconformance + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/kandev/kandev/internal/persistence/requiredstores" + testconformance "github.com/kandev/kandev/internal/testutil/storeconformance" +) + +const previousStableTag = "v0.93.0" + +type upgradeManifest struct { + Tag string `json:"tag"` + SourceCommit string `json:"source_commit"` + Fixtures []upgradeFixture `json:"fixtures"` + KnownMissingRequiredStore []string `json:"known_missing_required_stores"` +} + +type upgradeFixture struct { + Engine testconformance.EngineName `json:"engine"` + File string `json:"file"` + SHA256 string `json:"sha256"` + Sentinels map[string]string `json:"sentinels"` + OwnerRows []ownerRowSentinel `json:"owner_rows"` +} + +type ownerRowSentinel struct { + Owner string `json:"owner"` + Table string `json:"table"` + KeyColumn string `json:"key_column"` + KeyValue string `json:"key_value"` + Values map[string]string `json:"values"` +} + +func TestUpgradeFixtureManifest(t *testing.T) { + manifest := loadUpgradeManifest(t) + if manifest.Tag != previousStableTag || manifest.SourceCommit == "" { + t.Fatalf("manifest = %#v, want tagged provenance", manifest) + } + if len(manifest.Fixtures) != 2 { + t.Fatalf("manifest fixtures = %d, want SQLite and PostgreSQL", len(manifest.Fixtures)) + } + seenEngines := make(map[testconformance.EngineName]bool) + for _, fixture := range manifest.Fixtures { + if seenEngines[fixture.Engine] { + t.Fatalf("duplicate fixture engine %q", fixture.Engine) + } + seenEngines[fixture.Engine] = true + if len(fixture.SHA256) != sha256.Size*2 { + t.Fatalf("fixture %q has invalid checksum %q", fixture.File, fixture.SHA256) + } + path := filepath.Join("testdata", "upgrades", previousStableTag, fixture.File) + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture %s: %v", path, err) + } + for _, table := range []string{"kandev_meta", "tasks", "workspaces"} { + schema := string(contents) + if !strings.Contains(schema, "CREATE TABLE "+table) && + !strings.Contains(schema, `CREATE TABLE "`+table+`"`) { + t.Errorf("fixture %s does not contain stable core table %s", fixture.File, table) + } + } + checksum := sha256.Sum256(contents) + if got := hex.EncodeToString(checksum[:]); got != fixture.SHA256 { + t.Errorf("fixture %s checksum = %s, want %s", fixture.File, got, fixture.SHA256) + } + if len(fixture.OwnerRows) == 0 { + t.Errorf("fixture %s has no owner row sentinels", fixture.File) + } + if err := validateOwnerRowCoverage(fixture, manifest.KnownMissingRequiredStore); err != nil { + t.Errorf("fixture %s owner row coverage: %v", fixture.File, err) + } + for _, row := range fixture.OwnerRows { + if !validUpgradeIdentifier(row.Owner) || !validUpgradeIdentifier(row.Table) || + !validUpgradeIdentifier(row.KeyColumn) || row.KeyValue == "" || len(row.Values) == 0 { + t.Errorf("fixture %s has invalid owner row sentinel %#v", fixture.File, row) + } + for column := range row.Values { + if !validUpgradeIdentifier(column) { + t.Errorf("fixture %s owner row %s has invalid column %q", fixture.File, row.Owner, column) + } + } + } + } + if !seenEngines[testconformance.EngineSQLite] || !seenEngines[testconformance.EnginePostgres] { + t.Fatalf("fixture engines = %#v, want both engines", seenEngines) + } + catalogIDs := make(map[string]struct{}, len(requiredstores.Catalog())) + for _, descriptor := range requiredstores.Catalog() { + catalogIDs[descriptor.ID] = struct{}{} + } + seenMissing := make(map[string]struct{}, len(manifest.KnownMissingRequiredStore)) + for _, id := range manifest.KnownMissingRequiredStore { + if _, ok := catalogIDs[id]; !ok { + t.Errorf("manifest lists unknown missing required store %q", id) + } + if _, duplicate := seenMissing[id]; duplicate { + t.Errorf("manifest lists duplicate missing required store %q", id) + } + seenMissing[id] = struct{}{} + } + if len(seenMissing) == 0 { + t.Fatal("manifest has no partial-store provenance") + } +} + +func validateOwnerRowCoverage(fixture upgradeFixture, knownMissing []string) error { + missing := make(map[string]struct{}, len(knownMissing)) + for _, id := range knownMissing { + missing[id] = struct{}{} + } + present := make(map[string]bool) + catalogIDs := make(map[string]struct{}, len(requiredstores.Catalog())) + for _, descriptor := range requiredstores.Catalog() { + catalogIDs[descriptor.ID] = struct{}{} + } + for _, row := range fixture.OwnerRows { + if _, ok := catalogIDs[row.Owner]; !ok { + return fmt.Errorf("owner row names unknown catalog store %q", row.Owner) + } + if _, ok := missing[row.Owner]; ok { + return fmt.Errorf("owner row exists for known-missing store %q", row.Owner) + } + present[row.Owner] = true + } + for _, descriptor := range requiredstores.Catalog() { + _, expectedMissing := missing[descriptor.ID] + if present[descriptor.ID] == expectedMissing { + if expectedMissing { + return fmt.Errorf("known-missing store %q has an owner row", descriptor.ID) + } + return fmt.Errorf("present store %q has no owner row", descriptor.ID) + } + } + return nil +} + +func TestPreviousStableUpgrade(t *testing.T) { + manifest := loadUpgradeManifest(t) + if err := testconformance.ValidateAdapters(requiredstores.Catalog(), Adapters()); err != nil { + t.Fatalf("validate adapters before fixture setup: %v", err) + } + for _, engine := range []testconformance.EngineName{testconformance.EngineSQLite, testconformance.EnginePostgres} { + engine := engine + t.Run(string(engine)+"/"+previousStableTag, func(t *testing.T) { + fixture := fixtureForEngine(t, manifest, engine) + database := testconformance.OpenEngine(t, engine, "") + if err := applyFixture(t, database, fixture); err != nil { + t.Fatalf("apply %s fixture: %v", engine, err) + } + if err := validateKnownMissingRequiredStores(database, manifest.KnownMissingRequiredStore); err != nil { + t.Fatalf("fixture store inventory: %v", err) + } + if err := checkSentinels(database, fixture); err != nil { + t.Fatalf("fixture sentinels before initialization: %v", err) + } + if err := runCurrentInitialization(database); err != nil { + t.Fatalf("current initialization: %v", err) + } + if err := checkSentinels(database, fixture); err != nil { + t.Fatalf("sentinels after initialization: %v", err) + } + if err := runCurrentInitialization(database); err != nil { + t.Fatalf("schema replay: %v", err) + } + if err := checkSentinels(database, fixture); err != nil { + t.Fatalf("sentinels after schema replay: %v", err) + } + if err := runCurrentScenarios(database); err != nil { + t.Fatalf("conformance scenarios: %v", err) + } + if err := checkSentinels(database, fixture); err != nil { + t.Fatal(err) + } + }) + } +} + +func loadUpgradeManifest(t *testing.T) upgradeManifest { + t.Helper() + contents, err := os.ReadFile(filepath.Join("testdata", "upgrades", previousStableTag, "manifest.json")) + if err != nil { + t.Fatalf("read upgrade manifest: %v", err) + } + var manifest upgradeManifest + if err := json.Unmarshal(contents, &manifest); err != nil { + t.Fatalf("parse upgrade manifest: %v", err) + } + return manifest +} + +func fixtureForEngine(t *testing.T, manifest upgradeManifest, engine testconformance.EngineName) upgradeFixture { + t.Helper() + for _, fixture := range manifest.Fixtures { + if fixture.Engine == engine { + return fixture + } + } + t.Fatalf("manifest has no %s fixture", engine) + return upgradeFixture{} +} + +func applyFixture(t *testing.T, engine testconformance.Engine, fixture upgradeFixture) error { + t.Helper() + contents, err := os.ReadFile(filepath.Join("testdata", "upgrades", previousStableTag, fixture.File)) + if err != nil { + return err + } + for _, statement := range strings.Split(string(contents), ";") { + statement = strings.TrimSpace(statement) + if statement == "" { + continue + } + if _, err := engine.DB.ExecContext(context.Background(), statement); err != nil { + return err + } + } + return nil +} + +func runCurrentInitialization(engine testconformance.Engine) error { + adapters := Adapters() + for _, adapter := range adapters { + callbacks := adapter.Engines[engine.Name] + scenario := testconformance.ScenarioContext{ + Context: context.Background(), Engine: engine.Name, StoreID: adapter.ID, DB: engine.DB, + } + if err := callbacks.Fresh(scenario); err != nil { + return fmt.Errorf("%s fresh: %w", adapter.ID, err) + } + if err := callbacks.Replay(scenario); err != nil { + return fmt.Errorf("%s replay: %w", adapter.ID, err) + } + } + return nil +} + +func runCurrentScenarios(engine testconformance.Engine) error { + for _, adapter := range Adapters() { + scenario := testconformance.ScenarioContext{ + Context: context.Background(), Engine: engine.Name, StoreID: adapter.ID, DB: engine.DB, + } + if err := adapter.Scenarios.CRUD(scenario); err != nil { + return fmt.Errorf("%s CRUD: %w", adapter.ID, err) + } + for _, capability := range adapter.Scenarios.Capabilities { + if err := capability.Run(scenario); err != nil { + return fmt.Errorf("%s %s: %w", adapter.ID, capability.Capability, err) + } + } + } + return nil +} + +func checkSentinels(engine testconformance.Engine, fixture upgradeFixture) error { + for key, want := range fixture.Sentinels { + var got string + if err := engine.DB.QueryRowxContext(context.Background(), engine.DB.Rebind( + `SELECT value FROM kandev_meta WHERE key = ?`, + ), key).Scan(&got); err != nil { + return fmt.Errorf("read sentinel %q: %w", key, err) + } + if got != want { + return fmt.Errorf("sentinel %q = %q, want %q", key, got, want) + } + } + for _, sentinel := range fixture.OwnerRows { + columns := make([]string, 0, len(sentinel.Values)) + for column := range sentinel.Values { + columns = append(columns, column) + } + sort.Strings(columns) + selectColumns := make([]string, 0, len(columns)) + for _, column := range columns { + selectColumns = append(selectColumns, "CAST("+column+" AS TEXT)") + } + query := fmt.Sprintf( + "SELECT %s FROM %s WHERE %s = ?", + strings.Join(selectColumns, ", "), sentinel.Table, sentinel.KeyColumn, + ) + values := make([]any, len(columns)) + destinations := make([]any, len(values)) + for index := range values { + destinations[index] = &values[index] + } + if err := engine.DB.QueryRowxContext(context.Background(), engine.DB.Rebind(query), sentinel.KeyValue).Scan(destinations...); err != nil { + return fmt.Errorf("read %s owner row %s: %w", sentinel.Owner, sentinel.Table, err) + } + for index, column := range columns { + got := fmt.Sprint(values[index]) + if got != sentinel.Values[column] { + return fmt.Errorf("owner row %s.%s %s = %q, want %q", sentinel.Table, sentinel.KeyColumn, column, got, sentinel.Values[column]) + } + } + } + return nil +} + +func validateKnownMissingRequiredStores(engine testconformance.Engine, expected []string) error { + actual := make([]string, 0) + for _, descriptor := range requiredstores.Catalog() { + present := true + for _, table := range descriptor.RequiredTables { + if exists, err := requiredTableExists(engine, table); err != nil { + return fmt.Errorf("check %s.%s: %w", descriptor.ID, table, err) + } else if !exists { + present = false + break + } + } + if !present { + actual = append(actual, descriptor.ID) + } + } + got := append([]string(nil), actual...) + want := append([]string(nil), expected...) + sort.Strings(got) + sort.Strings(want) + if strings.Join(got, "\x00") != strings.Join(want, "\x00") { + return fmt.Errorf("known missing required stores = %v, want %v", got, want) + } + return nil +} + +func requiredTableExists(engine testconformance.Engine, table string) (bool, error) { + var exists bool + query := `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = ? + )` + if engine.Name == testconformance.EngineSQLite { + query = `SELECT EXISTS ( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = ? + )` + } + err := engine.DB.QueryRowxContext(context.Background(), engine.DB.Rebind(query), table).Scan(&exists) + return exists, err +} + +func validUpgradeIdentifier(value string) bool { + if value == "" { + return false + } + for index, character := range value { + if !validUpgradeIdentifierCharacter(character) { + return false + } + if index == 0 && (character >= '0' && character <= '9' || character == '-' || character == '_') { + return false + } + } + return true +} + +func validUpgradeIdentifierCharacter(character rune) bool { + return character == '_' || character == '-' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' +} diff --git a/apps/backend/internal/plugins/provider.go b/apps/backend/internal/plugins/provider.go index 2ad8230f0e..73d5c8bd41 100644 --- a/apps/backend/internal/plugins/provider.go +++ b/apps/backend/internal/plugins/provider.go @@ -2,9 +2,11 @@ package plugins import ( "context" + "errors" "fmt" "os" "path/filepath" + "sort" "go.uber.org/zap" @@ -55,48 +57,49 @@ const pluginsSubdir = "plugins" // "Extension points" doc comment on Service for how backendapp attaches // them after calling Provide. cleanup stops the runtime manager (kills any // spawned processes); callers should register it with addCleanup. +// StoreInitErrors contains independent initialization results for the six +// plugin-owned required SQL stores. Filesystem, web-artifact, and runtime +// failures are intentionally not included: those capabilities can degrade +// while the rest of the backend remains available. +type StoreInitErrors map[string]error + +// CombinedError returns a stable aggregate for callers that still use the +// all-or-nothing provider API. +func (e StoreInitErrors) CombinedError() error { + if len(e) == 0 { + return nil + } + ids := make([]string, 0, len(e)) + for id := range e { + ids = append(ids, id) + } + sort.Strings(ids) + joined := make([]error, 0, len(ids)) + for _, id := range ids { + joined = append(joined, fmt.Errorf("%s: %w", id, e[id])) + } + return errors.Join(joined...) +} + +// Provide builds the plugin service and preserves the historical provider +// contract by returning the aggregate of required store errors. func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error) { + svc, cleanup, storeErrors := ProvideWithStoreErrors(cfg, dbPool, secrets, eventBus, log) + return svc, cleanup, storeErrors.CombinedError() +} + +// ProvideWithStoreErrors builds as much of the plugin service as possible and +// reports each required SQL store independently. The backend records these +// results against the required-store tracker, while filesystem and runtime +// capabilities remain independently degradable. +func ProvideWithStoreErrors(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, StoreInitErrors) { dir := filepath.Join(cfg.ResolvedHomeDir(), pluginsSubdir) pluginStore := store.NewFSStore(dir) pluginStore.SetLogger(log) - stateStore, err := state.NewStore(dbPool) - if err != nil { - return nil, nil, fmt.Errorf("plugins: init state store: %w", err) - } - - userStateStore, err := state.NewUserStore(dbPool) - if err != nil { - return nil, nil, fmt.Errorf("plugins: init user state store: %w", err) - } - - instanceStore, err := instances.NewStore(dbPool) - if err != nil { - return nil, nil, fmt.Errorf("plugins: init web-app instance store: %w", err) - } - instanceState, err := state.NewInstanceStore(dbPool) - if err != nil { - return nil, nil, fmt.Errorf("plugins: init web-app instance state: %w", err) - } - artifactStore, err := webapp.NewArtifactStore(filepath.Join(cfg.ResolvedHomeDir(), pluginsSubdir, "webapps")) - if err != nil { - return nil, nil, fmt.Errorf("plugins: init web-app artifact store: %w", err) - } - if cfg.Features.Canvases { - if _, err := instanceStore.ReconcileArtifacts(context.Background(), func(path, digest string, bytes int64) (instances.ArtifactCheck, error) { - artifact, err := artifactStore.Reconcile(webapp.Artifact{Digest: digest, RelativePath: path, Bytes: bytes}) - if err != nil { - return instances.ArtifactCheck{}, err - } - return instances.ArtifactCheck{Available: artifact.Available, Reason: artifact.Reason}, nil - }); err != nil { - return nil, nil, fmt.Errorf("plugins: reconcile web-app artifacts: %w", err) - } - } - registry := NewRegistry() if err := registry.Load(pluginStore); err != nil { - return nil, nil, fmt.Errorf("plugins: load registry: %w", err) + warnProvider(log, "Plugins registry load failed; starting with an empty registry", err) } svc := NewService(pluginStore, registry, eventBus, log) @@ -104,40 +107,21 @@ func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus svc.subscribeWebAppEvents() } svc.warnLoadedWebhookAccessIssues() - svc.SetState(stateStore) - svc.SetUserState(userStateStore) - if cfg.Features.Canvases { - svc.SetWebAppStorage(instanceStore, artifactStore) - svc.SetInstanceState(instanceState) - backendPort := cfg.Server.Port - if backendPort == 0 { - backendPort = ports.Backend - } - webPort := cfg.Launcher.WebPort - if webPort == 0 { - webPort = backendPort - } - frameAncestors, frameErr := webapp.FrameAncestorsForConfig(backendPort, webPort, fmt.Sprintf("http://localhost:%d", webPort)) - if frameErr != nil { - return nil, nil, fmt.Errorf("plugins: configure web-app frame origins: %w", frameErr) - } - svc.SetWebRuntime(webapp.NewRuntime(webapp.NewTokenManager(nil), artifactStore, svc.validateWebAppBinding, frameAncestors)) + + stateStore, userStateStore, instanceStore, instanceState, sourceStore, settingsStore, storeErrors := initializeRequiredStores(dbPool) + if stateStore != nil { + svc.SetState(stateStore) } + if userStateStore != nil { + svc.SetUserState(userStateStore) + } + + configureWebAppStorage(cfg, svc, instanceStore, instanceState, log) svc.SetSecrets(secrets) svc.SetPluginsDir(dir) - if err := attachMarketplace(svc, dbPool, log); err != nil { - // Non-fatal: the rest of the plugin system still works without the - // discovery catalog (install-by-URL/upload is unaffected). - log.Warn("plugins: marketplace init failed (non-fatal)", zap.Error(err)) - } - - if settingsStore, err := newSettingsStore(dbPool); err != nil { - // Non-fatal: without the settings store the auto-update default reads as - // off and per-plugin toggles are rejected, but install/enable/etc. all - // still work. - log.Warn("plugins: settings store init failed (non-fatal)", zap.Error(err)) - } else { + seedMarketplace(svc, sourceStore, storeErrors, log) + if settingsStore != nil { svc.SetSettings(settingsStore) } @@ -154,24 +138,96 @@ func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus rt.StopAll() return nil } - return svc, cleanup, nil + return svc, cleanup, storeErrors } -// attachMarketplace builds the marketplace source store, seeds the built-in -// official source (URL overridable via KANDEV_PLUGIN_MARKETPLACE_URL), and -// attaches the catalog service to svc. -func attachMarketplace(svc *Service, dbPool *db.Pool, log *logger.Logger) error { - sourceStore, err := marketplace.NewSourceStore(dbPool) +func recordStoreError(storeErrors StoreInitErrors, id string, err error) { if err != nil { - return fmt.Errorf("marketplace source store: %w", err) + storeErrors[id] = err + } +} + +func initializeRequiredStores(dbPool *db.Pool) (*state.Store, *state.UserStore, *instances.Store, *state.InstanceStore, *marketplace.SourceStore, *settingsStore, StoreInitErrors) { + storeErrors := make(StoreInitErrors) + if dbPool == nil || dbPool.Writer() == nil || dbPool.Reader() == nil { + err := errors.New("database pool is unavailable") + for _, id := range []string{ + "plugin-instances", "plugin-marketplace", "plugin-settings", + "plugin-state", "plugin-instance-state", "plugin-user-state", + } { + storeErrors[id] = err + } + return nil, nil, nil, nil, nil, nil, storeErrors + } + stateStore, err := state.NewStore(dbPool) + recordStoreError(storeErrors, "plugin-state", err) + userStateStore, err := state.NewUserStore(dbPool) + recordStoreError(storeErrors, "plugin-user-state", err) + instanceStore, err := instances.NewStore(dbPool) + recordStoreError(storeErrors, "plugin-instances", err) + instanceState, err := state.NewInstanceStore(dbPool) + recordStoreError(storeErrors, "plugin-instance-state", err) + sourceStore, err := marketplace.NewSourceStore(dbPool) + recordStoreError(storeErrors, "plugin-marketplace", err) + settingsStore, err := newSettingsStore(dbPool) + recordStoreError(storeErrors, "plugin-settings", err) + return stateStore, userStateStore, instanceStore, instanceState, sourceStore, settingsStore, storeErrors +} + +func configureWebAppStorage(cfg *config.Config, svc *Service, instanceStore *instances.Store, instanceState *state.InstanceStore, log *logger.Logger) { + if instanceState != nil { + svc.SetInstanceState(instanceState) + } + artifactStore, err := webapp.NewArtifactStore(filepath.Join(cfg.ResolvedHomeDir(), pluginsSubdir, "webapps")) + if err != nil { + warnProvider(log, "Plugin web-artifact storage is unavailable", err) + return + } + if !cfg.Features.Canvases || instanceStore == nil { + return + } + if _, err := instanceStore.ReconcileArtifacts(context.Background(), func(path, digest string, bytes int64) (instances.ArtifactCheck, error) { + artifact, err := artifactStore.Reconcile(webapp.Artifact{Digest: digest, RelativePath: path, Bytes: bytes}) + if err != nil { + return instances.ArtifactCheck{}, err + } + return instances.ArtifactCheck{Available: artifact.Available, Reason: artifact.Reason}, nil + }); err != nil { + warnProvider(log, "Plugin web-artifact reconciliation failed", err) + } + svc.SetWebAppStorage(instanceStore, artifactStore) + backendPort := cfg.Server.Port + if backendPort == 0 { + backendPort = ports.Backend + } + webPort := cfg.Launcher.WebPort + if webPort == 0 { + webPort = backendPort + } + frameAncestors, err := webapp.FrameAncestorsForConfig(backendPort, webPort, fmt.Sprintf("http://localhost:%d", webPort)) + if err != nil { + warnProvider(log, "Plugin web-app runtime is unavailable", err) + return + } + svc.SetWebRuntime(webapp.NewRuntime(webapp.NewTokenManager(nil), artifactStore, svc.validateWebAppBinding, frameAncestors)) +} + +func seedMarketplace(svc *Service, sourceStore *marketplace.SourceStore, storeErrors StoreInitErrors, log *logger.Logger) { + if sourceStore == nil { + return } officialURL := marketplace.OfficialSourceURL if override := os.Getenv(marketplaceURLEnv); override != "" { officialURL = override } if err := sourceStore.EnsureBuiltin(marketplace.OfficialSourceName, officialURL); err != nil { - return fmt.Errorf("marketplace seed builtin: %w", err) + recordStoreError(storeErrors, "plugin-marketplace", fmt.Errorf("marketplace seed builtin: %w", err)) } svc.SetMarketplace(marketplace.NewService(sourceStore, log)) - return nil +} + +func warnProvider(log *logger.Logger, message string, err error) { + if log != nil { + log.Warn(message, zap.Error(err)) + } } diff --git a/apps/backend/internal/plugins/provider_test.go b/apps/backend/internal/plugins/provider_test.go index 6bcedb2d8c..c366739026 100644 --- a/apps/backend/internal/plugins/provider_test.go +++ b/apps/backend/internal/plugins/provider_test.go @@ -126,3 +126,25 @@ func TestProvideCleanupDoesNotError(t *testing.T) { t.Fatalf("cleanup() unexpected error: %v", err) } } + +func TestProvideWithStoreErrorsKeepsRequiredStoreResultsIndependent(t *testing.T) { + cfg := &config.Config{HomeDir: t.TempDir()} + + svc, cleanup, storeErrors := ProvideWithStoreErrors(cfg, nil, newFakeSecretRevealer(), nil, testLogger(t)) + if svc == nil { + t.Fatal("ProvideWithStoreErrors() service = nil, want partially initialized service") + } + t.Cleanup(func() { _ = cleanup() }) + wantIDs := []string{ + "plugin-instances", "plugin-marketplace", "plugin-settings", + "plugin-state", "plugin-instance-state", "plugin-user-state", + } + if len(storeErrors) != len(wantIDs) { + t.Fatalf("store error set has %d entries, want %d: %v", len(storeErrors), len(wantIDs), storeErrors) + } + for _, id := range wantIDs { + if err := storeErrors[id]; err == nil { + t.Errorf("%s error = nil, want independent database-pool error", id) + } + } +} diff --git a/apps/backend/internal/plugins/settings_store.go b/apps/backend/internal/plugins/settings_store.go index de495a21ea..9668ab7d93 100644 --- a/apps/backend/internal/plugins/settings_store.go +++ b/apps/backend/internal/plugins/settings_store.go @@ -26,6 +26,20 @@ type Settings struct { AutoUpdateDefault bool `json:"auto_update_default"` } +// SettingsSnapshot is the persisted single-row settings value. +type SettingsSnapshot struct { + Settings + UpdatedAt time.Time +} + +// SettingsStore is the public contract for the plugin settings owner. +type SettingsStore interface { + Get() (Settings, error) + Snapshot() (SettingsSnapshot, bool, error) + SetAutoUpdateDefault(bool) error + Delete() error +} + // settingsStore persists instance-wide plugin settings in the single-row // plugin_settings table. It mirrors marketplace.SourceStore's self-initializing // schema convention (the plugin subsystem's SQLite tables init in-package @@ -44,6 +58,21 @@ func newSettingsStore(pool *db.Pool) (*settingsStore, error) { return s, nil } +// InitializeSettingsStore initializes the plugin settings schema without +// exposing the package-private store implementation. It is used by the +// catalog conformance suite, which must exercise schema owners without +// constructing the full plugin service. +func InitializeSettingsStore(pool *db.Pool) error { + _, err := newSettingsStore(pool) + return err +} + +// NewSettingsStore returns the plugin settings owner without exposing its +// implementation type. +func NewSettingsStore(pool *db.Pool) (SettingsStore, error) { + return newSettingsStore(pool) +} + func (s *settingsStore) initSchema() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS plugin_settings ( @@ -72,6 +101,36 @@ func (s *settingsStore) Get() (Settings, error) { return Settings{AutoUpdateDefault: autoUpdate != 0}, nil } +// Snapshot returns the stored row and its timestamp. A fresh store has no row +// until a setting is explicitly saved. +func (s *settingsStore) Snapshot() (SettingsSnapshot, bool, error) { + var autoUpdate int + var updatedAtRaw string + err := s.ro.QueryRow(s.ro.Rebind(` + SELECT auto_update_default, updated_at FROM plugin_settings WHERE id = ? + `), settingsRowID).Scan(&autoUpdate, &updatedAtRaw) + if errors.Is(err, sql.ErrNoRows) { + return SettingsSnapshot{}, false, nil + } + if err != nil { + return SettingsSnapshot{}, false, err + } + updatedAt, err := parseSettingsTimestamp(updatedAtRaw) + if err != nil { + return SettingsSnapshot{}, false, err + } + return SettingsSnapshot{Settings: Settings{AutoUpdateDefault: autoUpdate != 0}, UpdatedAt: updatedAt}, true, nil +} + +func parseSettingsTimestamp(value string) (time.Time, error) { + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05"} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed.UTC(), nil + } + } + return time.Time{}, fmt.Errorf("parse plugin settings timestamp %q", value) +} + // SetAutoUpdateDefault upserts the instance-wide auto-update default. func (s *settingsStore) SetAutoUpdateDefault(enabled bool) error { now := time.Now().UTC().Format(time.RFC3339) @@ -83,6 +142,12 @@ func (s *settingsStore) SetAutoUpdateDefault(enabled bool) error { return err } +// Delete removes the optional single settings row. +func (s *settingsStore) Delete() error { + _, err := s.db.Exec(s.db.Rebind(`DELETE FROM plugin_settings WHERE id = ?`), settingsRowID) + return err +} + func boolToInt(b bool) int { if b { return 1 diff --git a/apps/backend/internal/runtimeflags/sqlite.go b/apps/backend/internal/runtimeflags/sqlite.go index 289c3d2a23..78ef5feab7 100644 --- a/apps/backend/internal/runtimeflags/sqlite.go +++ b/apps/backend/internal/runtimeflags/sqlite.go @@ -14,6 +14,16 @@ type SQLiteStore struct { reader *sqlx.DB } +// Override is one persisted runtime flag override, including its database +// timestamps. It is useful to callers that need to distinguish an upserted +// row from a value reconstructed from the effective flag registry. +type Override struct { + Key string + Value bool + CreatedAt time.Time + UpdatedAt time.Time +} + func NewSQLiteStore(writer, reader *sqlx.DB) (*SQLiteStore, error) { store := &SQLiteStore{writer: writer, reader: reader} if err := store.initSchema(); err != nil { @@ -61,6 +71,22 @@ func (s *SQLiteStore) ListOverrides(ctx context.Context) (map[string]bool, error return out, nil } +// GetOverride reads one override and its persisted timestamps. +func (s *SQLiteStore) GetOverride(ctx context.Context, key string) (Override, error) { + var row struct { + Key string `db:"key"` + Value int `db:"value"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + if err := s.reader.GetContext(ctx, &row, s.reader.Rebind(` + SELECT key, value, created_at, updated_at FROM runtime_flag_overrides WHERE key = ? + `), key); err != nil { + return Override{}, err + } + return Override{Key: row.Key, Value: row.Value != 0, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil +} + func (s *SQLiteStore) SetOverride(ctx context.Context, key string, value bool) error { now := time.Now().UTC() intValue := 0 diff --git a/apps/backend/internal/secrets/sqlite_store.go b/apps/backend/internal/secrets/sqlite_store.go index 172116840e..4f4213478d 100644 --- a/apps/backend/internal/secrets/sqlite_store.go +++ b/apps/backend/internal/secrets/sqlite_store.go @@ -72,10 +72,13 @@ func (s *sqliteStore) initSchema() error { } // Existing databases: CREATE TABLE IF NOT EXISTS is a no-op, so the // column must also be added via an idempotent migration (ADR 0027). - migrate := db.NewMigrateLogger(s.db, nil) + migrate := db.NewRequiredMigrateLogger(s.db, nil) migrate.Apply("secrets.user_id", "ALTER TABLE secrets ADD COLUMN user_id TEXT NOT NULL DEFAULT ''") migrate.Apply("secrets.scope", "ALTER TABLE secrets ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'") migrate.Apply("secrets.workspace_id", "ALTER TABLE secrets ADD COLUMN workspace_id TEXT NOT NULL DEFAULT ''") + if err := migrate.Err(); err != nil { + return fmt.Errorf("required secrets migration: %w", err) + } return nil } diff --git a/apps/backend/internal/sentry/store.go b/apps/backend/internal/sentry/store.go index 26e84c7fc2..f34df5a259 100644 --- a/apps/backend/internal/sentry/store.go +++ b/apps/backend/internal/sentry/store.go @@ -774,15 +774,7 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) { } func schemaSQLForDriver(schema, driver string) string { - schema = strings.ReplaceAll(schema, "TIMESTAMP", dialect.TimestampType(driver)) - schema = strings.ReplaceAll(schema, "DATETIME", dialect.TimestampType(driver)) - if dialect.IsPostgres(driver) { - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 1", "BOOLEAN NOT NULL DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN NOT NULL DEFAULT 0", "BOOLEAN NOT NULL DEFAULT FALSE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 1", "BOOLEAN DEFAULT TRUE") - schema = strings.ReplaceAll(schema, "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE") - } - return schema + return dialect.MustRenderSchema(driver, schema) } const selectInstanceColumns = `id, workspace_id, name, auth_method, url, diff --git a/apps/backend/internal/system/persistence/handler.go b/apps/backend/internal/system/persistence/handler.go new file mode 100644 index 0000000000..1c4b6e4274 --- /dev/null +++ b/apps/backend/internal/system/persistence/handler.go @@ -0,0 +1,79 @@ +// Package persistence exposes the authenticated required-store diagnostics +// endpoint used by the system pages and operator tooling. +package persistence + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/persistence/requiredstores" +) + +// StoreDiagnostic is the public, sanitized view of one catalog entry. +type StoreDiagnostic struct { + ID string `json:"id"` + OwnerPackage string `json:"owner_package"` + State string `json:"state"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` + Error string `json:"error,omitempty"` +} + +// Response is the complete required-persistence diagnostic payload. +type Response struct { + Driver string `json:"driver"` + State string `json:"state"` + Stores []StoreDiagnostic `json:"stores"` +} + +// Handler serves diagnostics for one required-store tracker. +type Handler struct { + tracker *requiredstores.Tracker + health *requiredstores.Health + driver string +} + +// NewHandler creates a diagnostics handler. The driver is the stable driver +// name reported by the configured database pool. +func NewHandler(tracker *requiredstores.Tracker, health *requiredstores.Health, driver string) *Handler { + return &Handler{tracker: tracker, health: health, driver: driver} +} + +// Handle writes one stable row for every catalog descriptor. +func (h *Handler) Handle(c *gin.Context) { + if h == nil || h.tracker == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "persistence diagnostics are unavailable"}) + return + } + snapshot := h.tracker.Snapshot() + stores := make([]StoreDiagnostic, len(snapshot)) + for index, status := range snapshot { + stores[index] = diagnosticFor(status) + } + state := h.tracker.AggregateState() + if h.health != nil { + state = h.health.State() + } + c.JSON(http.StatusOK, Response{Driver: h.driver, State: string(state), Stores: stores}) +} + +// RegisterRoutes mounts the diagnostics endpoint under a system route group. +func RegisterRoutes(group *gin.RouterGroup, handler *Handler) { + if group == nil || handler == nil { + return + } + group.GET("/diagnostics/persistence", handler.Handle) +} + +func diagnosticFor(status requiredstores.Status) StoreDiagnostic { + diagnostic := StoreDiagnostic{ + ID: status.ID, OwnerPackage: status.OwnerPackage, State: string(status.State), + Error: requiredstores.PublicError(status), + } + if !status.LastCheckedAt.IsZero() { + checkedAt := status.LastCheckedAt + diagnostic.LastCheckedAt = &checkedAt + } + return diagnostic +} diff --git a/apps/backend/internal/system/persistence/handler_test.go b/apps/backend/internal/system/persistence/handler_test.go new file mode 100644 index 0000000000..ffb7d250cf --- /dev/null +++ b/apps/backend/internal/system/persistence/handler_test.go @@ -0,0 +1,60 @@ +package persistence + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/persistence/requiredstores" +) + +func TestHandlerReturnsStableRowsWithSanitizedErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{ + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}}, + }) + if err != nil { + t.Fatalf("NewTracker: %v", err) + } + if err := tracker.RecordSuccess("first"); err != nil { + t.Fatalf("RecordSuccess(first): %v", err) + } + if err := tracker.RecordSuccess("second"); err != nil { + t.Fatalf("RecordSuccess(second): %v", err) + } + if err := tracker.RecordProbe("first", persistenceDatabaseError("password=secret host=db.example")); err != nil { + t.Fatalf("RecordProbe(first): %v", err) + } + if err := tracker.RecordProbe("second", nil); err != nil { + t.Fatalf("RecordProbe(second): %v", err) + } + + handler := NewHandler(tracker, requiredstores.NewHealth(tracker, nil, nil), "pgx") + recorder := httptest.NewRecorder() + router := gin.New() + router.GET("/diagnostics/persistence", handler.Handle) + router.ServeHTTP(recorder, httptest.NewRequest("GET", "/diagnostics/persistence", nil)) + if recorder.Code != 200 { + t.Fatalf("status = %d, want 200", recorder.Code) + } + var body Response + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Driver != "pgx" || body.State != string(requiredstores.StateUnhealthy) { + t.Fatalf("header = %#v, want pgx/unhealthy", body) + } + if len(body.Stores) != 2 || body.Stores[0].ID != "first" || body.Stores[1].ID != "second" { + t.Fatalf("stores = %#v, want catalog order", body.Stores) + } + if body.Stores[0].Error != "database probe failed" { + t.Fatalf("error = %q, want sanitized error", body.Stores[0].Error) + } +} + +type persistenceDatabaseError string + +func (e persistenceDatabaseError) Error() string { return string(e) } diff --git a/apps/backend/internal/system/settings/store.go b/apps/backend/internal/system/settings/store.go index ac04b3f15c..95cecd5107 100644 --- a/apps/backend/internal/system/settings/store.go +++ b/apps/backend/internal/system/settings/store.go @@ -18,6 +18,13 @@ type Store struct { ro *sqlx.DB } +// Entry is a setting value together with the timestamp persisted by Save. +type Entry struct { + Key string + Value []byte + UpdatedAt time.Time +} + func NewStore(pool *db.Pool) (*Store, error) { store := &Store{db: pool.Writer(), ro: pool.Reader()} if err := store.initSchema(); err != nil { @@ -72,6 +79,25 @@ func (s *Store) Get(ctx context.Context, key string) ([]byte, bool, error) { return []byte(raw), true, nil } +// GetEntry reads one setting row, including its persisted update timestamp. +func (s *Store) GetEntry(ctx context.Context, key string) (Entry, bool, error) { + var row struct { + Key string `db:"key"` + Value string `db:"value"` + UpdatedAt time.Time `db:"updated_at"` + } + err := s.ro.GetContext(ctx, &row, s.ro.Rebind(` + SELECT key, value, updated_at FROM settings WHERE key = ? + `), key) + if err == sql.ErrNoRows { + return Entry{}, false, nil + } + if err != nil { + return Entry{}, false, err + } + return Entry{Key: row.Key, Value: []byte(row.Value), UpdatedAt: row.UpdatedAt}, true, nil +} + func (s *Store) Save(ctx context.Context, key string, value []byte) error { _, err := s.db.ExecContext(ctx, s.db.Rebind(` INSERT INTO settings (key, value, updated_at) diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index 24b1188a3c..0acbcc1c8f 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -22,6 +22,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/persistence/requiredstores" "github.com/kandev/kandev/internal/system/backups" "github.com/kandev/kandev/internal/system/database" "github.com/kandev/kandev/internal/system/disk" @@ -30,6 +31,7 @@ import ( "github.com/kandev/kandev/internal/system/jobs" "github.com/kandev/kandev/internal/system/logbundle" "github.com/kandev/kandev/internal/system/metrics" + systempersistence "github.com/kandev/kandev/internal/system/persistence" "github.com/kandev/kandev/internal/system/queuesettings" "github.com/kandev/kandev/internal/system/restart" systemsettings "github.com/kandev/kandev/internal/system/settings" @@ -60,6 +62,9 @@ type Wiring struct { OrchestratorShutdown func() DatabaseQuiesce func() error RestoreQuiesce func() error + SystemSettings *systemsettings.Store + RequiredStores *requiredstores.Tracker + PersistenceHealth *requiredstores.Health MessageQueue queuesettings.Target MessageQueueConfig queuesettings.Configuration TaskSessions sleepinhibition.SessionReader @@ -85,6 +90,7 @@ type Service struct { Storage *storage.Handler // StorageRuntime owns the scheduler, reconciliation, and durable cleanup worker. StorageRuntime *storage.Runtime + Persistence *systempersistence.Handler } // Provide constructs the composed Service. The HTTP routes are @@ -115,9 +121,13 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus backupsSvc.OrchestratorShutdown = wiring.OrchestratorShutdown backupsSvc.RestoreQuiesce = wiring.RestoreQuiesce - settingsStore, err := systemsettings.NewStore(pool) - if err != nil { - log.Error("Failed to initialize system settings store", zap.Error(err)) + settingsStore := wiring.SystemSettings + if settingsStore == nil { + var err error + settingsStore, err = systemsettings.NewStore(pool) + if err != nil { + log.Error("Failed to initialize system settings store", zap.Error(err)) + } } var metricsSvc *metrics.Service var queueSettingsSvc *queuesettings.Service @@ -149,6 +159,13 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus updatesSvc.SetNightlyURL(registryURL) } + var persistenceHandler *systempersistence.Handler + if wiring.RequiredStores != nil { + persistenceHandler = systempersistence.NewHandler( + wiring.RequiredStores, wiring.PersistenceHealth, pool.Writer().DriverName(), + ) + } + return &Service{ logger: log, Info: info.NewService(build.Version, build.Commit, build.BuildTime), @@ -166,6 +183,7 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus SleepInhibition: sleepInhibitionSvc, Updates: updatesSvc, Restart: restart.NewManagerFromEnv(), + Persistence: persistenceHandler, } } @@ -190,6 +208,7 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { if s.Storage != nil { storage.RegisterRoutes(g, admin, s.Storage) } + systempersistence.RegisterRoutes(g, s.Persistence) g.GET("/disk-usage", disk.HandleGet(s.Disk)) g.POST("/disk-usage/refresh", disk.HandleRefresh(s.Disk)) diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index acb988fe71..b0a1dcb6f4 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -151,7 +151,7 @@ func newRepository(writer, reader *sqlx.DB, log *logger.Logger, ownsDB bool) (*R ro: reader, ownsDB: ownsDB, log: log, - migrate: db.NewMigrateLogger(writer, log), + migrate: db.NewRequiredMigrateLogger(writer, log), } if err := repo.initSchema(); err != nil { if ownsDB { diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index a267947499..bfef93aa6c 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -34,7 +34,7 @@ func (r *Repository) migrateTaskSessions() error { // A DB that no longer contains the rebuild trigger columns would never gain the // cost columns, breaking the office cost subscriber's IncrementTaskSessionUsage // with "no such column: tokens_in". These additive ALTERs are idempotent — the -// MigrateLogger swallows "duplicate column name" on DBs that already have them. +// required migration logger tolerates only "duplicate column name" on replay. func (r *Repository) migrateSessionsAddCostColumns() { r.migrate.Apply("task_sessions.cost_subcents", `ALTER TABLE task_sessions ADD COLUMN cost_subcents INTEGER NOT NULL DEFAULT 0`) r.migrate.Apply("task_sessions.tokens_in", `ALTER TABLE task_sessions ADD COLUMN tokens_in INTEGER NOT NULL DEFAULT 0`) @@ -190,8 +190,8 @@ func (r *Repository) runMigrations() error { // task_session_commits gains a uniqueness constraint before its writer // starts firing from more than just archive capture (CreateSessionCommit // was previously a plain INSERT). Must dedupe existing duplicates first: - // CREATE UNIQUE INDEX fails on a duplicate pair, and MigrateLogger.Apply - // swallows non-"already exists" errors, so an unhandled duplicate would + // CREATE UNIQUE INDEX fails on a duplicate pair, and an idempotent migration + // helper must not classify that data error as an existing schema object, so an unhandled duplicate would // silently leave both the index and every future ON CONFLICT missing. if err := r.migrateSessionCommitsDedupeAndActivation(); err != nil { return err @@ -287,7 +287,9 @@ func (r *Repository) runMigrations() error { // task SELECTs that reference them via correlated subquery don't // fail. Required for tests and any environment where the workflow // repo hasn't run yet. - r.ensureRunnerProjectionTables() + if err := r.ensureRunnerProjectionTables(); err != nil { + return err + } // Keep the projection table compatible with databases whose workflow // repository has not replayed its own migrations yet. These additive // migrations are idempotent and preserve the false default for legacy rows. @@ -358,6 +360,9 @@ func (r *Repository) runMigrations() error { r.migrate.Apply("task_plan_revisions.workflow_step_id", `ALTER TABLE task_plan_revisions ADD COLUMN workflow_step_id TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("task_plan_revisions.workflow_step_name", `ALTER TABLE task_plan_revisions ADD COLUMN workflow_step_name TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("task_plan_revisions.workflow_step_color", `ALTER TABLE task_plan_revisions ADD COLUMN workflow_step_color TEXT NOT NULL DEFAULT ''`) + if err := r.migrate.Err(); err != nil { + return fmt.Errorf("required task migration: %w", err) + } return nil } @@ -819,9 +824,8 @@ const commitCaptureActivatedAtMetaKey = "commit_capture_activated_at" // (e.g. abandoned work), not just noise. // // Must run before CreateSessionCommit starts firing from more than archive - -// CREATE UNIQUE INDEX fails on an existing duplicate pair, and -// MigrateLogger.Apply swallows non-"already exists" errors, so an unhandled -// duplicate would silently leave both the index and every future +// CREATE UNIQUE INDEX fails on an existing duplicate pair. An unhandled +// duplicate must not silently leave both the index and every future // ON CONFLICT missing. That is exactly why these two statements, unlike most // migrations in this file, do NOT go through r.migrate.Apply: the writer's // ON CONFLICT (session_id, commit_sha) target hard-requires this index to diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 6b4d6fd7ad..8b1406336f 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -213,12 +213,12 @@ func (r *Repository) ensurePromptOrderIndex() error { // would error with "no such table". Stubs created here are minimal — // the workflow repo's init still runs and adds the rest of its columns // via idempotent ALTER and CREATE statements. -func (r *Repository) ensureRunnerProjectionTables() { +func (r *Repository) ensureRunnerProjectionTables() error { // workflow_steps: matches the full schema declared in the workflow // repo so workflow.NewWithDB's later ALTER ADD COLUMNs become no-ops - // (column-already-exists errors are swallowed). Mirrors + // (only column-already-exists errors are tolerated). Mirrors // internal/workflow/repository/sqlite.go (the canonical owner). - _, _ = r.db.Exec(` + if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS workflow_steps ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL DEFAULT '', @@ -239,8 +239,10 @@ func (r *Repository) ensureRunnerProjectionTables() { cancel_triggers_turn_complete INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - )`) - _, _ = r.db.Exec(` + )`); err != nil { + return fmt.Errorf("create workflow_steps projection table: %w", err) + } + if _, err := r.db.Exec(` CREATE TABLE IF NOT EXISTS workflow_step_participants ( id TEXT PRIMARY KEY, step_id TEXT NOT NULL DEFAULT '', @@ -249,7 +251,10 @@ func (r *Repository) ensureRunnerProjectionTables() { agent_profile_id TEXT NOT NULL DEFAULT '', decision_required INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0 - )`) + )`); err != nil { + return fmt.Errorf("create workflow_step_participants projection table: %w", err) + } + return nil } func (r *Repository) initCoreSchema() error { diff --git a/apps/backend/internal/task/repository/sqlite/slack_retirement.go b/apps/backend/internal/task/repository/sqlite/slack_retirement.go index 9c767fff60..cdbefae469 100644 --- a/apps/backend/internal/task/repository/sqlite/slack_retirement.go +++ b/apps/backend/internal/task/repository/sqlite/slack_retirement.go @@ -3,7 +3,7 @@ package sqlite import ( "fmt" - "github.com/kandev/kandev/internal/db/dialect" + internaldb "github.com/kandev/kandev/internal/db" ) // dropRetiredSlackIntegration removes the storage the in-tree Slack @@ -20,7 +20,9 @@ import ( // migrations run before secrets.Provide creates it, and a fresh database has // nothing to clean up anyway. func (r *Repository) dropRetiredSlackIntegration() error { - r.migrate.Apply("drop_slack_configs", `DROP TABLE IF EXISTS slack_configs`) + if err := r.migrate.Apply("drop_slack_configs", `DROP TABLE IF EXISTS slack_configs`); err != nil { + return fmt.Errorf("drop retired slack configs: %w", err) + } exists, err := r.secretsTableExists() if err != nil { return fmt.Errorf("drop retired slack integration: %w", err) @@ -30,7 +32,9 @@ func (r *Repository) dropRetiredSlackIntegration() error { } // LIKE with a literal pattern rather than a bound parameter: this file is // shared with PostgreSQL, whose placeholder syntax differs from SQLite's. - r.migrate.Apply("delete_slack_secrets", `DELETE FROM secrets WHERE id LIKE 'slack:%'`) + if err := r.migrate.Apply("delete_slack_secrets", `DELETE FROM secrets WHERE id LIKE 'slack:%'`); err != nil { + return fmt.Errorf("delete retired slack secrets: %w", err) + } return nil } @@ -39,13 +43,5 @@ func (r *Repository) dropRetiredSlackIntegration() error { // initialize independently, and on a fresh boot this repository's migrations // run first — so "absent" is the normal fresh-install case, not an error. func (r *Repository) secretsTableExists() (bool, error) { - query := `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'secrets'` - if dialect.IsPostgres(r.db.DriverName()) { - query = `SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'secrets'` - } - var count int - if err := r.db.QueryRow(query).Scan(&count); err != nil { - return false, err - } - return count > 0, nil + return internaldb.TableExists(r.db, "secrets") } diff --git a/apps/backend/internal/task/repository/sqlite/task_queue_purge_postgres_test.go b/apps/backend/internal/task/repository/sqlite/task_queue_purge_postgres_test.go new file mode 100644 index 0000000000..13b7b6dd5f --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_queue_purge_postgres_test.go @@ -0,0 +1,34 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +// Task lifecycle cleanup is allowed to run before the optional message queue +// store is initialized. PostgreSQL aborts a transaction after a missing-table +// statement, so this verifies that DeleteTask leaves the transaction usable +// when queued_messages has not been created yet. +func TestPostgresDeleteTaskWithoutQueueSchema(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init task schema: %v", err) + } + ctx := context.Background() + workspaceID := "workspace-missing-queue" + taskID := "task-missing-queue" + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: workspaceID, Name: workspaceID}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ID: taskID, WorkspaceID: workspaceID, Title: taskID}); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + if err := repo.DeleteTask(ctx, taskID); err != nil { + t.Fatalf("DeleteTask without queue schema: %v", err) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/team_access_migration.go b/apps/backend/internal/task/repository/sqlite/team_access_migration.go index 946466c45d..a84803a341 100644 --- a/apps/backend/internal/task/repository/sqlite/team_access_migration.go +++ b/apps/backend/internal/task/repository/sqlite/team_access_migration.go @@ -67,10 +67,10 @@ func (r *Repository) backfillWorkspaceOwnerMembers() error { // // No migration currently recreates `workspaces`. If one is ever added, this // column has to be in its replacement CREATE TABLE and its INSERT ... SELECT -// list: a rebuild copies a fixed column list, and MigrateLogger.Apply swallows -// errors, so a column added before a rebuild disappears without a sound. That -// is recorded in apps/backend/AGENTS.md and has already cost this branch a -// silent loss once, on `tasks`. +// list: a rebuild copies a fixed column list, so a column added before a +// rebuild disappears unless the replacement explicitly carries it forward. +// Required-store startup now reports unexpected migration failures instead of +// allowing that omission to pass silently. func (r *Repository) ensureOrgUnitPlacement() { r.migrate.Apply( "workspaces.unit_id", diff --git a/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go index c8ebe49a42..507201ae18 100644 --- a/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go +++ b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go @@ -16,9 +16,9 @@ import ( // transaction. Runtime code must never reference the legacy schema; only this // migration knows it. // -// This migration returns every error directly. It never uses the -// best-effort MigrateLogger path, whose contract swallows unexpected -// failures. +// This migration returns every error directly. It does not rely on the +// compatibility migration logger because the cutover is a single transactional +// schema operation whose failure must abort the required task store. const ( executorTypeLocalPC = "local_pc" diff --git a/apps/backend/internal/task/share/repository.go b/apps/backend/internal/task/share/repository.go index e130ba10ad..9cb3566754 100644 --- a/apps/backend/internal/task/share/repository.go +++ b/apps/backend/internal/task/share/repository.go @@ -11,6 +11,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" ) // Backend identifiers persisted in task_shares.backend. @@ -39,7 +40,7 @@ type Share struct { // IsRevoked returns true when the share has been revoked. func (s *Share) IsRevoked() bool { return s.RevokedAt != nil } -// Repository persists Share rows in SQLite. +// Repository persists Share rows in the configured SQL database. type Repository struct { writer *sqlx.DB reader *sqlx.DB @@ -57,23 +58,32 @@ func NewRepository(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, er } func (r *Repository) initSchema() error { - mig := db.NewMigrateLogger(r.writer, r.log) - mig.Apply("table.task_shares", `CREATE TABLE IF NOT EXISTS task_shares ( - id TEXT PRIMARY KEY, - task_session_id TEXT NOT NULL, - backend TEXT NOT NULL, - external_id TEXT NOT NULL, - external_url TEXT NOT NULL, - snapshot_size_bytes INTEGER NOT NULL, - created_at DATETIME NOT NULL, - revoked_at DATETIME NULL, - view_count INTEGER NOT NULL DEFAULT 0 - )`) - mig.Apply("index.task_shares_session", - `CREATE INDEX IF NOT EXISTS idx_task_shares_session ON task_shares(task_session_id)`) + mig := db.NewRequiredMigrateLogger(r.writer, r.log) + if err := mig.Apply("table.task_shares", dialect.MustRenderSchema(r.writer.DriverName(), shareTableSchema)); err != nil { + return fmt.Errorf("required task share migration: %w", err) + } + if err := mig.Apply("index.task_shares_session", + `CREATE INDEX IF NOT EXISTS idx_task_shares_session ON task_shares(task_session_id)`); err != nil { + return fmt.Errorf("required task share migration: %w", err) + } + if err := mig.Err(); err != nil { + return fmt.Errorf("required task share migration: %w", err) + } return nil } +const shareTableSchema = `CREATE TABLE IF NOT EXISTS task_shares ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + backend TEXT NOT NULL, + external_id TEXT NOT NULL, + external_url TEXT NOT NULL, + snapshot_size_bytes INTEGER NOT NULL, + created_at {{timestamp}} NOT NULL, + revoked_at {{timestamp}} NULL, + view_count INTEGER NOT NULL DEFAULT 0 +)` + // Create inserts a new share row. ID, Backend, ExternalID, ExternalURL, and // TaskSessionID must be set by the caller. CreatedAt defaults to time.Now() // (UTC) if zero. @@ -81,9 +91,9 @@ func (r *Repository) Create(ctx context.Context, s *Share) error { if s.CreatedAt.IsZero() { s.CreatedAt = time.Now().UTC() } - _, err := r.writer.ExecContext(ctx, `INSERT INTO task_shares + _, err := r.writer.ExecContext(ctx, r.writer.Rebind(`INSERT INTO task_shares (id, task_session_id, backend, external_id, external_url, snapshot_size_bytes, created_at, view_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), s.ID, s.TaskSessionID, s.Backend, s.ExternalID, s.ExternalURL, s.SnapshotSizeBytes, s.CreatedAt, s.ViewCount, ) @@ -95,22 +105,22 @@ func (r *Repository) Create(ctx context.Context, s *Share) error { // GetByID returns the share with the given ID, or ErrNotFound. func (r *Repository) GetByID(ctx context.Context, id string) (*Share, error) { - row := r.reader.QueryRowxContext(ctx, `SELECT + row := r.reader.QueryRowxContext(ctx, r.reader.Rebind(`SELECT id, task_session_id, backend, external_id, external_url, snapshot_size_bytes, created_at, revoked_at, view_count - FROM task_shares WHERE id = ?`, id) + FROM task_shares WHERE id = ?`), id) return scanShareRow(row) } // ListByTaskSession returns every share row for a session ordered by creation // time (newest first), including revoked rows. func (r *Repository) ListByTaskSession(ctx context.Context, sessionID string) ([]*Share, error) { - rows, err := r.reader.QueryxContext(ctx, `SELECT + rows, err := r.reader.QueryxContext(ctx, r.reader.Rebind(`SELECT id, task_session_id, backend, external_id, external_url, snapshot_size_bytes, created_at, revoked_at, view_count FROM task_shares WHERE task_session_id = ? - ORDER BY created_at DESC`, sessionID) + ORDER BY created_at DESC`), sessionID) if err != nil { return nil, fmt.Errorf("list task_shares: %w", err) } @@ -129,8 +139,8 @@ func (r *Repository) ListByTaskSession(ctx context.Context, sessionID string) ([ // MarkRevoked sets revoked_at on the share row. If the row is already revoked, // the existing revoked_at is preserved. func (r *Repository) MarkRevoked(ctx context.Context, id string, at time.Time) error { - res, err := r.writer.ExecContext(ctx, - `UPDATE task_shares SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`, + res, err := r.writer.ExecContext(ctx, r.writer.Rebind( + `UPDATE task_shares SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`), at.UTC(), id, ) if err != nil { diff --git a/apps/backend/internal/task/share/repository_test.go b/apps/backend/internal/task/share/repository_test.go index 2d6b9ee254..d61598afe3 100644 --- a/apps/backend/internal/task/share/repository_test.go +++ b/apps/backend/internal/task/share/repository_test.go @@ -4,14 +4,29 @@ import ( "context" "errors" "path/filepath" + "strings" "testing" "time" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" ) +func TestShareSchemaRendersForPostgres(t *testing.T) { + rendered, err := dialect.RenderSchema(dialect.PGX, shareTableSchema) + if err != nil { + t.Fatalf("render share schema: %v", err) + } + if !strings.Contains(rendered, "TIMESTAMPTZ") { + t.Fatalf("rendered share schema = %q, want PostgreSQL timestamp type", rendered) + } + if strings.Contains(rendered, "DATETIME") || strings.Contains(rendered, "{{") { + t.Fatalf("rendered share schema contains SQLite or unresolved syntax: %q", rendered) + } +} + func newTestRepo(t *testing.T) *Repository { t.Helper() tmp := t.TempDir() diff --git a/apps/backend/internal/telemetrycontract/store.go b/apps/backend/internal/telemetrycontract/store.go index 1233453f10..25cc9416a1 100644 --- a/apps/backend/internal/telemetrycontract/store.go +++ b/apps/backend/internal/telemetrycontract/store.go @@ -20,6 +20,13 @@ type Store struct { reader *sqlx.DB } +// Activation is one persisted contract activation row. +type Activation struct { + Key string `db:"key"` + Version int `db:"version"` + ActivatedAt time.Time `db:"activated_at"` +} + // NewWithDB creates telemetry_activations idempotently and returns a Store. func NewWithDB(writer, reader *sqlx.DB) (*Store, error) { if _, err := writer.Exec(` @@ -54,6 +61,25 @@ func (s *Store) Activate(ctx context.Context) error { return nil } +// GetActivation reads one activation row. +func (s *Store) GetActivation(ctx context.Context, key string, version int) (Activation, error) { + var activation Activation + err := s.reader.GetContext(ctx, &activation, s.reader.Rebind(` + SELECT contract_key AS key, contract_version AS version, activated_at + FROM telemetry_activations WHERE contract_key = ? AND contract_version = ? + `), key, version) + return activation, err +} + +// DeleteActivation removes one activation row. It is intended for lifecycle +// cleanup and conformance probes, not for normal boot activation. +func (s *Store) DeleteActivation(ctx context.Context, key string, version int) error { + _, err := s.writer.ExecContext(ctx, s.writer.Rebind(` + DELETE FROM telemetry_activations WHERE contract_key = ? AND contract_version = ? + `), key, version) + return err +} + // contractHealth is one registered contract's boot-time health snapshot. type contractHealth struct { key string diff --git a/apps/backend/internal/testutil/postgres.go b/apps/backend/internal/testutil/postgres.go index 3290484c15..1de0532feb 100644 --- a/apps/backend/internal/testutil/postgres.go +++ b/apps/backend/internal/testutil/postgres.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" - _ "github.com/jackc/pgx/v5/stdlib" + internaldb "github.com/kandev/kandev/internal/db" ) // OpenIsolatedPostgres opens dsn with a unique schema on a single connection. @@ -18,10 +18,11 @@ func OpenIsolatedPostgres(t testing.TB, dsn string) *sqlx.DB { t.Helper() schema := "kandev_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") - db, err := sqlx.Open("pgx", dsn) + raw, err := internaldb.OpenPostgres(dsn, 1, 1) if err != nil { t.Fatalf("open postgres: %v", err) } + db := sqlx.NewDb(raw, "pgx") db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) diff --git a/apps/backend/internal/testutil/storeconformance/engine.go b/apps/backend/internal/testutil/storeconformance/engine.go new file mode 100644 index 0000000000..5e545bbc56 --- /dev/null +++ b/apps/backend/internal/testutil/storeconformance/engine.go @@ -0,0 +1,67 @@ +package storeconformance + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/testutil" +) + +// EngineName identifies a database driver exercised by the conformance suite. +type EngineName string + +const ( + EngineSQLite EngineName = "sqlite3" + EnginePostgres EngineName = "pgx" +) + +// Engine is the database handle supplied to an adapter callback. +type Engine struct { + Name EngineName + DB *sqlx.DB +} + +// OpenEngine opens an isolated database for a conformance or upgrade test. +// PostgreSQL is skipped only after adapter completeness has been validated by +// the caller. +func OpenEngine(t *testing.T, name EngineName, postgresDSN string) Engine { + return openEngine(t, name, postgresDSN) +} + +func openEngine(t *testing.T, name EngineName, postgresDSN string) Engine { + t.Helper() + switch name { + case EngineSQLite: + path := filepath.Join(t.TempDir(), "store-conformance.db") + raw, err := db.OpenSQLite(path) + if err != nil { + t.Fatalf("open SQLite conformance database: %v", err) + } + database := sqlx.NewDb(raw, string(EngineSQLite)) + t.Cleanup(func() { _ = database.Close() }) + return Engine{Name: name, DB: database} + case EnginePostgres: + if postgresDSN == "" { + postgresDSN = os.Getenv("KANDEV_TEST_POSTGRES_DSN") + } + if postgresDSN == "" { + t.Skip("set KANDEV_TEST_POSTGRES_DSN to run PostgreSQL conformance") + } + return Engine{Name: name, DB: testutil.OpenIsolatedPostgres(t, postgresDSN)} + default: + t.Fatalf("unknown conformance engine %q", name) + return Engine{} + } +} + +func validateEngine(name EngineName) error { + if name != EngineSQLite && name != EnginePostgres { + return fmt.Errorf("unknown conformance engine %q", name) + } + return nil +} diff --git a/apps/backend/internal/testutil/storeconformance/engine_behavior.go b/apps/backend/internal/testutil/storeconformance/engine_behavior.go new file mode 100644 index 0000000000..8aed065280 --- /dev/null +++ b/apps/backend/internal/testutil/storeconformance/engine_behavior.go @@ -0,0 +1,192 @@ +package storeconformance + +import ( + "fmt" + "time" + + "github.com/kandev/kandev/internal/db/dialect" +) + +const engineBehaviorTable = "__kandev_conformance_engine_behavior" + +// engineBehaviorScenario proves the portable runner contract independently of +// domain schemas. Domain adapters still execute against their owning tables; +// this dedicated fixture is the only generated table in the suite. +func engineBehaviorScenario(s ScenarioContext) error { + schema := `CREATE TABLE IF NOT EXISTS "` + engineBehaviorTable + `" ( + id TEXT PRIMARY KEY, + enabled {{boolean}} NOT NULL DEFAULT FALSE, + value TEXT NOT NULL DEFAULT '', + created_at {{timestamp}} NOT NULL DEFAULT {{current_time}} + )` + if _, err := s.DB.ExecContext(s.Context, dialect.MustRenderSchema(string(s.Engine), schema)); err != nil { + return fmt.Errorf("create engine behavior table: %w", err) + } + if err := engineCRUD(s); err != nil { + return err + } + if err := engineBoolean(s); err != nil { + return err + } + if err := engineTimestamp(s); err != nil { + return err + } + if err := engineConflict(s); err != nil { + return err + } + return engineTransaction(s) +} + +func engineCRUD(s ScenarioContext) error { + table := `"` + engineBehaviorTable + `"` + if _, err := s.DB.ExecContext(s.Context, s.DB.Rebind( + `INSERT INTO `+table+` (id, enabled, value, created_at) VALUES (?, ?, ?, ?)`, + ), "crud", false, "created", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)); err != nil { + return fmt.Errorf("engine create: %w", err) + } + var value string + if err := s.DB.QueryRowxContext(s.Context, s.DB.Rebind( + `SELECT value FROM `+table+` WHERE id = ?`, + ), "crud").Scan(&value); err != nil { + return fmt.Errorf("engine read: %w", err) + } + if value != "created" { + return fmt.Errorf("engine read value = %q, want created", value) + } + if _, err := s.DB.ExecContext(s.Context, s.DB.Rebind( + `UPDATE `+table+` SET value = ? WHERE id = ?`, + ), "updated", "crud"); err != nil { + return fmt.Errorf("engine update: %w", err) + } + if _, err := s.DB.ExecContext(s.Context, s.DB.Rebind( + `DELETE FROM `+table+` WHERE id = ?`, + ), "crud"); err != nil { + return fmt.Errorf("engine delete: %w", err) + } + return nil +} + +func engineBoolean(s ScenarioContext) error { + table := `"` + engineBehaviorTable + `"` + for _, row := range []struct { + id string + want bool + }{ + {id: "boolean-false", want: false}, + {id: "boolean-true", want: true}, + } { + if _, err := s.DB.ExecContext(s.Context, s.DB.Rebind( + `INSERT INTO `+table+` (id, enabled, value) VALUES (?, ?, ?)`, + ), row.id, row.want, row.id); err != nil { + return fmt.Errorf("engine boolean insert %s: %w", row.id, err) + } + var got bool + if err := s.DB.QueryRowxContext(s.Context, s.DB.Rebind( + `SELECT enabled FROM `+table+` WHERE id = ?`, + ), row.id).Scan(&got); err != nil { + return fmt.Errorf("engine boolean read %s: %w", row.id, err) + } + if got != row.want { + return fmt.Errorf("engine boolean %s = %t, want %t", row.id, got, row.want) + } + } + return nil +} + +func engineTimestamp(s ScenarioContext) error { + table := `"` + engineBehaviorTable + `"` + first := time.Date(2024, 2, 1, 10, 0, 0, 0, time.UTC) + second := first.Add(time.Hour) + for _, row := range []struct { + id string + at time.Time + }{{"timestamp-first", first}, {"timestamp-second", second}} { + if _, err := s.DB.ExecContext(s.Context, s.DB.Rebind( + `INSERT INTO `+table+` (id, value, created_at) VALUES (?, ?, ?)`, + ), row.id, row.id, row.at); err != nil { + return fmt.Errorf("engine timestamp insert %s: %w", row.id, err) + } + } + rows, err := s.DB.QueryxContext(s.Context, s.DB.Rebind( + `SELECT created_at FROM `+table+` WHERE id LIKE ? ORDER BY created_at`, + ), "timestamp-%") + if err != nil { + return fmt.Errorf("engine timestamp query: %w", err) + } + defer func() { _ = rows.Close() }() + var got []time.Time + for rows.Next() { + var at time.Time + if err := rows.Scan(&at); err != nil { + return fmt.Errorf("engine timestamp scan: %w", err) + } + got = append(got, at.UTC()) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("engine timestamp rows: %w", err) + } + if len(got) != 2 || !got[0].Equal(first) || !got[1].Equal(second) { + return fmt.Errorf("engine timestamps = %v, want %v and %v", got, first, second) + } + return nil +} + +func engineConflict(s ScenarioContext) error { + table := `"` + engineBehaviorTable + `"` + query := `INSERT INTO ` + table + ` (id, value) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET value = excluded.value` + for _, value := range []string{"first", "second"} { + if _, err := s.DB.ExecContext(s.Context, s.DB.Rebind(query), "conflict", value); err != nil { + return fmt.Errorf("engine conflict upsert %s: %w", value, err) + } + } + var got string + if err := s.DB.QueryRowxContext(s.Context, s.DB.Rebind( + `SELECT value FROM `+table+` WHERE id = ?`, + ), "conflict").Scan(&got); err != nil { + return fmt.Errorf("engine conflict read: %w", err) + } + if got != "second" { + return fmt.Errorf("engine conflict value = %q, want second", got) + } + return nil +} + +func engineTransaction(s ScenarioContext) error { + table := `"` + engineBehaviorTable + `"` + tx, err := s.DB.BeginTxx(s.Context, nil) + if err != nil { + return fmt.Errorf("engine begin rollback transaction: %w", err) + } + if _, err := tx.ExecContext(s.Context, tx.Rebind( + `INSERT INTO `+table+` (id, value) VALUES (?, ?)`, + ), "transaction-rollback", "rollback"); err != nil { + _ = tx.Rollback() + return fmt.Errorf("engine rollback insert: %w", err) + } + if err := tx.Rollback(); err != nil { + return fmt.Errorf("engine rollback: %w", err) + } + var count int + if err := s.DB.QueryRowxContext(s.Context, s.DB.Rebind( + `SELECT COUNT(*) FROM `+table+` WHERE id = ?`, + ), "transaction-rollback").Scan(&count); err != nil { + return fmt.Errorf("engine rollback read: %w", err) + } + if count != 0 { + return fmt.Errorf("engine rollback left %d row(s)", count) + } + committed, err := s.DB.BeginTxx(s.Context, nil) + if err != nil { + return fmt.Errorf("engine begin commit transaction: %w", err) + } + if _, err := committed.ExecContext(s.Context, committed.Rebind( + `INSERT INTO `+table+` (id, value) VALUES (?, ?)`, + ), "transaction-commit", "commit"); err != nil { + _ = committed.Rollback() + return fmt.Errorf("engine commit insert: %w", err) + } + if err := committed.Commit(); err != nil { + return fmt.Errorf("engine commit: %w", err) + } + return nil +} diff --git a/apps/backend/internal/testutil/storeconformance/suite.go b/apps/backend/internal/testutil/storeconformance/suite.go new file mode 100644 index 0000000000..7a2d457b19 --- /dev/null +++ b/apps/backend/internal/testutil/storeconformance/suite.go @@ -0,0 +1,243 @@ +package storeconformance + +import ( + "context" + "fmt" + "sort" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/persistence/requiredstores" +) + +// ScenarioContext is shared by all engine-specific and behavior callbacks. +type ScenarioContext struct { + Context context.Context + Engine EngineName + StoreID string + DB *sqlx.DB +} + +// Scenario is one assertion against an initialized store. +type Scenario func(ScenarioContext) error + +// CapabilityScenario binds exactly one catalog capability to one assertion. +type CapabilityScenario struct { + Capability requiredstores.Capability + Run Scenario +} + +// Scenarios contains the common behavior assertions. Fresh and Replay are +// engine callbacks because schema initialization can use dialect-specific SQL; +// CRUD and capability assertions are shared across engines. +type Scenarios struct { + CRUD Scenario + Capabilities []CapabilityScenario +} + +// EngineAdapter supplies fresh and replay schema initialization for one engine. +type EngineAdapter struct { + Fresh Scenario + Replay Scenario +} + +// Adapter binds one catalog entry to both database engines. +type Adapter struct { + ID string + Engines map[EngineName]EngineAdapter + Scenarios Scenarios +} + +// RunOptions controls which engines a test invokes. The default runs both +// engines. Completeness validation always runs before any engine is opened. +type RunOptions struct { + Engines []EngineName + PostgresDSN string +} + +// ValidateAdapters compares fixed adapter metadata with the authoritative +// catalog. It is deliberately independent of database setup so a skipped +// PostgreSQL test cannot hide an incomplete adapter set. +func ValidateAdapters(catalog []requiredstores.Descriptor, adapters []Adapter) error { + if err := requiredstores.ValidateCatalog(catalog); err != nil { + return fmt.Errorf("validate catalog: %w", err) + } + byID := make(map[string]Adapter, len(adapters)) + for _, adapter := range adapters { + if adapter.ID == "" { + return fmt.Errorf("adapter has empty ID") + } + if _, exists := byID[adapter.ID]; exists { + return fmt.Errorf("duplicate store adapter %q", adapter.ID) + } + byID[adapter.ID] = adapter + } + for _, descriptor := range catalog { + adapter, exists := byID[descriptor.ID] + if !exists { + return fmt.Errorf("missing store adapter %q", descriptor.ID) + } + if err := validateAdapter(descriptor, adapter); err != nil { + return err + } + delete(byID, descriptor.ID) + } + if len(byID) > 0 { + unknown := make([]string, 0, len(byID)) + for id := range byID { + unknown = append(unknown, id) + } + sort.Strings(unknown) + return fmt.Errorf("unknown store adapter %q", unknown[0]) + } + return nil +} + +func validateAdapter(descriptor requiredstores.Descriptor, adapter Adapter) error { + if err := validateAdapterEngines(adapter); err != nil { + return err + } + if adapter.Scenarios.CRUD == nil { + return fmt.Errorf("store adapter %q has no CRUD scenario", adapter.ID) + } + return validateAdapterCapabilities(descriptor, adapter) +} + +func validateAdapterEngines(adapter Adapter) error { + for _, name := range []EngineName{EngineSQLite, EnginePostgres} { + engine, ok := adapter.Engines[name] + if !ok { + return fmt.Errorf("store adapter %q has no %s engine", adapter.ID, name) + } + if engine.Fresh == nil || engine.Replay == nil { + return fmt.Errorf("store adapter %q has incomplete %s schema callbacks", adapter.ID, name) + } + } + for name := range adapter.Engines { + if err := validateEngine(name); err != nil { + return fmt.Errorf("store adapter %q: %w", adapter.ID, err) + } + } + return nil +} + +func validateAdapterCapabilities(descriptor requiredstores.Descriptor, adapter Adapter) error { + want := make(map[requiredstores.Capability]struct{}, len(descriptor.Capabilities)) + for _, capability := range descriptor.Capabilities { + want[capability] = struct{}{} + } + got := make(map[requiredstores.Capability]struct{}, len(adapter.Scenarios.Capabilities)) + for _, capability := range adapter.Scenarios.Capabilities { + if capability.Run == nil { + return fmt.Errorf("store adapter %q has nil %s capability callback", adapter.ID, capability.Capability) + } + if _, exists := got[capability.Capability]; exists { + return fmt.Errorf("store adapter %q has duplicate %s capability callback", adapter.ID, capability.Capability) + } + got[capability.Capability] = struct{}{} + } + return validateCapabilitySets(adapter.ID, want, got) +} + +func validateCapabilitySets(id string, want, got map[requiredstores.Capability]struct{}) error { + for capability := range want { + if _, exists := got[capability]; !exists { + return fmt.Errorf("store adapter %q is missing %s capability callback", id, capability) + } + } + for capability := range got { + if _, exists := want[capability]; !exists { + return fmt.Errorf("store adapter %q declares unexpected %s capability callback", id, capability) + } + } + return nil +} + +// Run executes all common and capability scenarios with stable subtest names. +func Run(t testingT, catalog []requiredstores.Descriptor, adapters []Adapter, options RunOptions) { + t.Helper() + if err := ValidateAdapters(catalog, adapters); err != nil { + t.Fatalf("store conformance metadata: %v", err) + } + byID := make(map[string]Adapter, len(adapters)) + for _, adapter := range adapters { + byID[adapter.ID] = adapter + } + engines := options.Engines + if len(engines) == 0 { + engines = []EngineName{EngineSQLite, EnginePostgres} + } + for _, engineName := range engines { + if err := validateEngine(engineName); err != nil { + t.Fatalf("store conformance options: %v", err) + } + for _, descriptor := range catalog { + adapter := byID[descriptor.ID] + t.Run(string(engineName)+"/"+descriptor.ID, func(t *testing.T) { + engine := openEngine(t, engineName, options.PostgresDSN) + callbacks := adapter.Engines[engineName] + runScenario(t, "fresh", ScenarioContext{Context: context.Background(), Engine: engineName, StoreID: adapter.ID, DB: engine.DB}, callbacks.Fresh) + runScenario(t, "replay", ScenarioContext{Context: context.Background(), Engine: engineName, StoreID: adapter.ID, DB: engine.DB}, callbacks.Replay) + runScenario(t, "crud", ScenarioContext{Context: context.Background(), Engine: engineName, StoreID: adapter.ID, DB: engine.DB}, adapter.Scenarios.CRUD) + for _, capability := range adapter.Scenarios.Capabilities { + capabilityName := capabilityName(capability.Capability) + runScenario(t, capabilityName, ScenarioContext{Context: context.Background(), Engine: engineName, StoreID: adapter.ID, DB: engine.DB}, capability.Run) + } + }) + } + } +} + +// RunHarnessSelfTest exercises the runner's own portable SQL fixture. It is +// intentionally separate from Run so the generated table cannot make a +// domain adapter appear to cover its owning schema. +func RunHarnessSelfTest(t testingT, options RunOptions) { + t.Helper() + engines := options.Engines + if len(engines) == 0 { + engines = []EngineName{EngineSQLite, EnginePostgres} + } + for _, engineName := range engines { + if err := validateEngine(engineName); err != nil { + t.Fatalf("store conformance harness options: %v", err) + } + engineName := engineName + t.Run("harness/"+string(engineName), func(t *testing.T) { + engine := openEngine(t, engineName, options.PostgresDSN) + runScenario(t, "engine-behavior", ScenarioContext{ + Context: context.Background(), Engine: engineName, StoreID: "harness", DB: engine.DB, + }, engineBehaviorScenario) + }) + } +} + +// testingT keeps Run usable with testing.T while making its contract obvious. +type testingT interface { + Helper() + Fatalf(format string, args ...any) + Run(name string, f func(*testing.T)) bool +} + +func runScenario(t *testing.T, name string, context ScenarioContext, scenario Scenario) { + t.Run(name, func(t *testing.T) { + if err := scenario(context); err != nil { + t.Fatalf("%s scenario failed: %v", name, err) + } + }) +} + +func capabilityName(capability requiredstores.Capability) string { + switch capability { + case requiredstores.CapabilityBoolean: + return "boolean" + case requiredstores.CapabilityTimestamp: + return "timestamp" + case requiredstores.CapabilityConflict: + return "conflict" + case requiredstores.CapabilityTransaction: + return "transaction" + default: + return string(capability) + } +} diff --git a/apps/backend/internal/testutil/storeconformance/suite_test.go b/apps/backend/internal/testutil/storeconformance/suite_test.go new file mode 100644 index 0000000000..4e15c593f1 --- /dev/null +++ b/apps/backend/internal/testutil/storeconformance/suite_test.go @@ -0,0 +1,102 @@ +package storeconformance + +import ( + "fmt" + "testing" + + "github.com/kandev/kandev/internal/persistence/requiredstores" +) + +func TestValidateAdaptersRejectsMissingCapability(t *testing.T) { + catalog := []requiredstores.Descriptor{{ + ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}, + Capabilities: []requiredstores.Capability{requiredstores.CapabilityBoolean}, + }} + adapter := Adapter{ + ID: "store", + Engines: map[EngineName]EngineAdapter{ + EngineSQLite: {Fresh: noop, Replay: noop}, + EnginePostgres: {Fresh: noop, Replay: noop}, + }, + Scenarios: Scenarios{CRUD: noop}, + } + if err := ValidateAdapters(catalog, []Adapter{adapter}); err == nil { + t.Fatal("ValidateAdapters() error = nil, want missing boolean callback") + } +} + +func TestValidateAdaptersRejectsMissingEngine(t *testing.T) { + catalog := []requiredstores.Descriptor{{ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}}} + adapter := Adapter{ + ID: "store", + Engines: map[EngineName]EngineAdapter{EngineSQLite: {Fresh: noop, Replay: noop}}, + Scenarios: Scenarios{CRUD: noop}, + } + if err := ValidateAdapters(catalog, []Adapter{adapter}); err == nil { + t.Fatal("ValidateAdapters() error = nil, want missing PostgreSQL engine") + } +} + +func TestRunUsesStableEngineStoreScenarioNames(t *testing.T) { + catalog := []requiredstores.Descriptor{{ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}}} + seen := make(chan string, 3) + callback := func(name string) Scenario { + return func(s ScenarioContext) error { + seen <- fmt.Sprintf("%s/%s/%s", s.Engine, s.StoreID, name) + return nil + } + } + adapter := Adapter{ + ID: "store", + Engines: map[EngineName]EngineAdapter{ + EngineSQLite: {Fresh: callback("fresh"), Replay: callback("replay")}, + EnginePostgres: {Fresh: noop, Replay: noop}, + }, + Scenarios: Scenarios{CRUD: callback("crud")}, + } + Run(t, catalog, []Adapter{adapter}, RunOptions{Engines: []EngineName{EngineSQLite}}) + close(seen) + want := map[string]bool{"sqlite3/store/fresh": false, "sqlite3/store/replay": false, "sqlite3/store/crud": false} + for got := range seen { + if _, ok := want[got]; !ok { + t.Errorf("unexpected callback %q", got) + continue + } + want[got] = true + } + for name, found := range want { + if !found { + t.Errorf("callback %q was not run", name) + } + } +} + +func TestCapabilityScenarioUsesDatabase(t *testing.T) { + catalog := []requiredstores.Descriptor{{ + ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}, + Capabilities: []requiredstores.Capability{requiredstores.CapabilityTransaction}, + }} + adapter := Adapter{ + ID: "store", + Engines: map[EngineName]EngineAdapter{ + EngineSQLite: {Fresh: func(s ScenarioContext) error { + _, err := s.DB.ExecContext(s.Context, "CREATE TABLE values_table (value TEXT NOT NULL)") + return err + }, Replay: noop}, + EnginePostgres: {Fresh: noop, Replay: noop}, + }, + Scenarios: Scenarios{ + CRUD: noop, + Capabilities: []CapabilityScenario{{ + Capability: requiredstores.CapabilityTransaction, + Run: func(s ScenarioContext) error { + _, err := s.DB.ExecContext(s.Context, "INSERT INTO values_table(value) VALUES (?)", "committed") + return err + }, + }}, + }, + } + Run(t, catalog, []Adapter{adapter}, RunOptions{Engines: []EngineName{EngineSQLite}}) +} + +func noop(ScenarioContext) error { return nil } diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 6a8112b0f6..de24f0a940 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -88,7 +88,9 @@ func (r *sqliteRepository) initSchema() error { if _, err := r.db.Exec(schema); err != nil { return err } - r.runMigrations() + if err := r.runMigrations(); err != nil { + return err + } return r.ensureDefaultUser() } @@ -96,8 +98,8 @@ func (r *sqliteRepository) initSchema() error { // runMigrations evolves existing databases. CREATE TABLE IF NOT EXISTS is a // no-op on a table that already exists, so every added column must also appear // here as an idempotent ADD COLUMN (see apps/backend/CLAUDE.md, ADR 0027). -func (r *sqliteRepository) runMigrations() { - m := db.NewMigrateLogger(r.db, nil) +func (r *sqliteRepository) runMigrations() error { + m := db.NewRequiredMigrateLogger(r.db, nil) m.Apply("users.display_name", "ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT ''") // Default 'admin': the pre-auth singleton default-user becomes the admin // when authentication is enabled. Explicit CreateUser calls always set role. @@ -116,6 +118,10 @@ func (r *sqliteRepository) runMigrations() { m.Apply("users.is_operator", "ALTER TABLE users ADD COLUMN is_operator INTEGER NOT NULL DEFAULT 0") // Safe pre-auth: the table only ever held the single default-user row. m.Apply("users.email_unique", "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)") + if err := m.Err(); err != nil { + return fmt.Errorf("required user migration: %w", err) + } + return nil } // ensureDefaultUser inserts the pre-auth default user row when it does not @@ -215,7 +221,7 @@ func (r *sqliteRepository) CreateUser(ctx context.Context, user *models.User) er _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO users (id, email, display_name, role, status, org_id, is_operator, settings, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, '{}', ?, ?) - `), user.ID, user.Email, user.DisplayName, user.Role, user.Status, user.OrgID, user.IsOperator, + `), user.ID, user.Email, user.DisplayName, user.Role, user.Status, user.OrgID, dialect.BoolToInt(user.IsOperator), user.CreatedAt, user.UpdatedAt) return err } diff --git a/apps/backend/internal/utility/store/sqlite.go b/apps/backend/internal/utility/store/sqlite.go index 356a94baac..a8c86d10ad 100644 --- a/apps/backend/internal/utility/store/sqlite.go +++ b/apps/backend/internal/utility/store/sqlite.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/utility/models" ) @@ -84,12 +85,28 @@ func (r *sqliteRepository) initSchema() error { return err } - // Add enabled column if it doesn't exist (migration for existing DBs) - _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`) - _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN agent_profile_id TEXT NOT NULL DEFAULT ''`) - _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN profile_binding_state TEXT NOT NULL DEFAULT 'explicit'`) - _, _ = r.db.Exec(`ALTER TABLE utility_agent_calls ADD COLUMN agent_profile_id TEXT NOT NULL DEFAULT ''`) - _, _ = r.db.Exec(`ALTER TABLE utility_agent_calls ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) + // Add columns for existing databases. Duplicate-column errors are the only + // tolerated replay result; every other migration failure must stop the + // required utility store from being published. + migrate := db.NewRequiredMigrateLogger(r.db, nil) + migrations := []struct { + name string + stmt string + }{ + {"utility_agents.enabled", `ALTER TABLE utility_agents ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`}, + {"utility_agents.agent_profile_id", `ALTER TABLE utility_agents ADD COLUMN agent_profile_id TEXT NOT NULL DEFAULT ''`}, + {"utility_agents.profile_binding_state", `ALTER TABLE utility_agents ADD COLUMN profile_binding_state TEXT NOT NULL DEFAULT 'explicit'`}, + {"utility_agent_calls.agent_profile_id", `ALTER TABLE utility_agent_calls ADD COLUMN agent_profile_id TEXT NOT NULL DEFAULT ''`}, + {"utility_agent_calls.execution_profile_id", `ALTER TABLE utility_agent_calls ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`}, + } + for _, migration := range migrations { + if err := migrate.Apply(migration.name, migration.stmt); err != nil { + return fmt.Errorf("required utility migration: %w", err) + } + } + if err := migrate.Err(); err != nil { + return fmt.Errorf("required utility migration: %w", err) + } // Heal pre-existing custom agents that were created with enabled=0 due // to a bug in CreateAgent (the Enabled field on the model defaulted to @@ -102,10 +119,12 @@ func (r *sqliteRepository) initSchema() error { // updated_at, so a user who deliberately disables an agent post-fix // has updated_at > created_at and is left alone. Without this guard // every restart would silently re-enable any user-disabled custom agent. - _, _ = r.db.Exec(`UPDATE utility_agents SET enabled = 1 + if _, err := r.db.Exec(`UPDATE utility_agents SET enabled = 1 WHERE builtin = 0 AND enabled = 0 AND agent_id <> '' AND model <> '' - AND updated_at = created_at`) + AND updated_at = created_at`); err != nil { + return fmt.Errorf("repair utility agent enabled values: %w", err) + } // Legacy-data backfill: rewrite the old "claude-code" identifier and any // pre-fix builtin rows that were persisted with an empty agent_id to the @@ -115,8 +134,10 @@ func (r *sqliteRepository) initSchema() error { // ON CONFLICT(id) DO NOTHING contract, those stale rows survived // untouched on every restart. Current seeds now use builtinSeedAgentID // (see builtins.go), so this migration only repairs existing deployments. - _, _ = r.db.Exec(`UPDATE utility_agents SET agent_id = 'claude-acp' - WHERE agent_id IN ('claude-code', '')`) + if _, err := r.db.Exec(`UPDATE utility_agents SET agent_id = 'claude-acp' + WHERE agent_id IN ('claude-code', '')`); err != nil { + return fmt.Errorf("repair utility agent identifiers: %w", err) + } // Seed built-in agents if err := r.seedBuiltinAgents(); err != nil { @@ -140,7 +161,9 @@ func (r *sqliteRepository) migrateTemplatePlaceholders() error { for _, p := range placeholders { old := "{{." + p + "}}" new := "{{" + p + "}}" - _, _ = r.db.Exec(r.db.Rebind(`UPDATE utility_agents SET prompt = REPLACE(prompt, ?, ?) WHERE builtin = 1`), old, new) + if _, err := r.db.Exec(r.db.Rebind(`UPDATE utility_agents SET prompt = REPLACE(prompt, ?, ?) WHERE builtin = 1`), old, new); err != nil { + return fmt.Errorf("replace utility prompt placeholder %q: %w", old, err) + } } return nil } diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index f9779a68ce..09604cb4e3 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -32,7 +32,7 @@ func NewWithDB(writer, reader *sqlx.DB, log *logger.Logger) (*Repository, error) repo := &Repository{ db: writer, ro: reader, - migrate: db.NewMigrateLogger(writer, log), + migrate: db.NewRequiredMigrateLogger(writer, log), } if err := repo.initSchema(); err != nil { return nil, fmt.Errorf("failed to initialize workflow schema: %w", err) @@ -167,6 +167,9 @@ func (r *Repository) initSchema() error { if err := r.repairBrokenStepIDReferences(); err != nil { return fmt.Errorf("failed to repair step_id references: %w", err) } + if err := r.migrate.Err(); err != nil { + return fmt.Errorf("required workflow migration: %w", err) + } return nil } diff --git a/apps/backend/internal/workflowsync/store.go b/apps/backend/internal/workflowsync/store.go index 679d079c87..ab94e486e4 100644 --- a/apps/backend/internal/workflowsync/store.go +++ b/apps/backend/internal/workflowsync/store.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "strings" "time" "github.com/jmoiron/sqlx" @@ -241,5 +240,5 @@ func boolToInt(b bool) int { } func schemaSQLForDriver(schema, driver string) string { - return strings.ReplaceAll(schema, "DATETIME", dialect.TimestampType(driver)) + return dialect.MustRenderSchema(driver, schema) } diff --git a/apps/backend/scripts/update-store-schema-fixtures.sh b/apps/backend/scripts/update-store-schema-fixtures.sh new file mode 100644 index 0000000000..6b60951897 --- /dev/null +++ b/apps/backend/scripts/update-store-schema-fixtures.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +tag=$1 +if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "stable tag must be an explicit SemVer tag such as v0.93.0" >&2 + exit 2 +fi + +root=$(git rev-parse --show-toplevel) +commit=$(git rev-parse "$tag^{commit}") +fixture_dir="$root/apps/backend/internal/persistence/storeconformance/testdata/upgrades/$tag" +manifest="$fixture_dir/manifest.json" + +if [[ ! -f "$manifest" || ! -f "$fixture_dir/sqlite.sql" || ! -f "$fixture_dir/postgres.sql" ]]; then + echo "fixture files are missing for $tag" >&2 + exit 1 +fi + +FIXTURE_ROOT="$root" FIXTURE_MANIFEST="$manifest" FIXTURE_TAG="$tag" FIXTURE_COMMIT="$commit" \ +python3 - <<'PY' +import hashlib +import json +import os +from pathlib import Path + +manifest_path = Path(os.environ["FIXTURE_MANIFEST"]) +fixture_root = Path(os.environ["FIXTURE_ROOT"]) +tag = os.environ["FIXTURE_TAG"] +commit = os.environ["FIXTURE_COMMIT"] +document = json.loads(manifest_path.read_text()) +if document.get("tag") != tag: + raise SystemExit(f"manifest tag is {document.get('tag')!r}, expected {tag!r}") +document["source_commit"] = commit +for fixture in document.get("fixtures", []): + path = fixture_root / "apps/backend/internal/persistence/storeconformance/testdata/upgrades" / tag / fixture["file"] + fixture["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() +manifest_path.write_text(json.dumps(document, indent=2) + "\n") +PY + +echo "updated provenance and checksums for $tag at $commit" diff --git a/docs/decisions/2026-09-05-required-internal-persistence.md b/docs/decisions/2026-09-05-required-internal-persistence.md new file mode 100644 index 0000000000..cb4e7a5ae0 --- /dev/null +++ b/docs/decisions/2026-09-05-required-internal-persistence.md @@ -0,0 +1,55 @@ +# ADR-2026-09-05-required-internal-persistence: Required internal persistence fails startup + +**Status:** accepted +**Date:** 2026-09-05 +**Area:** backend + +## Context + +Kandev stores internal state in built-in SQL stores. Many stores support both SQLite and PostgreSQL. + +Some provider constructors currently combine local schema initialization with remote provider setup. Bootstrap treats their errors as nonfatal. + +This behavior lets a database error look like an unavailable provider. Kandev can then report readiness while required internal state is unavailable. + +Package-specific PostgreSQL tests do not close this gap. A package without a PostgreSQL test marker can remove itself from CI discovery. + +## Decision + +All built-in SQL stores are required internal persistence. One catalog lists their identities, dependencies, health probes, and conformance capabilities. + +Bootstrap must initialize every catalog entry before readiness. A store or schema error stops startup and keeps readiness unavailable. + +Feature flags control service activation, not SQL schema initialization. External credentials, authentication, and provider reachability remain degradable after local stores initialize. + +Fixed CI packages compare bootstrap wiring and dual-engine conformance adapters with the catalog. CI does not discover PostgreSQL coverage from source markers. + +`GET /health` remains a liveness endpoint. `GET /ready` and authenticated diagnostics expose aggregate required-store health without raw database errors. + +## Consequences + +Database defects stop a rollout before Kandev accepts stateful traffic. Operators receive the failed store identity and a recovery action. + +An unavailable provider still affects only that provider. Its local store remains available for settings, diagnostics, and later recovery. + +Each new SQL store adds catalog, bootstrap, health, SQLite, PostgreSQL, and upgrade obligations. This adds test work but removes self-selected coverage. + +Feature-disabled schemas can exist in the database. This cost is small and keeps later feature activation inside the tested startup contract. + +## Alternatives Considered + +### Keep provider initialization nonfatal + +Rejected. This option cannot distinguish a remote provider error from a required local schema error. + +### Discover PostgreSQL packages from test markers + +Rejected. A package without a marker can exclude itself from the test job. + +### Keep separate inventories for bootstrap and tests + +Rejected. Separate lists drift and cannot prove completeness. + +### Make every provider error fatal + +Rejected. A remote provider outage must not stop unrelated Kandev services. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 17ff4e25df..d4ae561599 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -246,6 +246,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-09-03-separate-system-data-storage-pages | [Separate System Data and Storage Pages](2026-09-03-separate-system-data-storage-pages.md) | accepted | frontend | 2026-09-03 | | 2026-09-04-generation-fenced-task-environment-ownership | [Fence Task Environment Ownership by Generation](2026-09-04-generation-fenced-task-environment-ownership.md) | accepted | backend | 2026-09-04 | | 2026-09-04-use-repository-token-for-runtime-pin-prs | [Use the built-in Actions token for runtime pin PRs](2026-09-04-use-repository-token-for-runtime-pin-prs.md) | accepted | workflow, security | 2026-09-04 | +| 2026-09-05-required-internal-persistence | [Required internal persistence fails startup](2026-09-05-required-internal-persistence.md) | accepted | backend | 2026-09-05 | | 2026-09-05-bounded-clarification-response-path | [Bound Clarification Responses with Indexed Lookup](2026-09-05-bounded-clarification-response-path.md) | accepted | backend, frontend, protocol | 2026-09-05 | | 2026-09-05-agent-owned-credential-file-conflicts | [Agent-owned credential file conflicts](2026-09-05-agent-owned-credential-file-conflicts.md) | accepted | backend | 2026-09-05 | | 2026-09-06-opt-in-external-e2e-runners | [Opt in selected Linux CI jobs to external runners](2026-09-06-opt-in-external-e2e-runners.md) | accepted | infra, workflow, security | 2026-09-06 | diff --git a/docs/plans/required-store-drift-prevention/plan.md b/docs/plans/required-store-drift-prevention/plan.md new file mode 100644 index 0000000000..1c32ddc397 --- /dev/null +++ b/docs/plans/required-store-drift-prevention/plan.md @@ -0,0 +1,144 @@ +--- +created: 2026-09-05 +status: implemented +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +legacy_specs: [] +--- + +# Implementation Plan: Required Store Drift Prevention + +## Overview + +This package makes SQL store coverage catalog-driven and fail-closed. It starts from `origin/main` commit `d74e85883`, which contains PR #3372. + +The work establishes metadata and shared SQL helpers first. Bootstrap, conformance, upgrade, health, CI, and contributor documentation then consume those contracts. + +## Scope + +### In scope + +- Define one inventory for every built-in SQL schema owner. +- Make required store initialization fatal before readiness. +- Preserve independent degradation for external provider credentials and APIs. +- Add shared schema rendering, query rebinding, and static SQL checks. +- Run one behavior suite against SQLite and PostgreSQL for every catalog entry. +- Add clean-install and previous-stable upgrade coverage for both engines. +- Expose required-store health through readiness, diagnostics, and service errors. +- Replace marker-based PostgreSQL CI discovery with fixed catalog-driven tests. +- Document the contributor and operator contracts. + +### Out of scope + +- Active-active backend support. +- Cross-engine data migration. +- Automatic PostgreSQL backup or restore. +- Automatic repair of a damaged schema. +- Provider API or authentication policy changes. +- Filesystem-only stores. +- A change to `/health` liveness behavior. + +## Technical approach + +### Catalog and startup tracking + +Add `internal/persistence/requiredstores` with immutable descriptors and a runtime tracker. Audit every SQL schema owner reached from `internal/backendapp`. + +The tracker rejects unknown, duplicate, out-of-order, and missing store results. Bootstrap passes the tracker through every current repository and secondary store phase. + +### Shared SQL contracts + +Extend `internal/db/dialect` with token-based schema rendering. Add shared execution helpers that rebind at the final database or transaction boundary. + +Remove store-local DDL replacements from the PR #3372 store set. Keep driver differences in central helpers or explicit dialect branches. + +### Static SQL analysis + +Add `internal/db/sqlguard` and `cmd/sqlguard`. The analyzer uses Go syntax trees and exact exemptions from `internal/db/sqlguard/exemptions.json`. + +The first rules cover SQLite catalog syntax, conflict syntax, raw placeholders, boolean integers, SQLite date functions, and direct `DATETIME` types. + +### Required and degradable phases + +Initialize provider-owned SQL stores before provider services. Then pass those stores into service constructors. + +Remote credentials, authentication, probes, and pollers remain nonfatal. Local store errors stop startup and leave readiness unavailable. + +### Conformance and upgrades + +Add a reusable runner in `internal/testutil/storeconformance`. Add fixed aggregation adapters in `internal/persistence/storeconformance`. + +The aggregation package compares adapter IDs and capabilities with the catalog before it opens an engine. It runs the same behavior against SQLite and PostgreSQL. + +Commit `v0.93.0` schema fixtures with provenance and sentinel rows. Run current bootstrap and replay against each fixture. + +### Health and caller behavior + +Run an immediate required-store probe before readiness and a periodic probe after startup. Project aggregate state through `/ready` and authenticated system diagnostics. + +Gate stateful HTTP and WebSocket entry points while persistence is unhealthy. Return a stable `persistence_unavailable` response without raw database data. + +### CI and documentation + +Replace the `PostgresDSNFromEnv` grep in `.github/workflows/backend-tests.yml` with fixed package commands. Run full conformance on PostgreSQL 16. + +Run clean-boot and upgrade coverage on PostgreSQL 18. Mirror and pin the service image through `.github/workflows/ci-base-image.yml`. + +Update `docs/public/backend-development.md`, `docs/public/operations.md`, the root `AGENTS.md`, and `apps/backend/AGENTS.md`. + +## Tests + +| Acceptance criteria | Evidence | +| --- | --- | +| `AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.1` through `.7` | Central SQLite and PostgreSQL conformance plus boot tests | +| `AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.1`, `.4` | Failure-injected required-store bootstrap tests | +| `AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.2`, `.3` | Provider credential and remote-probe isolation tests | +| `AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.1` through `.4` | Catalog, bootstrap-visit, adapter, engine, and diagnostics completeness tests | +| `AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.1` through `.4` | Reusable scenario runner and fixed aggregation package | +| `AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.1` through `.5` | SQL guard fixtures and shared dialect helper tests | +| `AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.1` through `.4` | `v0.93.0` fixture upgrades on SQLite, PostgreSQL 16, and PostgreSQL 18 | +| `AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.1` through `.5` | Readiness, diagnostics, middleware, recovery, and liveness tests | +| `AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008.1` through `.3` | Public-doc checks and workflow contract tests | + +## E2E tests + +No browser test is required. Backend HTTP tests cover the user-visible readiness and `503` contracts. + +## Work orders + +- [x] [Task 01: Create the required-store catalog](task-01-required-store-catalog.md) +- [x] [Task 02: Centralize shared SQL rendering](task-02-shared-sql-rendering.md) +- [x] [Task 03: Enforce SQL dialect safety](task-03-sql-dialect-safety.md) +- [x] [Task 04: Enforce required-store bootstrap](task-04-required-store-bootstrap.md) +- [x] [Task 05: Build the conformance framework](task-05-conformance-framework.md) +- [x] [Task 06: Complete catalog conformance](task-06-catalog-conformance.md) +- [x] [Task 07: Add previous-stable upgrade fixtures](task-07-upgrade-fixtures.md) +- [x] [Task 08: Expose required-store health](task-08-required-store-health.md) +- [x] [Task 09: Install CI and contributor gates](task-09-ci-contributor-gates.md) + +## Verification results + +Implemented and verified with the targeted package suites, full SQLite catalog +conformance, PostgreSQL 16 conformance and upgrade coverage, and PostgreSQL 18 +upgrade and boot coverage. The broad `go test ./internal/...` audit reached +9,902 passing tests; nine config and launcher home-discovery tests failed in +this runner because `/root/.kandev/config.yaml` took precedence over their +temporary home fixtures. + +## Risks + +- Store constructors use varied dependencies and startup phases. The audit can find hidden schema owners after the catalog shape lands. +- Some provider constructors combine schema work with external setup. Their split must preserve existing cleanup and polling behavior. +- Static placeholder analysis cannot infer arbitrary runtime strings. Shared execution helpers reduce this blind spot. +- The `v0.93.0` PostgreSQL fixture must preserve the incident's partial initialization state. +- Runtime health gating can block diagnostics unless the route exclusions are exact. +- PostgreSQL 18 adds CI time and needs an immutable mirrored image. diff --git a/docs/plans/required-store-drift-prevention/task-01-required-store-catalog.md b/docs/plans/required-store-drift-prevention/task-01-required-store-catalog.md new file mode 100644 index 0000000000..4ace7d4335 --- /dev/null +++ b/docs/plans/required-store-drift-prevention/task-01-required-store-catalog.md @@ -0,0 +1,87 @@ +--- +id: "01-required-store-catalog" +title: "Create the required-store catalog" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003 +acceptance_criteria: + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.3 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.4 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +--- + +# Task 01: Create the required-store catalog + +## Summary + +Create the authoritative SQL store catalog and its state tracker. Audit all bootstrap paths and record every schema owner. + +## In scope + +- Add catalog descriptors, stable IDs, dependencies, tables, and capabilities. +- Add tracker states and validation for unknown, duplicate, out-of-order, and missing results. +- Audit schema constructors in all `backendapp` startup phases. +- Add catalog unit tests that run without a database. + +## Out of scope + +- Change startup error handling. +- Add engine conformance adapters. +- Add runtime health polling. + +## Acceptance + +- The catalog contains every built-in SQL schema owner that uses the selected Kandev database. +- Catalog validation rejects duplicate IDs, missing dependencies, cycles, and invalid capabilities. +- The tracker returns deterministic snapshots in catalog order. + +## Verification + +```bash +go test -race ./internal/persistence/requiredstores -run '^Test(Catalog|Tracker)' -v +``` + +## Files likely touched + +- `apps/backend/internal/persistence/requiredstores/catalog.go` +- `apps/backend/internal/persistence/requiredstores/tracker.go` +- `apps/backend/internal/persistence/requiredstores/catalog_test.go` +- `apps/backend/internal/persistence/requiredstores/tracker_test.go` +- `apps/backend/internal/backendapp/storage.go` +- `apps/backend/internal/backendapp/services.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/backendapp/orchestrator.go` +- `apps/backend/internal/backendapp/worktree.go` + +## Dependencies + +None. + +## Risks + +- A store can create schema inside a service constructor that does not use a store name. + +## Parallelism + +`sequential` + +## Inputs + +- System design sections: Required store catalog, Bootstrap contract. +- Existing constructors found from `db.Pool`, `Writer`, and `Reader` usage. + +## Results + +Implemented the immutable catalog, dependency-aware tracker, runtime status +snapshot, and table-level health probe. Verified with: + +```text +go test -race ./internal/persistence/requiredstores -run 'Test(Catalog|Tracker)' -count=1 +``` + +Five tests passed. diff --git a/docs/plans/required-store-drift-prevention/task-02-shared-sql-rendering.md b/docs/plans/required-store-drift-prevention/task-02-shared-sql-rendering.md new file mode 100644 index 0000000000..fed44d8819 --- /dev/null +++ b/docs/plans/required-store-drift-prevention/task-02-shared-sql-rendering.md @@ -0,0 +1,89 @@ +--- +id: "02-shared-sql-rendering" +title: "Centralize shared SQL rendering" +status: done +wave: 2 +depends_on: + - "01-required-store-catalog" +plan: "plan.md" +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005 +acceptance_criteria: + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.7 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.3 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.5 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +--- + +# Task 02: Centralize shared SQL rendering + +## Summary + +Add central schema rendering and rebound execution helpers. Replace store-local DDL text replacement in the PR #3372 store set. + +## In scope + +- Add validated schema tokens for timestamps, booleans, identities, and current time. +- Add final-boundary helpers for database and transaction execution. +- Support `sqlx.In` expansion before rebinding. +- Convert GitHub, GitLab, Jira, Linear, Sentry, Azure DevOps, workflow-sync, and automation schema rendering. + +## Out of scope + +- Add the static analyzer. +- Change domain data models. +- Convert dialect-specific SQL that already uses a central helper. + +## Acceptance + +- Schema rendering rejects unknown and unexpanded tokens. +- Shared execution helpers rebind database and transaction queries exactly once. +- The affected stores contain no local DDL `strings.Replace` chain. + +## Verification + +```bash +go test -race ./internal/db/... ./internal/github ./internal/gitlab ./internal/jira ./internal/linear ./internal/sentry ./internal/azuredevops ./internal/workflowsync ./internal/automation -run 'Test.*(Schema|Rebind|Boolean|Timestamp|Conflict|Transaction)' -v +``` + +## Files likely touched + +- `apps/backend/internal/db/dialect/schema.go` +- `apps/backend/internal/db/dialect/schema_test.go` +- `apps/backend/internal/db/exec.go` +- `apps/backend/internal/db/exec_test.go` +- `apps/backend/internal/github/store.go` +- `apps/backend/internal/gitlab/store.go` +- `apps/backend/internal/jira/store.go` +- `apps/backend/internal/linear/store.go` +- `apps/backend/internal/sentry/store.go` +- `apps/backend/internal/azuredevops/store.go` +- `apps/backend/internal/workflowsync/store.go` +- `apps/backend/internal/automation/store.go` + +## Dependencies + +- Task 01 supplies catalog capabilities for the affected stores. + +## Risks + +- Token replacement order can change a legacy schema fragment if tokens are not explicit. + +## Parallelism + +`sequential` + +## Inputs + +- System design section: Schema and query contracts. +- Existing `internal/db/dialect` helpers and PR #3372 store conversions. + +## Results + +Implemented token-based schema rendering for SQLite and PostgreSQL, final-boundary +query rebinding, and provider-store migration updates. The dialect, execution, +share, and provider regression suites passed, including the PostgreSQL share +schema test. diff --git a/docs/plans/required-store-drift-prevention/task-03-sql-dialect-safety.md b/docs/plans/required-store-drift-prevention/task-03-sql-dialect-safety.md new file mode 100644 index 0000000000..abe2b3474b --- /dev/null +++ b/docs/plans/required-store-drift-prevention/task-03-sql-dialect-safety.md @@ -0,0 +1,81 @@ +--- +id: "03-sql-dialect-safety" +title: "Enforce SQL dialect safety" +status: done +wave: 3 +depends_on: + - "02-shared-sql-rendering" +plan: "plan.md" +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005 +acceptance_criteria: + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.3 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.4 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +--- + +# Task 03: Enforce SQL dialect safety + +## Summary + +Add an AST-based SQL safety command and an exact exemption registry. Establish a clean baseline for all non-test backend source. + +## In scope + +- Detect every risky SQL class named in the system design. +- Resolve package constants, function-local literals, concatenation, and direct executor calls. +- Add exact file, symbol, rule, and reason exemptions for SQLite-only code. +- Reject invalid, broad, stale, and unused exemptions. +- Add a backend Make target for the command. + +## Out of scope + +- Infer arbitrary SQL returned by remote data or reflection. +- Add the command to GitHub Actions. + +## Acceptance + +- Each rule has positive and negative analyzer fixtures. +- Current shared-store source passes with no implicit directory exemptions. +- A raw placeholder sent through a database or transaction executor fails the command. + +## Verification + +```bash +go test -race ./internal/db/sqlguard/... && go run ./cmd/sqlguard ./internal +``` + +## Files likely touched + +- `apps/backend/internal/db/sqlguard/analyzer.go` +- `apps/backend/internal/db/sqlguard/analyzer_test.go` +- `apps/backend/internal/db/sqlguard/testdata/` +- `apps/backend/internal/db/sqlguard/exemptions.json` +- `apps/backend/cmd/sqlguard/main.go` +- `apps/backend/Makefile` + +## Dependencies + +- Task 02 removes known ad hoc schema replacements before the baseline is recorded. + +## Risks + +- Incomplete local data-flow analysis can miss a placeholder carried through a helper. + +## Parallelism + +`sequential` + +## Inputs + +- System design section: Dialect safety check. +- Existing SQL under `apps/backend/internal`. + +## Results + +Implemented the AST-based SQL guard, exact exemption registry, command, and +Make target. Both the guard tests and the complete `./internal` source scan +passed with no unused exemptions. diff --git a/docs/plans/required-store-drift-prevention/task-04-required-store-bootstrap.md b/docs/plans/required-store-drift-prevention/task-04-required-store-bootstrap.md new file mode 100644 index 0000000000..7091ce5722 --- /dev/null +++ b/docs/plans/required-store-drift-prevention/task-04-required-store-bootstrap.md @@ -0,0 +1,98 @@ +--- +id: "04-required-store-bootstrap" +title: "Enforce required-store bootstrap" +status: done +wave: 4 +depends_on: + - "01-required-store-catalog" + - "02-shared-sql-rendering" +plan: "plan.md" +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003 +acceptance_criteria: + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.3 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.4 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.3 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +--- + +# Task 04: Enforce required-store bootstrap + +## Summary + +Wire every catalog store through the required startup tracker. Separate local store creation from remote provider availability. + +## In scope + +- Pass one tracker through all bootstrap phases. +- Initialize feature-gated and provider-owned SQL schemas before service activation. +- Make schema metadata, telemetry, share, delivery, plugin, and provider store errors fatal. +- Pass initialized stores into provider and feature services. +- Keep credentials, provider probes, migrations from remote accounts, and poller starts degradable. +- Add error-injection and provider-isolation tests. + +## Out of scope + +- Add periodic runtime probes. +- Change provider authentication behavior or retry policy. +- Change feature enablement. + +## Acceptance + +- Each catalog ID has exactly one successful bootstrap visit before readiness. +- Any injected store constructor error returns a startup error with the store ID. +- Each injected external provider error leaves all stores and unrelated provider services available. + +## Verification + +```bash +go test -race ./internal/backendapp -run '^Test(RequiredStoreBootstrap|RequiredStoreFailure|ExternalProviderFailureIsolation|PostgresBootInitializesRepositories)$' -v +``` + +## Files likely touched + +- `apps/backend/internal/backendapp/storage.go` +- `apps/backend/internal/backendapp/services.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/backendapp/orchestrator.go` +- `apps/backend/internal/backendapp/worktree.go` +- `apps/backend/internal/backendapp/postgres_boot_test.go` +- `apps/backend/internal/backendapp/required_store_bootstrap_test.go` +- `apps/backend/internal/github/provider.go` +- `apps/backend/internal/gitlab/provider.go` +- `apps/backend/internal/jira/provider.go` +- `apps/backend/internal/linear/provider.go` +- `apps/backend/internal/sentry/provider.go` +- `apps/backend/internal/azuredevops/provider.go` +- `apps/backend/internal/workflowsync/provider.go` +- `apps/backend/internal/office/configsync/provider.go` + +## Dependencies + +- Task 01 defines the catalog and tracker. +- Task 02 provides the shared schema boundary for extracted stores. + +## Risks + +- Provider cleanup functions can be lost when constructors split into two phases. + +## Parallelism + +`sequential` + +## Inputs + +- System design sections: Bootstrap contract, External provider isolation. +- Existing provider pattern in `apps/backend/AGENTS.md`. + +## Results + +Implemented tracker wiring across repository, service, orchestrator, storage, +delivery, plugin, and provider phases. Required local schema failures are fatal +and remote credential or probe failures remain degradable. The focused +bootstrap, fatal-error, provider-isolation, and PostgreSQL boot tests passed. diff --git a/docs/plans/required-store-drift-prevention/task-05-conformance-framework.md b/docs/plans/required-store-drift-prevention/task-05-conformance-framework.md new file mode 100644 index 0000000000..91ee8c1980 --- /dev/null +++ b/docs/plans/required-store-drift-prevention/task-05-conformance-framework.md @@ -0,0 +1,87 @@ +--- +id: "05-conformance-framework" +title: "Build the conformance framework" +status: done +wave: 5 +depends_on: + - "01-required-store-catalog" + - "02-shared-sql-rendering" +plan: "plan.md" +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004 +acceptance_criteria: + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.3 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.6 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.7 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.3 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +--- + +# Task 05: Build the conformance framework + +## Summary + +Create the reusable dual-engine scenario runner and central adapter contract. Prove it with representative core stores. + +## In scope + +- Open isolated SQLite databases and PostgreSQL schemas through one engine interface. +- Define baseline and capability-specific adapter callbacks. +- Run identical assertion callbacks for both engines. +- Add task, workflow, system-settings, and one small support-store adapter. +- Prove fresh, replay, CRUD, boolean, timestamp, conflict, and transaction scenarios. + +## Out of scope + +- Add every catalog adapter. +- Enable the CI completeness gate. +- Add previous-stable fixtures. + +## Acceptance + +- The runner produces stable `engine/store/scenario` test names. +- Capability callbacks fail when omitted or duplicated. +- Core adapters demonstrate every scenario type on both engines. + +## Verification + +```bash +KANDEV_TEST_POSTGRES_DSN='' go test -race ./internal/testutil/storeconformance ./internal/persistence/storeconformance -run '^TestStoreConformance/(sqlite3|pgx)/(task|workflow|system-settings|user)/' -v +``` + +## Files likely touched + +- `apps/backend/internal/testutil/storeconformance/engine.go` +- `apps/backend/internal/testutil/storeconformance/suite.go` +- `apps/backend/internal/testutil/storeconformance/suite_test.go` +- `apps/backend/internal/persistence/storeconformance/suite_test.go` +- `apps/backend/internal/persistence/storeconformance/core_adapters_test.go` + +## Dependencies + +- Task 01 defines descriptors and capabilities. +- Task 02 provides common SQL behavior for adapters. + +## Risks + +- A generic callback can hide different behavior if assertions depend on engine names. + +## Parallelism + +`sequential` + +## Inputs + +- System design section: Conformance suite. +- Existing `testutil.OpenIsolatedPostgres` and store-specific PostgreSQL tests. + +## Results + +Implemented the reusable dual-engine runner with stable subtest names, +capability validation, isolated engines, and replay support. The runner tests +passed, followed by the complete catalog suite on SQLite and PostgreSQL 16. diff --git a/docs/plans/required-store-drift-prevention/task-06-catalog-conformance.md b/docs/plans/required-store-drift-prevention/task-06-catalog-conformance.md new file mode 100644 index 0000000000..f77a1612bb --- /dev/null +++ b/docs/plans/required-store-drift-prevention/task-06-catalog-conformance.md @@ -0,0 +1,92 @@ +--- +id: "06-catalog-conformance" +title: "Complete catalog conformance" +status: done +wave: 6 +depends_on: + - "04-required-store-bootstrap" + - "05-conformance-framework" +plan: "plan.md" +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004 +acceptance_criteria: + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.3 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.5 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.4 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.3 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +--- + +# Task 06: Complete catalog conformance + +## Summary + +Add conformance adapters for every remaining catalog store. Turn catalog-to-adapter and dual-engine parity into mandatory test assertions. + +## In scope + +- Add support, system, plugin, provider, sync, automation, and feature-store adapters. +- Declare and satisfy each catalog capability. +- Compare catalog, bootstrap visits, adapter IDs, and engine declarations. +- Reject unknown, duplicate, missing, and SQLite-only adapters. +- Retire redundant store-specific parity scenarios after equivalent central coverage exists. + +## Out of scope + +- Remove store-specific regression tests that cover behavior beyond the suite. +- Change domain semantics to fit the generic runner. +- Modify CI discovery. + +## Acceptance + +- The central suite has one adapter for every catalog entry and both engines. +- Completeness fails before PostgreSQL setup when an adapter or capability is absent. +- Every required store passes the catalog scenarios on SQLite and PostgreSQL. + +## Verification + +```bash +KANDEV_TEST_POSTGRES_DSN='' go test -race ./internal/persistence/storeconformance ./internal/backendapp -run '^(TestStoreCatalogCompleteness|TestStoreConformance|TestRequiredStoreBootstrapCompleteness)$' -v +``` + +## Files likely touched + +- `apps/backend/internal/persistence/storeconformance/core_adapters_test.go` +- `apps/backend/internal/persistence/storeconformance/support_adapters_test.go` +- `apps/backend/internal/persistence/storeconformance/provider_adapters_test.go` +- `apps/backend/internal/persistence/storeconformance/feature_adapters_test.go` +- `apps/backend/internal/persistence/storeconformance/suite_test.go` +- `apps/backend/internal/backendapp/required_store_bootstrap_test.go` +- Existing `store_postgres_test.go` files where scenarios become redundant. + +## Dependencies + +- Task 04 supplies final bootstrap constructors. +- Task 05 supplies the runner and adapter contract. + +## Risks + +- The central package imports many store packages and can expose import cycles. + +## Parallelism + +`sequential` + +## Inputs + +- System design sections: Conformance suite, Completeness enforcement. +- Task 01 final catalog. + +## Results + +Implemented fixed adapters for every catalog entry using the owning store +constructors, exact catalog/adapter/capability validation, and both-engine +coverage. SQLite passed 283 catalog tests; PostgreSQL 16 passed 565 conformance +and upgrade tests. diff --git a/docs/plans/required-store-drift-prevention/task-07-upgrade-fixtures.md b/docs/plans/required-store-drift-prevention/task-07-upgrade-fixtures.md new file mode 100644 index 0000000000..61c5ef9ebb --- /dev/null +++ b/docs/plans/required-store-drift-prevention/task-07-upgrade-fixtures.md @@ -0,0 +1,85 @@ +--- +id: "07-upgrade-fixtures" +title: "Add previous-stable upgrade fixtures" +status: done +wave: 7 +depends_on: + - "04-required-store-bootstrap" + - "06-catalog-conformance" +plan: "plan.md" +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006 +acceptance_criteria: + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.3 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.4 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +--- + +# Task 07: Add previous-stable upgrade fixtures + +## Summary + +Commit provenance-checked `v0.93.0` fixtures for SQLite and PostgreSQL. Run current bootstrap, replay, and sentinel checks against both. + +## In scope + +- Generate fixtures from tag `v0.93.0` and record its source commit. +- Preserve representative rows for every schema owner present in the stable release. +- Represent the PostgreSQL partial store state from issue #3352. +- Add a fixture manifest, checksums, and an explicit update script. +- Run current schema initialization twice and execute conformance reads. +- Add PostgreSQL 16 and 18 upgrade entry points. + +## Out of scope + +- Store customer data or credentials in fixtures. +- Test upgrades from every historic release. +- Add automatic fixture rotation during release. + +## Acceptance + +- Fixture provenance and checksums fail on an unreviewed change. +- SQLite and PostgreSQL upgrades preserve all sentinel values. +- A second current initialization changes no expected data and returns no error. + +## Verification + +```bash +KANDEV_TEST_POSTGRES_DSN='' go test -race ./internal/persistence/storeconformance -run '^TestPreviousStableUpgrade/(sqlite3|pgx)/v0.93.0$' -v +``` + +## Files likely touched + +- `apps/backend/internal/persistence/storeconformance/upgrade_test.go` +- `apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/manifest.json` +- `apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/sqlite.sql` +- `apps/backend/internal/persistence/storeconformance/testdata/upgrades/v0.93.0/postgres.sql` +- `apps/backend/scripts/update-store-schema-fixtures.sh` + +## Dependencies + +- Task 04 supplies current bootstrap order. +- Task 06 supplies all conformance reads. + +## Risks + +- A generated PostgreSQL dump can include unstable ownership or extension statements. + +## Parallelism + +`sequential` + +## Inputs + +- System design sections: Upgrade fixtures, PostgreSQL version coverage. +- Tag `v0.93.0` and issue #3352. + +## Results + +Committed schema-only SQLite and PostgreSQL fixtures generated from tag +`v0.93.0` at commit `866bd324855fda1f71e13b8863d2c71d4a11237e`, with checksums, +sentinels, and known missing provider stores. Current initialization, replay, +sentinel checks, and PostgreSQL 18 upgrade coverage passed. diff --git a/docs/plans/required-store-drift-prevention/task-08-required-store-health.md b/docs/plans/required-store-drift-prevention/task-08-required-store-health.md new file mode 100644 index 0000000000..dff34dac5b --- /dev/null +++ b/docs/plans/required-store-drift-prevention/task-08-required-store-health.md @@ -0,0 +1,95 @@ +--- +id: "08-required-store-health" +title: "Expose required-store health" +status: done +wave: 8 +depends_on: + - "04-required-store-bootstrap" + - "06-catalog-conformance" +plan: "plan.md" +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007 +acceptance_criteria: + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.4 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.3 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.4 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.5 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +--- + +# Task 08: Expose required-store health + +## Summary + +Add runtime probes, readiness projection, authenticated diagnostics, and stateful request gating. Preserve the liveness endpoint. + +## In scope + +- Run immediate and periodic pool and table probes. +- Add aggregate runtime health to the tracker. +- Extend `/ready` with a safe persistence failure shape. +- Add authenticated persistence diagnostics under the system API. +- Return stable `503` errors for stateful HTTP and WebSocket callers. +- Recover readiness and request service after a successful probe. +- Add structured transition logs and cleanup for the probe loop. + +## Out of scope + +- Return raw SQL or driver errors from public endpoints. +- Change `/health` status, body, or desktop token behavior. +- Repair missing tables at runtime. + +## Acceptance + +- One unhealthy catalog store makes readiness and stateful traffic unavailable. +- Diagnostics list every catalog entry in stable order with sanitized errors. +- A later successful probe restores readiness while `/health` remains unchanged. + +## Verification + +```bash +go test -race ./internal/persistence/requiredstores ./internal/backendapp ./internal/system -run '^Test(PersistenceHealth|ReadyHandlerPersistence|PersistenceDiagnostics|PersistenceUnavailableMiddleware|HealthHandler)' -v +``` + +## Files likely touched + +- `apps/backend/internal/persistence/requiredstores/health.go` +- `apps/backend/internal/persistence/requiredstores/health_test.go` +- `apps/backend/internal/backendapp/helpers.go` +- `apps/backend/internal/backendapp/health_test.go` +- `apps/backend/internal/backendapp/middleware.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/system/persistence/handler.go` +- `apps/backend/internal/system/persistence/handler_test.go` +- `apps/backend/internal/system/system.go` + +## Dependencies + +- Task 04 supplies tracker results and initialized stores. +- Task 06 proves catalog table ownership. + +## Risks + +- The middleware can block its own diagnostic route if exclusions run after the gate. + +## Parallelism + +`sequential` + +## Inputs + +- System design sections: Runtime health, Caller errors, Diagnostics. +- Existing `/health` and `/ready` tests. + +## Results + +Implemented immediate and periodic probes, readiness projection, authenticated +diagnostics, stable stateful-request `503` responses, recovery, and liveness +preservation. The required-store, backendapp, system, and middleware tests +passed, including the full backendapp package suite. diff --git a/docs/plans/required-store-drift-prevention/task-09-ci-contributor-gates.md b/docs/plans/required-store-drift-prevention/task-09-ci-contributor-gates.md new file mode 100644 index 0000000000..1b8c950248 --- /dev/null +++ b/docs/plans/required-store-drift-prevention/task-09-ci-contributor-gates.md @@ -0,0 +1,102 @@ +--- +id: "09-ci-contributor-gates" +title: "Install CI and contributor gates" +status: done +wave: 9 +depends_on: + - "03-sql-dialect-safety" + - "07-upgrade-fixtures" + - "08-required-store-health" +plan: "plan.md" +requirements: + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008 +acceptance_criteria: + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.4 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.3 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.4 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008.1 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008.2 + - AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008.3 +system_design: + - ../../specs/platform/system-design/postgres-domain-store-parity.md +--- + +# Task 09: Install CI and contributor gates + +## Summary + +Replace marker-based PostgreSQL discovery with fixed gates. Add the PostgreSQL 18 smoke matrix and document the required-store workflow. + +## In scope + +- Invoke catalog completeness, conformance, upgrade, boot, and SQL guard commands explicitly. +- Remove the `PostgresDSNFromEnv` package discovery shell. +- Mirror and pin PostgreSQL 18 for clean-boot and upgrade tests. +- Add workflow contract tests that reject grep-based discovery and missing fixed commands. +- Update public backend-development and operations documentation. +- Update root and backend agent guidance. + +## Out of scope + +- Add more PostgreSQL majors than 16 and 18. +- Change release support outside the documented database matrix. +- Add browser E2E coverage. + +## Acceptance + +- A catalog entry without both engine adapters fails a fixed CI command. +- CI runs full PostgreSQL 16 conformance and PostgreSQL 18 boot plus upgrade coverage. +- Contributor docs state the catalog, test, fixture, SQL safety, and provider-isolation rules. + +## Verification + +```bash +python3 .github/scripts/backend-tests-workflow-contract_test.py +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +- `.github/workflows/backend-tests.yml` +- `.github/workflows/ci-base-image.yml` +- `.github/scripts/backend-tests-workflow-contract_test.py` +- `docs/public/backend-development.md` +- `docs/public/operations.md` +- `AGENTS.md` +- `apps/backend/AGENTS.md` + +## Dependencies + +- Task 03 supplies the SQL guard command. +- Task 07 supplies upgrade tests and PostgreSQL 18 entry points. +- Task 08 supplies final readiness behavior for documentation. + +## Risks + +- A second service image can increase CI startup time and registry load. + +## Parallelism + +`sequential` + +## Inputs + +- System design sections: CI execution, PostgreSQL version coverage, Contributor workflow. +- Existing backend workflow and CI image mirror process. + +## Results + +Implemented fixed PostgreSQL test commands, SQL guard and conformance CI gates, +the pinned PostgreSQL 18 image mirror, workflow contract tests, and contributor +and operator documentation. Workflow, public-doc, specification, and diff +validation passed. diff --git a/docs/public/backend-development.md b/docs/public/backend-development.md index 60157cef53..ba864e4633 100644 --- a/docs/public/backend-development.md +++ b/docs/public/backend-development.md @@ -84,6 +84,30 @@ SQLite table rebuilds need explicit copy order, index/trigger recreation, and in Test fresh initialization, replay, and upgrade from representative old rows. Useful patterns include `internal/task/repository/sqlite/schema_replay_test.go` and PostgreSQL tests gated by `KANDEV_TEST_POSTGRES_DSN`. Add PostgreSQL coverage when shared SQL or startup ordering changes. +Every built-in SQL schema owner is listed in the +`internal/persistence/requiredstores` catalog. A new persisted store must add +one catalog descriptor, wire its constructor through the required-store +tracker, and add a fixed adapter in +`internal/persistence/storeconformance`. Keep the store in the catalog even +when its feature is disabled: the schema is part of the compatibility +contract. External credentials, remote authentication, and provider probes +remain degradable after local schema initialization. + +Run the local persistence gates from `apps/backend/` when changing a store: + +```bash +go run ./cmd/sqlguard ./internal +go test -race ./internal/persistence/storeconformance -count=1 +go test -race ./internal/backendapp -run 'TestPostgresBootInitializesRepositories|TestPreviousStableUpgrade' -count=1 +``` + +The conformance package runs SQLite by default and runs PostgreSQL when +`KANDEV_TEST_POSTGRES_DSN` is set. Package names and test markers do not count +as PostgreSQL coverage. For a schema-history change, update the committed +fixture under `internal/persistence/storeconformance/testdata/upgrades/` with +the explicit stable tag, source commit, checksums, and sentinel rows; do not +derive a baseline from the working tree. + ## Agent runtime and agentctl `internal/agent/runtime/` defines launch, resume, stop, and observation seams. `internal/agent/runtime/lifecycle/` supplies executor backends and environment preparers. Product executor names in `internal/task/models/` map through `internal/agent/executor/`; startup registration is in `internal/backendapp/agents.go`. diff --git a/docs/public/operations.md b/docs/public/operations.md index b8dff49481..21f3743575 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -88,6 +88,25 @@ curl -fsS http://127.0.0.1:38429/api/v1/system/health This diagnostic checks the Git executable, GitHub authentication/rate limits, agent discovery, and Linux inotify pressure. It returns a JSON `healthy` field and issue list, but normally uses HTTP 200 even when `healthy` is false; do not substitute it for `/health` in a status-only probe. +Required local persistence has a separate authenticated diagnostic endpoint: + +```bash +curl -fsS http://127.0.0.1:38429/api/v1/system/diagnostics/persistence +``` + +It reports the database driver, aggregate state, and one sanitized row for +each required store. The rows include stable store IDs, state, last check +time, and a safe error description. They never include credentials, DSNs, +SQL, database paths, or row data. + +After startup, a failed required-store probe makes `/ready` return HTTP 503 +with `reason: "persistence"` and the affected `store_ids`. Stateful API and +WebSocket requests return HTTP 503 with the stable code +`persistence_unavailable` and an action to check the database and persistence +diagnostics. A later successful probe restores readiness and stateful traffic +without a process restart. `/health` remains a pure liveness check and stays +HTTP 200 while the process is alive. + ![Settings > System > Status showing health checks, the running version, and disk usage.](../screenshots/system-status.png) For a managed service, also check its process manager: diff --git a/docs/specs/platform/README.md b/docs/specs/platform/README.md index 61331aa90f..0374a8bb22 100644 --- a/docs/specs/platform/README.md +++ b/docs/specs/platform/README.md @@ -60,7 +60,7 @@ localization, feature toggles, health, and shared session recovery services. - [Session MCP Attachment Observability](requirements/mcp-session-observability.md) - [Mid-Turn Steering](requirements/mid-turn-steering.md) - [Semantic Notifications](requirements/notifications.md) -- [PostgreSQL Domain Store Parity](requirements/postgres-domain-store-parity.md) +- [Required Persisted Store Parity](requirements/postgres-domain-store-parity.md) - [Provider Error Recovery](requirements/provider-error-recovery.md) - [Session Config Reconciliation Across Agent Types](requirements/session-config-cross-agent-reconcile.md) - [Session subscription recovery](requirements/session-subscription-recovery.md) @@ -93,7 +93,7 @@ localization, feature toggles, health, and shared session recovery services. - [LSP File Intelligence System Design Part 1](system-design/lsp-file-intelligence-01.md) - [LSP File Intelligence System Design Part 2](system-design/lsp-file-intelligence-02.md) - [Session MCP Attachment Observability](system-design/mcp-session-observability.md) -- [PostgreSQL Domain Store Parity](system-design/postgres-domain-store-parity.md) +- [Required Persisted Store Parity](system-design/postgres-domain-store-parity.md) - [Provider Error Recovery](system-design/provider-error-recovery.md) - [Workspace Git Status](system-design/workspace-git-status.md) - [Go dev launcher and startup version](system-design/go-dev-launcher.md) diff --git a/docs/specs/platform/requirements/postgres-domain-store-parity.md b/docs/specs/platform/requirements/postgres-domain-store-parity.md index b5d6c197c1..aed5f41b49 100644 --- a/docs/specs/platform/requirements/postgres-domain-store-parity.md +++ b/docs/specs/platform/requirements/postgres-domain-store-parity.md @@ -1,29 +1,32 @@ --- -status: active +status: draft system: platform created: 2026-09-03 owners: - kandev --- -# PostgreSQL Domain Store Parity Requirements +# Required Persisted Store Parity Requirements ## Overview -Kandev supports SQLite and PostgreSQL as database backends. The Platform system owns this shared operational guarantee. +Kandev supports SQLite and PostgreSQL for its built-in SQL stores. The Platform system owns this shared startup and compatibility guarantee. -Built-in domain services must remain available with either supported backend. A database dialect must not silently disable a configured service during startup. +Each required store must initialize before Kandev becomes ready. External providers can remain unavailable without changing the health of their local stores. ## Terminology -- **Domain store:** A store that persists state for one built-in service in the shared Kandev database. +- **Required store:** A built-in SQL store that persists Kandev state in the selected database. +- **External provider:** A remote service, credential, or authentication flow that uses a required store. - **Replay:** A second schema initialization against the same database without a version change. +- **Previous stable schema:** The committed schema fixture from the stable release that immediately precedes the current development version. +- **Store catalog:** The authoritative inventory of required stores and their conformance obligations. ## Requirements ### REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001: Portable domain stores -**Intent:** Operators can use all built-in persisted services when Kandev uses PostgreSQL. +**Intent:** Operators can use all built-in persisted services with SQLite or PostgreSQL. #### Acceptance criteria @@ -32,10 +35,92 @@ Built-in domain services must remain available with either supported backend. A - **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.3:** When a service reads or changes PostgreSQL data, its result shall match the equivalent SQLite operation. - **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.4:** When a domain store cannot initialize, startup diagnostics shall identify that store and the database error. - **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.5:** When PostgreSQL is active, integration and automation endpoints shall not fail because their stores are unavailable. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.6:** When Kandev starts with SQLite, every required store shall initialize through the same store catalog used for PostgreSQL. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001.7:** When a store uses booleans, timestamps, conflicts, or transactions, both database engines shall produce equivalent results. + +### REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002: Required persistence startup contract + +**Intent:** Kandev never reports readiness when required internal persistence is incomplete. + +#### Acceptance criteria + +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.1:** When a required store or schema cannot initialize, Kandev shall not become ready and startup shall return an error. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.2:** When an external provider or its authentication is unavailable, Kandev shall keep unrelated providers and internal services available. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.3:** When one provider is unavailable, its local required store shall remain initialized and diagnosable. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002.4:** Kandev shall not classify a required store initialization error as a degradable provider error. + +### REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003: Authoritative store catalog + +**Intent:** A new required store cannot bypass startup, health, or dual-engine coverage. + +#### Acceptance criteria + +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.1:** Bootstrap, readiness diagnostics, and coverage tests shall consume one authoritative store catalog. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.2:** When a catalog entry lacks bootstrap wiring, SQLite coverage, or PostgreSQL coverage, CI shall fail. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.3:** A disabled feature shall not remove its required SQL schema from initialization or compatibility coverage. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003.4:** When a catalog entry or coverage adapter becomes stale, CI shall identify the missing or unknown store ID. + +### REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004: Store conformance suite + +**Intent:** Equivalent store behavior is executable evidence, not an inference from package names or test markers. + +#### Acceptance criteria + +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.1:** Each required store shall pass fresh initialization, schema replay, and representative create, read, update, and delete behavior on both engines. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.2:** The suite shall cover boolean values, timestamps, conflict handling, and transaction commit or rollback where the store uses those behaviors. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.3:** SQLite and PostgreSQL scenarios shall use the same behavioral assertions for each store. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004.4:** PostgreSQL CI shall invoke a fixed conformance package instead of discovering packages from test source markers. + +### REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005: Dialect-safe shared SQL + +**Intent:** Unsafe shared SQL fails before merge. + +#### Acceptance criteria + +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.1:** CI shall reject unapproved shared-store uses of `PRAGMA`, `sqlite_master`, `INSERT OR IGNORE`, SQLite date functions, and direct `DATETIME` types. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.2:** CI shall reject raw `?` placeholders sent to an executor without rebinding. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.3:** CI shall reject integer defaults or comparisons for SQL boolean columns in shared-store code. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.4:** A SQLite-only exemption shall name an exact scope, rule, and reason. CI shall reject stale or unused exemptions. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005.5:** Shared schema and query code shall use the central dialect helpers instead of local text replacement. + +### REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006: Supported upgrade paths + +**Intent:** A clean install and the supported upgrade path remain valid on each database engine. + +#### Acceptance criteria + +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.1:** Current code shall upgrade the previous stable SQLite and PostgreSQL schema fixtures without data loss. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.2:** After an upgrade, a schema replay shall succeed and the conformance scenarios shall pass. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.3:** Each fixture shall record its stable tag, source commit, and expected sentinel data. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006.4:** CI shall cover the oldest and newest PostgreSQL majors in the documented support matrix where their behavior can differ. + +### REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007: Required-store health + +**Intent:** Operators and callers receive actionable persistence status. + +#### Acceptance criteria + +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.1:** When required persistence is unhealthy, `GET /ready` shall return `503` and identify the affected store IDs. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.2:** Authenticated diagnostics shall show each required store state, last check time, and a sanitized error. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.3:** When a stateful caller reaches an unhealthy backend, it shall receive `503` with the stable code `persistence_unavailable` and a recovery action. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.4:** When runtime database health recovers, readiness and stateful requests shall recover without a process restart. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007.5:** Required-store health shall not change the liveness contract of `GET /health`. + +### REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008: Contributor workflow + +**Intent:** Contributors can add or change a store without guessing the portability rules. + +#### Acceptance criteria + +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008.1:** Contributor documentation shall define the catalog, conformance, upgrade-fixture, dialect-check, and local PostgreSQL workflow. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008.2:** The documented workflow shall state that package names and local test markers do not provide PostgreSQL coverage. +- **AC-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008.3:** The documented workflow shall preserve independent degradation for external providers and authentication. ## Out of scope - Active-active backend replicas that share one PostgreSQL database. - Automatic PostgreSQL backups or restores. -- A central migration framework. -- Changes to provider authentication or external provider APIs. +- Migration of data from one database engine to another. +- Automatic schema repair after startup. +- Changes to external provider APIs or authentication policy. +- A change to the `GET /health` liveness contract. diff --git a/docs/specs/platform/system-design/postgres-domain-store-parity.md b/docs/specs/platform/system-design/postgres-domain-store-parity.md index 01b04d8459..d5da8d5ce5 100644 --- a/docs/specs/platform/system-design/postgres-domain-store-parity.md +++ b/docs/specs/platform/system-design/postgres-domain-store-parity.md @@ -1,76 +1,256 @@ --- -status: current +status: draft system: platform requirements: - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007 + - REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008 --- -# PostgreSQL Domain Store Parity System Design +# Required Persisted Store Parity System Design ## Purpose and boundaries -The Platform system owns database portability because it is a shared startup guarantee. Integration and automation services consume this guarantee. +The Platform system owns database portability, startup, readiness, and shared diagnostics. Domain systems own their data models and store operations. -This design covers GitHub, GitLab, Jira, Linear, Sentry, Azure DevOps, workflow sync, and automation stores. It does not change provider behavior. +This design covers built-in SQL stores that use the selected Kandev database. Filesystem stores and remote provider APIs are outside the catalog. + +Local provider data is required persistence. Provider credentials, API reachability, and remote authentication remain degradable dependencies. ## Requirement mapping | Requirement | Design sections | | --- | --- | -| `REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001` | [Schema contract](#schema-contract), [Query contract](#query-contract), [Startup contract](#startup-contract), [Verification strategy](#verification-strategy) | +| `REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-001` | [Schema and query contracts](#schema-and-query-contracts), [Conformance suite](#conformance-suite) | +| `REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-002` | [Bootstrap contract](#bootstrap-contract), [External provider isolation](#external-provider-isolation) | +| `REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-003` | [Required store catalog](#required-store-catalog), [Completeness enforcement](#completeness-enforcement) | +| `REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-004` | [Conformance suite](#conformance-suite), [CI execution](#ci-execution) | +| `REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-005` | [Dialect safety check](#dialect-safety-check), [Schema and query contracts](#schema-and-query-contracts) | +| `REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-006` | [Upgrade fixtures](#upgrade-fixtures), [PostgreSQL version coverage](#postgresql-version-coverage) | +| `REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-007` | [Runtime health](#runtime-health), [Caller errors](#caller-errors), [Diagnostics](#diagnostics) | +| `REQ-PLATFORM-POSTGRES-DOMAIN-STORE-PARITY-008` | [Contributor workflow](#contributor-workflow) | -## Components and responsibilities +## Required store catalog -- `internal/db/dialect` supplies SQL types and fragments for each supported database. -- `internal/db` supplies shared schema-introspection helpers. -- Each domain store owns its tables, ordered upgrade steps, and store methods. -- `internal/backendapp` creates the stores and keeps provider errors isolated from unrelated services. +`internal/persistence/requiredstores` owns the authoritative catalog. The package does not import domain stores or `backendapp`. -The shared helpers replace local copies of `PRAGMA` and `sqlite_master` probes. This boundary keeps database detection out of provider packages. +Each `Descriptor` contains these stable fields: -## Schema contract +| Field | Purpose | +| --- | --- | +| `ID` | Stable kebab-case identity for startup, tests, logs, and diagnostics. | +| `OwnerPackage` | Go package that owns the schema and behavior. | +| `RequiredTables` | Tables used by the runtime health probe. | +| `DependsOn` | Store IDs that must initialize first. | +| `Capabilities` | Required conformance scenarios for booleans, timestamps, conflicts, and transactions. | -Each schema owner builds data-definition statements for the active driver. SQLite uses `DATETIME`. PostgreSQL uses `TIMESTAMPTZ`. +The catalog contains SQL schema owners, not each service facade. One schema owner can supply several interfaces. -Boolean defaults and predicates use SQL boolean literals. Integer status fields remain integers when their model contract is numeric. +The first implementation audits and registers these families: -Schema introspection uses the current PostgreSQL schema. SQLite introspection uses its native catalog and table-info interface. +- Core task, workflow, analytics, agent settings, user, notification, editor, prompt, utility, Office, terminal, quick-terminal, runtime-flag, authentication, and secret stores. +- Shared system settings, organizations, organization units, session hostnames, worktrees, message queue, telemetry contracts, schema metadata, storage jobs, share links, and delivery ledger. +- Plugin state, user state, web-app instances, and instance state. +- GitHub, GitLab, Jira, Linear, Sentry, Azure DevOps, workflow sync, Office configuration sync, and automation stores. +- Canvas SQL state and any other feature-gated SQL schema owner found by the bootstrap audit. -Each store applies the same ordered initialization on a fresh database and during replay. SQLite-only table rebuilds run only when SQLite needs them. +All SQL schemas initialize regardless of their feature flag. A feature flag controls service activation after the required persistence phase. -## Query contract +## Bootstrap contract -Store queries use `?` as the source placeholder syntax. The final database or transaction executor calls `Rebind` before execution. +`backendapp` creates a `requiredstores.Tracker` from the catalog before it initializes repositories. Bootstrap reports each store through its catalog ID. -Queries built with `sqlx.In` call `Rebind` after expansion. Store code does not send unresolved `?` placeholders to PostgreSQL. +The tracker accepts only known IDs. It rejects duplicate completion, dependency-order errors, and missing results. -Portable conflict handling uses `ON CONFLICT`. Store code does not use `INSERT OR IGNORE` in shared database paths. +Bootstrap uses this sequence: -SQL predicates compare boolean columns with `TRUE` or `FALSE`. Bound values use Go booleans for boolean columns. +1. Open the configured database pool. +2. Initialize each catalog store in dependency order. +3. Record a sanitized success or error for each store. +4. Stop startup on the first required-store error. +5. Reject a catalog entry that bootstrap did not visit. +6. Run an immediate health probe for all initialized stores. +7. Build feature services and external provider clients. +8. Set readiness only after the aggregate required-store state is healthy. -## Startup contract +The bootstrap listener keeps its existing behavior during this sequence. `GET /health` remains a liveness response, and `GET /ready` remains unavailable. -Provider initialization errors remain isolated. One unavailable provider does not stop unrelated Kandev services. +`provideRepositories`, `provideServices`, `provideOrchestrator`, `provideWorktreeManager`, and later store constructors use the same tracker. A store cannot hide in a secondary service phase. -The PostgreSQL boot test must also assert service availability. A successful outer startup call is not sufficient because provider errors are nonfatal. +## External provider isolation -GitLab has a separate host store and task store. PostgreSQL evidence must cover the task store even when the outer service exists. +Provider construction separates local store initialization from remote availability. Local stores initialize in the required persistence phase. -## Failure and recovery +Services then receive initialized stores. Credential discovery, token validation, remote probes, and poller startup occur in the degradable provider phase. -A schema or query error returns through the owning store. Existing startup logs keep the store name and database error. +A provider error updates only that provider status. It does not mark the local store or aggregate persistence state unhealthy. -Schema replay does not remove data. PostgreSQL operators keep responsibility for database backups before an upgrade. +This split applies to GitHub, GitLab, Jira, Linear, Sentry, Azure DevOps, and future external providers. Workflow sync and Office configuration sync accept a nil remote client. -## Verification strategy +Tests inject one unavailable provider at a time. They assert that all catalog stores remain healthy and unrelated providers remain usable. -Each affected package uses `testutil.OpenIsolatedPostgres` for fresh-schema, replay, and representative store operations. SQLite package tests keep their current coverage. +## Runtime health -The backend boot test creates the full service graph on PostgreSQL. It asserts availability for every affected service and store boundary. +The tracker owns immutable initialization results and mutable runtime probe results. Each store state is `initializing`, `healthy`, or `unhealthy`. -Provider tests cover settings, watches, task links, and automation state where those concepts exist. These tests expose placeholder and boolean-type errors after startup. +An immediate probe runs before readiness. A background probe then runs every 15 seconds with a two-second timeout. + +Each probe pings the shared writer and reader. It also checks each descriptor's required tables through `internal/db` schema helpers. + +One probe error makes aggregate persistence unhealthy. A later successful probe restores runtime health without a restart. + +Initialization errors remain fatal and cannot recover inside the same process. The process exits through the existing startup error path. + +## Caller errors + +`GET /health` remains a pure liveness endpoint. Persistence state does not change its status or body. + +`GET /ready` returns `503` while startup is incomplete or aggregate persistence is unhealthy. A persistence response adds `reason: "persistence"` and sorted `store_ids`. + +A middleware protects stateful HTTP routes and the WebSocket upgrade. If persistence is unhealthy, it returns this authenticated response: + +```json +{ + "error": "required persistence is unavailable", + "code": "persistence_unavailable", + "store_ids": ["task"], + "action": "Check the database connection and the persistence diagnostics, then retry." +} +``` + +The middleware excludes liveness, readiness, static assets, and authenticated persistence diagnostics. It does not expose raw database text. + +## Diagnostics + +`GET /api/v1/system/diagnostics/persistence` returns one row per catalog entry. Each row includes its ID, owner package, state, last check time, and sanitized error. + +The endpoint also returns the database driver and aggregate state. It does not return credentials, DSNs, SQL text, row data, or database paths. + +Structured logs use `store_id`, `driver`, `phase`, `state`, and `error_class`. Diagnostic bundles already include these backend logs. + +## Schema and query contracts + +`internal/db/dialect` owns shared schema rendering. A store supplies a template with explicit tokens for timestamp, boolean, identity, and current-time fragments. + +The renderer fails on an unknown or unexpanded token. Store packages do not transform DDL through local `strings.Replace` calls. + +SQLite timestamps use `DATETIME`. PostgreSQL timestamps use `TIMESTAMPTZ` unless a documented model requires a naive timestamp. + +SQL boolean columns use Go booleans, SQL boolean literals, and dialect-safe defaults. Numeric status fields remain integers. + +Portable conflict handling uses `ON CONFLICT`. Shared SQL does not use `INSERT OR IGNORE`. + +Shared execution helpers rebind source `?` placeholders at the final database or transaction boundary. `sqlx.In` expansion occurs before rebinding. + +Schema inspection uses `internal/db.TableExists`, `TableColumns`, and related helpers. Store packages do not query SQLite catalogs directly. + +## Dialect safety check + +`internal/db/sqlguard` parses Go source with the Go syntax tree. `cmd/sqlguard` supplies a stable local and CI command. + +The check scans non-test Go source under `apps/backend/internal`. It reports the file, symbol, rule, and unsafe SQL fragment. + +The first rule set rejects these patterns outside central dialect helpers: + +- `PRAGMA` and `sqlite_master`. +- `INSERT OR IGNORE`. +- `julianday`, `datetime`, `strftime`, and SQLite current-time expressions. +- Direct `DATETIME` schema types. +- Integer defaults or comparisons on SQL boolean columns. +- SQL with raw `?` placeholders passed to direct execution without `Rebind` or a shared rebinding helper. + +`internal/db/sqlguard/exemptions.json` stores exact file, symbol, rule, and reason entries for genuine SQLite-only code. Broad path globs are invalid. + +The check fails on an unused exemption. It also fails when an exemption names a missing file or symbol. + +Unit fixtures contain one positive and one negative case for every rule. They include transaction and `sqlx.In` rebinding cases. + +## Conformance suite + +`internal/testutil/storeconformance` supplies a reusable scenario runner. It opens an isolated SQLite database or PostgreSQL schema and applies the same assertion callbacks. + +`internal/persistence/storeconformance` is the fixed aggregation package. Its test adapters import each schema owner and bind domain operations to the runner. + +Each adapter provides these baseline scenarios: + +- Fresh schema initialization. +- Schema replay with sentinel data preservation. +- Representative create, read, update, and delete behavior. + +The catalog capabilities require more scenarios when a store uses them: + +- Boolean false and true round trips. +- Timestamp insertion, ordering, null handling, and UTC normalization. +- Duplicate insertion and conflict-update behavior. +- Transaction commit and rollback behavior. + +The same adapter assertions run against both engines. Engine-specific assertions can inspect schema types, but they cannot replace behavior assertions. + +## Completeness enforcement + +The aggregation package compares its adapter IDs with the catalog. Unknown, duplicate, and missing IDs fail before engine setup. + +The comparison runs even when `KANDEV_TEST_POSTGRES_DSN` is absent. A developer cannot hide a missing adapter through a PostgreSQL skip. + +Each adapter declares both `sqlite3` and `pgx`. The suite rejects a missing engine or a missing callback for a catalog capability. + +Bootstrap tests compare tracker visits with the same catalog. Readiness tests compare diagnostic rows with the same catalog. + +## Upgrade fixtures + +Committed fixtures live under `internal/persistence/storeconformance/testdata/upgrades//`. The directory contains SQLite and PostgreSQL schema files plus one manifest. + +The manifest records the stable tag, source commit, engine, fixture checksum, and sentinel rows. The initial baseline is `v0.93.0`. + +The PostgreSQL fixture represents a successful `v0.93.0` core boot and its known missing provider tables. This state reproduces the incident without pretending that failed tables existed. + +Upgrade tests load a fixture, run current bootstrap order, run schema replay, and execute all conformance reads. Sentinel rows must remain byte-equivalent where the model contract preserves bytes. + +A fixture update script accepts an explicit stable tag. It never derives the baseline from the current working tree. + +## PostgreSQL version coverage + +PostgreSQL 16 remains the full conformance target. PostgreSQL 18 runs the clean-boot and previous-stable upgrade tests that cover catalog and type behavior. + +Both service images use immutable digests and the existing GHCR mirror process. The matrix documents its supported major versions beside the workflow. + +The smaller PostgreSQL 18 job protects the incident environment without doubling all race tests. A support-matrix change must update this design and CI together. + +## CI execution + +The backend workflow invokes fixed commands. It does not search source text for `PostgresDSNFromEnv` or another marker. + +The required gates are: + +1. Catalog completeness and SQLite conformance without external services. +2. SQL dialect safety. +3. Full PostgreSQL 16 conformance and backend boot. +4. PostgreSQL 18 clean-boot and upgrade coverage. +5. Previous-stable upgrade coverage on SQLite. + +The workflow asserts named top-level tests in each fixed package. A zero-test result fails the job. + +## Contributor workflow + +`docs/public/backend-development.md` documents the catalog and commands. It also explains the required and degradable startup phases. + +The root and backend `AGENTS.md` files state that a new SQL store must add a catalog entry and one central conformance adapter. + +The guide requires a fresh schema change, a replay change, a previous-stable upgrade path, and central dialect helpers in the same pull request. + +## Security and privacy + +Public readiness output contains stable store IDs only. It does not contain SQL, credentials, hostnames, database names, or raw errors. + +Authenticated diagnostics sanitize driver errors before they cross the API boundary. Full errors remain in local structured logs. ## Related decisions +- [Required internal persistence fails startup](../../../decisions/2026-09-05-required-internal-persistence.md) - [Replayable schema migrations across SQLite and Postgres](../../../decisions/0027-replayable-schema-migrations.md) -- [Workspace-scoped integration settings](../../../decisions/0030-workspace-scoped-integration-settings.md) +- [Startup Configuration Uses One Typed Source Model](../../../decisions/2026-08-20-startup-configuration-source-parity.md) diff --git a/docs/specs/tasks/system-design/dirty-worktree-deletion.md b/docs/specs/tasks/system-design/dirty-worktree-deletion.md index 96542a80a2..c06eb60803 100644 --- a/docs/specs/tasks/system-design/dirty-worktree-deletion.md +++ b/docs/specs/tasks/system-design/dirty-worktree-deletion.md @@ -166,4 +166,3 @@ error. It does not emit repeated retry attempts. - [Fail-closed GC semantics](../../../decisions/0009-fail-closed-gc-semantics.md) - [Task-owned worktree lifetime](../../../decisions/2026-08-08-task-owned-worktree-lifetime.md) - [Confirmation Warning Hierarchy](../../ui/system-design/confirmation-warning-hierarchy.md) -