diff --git a/backend/test/workspace-registry-deployment.test.ts b/backend/test/workspace-registry-deployment.test.ts index e1d7f5a0..48efbb95 100644 --- a/backend/test/workspace-registry-deployment.test.ts +++ b/backend/test/workspace-registry-deployment.test.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { expect, test } from "vitest"; +import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; test("declares a durable isolated registry volume and only read-only Git credential mounts", () => { const compose = readFileSync(new URL("../../compose.yaml", import.meta.url), "utf8"); @@ -25,6 +26,120 @@ test("declares a durable isolated registry volume and only read-only Git credent expect(smoke).toContain('core_remote="/fixtures/offline.git"'); expect(smoke).toContain('"degraded":true'); expect(smoke).toContain('core_remote="/fixtures/remote.git"'); + expect(smoke).toContain( + 'cp "$root/scripts/fixtures/workspace-registry-smoke.yaml" "$seed/workspaces/local.yaml"', + ); + expect(smoke).not.toMatch(/npm\s+--prefix\s+[^\n]*backend[^\n]*\srun\s+build/); + expect(smoke).not.toMatch(/migrate-(?:legacy|v2-qdrant)/); + expect(smoke).toContain("<<'COMPOSE_YAML'"); + expect(smoke).toContain('context: "${SMOKE_ROOT:?}"'); + expect(smoke).toContain('image: "${SMOKE_IMAGE:?}"'); + expect(smoke).toContain('THT_WORKSPACE_GIT_REMOTE: "${SMOKE_CORE_REMOTE:?}"'); + expect(smoke).toContain('THT_WORKSPACE_GIT_BRANCH: "${SMOKE_BRANCH:?}"'); + expect(smoke).toContain('source: "${SMOKE_REMOTE:?}"'); + expect(smoke).toContain("type: bind"); + expect(smoke).toContain("read_only: true"); + expect(smoke).not.toContain("context: $root"); + expect(smoke).not.toContain("image: $image"); + expect(smoke).not.toContain("- $remote:/fixtures/remote.git:ro"); + expect(smoke).toContain("compose-config-contract)"); + expect(smoke).toContain("cleanup-failure-path)"); +}); + +test("shared workspace registry smoke fixture parses as schema v3 internal semantic identity", () => { + const source = readFileSync( + new URL("../../scripts/fixtures/workspace-registry-smoke.yaml", import.meta.url), + "utf8", + ); + const descriptor = parseWorkspaceYaml(source); + + expect(descriptor).toMatchObject({ + workspace: { schema_version: 3, id: "local", name: "Local" }, + dwh: { + engine: "postgres", + database: "postgres", + schema: "public", + supported_transports: ["postgres_direct", "rest_api"], + }, + semantic_index: { + vector_store: { + engine: "qdrant", + collection: "local", + dimensions: 1024, + distance: "cosine", + }, + embedding: { + provider: "ollama_internal", + model: "qwen3-embedding:0.6b", + dimensions: 1024, + }, + }, + llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] }, + diagnostics: { + dwh_rest: { + method: "GET", + path: "/health", + auth: "none", + response: { database: "database", schema: "schema" }, + }, + }, + }); + expect(descriptor).not.toHaveProperty("evidence"); +}); + +test("Windows clone contract copies the shared complete schema v3 descriptor", () => { + const fixture = readFileSync( + new URL("../../scripts/fixtures/workspace-registry-windows.yaml", import.meta.url), + "utf8", + ); + const descriptor = parseWorkspaceYaml(fixture); + const windows = readFileSync( + new URL("../../scripts/test-windows-clone-contract.ps1", import.meta.url), + "utf8", + ); + + expect(windows).toContain('"scripts/fixtures/workspace-registry-windows.yaml"'); + expect(windows).toContain("Copy-Item -LiteralPath $workspaceFixture -Destination $workspaceDestination"); + expect(windows).not.toContain("schema_version:"); + expect(descriptor).toEqual({ + workspace: { + schema_version: 3, + id: "task13-windows", + name: "Task 13 Windows", + language: "en", + }, + dwh: { + engine: "postgres", + database: "warehouse", + schema: "public", + port: 5432, + timeout_ms: 5000, + supported_transports: ["postgres_direct", "rest_api"], + }, + semantic_index: { + vector_store: { + engine: "qdrant", + collection: "task13-windows", + dimensions: 1024, + distance: "cosine", + }, + embedding: { + provider: "ollama_internal", + model: "qwen3-embedding:0.6b", + dimensions: 1024, + }, + }, + llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] }, + diagnostics: { + dwh_rest: { + method: "GET", + path: "/health", + auth: "none", + response: { database: "database", schema: "schema" }, + }, + }, + }); + expect(descriptor).not.toHaveProperty("evidence"); }); test("workspace registry smoke image cleanup is scoped to the per-run image identity", () => { @@ -37,6 +152,16 @@ test("workspace registry smoke image cleanup is scoped to the per-run image iden expect(output).toContain("workspace registry smoke image cleanup identity self-test passed"); }); +test("workspace registry smoke cleanup failure-path self-test preserves status and retention", () => { + const output = execFileSync("bash", ["scripts/workspace-registry-smoke.sh"], { + cwd: new URL("../..", import.meta.url), + env: { ...process.env, WORKSPACE_REGISTRY_SMOKE_SELF_TEST: "cleanup-failure-path" }, + encoding: "utf8", + }); + + expect(output).toContain("workspace registry smoke cleanup failure-path self-test passed"); +}); + test("workspace migration source modules are absent from the live backend boundary", () => { expect(existsSync(new URL("../src/workspaces/migrate-legacy.ts", import.meta.url))).toBe(false); expect(existsSync(new URL("../src/workspaces/migrate-v2-qdrant.ts", import.meta.url))).toBe(false); diff --git a/scripts/fixtures/workspace-registry-smoke.yaml b/scripts/fixtures/workspace-registry-smoke.yaml new file mode 100644 index 00000000..d2dda9b3 --- /dev/null +++ b/scripts/fixtures/workspace-registry-smoke.yaml @@ -0,0 +1,41 @@ +workspace: + schema_version: 3 + id: local + name: Local + description: Isolated workspace registry smoke fixture. + language: en + +dwh: + engine: postgres + database: postgres + schema: public + port: 5432 + timeout_ms: 5000 + supported_transports: + - postgres_direct + - rest_api + +semantic_index: + vector_store: + engine: qdrant + collection: local + dimensions: 1024 + distance: cosine + embedding: + provider: ollama_internal + model: qwen3-embedding:0.6b + dimensions: 1024 + +llm_policy: + default: zai/glm-5.2 + allowed: + - zai/glm-5.2 + +diagnostics: + dwh_rest: + method: GET + path: /health + auth: none + response: + database: database + schema: schema diff --git a/scripts/fixtures/workspace-registry-windows.yaml b/scripts/fixtures/workspace-registry-windows.yaml new file mode 100644 index 00000000..d0c03de3 --- /dev/null +++ b/scripts/fixtures/workspace-registry-windows.yaml @@ -0,0 +1,40 @@ +workspace: + schema_version: 3 + id: task13-windows + name: Task 13 Windows + language: en + +dwh: + engine: postgres + database: warehouse + schema: public + port: 5432 + timeout_ms: 5000 + supported_transports: + - postgres_direct + - rest_api + +semantic_index: + vector_store: + engine: qdrant + collection: task13-windows + dimensions: 1024 + distance: cosine + embedding: + provider: ollama_internal + model: qwen3-embedding:0.6b + dimensions: 1024 + +llm_policy: + default: zai/glm-5.2 + allowed: + - zai/glm-5.2 + +diagnostics: + dwh_rest: + method: GET + path: /health + auth: none + response: + database: database + schema: schema diff --git a/scripts/test-no-deployment-coupling-scope.sh b/scripts/test-no-deployment-coupling-scope.sh index c2b4d1f6..727c4677 100755 --- a/scripts/test-no-deployment-coupling-scope.sh +++ b/scripts/test-no-deployment-coupling-scope.sh @@ -9,7 +9,6 @@ trap 'rm -rf "$fixture"' EXIT HUP INT TERM new_fixture() { rm -rf "$fixture/repository" mkdir -p \ - "$fixture/repository/backend/src/workspaces" \ "$fixture/repository/deploy/env" \ "$fixture/repository/deploy/workspaces" \ "$fixture/repository/docker/smoke" \ @@ -26,8 +25,6 @@ new_fixture() { printf '%s\n' 'THT_LLM_URL=https://llm.example.invalid' >"$fixture/repository/deploy/env/local.env.example" printf '%s\n' '# generic launcher' >"$fixture/repository/scripts/run-stack.sh" printf '%s\n' '// generic frontend configuration' >"$fixture/repository/frontend/vite.config.ts" - printf '%s\n' '// explicit descriptor migration module may mention pgvector during conversion' \ - >"$fixture/repository/backend/src/workspaces/migrate-legacy.ts" printf '%s\n' 'language: en' 'vectors: { type: qdrant, base_url: http://qdrant:6333, collection: demo }' \ >"$fixture/repository/deploy/workspaces/example.yaml" printf '%s\n' '# qdrant backup helper' >"$fixture/repository/scripts/vector-backup.sh" diff --git a/scripts/test-windows-clone-contract.ps1 b/scripts/test-windows-clone-contract.ps1 index 12e77312..29f55929 100644 --- a/scripts/test-windows-clone-contract.ps1 +++ b/scripts/test-windows-clone-contract.ps1 @@ -152,18 +152,9 @@ try { [System.IO.Directory]::CreateDirectory((Join-Path $seed "workspaces")) | Out-Null Invoke-BoundedNative -FilePath "git" -Arguments @("init", "--bare", "--initial-branch=main", $remote) -Label "initialize Windows bare registry" | Out-Null Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $seed, "init", "--initial-branch=main") -Label "initialize Windows registry seed" | Out-Null - Write-Utf8File (Join-Path $seed "workspaces/task13-windows.yaml") @" -workspace: - schema_version: 2 - id: task13-windows - name: Task 13 Windows - language: en -dwh: - engine: postgres - database: warehouse - schema: public - supported_transports: [postgres_direct] -"@ + $workspaceFixture = Join-Path $spacedRepository "scripts/fixtures/workspace-registry-windows.yaml" + $workspaceDestination = Join-Path $seed "workspaces/task13-windows.yaml" + Copy-Item -LiteralPath $workspaceFixture -Destination $workspaceDestination Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $seed, "add", "workspaces/task13-windows.yaml") -Label "stage Windows registry seed" | Out-Null Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $seed, "-c", "user.name=Task 13 Windows", "-c", "user.email=task13-windows@example.invalid", "commit", "-m", "Seed Windows smoke") -Label "commit Windows registry seed" | Out-Null Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $seed, "push", $remote, "HEAD:main") -Label "push Windows registry seed" | Out-Null diff --git a/scripts/workspace-registry-smoke.sh b/scripts/workspace-registry-smoke.sh index 4cc35952..4de7a334 100755 --- a/scripts/workspace-registry-smoke.sh +++ b/scripts/workspace-registry-smoke.sh @@ -6,6 +6,7 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd -P)" tmp="$(mktemp -d "${TMPDIR:-/tmp}/thoth-workspace-registry-smoke.XXXXXX")" +tmp="$(cd "$tmp" && pwd -P)" tmp_slug="$(basename "$tmp" | tr '[:upper:]._' '[:lower:]--' | tr -cd 'a-z0-9-')" project="thoth-workspace-registry-smoke-${tmp_slug}-$$" image="thothii-workspace-registry-smoke:${project}" @@ -15,20 +16,128 @@ branch="workspace-registry-smoke" core_remote="/fixtures/remote.git" cleanup_smoke_image() { - docker image rm -f "$image" >/dev/null 2>&1 || true + local inspect_status listed list_status + docker image inspect --format '{{.Id}}' "$image" >/dev/null 2>&1 + inspect_status=$? + if [[ "$inspect_status" -eq 0 ]]; then + docker image rm -f "$image" >/dev/null 2>&1 + return $? + fi + + listed="$(docker image ls --quiet --no-trunc "$image" 2>/dev/null)" + list_status=$? + [[ "$list_status" -eq 0 && -z "$listed" ]] } workspace_registry_smoke_leftovers() { - { - docker ps -a --filter "label=com.docker.compose.project=$project" -q - docker volume ls --filter "label=com.docker.compose.project=$project" -q - docker network ls --filter "label=com.docker.compose.project=$project" -q - docker image inspect --format '{{.Id}}' "$image" 2>/dev/null || true - } | sed '/^$/d' + local output status=0 + if output="$(docker ps -a --filter "label=com.docker.compose.project=$project" -q)"; then + printf '%s\n' "$output" + else + status=1 + fi + if output="$(docker volume ls --filter "label=com.docker.compose.project=$project" -q)"; then + printf '%s\n' "$output" + else + status=1 + fi + if output="$(docker network ls --filter "label=com.docker.compose.project=$project" -q)"; then + printf '%s\n' "$output" + else + status=1 + fi + if output="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null)"; then + printf '%s\n' "$output" + elif output="$(docker image ls --quiet --no-trunc "$image" 2>/dev/null)"; then + printf '%s\n' "$output" + else + status=1 + fi + return "$status" +} + +cleanup() { + local body_status=$? + local down_status image_status enumeration_status rm_status=0 cleanup_incomplete=0 final_status + local leftovers + trap - EXIT HUP INT TERM + set +e + + compose down --volumes --remove-orphans >/dev/null 2>&1 + down_status=$? + cleanup_smoke_image + image_status=$? + leftovers="$(workspace_registry_smoke_leftovers)" + enumeration_status=$? + leftovers="$(printf '%s\n' "$leftovers" | sed '/^$/d')" + + if [[ "$down_status" -ne 0 || "$image_status" -ne 0 || "$enumeration_status" -ne 0 || -n "$leftovers" ]]; then + cleanup_incomplete=1 + else + rm -rf "$tmp" + rm_status=$? + [[ "$rm_status" -eq 0 ]] || cleanup_incomplete=1 + fi + + if [[ "$cleanup_incomplete" -ne 0 ]]; then + echo "workspace registry cleanup incomplete: compose down status=$down_status, image cleanup status=$image_status, enumeration status=$enumeration_status, temp cleanup status=$rm_status." >&2 + if [[ -n "$leftovers" ]]; then + echo "workspace registry cleanup left owned Docker resources:" >&2 + printf '%s\n' "$leftovers" >&2 + fi + echo "workspace registry smoke recovery path retained: $tmp" >&2 + if [[ "$body_status" -ne 0 ]]; then + final_status=$body_status + else + final_status=1 + fi + else + echo "workspace registry cleanup proof: no compose containers, volumes, networks, image, or temporary path remain for $project." + final_status=$body_status + fi + exit "$final_status" +} + +compose() { + SMOKE_ROOT="$root" \ + SMOKE_IMAGE="$image" \ + SMOKE_REMOTE="$remote" \ + SMOKE_BRANCH="$branch" \ + SMOKE_CORE_REMOTE="$core_remote" \ + docker compose --project-name "$project" -f - "$@" <<'COMPOSE_YAML' +services: + core: + build: + context: "${SMOKE_ROOT:?}" + dockerfile: docker/core.Dockerfile + image: "${SMOKE_IMAGE:?}" + environment: + HOST: 0.0.0.0 + PORT: "8787" + AUTH_MODE: none + THT_HARNESS_DIR: /app/harness + THT_BIN: /opt/venv/bin/tht + SETTINGS_FILE: /tmp/settings.json + THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry + THT_WORKSPACE_GIT_REMOTE: "${SMOKE_CORE_REMOTE:?}" + THT_WORKSPACE_GIT_BRANCH: "${SMOKE_BRANCH:?}" + THT_WORKSPACE_INSTALLATION_ID: smoke + THT_WORKSPACE_SECRET_ROOTS: /run/secrets + volumes: + - type: volume + source: workspace-registry + target: /data/workspace-registry + - type: bind + source: "${SMOKE_REMOTE:?}" + target: /fixtures/remote.git + read_only: true +volumes: + workspace-registry: {} +COMPOSE_YAML } workspace_registry_smoke_self_test_image_cleanup_identity() { - local calls exact_image foreign_project foreign_tag leftovers + local calls exact_image foreign_project foreign_tag leftovers removed=0 calls="$(mktemp "${TMPDIR:-/tmp}/thoth-workspace-registry-smoke-image-contract.XXXXXX")" project="thoth-workspace-registry-smoke-selftest-123" exact_image="thothii-workspace-registry-smoke:${project}" @@ -39,19 +148,19 @@ workspace_registry_smoke_self_test_image_cleanup_identity() { docker() { printf '%s\n' "docker $*" >>"$calls" case "$1 $2" in - "image rm") - [[ "$3" == "-f" ]] || return 41 - [[ "$4" == "$exact_image" ]] || return 42 - return 0 - ;; "image inspect") - [[ "$3" == "--format" ]] || return 43 - [[ "$5" == "$exact_image" ]] || return 44 - return 1 + [[ "$3" == "--format" && "$5" == "$exact_image" ]] || return 43 + [[ "$removed" -eq 0 ]] + ;; + "image rm") + [[ "$3" == "-f" && "$4" == "$exact_image" ]] || return 42 + removed=1 + ;; + "image ls") + [[ "$3" == "--quiet" && "$4" == "--no-trunc" && "$5" == "$exact_image" ]] || return 47 ;; "ps -a"|"volume ls"|"network ls") [[ "$*" == *"label=com.docker.compose.project=$project"* ]] || return 45 - return 0 ;; *) return 46 @@ -61,7 +170,7 @@ workspace_registry_smoke_self_test_image_cleanup_identity() { cleanup_smoke_image leftovers="$(workspace_registry_smoke_leftovers)" - [[ -z "$leftovers" ]] || { + [[ -z "$(printf '%s\n' "$leftovers" | sed '/^$/d')" ]] || { echo "self-test observed leftovers for the per-run image" >&2 printf '%s\n' "$leftovers" >&2 return 1 @@ -72,62 +181,95 @@ workspace_registry_smoke_self_test_image_cleanup_identity() { echo "self-test cleanup touched a foreign workspace-registry smoke image reference" >&2 return 1 fi + rm -f "$calls" echo "workspace registry smoke image cleanup identity self-test passed" } -if [[ "${WORKSPACE_REGISTRY_SMOKE_SELF_TEST:-}" == "image-cleanup-identity" ]]; then - workspace_registry_smoke_self_test_image_cleanup_identity - exit 0 -fi +workspace_registry_smoke_self_test_compose_config() { + local special_root rendered + special_root="$tmp/compose config path # colon: fixture" + root="$special_root/root context" + remote="$special_root/remote repo # fixture.git" + branch="workspace registry # branch" + core_remote="/fixtures/remote repo # fixture.git" + image="thothii-workspace-registry-smoke:compose-config-selftest" + project="thoth-workspace-registry-smoke-compose-config-selftest-$$" + rendered="$special_root/rendered.yaml" + mkdir -p "$root" "$remote" -cleanup() { - local cleanup_status=$? - compose down --volumes --remove-orphans >/dev/null 2>&1 || true - cleanup_smoke_image - if [[ "$cleanup_status" -eq 0 ]]; then - local leftovers - leftovers="$(workspace_registry_smoke_leftovers)" - if [[ -n "$leftovers" ]]; then - echo "workspace registry cleanup left owned Docker resources:" >&2 - printf '%s\n' "$leftovers" >&2 - cleanup_status=1 - else - echo "workspace registry cleanup proof: no compose containers, volumes, networks, or image remain for $project." - fi - fi + compose config --quiet + compose config --format json >"$rendered" + grep -Fq "$root" "$rendered" + grep -Fq "$remote" "$rendered" + grep -Fq "$branch" "$rendered" + grep -Fq "$core_remote" "$rendered" + grep -Fq '"read_only": true' "$rendered" rm -rf "$tmp" - exit "$cleanup_status" + echo "workspace registry smoke Compose config special-path self-test passed" } -trap cleanup EXIT HUP INT TERM -compose() { - docker compose --project-name "$project" -f - "$@" <>"$calls"; return 71; } + cleanup_smoke_image() { printf '%s\n' 'image cleanup' >>"$calls"; return 72; } + workspace_registry_smoke_leftovers() { printf '%s\n' 'owned-resource-selftest'; printf '%s\n' 'enumerate leftovers' >>"$calls"; return 73; } + trap cleanup EXIT + exit 37 + ) 2>&1 + )" + status=$? + set -e + + [[ "$status" -eq 37 ]] || { echo "cleanup self-test did not preserve body status 37 (got $status)" >&2; return 1; } + grep -Fq 'compose down --volumes --remove-orphans' "$calls" + grep -Fq 'image cleanup' "$calls" + grep -Fq 'enumerate leftovers' "$calls" + grep -Fq 'cleanup incomplete: compose down status=71, image cleanup status=72, enumeration status=73' <<<"$output" + grep -Fq 'owned-resource-selftest' <<<"$output" + grep -Fq "recovery path retained: $retained" <<<"$output" + [[ -d "$retained" ]] || { echo "cleanup self-test did not retain its recovery path" >&2; return 1; } + rm -rf "$tmp" + echo "workspace registry smoke cleanup failure-path self-test passed" } +case "${WORKSPACE_REGISTRY_SMOKE_SELF_TEST:-}" in + "") ;; + image-cleanup-identity) + workspace_registry_smoke_self_test_image_cleanup_identity + rm -rf "$tmp" + exit 0 + ;; + compose-config-contract) + workspace_registry_smoke_self_test_compose_config + exit 0 + ;; + cleanup-failure-path) + workspace_registry_smoke_self_test_cleanup_failure_path + exit 0 + ;; + *) + echo "unknown workspace registry smoke self-test: ${WORKSPACE_REGISTRY_SMOKE_SELF_TEST}" >&2 + rm -rf "$tmp" + exit 2 + ;; +esac + +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + wait_for_core() { local attempt for attempt in $(seq 1 30); do @@ -149,9 +291,8 @@ echo "== Seed isolated workspace registry ==" git init --bare --initial-branch=main "$remote" >/dev/null git clone "$remote" "$seed" >/dev/null git -C "$seed" checkout -b "$branch" >/dev/null -npm --prefix "$root/backend" run build >/dev/null -node "$root/backend/dist/workspaces/migrate-legacy.js" \ - --input "$root/harness/workspaces/local.yaml" --output "$seed" --collection local >/dev/null +mkdir -p "$seed/workspaces" +cp "$root/scripts/fixtures/workspace-registry-smoke.yaml" "$seed/workspaces/local.yaml" git -C "$seed" add workspaces/local.yaml git -C "$seed" -c user.name='Workspace Registry Smoke' -c user.email='workspace-registry-smoke@example.invalid' \ commit -m 'Seed workspace registry smoke' >/dev/null