diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c2393127a..03965da58 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -71,8 +71,10 @@ jobs: - uses: actions/setup-node@v6 with: + # No `cache: npm` here — this job caches the resolved node_modules tree directly + # below, and both caches key off the same lockfile, so the npm download cache only + # ever hits when node_modules already hit. It was pure repo cache-quota spend. node-version: 24 - cache: npm - name: Restore node_modules id: node-modules @@ -153,8 +155,10 @@ jobs: - uses: actions/setup-node@v6 with: + # No `cache: npm` here — this job caches the resolved node_modules tree directly + # below, and both caches key off the same lockfile, so the npm download cache only + # ever hits when node_modules already hit. It was pure repo cache-quota spend. node-version: 24 - cache: npm - name: Restore node_modules id: node-modules @@ -258,22 +262,81 @@ jobs: - name: Apply the fresh target database baseline run: psql "$DATABASE_URL" -X --set=ON_ERROR_STOP=1 --file=apps/opencrane/prisma/bootstrap/target-baseline.sql + # Restored before the first Nx invocation so the SQL suites replay too. The save half + # is a separate step at the end of the job: actions/cache only writes its cache when the + # job succeeded, and a run that fails late would otherwise leave the next run cold. + - name: Restore the Nx computation cache + uses: actions/cache/restore@v4 + with: + path: .nx/cache + key: nx-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.run_id }} + restore-keys: | + nx-${{ runner.os }}-${{ hashFiles('package-lock.json') }}- + # Every suite validates the shared target baseline. A baseline-only change belongs to the # opencrane app and therefore cannot make the library-owned SQL suites "affected" in Nx. - name: Run every PostgreSQL authority suite run: npx nx run-many -t test:sql --parallel=1 - - name: Restore the Nx computation cache + - name: Build, test, and lint affected projects + run: npx nx affected -t build test lint + + - name: Enforce monorepo dependency boundaries + run: npm run lint:boundaries + + - name: Verify API reference and generated client when affected + if: needs.prepare.outputs.api_contract_changed == 'true' + run: | + npx nx run opencrane:build + npm run sync-openapi -w @opencrane/website + git diff --exit-code -- website/public/openapi.json + npx nx run contracts:generate + git diff --exit-code -- libs/contracts/src/generated/api.ts + + # Saved even when the job failed. A failing guard says nothing about the build/test/lint + # tasks that already passed, and discarding their results made every later run start cold. + - name: Save the Nx computation cache + if: always() + uses: actions/cache/save@v4 + with: + path: .nx/cache + key: nx-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-test-${{ github.run_id }} + + storybook: + name: Storybook component contracts + runs-on: ubuntu-latest + needs: prepare + env: + NX_BASE: ${{ needs.prepare.outputs.nx_base }} + NX_HEAD: ${{ needs.prepare.outputs.nx_head }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Restore node_modules + id: node-modules uses: actions/cache@v4 + with: + path: node_modules + key: node-modules-${{ runner.os }}-node24-${{ hashFiles('package-lock.json') }} + + - name: Install dependencies + if: steps.node-modules.outputs.cache-hit != 'true' + run: npm ci + + - name: Restore the Nx computation cache + uses: actions/cache/restore@v4 with: path: .nx/cache - key: nx-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }} + key: nx-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-storybook-${{ github.run_id }} restore-keys: | nx-${{ runner.os }}-${{ hashFiles('package-lock.json') }}- - - name: Build, test, and lint affected projects - run: npx nx affected -t build test lint - - name: Detect affected Storybook regression coverage id: storybook-regression shell: bash @@ -285,10 +348,21 @@ jobs: echo "required=false" >> "$GITHUB_OUTPUT" fi + # Keyed on the lockfile, which pins the Playwright version the browser build must match. + - name: Restore the Chromium build + id: playwright-browsers + if: steps.storybook-regression.outputs.required == 'true' + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + - name: Install Chromium for affected Storybook regressions if: steps.storybook-regression.outputs.required == 'true' run: npx playwright install --with-deps chromium + # --parallel=1 is required, not conservative: test-storybook and test-storybook-visual + # both serve the catalogue on port 4400, so running them at once makes them fight for it. - name: Build and test affected Storybook component contracts if: steps.storybook-regression.outputs.required == 'true' run: npx nx affected -t build-storybook test-storybook test-storybook-visual --base="$NX_BASE" --head="$NX_HEAD" --parallel=1 @@ -303,17 +377,12 @@ jobs: if-no-files-found: ignore retention-days: 7 - - name: Enforce monorepo dependency boundaries - run: npm run lint:boundaries - - - name: Verify API reference and generated client when affected - if: needs.prepare.outputs.api_contract_changed == 'true' - run: | - npx nx run opencrane:build - npm run sync-openapi -w @opencrane/website - git diff --exit-code -- website/public/openapi.json - npx nx run contracts:generate - git diff --exit-code -- libs/contracts/src/generated/api.ts + - name: Save the Nx computation cache + if: always() + uses: actions/cache/save@v4 + with: + path: .nx/cache + key: nx-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-storybook-${{ github.run_id }} develop_smoke: name: k3d current-silo smoke test @@ -339,8 +408,10 @@ jobs: - uses: actions/setup-node@v6 with: + # No `cache: npm` here — this job caches the resolved node_modules tree directly + # below, and both caches key off the same lockfile, so the npm download cache only + # ever hits when node_modules already hit. It was pure repo cache-quota spend. node-version: 24 - cache: npm # The production deploy wrapper resolves and schema-validates the database transition before # it mutates the cluster. The smoke job is a fresh checkout, so restore the same exact @@ -410,8 +481,10 @@ jobs: - uses: actions/setup-node@v6 with: + # No `cache: npm` here — this job caches the resolved node_modules tree directly + # below, and both caches key off the same lockfile, so the npm download cache only + # ever hits when node_modules already hit. It was pure repo cache-quota spend. node-version: 24 - cache: npm - name: Restore node_modules id: node-modules @@ -432,12 +505,13 @@ jobs: build-and-push: name: Build and publish affected images runs-on: ubuntu-latest - needs: [prepare, test, develop_smoke, image_smoke] + needs: [prepare, test, storybook, develop_smoke, image_smoke] if: >- ${{ always() && needs.prepare.outputs.has_deployables == 'true' && needs.test.result == 'success' && + (needs.storybook.result == 'success' || needs.storybook.result == 'skipped') && (needs.develop_smoke.result == 'success' || needs.develop_smoke.result == 'skipped') && (needs.image_smoke.result == 'success' || needs.image_smoke.result == 'skipped') }} @@ -484,13 +558,14 @@ jobs: publish-develop-smoke-images: name: Publish develop smoke image (${{ matrix.project }}) runs-on: ubuntu-latest - needs: [prepare, test, develop_smoke, image_smoke, build-and-push] + needs: [prepare, test, storybook, develop_smoke, image_smoke, build-and-push] if: >- ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/develop' && needs.test.result == 'success' && + (needs.storybook.result == 'success' || needs.storybook.result == 'skipped') && needs.develop_smoke.result == 'success' && (needs.image_smoke.result == 'success' || needs.image_smoke.result == 'skipped') && (needs['build-and-push'].result == 'success' || needs['build-and-push'].result == 'skipped') diff --git a/apps/_infra/deploy-k8s/charts/opencrane-agent-controller-0.8.0.tgz b/apps/_infra/deploy-k8s/charts/opencrane-agent-controller-0.8.0.tgz index d9b00cd7a..ccf9a0176 100644 Binary files a/apps/_infra/deploy-k8s/charts/opencrane-agent-controller-0.8.0.tgz and b/apps/_infra/deploy-k8s/charts/opencrane-agent-controller-0.8.0.tgz differ diff --git a/apps/_infra/deploy-k8s/charts/opencrane-artifact-preprocessor-0.8.0.tgz b/apps/_infra/deploy-k8s/charts/opencrane-artifact-preprocessor-0.8.0.tgz index 81e738803..624e2c261 100644 Binary files a/apps/_infra/deploy-k8s/charts/opencrane-artifact-preprocessor-0.8.0.tgz and b/apps/_infra/deploy-k8s/charts/opencrane-artifact-preprocessor-0.8.0.tgz differ diff --git a/apps/_infra/deploy-k8s/charts/opencrane-artifact-scanner-0.8.0.tgz b/apps/_infra/deploy-k8s/charts/opencrane-artifact-scanner-0.8.0.tgz index 6a381a9f4..bd0110f03 100644 Binary files a/apps/_infra/deploy-k8s/charts/opencrane-artifact-scanner-0.8.0.tgz and b/apps/_infra/deploy-k8s/charts/opencrane-artifact-scanner-0.8.0.tgz differ diff --git a/apps/_infra/deploy-k8s/charts/opencrane-artifact-service-0.8.0.tgz b/apps/_infra/deploy-k8s/charts/opencrane-artifact-service-0.8.0.tgz index 2d28c3036..7caa87b81 100644 Binary files a/apps/_infra/deploy-k8s/charts/opencrane-artifact-service-0.8.0.tgz and b/apps/_infra/deploy-k8s/charts/opencrane-artifact-service-0.8.0.tgz differ diff --git a/apps/_infra/deploy-k8s/charts/opencrane-channel-proxy-0.8.0.tgz b/apps/_infra/deploy-k8s/charts/opencrane-channel-proxy-0.8.0.tgz index a97104133..de894706a 100644 Binary files a/apps/_infra/deploy-k8s/charts/opencrane-channel-proxy-0.8.0.tgz and b/apps/_infra/deploy-k8s/charts/opencrane-channel-proxy-0.8.0.tgz differ diff --git a/apps/_infra/deploy-k8s/charts/opencrane-memory-gateway-0.8.0.tgz b/apps/_infra/deploy-k8s/charts/opencrane-memory-gateway-0.8.0.tgz index a5cc19266..c301d367e 100644 Binary files a/apps/_infra/deploy-k8s/charts/opencrane-memory-gateway-0.8.0.tgz and b/apps/_infra/deploy-k8s/charts/opencrane-memory-gateway-0.8.0.tgz differ diff --git a/apps/_infra/deploy-k8s/charts/opencrane-server-0.8.0.tgz b/apps/_infra/deploy-k8s/charts/opencrane-server-0.8.0.tgz index 59f3130dd..de19b4ac2 100644 Binary files a/apps/_infra/deploy-k8s/charts/opencrane-server-0.8.0.tgz and b/apps/_infra/deploy-k8s/charts/opencrane-server-0.8.0.tgz differ diff --git a/apps/_infra/deploy-k8s/platform/tests/develop-smoke.sh b/apps/_infra/deploy-k8s/platform/tests/develop-smoke.sh index c05689480..be7411678 100755 --- a/apps/_infra/deploy-k8s/platform/tests/develop-smoke.sh +++ b/apps/_infra/deploy-k8s/platform/tests/develop-smoke.sh @@ -24,6 +24,9 @@ SMOKE_AFFECTED_PROJECTS="${SMOKE_AFFECTED_PROJECTS-all}" SMOKE_BASE_SHA="${SMOKE_BASE_SHA:-}" SMOKE_REGISTRY="${SMOKE_REGISTRY:-ghcr.io/elewa-git}" SMOKE_STORAGE_MODE="${SMOKE_STORAGE_MODE:-full}" +# How many images to build or pull at once. Three keeps the network and disk busy without +# starving the two cores a hosted runner gives us; raise it on a bigger machine. +SMOKE_BUILD_CONCURRENCY="${SMOKE_BUILD_CONCURRENCY:-3}" KEY_DIR="" CSI_DIR="" IMAGE_PREPARATION_PID="" @@ -201,18 +204,56 @@ _prepare_image() fi } +# Pending image jobs and the file each one's output is collected into. +_PREPARE_PIDS=() +_PREPARE_LOGS=() + +# Wait for the images started so far, then replay their output one image at a time. +# Concurrent builds interleave their progress lines, so each job writes to its own +# file and it is printed whole — the log stays readable when a build fails. +_await_image_batch() +{ + local index status=0 + for index in "${!_PREPARE_PIDS[@]}"; do + if ! wait "${_PREPARE_PIDS[$index]}"; then + status=1 + fi + cat "${_PREPARE_LOGS[$index]}" + rm -f "${_PREPARE_LOGS[$index]}" + done + _PREPARE_PIDS=() + _PREPARE_LOGS=() + return "$status" +} + +# Every image is independent, so prepare several at once. Run one at a time they were by far +# the longest phase of the smoke. The fan-out stays capped because the runner's Docker daemon +# is small — all five at once would make them fight for the same two cores. _prepare_images() { - _prepare_image opencrane opencrane/opencrane-server:develop-smoke \ - opencrane-server apps/opencrane/deploy/Dockerfile - _prepare_image opencrane-ui opencrane/opencrane-ui:develop-smoke \ - opencrane-ui apps/opencrane-ui/deploy/Dockerfile - _prepare_image channel-proxy opencrane/channel-proxy:develop-smoke \ - opencrane-channel-proxy apps/channel-proxy/deploy/Dockerfile - _prepare_image memory-gateway opencrane/memory-gateway:develop-smoke \ - opencrane-memory-gateway apps/memory-gateway/deploy/Dockerfile - _prepare_image artifact-service opencrane/artifact-service:develop-smoke \ - opencrane-artifact-service apps/artifact-service/deploy/Dockerfile + local -a specs=( + "opencrane opencrane/opencrane-server:develop-smoke opencrane-server apps/opencrane/deploy/Dockerfile" + "opencrane-ui opencrane/opencrane-ui:develop-smoke opencrane-ui apps/opencrane-ui/deploy/Dockerfile" + "channel-proxy opencrane/channel-proxy:develop-smoke opencrane-channel-proxy apps/channel-proxy/deploy/Dockerfile" + "memory-gateway opencrane/memory-gateway:develop-smoke opencrane-memory-gateway apps/memory-gateway/deploy/Dockerfile" + "artifact-service opencrane/artifact-service:develop-smoke opencrane-artifact-service apps/artifact-service/deploy/Dockerfile" + ) + local spec log status=0 + + for spec in "${specs[@]}"; do + log="$(mktemp)" + _PREPARE_LOGS+=("$log") + # Word splitting is intended: a spec is four fields, none of which contains a space. + # shellcheck disable=SC2086 + ( _prepare_image $spec ) >"$log" 2>&1 & + _PREPARE_PIDS+=("$!") + if [[ "${#_PREPARE_PIDS[@]}" -ge "$SMOKE_BUILD_CONCURRENCY" ]]; then + _await_image_batch || status=1 + fi + done + + _await_image_batch || status=1 + return "$status" } _create_database_credentials() @@ -405,8 +446,8 @@ if [[ "$SMOKE_STORAGE_MODE" != "fast" && "$SMOKE_STORAGE_MODE" != "full" ]]; the fi # Image preparation is the longest independent lane. Start it before k3d so cluster creation and -# external-controller readiness consume the same wall-clock time without fanning out five builds -# against the runner's small Docker daemon. +# external-controller readiness consume the same wall-clock time. The lane builds a few images at +# a time (SMOKE_BUILD_CONCURRENCY) rather than all of them, so it never swamps the small daemon. _prepare_images & IMAGE_PREPARATION_PID=$! diff --git a/apps/agent-controller/deploy/Dockerfile b/apps/agent-controller/deploy/Dockerfile index e6f166880..2daf99cf7 100644 --- a/apps/agent-controller/deploy/Dockerfile +++ b/apps/agent-controller/deploy/Dockerfile @@ -3,9 +3,9 @@ FROM node:22-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json nx.json ./ -COPY libs libs COPY apps/agent-controller/package.json apps/agent-controller/project.json apps/agent-controller/tsconfig.json apps/agent-controller/ RUN npm ci +COPY libs libs COPY apps/agent-controller/src apps/agent-controller/src RUN npx nx run agent-controller:build diff --git a/apps/agent-controller/helm/Chart.yaml b/apps/agent-controller/helm/Chart.yaml index 88686ccb6..650eedd5c 100644 --- a/apps/agent-controller/helm/Chart.yaml +++ b/apps/agent-controller/helm/Chart.yaml @@ -3,4 +3,4 @@ name: opencrane-agent-controller description: App-owned named-template library for the personal-runtime workload controller. type: library version: 0.8.0 -appVersion: "0.8.0" +appVersion: "0.8.1" diff --git a/apps/agent-controller/package.json b/apps/agent-controller/package.json index 867179ea7..49a7a78d2 100644 --- a/apps/agent-controller/package.json +++ b/apps/agent-controller/package.json @@ -1,6 +1,6 @@ { "name": "@opencrane/agent-controller", - "version": "0.8.0", + "version": "0.8.1", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/agent-controller/project.json b/apps/agent-controller/project.json index 148b0a6c9..8c59f4f5b 100644 --- a/apps/agent-controller/project.json +++ b/apps/agent-controller/project.json @@ -2,7 +2,7 @@ "name": "agent-controller", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "metadata": { "release": { "adaptedVersion": "0.8.0" } }, + "metadata": { "release": { "adaptedVersion": "0.8.1" } }, "sourceRoot": "apps/agent-controller/src", "tags": ["type:app", "layer:entrypoint", "scope:agent-controller"], "targets": { diff --git a/apps/artifact-preprocessor/deploy/Dockerfile b/apps/artifact-preprocessor/deploy/Dockerfile index f220df31e..910718ad8 100644 --- a/apps/artifact-preprocessor/deploy/Dockerfile +++ b/apps/artifact-preprocessor/deploy/Dockerfile @@ -3,9 +3,9 @@ FROM node:22-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json nx.json ./ -COPY libs libs COPY apps/artifact-preprocessor/package.json apps/artifact-preprocessor/project.json apps/artifact-preprocessor/tsconfig.json apps/artifact-preprocessor/ RUN npm ci +COPY libs libs COPY apps/artifact-preprocessor/src apps/artifact-preprocessor/src RUN npx nx run artifact-preprocessor:build diff --git a/apps/artifact-preprocessor/helm/Chart.yaml b/apps/artifact-preprocessor/helm/Chart.yaml index 11bdf340c..618ed432f 100644 --- a/apps/artifact-preprocessor/helm/Chart.yaml +++ b/apps/artifact-preprocessor/helm/Chart.yaml @@ -3,4 +3,4 @@ name: opencrane-artifact-preprocessor description: App-owned named-template library for the isolated PDF preprocessing worker. type: library version: 0.8.0 -appVersion: "0.8.0" +appVersion: "0.8.1" diff --git a/apps/artifact-preprocessor/package.json b/apps/artifact-preprocessor/package.json index 3afa2383b..2f4f3858d 100644 --- a/apps/artifact-preprocessor/package.json +++ b/apps/artifact-preprocessor/package.json @@ -1,6 +1,6 @@ { "name": "@opencrane/artifact-preprocessor", - "version": "0.8.0", + "version": "0.8.1", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/artifact-preprocessor/project.json b/apps/artifact-preprocessor/project.json index e59c3211c..c021409de 100644 --- a/apps/artifact-preprocessor/project.json +++ b/apps/artifact-preprocessor/project.json @@ -2,7 +2,7 @@ "name": "artifact-preprocessor", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "metadata": { "release": { "adaptedVersion": "0.8.0" } }, + "metadata": { "release": { "adaptedVersion": "0.8.1" } }, "sourceRoot": "apps/artifact-preprocessor/src", "tags": ["type:app", "layer:entrypoint", "scope:artifacts"], "targets": { diff --git a/apps/artifact-scanner/deploy/Dockerfile b/apps/artifact-scanner/deploy/Dockerfile index c1e6ec547..8dfc2ac8f 100644 --- a/apps/artifact-scanner/deploy/Dockerfile +++ b/apps/artifact-scanner/deploy/Dockerfile @@ -2,9 +2,9 @@ FROM node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436 AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json nx.json ./ -COPY libs libs COPY apps/artifact-scanner/package.json apps/artifact-scanner/project.json apps/artifact-scanner/tsconfig.json apps/artifact-scanner/ RUN npm ci +COPY libs libs COPY apps/artifact-scanner/src apps/artifact-scanner/src RUN npx nx run artifact-scanner:build diff --git a/apps/artifact-scanner/helm/Chart.yaml b/apps/artifact-scanner/helm/Chart.yaml index 8951940cc..a634c546e 100644 --- a/apps/artifact-scanner/helm/Chart.yaml +++ b/apps/artifact-scanner/helm/Chart.yaml @@ -3,4 +3,4 @@ name: opencrane-artifact-scanner description: App-owned named-template library for the isolated malware-scanning worker. type: library version: 0.8.0 -appVersion: "0.8.0" +appVersion: "0.8.1" diff --git a/apps/artifact-scanner/package.json b/apps/artifact-scanner/package.json index 5ebc136c1..d8d63c399 100644 --- a/apps/artifact-scanner/package.json +++ b/apps/artifact-scanner/package.json @@ -1,6 +1,6 @@ { "name": "@opencrane/artifact-scanner", - "version": "0.8.0", + "version": "0.8.1", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/artifact-scanner/project.json b/apps/artifact-scanner/project.json index 5277a55a8..20a897fd4 100644 --- a/apps/artifact-scanner/project.json +++ b/apps/artifact-scanner/project.json @@ -2,7 +2,7 @@ "name": "artifact-scanner", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "metadata": { "release": { "adaptedVersion": "0.8.0" } }, + "metadata": { "release": { "adaptedVersion": "0.8.1" } }, "sourceRoot": "apps/artifact-scanner/src", "tags": ["type:app", "layer:entrypoint", "scope:artifacts"], "targets": { diff --git a/apps/artifact-service/deploy/Dockerfile b/apps/artifact-service/deploy/Dockerfile index 3ee11e0a3..709423705 100644 --- a/apps/artifact-service/deploy/Dockerfile +++ b/apps/artifact-service/deploy/Dockerfile @@ -3,9 +3,9 @@ FROM node:22-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json nx.json ./ -COPY libs libs COPY apps/artifact-service/package.json apps/artifact-service/project.json apps/artifact-service/tsconfig.json apps/artifact-service/ RUN npm ci +COPY libs libs COPY apps/artifact-service/src apps/artifact-service/src RUN npx nx run artifact-service:build diff --git a/apps/artifact-service/helm/Chart.yaml b/apps/artifact-service/helm/Chart.yaml index 02be92ea8..26ac7f175 100644 --- a/apps/artifact-service/helm/Chart.yaml +++ b/apps/artifact-service/helm/Chart.yaml @@ -3,4 +3,4 @@ name: opencrane-artifact-service description: App-owned named-template library for canonical artifact bytes. type: library version: 0.8.0 -appVersion: "0.8.0" +appVersion: "0.8.1" diff --git a/apps/artifact-service/package.json b/apps/artifact-service/package.json index 31bf778bc..71cf4d274 100644 --- a/apps/artifact-service/package.json +++ b/apps/artifact-service/package.json @@ -1,6 +1,6 @@ { "name": "@opencrane/artifact-service", - "version": "0.8.0", + "version": "0.8.1", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/artifact-service/project.json b/apps/artifact-service/project.json index cbd580ec8..97d127428 100644 --- a/apps/artifact-service/project.json +++ b/apps/artifact-service/project.json @@ -2,7 +2,7 @@ "name": "artifact-service", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "metadata": { "release": { "adaptedVersion": "0.8.0" } }, + "metadata": { "release": { "adaptedVersion": "0.8.1" } }, "sourceRoot": "apps/artifact-service/src", "tags": ["type:app", "layer:entrypoint", "scope:artifacts"], "targets": { diff --git a/apps/channel-proxy/deploy/Dockerfile b/apps/channel-proxy/deploy/Dockerfile index 9d3cdef70..3c92f38e4 100644 --- a/apps/channel-proxy/deploy/Dockerfile +++ b/apps/channel-proxy/deploy/Dockerfile @@ -3,9 +3,9 @@ FROM node:22-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json nx.json ./ -COPY libs libs COPY apps/channel-proxy/package.json apps/channel-proxy/project.json apps/channel-proxy/tsconfig.json apps/channel-proxy/ RUN npm ci +COPY libs libs COPY apps/channel-proxy/src apps/channel-proxy/src RUN npx nx run channel-proxy:build diff --git a/apps/channel-proxy/helm/Chart.yaml b/apps/channel-proxy/helm/Chart.yaml index c2fb48a70..5de51b211 100644 --- a/apps/channel-proxy/helm/Chart.yaml +++ b/apps/channel-proxy/helm/Chart.yaml @@ -3,4 +3,4 @@ name: opencrane-channel-proxy description: App-owned named-template library for the channel trust boundary. type: library version: 0.8.0 -appVersion: "0.8.0" +appVersion: "0.8.1" diff --git a/apps/channel-proxy/package.json b/apps/channel-proxy/package.json index 90a30ca9f..5887c7613 100644 --- a/apps/channel-proxy/package.json +++ b/apps/channel-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@opencrane/channel-proxy", - "version": "0.8.0", + "version": "0.8.1", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/channel-proxy/project.json b/apps/channel-proxy/project.json index b5e2ad1b9..4605349d6 100644 --- a/apps/channel-proxy/project.json +++ b/apps/channel-proxy/project.json @@ -2,7 +2,7 @@ "name": "channel-proxy", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "metadata": { "release": { "adaptedVersion": "0.8.0" } }, + "metadata": { "release": { "adaptedVersion": "0.8.1" } }, "sourceRoot": "apps/channel-proxy/src", "tags": ["type:app", "layer:entrypoint", "scope:app"], "targets": { diff --git a/apps/memory-gateway/deploy/Dockerfile b/apps/memory-gateway/deploy/Dockerfile index a5b47423c..f4aa8922e 100644 --- a/apps/memory-gateway/deploy/Dockerfile +++ b/apps/memory-gateway/deploy/Dockerfile @@ -3,9 +3,9 @@ FROM node:22-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json nx.json ./ -COPY libs libs COPY apps/memory-gateway/package.json apps/memory-gateway/project.json apps/memory-gateway/tsconfig.json apps/memory-gateway/ RUN npm ci +COPY libs libs COPY apps/memory-gateway/src apps/memory-gateway/src RUN npx nx run memory-gateway:build diff --git a/apps/memory-gateway/helm/Chart.yaml b/apps/memory-gateway/helm/Chart.yaml index deb3a86b8..6b07d770a 100644 --- a/apps/memory-gateway/helm/Chart.yaml +++ b/apps/memory-gateway/helm/Chart.yaml @@ -3,4 +3,4 @@ name: opencrane-memory-gateway description: App-owned named-template library for the private Cognee boundary. type: library version: 0.8.0 -appVersion: "0.8.0" +appVersion: "0.8.1" diff --git a/apps/memory-gateway/package.json b/apps/memory-gateway/package.json index b811239ee..75ae57bad 100644 --- a/apps/memory-gateway/package.json +++ b/apps/memory-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@opencrane/memory-gateway", - "version": "0.8.0", + "version": "0.8.1", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/memory-gateway/project.json b/apps/memory-gateway/project.json index c4888e644..b8db96e29 100644 --- a/apps/memory-gateway/project.json +++ b/apps/memory-gateway/project.json @@ -2,7 +2,7 @@ "name": "memory-gateway", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "metadata": { "release": { "adaptedVersion": "0.8.0" } }, + "metadata": { "release": { "adaptedVersion": "0.8.1" } }, "sourceRoot": "apps/memory-gateway/src", "tags": ["type:app", "layer:entrypoint", "scope:memory-gateway"], "targets": { diff --git a/apps/opencrane-ui/deploy/Dockerfile b/apps/opencrane-ui/deploy/Dockerfile index 7c327a4af..014860d03 100644 --- a/apps/opencrane-ui/deploy/Dockerfile +++ b/apps/opencrane-ui/deploy/Dockerfile @@ -11,6 +11,12 @@ FROM node:22-bookworm-slim AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json tsconfig.frontend.json nx.json ./ +# Nothing but the root manifests is copied before the install. The whole app and every lib used +# to come first, so editing a single component invalidated this layer and paid for a full npm ci +# on every build. This app has no package.json of its own, and `libs` is not an npm workspace +# (the root "workspaces" field is apps/* and website), so the install needs neither. +RUN npm ci + # All workspace libs (frontend/*, backend/*/main, contracts, state/*) come in wholesale so # the image never needs editing when a lib is added; the root .dockerignore keeps # node_modules/dist out of the context. The Angular app types against @opencrane/contracts' @@ -18,8 +24,6 @@ COPY package.json package-lock.json tsconfig.json tsconfig.frontend.json nx.json # dist OpenAPI artifact) — no OpenAPI spec or codegen step needed here. COPY libs libs COPY apps/opencrane-ui apps/opencrane-ui -# npm workspaces install: root package.json's "workspaces" field includes apps/* and website -RUN npm ci RUN npx nx build opencrane-ui diff --git a/apps/opencrane/deploy/Dockerfile b/apps/opencrane/deploy/Dockerfile index 4884bd25d..58e8d188c 100644 --- a/apps/opencrane/deploy/Dockerfile +++ b/apps/opencrane/deploy/Dockerfile @@ -9,16 +9,21 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json tsconfig.json nx.json ./ +# Only the manifests the install itself reads are copied before it. Source used to come first, +# which meant editing any library invalidated this layer and paid for a full npm ci on every +# build. `libs` is not an npm workspace (the root "workspaces" field is apps/* and website), so +# the install never needed it. +COPY apps/opencrane/package.json apps/opencrane/ +# npm workspaces install: root package.json's "workspaces" field includes apps/* and website +RUN npm ci + # All workspace libs (shared libs + libs/backend/*/main) come in wholesale so the image never # needs editing when a backend package is added; the root .dockerignore keeps # node_modules/dist out of the context. COPY libs libs COPY apps/opencrane/scripts apps/opencrane/scripts -COPY apps/opencrane/package.json apps/opencrane/tsconfig.json apps/opencrane/prisma.config.ts apps/opencrane/ +COPY apps/opencrane/tsconfig.json apps/opencrane/prisma.config.ts apps/opencrane/ COPY apps/opencrane/prisma apps/opencrane/prisma -# npm workspaces install: root package.json's "workspaces" field includes apps/* and website -RUN npm ci - COPY apps/opencrane/src apps/opencrane/src # Generate Prisma client, build dependencies first, then the app RUN npm run build -w @opencrane/server diff --git a/apps/opencrane/helm/Chart.yaml b/apps/opencrane/helm/Chart.yaml index 847b2be5a..1a136255c 100644 --- a/apps/opencrane/helm/Chart.yaml +++ b/apps/opencrane/helm/Chart.yaml @@ -3,4 +3,4 @@ name: opencrane-server description: App-owned named-template library for the OpenCrane server workload boundary. type: library version: 0.8.0 -appVersion: "0.8.0" +appVersion: "0.8.1" diff --git a/apps/opencrane/package.json b/apps/opencrane/package.json index accc667fb..27e7d7f2b 100644 --- a/apps/opencrane/package.json +++ b/apps/opencrane/package.json @@ -1,6 +1,6 @@ { "name": "@opencrane/server", - "version": "0.8.0", + "version": "0.8.1", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/apps/opencrane/project.json b/apps/opencrane/project.json index 1a3d3d7cf..4ed5924d6 100644 --- a/apps/opencrane/project.json +++ b/apps/opencrane/project.json @@ -2,5 +2,5 @@ "name": "opencrane", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "metadata": { "release": { "adaptedVersion": "0.8.0" } } + "metadata": { "release": { "adaptedVersion": "0.8.1" } } } diff --git a/libs/backend/server/iam/authorization/main/src/__tests__/runtime-bootstrap.router.test.ts b/libs/backend/server/iam/authorization/main/src/__tests__/runtime-bootstrap.router.test.ts index 0ca8c3801..6942f50ad 100644 --- a/libs/backend/server/iam/authorization/main/src/__tests__/runtime-bootstrap.router.test.ts +++ b/libs/backend/server/iam/authorization/main/src/__tests__/runtime-bootstrap.router.test.ts @@ -64,10 +64,17 @@ function _app(options: { record: RuntimeBootstrapExchangeRecord | null; consumpt async loadBootstrapExchange() { return options.record; }, consumeAndBindProofKeyAtomically: consume, }; + // No identity override means the default identity; an explicit null means the + // reviewer accepted the token but resolved no identity. + function _reviewedIdentity() + { + return options.identity === undefined ? _identity : options.identity; + } + const app = express(); app.use(express.json()); app.use("/api/internal/agent-runtime", __CreateRuntimeBootstrapRouter({ - tokenReviewer: { async __Review(token: string) { return token === "valid" ? (options.identity === undefined ? _identity : options.identity) : null; } }, + tokenReviewer: { async __Review(token: string) { return token === "valid" ? _reviewedIdentity() : null; } }, runtimeNamespaces: ["runtime-ns", "managed-runtime-ns"], repository, clock: { nowEpochMs(): number { return Date.parse("2026-07-20T00:01:00.000Z"); } }, diff --git a/libs/frontend/elements/ui/project.json b/libs/frontend/elements/ui/project.json index edeb88a42..844f66ec8 100644 --- a/libs/frontend/elements/ui/project.json +++ b/libs/frontend/elements/ui/project.json @@ -30,16 +30,7 @@ "inputs": [ "default", "^production", - "{workspaceRoot}/libs/frontend/core/src/lib/theme/opencrane-theme.scss", - "{workspaceRoot}/libs/frontend/elements/a2ui/src/**/*", - "{workspaceRoot}/libs/frontend/features/onboarding/src/**/*", - "{workspaceRoot}/libs/frontend/features/context/src/**/__tests__/*.stories.ts", - "{workspaceRoot}/libs/frontend/features/context/src/lib/components/canvas-doc/**/*", - "{workspaceRoot}/libs/frontend/features/conversation-assets/src/**/*", - "{workspaceRoot}/libs/frontend/features/conversation-activity/src/**/*", - "{workspaceRoot}/libs/frontend/features/conversation-elicitation/src/**/*" - ,"{workspaceRoot}/libs/frontend/elements/conversation/src/**/*" - ,"{workspaceRoot}/libs/frontend/features/agent-threads/src/**/*" + "storybookCatalogue" ], "options": { "browserTarget": "frontend-elements-ui:build-storybook", @@ -62,19 +53,11 @@ }, "build-storybook": { "executor": "@storybook/angular:build-storybook", + "cache": true, "inputs": [ "default", "^production", - "{workspaceRoot}/libs/frontend/core/src/lib/theme/opencrane-theme.scss", - "{workspaceRoot}/libs/frontend/elements/a2ui/src/**/*", - "{workspaceRoot}/libs/frontend/features/onboarding/src/**/*", - "{workspaceRoot}/libs/frontend/features/context/src/**/__tests__/*.stories.ts", - "{workspaceRoot}/libs/frontend/features/context/src/lib/components/canvas-doc/**/*", - "{workspaceRoot}/libs/frontend/features/conversation-assets/src/**/*", - "{workspaceRoot}/libs/frontend/features/conversation-activity/src/**/*", - "{workspaceRoot}/libs/frontend/features/conversation-elicitation/src/**/*" - ,"{workspaceRoot}/libs/frontend/elements/conversation/src/**/*" - ,"{workspaceRoot}/libs/frontend/features/agent-threads/src/**/*" + "storybookCatalogue" ], "outputs": [ "{options.outputDir}" @@ -114,7 +97,12 @@ }, "test-storybook": { "executor": "nx:run-commands", - "cache": false, + "cache": true, + "inputs": [ + "default", + "^production", + "storybookCatalogue" + ], "dependsOn": [ "static-storybook" ], @@ -124,15 +112,14 @@ }, "test-storybook-visual": { "executor": "nx:run-commands", - "cache": false, + "cache": true, "dependsOn": [ "static-storybook" ], "inputs": [ "default", "^production", - "{workspaceRoot}/playwright.storybook.config.ts", - "{workspaceRoot}/tests/storybook/**/*" + "storybookCatalogue" ], "outputs": [ "{workspaceRoot}/.nx/test-results/storybook" diff --git a/libs/frontend/state/mcp/adapter/src/lib/mcp-gateway.types.ts b/libs/frontend/state/mcp/adapter/src/lib/mcp-gateway.types.ts index 351ce0df3..d35361b66 100644 --- a/libs/frontend/state/mcp/adapter/src/lib/mcp-gateway.types.ts +++ b/libs/frontend/state/mcp/adapter/src/lib/mcp-gateway.types.ts @@ -1,6 +1,6 @@ import { InjectionToken } from "@angular/core"; -import { McpAccessPolicy, McpDirectory, McpInstalledServer, McpServer } from "@opencrane/core"; +import { McpAccessPolicy, McpCredentialField, McpDirectory, McpEntitledUser, McpInstalledServer, McpServer } from "@opencrane/core"; /** * Abstraction over the OpenCrane MCP catalogue / credential / activation reads @@ -138,3 +138,58 @@ export interface McpGateway /** DI token for the active {@link McpGateway} implementation. */ export const MCP_GATEWAY: InjectionToken = new InjectionToken("WO_MCP_GATEWAY"); + +/* + * Wire shapes of the `/api/v1/mcp/...` JSON. These are local projections — + * WeOwnAI never imports OpenCrane source. The mappers in `mcp-mapper.util.ts` + * turn them into the read models above. + */ + +/** Wire shape of a catalogue server. */ +export interface McpServerWire +{ + /** Stable id / slug. */ + id: string; + /** Display name. */ + name?: string; + /** Short description. */ + description?: string; + /** Publisher label. */ + publisher?: string; + /** Tile glyph. */ + glyph?: string; + /** Connection type (raw string). */ + type?: string; + /** Lifecycle status (raw string). */ + approvalStatus?: string; + /** Credential fields. */ + credentialSchema?: McpCredentialField[]; + /** Entitlement summary. */ + entitlementSummary?: string; +} + +/** Wire shape of an installed-server record. */ +export interface McpInstalledWire +{ + /** Catalogue server id. */ + serverId: string; + /** Connection status (raw string). */ + connectionStatus?: string; + /** Relative last-used label. */ + lastUsed?: string | null; + /** Connected OAuth account. */ + connectedAccount?: string; +} + +/** Wire shape of an access policy. */ +export interface McpAccessPolicyWire +{ + /** Server id. */ + serverId: string; + /** Org-wide grant flag. */ + everyoneInOrg?: boolean; + /** Entitled groups. */ + groups?: string[]; + /** Entitled users. */ + users?: McpEntitledUser[]; +} diff --git a/libs/frontend/state/mcp/adapter/src/lib/mcp-mapper.util.ts b/libs/frontend/state/mcp/adapter/src/lib/mcp-mapper.util.ts index 265f116be..dcbcba5c0 100644 --- a/libs/frontend/state/mcp/adapter/src/lib/mcp-mapper.util.ts +++ b/libs/frontend/state/mcp/adapter/src/lib/mcp-mapper.util.ts @@ -1,63 +1,16 @@ -import { McpAccessPolicy, McpApprovalStatus, McpConnectionStatus, McpCredentialField, McpDirectory, McpEntitledUser, McpInstalledServer, McpServer, McpServerType } from "@opencrane/core"; +import { McpAccessPolicy, McpApprovalStatus, McpConnectionStatus, McpDirectory, McpEntitledUser, McpInstalledServer, McpServer, McpServerType } from "@opencrane/core"; + +import { McpAccessPolicyWire, McpInstalledWire, McpServerWire } from "./mcp-gateway.types"; /** - * Wire shapes + mappers for the live OpenCrane MCP gateway. + * Mappers for the live OpenCrane MCP gateway. * - * Local projections of the `/api/v1/mcp/...` JSON — WeOwnAI never imports - * OpenCrane source. Enum-bearing fields arrive as raw strings, so the mappers - * coerce them through the known enum values (with a safe default) and fill - * missing collections, so every field on the read models is always set — components never see undefined. + * The wire shapes they read live in `mcp-gateway.types.ts`. Enum-bearing fields + * arrive as raw strings, so the mappers coerce them through the known enum + * values (with a safe default) and fill missing collections, so every field on + * the read models is always set — components never see undefined. */ -/** Wire shape of a catalogue server. */ -export interface McpServerWire -{ - /** Stable id / slug. */ - id: string; - /** Display name. */ - name?: string; - /** Short description. */ - description?: string; - /** Publisher label. */ - publisher?: string; - /** Tile glyph. */ - glyph?: string; - /** Connection type (raw string). */ - type?: string; - /** Lifecycle status (raw string). */ - approvalStatus?: string; - /** Credential fields. */ - credentialSchema?: McpCredentialField[]; - /** Entitlement summary. */ - entitlementSummary?: string; -} - -/** Wire shape of an installed-server record. */ -export interface McpInstalledWire -{ - /** Catalogue server id. */ - serverId: string; - /** Connection status (raw string). */ - connectionStatus?: string; - /** Relative last-used label. */ - lastUsed?: string | null; - /** Connected OAuth account. */ - connectedAccount?: string; -} - -/** Wire shape of an access policy. */ -export interface McpAccessPolicyWire -{ - /** Server id. */ - serverId: string; - /** Org-wide grant flag. */ - everyoneInOrg?: boolean; - /** Entitled groups. */ - groups?: string[]; - /** Entitled users. */ - users?: McpEntitledUser[]; -} - /** Coerce a raw string into a {@link McpServerType}, defaulting to single-user. */ function _ToServerType(raw: string | undefined): McpServerType { diff --git a/libs/frontend/state/mcp/adapter/src/lib/opencrane-mcp-gateway.ts b/libs/frontend/state/mcp/adapter/src/lib/opencrane-mcp-gateway.ts index 3481bdc38..a85df262f 100644 --- a/libs/frontend/state/mcp/adapter/src/lib/opencrane-mcp-gateway.ts +++ b/libs/frontend/state/mcp/adapter/src/lib/opencrane-mcp-gateway.ts @@ -2,8 +2,8 @@ import { Injectable, inject } from "@angular/core"; import { ControlPlaneApiService, McpAccessPolicy, McpDirectory, McpInstalledServer, McpServer } from "@opencrane/core"; -import { McpGateway } from "./mcp-gateway.types"; -import { McpAccessPolicyWire, McpInstalledWire, McpServerWire, _MapAccessPolicy, _MapDirectory, _MapInstalled, _MapServer } from "./mcp-mapper.util"; +import { McpAccessPolicyWire, McpGateway, McpInstalledWire, McpServerWire } from "./mcp-gateway.types"; +import { _MapAccessPolicy, _MapDirectory, _MapInstalled, _MapServer } from "./mcp-mapper.util"; /** * Live {@link McpGateway} backed by the OpenCrane opencrane-ui MCP API. diff --git a/nx.json b/nx.json index 048758476..f5c393d94 100644 --- a/nx.json +++ b/nx.json @@ -13,6 +13,12 @@ "production": [ "default", "!{projectRoot}/src/__tests__/**" + ], + "storybookCatalogue": [ + "{workspaceRoot}/libs/frontend/**/*", + "{workspaceRoot}/libs/frontend/elements/ui/.storybook/**/*", + "{workspaceRoot}/playwright.storybook.config.ts", + "{workspaceRoot}/tests/storybook/**/*" ] }, "targetDefaults": { diff --git a/releases/0.8.1.json b/releases/0.8.1.json index b62d4efb8..c7b02345e 100644 --- a/releases/0.8.1.json +++ b/releases/0.8.1.json @@ -20,10 +20,10 @@ }, "agent-controller": { "root": "apps/agent-controller", - "adaptedVersion": "0.8.0", - "packageVersion": "0.8.0", + "adaptedVersion": "0.8.1", + "packageVersion": "0.8.1", "chartVersion": "0.8.0", - "chartAppVersion": "0.8.0" + "chartAppVersion": "0.8.1" }, "agent-runtime": { "root": "apps/agent-runtime", @@ -31,31 +31,31 @@ }, "artifact-preprocessor": { "root": "apps/artifact-preprocessor", - "adaptedVersion": "0.8.0", - "packageVersion": "0.8.0", + "adaptedVersion": "0.8.1", + "packageVersion": "0.8.1", "chartVersion": "0.8.0", - "chartAppVersion": "0.8.0" + "chartAppVersion": "0.8.1" }, "artifact-scanner": { "root": "apps/artifact-scanner", - "adaptedVersion": "0.8.0", - "packageVersion": "0.8.0", + "adaptedVersion": "0.8.1", + "packageVersion": "0.8.1", "chartVersion": "0.8.0", - "chartAppVersion": "0.8.0" + "chartAppVersion": "0.8.1" }, "artifact-service": { "root": "apps/artifact-service", - "adaptedVersion": "0.8.0", - "packageVersion": "0.8.0", + "adaptedVersion": "0.8.1", + "packageVersion": "0.8.1", "chartVersion": "0.8.0", - "chartAppVersion": "0.8.0" + "chartAppVersion": "0.8.1" }, "channel-proxy": { "root": "apps/channel-proxy", - "adaptedVersion": "0.8.0", - "packageVersion": "0.8.0", + "adaptedVersion": "0.8.1", + "packageVersion": "0.8.1", "chartVersion": "0.8.0", - "chartAppVersion": "0.8.0" + "chartAppVersion": "0.8.1" }, "cognee": { "root": "apps/_infra/cognee", @@ -83,10 +83,10 @@ }, "memory-gateway": { "root": "apps/memory-gateway", - "adaptedVersion": "0.8.0", - "packageVersion": "0.8.0", + "adaptedVersion": "0.8.1", + "packageVersion": "0.8.1", "chartVersion": "0.8.0", - "chartAppVersion": "0.8.0" + "chartAppVersion": "0.8.1" }, "obot": { "root": "apps/_infra/obot", @@ -96,10 +96,10 @@ }, "opencrane": { "root": "apps/opencrane", - "adaptedVersion": "0.8.0", - "packageVersion": "0.8.0", + "adaptedVersion": "0.8.1", + "packageVersion": "0.8.1", "chartVersion": "0.8.0", - "chartAppVersion": "0.8.0" + "chartAppVersion": "0.8.1" }, "opencrane-ui": { "root": "apps/opencrane-ui", diff --git a/scripts/__tests__/affected-deployables.test.mjs b/scripts/__tests__/affected-deployables.test.mjs index e47c0c841..8b8b0e52f 100644 --- a/scripts/__tests__/affected-deployables.test.mjs +++ b/scripts/__tests__/affected-deployables.test.mjs @@ -210,7 +210,10 @@ test("keeps heavyweight remote qualification ahead of image publication", functi assert.match(workflow, /run: \.\/apps\/_infra\/deploy-k8s\/platform\/tests\/develop-smoke\.sh/u); assert.match(workflow, /inputs\.heavy_qualification == 'k3d'/u); assert.match(workflow, /inputs\.heavy_qualification == 'all'/u); - assert.match(workflow, /needs: \[prepare, test, develop_smoke, image_smoke\]/u); + assert.match(workflow, /needs: \[prepare, test, storybook, develop_smoke, image_smoke\]/u); + // Storybook runs beside the test job rather than inside it, so publication has to gate on it + // separately — otherwise a broken component contract would no longer block an image. + assert.match(workflow, /needs\.storybook\.result == 'success'/u); assert.match(developSmokeJob[0], /needs: prepare/u); assert.match(developSmokeJob[0], /needs\.prepare\.outputs\.develop_smoke_can_skip != 'true'/u); assert.match(workflow, /continue-on-error: true[\s\S]*?run: node scripts\/develop-smoke-baseline\.mjs/u); diff --git a/scripts/agent-style-check.sh b/scripts/agent-style-check.sh index 65db6a975..0ad9e16e9 100755 --- a/scripts/agent-style-check.sh +++ b/scripts/agent-style-check.sh @@ -52,12 +52,15 @@ fi # 2. Exclusions — tests, declarations, generated output, vendored code. Test # files follow looser rules; generated files are not hand-maintained. +# A .upstream/ directory holds a pinned copy of someone else's source (see the +# sibling VERSION file); it is replaced wholesale on the next sync, so our +# hand-written style rules must not apply to it. CHECKABLE=() INLINE_CHECKABLE=() for f in ${FILES[@]+"${FILES[@]}"}; do [[ -z "$f" || ! -f "$f" ]] && continue case "$f" in - *.d.ts|*node_modules*|*dist/*|*generated*) continue ;; + *.d.ts|*node_modules*|*dist/*|*generated*|*/.upstream/*) continue ;; esac INLINE_CHECKABLE+=("$f") case "$f" in @@ -81,6 +84,9 @@ _report() # FILES list since test files are otherwise excluded from CHECKABLE below. for f in ${FILES[@]+"${FILES[@]}"}; do [[ -z "$f" || ! -f "$f" ]] && continue + case "$f" in + */.upstream/*) continue ;; + esac case "$f" in *.test.ts) case "$f" in @@ -93,11 +99,12 @@ done # INLINE-CONDITIONAL — unlike the looser declaration/style rules below, conditional density applies # to production and test TypeScript alike. One physical line may contain at most one ternary. -for f in ${INLINE_CHECKABLE[@]+"${INLINE_CHECKABLE[@]}"}; do - while IFS=: read -r ln _; do - _report "$f" "$ln" ERROR INLINE-CONDITIONAL "more than one ternary conditional on one line — use an exhaustive lookup, switch, or helper" - done < <(node scripts/inline-conditional-check.mjs "$f") -done +# The whole list goes to one node call — starting a process per file dominated the runtime. +if [[ ${#INLINE_CHECKABLE[@]} -gt 0 ]]; then + while IFS=: read -r fname ln _; do + _report "$fname" "$ln" ERROR INLINE-CONDITIONAL "more than one ternary conditional on one line — use an exhaustive lookup, switch, or helper" + done < <(node scripts/inline-conditional-check.mjs "${INLINE_CHECKABLE[@]}") +fi # MISSING-README / README-SECTIONS — package docs (docs/agents/package-docs.md). # A changed package must ship a README, and a changed leaf-package README must @@ -147,110 +154,116 @@ if [[ ${#CHECKABLE[@]} -eq 0 ]]; then exit 0 fi -for f in "${CHECKABLE[@]}"; do +# Each rule below makes ONE pass over the whole file list instead of one pass per +# file. grep and awk both accept many paths, and starting a few thousand +# short-lived processes cost far more than the scanning itself. -H keeps the +# ":" prefix even when a single file is in scope, so parsing stays uniform. - # ARROW-FN — a statement-level `const x = (...) =>` is a declaration via - # arrow, which the rules forbid (arrows belong inside HOF callbacks only). - while IFS=: read -r ln _; do - _report "$f" "$ln" ERROR ARROW-FN "standalone arrow-function declaration — use a named function declaration" - done < <(grep -nE '^[[:space:]]*(export[[:space:]]+)?const[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*(:[^=]*)?=[[:space:]]*(async[[:space:]]+)?(\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)[[:space:]]*(:[^=]*)?=>' "$f" || true) +# Two rules run on a subset, so split the list once up front. +TYPES_CHECKABLE=() +VITEST_CONFIGS=() +for f in "${CHECKABLE[@]}"; do + # TYPES-IN-IMPL: a bare `types.ts` is a types file by intent — exempt. + case "$f" in + *.types.ts|*/types.ts|types.ts) : ;; + *) TYPES_CHECKABLE+=("$f") ;; + esac + case "$f" in + *vitest.config.ts) VITEST_CONFIGS+=("$f") ;; + esac +done - # MULTILINE-IMPORT — an import line that opens `{` without closing it. - while IFS=: read -r ln _; do - _report "$f" "$ln" ERROR MULTILINE-IMPORT "import split across lines — merge onto one line" - done < <(grep -nE '^import[[:space:]]+(type[[:space:]]+)?\{[^}]*$' "$f" || true) +# ARROW-FN — a statement-level `const x = (...) =>` is a declaration via +# arrow, which the rules forbid (arrows belong inside HOF callbacks only). +while IFS=: read -r fname ln _; do + _report "$fname" "$ln" ERROR ARROW-FN "standalone arrow-function declaration — use a named function declaration" +done < <(grep -HnE '^[[:space:]]*(export[[:space:]]+)?const[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*(:[^=]*)?=[[:space:]]*(async[[:space:]]+)?(\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)[[:space:]]*(:[^=]*)?=>' "${CHECKABLE[@]}" || true) - # MIDFILE-IMPORT / JSDOC / BRACE — need statefulness, so one awk pass. - while IFS=$'\t' read -r ln rule msg; do - level=ERROR - [[ "$rule" == "JSDOC" || "$rule" == "BRACE" ]] && level=WARN - _report "$f" "$ln" "$level" "$rule" "$msg" - done < <(awk ' - BEGIN { seen_code = 0; prev = "" } - { - line = $0 - trimmed = line - sub(/^[[:space:]]+/, "", trimmed) +# MULTILINE-IMPORT — an import line that opens `{` without closing it. +while IFS=: read -r fname ln _; do + _report "$fname" "$ln" ERROR MULTILINE-IMPORT "import split across lines — merge onto one line" +done < <(grep -HnE '^import[[:space:]]+(type[[:space:]]+)?\{[^}]*$' "${CHECKABLE[@]}" || true) - is_import = (trimmed ~ /^import[[:space:]]/) - is_blank_or_comment = (trimmed == "" || trimmed ~ /^\/\// || trimmed ~ /^\/\*/ || trimmed ~ /^\*/ || trimmed ~ /^"use / || trimmed ~ /^#!/) +# MIDFILE-IMPORT / JSDOC / BRACE — need statefulness, so one awk pass. +# FNR == 1 resets that state at each new file; FILENAME/FNR replace the per-file loop. +while IFS=$'\t' read -r fname ln rule msg; do + level=ERROR + [[ "$rule" == "JSDOC" || "$rule" == "BRACE" ]] && level=WARN + _report "$fname" "$ln" "$level" "$rule" "$msg" +done < <(awk ' + FNR == 1 { seen_code = 0; prev = "" } + { + line = $0 + trimmed = line + sub(/^[[:space:]]+/, "", trimmed) - # MIDFILE-IMPORT: an import after real code has started. - if (is_import && seen_code) - printf "%d\tMIDFILE-IMPORT\timport below the first non-import statement — move to top\n", NR - if (!is_import && !is_blank_or_comment) - seen_code = 1 + is_import = (trimmed ~ /^import[[:space:]]/) + is_blank_or_comment = (trimmed == "" || trimmed ~ /^\/\// || trimmed ~ /^\/\*/ || trimmed ~ /^\*/ || trimmed ~ /^"use / || trimmed ~ /^#!/) - # JSDOC: exported declaration must be directly preceded by a JSDoc close. - # A decorator between the JSDoc and the declaration is fine (prev is then - # the decorator itself or its closing "})"). - # (identifier required after the keyword so barrel re-exports like - # "export type { paths }" do not match) - if (trimmed ~ /^export[[:space:]]+(default[[:space:]]+)?(async[[:space:]]+)?(function|class|interface|type|const|enum)[[:space:]]+[A-Za-z_$]/ && prev !~ /\*\/[[:space:]]*$/ && prev !~ /^@/ && prev !~ /^\}\)/) - printf "%d\tJSDOC\texported declaration has no JSDoc directly above it\n", NR + # MIDFILE-IMPORT: an import after real code has started. + if (is_import && seen_code) + printf "%s\t%d\tMIDFILE-IMPORT\timport below the first non-import statement — move to top\n", FILENAME, FNR + if (!is_import && !is_blank_or_comment) + seen_code = 1 - # BRACE: multi-line function/class with { on the declaration line - # (single-line bodies are exempt — they close } on the same line). - if ((trimmed ~ /^(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$]/ || trimmed ~ /^(export[[:space:]]+)?(abstract[[:space:]]+)?class[[:space:]]+[A-Za-z_$]/) && trimmed ~ /\{[[:space:]]*$/ && trimmed !~ /\}/) - printf "%d\tBRACE\topening { should be on its own line (Allman) for multi-line declarations\n", NR + # JSDOC: exported declaration must be directly preceded by a JSDoc close. + # A decorator between the JSDoc and the declaration is fine (prev is then + # the decorator itself or its closing "})"). + # (identifier required after the keyword so barrel re-exports like + # "export type { paths }" do not match) + if (trimmed ~ /^export[[:space:]]+(default[[:space:]]+)?(async[[:space:]]+)?(function|class|interface|type|const|enum)[[:space:]]+[A-Za-z_$]/ && prev !~ /\*\/[[:space:]]*$/ && prev !~ /^@/ && prev !~ /^\}\)/) + printf "%s\t%d\tJSDOC\texported declaration has no JSDoc directly above it\n", FILENAME, FNR - if (!is_blank_or_comment || trimmed ~ /\*\/[[:space:]]*$/) - prev = trimmed - } - ' "$f") + # BRACE: multi-line function/class with { on the declaration line + # (single-line bodies are exempt — they close } on the same line). + if ((trimmed ~ /^(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$]/ || trimmed ~ /^(export[[:space:]]+)?(abstract[[:space:]]+)?class[[:space:]]+[A-Za-z_$]/) && trimmed ~ /\{[[:space:]]*$/ && trimmed !~ /\}/) + printf "%s\t%d\tBRACE\topening { should be on its own line (Allman) for multi-line declarations\n", FILENAME, FNR - # REL-IMPORT-EXT — the repo type-checks with moduleResolution "bundler" and ships - # esbuild bundles, so a relative import names the file on disk, not the compiled - # output. Run scripts/relative-import-extensions.mjs to fix a whole branch at once. - while IFS=: read -r ln _; do - _report "$f" "$ln" ERROR REL-IMPORT-EXT "relative import must not end in .js (bundler resolution)" - done < <(grep -nE '(from|import|require|vi\.(mock|doMock))[[:space:]]*\(?[[:space:]]*"\.\.?/[^"]*\.js"' "$f" || true) + if (!is_blank_or_comment || trimmed ~ /\*\/[[:space:]]*$/) + prev = trimmed + } +' "${CHECKABLE[@]}") - # PKG-IMPORT-EXT — @opencrane barrel specifiers must NOT carry .js. (Deep - # subpath imports of third-party packages, e.g. the MCP SDK, genuinely end - # in .js — only our own barrels are covered by the rule.) - while IFS=: read -r ln _; do - _report "$f" "$ln" ERROR PKG-IMPORT-EXT "@opencrane package specifier must not end in .js" - done < <(grep -nE 'from[[:space:]]+"@opencrane/[^"]+\.js"' "$f" || true) +# REL-IMPORT-EXT — the repo type-checks with moduleResolution "bundler" and ships +# esbuild bundles, so a relative import names the file on disk, not the compiled +# output. Run scripts/relative-import-extensions.mjs to fix a whole branch at once. +while IFS=: read -r fname ln _; do + _report "$fname" "$ln" ERROR REL-IMPORT-EXT "relative import must not end in .js (bundler resolution)" +done < <(grep -HnE '(from|import|require|vi\.(mock|doMock))[[:space:]]*\(?[[:space:]]*"\.\.?/[^"]*\.js"' "${CHECKABLE[@]}" || true) - # CONSOLE — shipped code logs via @opencrane/backend/observability. - case "$f" in - *) - while IFS=: read -r ln _; do - _report "$f" "$ln" ERROR CONSOLE "raw console.* — use the structured logger (@opencrane/backend/observability)" - done < <(grep -nE '(^|[^.[:alnum:]_])console\.(log|warn|error|info|debug)\(' "$f" || true) - ;; - esac +# PKG-IMPORT-EXT — @opencrane barrel specifiers must NOT carry .js. (Deep +# subpath imports of third-party packages, e.g. the MCP SDK, genuinely end +# in .js — only our own barrels are covered by the rule.) +while IFS=: read -r fname ln _; do + _report "$fname" "$ln" ERROR PKG-IMPORT-EXT "@opencrane package specifier must not end in .js" +done < <(grep -HnE 'from[[:space:]]+"@opencrane/[^"]+\.js"' "${CHECKABLE[@]}" || true) - # CATEGORICAL-LITERAL — an OpenCrane-owned kind/type/status/state/reason/mode/action/ - # outcome/decision branch should compare against a documented string-backed enum. - # External protocols and schema/data literals can look identical, so this remains a - # WARN for the reviewer to confirm rather than an automatic failure. - while IFS=: read -r ln _; do - _report "$f" "$ln" WARN CATEGORICAL-LITERAL "categorical property compared with a raw string — use the owning string-backed enum or verify an external/schema/data exemption" - done < <(grep -nE '(\.(kind|type|status|state|reason|mode|action|outcome|decision)[[:space:]]*(===|!==)[[:space:]]*"[^"]+"|"[^"]+"[[:space:]]*(===|!==)[[:space:]]*[^[:space:]]+\.(kind|type|status|state|reason|mode|action|outcome|decision))' "$f" || true) +# CONSOLE — shipped code logs via @opencrane/backend/observability. +while IFS=: read -r fname ln _; do + _report "$fname" "$ln" ERROR CONSOLE "raw console.* — use the structured logger (@opencrane/backend/observability)" +done < <(grep -HnE '(^|[^.[:alnum:]_])console\.(log|warn|error|info|debug)\(' "${CHECKABLE[@]}" || true) - # TYPES-IN-IMPL — exported interfaces/type aliases belong in *.types.ts. - # (A bare `types.ts` is a types file by intent — exempt.) - case "$f" in - *.types.ts|*/types.ts|types.ts) : ;; - *) - while IFS=: read -r ln _; do - _report "$f" "$ln" ERROR TYPES-IN-IMPL "exported interface/type outside *.types.ts — move to the paired types file" - done < <(grep -nE '^[[:space:]]*export[[:space:]]+(interface|type)[[:space:]]+[A-Za-z_$]' "$f" || true) - ;; - esac +# CATEGORICAL-LITERAL — an OpenCrane-owned kind/type/status/state/reason/mode/action/ +# outcome/decision branch should compare against a documented string-backed enum. +# External protocols and schema/data literals can look identical, so this remains a +# WARN for the reviewer to confirm rather than an automatic failure. +while IFS=: read -r fname ln _; do + _report "$fname" "$ln" WARN CATEGORICAL-LITERAL "categorical property compared with a raw string — use the owning string-backed enum or verify an external/schema/data exemption" +done < <(grep -HnE '(\.(kind|type|status|state|reason|mode|action|outcome|decision)[[:space:]]*(===|!==)[[:space:]]*"[^"]+"|"[^"]+"[[:space:]]*(===|!==)[[:space:]]*[^[:space:]]+\.(kind|type|status|state|reason|mode|action|outcome|decision))' "${CHECKABLE[@]}" || true) - # ROOT-CACHE — every vitest config must anchor its Vite cache at the repo root, - # or the dep optimizer spawns a stray node_modules/.vite inside the package. - case "$f" in - *vitest.config.ts) - if ! grep -q '_PackageCacheDir' "$f"; then - _report "$f" 1 ERROR ROOT-CACHE "vitest config without _PackageCacheDir cacheDir — caches must live under the root node_modules (see vitest.cache.ts)" - fi - ;; - esac +# TYPES-IN-IMPL — exported interfaces/type aliases belong in *.types.ts. +if [[ ${#TYPES_CHECKABLE[@]} -gt 0 ]]; then + while IFS=: read -r fname ln _; do + _report "$fname" "$ln" ERROR TYPES-IN-IMPL "exported interface/type outside *.types.ts — move to the paired types file" + done < <(grep -HnE '^[[:space:]]*export[[:space:]]+(interface|type)[[:space:]]+[A-Za-z_$]' "${TYPES_CHECKABLE[@]}" || true) +fi +# ROOT-CACHE — every vitest config must anchor its Vite cache at the repo root, +# or the dep optimizer spawns a stray node_modules/.vite inside the package. +for f in ${VITEST_CONFIGS[@]+"${VITEST_CONFIGS[@]}"}; do + if ! grep -q '_PackageCacheDir' "$f"; then + _report "$f" 1 ERROR ROOT-CACHE "vitest config without _PackageCacheDir cacheDir — caches must live under the root node_modules (see vitest.cache.ts)" + fi done # 3. Summary + exit code: ERROR findings fail the check; WARN findings are diff --git a/scripts/inline-conditional-check.mjs b/scripts/inline-conditional-check.mjs index 92f9cbe13..808d2ba04 100644 --- a/scripts/inline-conditional-check.mjs +++ b/scripts/inline-conditional-check.mjs @@ -23,17 +23,25 @@ export function inlineConditionalDensity(sourcePath, source) return [...counts.entries()].filter(function _Repeated(entry) { return entry[1] > 1; }).map(function _Line(entry) { return entry[0]; }).sort(function _Ascending(left, right) { return left - right; }); } -/** Print checker-compatible line coordinates when invoked as a command. */ -function _Main(sourcePath) +/** + * Print checker-compatible coordinates for every path given on the command line. + * The whole file list arrives in one call: loading the TypeScript compiler costs + * far more than parsing a file, so one process per file made this the slowest + * step in CI. + */ +function _Main(sourcePaths) { - const source = readFileSync(sourcePath, "utf8"); - for (const line of inlineConditionalDensity(sourcePath, source)) + for (const sourcePath of sourcePaths) { - process.stdout.write(`${line}:inline conditional density\n`); + const source = readFileSync(sourcePath, "utf8"); + for (const line of inlineConditionalDensity(sourcePath, source)) + { + process.stdout.write(`${sourcePath}:${line}:inline conditional density\n`); + } } } if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] !== undefined) { - _Main(process.argv[2]); + _Main(process.argv.slice(2)); }