From 835a0e68ae88a31ceeae072f4faddc99f1d5b191 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 10:11:28 -0700 Subject: [PATCH 1/6] docs(plans): Part C keeps cockpit-shell in the workspace libraries test list Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-09-04-docs-workspace-unification.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-04-docs-workspace-unification.md b/docs/superpowers/plans/2026-09-04-docs-workspace-unification.md index 7cbceda44..0f43147bc 100644 --- a/docs/superpowers/plans/2026-09-04-docs-workspace-unification.md +++ b/docs/superpowers/plans/2026-09-04-docs-workspace-unification.md @@ -804,7 +804,7 @@ In `scripts/ci-workflow.spec.mjs`: - In `'verifies every protected immutable preview with its own automation bypass'` (line 559): delete the `cockpitStep` lookup and every assertion on it, keep the Website assertions. - In `'requires both PR-side preview verifications through the scoped gate'` (line 721): rename to `'requires the Website preview verification through the scoped gate'`; delete the `cockpit-preview-smoke` needs assertion, the `RESULT_COCKPIT_PREVIEW_SMOKE` assertion, and the cockpit `require_preview` assertion. - In the exact-`needs` fixture (line ~1150): delete `'cockpit-deploy-smoke'` and `'cockpit-preview-smoke'`. -- In `'runs the cockpit sibling libraries that own vitest specs'` (line 933): the expected `run-many` project list becomes `cockpit-registry,workspace-react` (drop `cockpit` and `cockpit-docs`); read the test and adjust its assertion accordingly. +- In `'runs the cockpit sibling libraries that own vitest specs'` (line 933): the expected `run-many` project list becomes `cockpit-registry,cockpit-shell,workspace-react` (drop `cockpit` and `cockpit-docs`; `cockpit-shell` was added in Task A3); read the test and adjust its assertion accordingly. - In `'binds Vercel deploys to the renamed Threadplane projects'` (line ~732): delete the `threadplane-cockpit` assertion. - Add: @@ -829,7 +829,7 @@ Expected: `carries no cockpit redirect deployment` fails, plus any test that now - [ ] **Step 3: Edit `ci.yml`** - Delete jobs `cockpit-deploy-smoke` and `cockpit-preview-smoke` entirely. -- `cockpit` job: delete `- run: npx nx build cockpit --skip-nx-cache`; change the `run-many` projects to `cockpit-registry,workspace-react`; rename the job's `name` to `Workspace libraries — lint / test`. +- `cockpit` job: delete `- run: npx nx build cockpit --skip-nx-cache`; change the `run-many` projects to `cockpit-registry,cockpit-shell,workspace-react`; rename the job's `name` to `Workspace libraries — lint / test`. - `ci-scope` job outputs: delete `cockpit_deploy_smoke: ...`. - `required-pr-checks`: delete `- cockpit-deploy-smoke` and `- cockpit-preview-smoke` from `needs`; delete `RESULT_COCKPIT_DEPLOY_SMOKE`, `RESULT_COCKPIT_PREVIEW_SMOKE`, `SCOPE_COCKPIT_DEPLOY_SMOKE` from env; delete the `require_scoped "cockpit_deploy_smoke" ...` and `require_preview "cockpit_deploy_smoke" ...` lines. Keep `require_preview` and `PREVIEW_LANES_ELIGIBLE` for the Website lane. - `deploy` job: in `Detect deploy-relevant changes`, remove the `cockpit_changed` computation and the `cockpit=` output line; remove `|| steps.affected.outputs.cockpit == 'true'` from the `Cache Playwright browsers`, `Install Playwright browsers`, and `Verify deployed website` conditions; delete the seven steps from `Prepare cockpit Vercel project` through `Verify production cockpit redirects` (lines 1288–1347). From fd691d361337e3ecd5f5d6f917970d49da1d1a9f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 11:00:34 -0700 Subject: [PATCH 2/6] refactor: move the capability registry into cockpit-registry and example tooling into scripts/examples Co-Authored-By: Claude Fable 5.1 --- .github/workflows/deploy-ag-ui.yml | 2 +- .github/workflows/deploy-langgraph.yml | 2 +- apps/cockpit/ag-ui-agent-url.spec.ts | 4 +-- apps/cockpit/project.json | 30 +++++++++---------- .../docs/runtimes/aws-strands/quickstart.mdx | 2 +- .../docs/runtimes/mastra/quickstart.mdx | 2 +- .../microsoft-agent-framework/quickstart.mdx | 2 +- .../runtimes/aws-strands/python/docs/guide.md | 2 +- cockpit/runtimes/mastra/angular/docs/guide.md | 2 +- .../mastra/angular/e2e/global-setup-impl.ts | 2 +- .../python/docs/guide.md | 2 +- deployments/ag-ui-dev/README.md | 2 +- .../ag-ui-dev/deps/aws_strands/docs/guide.md | 2 +- .../microsoft_agent_framework/docs/guide.md | 2 +- deployments/ag-ui-mastra/README.md | 2 +- libs/cockpit-registry/src/index.ts | 1 + .../src/lib}/capability-registry.ts | 2 +- scripts/assemble-examples.ts | 2 +- scripts/ci-scope.spec.mjs | 16 ++++++++-- .../cockpit-runtime-bridge-coverage.spec.mjs | 2 +- .../examples/capability-wiring.spec.ts | 4 +-- .../examples/e2e-wiring.spec.ts | 2 +- .../examples}/generate-combined-langgraph.ts | 2 +- .../examples}/runtime-wiring-audit.ts | 0 .../examples}/serve-example.spec.ts | 2 +- .../examples}/serve-example.ts | 6 ++-- scripts/generate-ag-ui-deployment-config.ts | 2 +- scripts/generate-aimock-scaffold.ts | 6 ++-- scripts/generate-shared-deployment-config.ts | 2 +- scripts/record-aimock-cap.sh | 4 +-- scripts/verify-ag-ui-runtime.ts | 2 +- scripts/vite.config.mts | 2 +- 32 files changed, 64 insertions(+), 53 deletions(-) rename {apps/cockpit/scripts => libs/cockpit-registry/src/lib}/capability-registry.ts (99%) rename apps/cockpit/cockpit-capability-wiring.spec.ts => scripts/examples/capability-wiring.spec.ts (99%) rename apps/cockpit/cockpit-e2e-wiring.spec.ts => scripts/examples/e2e-wiring.spec.ts (99%) rename {apps/cockpit/scripts => scripts/examples}/generate-combined-langgraph.ts (96%) rename {apps/cockpit => scripts/examples}/runtime-wiring-audit.ts (100%) rename {apps/cockpit/scripts => scripts/examples}/serve-example.spec.ts (95%) rename {apps/cockpit/scripts => scripts/examples}/serve-example.ts (92%) diff --git a/.github/workflows/deploy-ag-ui.yml b/.github/workflows/deploy-ag-ui.yml index 9dbfb7d13..d7102c018 100644 --- a/.github/workflows/deploy-ag-ui.yml +++ b/.github/workflows/deploy-ag-ui.yml @@ -6,7 +6,7 @@ on: paths: - 'cockpit/ag-ui/**/python/**' - 'cockpit/runtimes/**/python/**' - - 'apps/cockpit/scripts/capability-registry.ts' + - 'libs/cockpit-registry/src/lib/capability-registry.ts' - 'scripts/generate-ag-ui-deployment-config.ts' - 'deployments/ag-ui-dev/**' workflow_dispatch: diff --git a/.github/workflows/deploy-langgraph.yml b/.github/workflows/deploy-langgraph.yml index 5ead692e0..26dc767b7 100644 --- a/.github/workflows/deploy-langgraph.yml +++ b/.github/workflows/deploy-langgraph.yml @@ -7,7 +7,7 @@ on: - 'cockpit/langgraph/**/python/**' - 'cockpit/deep-agents/**/python/**' - 'examples/chat/python/**' - - 'apps/cockpit/scripts/capability-registry.ts' + - 'libs/cockpit-registry/src/lib/capability-registry.ts' - 'scripts/generate-shared-deployment-config.ts' - 'deployments/shared-dev/langgraph.json' workflow_dispatch: diff --git a/apps/cockpit/ag-ui-agent-url.spec.ts b/apps/cockpit/ag-ui-agent-url.spec.ts index 96d8e0a5a..3ef74e080 100644 --- a/apps/cockpit/ag-ui-agent-url.spec.ts +++ b/apps/cockpit/ag-ui-agent-url.spec.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { capabilities } from './scripts/capability-registry'; -import { inspectRuntimeTargetSource } from './runtime-wiring-audit'; +import { capabilities } from '@threadplane/cockpit-registry'; +import { inspectRuntimeTargetSource } from '../../scripts/examples/runtime-wiring-audit'; /** * Guard for a failure mode production smoke cannot see. diff --git a/apps/cockpit/project.json b/apps/cockpit/project.json index 1ebaf75a1..47f9ed34d 100644 --- a/apps/cockpit/project.json +++ b/apps/cockpit/project.json @@ -59,98 +59,98 @@ "serve-streaming": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=streaming", + "command": "npx tsx scripts/examples/serve-example.ts --capability=streaming", "cwd": "." } }, "serve-persistence": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=persistence", + "command": "npx tsx scripts/examples/serve-example.ts --capability=persistence", "cwd": "." } }, "serve-interrupts": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=interrupts", + "command": "npx tsx scripts/examples/serve-example.ts --capability=interrupts", "cwd": "." } }, "serve-memory": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=memory", + "command": "npx tsx scripts/examples/serve-example.ts --capability=memory", "cwd": "." } }, "serve-durable-execution": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=durable-execution", + "command": "npx tsx scripts/examples/serve-example.ts --capability=durable-execution", "cwd": "." } }, "serve-subgraphs": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=subgraphs", + "command": "npx tsx scripts/examples/serve-example.ts --capability=subgraphs", "cwd": "." } }, "serve-time-travel": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=time-travel", + "command": "npx tsx scripts/examples/serve-example.ts --capability=time-travel", "cwd": "." } }, "serve-deployment-runtime": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=deployment-runtime", + "command": "npx tsx scripts/examples/serve-example.ts --capability=deployment-runtime", "cwd": "." } }, "serve-planning": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=da-planning", + "command": "npx tsx scripts/examples/serve-example.ts --capability=da-planning", "cwd": "." } }, "serve-filesystem": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=da-filesystem", + "command": "npx tsx scripts/examples/serve-example.ts --capability=da-filesystem", "cwd": "." } }, "serve-da-subagents": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=da-subagents", + "command": "npx tsx scripts/examples/serve-example.ts --capability=da-subagents", "cwd": "." } }, "serve-da-memory": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=da-memory", + "command": "npx tsx scripts/examples/serve-example.ts --capability=da-memory", "cwd": "." } }, "serve-skills": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --capability=da-skills", + "command": "npx tsx scripts/examples/serve-example.ts --capability=da-skills", "cwd": "." } }, "serve-all": { "executor": "nx:run-commands", "options": { - "command": "npx tsx apps/cockpit/scripts/serve-example.ts --all", + "command": "npx tsx scripts/examples/serve-example.ts --all", "cwd": "." } } @@ -170,7 +170,7 @@ "{workspaceRoot}/scripts/rate-limit.ts", "{workspaceRoot}/apps/cockpit/scripts/deploy-smoke.ts", "{workspaceRoot}/scripts/generate-shared-deployment-config.ts", - "{workspaceRoot}/apps/cockpit/scripts/capability-registry.ts" + "{workspaceRoot}/libs/cockpit-registry/src/lib/capability-registry.ts" ] } } diff --git a/apps/website/content/docs/runtimes/aws-strands/quickstart.mdx b/apps/website/content/docs/runtimes/aws-strands/quickstart.mdx index aa3fbd6a2..a40da4454 100644 --- a/apps/website/content/docs/runtimes/aws-strands/quickstart.mdx +++ b/apps/website/content/docs/runtimes/aws-strands/quickstart.mdx @@ -44,7 +44,7 @@ cp cockpit/runtimes/aws-strands/python/.env.example \ One command starts the Python backend and the Angular dev server together. ```bash -npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-strands +npx tsx scripts/examples/serve-example.ts --capability=rt-strands ``` The script runs `uv sync` in `cockpit/runtimes/aws-strands/python` on the way, so the first start takes longer than later ones. diff --git a/apps/website/content/docs/runtimes/mastra/quickstart.mdx b/apps/website/content/docs/runtimes/mastra/quickstart.mdx index ca93338e6..37db1623e 100644 --- a/apps/website/content/docs/runtimes/mastra/quickstart.mdx +++ b/apps/website/content/docs/runtimes/mastra/quickstart.mdx @@ -52,7 +52,7 @@ Export only the variables you need. A stray `AG_UI_INTERNAL_TOKEN` that disagree ```bash -npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-mastra +npx tsx scripts/examples/serve-example.ts --capability=rt-mastra ``` Or run the target directly: diff --git a/apps/website/content/docs/runtimes/microsoft-agent-framework/quickstart.mdx b/apps/website/content/docs/runtimes/microsoft-agent-framework/quickstart.mdx index 965bc7575..54ae8348b 100644 --- a/apps/website/content/docs/runtimes/microsoft-agent-framework/quickstart.mdx +++ b/apps/website/content/docs/runtimes/microsoft-agent-framework/quickstart.mdx @@ -50,7 +50,7 @@ Azure addresses models by the deployment name you chose in your resource, which ```bash -npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-maf +npx tsx scripts/examples/serve-example.ts --capability=rt-maf ``` The script runs `uv sync` in `cockpit/runtimes/microsoft-agent-framework/python` on the way, so the first start takes longer than later ones. diff --git a/cockpit/runtimes/aws-strands/python/docs/guide.md b/cockpit/runtimes/aws-strands/python/docs/guide.md index f15cc3de6..49aceed0b 100644 --- a/cockpit/runtimes/aws-strands/python/docs/guide.md +++ b/cockpit/runtimes/aws-strands/python/docs/guide.md @@ -39,7 +39,7 @@ exporter noise. See `.env.example`. ## Running locally ```sh -npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-strands +npx tsx scripts/examples/serve-example.ts --capability=rt-strands ``` Angular dev server on :4331, uvicorn backend on :5331 (`/agent`, health at diff --git a/cockpit/runtimes/mastra/angular/docs/guide.md b/cockpit/runtimes/mastra/angular/docs/guide.md index 7d48675aa..3c4a662ac 100644 --- a/cockpit/runtimes/mastra/angular/docs/guide.md +++ b/cockpit/runtimes/mastra/angular/docs/guide.md @@ -52,7 +52,7 @@ fork. ## Running locally ```sh -npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-mastra +npx tsx scripts/examples/serve-example.ts --capability=rt-mastra ``` Angular dev server on :4332. The serve script only auto-starts Python diff --git a/cockpit/runtimes/mastra/angular/e2e/global-setup-impl.ts b/cockpit/runtimes/mastra/angular/e2e/global-setup-impl.ts index f361f16fe..7af039acf 100644 --- a/cockpit/runtimes/mastra/angular/e2e/global-setup-impl.ts +++ b/cockpit/runtimes/mastra/angular/e2e/global-setup-impl.ts @@ -16,7 +16,7 @@ import { startAimock, type AimockHandle } from '@threadplane-internal/e2e-harnes const ports = portsFor('cockpit-runtimes-mastra-angular'); const angularProject = 'cockpit-runtimes-mastra-angular'; -// Parsed by apps/cockpit/cockpit-e2e-wiring.spec.ts (accepts backendCwd for +// Parsed by scripts/examples/e2e-wiring.spec.ts (accepts backendCwd for // Node-hosted backends alongside langgraphCwd/pythonCwd — keep the // `backendCwd: ''` literal shape). const wiring = { backendCwd: 'deployments/ag-ui-mastra' }; diff --git a/cockpit/runtimes/microsoft-agent-framework/python/docs/guide.md b/cockpit/runtimes/microsoft-agent-framework/python/docs/guide.md index 45f2db2b9..fbab59bd8 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/docs/guide.md +++ b/cockpit/runtimes/microsoft-agent-framework/python/docs/guide.md @@ -31,7 +31,7 @@ harness intercepts model calls). See `.env.example`. ## Running locally ```sh -npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-maf +npx tsx scripts/examples/serve-example.ts --capability=rt-maf ``` Angular dev server on :4330, uvicorn backend on :5330 (`/agent`, health at diff --git a/deployments/ag-ui-dev/README.md b/deployments/ag-ui-dev/README.md index 705cf6e0c..30f2582b9 100644 --- a/deployments/ag-ui-dev/README.md +++ b/deployments/ag-ui-dev/README.md @@ -9,7 +9,7 @@ Multi-topic FastAPI app hosting the cockpit `ag-ui/*` runtimes on Railway. | `Dockerfile`, `entrypoint.sh`, `railway.json`, `README.md` | hand-written | yes | | `server.py`, `requirements.txt`, `deps/` | generated by `scripts/generate-ag-ui-deployment-config.ts` | no | -The generator reads `apps/cockpit/scripts/capability-registry.ts`, filters to +The generator reads `libs/cockpit-registry/src/lib/capability-registry.ts`, filters to `product === 'ag-ui'` and `product === 'runtimes'` entries with a `pythonDir`, stages each into `deps//`, and writes one framework-appropriate mount (`add_langgraph_fastapi_endpoint`, `add_agent_framework_fastapi_endpoint`, or diff --git a/deployments/ag-ui-dev/deps/aws_strands/docs/guide.md b/deployments/ag-ui-dev/deps/aws_strands/docs/guide.md index f15cc3de6..49aceed0b 100644 --- a/deployments/ag-ui-dev/deps/aws_strands/docs/guide.md +++ b/deployments/ag-ui-dev/deps/aws_strands/docs/guide.md @@ -39,7 +39,7 @@ exporter noise. See `.env.example`. ## Running locally ```sh -npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-strands +npx tsx scripts/examples/serve-example.ts --capability=rt-strands ``` Angular dev server on :4331, uvicorn backend on :5331 (`/agent`, health at diff --git a/deployments/ag-ui-dev/deps/microsoft_agent_framework/docs/guide.md b/deployments/ag-ui-dev/deps/microsoft_agent_framework/docs/guide.md index 45f2db2b9..fbab59bd8 100644 --- a/deployments/ag-ui-dev/deps/microsoft_agent_framework/docs/guide.md +++ b/deployments/ag-ui-dev/deps/microsoft_agent_framework/docs/guide.md @@ -31,7 +31,7 @@ harness intercepts model calls). See `.env.example`. ## Running locally ```sh -npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-maf +npx tsx scripts/examples/serve-example.ts --capability=rt-maf ``` Angular dev server on :4330, uvicorn backend on :5330 (`/agent`, health at diff --git a/deployments/ag-ui-mastra/README.md b/deployments/ag-ui-mastra/README.md index 3291c4e69..472d20fed 100644 --- a/deployments/ag-ui-mastra/README.md +++ b/deployments/ag-ui-mastra/README.md @@ -47,7 +47,7 @@ AG_UI_INTERNAL_TOKEN=dev-local-token \ ``` Then in another terminal: `npx nx run cockpit-runtimes-mastra-angular:serve:cockpit --port 4332` -(or `npx tsx apps/cockpit/scripts/serve-example.ts --capability=rt-mastra`, +(or `npx tsx scripts/examples/serve-example.ts --capability=rt-mastra`, which starts the Angular side; this Node service must be started manually as above — the serve script only auto-starts Python backends). The example's `proxy.conf.mjs` forwards `/agent` → `http://localhost:5332/agent/mastra` and diff --git a/libs/cockpit-registry/src/index.ts b/libs/cockpit-registry/src/index.ts index 7cd44901f..4d5e2df9d 100644 --- a/libs/cockpit-registry/src/index.ts +++ b/libs/cockpit-registry/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/manifest.types'; export * from './lib/resolve-language'; export * from './lib/validate-manifest'; export * from './lib/workspace-resolution'; +export * from './lib/capability-registry'; diff --git a/apps/cockpit/scripts/capability-registry.ts b/libs/cockpit-registry/src/lib/capability-registry.ts similarity index 99% rename from apps/cockpit/scripts/capability-registry.ts rename to libs/cockpit-registry/src/lib/capability-registry.ts index 6c88291b8..81a7a00fb 100644 --- a/apps/cockpit/scripts/capability-registry.ts +++ b/libs/cockpit-registry/src/lib/capability-registry.ts @@ -1,4 +1,4 @@ -import type { RuntimeAdapter } from '@threadplane/cockpit-registry'; +import type { RuntimeAdapter } from './manifest.types'; /** * Single source of truth for all cockpit capability examples. diff --git a/scripts/assemble-examples.ts b/scripts/assemble-examples.ts index 50f11dd3e..c37cd5e0e 100644 --- a/scripts/assemble-examples.ts +++ b/scripts/assemble-examples.ts @@ -18,7 +18,7 @@ import { readFileSync, } from 'fs'; import { resolve } from 'path'; -import { capabilities as registryCapabilities } from '../apps/cockpit/scripts/capability-registry'; +import { capabilities as registryCapabilities } from '@threadplane/cockpit-registry'; import { GENERATED_RUNTIME_PARENT_ORIGINS_MODULE, generateRuntimeParentOriginPolicy, diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index 5bff24838..77dd27637 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -467,10 +467,20 @@ describe('classifyFromAffected — apps + fallback paths via namedInputs', () => assert.equal(scope.cockpit, false); }); - it('capability-registry.ts change marks apps/cockpit affected → all cockpit_*', () => { + it('capability-registry.ts change marks cockpit-registry affected → cockpit scopes', () => { const scope = classifyFromAffected( - ['apps/cockpit/scripts/capability-registry.ts'], - [{ name: 'cockpit', tags: COCKPIT_APP_TAGS }] + ['libs/cockpit-registry/src/lib/capability-registry.ts'], + [ + { + name: 'cockpit-registry', + tags: [ + 'scope:cockpit', + 'scope:cockpit-deploy-smoke', + 'scope:cockpit-e2e', + 'scope:cockpit-examples', + ], + }, + ] ); assert.equal(scope.cockpit, true); assert.equal(scope.cockpit_examples, true); diff --git a/scripts/cockpit-runtime-bridge-coverage.spec.mjs b/scripts/cockpit-runtime-bridge-coverage.spec.mjs index c555361ae..d38ad4461 100644 --- a/scripts/cockpit-runtime-bridge-coverage.spec.mjs +++ b/scripts/cockpit-runtime-bridge-coverage.spec.mjs @@ -7,7 +7,7 @@ import ts from 'typescript'; const REPO_ROOT = resolve(import.meta.dirname, '..'); const REGISTRY_PATH = join( REPO_ROOT, - 'apps/cockpit/scripts/capability-registry.ts', + 'libs/cockpit-registry/src/lib/capability-registry.ts', ); function unwrapExpression(expression) { diff --git a/apps/cockpit/cockpit-capability-wiring.spec.ts b/scripts/examples/capability-wiring.spec.ts similarity index 99% rename from apps/cockpit/cockpit-capability-wiring.spec.ts rename to scripts/examples/capability-wiring.spec.ts index 114d9173c..9810c10ff 100644 --- a/apps/cockpit/cockpit-capability-wiring.spec.ts +++ b/scripts/examples/capability-wiring.spec.ts @@ -1,8 +1,8 @@ import { + capabilities, capabilityModules, cockpitManifest, } from '@threadplane/cockpit-registry'; -import { capabilities } from './scripts/capability-registry'; import { auditRuntimeTargetSource, hasExactImportBinding, @@ -538,7 +538,7 @@ function auditThreadsRootProviders( /** * The cockpit site is assembled from three lists that nothing forced to agree: * - * - `apps/cockpit/scripts/capability-registry.ts` — what serve/build/deploy know about; + * - `libs/cockpit-registry/src/lib/capability-registry.ts` — what serve/build/deploy know about; * - `libs/cockpit-registry` `cockpitManifest` — what the Next route can resolve; * - registry-owned `capabilityModules` — what supplies a page's assets. * diff --git a/apps/cockpit/cockpit-e2e-wiring.spec.ts b/scripts/examples/e2e-wiring.spec.ts similarity index 99% rename from apps/cockpit/cockpit-e2e-wiring.spec.ts rename to scripts/examples/e2e-wiring.spec.ts index ecbf1f05f..6107a5652 100644 --- a/apps/cockpit/cockpit-e2e-wiring.spec.ts +++ b/scripts/examples/e2e-wiring.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { capabilities } from './scripts/capability-registry'; +import { capabilities } from '@threadplane/cockpit-registry'; // @ts-expect-error — .mjs ES module without .d.ts; the e2e tsconfig uses // allowJs:true but this top-level test file doesn't go through that config. // eslint-disable-next-line @nx/enforce-module-boundaries -- repo-root port registry is intentionally outside an Nx project. diff --git a/apps/cockpit/scripts/generate-combined-langgraph.ts b/scripts/examples/generate-combined-langgraph.ts similarity index 96% rename from apps/cockpit/scripts/generate-combined-langgraph.ts rename to scripts/examples/generate-combined-langgraph.ts index 9d178ffc9..d709d5792 100644 --- a/apps/cockpit/scripts/generate-combined-langgraph.ts +++ b/scripts/examples/generate-combined-langgraph.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from 'fs'; import { resolve } from 'path'; -import { capabilities } from './capability-registry'; +import { capabilities } from '@threadplane/cockpit-registry'; type LangGraphManifest = { graphs: Record; diff --git a/apps/cockpit/runtime-wiring-audit.ts b/scripts/examples/runtime-wiring-audit.ts similarity index 100% rename from apps/cockpit/runtime-wiring-audit.ts rename to scripts/examples/runtime-wiring-audit.ts diff --git a/apps/cockpit/scripts/serve-example.spec.ts b/scripts/examples/serve-example.spec.ts similarity index 95% rename from apps/cockpit/scripts/serve-example.spec.ts rename to scripts/examples/serve-example.spec.ts index 586046454..7d8614ac6 100644 --- a/apps/cockpit/scripts/serve-example.spec.ts +++ b/scripts/examples/serve-example.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { backendCommand, COCKPIT_RUNTIME_ENV, formatAllModeSummary } from './serve-example'; -import { capabilities, findCapability, type Capability } from './capability-registry'; +import { capabilities, findCapability, type Capability } from '@threadplane/cockpit-registry'; describe('backendCommand', () => { it('uses uvicorn on the registry pythonPort for AG-UI caps', () => { diff --git a/apps/cockpit/scripts/serve-example.ts b/scripts/examples/serve-example.ts similarity index 92% rename from apps/cockpit/scripts/serve-example.ts rename to scripts/examples/serve-example.ts index 0eea17bb7..5ed3e1650 100644 --- a/apps/cockpit/scripts/serve-example.ts +++ b/scripts/examples/serve-example.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcess } from 'child_process'; import { fileURLToPath } from 'node:url'; -import { capabilities, findCapability, type Capability } from './capability-registry'; +import { capabilities, findCapability, type Capability } from '@threadplane/cockpit-registry'; /** Empty base URL makes resolveRuntimeUrl fall through to http://localhost:. */ export const COCKPIT_RUNTIME_ENV = { NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL: '' } as const; @@ -27,8 +27,8 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { if (!capabilityArg && !allMode) { console.log('Usage:'); - console.log(' npx tsx apps/cockpit/scripts/serve-example.ts --capability=streaming'); - console.log(' npx tsx apps/cockpit/scripts/serve-example.ts --all'); + console.log(' npx tsx scripts/examples/serve-example.ts --capability=streaming'); + console.log(' npx tsx scripts/examples/serve-example.ts --all'); console.log('\nCapabilities:'); capabilities.forEach((c) => console.log(` ${c.id.padEnd(22)} port ${c.port} ${c.product}/${c.topic}`)); process.exit(0); diff --git a/scripts/generate-ag-ui-deployment-config.ts b/scripts/generate-ag-ui-deployment-config.ts index 9f198a7ec..e957dfb46 100644 --- a/scripts/generate-ag-ui-deployment-config.ts +++ b/scripts/generate-ag-ui-deployment-config.ts @@ -1,6 +1,6 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { resolve } from 'path'; -import { capabilities, type CapabilityFramework } from '../apps/cockpit/scripts/capability-registry'; +import { capabilities, type CapabilityFramework } from '@threadplane/cockpit-registry'; /** * Bridge-agent detection (langgraph topics only). diff --git a/scripts/generate-aimock-scaffold.ts b/scripts/generate-aimock-scaffold.ts index c4ed93fd7..e85c3c127 100644 --- a/scripts/generate-aimock-scaffold.ts +++ b/scripts/generate-aimock-scaffold.ts @@ -3,7 +3,7 @@ // Throwaway aimock scaffold generator. // Usage: npx tsx scripts/generate-aimock-scaffold.ts --cap // -// For a cap in apps/cockpit/scripts/capability-registry.ts with a pythonDir, +// For a cap in libs/cockpit-registry/src/lib/capability-registry.ts with a pythonDir, // creates the per-cap aimock e2e directory under // cockpit///angular/e2e/ (5 files), adds the e2e Nx target // to cockpit///angular/project.json, and appends a matrix @@ -14,7 +14,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { capabilities, type Capability } from '../apps/cockpit/scripts/capability-registry'; +import { capabilities, type Capability } from '@threadplane/cockpit-registry'; const REPO_ROOT = resolve(__dirname, '..'); @@ -36,7 +36,7 @@ function parseArgs(): { capId: string } { function findCap(capId: string): Capability { const cap = capabilities.find((c) => c.id === capId); - if (!cap) die(`cap "${capId}" not found in apps/cockpit/scripts/capability-registry.ts`); + if (!cap) die(`cap "${capId}" not found in libs/cockpit-registry/src/lib/capability-registry.ts`); if (!cap.pythonDir) die(`cap "${capId}" has no pythonDir (in-process cap not eligible for aimock e2e)`); if (cap.pythonPort === undefined) die(`cap "${capId}" has no pythonPort`); return cap; diff --git a/scripts/generate-shared-deployment-config.ts b/scripts/generate-shared-deployment-config.ts index 954694b86..1e3087b53 100644 --- a/scripts/generate-shared-deployment-config.ts +++ b/scripts/generate-shared-deployment-config.ts @@ -1,6 +1,6 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { capabilities } from '../apps/cockpit/scripts/capability-registry'; +import { capabilities } from '@threadplane/cockpit-registry'; type LangGraphManifest = { graphs: Record; diff --git a/scripts/record-aimock-cap.sh b/scripts/record-aimock-cap.sh index 8f7f2d75d..872c4a011 100755 --- a/scripts/record-aimock-cap.sh +++ b/scripts/record-aimock-cap.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # Generic aimock fixture recorder for a single cockpit cap. -# Reads cap metadata from apps/cockpit/scripts/capability-registry.ts via tsx, +# Reads cap metadata from libs/cockpit-registry/src/lib/capability-registry.ts via tsx, # drives one or more prompts through aimock --record mode, merges captured # fixtures into the cap's e2e/fixtures/.json. # @@ -27,7 +27,7 @@ cd "$REPO_ROOT" # Look up cap metadata via the registry. read -r CAP_PRODUCT CAP_TOPIC CAP_GRAPH CAP_PORT CAP_PYPORT CAP_PYDIR < <(npx tsx -e " -import { capabilities } from './apps/cockpit/scripts/capability-registry'; +import { capabilities } from '@threadplane/cockpit-registry'; const c = capabilities.find(x => x.id === '$CAP_ID'); if (!c) { console.error('cap not found: $CAP_ID'); process.exit(1); } if (!c.pythonDir || c.pythonPort === undefined) { console.error('cap missing pythonDir/pythonPort'); process.exit(1); } diff --git a/scripts/verify-ag-ui-runtime.ts b/scripts/verify-ag-ui-runtime.ts index 459d175e4..a08cf5571 100644 --- a/scripts/verify-ag-ui-runtime.ts +++ b/scripts/verify-ag-ui-runtime.ts @@ -29,7 +29,7 @@ * EXAMPLES_URL=https://examples.threadplane.ai npx tsx scripts/verify-ag-ui-runtime.ts */ import { pathToFileURL } from 'url'; -import { capabilities } from '../apps/cockpit/scripts/capability-registry'; +import { capabilities } from '@threadplane/cockpit-registry'; const EXAMPLES_URL = process.env['EXAMPLES_URL'] ?? 'https://examples.threadplane.ai'; const RAILWAY_URL = diff --git a/scripts/vite.config.mts b/scripts/vite.config.mts index cfbde0f72..e56eb6cee 100644 --- a/scripts/vite.config.mts +++ b/scripts/vite.config.mts @@ -15,7 +15,7 @@ export default defineConfig({ test: { environment: 'node', globals: true, - include: ['*.spec.ts', '*.spec.mjs'], + include: ['*.spec.ts', '*.spec.mjs', 'examples/**/*.spec.ts'], exclude: [ // node:test suites — run by ci.yml directly, not by vitest. 'ci-scope.spec.mjs', From 5b1733b18047e24a229458810e29f09a7a244cb7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 11:23:53 -0700 Subject: [PATCH 3/6] chore: delete the cockpit redirect service, legacy-path resolution, and empty scaffold libraries Co-Authored-By: Claude Fable 5.1 --- apps/cockpit/ag-ui-agent-url.spec.ts | 84 --- apps/cockpit/next.config.spec.ts | 14 - apps/cockpit/next.config.ts | 16 - apps/cockpit/package.json | 11 - apps/cockpit/project.json | 176 ------ apps/cockpit/public/.gitkeep | 1 - apps/cockpit/scripts/deploy-smoke.spec.ts | 499 ----------------- apps/cockpit/scripts/deploy-smoke.ts | 518 ------------------ .../scripts/manual-spec-freshness.spec.ts | 92 ---- apps/cockpit/scripts/vercel-config.spec.ts | 40 -- .../cockpit/src/app/[[...slug]]/route.spec.ts | 193 ------- apps/cockpit/src/app/[[...slug]]/route.ts | 20 - .../cockpit/src/app/favicon.ico/route.spec.ts | 89 --- apps/cockpit/src/app/favicon.ico/route.ts | 12 - apps/cockpit/src/app/icon.svg | 6 - apps/cockpit/src/app/layout.tsx | 13 - apps/cockpit/src/lib/cockpit-page.spec.ts | 310 ----------- apps/cockpit/src/lib/cockpit-page.ts | 98 ---- .../src/lib/verify-shared-deployment.spec.ts | 21 - apps/cockpit/tsconfig.json | 22 - apps/cockpit/vite.config.mts | 37 -- .../e2e/platform-production-smoke.spec.ts | 70 +-- .../workspace/WebsiteWorkspace.spec.tsx | 4 - .../components/workspace/WebsiteWorkspace.tsx | 1 - .../src/lib/cockpit-retirement.spec.ts | 1 - apps/website/tsconfig.json | 1 - libs/cockpit-docs/package.json | 16 - libs/cockpit-docs/project.json | 34 -- libs/cockpit-docs/src/index.ts | 3 - .../cockpit-docs/src/lib/docs-adapter-home.ts | 8 - libs/cockpit-docs/src/lib/docs-bundle.spec.ts | 119 ---- libs/cockpit-docs/src/lib/docs-bundle.ts | 112 ---- .../src/lib/docs-mapping-contracts.ts | 18 - libs/cockpit-docs/tsconfig.json | 14 - libs/cockpit-docs/tsconfig.lib.json | 11 - libs/cockpit-docs/vite.config.mts | 11 - libs/cockpit-registry/src/lib/manifest.ts | 2 - .../src/lib/manifest.types.ts | 4 - .../src/lib/validate-manifest.spec.ts | 44 +- .../src/lib/validate-manifest.ts | 26 - .../src/lib/workspace-resolution.spec.ts | 125 +---- .../src/lib/workspace-resolution.ts | 57 +- .../src/lib/workspace-content.spec.ts | 7 +- .../src/lib/workspace-presentation.spec.ts | 6 +- libs/cockpit-testing/package.json | 16 - libs/cockpit-testing/project.json | 25 - libs/cockpit-testing/src/index.ts | 2 - .../src/lib/smoke-assertion-contracts.ts | 15 - .../src/lib/smoke-harness-home.ts | 6 - libs/cockpit-testing/tsconfig.json | 15 - libs/cockpit-testing/tsconfig.lib.json | 11 - libs/cockpit-ui/package.json | 16 - libs/cockpit-ui/project.json | 25 - libs/cockpit-ui/src/index.ts | 2 - .../src/lib/ui-primitive-contracts.ts | 14 - libs/cockpit-ui/src/lib/ui-primitive-home.ts | 6 - libs/cockpit-ui/tsconfig.json | 15 - libs/cockpit-ui/tsconfig.lib.json | 11 - .../src/lib/public-api.spec.tsx | 1 - .../src/lib/workspace-provider.spec.tsx | 27 +- .../src/lib/workspace-provider.tsx | 18 +- .../src/lib/workspace-shell.spec.tsx | 10 +- package-lock.json | 40 -- scripts/ag-ui-proxy.ts | 1 - scripts/examples-middleware.ts | 1 - scripts/examples/capability-wiring.spec.ts | 38 -- scripts/examples/e2e-wiring.spec.ts | 2 - .../generate-runtime-parent-origins.spec.ts | 6 - tsconfig.base.json | 3 - tsconfig.json | 3 - vercel.cockpit.json | 13 - 71 files changed, 61 insertions(+), 3247 deletions(-) delete mode 100644 apps/cockpit/ag-ui-agent-url.spec.ts delete mode 100644 apps/cockpit/next.config.spec.ts delete mode 100644 apps/cockpit/next.config.ts delete mode 100644 apps/cockpit/package.json delete mode 100644 apps/cockpit/project.json delete mode 100644 apps/cockpit/public/.gitkeep delete mode 100644 apps/cockpit/scripts/deploy-smoke.spec.ts delete mode 100644 apps/cockpit/scripts/deploy-smoke.ts delete mode 100644 apps/cockpit/scripts/manual-spec-freshness.spec.ts delete mode 100644 apps/cockpit/scripts/vercel-config.spec.ts delete mode 100644 apps/cockpit/src/app/[[...slug]]/route.spec.ts delete mode 100644 apps/cockpit/src/app/[[...slug]]/route.ts delete mode 100644 apps/cockpit/src/app/favicon.ico/route.spec.ts delete mode 100644 apps/cockpit/src/app/favicon.ico/route.ts delete mode 100644 apps/cockpit/src/app/icon.svg delete mode 100644 apps/cockpit/src/app/layout.tsx delete mode 100644 apps/cockpit/src/lib/cockpit-page.spec.ts delete mode 100644 apps/cockpit/src/lib/cockpit-page.ts delete mode 100644 apps/cockpit/src/lib/verify-shared-deployment.spec.ts delete mode 100644 apps/cockpit/tsconfig.json delete mode 100644 apps/cockpit/vite.config.mts delete mode 100644 libs/cockpit-docs/package.json delete mode 100644 libs/cockpit-docs/project.json delete mode 100644 libs/cockpit-docs/src/index.ts delete mode 100644 libs/cockpit-docs/src/lib/docs-adapter-home.ts delete mode 100644 libs/cockpit-docs/src/lib/docs-bundle.spec.ts delete mode 100644 libs/cockpit-docs/src/lib/docs-bundle.ts delete mode 100644 libs/cockpit-docs/src/lib/docs-mapping-contracts.ts delete mode 100644 libs/cockpit-docs/tsconfig.json delete mode 100644 libs/cockpit-docs/tsconfig.lib.json delete mode 100644 libs/cockpit-docs/vite.config.mts delete mode 100644 libs/cockpit-testing/package.json delete mode 100644 libs/cockpit-testing/project.json delete mode 100644 libs/cockpit-testing/src/index.ts delete mode 100644 libs/cockpit-testing/src/lib/smoke-assertion-contracts.ts delete mode 100644 libs/cockpit-testing/src/lib/smoke-harness-home.ts delete mode 100644 libs/cockpit-testing/tsconfig.json delete mode 100644 libs/cockpit-testing/tsconfig.lib.json delete mode 100644 libs/cockpit-ui/package.json delete mode 100644 libs/cockpit-ui/project.json delete mode 100644 libs/cockpit-ui/src/index.ts delete mode 100644 libs/cockpit-ui/src/lib/ui-primitive-contracts.ts delete mode 100644 libs/cockpit-ui/src/lib/ui-primitive-home.ts delete mode 100644 libs/cockpit-ui/tsconfig.json delete mode 100644 libs/cockpit-ui/tsconfig.lib.json delete mode 100644 vercel.cockpit.json diff --git a/apps/cockpit/ag-ui-agent-url.spec.ts b/apps/cockpit/ag-ui-agent-url.spec.ts deleted file mode 100644 index 3ef74e080..000000000 --- a/apps/cockpit/ag-ui-agent-url.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { capabilities } from '@threadplane/cockpit-registry'; -import { inspectRuntimeTargetSource } from '../../scripts/examples/runtime-wiring-audit'; - -/** - * Guard for a failure mode production smoke cannot see. - * - * `scripts/assemble-examples.ts` rewrites each cockpit app's `` to - * `///`, and the Vercel route table only proxies - * `^/ag-ui/([^/]+)/agent(/.*)?$` to the Railway runtime. An app that hardcodes - * a root-absolute `'/agent'` therefore requests `https:///agent`, which - * matches no route, falls through the filesystem handle and lands on the 404 - * catch-all — while still serving a perfectly healthy 200 index.html, so every - * page-reachability assertion keeps passing. - * - * Local dev is not a safety net either: dev serves with ``, so - * the literal and the base-relative form resolve identically to `/agent` and - * proxy.conf.mjs forwards both. - * - * The base-relative form is the only one that survives the base-href rewrite. - */ -const AGENT_URL_EXPR = "new URL('agent', document.baseURI).pathname"; - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); - -/** - * Both products are served by the same aggregated AG-UI runtime and both get - * their rewritten at assemble time, so both are exposed to this. - */ -const agentBackedCapabilities = capabilities.filter( - (c) => c.product === 'ag-ui' || c.product === 'runtimes' -); - -describe('AG-UI agent URL is resolved against ', () => { - it('covers every registered agent-backed capability', () => { - expect(agentBackedCapabilities.length).toBeGreaterThan(0); - }); - - for (const cap of agentBackedCapabilities) { - it(`${cap.product}/${cap.topic} resolves its agent URL relative to the base href`, () => { - const angularRoot = join( - repoRoot, - 'cockpit', - cap.product, - cap.topic, - 'angular/src' - ); - const configPath = join(angularRoot, 'app/app.config.ts'); - const configSource = readFileSync(configPath, 'utf8'); - const entryPoints = ['main.ts', 'main.cockpit.ts'].map((fileName) => ({ - path: join(angularRoot, fileName), - source: readFileSync(join(angularRoot, fileName), 'utf8'), - })); - - for (const entryPoint of entryPoints) { - const bootstrapCalls = inspectRuntimeTargetSource( - entryPoint.source, - entryPoint.path, - 'ag-ui' - ).bootstrapCalls; - expect( - bootstrapCalls, - `${entryPoint.path} must supply sharedUrl with ${AGENT_URL_EXPR} so it ` + - `resolves under the deployed ` - ).toHaveLength(1); - expect(bootstrapCalls[0].runtimeProperties['sharedUrl']).toBe( - AGENT_URL_EXPR - ); - } - const providerCalls = inspectRuntimeTargetSource( - configSource, - configPath, - 'ag-ui' - ).providerCalls; - expect( - providerCalls, - `${configPath} must source the runtime URL from the generation-scoped connection` - ).toHaveLength(1); - expect(providerCalls[0].properties['url']).toBe('connection.url'); - }); - } -}); diff --git a/apps/cockpit/next.config.spec.ts b/apps/cockpit/next.config.spec.ts deleted file mode 100644 index e5b7a15d6..000000000 --- a/apps/cockpit/next.config.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { nextConfig as config } from './next.config'; - -describe('cockpit next.config', () => { - it('keeps exact trailing-slash paths visible to the redirect route', () => { - expect(config.skipTrailingSlashRedirect).toBe(true); - }); - - it('does not retain interactive shell rewrites, headers, or content tracing', () => { - expect(config.rewrites).toBeUndefined(); - expect(config.headers).toBeUndefined(); - expect(config.outputFileTracingIncludes).toBeUndefined(); - }); -}); diff --git a/apps/cockpit/next.config.ts b/apps/cockpit/next.config.ts deleted file mode 100644 index 05551f7ea..000000000 --- a/apps/cockpit/next.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { composePlugins, withNx } from '@nx/next'; -import type { WithNxOptions } from '@nx/next/plugins/with-nx'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const cockpitAppDir = dirname(fileURLToPath(import.meta.url)); - -export const nextConfig: WithNxOptions = { - nx: {}, - outputFileTracingRoot: join(cockpitAppDir, '../..'), - skipTrailingSlashRedirect: true, -}; - -const plugins = [withNx]; - -export default composePlugins(...plugins)(nextConfig); diff --git a/apps/cockpit/package.json b/apps/cockpit/package.json deleted file mode 100644 index 365f40570..000000000 --- a/apps/cockpit/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "cockpit", - "version": "0.0.1", - "private": true, - "dependencies": { - "@threadplane/cockpit-registry": "*", - "next": "~16.1.6", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } -} diff --git a/apps/cockpit/project.json b/apps/cockpit/project.json deleted file mode 100644 index 47f9ed34d..000000000 --- a/apps/cockpit/project.json +++ /dev/null @@ -1,176 +0,0 @@ -{ - "name": "cockpit", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "apps/cockpit/src", - "projectType": "application", - "tags": [ - "scope:cockpit", - "scope:cockpit-deploy-smoke", - "scope:cockpit-examples", - "type:app" - ], - "targets": { - "build": { - "executor": "@nx/next:build", - "outputs": ["{options.outputPath}"], - "defaultConfiguration": "production", - "options": { - "outputPath": "dist/apps/cockpit" - }, - "configurations": { - "development": { - "outputPath": "dist/apps/cockpit" - }, - "production": { - "outputPath": "dist/apps/cockpit" - } - }, - "inputs": ["default", "deploymentConfig", "^default"] - }, - "serve": { - "executor": "@nx/next:server", - "defaultConfiguration": "development", - "options": { - "buildTarget": "cockpit:build", - "dev": true, - "port": 4201 - }, - "configurations": { - "development": { - "buildTarget": "cockpit:build:development", - "dev": true - }, - "production": { - "buildTarget": "cockpit:build:production", - "dev": false - } - } - }, - "test": { - "executor": "@nx/vitest:test", - "options": { - "configFile": "apps/cockpit/vite.config.mts" - } - }, - "lint": { - "executor": "@nx/eslint:lint", - "outputs": ["{options.outputFile}"] - }, - "serve-streaming": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=streaming", - "cwd": "." - } - }, - "serve-persistence": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=persistence", - "cwd": "." - } - }, - "serve-interrupts": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=interrupts", - "cwd": "." - } - }, - "serve-memory": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=memory", - "cwd": "." - } - }, - "serve-durable-execution": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=durable-execution", - "cwd": "." - } - }, - "serve-subgraphs": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=subgraphs", - "cwd": "." - } - }, - "serve-time-travel": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=time-travel", - "cwd": "." - } - }, - "serve-deployment-runtime": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=deployment-runtime", - "cwd": "." - } - }, - "serve-planning": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=da-planning", - "cwd": "." - } - }, - "serve-filesystem": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=da-filesystem", - "cwd": "." - } - }, - "serve-da-subagents": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=da-subagents", - "cwd": "." - } - }, - "serve-da-memory": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=da-memory", - "cwd": "." - } - }, - "serve-skills": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --capability=da-skills", - "cwd": "." - } - }, - "serve-all": { - "executor": "nx:run-commands", - "options": { - "command": "npx tsx scripts/examples/serve-example.ts --all", - "cwd": "." - } - } - }, - "namedInputs": { - "deploymentConfig": [ - "{workspaceRoot}/vercel.cockpit.json", - "{workspaceRoot}/vercel.examples.json", - "{workspaceRoot}/vercel.demo.json", - "{workspaceRoot}/scripts/assemble-demo.ts", - "{workspaceRoot}/scripts/assemble-examples.ts", - "{workspaceRoot}/scripts/generate-runtime-parent-origins.ts", - "{workspaceRoot}/runtime-parent-origins.json", - "{workspaceRoot}/libs/cockpit-runtime-bridge/src/lib/generated-runtime-parent-origins.ts", - "{workspaceRoot}/scripts/demo-middleware.ts", - "{workspaceRoot}/scripts/langgraph-proxy.ts", - "{workspaceRoot}/scripts/rate-limit.ts", - "{workspaceRoot}/apps/cockpit/scripts/deploy-smoke.ts", - "{workspaceRoot}/scripts/generate-shared-deployment-config.ts", - "{workspaceRoot}/libs/cockpit-registry/src/lib/capability-registry.ts" - ] - } -} diff --git a/apps/cockpit/public/.gitkeep b/apps/cockpit/public/.gitkeep deleted file mode 100644 index 8b1378917..000000000 --- a/apps/cockpit/public/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/cockpit/scripts/deploy-smoke.spec.ts b/apps/cockpit/scripts/deploy-smoke.spec.ts deleted file mode 100644 index 1b76ce1a1..000000000 --- a/apps/cockpit/scripts/deploy-smoke.spec.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { cockpitManifest } from '@threadplane/cockpit-registry'; -import { createServer } from 'node:http'; -import { describe, expect, it, vi } from 'vitest'; -import { - RAW_MALFORMED_REQUEST_TARGETS, - buildRedirectSmokeCases, - parseDeploySmokeArgs, - requestExactTarget, - runDeploySmoke, - type RedirectSmokeRequest, - type RedirectSmokeResponse, -} from './deploy-smoke'; - -const previewUrl = 'https://immutable-preview.vercel.app'; - -const responseFor = ( - request: RedirectSmokeRequest, - cases = buildRedirectSmokeCases('preview') -): RedirectSmokeResponse => { - const smokeCase = cases.find( - (candidate) => - candidate.path === request.path && - JSON.stringify(candidate.headers ?? {}) === - JSON.stringify(request.headers ?? {}) - ); - if (!smokeCase) throw new Error(`Unexpected request ${request.path}`); - return { - status: smokeCase.expectedStatus, - headers: smokeCase.expectedLocation - ? { location: smokeCase.expectedLocation } - : {}, - }; -}; - -describe('redirect deploy smoke contract', () => { - it('parses explicit preview and production modes', () => { - expect( - parseDeploySmokeArgs([ - '--url', - previewUrl, - '--mode', - 'preview', - '--dry-run', - '--retries', - '5', - '--retry-delay-ms', - '1000', - ]) - ).toEqual({ - url: previewUrl, - mode: 'preview', - dryRun: true, - retries: 5, - retryDelayMs: 1000, - }); - - expect( - parseDeploySmokeArgs([ - '--url', - 'https://cockpit.threadplane.ai', - '--mode', - 'production', - ]).mode - ).toBe('production'); - expect(() => parseDeploySmokeArgs(['--mode', 'other'])).toThrow( - /--mode must be preview or production/ - ); - }); - - it('enumerates every registry path and mode in exhaustive preview mode', () => { - const cases = buildRedirectSmokeCases('preview'); - - for (const entry of cockpitManifest) { - expect( - cases.some( - (smokeCase) => - smokeCase.path === entry.legacyPath && - smokeCase.expectedStatus === 308 - ), - `${entry.id} missing default redirect probe` - ).toBe(true); - for (const mode of entry.availableModes) { - expect( - cases.some( - (smokeCase) => - smokeCase.path === - `${entry.legacyPath}?mode=${mode.toLowerCase()}` && - smokeCase.expectedStatus === 308 - ), - `${entry.id} missing ${mode} redirect probe` - ).toBe(true); - } - expect( - cases.some( - (smokeCase) => smokeCase.path === `${entry.legacyPath}?mode=invalid` - ), - `${entry.id} missing invalid-mode probe` - ).toBe(true); - expect( - cases.some( - (smokeCase) => - smokeCase.path === `${entry.legacyPath}?mode=docs&mode=run` - ), - `${entry.id} missing duplicate-mode probe` - ).toBe(true); - } - - const rootLocation = - 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run'; - for (const path of [ - '/', - '/?mode=docs', - '/?mode=run', - '/?mode=code', - '/?mode=api', - '/?mode=invalid', - '/?mode=docs&mode=run', - '/?return_to=https%3A%2F%2Fattacker.test&utm_source=legacy', - ]) { - expect(cases).toContainEqual( - expect.objectContaining({ - path, - expectedStatus: 308, - expectedLocation: rootLocation, - }) - ); - } - expect( - cases.some((smokeCase) => smokeCase.name.includes('unavailable mode')) - ).toBe(true); - expect( - cases.some( - (smokeCase) => - smokeCase.name.includes('unrelated query') && - !smokeCase.expectedLocation?.includes('return_to') - ) - ).toBe(true); - expect( - cases.every( - (smokeCase) => - !Object.keys(smokeCase.headers ?? {}).some( - (header) => header.toLowerCase() === 'host' - ) - ) - ).toBe(true); - - expect(cases).toContainEqual( - expect.objectContaining({ - name: 'hostile forwarding headers ignored', - path: '/langgraph/core-capabilities/streaming/overview/python', - headers: { - forwarded: 'host=attacker.test;proto=http', - 'x-forwarded-host': 'attacker.test', - 'x-forwarded-proto': 'http', - referer: 'https://attacker.test/redirect', - }, - expectedStatus: 308, - expectedLocation: - 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run', - }) - ); - }); - - it('sends every malformed request target as an exact 404 probe', () => { - const cases = buildRedirectSmokeCases('preview'); - - expect(RAW_MALFORMED_REQUEST_TARGETS).toEqual( - expect.arrayContaining([ - expect.stringContaining('//'), - expect.stringContaining('\\'), - expect.stringContaining('/./'), - expect.stringContaining('/../'), - expect.stringContaining('%2e'), - expect.stringContaining('%2F'), - ]) - ); - for (const path of RAW_MALFORMED_REQUEST_TARGETS) { - expect(cases).toContainEqual( - expect.objectContaining({ - path, - expectedStatus: 404, - raw: true, - }) - ); - } - }); - - it('keeps production mode representative and includes raw canaries', () => { - const cases = buildRedirectSmokeCases('production'); - - expect(cases.length).toBeLessThan( - buildRedirectSmokeCases('preview').length - ); - for (const label of [ - 'root', - 'Docs-backed', - 'unknown', - 'favicon', - 'raw malformed', - ]) { - expect( - cases.some((smokeCase) => smokeCase.name.includes(label)), - `missing ${label}` - ).toBe(true); - } - }); - - it('uses the injected low-level transport without normalizing request targets', async () => { - const cases = buildRedirectSmokeCases('preview'); - const requestImpl = vi.fn(async (request: RedirectSmokeRequest) => - responseFor(request, cases) - ); - - await expect( - runDeploySmoke({ - url: previewUrl, - mode: 'preview', - requestImpl, - }) - ).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`); - - for (const path of RAW_MALFORMED_REQUEST_TARGETS) { - expect(requestImpl).toHaveBeenCalledWith( - expect.objectContaining({ origin: previewUrl, path }) - ); - } - }); - - it('writes malformed paths unchanged onto the Node HTTP request line', async () => { - const received: string[] = []; - const server = createServer((request, response) => { - received.push(request.url ?? ''); - response.statusCode = 404; - response.end(); - }); - await new Promise((resolvePromise) => - server.listen(0, '127.0.0.1', resolvePromise) - ); - - try { - const address = server.address(); - if (!address || typeof address === 'string') { - throw new Error('Expected a TCP test server address'); - } - const origin = `http://127.0.0.1:${address.port}`; - for (const path of RAW_MALFORMED_REQUEST_TARGETS) { - await requestExactTarget({ origin, path }); - } - expect(received).toEqual(RAW_MALFORMED_REQUEST_TARGETS); - } finally { - await new Promise((resolvePromise, reject) => - server.close((error) => (error ? reject(error) : resolvePromise())) - ); - } - }); - - it('reports a deterministic contract mismatch immediately without retrying', async () => { - const requestImpl = vi.fn(async () => ({ status: 200, headers: {} })); - const sleep = vi.fn(); - - await expect( - runDeploySmoke({ - url: previewUrl, - mode: 'preview', - retries: 4, - requestImpl, - sleep, - }) - ).rejects.toThrow(/preview.*root.*expected 308.*received 200/i); - expect(requestImpl).toHaveBeenCalledTimes(1); - expect(sleep).not.toHaveBeenCalled(); - }); - - it('retries transport failures and identifies the failing raw case', async () => { - const cases = buildRedirectSmokeCases('production'); - const rawCase = cases.find((smokeCase) => smokeCase.raw); - if (!rawCase) throw new Error('Expected production raw canary'); - - const requestImpl = vi.fn(async (request: RedirectSmokeRequest) => { - if (request.path === rawCase.path) { - throw new Error('socket reset'); - } - return responseFor(request, cases); - }); - const sleep = vi.fn().mockResolvedValue(undefined); - - await expect( - runDeploySmoke({ - url: 'https://cockpit.threadplane.ai', - mode: 'production', - retries: 1, - retryDelayMs: 1, - requestImpl, - sleep, - }) - ).rejects.toThrow( - new RegExp(`production.*${rawCase.name}.*socket reset`, 'i') - ); - expect( - requestImpl.mock.calls.filter( - ([request]) => request.path === rawCase.path - ) - ).toHaveLength(2); - expect(sleep).toHaveBeenCalledTimes(1); - }); - - it('identifies the raw-path rejection route when a raw canary is normalized', async () => { - const cases = buildRedirectSmokeCases('production'); - const requestImpl = vi.fn(async (request: RedirectSmokeRequest) => { - const smokeCase = cases.find( - (candidate) => candidate.path === request.path - ); - if (!smokeCase) throw new Error(`Unexpected request ${request.path}`); - if (smokeCase.raw) return { status: 308, headers: {} }; - return responseFor(request, cases); - }); - - await expect( - runDeploySmoke({ - url: 'https://cockpit.threadplane.ai', - mode: 'production', - requestImpl, - }) - ).rejects.toThrow(/vercel\.cockpit\.json/); - }); - - it('accepts only the platform same-origin slash collapse for a consecutive-slash probe', async () => { - // Vercel's CDN collapses consecutive slashes and answers 308 to the - // single-slash path on the same origin before any route, rewrite, or - // function runs, so that probe can never reach the 404 route. The only - // acceptable non-404 answer is that exact normalization; a redirect off - // the deployment from a malformed path is still a contract failure. - const cases = buildRedirectSmokeCases('preview'); - const slashCase = cases.find( - (smokeCase) => smokeCase.raw && smokeCase.path.includes('//') - ); - const dotCase = cases.find( - (smokeCase) => smokeCase.raw && smokeCase.path.includes('/./') - ); - if (!slashCase || !dotCase) throw new Error('Expected raw canaries'); - expect(slashCase.platformNormalizedPath).toBe( - '/langgraph/core-capabilities/streaming/overview/python' - ); - expect(dotCase.platformNormalizedPath).toBeUndefined(); - - const impl = (answer: (request: RedirectSmokeRequest) => RedirectSmokeResponse | null) => - vi.fn(async (request: RedirectSmokeRequest) => - answer(request) ?? responseFor(request, cases) - ); - - await expect( - runDeploySmoke({ - url: previewUrl, - mode: 'preview', - requestImpl: impl((request) => - request.path === slashCase.path - ? { - status: 308, - // The platform answers with a relative Location. - headers: { - location: - '/langgraph/core-capabilities/streaming/overview/python', - }, - } - : null - ), - }) - ).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`); - - await expect( - runDeploySmoke({ - url: previewUrl, - mode: 'preview', - requestImpl: impl((request) => - request.path === slashCase.path - ? { - status: 308, - headers: { - location: `${previewUrl}/langgraph/core-capabilities/streaming/overview/python`, - }, - } - : null - ), - }) - ).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`); - - await expect( - runDeploySmoke({ - url: previewUrl, - mode: 'preview', - requestImpl: impl((request) => - request.path === slashCase.path - ? { - status: 308, - headers: { - location: - 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run', - }, - } - : null - ), - }) - ).rejects.toThrow(/raw malformed 1.*expected 404, received 308/); - - await expect( - runDeploySmoke({ - url: previewUrl, - mode: 'preview', - requestImpl: impl((request) => - request.path === dotCase.path - ? { - status: 308, - headers: { - location: `${previewUrl}/langgraph/core-capabilities/streaming/overview/python`, - }, - } - : null - ), - }) - ).rejects.toThrow(/expected 404, received 308/); - }); - - it('sends the automation bypass on every probe only when a secret is supplied', async () => { - // Vercel deployment protection answers every path on an unaliased - // deployment with 302 -> vercel.com/sso-api, so the immutable cockpit - // artifact can only be verified with the project's automation bypass. - const cases = buildRedirectSmokeCases('preview'); - const withSecret = vi.fn(async (request: RedirectSmokeRequest) => { - const { 'x-vercel-protection-bypass': bypass, ...rest } = - request.headers ?? {}; - if (bypass !== 'cockpit-bypass-sentinel') { - throw new Error(`Missing bypass on ${request.path}`); - } - return responseFor( - { ...request, headers: Object.keys(rest).length ? rest : undefined }, - cases - ); - }); - - await expect( - runDeploySmoke({ - url: previewUrl, - mode: 'preview', - requestImpl: withSecret, - bypassSecret: 'cockpit-bypass-sentinel', - }) - ).resolves.toBe(`pass:preview:${previewUrl}:${cases.length}`); - expect(withSecret).toHaveBeenCalledTimes(cases.length); - expect(withSecret).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/langgraph/core-capabilities/streaming/overview/python', - headers: expect.objectContaining({ - 'x-forwarded-host': 'attacker.test', - 'x-vercel-protection-bypass': 'cockpit-bypass-sentinel', - }), - }) - ); - - const withoutSecret = vi.fn(async (request: RedirectSmokeRequest) => - responseFor(request, cases) - ); - await runDeploySmoke({ - url: previewUrl, - mode: 'preview', - requestImpl: withoutSecret, - }); - for (const [request] of withoutSecret.mock.calls) { - expect(request.headers ?? {}).not.toHaveProperty( - 'x-vercel-protection-bypass' - ); - } - }); - - it('names Vercel deployment protection when a probe lands on the SSO redirect', async () => { - const requestImpl = vi.fn(async () => ({ - status: 302, - headers: { - location: - 'https://vercel.com/sso-api?url=https%3A%2F%2Fimmutable-preview.vercel.app%2F&nonce=abc', - }, - })); - - await expect( - runDeploySmoke({ url: previewUrl, mode: 'preview', requestImpl }) - ).rejects.toThrow( - /expected 308, received 302.*deployment protection.*automation bypass/i - ); - }); - - it('formats dry-run output with the selected mode and case count', async () => { - await expect( - runDeploySmoke({ url: previewUrl, mode: 'preview', dryRun: true }) - ).resolves.toBe( - `dry-run:preview:${previewUrl}:${ - buildRedirectSmokeCases('preview').length - }` - ); - }); -}); diff --git a/apps/cockpit/scripts/deploy-smoke.ts b/apps/cockpit/scripts/deploy-smoke.ts deleted file mode 100644 index e2e55c68c..000000000 --- a/apps/cockpit/scripts/deploy-smoke.ts +++ /dev/null @@ -1,518 +0,0 @@ -import * as http from 'node:http'; -import * as https from 'node:https'; -import { resolve } from 'node:path'; -import { - cockpitManifest, - getCanonicalWebsiteWorkspaceHref, - getWorkspaceDestinationPath, - resolveLegacyPath, - resolveLegacyRequestMode, - type CockpitManifestEntry, - type WorkspaceMode, - type WorkspaceResolution, -} from '@threadplane/cockpit-registry'; - -export type DeploySmokeMode = 'preview' | 'production'; - -export interface RedirectSmokeRequest { - readonly origin: string; - readonly path: string; - readonly headers?: Readonly>; -} - -export interface RedirectSmokeResponse { - readonly status: number; - readonly headers: Readonly>; -} - -export type RedirectSmokeRequestImpl = ( - request: RedirectSmokeRequest -) => Promise; - -export interface RedirectSmokeCase { - readonly name: string; - readonly path: string; - readonly expectedStatus: 308 | 404; - readonly expectedLocation?: string; - readonly headers?: Readonly>; - readonly raw?: boolean; - /** - * Vercel's CDN collapses consecutive slashes and answers 308 to the - * single-slash path on the same origin before any route, rewrite, or - * function runs, so a raw probe carrying `//` can never reach the 404 - * route in vercel.cockpit.json. The only acceptable non-404 answer for such - * a probe is that exact same-origin normalization — never a redirect off - * the deployment. - */ - readonly platformNormalizedPath?: string; -} - -export interface DeploySmokeOptions { - readonly url: string; - readonly mode?: DeploySmokeMode; - readonly dryRun?: boolean; - readonly retries?: number; - readonly retryDelayMs?: number; - readonly requestImpl?: RedirectSmokeRequestImpl; - readonly sleep?: (delayMs: number) => Promise; - /** - * Vercel "Protection Bypass for Automation" secret for the project that - * owns the deployment. Deployment protection answers every path on an - * unaliased deployment with 302 -> vercel.com/sso-api, so the immutable - * artifact can only be probed when each request carries this header. The - * secret is issued per project: the cockpit one is not the Website one. - */ - readonly bypassSecret?: string; -} - -export interface ParsedDeploySmokeArgs { - url: string; - mode: DeploySmokeMode; - dryRun: boolean; - retries: number; - retryDelayMs: number; -} - -const WEBSITE_ORIGIN = 'https://threadplane.ai'; -const BYPASS_HEADER = 'x-vercel-protection-bypass'; -const BYPASS_SECRET_ENV = 'VERCEL_AUTOMATION_BYPASS_SECRET'; -const DEFAULT_RETRIES = 0; -const DEFAULT_RETRY_DELAY_MS = 2000; -const ALL_MODES: readonly WorkspaceMode[] = ['Docs', 'Run', 'Code', 'API']; -const ROOT_STREAMING_LEGACY_PATH = - '/langgraph/core-capabilities/streaming/overview/python'; -const defaultSleep = (delayMs: number): Promise => - new Promise((resolvePromise) => setTimeout(resolvePromise, delayMs)); - -type MappedWorkspaceResolution = Extract< - WorkspaceResolution, - { kind: 'mapped' } ->; - -const rootResolution = (): MappedWorkspaceResolution => { - const resolution = resolveLegacyPath(ROOT_STREAMING_LEGACY_PATH); - if (!resolution || resolution.kind !== 'mapped') { - throw new Error('Redirect smoke requires the registry streaming route'); - } - return resolution; -}; - -const expectedLocation = ( - resolution: WorkspaceResolution, - rawMode: string | string[] | undefined -): string => { - const mode = resolveLegacyRequestMode(rawMode, resolution); - return new URL( - getCanonicalWebsiteWorkspaceHref(resolution, mode), - `${WEBSITE_ORIGIN}/` - ).toString(); -}; - -const redirectCase = ( - name: string, - path: string, - resolution: WorkspaceResolution, - rawMode?: string | string[], - headers?: Readonly> -): RedirectSmokeCase => ({ - name, - path, - expectedStatus: 308, - expectedLocation: expectedLocation(resolution, rawMode), - ...(headers ? { headers } : {}), -}); - -const notFoundCase = ( - name: string, - path: string, - raw = false -): RedirectSmokeCase => ({ - name, - path, - expectedStatus: 404, - raw, - ...(raw && path.includes('//') - ? { platformNormalizedPath: path.replace(/\/{2,}/g, '/') } - : {}), -}); - -export const RAW_MALFORMED_REQUEST_TARGETS = [ - `/${ROOT_STREAMING_LEGACY_PATH}`, - ROOT_STREAMING_LEGACY_PATH.replace( - '/core-capabilities/', - '/./core-capabilities/' - ), - ROOT_STREAMING_LEGACY_PATH.replace( - '/core-capabilities/', - '/../core-capabilities/' - ), - ROOT_STREAMING_LEGACY_PATH.replace( - '/core-capabilities/', - '/%2e/core-capabilities/' - ), - ROOT_STREAMING_LEGACY_PATH.replace( - '/core-capabilities/', - '/%2e%2e/core-capabilities/' - ), - ROOT_STREAMING_LEGACY_PATH.replace('/overview/', '/%2Foverview/'), - ROOT_STREAMING_LEGACY_PATH.replace('/overview/', '/%5Coverview/'), - ROOT_STREAMING_LEGACY_PATH.replace('/overview/', '/\\overview/'), -] as const; - -const entryResolution = ( - entry: CockpitManifestEntry -): MappedWorkspaceResolution => { - const resolution = resolveLegacyPath(entry.legacyPath); - if (!resolution || resolution.kind !== 'mapped') { - throw new Error(`Manifest route is not resolvable: ${entry.id}`); - } - return resolution; -}; - -const buildPreviewCases = (): RedirectSmokeCase[] => { - const cases: RedirectSmokeCase[] = []; - const root = rootResolution(); - for (const [name, path] of [ - ['root default redirect', '/'], - ['root Docs ignored', '/?mode=docs'], - ['root Run redirect', '/?mode=run'], - ['root Code ignored', '/?mode=code'], - ['root API ignored', '/?mode=api'], - ['root invalid mode ignored', '/?mode=invalid'], - ['root duplicate modes ignored', '/?mode=docs&mode=run'], - [ - 'root unrelated query stripped', - '/?return_to=https%3A%2F%2Fattacker.test&utm_source=legacy', - ], - ] as const) { - cases.push(redirectCase(name, path, root, 'run')); - } - - for (const entry of cockpitManifest) { - const resolution = entryResolution(entry); - cases.push( - redirectCase(`${entry.id} missing mode`, entry.legacyPath, resolution) - ); - for (const mode of entry.availableModes) { - cases.push( - redirectCase( - `${entry.id} available mode ${mode}`, - `${entry.legacyPath}?mode=${mode.toLowerCase()}`, - resolution, - mode.toLowerCase() - ) - ); - } - for (const mode of ALL_MODES.filter( - (candidate) => !entry.availableModes.includes(candidate) - )) { - cases.push( - redirectCase( - `${entry.id} unavailable mode ${mode}`, - `${entry.legacyPath}?mode=${mode.toLowerCase()}`, - resolution, - mode.toLowerCase() - ) - ); - } - cases.push( - redirectCase( - `${entry.id} invalid mode`, - `${entry.legacyPath}?mode=invalid`, - resolution, - 'invalid' - ), - redirectCase( - `${entry.id} duplicate modes`, - `${entry.legacyPath}?mode=docs&mode=run`, - resolution, - ['docs', 'run'] - ), - redirectCase( - `${entry.id} unrelated query stripping`, - `${entry.legacyPath}?return_to=https%3A%2F%2Fattacker.test&utm_source=legacy`, - resolution - ) - ); - } - - cases.push( - notFoundCase('unknown path 404', '/unknown'), - notFoundCase('partial path 404', '/langgraph/core-capabilities/streaming'), - notFoundCase('extra path 404', `${ROOT_STREAMING_LEGACY_PATH}/extra`), - notFoundCase('trailing slash 404', `${ROOT_STREAMING_LEGACY_PATH}/`), - redirectCase( - 'hostile forwarding headers ignored', - ROOT_STREAMING_LEGACY_PATH, - root, - undefined, - { - forwarded: 'host=attacker.test;proto=http', - 'x-forwarded-host': 'attacker.test', - 'x-forwarded-proto': 'http', - referer: 'https://attacker.test/redirect', - } - ), - { - name: 'favicon permanent redirect', - path: '/favicon.ico', - expectedStatus: 308, - expectedLocation: '/icon.svg', - }, - ...RAW_MALFORMED_REQUEST_TARGETS.map((path, index) => - notFoundCase(`raw malformed ${index + 1}: ${path}`, path, true) - ) - ); - return cases; -}; - -const buildProductionCases = (): RedirectSmokeCase[] => { - const root = rootResolution(); - const docsBacked = cockpitManifest.find((entry) => - getWorkspaceDestinationPath(entry).startsWith('/docs/') - ); - if (!docsBacked) { - throw new Error('Redirect smoke requires a Docs-backed route'); - } - return [ - redirectCase('root production redirect', '/', root, 'run'), - redirectCase( - 'Docs-backed production redirect', - docsBacked.legacyPath, - entryResolution(docsBacked) - ), - notFoundCase('unknown production 404', '/unknown'), - { - name: 'favicon production redirect', - path: '/favicon.ico', - expectedStatus: 308, - expectedLocation: '/icon.svg', - }, - ...RAW_MALFORMED_REQUEST_TARGETS.slice(0, 3).map((path, index) => - notFoundCase( - `raw malformed production canary ${index + 1}: ${path}`, - path, - true - ) - ), - ]; -}; - -export const buildRedirectSmokeCases = ( - mode: DeploySmokeMode -): RedirectSmokeCase[] => - mode === 'preview' ? buildPreviewCases() : buildProductionCases(); - -export const parseDeploySmokeArgs = (argv: string[]): ParsedDeploySmokeArgs => { - const options: ParsedDeploySmokeArgs = { - url: 'http://127.0.0.1:3000', - mode: 'preview', - dryRun: false, - retries: DEFAULT_RETRIES, - retryDelayMs: DEFAULT_RETRY_DELAY_MS, - }; - - for (let index = 0; index < argv.length; index += 1) { - const current = argv[index]; - const next = argv[index + 1]; - if (current === '--url' && next) { - options.url = next; - index += 1; - } else if (current === '--mode' && next) { - if (next !== 'preview' && next !== 'production') { - throw new Error('--mode must be preview or production'); - } - options.mode = next; - index += 1; - } else if (current === '--dry-run') { - options.dryRun = true; - } else if (current === '--retries' && next) { - options.retries = Number(next); - index += 1; - } else if (current === '--retry-delay-ms' && next) { - options.retryDelayMs = Number(next); - index += 1; - } - } - return options; -}; - -export const requestExactTarget: RedirectSmokeRequestImpl = ({ - origin, - path, - headers, -}) => - new Promise((resolvePromise, reject) => { - const target = new URL(origin); - const requester = - target.protocol === 'https:' ? https.request : http.request; - const request = requester( - { - protocol: target.protocol, - hostname: target.hostname, - port: target.port || undefined, - method: 'GET', - path, - headers, - }, - (response) => { - response.resume(); - response.on('end', () => { - const normalizedHeaders = Object.fromEntries( - Object.entries(response.headers).map(([key, value]) => [ - key.toLowerCase(), - Array.isArray(value) ? value.join(', ') : value, - ]) - ); - resolvePromise({ - status: response.statusCode ?? 0, - headers: normalizedHeaders, - }); - }); - } - ); - request.on('error', reject); - request.end(); - }); - -class RedirectContractError extends Error {} - -const isPlatformNormalization = ( - origin: string, - smokeCase: RedirectSmokeCase, - response: RedirectSmokeResponse -): boolean => { - const location = response.headers.location; - if ( - smokeCase.platformNormalizedPath === undefined || - response.status !== 308 || - location === undefined - ) { - return false; - } - // The platform answers with a relative Location; resolve both sides - // against the deployment origin so only that exact same-origin target - // passes. - let resolved: string; - try { - resolved = new URL(location, `${origin}/`).toString(); - } catch { - return false; - } - return ( - resolved === - new URL(smokeCase.platformNormalizedPath, `${origin}/`).toString() - ); -}; - -const verifyCase = ( - mode: DeploySmokeMode, - origin: string, - smokeCase: RedirectSmokeCase, - response: RedirectSmokeResponse -): void => { - const rawGateHint = smokeCase.raw - ? ' Raw-path rejection failed; verify the 404 route in vercel.cockpit.json still precedes framework routing before promotion.' - : ''; - if (isPlatformNormalization(origin, smokeCase, response)) return; - if (response.status !== smokeCase.expectedStatus) { - const protectionHint = - response.status === 302 && - (response.headers.location ?? '').startsWith('https://vercel.com/sso-api') - ? ` The deployment answered with Vercel deployment protection, not the redirect service; supply the owning project's automation bypass secret via ${BYPASS_SECRET_ENV}.` - : ''; - throw new RedirectContractError( - `[${mode}] ${smokeCase.name}: expected ${smokeCase.expectedStatus}, received ${response.status}.${rawGateHint}${protectionHint}` - ); - } - const location = response.headers.location; - if (smokeCase.expectedLocation !== undefined) { - if (location !== smokeCase.expectedLocation) { - throw new RedirectContractError( - `[${mode}] ${smokeCase.name}: expected Location ${ - smokeCase.expectedLocation - }, received ${location ?? ''}.${rawGateHint}` - ); - } - } else if (location !== undefined) { - throw new RedirectContractError( - `[${mode}] ${smokeCase.name}: expected no Location, received ${location}.${rawGateHint}` - ); - } -}; - -export const runDeploySmoke = async ({ - url, - mode = 'preview', - dryRun = false, - retries = DEFAULT_RETRIES, - retryDelayMs = DEFAULT_RETRY_DELAY_MS, - requestImpl = requestExactTarget, - sleep = defaultSleep, - bypassSecret, -}: DeploySmokeOptions): Promise => { - const target = new URL(url); - if (target.pathname !== '/' || target.search || target.hash) { - throw new Error('Deploy smoke --url must be an absolute origin'); - } - const origin = target.origin; - const cases = buildRedirectSmokeCases(mode); - if (dryRun) return `dry-run:${mode}:${origin}:${cases.length}`; - - const bypassHeaders: Readonly> | undefined = - bypassSecret ? { [BYPASS_HEADER]: bypassSecret } : undefined; - - for (const smokeCase of cases) { - const headers = - smokeCase.headers || bypassHeaders - ? { ...smokeCase.headers, ...bypassHeaders } - : undefined; - let attempt = 0; - while (true) { - try { - const response = await requestImpl({ - origin, - path: smokeCase.path, - ...(headers ? { headers } : {}), - }); - verifyCase(mode, origin, smokeCase, response); - break; - } catch (error: unknown) { - if (error instanceof RedirectContractError) throw error; - if (attempt >= retries) { - const message = - error instanceof Error ? error.message : String(error); - throw new Error( - `[${mode}] ${smokeCase.name}: transport failed: ${message}` - ); - } - attempt += 1; - await sleep(retryDelayMs); - } - } - } - return `pass:${mode}:${origin}:${cases.length}`; -}; - -if ( - process.argv[1] === - resolve(process.cwd(), 'apps/cockpit/scripts/deploy-smoke.ts') -) { - try { - const options = parseDeploySmokeArgs(process.argv.slice(2)); - // Read the secret from the environment, never argv, so it stays out of - // process listings and CI step logs. - const bypassSecret = process.env[BYPASS_SECRET_ENV] || undefined; - runDeploySmoke({ ...options, ...(bypassSecret ? { bypassSecret } : {}) }) - .then((result) => process.stdout.write(`${result}\n`)) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; - }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; - } -} diff --git a/apps/cockpit/scripts/manual-spec-freshness.spec.ts b/apps/cockpit/scripts/manual-spec-freshness.spec.ts deleted file mode 100644 index 57d4b0de9..000000000 --- a/apps/cockpit/scripts/manual-spec-freshness.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -// -// Tripwire for the `*.manual.ts` tier. -// -// Manual specs are live-LLM checks a human runs by hand: every cockpit -// playwright config matches `**/*.spec.ts`, so nothing executes these and CI -// can never notice when they drift. When this guard was written, 10 of 34 were -// asserting UI copy that existed nowhere in the repo — panel headings that had -// been renamed, and empty-state strings from sidebars that no longer exist. -// -// Running the specs themselves in CI isn't viable (real model, per-example dev -// server). What IS cheap is checking that everything they assert still exists -// in the source. That catches the rot this tier actually suffers — stale copy -// and dropped selectors — without booting anything. -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const REPO_ROOT = resolve(__dirname, '../../..'); -const SKIP_DIRS = new Set(['node_modules', 'dist', '.angular', '.venv', '.next', 'coverage']); - -function walk(dir: string, out: string[] = []): string[] { - let entries; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return out; - } - for (const entry of entries) { - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) walk(join(dir, entry.name), out); - } else { - out.push(join(dir, entry.name)); - } - } - return out; -} - -const allFiles = walk(join(REPO_ROOT, 'cockpit')).concat(walk(join(REPO_ROOT, 'libs'))); -const manualSpecs = allFiles.filter((f) => f.endsWith('.manual.ts')); - -/** Every source file a manual spec could legitimately be asserting against. */ -const sourceCorpus = allFiles - .filter((f) => /\.(ts|html|css)$/.test(f) && !f.endsWith('.manual.ts')) - .map((f) => { - try { - return readFileSync(f, 'utf8'); - } catch { - return ''; - } - }) - .join('\n'); - -/** `text=Some copy` and `getByText('Some copy')` assertions. */ -function assertedText(spec: string): string[] { - const out = new Set(); - for (const m of spec.matchAll(/text=([^'"`)]{4,80})/g)) out.add(m[1].trim()); - for (const m of spec.matchAll(/getByText\(\s*['"]([^'"]{4,80})['"]/g)) out.add(m[1].trim()); - return [...out]; -} - -/** `page.locator('some-element')` custom-element selectors. */ -function assertedSelectors(spec: string): string[] { - const out = new Set(); - for (const m of spec.matchAll(/locator\(\s*['"]([a-z][a-z0-9]*(?:-[a-z0-9]+)+)['"]/g)) out.add(m[1]); - return [...out]; -} - -describe('manual e2e specs stay in sync with the source', () => { - it('finds the manual tier', () => { - // Guards the guard: if the glob ever stops matching, every assertion below - // would pass over an empty list and this file would be worthless. - expect(manualSpecs.length).toBeGreaterThan(20); - }); - - it.each(manualSpecs.map((f) => [f.slice(REPO_ROOT.length + 1), f]))( - '%s asserts only text that still exists', - (_label, file) => { - const spec = readFileSync(file, 'utf8'); - const missing = assertedText(spec).filter((t) => !sourceCorpus.includes(t)); - expect(missing, `copy asserted by this manual spec no longer exists anywhere in the repo`).toEqual([]); - }, - ); - - it.each(manualSpecs.map((f) => [f.slice(REPO_ROOT.length + 1), f]))( - '%s targets only selectors that still exist', - (_label, file) => { - const spec = readFileSync(file, 'utf8'); - const missing = assertedSelectors(spec).filter((s) => !sourceCorpus.includes(s)); - expect(missing, `element selectors targeted by this manual spec no longer exist`).toEqual([]); - }, - ); -}); diff --git a/apps/cockpit/scripts/vercel-config.spec.ts b/apps/cockpit/scripts/vercel-config.spec.ts deleted file mode 100644 index 43c62544d..000000000 --- a/apps/cockpit/scripts/vercel-config.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { RAW_MALFORMED_REQUEST_TARGETS } from './deploy-smoke'; - -const repoRoot = resolve(import.meta.dirname, '../../..'); -const config = JSON.parse( - readFileSync(resolve(repoRoot, 'vercel.cockpit.json'), 'utf8') -) as { - routes?: Array<{ src?: string; status?: number }>; -}; - -const EXPECTED_RAW_REJECTION_PATTERN = - '^(?:.*//.*|.*(?:\\\\|%5[cC]|%2[fF]).*|.*(?:^|/)(?:\\.{1,2}|%2[eE](?:%2[eE])?)(?:/|$).*)$'; - -describe('Cockpit Vercel malformed raw-path rejection', () => { - it('places one rejection-only rule before framework routing', () => { - expect(config.routes?.[0]).toEqual({ - src: EXPECTED_RAW_REJECTION_PATTERN, - status: 404, - }); - expect(config.routes).toHaveLength(1); - }); - - it('targets every raw malformed preview probe without duplicating redirects', () => { - const pattern = new RegExp(EXPECTED_RAW_REJECTION_PATTERN, 'i'); - for (const path of RAW_MALFORMED_REQUEST_TARGETS) { - expect(pattern.test(path), path).toBe(true); - } - for (const path of [ - '/', - '/favicon.ico', - '/langgraph/core-capabilities/streaming/overview/python', - ]) { - expect(pattern.test(path), path).toBe(false); - } - expect(JSON.stringify(config.routes)).not.toContain('threadplane.ai'); - expect(JSON.stringify(config.routes)).not.toContain('mode='); - }); -}); diff --git a/apps/cockpit/src/app/[[...slug]]/route.spec.ts b/apps/cockpit/src/app/[[...slug]]/route.spec.ts deleted file mode 100644 index 064c7b8b6..000000000 --- a/apps/cockpit/src/app/[[...slug]]/route.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { - cockpitManifest, - getWorkspaceDestinationPath, - type WorkspaceMode, -} from '@threadplane/cockpit-registry'; -import { NextRequest } from 'next/server'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { GET } from './route'; - -const originalOrigin = process.env.COCKPIT_WEBSITE_ORIGIN; -const originalNodeEnvironment = process.env.NODE_ENV; - -const request = (path: string, headers?: HeadersInit) => - new NextRequest(`https://cockpit.threadplane.ai${path}`, { headers }); - -const expectedLocation = ( - entry: (typeof cockpitManifest)[number], - mode: WorkspaceMode -): string => { - const path = getWorkspaceDestinationPath(entry); - const query = - mode === 'Docs' && path.startsWith('/docs') - ? '' - : `?mode=${mode.toLowerCase()}`; - return `https://threadplane.ai${path}${query}`; -}; - -describe('legacy Cockpit redirect route', () => { - beforeEach(() => { - process.env.COCKPIT_WEBSITE_ORIGIN = 'https://threadplane.ai'; - process.env.NODE_ENV = 'production'; - }); - - afterEach(() => { - if (originalOrigin === undefined) { - delete process.env.COCKPIT_WEBSITE_ORIGIN; - } else { - process.env.COCKPIT_WEBSITE_ORIGIN = originalOrigin; - } - if (originalNodeEnvironment === undefined) { - delete process.env.NODE_ENV; - } else { - process.env.NODE_ENV = originalNodeEnvironment; - } - }); - - it.each([ - '/', - '/?mode=docs', - '/?mode=run', - '/?mode=code', - '/?mode=api', - '/?mode=invalid', - '/?mode=docs&mode=run', - '/?return_to=https%3A%2F%2Fattacker.test&utm_source=old-bookmark', - ])('always redirects root to representative Streaming Run for %s', (path) => { - const response = GET(request(path)); - - expect(response.status).toBe(308); - expect(response.headers.get('location')).toBe( - 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' - ); - }); - - it('redirects every exact manifest path permanently', () => { - for (const entry of cockpitManifest) { - const response = GET(request(`${entry.legacyPath}?ignored=1`)); - const defaultMode = entry.availableModes.includes('Run') ? 'Run' : 'Docs'; - - expect(response.status, entry.id).toBe(308); - expect(response.headers.get('location'), entry.id).toBe( - expectedLocation(entry, defaultMode) - ); - expect(response.headers.get('location'), entry.id).not.toContain( - 'ignored' - ); - } - }); - - it('honors every available mode for every manifest path', () => { - for (const entry of cockpitManifest) { - for (const mode of entry.availableModes) { - const response = GET( - request(`${entry.legacyPath}?mode=${mode.toUpperCase()}`) - ); - - expect(response.status, `${entry.id} ${mode}`).toBe(308); - expect(response.headers.get('location'), `${entry.id} ${mode}`).toBe( - expectedLocation(entry, mode) - ); - } - } - }); - - it('reads only mode and strips every unrelated query parameter', () => { - const streaming = cockpitManifest.find( - (entry) => - entry.id === 'langgraph:core-capabilities:streaming:overview:python' - ); - if (!streaming) throw new Error('Expected streaming fixture'); - - const response = GET( - request( - `${streaming.legacyPath}?return_to=https://attacker.test&mode=code&utm_source=x` - ) - ); - - expect(response.status).toBe(308); - expect(response.headers.get('location')).toBe( - 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=code' - ); - }); - - it('treats duplicate mode values as invalid and uses the old default', () => { - const streaming = cockpitManifest.find( - (entry) => - entry.id === 'langgraph:core-capabilities:streaming:overview:python' - ); - if (!streaming) throw new Error('Expected streaming fixture'); - - const response = GET( - request(`${streaming.legacyPath}?mode=docs&mode=code`) - ); - - expect(response.status).toBe(308); - expect(response.headers.get('location')).toBe( - 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' - ); - }); - - it('falls back to Docs when a mode is unavailable on a docs-only entry', () => { - const docsOnly = cockpitManifest.find( - (entry) => !entry.availableModes.includes('Run') - ); - if (!docsOnly) throw new Error('Expected docs-only fixture'); - - const response = GET(request(`${docsOnly.legacyPath}?mode=run`)); - - expect(response.status).toBe(308); - expect(response.headers.get('location')).toBe( - expectedLocation(docsOnly, 'Docs') - ); - }); - - it.each([ - '/unknown', - '/langgraph/core-capabilities/streaming', - '/langgraph/core-capabilities/streaming/overview/python/extra', - '/langgraph/core-capabilities/streaming/overview/python/', - '//langgraph/core-capabilities/streaming/overview/python', - '/langgraph/core-capabilities/streaming/%2Foverview/python', - ])('returns a real 404 for %s', (pathname) => { - const response = GET(request(pathname)); - - expect(response.status).toBe(404); - expect(response.headers.get('location')).toBeNull(); - }); - - it('ignores hostile request authorities and forwarding metadata', () => { - const streaming = cockpitManifest.find( - (entry) => - entry.id === 'langgraph:core-capabilities:streaming:overview:python' - ); - if (!streaming) throw new Error('Expected streaming fixture'); - - const response = GET( - request(streaming.legacyPath, { - host: 'attacker.example', - forwarded: 'host=attacker.example;proto=http', - 'x-forwarded-host': 'attacker.example', - 'x-forwarded-proto': 'http', - referer: 'https://attacker.example/redirect', - }) - ); - - expect(response.status).toBe(308); - expect(response.headers.get('location')).toBe( - 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' - ); - }); - - it.each([ - 'https://attacker@threadplane.ai', - 'https://threadplane.ai?', - 'https://threadplane.ai#', - 'https://threadplane.ai/%2e', - 'https://threadplane.ai/a/..', - ])('fails closed when the server-only destination origin is %s', (origin) => { - process.env.COCKPIT_WEBSITE_ORIGIN = origin; - - expect(() => GET(request('/'))).toThrow(/COCKPIT_WEBSITE_ORIGIN/); - }); -}); diff --git a/apps/cockpit/src/app/[[...slug]]/route.ts b/apps/cockpit/src/app/[[...slug]]/route.ts deleted file mode 100644 index 605f7372b..000000000 --- a/apps/cockpit/src/app/[[...slug]]/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { - getLegacyWebsiteRedirect, - getRootWebsiteRedirect, -} from '../../lib/cockpit-page'; - -export function GET(request: NextRequest): NextResponse { - const pathname = request.nextUrl.pathname; - const destination = - pathname === '/' - ? getRootWebsiteRedirect() - : getLegacyWebsiteRedirect( - pathname, - request.nextUrl.searchParams.getAll('mode') - ); - - return destination - ? NextResponse.redirect(destination, 308) - : new NextResponse(null, { status: 404 }); -} diff --git a/apps/cockpit/src/app/favicon.ico/route.spec.ts b/apps/cockpit/src/app/favicon.ico/route.spec.ts deleted file mode 100644 index 24ef36b5a..000000000 --- a/apps/cockpit/src/app/favicon.ico/route.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -import { GET } from './route'; - -describe('GET /favicon.ico', () => { - it('redirects permanently to the same-origin SVG', () => { - const response = GET(new Request('https://cockpit.test/favicon.ico')); - - expect(response.status).toBe(308); - expect(response.headers.get('location')).toBe('/icon.svg'); - expect(response.headers.get('cache-control')).toBe('public, max-age=86400'); - }); - - it('keeps the redirect relative when the request URL was normalized', () => { - const response = GET( - new Request('http://localhost:4319/favicon.ico', { - headers: { host: '127.0.0.1:4319' }, - }) - ); - - expect(response.status).toBe(308); - expect(response.headers.get('location')).toBe('/icon.svg'); - expect(response.headers.get('cache-control')).toBe('public, max-age=86400'); - }); - - it('ignores forwarded and malformed origin headers', () => { - const forwarded = GET( - new Request('http://localhost:4319/favicon.ico', { - headers: { - host: '127.0.0.1:4319', - 'x-forwarded-host': 'cockpit.alias.test:4319', - 'x-forwarded-proto': 'https', - }, - }) - ); - const malformed = GET( - new Request('http://localhost:4319/favicon.ico', { - headers: { - host: '127.0.0.1:4319/path', - 'x-forwarded-host': 'bad.example/path', - 'x-forwarded-proto': 'javascript', - }, - }) - ); - - expect(forwarded.headers.get('location')).toBe('/icon.svg'); - expect(malformed.headers.get('location')).toBe('/icon.svg'); - }); - - it('never incorporates hostile authorities into the redirect', () => { - const host = GET( - new Request('http://localhost:4319/favicon.ico', { - headers: { host: 'attacker.example' }, - }) - ); - const forwarded = GET( - new Request('http://localhost:4319/favicon.ico', { - headers: { - host: 'trusted.example', - 'x-forwarded-host': 'attacker.example', - 'x-forwarded-proto': 'https', - }, - }) - ); - - expect(host.headers.get('location')).toBe('/icon.svg'); - expect(forwarded.headers.get('location')).toBe('/icon.svg'); - }); - - it('ships a self-contained accessible Threadplane SVG icon', () => { - const svg = readFileSync( - join( - process.cwd().endsWith('/apps/cockpit') - ? process.cwd() - : join(process.cwd(), 'apps/cockpit'), - 'src/app/icon.svg' - ), - 'utf8' - ); - - expect(svg).toMatch(/^]+xmlns="http:\/\/www\.w3\.org\/2000\/svg"/); - expect(svg).toContain('Threadplane Cockpit'); - expect(svg).toContain('#004090'); - expect(svg).toContain('#64C3FD'); - expect(svg).not.toMatch(/ - Threadplane Cockpit - - - - diff --git a/apps/cockpit/src/app/layout.tsx b/apps/cockpit/src/app/layout.tsx deleted file mode 100644 index 6fe3fb7e1..000000000 --- a/apps/cockpit/src/app/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { ReactNode } from 'react'; - -interface RootLayoutProps { - children: ReactNode; -} - -export default function RootLayout({ children }: RootLayoutProps) { - return ( - - {children} - - ); -} diff --git a/apps/cockpit/src/lib/cockpit-page.spec.ts b/apps/cockpit/src/lib/cockpit-page.spec.ts deleted file mode 100644 index 6ccf75ee5..000000000 --- a/apps/cockpit/src/lib/cockpit-page.spec.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { - cockpitManifest, - getWorkspaceDestinationPath, - type WorkspaceMode, -} from '@threadplane/cockpit-registry'; -import { describe, expect, it } from 'vitest'; -import { - getCockpitWebsiteOrigin, - getLegacyWebsiteRedirect, - getRootWebsiteRedirect, -} from './cockpit-page'; - -const productionEnvironment = { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai', - NODE_ENV: 'production', -} as const; - -const expectedHref = ( - entry: (typeof cockpitManifest)[number], - mode: WorkspaceMode -): string => { - const path = getWorkspaceDestinationPath(entry); - const query = - mode === 'Docs' && path.startsWith('/docs') - ? '' - : `?mode=${mode.toLowerCase()}`; - return `https://threadplane.ai${path}${query}`; -}; - -describe('Cockpit Website origin validation', () => { - it('accepts only the canonical Website HTTPS origin in production', () => { - expect(getCockpitWebsiteOrigin(productionEnvironment)).toBe( - 'https://threadplane.ai' - ); - expect( - getCockpitWebsiteOrigin({ - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai/', - NODE_ENV: 'production', - }) - ).toBe('https://threadplane.ai'); - }); - - it('accepts explicit HTTP localhost only in development', () => { - expect( - getCockpitWebsiteOrigin({ - COCKPIT_WEBSITE_ORIGIN: 'http://localhost/', - NODE_ENV: 'development', - }) - ).toBe('http://localhost'); - expect( - getCockpitWebsiteOrigin({ - COCKPIT_WEBSITE_ORIGIN: 'http://localhost:4200/', - NODE_ENV: 'development', - }) - ).toBe('http://localhost:4200'); - }); - - it.each([ - [{ NODE_ENV: 'production' }, 'missing'], - [ - { COCKPIT_WEBSITE_ORIGIN: 'not a URL', NODE_ENV: 'production' }, - 'invalid', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://preview.threadplane.ai', - NODE_ENV: 'production', - }, - 'production preview origin', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai:8443', - NODE_ENV: 'production', - }, - 'production non-default port', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://preview.threadplane.ai', - NODE_ENV: 'development', - }, - 'development preview HTTPS origin', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://user:secret@threadplane.ai', - NODE_ENV: 'production', - }, - 'credentials', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai/docs', - NODE_ENV: 'production', - }, - 'non-root path', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai?next=/docs', - NODE_ENV: 'production', - }, - 'query', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai#fragment', - NODE_ENV: 'production', - }, - 'fragment', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai?', - NODE_ENV: 'production', - }, - 'empty query delimiter', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai#', - NODE_ENV: 'production', - }, - 'empty fragment delimiter', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: ' https://threadplane.ai', - NODE_ENV: 'production', - }, - 'leading whitespace', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai\n', - NODE_ENV: 'production', - }, - 'trailing whitespace', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai/%2e', - NODE_ENV: 'production', - }, - 'encoded dot path', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai/a/..', - NODE_ENV: 'production', - }, - 'normalized dot path', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'HTTPS://THREADPLANE.AI', - NODE_ENV: 'production', - }, - 'case-normalized origin', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'https://threadplane.ai:443', - NODE_ENV: 'production', - }, - 'normalized default port', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'http://threadplane.ai', - NODE_ENV: 'production', - }, - 'production HTTP', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'http://127.0.0.1:4200', - NODE_ENV: 'development', - }, - 'non-localhost development HTTP', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'http://localhost.evil.test:4200', - NODE_ENV: 'development', - }, - 'lookalike localhost', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'javascript:alert(1)', - NODE_ENV: 'development', - }, - 'unsafe protocol', - ], - [ - { - COCKPIT_WEBSITE_ORIGIN: 'file:///tmp/threadplane', - NODE_ENV: 'development', - }, - 'file protocol', - ], - ] as const)('rejects %s origin configuration', (environment) => { - expect(() => getCockpitWebsiteOrigin(environment)).toThrow( - /COCKPIT_WEBSITE_ORIGIN/ - ); - }); -}); - -describe('registry-derived legacy Website redirects', () => { - it('maps every exact manifest path using its old Cockpit default mode', () => { - for (const entry of cockpitManifest) { - const defaultMode = entry.availableModes.includes('Run') ? 'Run' : 'Docs'; - expect( - getLegacyWebsiteRedirect(entry.legacyPath, [], productionEnvironment), - entry.id - ).toBe(expectedHref(entry, defaultMode)); - } - }); - - it('honors every single available mode case-insensitively', () => { - for (const entry of cockpitManifest) { - for (const mode of entry.availableModes) { - expect( - getLegacyWebsiteRedirect( - entry.legacyPath, - [mode.toUpperCase()], - productionEnvironment - ), - `${entry.id} ${mode}` - ).toBe(expectedHref(entry, mode)); - } - } - }); - - it('uses the old default for invalid, duplicate, or unavailable modes', () => { - const runnable = cockpitManifest.find((entry) => - entry.availableModes.includes('Run') - ); - const docsOnly = cockpitManifest.find( - (entry) => !entry.availableModes.includes('Run') - ); - if (!runnable || !docsOnly) throw new Error('Expected manifest fixtures'); - - expect( - getLegacyWebsiteRedirect( - runnable.legacyPath, - ['preview'], - productionEnvironment - ) - ).toBe(expectedHref(runnable, 'Run')); - expect( - getLegacyWebsiteRedirect( - runnable.legacyPath, - ['docs', 'code'], - productionEnvironment - ) - ).toBe(expectedHref(runnable, 'Run')); - expect( - getLegacyWebsiteRedirect( - docsOnly.legacyPath, - ['run'], - productionEnvironment - ) - ).toBe(expectedHref(docsOnly, 'Docs')); - }); - - it('serializes Docs mode truthfully for docs destinations', () => { - const docsDestination = cockpitManifest.find((entry) => - getWorkspaceDestinationPath(entry).startsWith('/docs/') - ); - if (!docsDestination) { - throw new Error('Expected a docs fixture'); - } - - expect( - getLegacyWebsiteRedirect( - docsDestination.legacyPath, - ['docs'], - productionEnvironment - ) - ).toBe(expectedHref(docsDestination, 'Docs')); - }); - - it('returns null for unknown, partial, extra, malformed, and trailing paths', () => { - const exact = cockpitManifest[0].legacyPath; - const partial = exact.split('/').slice(0, -1).join('/'); - - for (const pathname of [ - '/not-a-capability', - partial, - `${exact}/extra`, - `${exact}/`, - exact.replace('/', '//'), - exact.replace('/overview/', '/%2Foverview/'), - ]) { - expect( - getLegacyWebsiteRedirect(pathname, [], productionEnvironment), - pathname - ).toBeNull(); - } - }); - - it('redirects root to the representative streaming Run surface', () => { - expect(getRootWebsiteRedirect(productionEnvironment)).toBe( - 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' - ); - }); -}); diff --git a/apps/cockpit/src/lib/cockpit-page.ts b/apps/cockpit/src/lib/cockpit-page.ts deleted file mode 100644 index 8f820c1a3..000000000 --- a/apps/cockpit/src/lib/cockpit-page.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - getCanonicalWebsiteWorkspaceHref, - resolveLegacyPath, - resolveLegacyRequestMode, - type WorkspaceResolution, -} from '@threadplane/cockpit-registry'; - -const ROOT_STREAMING_LEGACY_PATH = - '/langgraph/core-capabilities/streaming/overview/python'; - -export interface CockpitRedirectEnvironment { - readonly COCKPIT_WEBSITE_ORIGIN?: string; - readonly NODE_ENV?: string; -} - -export function getCockpitWebsiteOrigin( - environment: CockpitRedirectEnvironment = process.env -): string { - const rawOrigin = environment.COCKPIT_WEBSITE_ORIGIN; - - if (!rawOrigin) { - throw new Error('COCKPIT_WEBSITE_ORIGIN must be configured'); - } - - let url: URL; - try { - url = new URL(rawOrigin); - } catch { - throw new Error('COCKPIT_WEBSITE_ORIGIN must be a valid absolute origin'); - } - - const hasCanonicalOriginForm = - rawOrigin === url.origin || rawOrigin === `${url.origin}/`; - const hasOnlyOrigin = - !url.username && - !url.password && - url.pathname === '/' && - !url.search && - !url.hash; - const canonicalWebsiteOrigin = url.origin === 'https://threadplane.ai'; - const developmentLocalhost = - environment.NODE_ENV === 'development' && - url.protocol === 'http:' && - url.hostname === 'localhost'; - - if ( - !hasCanonicalOriginForm || - !hasOnlyOrigin || - (!canonicalWebsiteOrigin && !developmentLocalhost) - ) { - throw new Error( - 'COCKPIT_WEBSITE_ORIGIN must be https://threadplane.ai, or HTTP localhost in development' - ); - } - - return url.origin; -} - -const normalizeRequestedMode = ( - modeValues: readonly string[] -): string | string[] | undefined => { - if (modeValues.length === 0) return undefined; - return modeValues.length === 1 ? modeValues[0] : [...modeValues]; -}; - -const toWebsiteRedirect = ( - resolution: WorkspaceResolution, - modeValues: readonly string[], - environment: CockpitRedirectEnvironment -): string => { - const mode = resolveLegacyRequestMode( - normalizeRequestedMode(modeValues), - resolution - ); - const href = getCanonicalWebsiteWorkspaceHref(resolution, mode); - return new URL(href, `${getCockpitWebsiteOrigin(environment)}/`).toString(); -}; - -export function getLegacyWebsiteRedirect( - legacyPath: string, - modeValues: readonly string[], - environment: CockpitRedirectEnvironment = process.env -): string | null { - const resolution = resolveLegacyPath(legacyPath); - return resolution - ? toWebsiteRedirect(resolution, modeValues, environment) - : null; -} - -export function getRootWebsiteRedirect( - environment: CockpitRedirectEnvironment = process.env -): string { - const resolution = resolveLegacyPath(ROOT_STREAMING_LEGACY_PATH); - if (!resolution) { - throw new Error('The Cockpit root streaming capability is not registered'); - } - return toWebsiteRedirect(resolution, ['run'], environment); -} diff --git a/apps/cockpit/src/lib/verify-shared-deployment.spec.ts b/apps/cockpit/src/lib/verify-shared-deployment.spec.ts deleted file mode 100644 index 18a96b354..000000000 --- a/apps/cockpit/src/lib/verify-shared-deployment.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -// eslint-disable-next-line @nx/enforce-module-boundaries -- repo-root deployment verifier is intentionally outside an Nx project. -import { - DEFAULT_SMOKE_ASSISTANT_STREAM_TIMEOUT_MS, - getSmokeAssistantStreamTimeoutMs, -} from '../../../../scripts/verify-shared-deployment'; - -describe('verify-shared-deployment', () => { - it('allows the two-pass planning graph more time than single-response smoke assistants', () => { - expect(getSmokeAssistantStreamTimeoutMs('da-planning')).toBe(90000); - expect(getSmokeAssistantStreamTimeoutMs('streaming')).toBe( - DEFAULT_SMOKE_ASSISTANT_STREAM_TIMEOUT_MS, - ); - }); - - it('allows UI-heavy smoke assistants more time than single-response smoke assistants', () => { - expect(getSmokeAssistantStreamTimeoutMs('c-generative-ui')).toBe(90000); - expect(getSmokeAssistantStreamTimeoutMs('c-a2ui')).toBe(90000); - }); -}); diff --git a/apps/cockpit/tsconfig.json b/apps/cockpit/tsconfig.json deleted file mode 100644 index de56f8ec4..000000000 --- a/apps/cockpit/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "jsx": "preserve", - "allowJs": false, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "noEmit": true, - "lib": ["es2022", "dom", "dom.iterable"], - "resolveJsonModule": true, - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"], - "@threadplane/cockpit-registry": [ - "../../libs/cockpit-registry/src/index.ts" - ] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} diff --git a/apps/cockpit/vite.config.mts b/apps/cockpit/vite.config.mts deleted file mode 100644 index 21ba49539..000000000 --- a/apps/cockpit/vite.config.mts +++ /dev/null @@ -1,37 +0,0 @@ -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vite'; -import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default defineConfig({ - plugins: [nxViteTsPaths()], - // The capability matrix specs (see `test.include`) live outside this app's - // directory, and Vite's dev server refuses to serve files above its root - // unless they are allow-listed. Without this the matrix specs fail to load - // with ERR_MODULE_NOT_FOUND on a `/@fs/...` path under `nx test cockpit`. - server: { fs: { allow: [resolve(__dirname, '../..')] } }, - test: { - environment: 'node', - globals: true, - include: [ - 'src/**/*.spec.ts', - 'src/**/*.spec.tsx', - '*.spec.ts', - 'scripts/**/*.spec.ts', - // The per-product capability matrix specs live beside the examples they - // describe and had no test target of their own, which is how their - // docsPath assertion drifted into asserting a URL shape the website has - // never served. Run them here so `nx test cockpit` covers them. - '../../cockpit/*/matrix.spec.ts', - // Same story for the per-product footprint specs (chat, deep-agents, - // render): they sit outside any project root, so no `test` target owned - // them and the deep-agents one drifted into asserting a website docs - // library that does not exist. Glob the whole family rather than naming - // files, so a new `cockpit//footprint.spec.ts` is covered the - // day it lands instead of joining the unrun pile. - '../../cockpit/*/footprint.spec.ts', - ], - }, -}); diff --git a/apps/website/e2e/platform-production-smoke.spec.ts b/apps/website/e2e/platform-production-smoke.spec.ts index 89b473714..bcffe7b89 100644 --- a/apps/website/e2e/platform-production-smoke.spec.ts +++ b/apps/website/e2e/platform-production-smoke.spec.ts @@ -4,29 +4,23 @@ import { join } from 'node:path'; import { validateRuntimeParentOrigins } from '@threadplane/cockpit-runtime-bridge'; import { cockpitManifest, - getCanonicalWebsiteWorkspaceHref, getWorkspaceDestinationPath, - resolveLegacyPath, - resolveLegacyRequestMode, } from '@threadplane/cockpit-registry'; /** - * Production platform smoke: verifies the Website, legacy Cockpit redirects, - * deployed examples, canonical demo, and shared runtimes as one product. + * Production platform smoke: verifies the Website, deployed examples, + * canonical demo, and shared runtimes as one product. * * Requires: - * COCKPIT_URL - e.g. https://cockpit.threadplane.ai * EXAMPLES_URL - e.g. https://examples.threadplane.ai * OPENAI_API_KEY - optional; enables the single live-provider canary * * Run: - * PRODUCTION_SMOKE=true COCKPIT_URL=https://cockpit.threadplane.ai \ + * PRODUCTION_SMOKE=true \ * EXAMPLES_URL=https://examples.threadplane.ai \ * npx playwright test apps/website/e2e/platform-production-smoke.spec.ts */ -const COCKPIT_URL = - process.env['COCKPIT_URL'] ?? 'https://cockpit.threadplane.ai'; const EXAMPLES_URL = process.env['EXAMPLES_URL'] ?? 'https://examples.threadplane.ai'; const DEMO_URL = process.env['DEMO_URL'] ?? 'https://demo.threadplane.ai'; @@ -130,51 +124,6 @@ const WEBSITE_DESTINATIONS = [ ...new Set(cockpitManifest.map(getWorkspaceDestinationPath)), ].sort(); -const expectedRedirect = (legacyPath: string): string => { - const resolution = resolveLegacyPath(legacyPath); - if (!resolution) throw new Error(`Expected registry path ${legacyPath}`); - const mode = resolveLegacyRequestMode(undefined, resolution); - return new URL( - getCanonicalWebsiteWorkspaceHref(resolution, mode), - `${WEBSITE_URL}/` - ).toString(); -}; - -const docsBacked = cockpitManifest.find((entry) => - getWorkspaceDestinationPath(entry).startsWith('/docs/') -); -if (!docsBacked) - throw new Error('Production smoke requires a Docs-backed route'); - -const COCKPIT_REDIRECT_CASES = [ - { - name: 'root production redirect', - path: '/', - status: 308, - location: expectedRedirect( - '/langgraph/core-capabilities/streaming/overview/python' - ), - }, - { - name: 'Docs-backed production redirect', - path: docsBacked.legacyPath, - status: 308, - location: expectedRedirect(docsBacked.legacyPath), - }, - { - name: 'unknown production 404', - path: '/unknown', - status: 404, - location: undefined, - }, - { - name: 'favicon production redirect', - path: '/favicon.ico', - status: 308, - location: '/icon.svg', - }, -] as const; - test.describe('Production: registry-owned Website destinations load', () => { for (const destination of WEBSITE_DESTINATIONS) { test(`${destination} is reachable`, async ({ request }) => { @@ -227,19 +176,6 @@ test.describe('Production: render example apps load', () => { } }); -test.describe('Production: legacy Cockpit redirect service', () => { - for (const smokeCase of COCKPIT_REDIRECT_CASES) { - test(smokeCase.name, async ({ request }) => { - const response = await request.get(`${COCKPIT_URL}${smokeCase.path}`, { - maxRedirects: 0, - }); - - expect(response.status()).toBe(smokeCase.status); - expect(response.headers()['location']).toBe(smokeCase.location); - }); - } -}); - test.describe('Production: unified runtime embedding policy', () => { test('assembled children ship the exact parent/referrer policy without an X-Frame-Options conflict', async ({ request, diff --git a/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx b/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx index 16d7fa4bd..ff19c5638 100644 --- a/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx +++ b/apps/website/src/components/workspace/WebsiteWorkspace.spec.tsx @@ -102,8 +102,6 @@ const mappedResolution = ( language: 'python', title: topic, docsPath, - workspacePath: `/workspace/langgraph/${topic}`, - legacyPath: `/langgraph/core-capabilities/${topic}/overview/python`, runtimeAdapter: 'langgraph', availableModes, }, @@ -166,7 +164,6 @@ describe('WebsiteWorkspace', () => { resolution: docsOnlyResolution, presentation: docsOnlyPresentation, contentBundle: emptyContent, - routeKind: 'docs', routePath: '/docs/langgraph/guides/testing', requestedMode: null, }); @@ -485,7 +482,6 @@ describe('WebsiteWorkspace', () => { resolveHref({ id: 'langgraph:core-capabilities:durable-execution:overview:python', docsPath: '/docs/langgraph/guides/durable-execution', - workspacePath: '/workspace/langgraph/durable-execution', } as never) ).toBe('/docs/langgraph/guides/durable-execution'); }); diff --git a/apps/website/src/components/workspace/WebsiteWorkspace.tsx b/apps/website/src/components/workspace/WebsiteWorkspace.tsx index 46edf7b7e..9e82fced9 100644 --- a/apps/website/src/components/workspace/WebsiteWorkspace.tsx +++ b/apps/website/src/components/workspace/WebsiteWorkspace.tsx @@ -313,7 +313,6 @@ function WebsiteWorkspaceSurface({ resolution={resolution} presentation={presentation} contentBundle={contentBundle} - routeKind="docs" routePath={routePath} requestedMode={routeMode} docsSlot={docsSlot} diff --git a/apps/website/src/lib/cockpit-retirement.spec.ts b/apps/website/src/lib/cockpit-retirement.spec.ts index c9e3208a2..b4cbf2693 100644 --- a/apps/website/src/lib/cockpit-retirement.spec.ts +++ b/apps/website/src/lib/cockpit-retirement.spec.ts @@ -228,7 +228,6 @@ describe('Cockpit surface retirement', () => { const USER_FACING = /\.(?:mdx|md|tsx)$/; const ALLOWED_TOKENS = [ /cockpit\/[a-z-]+\/[a-z-]+/g, // example repository paths - /apps\/cockpit\//g, // tooling paths (removed by Part C) /@threadplane\/cockpit-[a-z-]+/g, // package names /cockpit-[a-z-]+-angular/g, // Nx project names /cockpit-(?:registry|shell|runtime-bridge|telemetry|docs|testing|ui)/g, diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 116101e16..fb4b6553e 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -20,7 +20,6 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"], - "@threadplane/cockpit-docs": ["../../libs/cockpit-docs/src/index.ts"], "@threadplane/cockpit-registry": [ "../../libs/cockpit-registry/src/index.ts" ], diff --git a/libs/cockpit-docs/package.json b/libs/cockpit-docs/package.json deleted file mode 100644 index 5e286cdfc..000000000 --- a/libs/cockpit-docs/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@threadplane/cockpit-docs", - "version": "0.0.29", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/cacheplane/angular-agent-framework.git", - "directory": "libs/cockpit-docs" - }, - "homepage": "https://github.com/cacheplane/angular-agent-framework#readme", - "bugs": { - "url": "https://github.com/cacheplane/angular-agent-framework/issues" - }, - "sideEffects": false, - "private": true -} diff --git a/libs/cockpit-docs/project.json b/libs/cockpit-docs/project.json deleted file mode 100644 index 23d60ae53..000000000 --- a/libs/cockpit-docs/project.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "cockpit-docs", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "libs/cockpit-docs/src", - "projectType": "library", - "targets": { - "build": { - "executor": "@nx/js:tsc", - "dependsOn": [ - "^build" - ], - "outputs": [ - "{workspaceRoot}/dist/libs/cockpit-docs" - ], - "options": { - "outputPath": "dist/libs/cockpit-docs", - "main": "libs/cockpit-docs/src/index.ts", - "tsConfig": "libs/cockpit-docs/tsconfig.lib.json" - } - }, - "test": { - "executor": "@nx/vitest:test", - "options": { - "configFile": "libs/cockpit-docs/vite.config.mts" - } - } - }, - "tags": [ - "scope:cockpit", - "scope:cockpit-deploy-smoke", - "scope:cockpit-e2e", - "scope:cockpit-examples" - ] -} diff --git a/libs/cockpit-docs/src/index.ts b/libs/cockpit-docs/src/index.ts deleted file mode 100644 index ce0c6f00b..000000000 --- a/libs/cockpit-docs/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './lib/docs-adapter-home'; -export * from './lib/docs-bundle'; -export * from './lib/docs-mapping-contracts'; diff --git a/libs/cockpit-docs/src/lib/docs-adapter-home.ts b/libs/cockpit-docs/src/lib/docs-adapter-home.ts deleted file mode 100644 index cd51ed59d..000000000 --- a/libs/cockpit-docs/src/lib/docs-adapter-home.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CockpitDocsAdapterContract } from './docs-mapping-contracts'; -import { COCKPIT_DOCS_MAPPING_KEYS } from './docs-mapping-contracts'; - -export const COCKPIT_DOCS_ADAPTER_HOME: CockpitDocsAdapterContract = { - name: 'cockpit-docs-content-adapter', - mappingKeys: COCKPIT_DOCS_MAPPING_KEYS, - description: 'Shared docs mapping contract for cockpit content adapters.', -}; diff --git a/libs/cockpit-docs/src/lib/docs-bundle.spec.ts b/libs/cockpit-docs/src/lib/docs-bundle.spec.ts deleted file mode 100644 index 32b9b5319..000000000 --- a/libs/cockpit-docs/src/lib/docs-bundle.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getDocsBundles, - resolveDocsBundle, - toCockpitHref, - toDocsSlug, -} from './docs-bundle'; - -describe('resolveDocsBundle', () => { - it('resolves an exact docs bundle by topic, page, and language', () => { - expect( - resolveDocsBundle({ - product: 'langgraph', - section: 'core-capabilities', - topic: 'streaming', - page: 'build', - language: 'python', - }) - ).toMatchObject({ - title: 'LangGraph Streaming Build', - sourcePath: - 'langgraph/core-capabilities/streaming/python/build.mdx', - }); - }); - - it('falls back to the product getting-started overview when no equivalent language bundle exists', () => { - expect( - resolveDocsBundle({ - product: 'langgraph', - section: 'core-capabilities', - topic: 'streaming', - page: 'build', - language: 'typescript', - }) - ).toMatchObject({ - product: 'langgraph', - section: 'getting-started', - topic: 'overview', - page: 'overview', - language: 'python', - }); - }); -}); - -describe('docs bundle links', () => { - it('generates metadata-driven website and cockpit links from a bundle', () => { - const bundle = resolveDocsBundle({ - product: 'deep-agents', - section: 'core-capabilities', - topic: 'planning', - page: 'overview', - language: 'python', - }); - - expect(toDocsSlug(bundle)).toEqual([ - 'deep-agents', - 'core-capabilities', - 'planning', - 'overview', - 'python', - ]); - expect(toCockpitHref(bundle)).toBe( - '/deep-agents/core-capabilities/planning/overview/python' - ); - }); -}); - -describe('docs bundle matrix coverage', () => { - it('covers every approved topic with canonical python docs pages', () => { - const expectedTopics = [ - ['deep-agents', 'getting-started', 'overview'], - ['deep-agents', 'core-capabilities', 'planning'], - ['deep-agents', 'core-capabilities', 'filesystem'], - ['deep-agents', 'core-capabilities', 'subagents'], - ['deep-agents', 'core-capabilities', 'memory'], - ['deep-agents', 'core-capabilities', 'skills'], - ['langgraph', 'getting-started', 'overview'], - ['langgraph', 'core-capabilities', 'persistence'], - ['langgraph', 'core-capabilities', 'durable-execution'], - ['langgraph', 'core-capabilities', 'streaming'], - ['langgraph', 'core-capabilities', 'interrupts'], - ['langgraph', 'core-capabilities', 'memory'], - ['langgraph', 'core-capabilities', 'subgraphs'], - ['langgraph', 'core-capabilities', 'time-travel'], - ['langgraph', 'core-capabilities', 'deployment-runtime'], - ['render', 'getting-started', 'overview'], - ['render', 'core-capabilities', 'spec-rendering'], - ['render', 'core-capabilities', 'element-rendering'], - ['render', 'core-capabilities', 'state-management'], - ['render', 'core-capabilities', 'registry'], - ['render', 'core-capabilities', 'repeat-loops'], - ['render', 'core-capabilities', 'computed-functions'], - ['chat', 'getting-started', 'overview'], - ['chat', 'core-capabilities', 'messages'], - ['chat', 'core-capabilities', 'input'], - ['chat', 'core-capabilities', 'interrupts'], - ['chat', 'core-capabilities', 'tool-calls'], - ['chat', 'core-capabilities', 'subagents'], - ['chat', 'core-capabilities', 'threads'], - ['chat', 'core-capabilities', 'timeline'], - ['chat', 'core-capabilities', 'generative-ui'], - ['chat', 'core-capabilities', 'debug'], - ['chat', 'core-capabilities', 'theming'], - ] as const; - const bundles = getDocsBundles(); - const pageCounts = new Map(); - - for (const bundle of bundles) { - const key = `${bundle.product}/${bundle.section}/${bundle.topic}/${bundle.language}`; - pageCounts.set(key, (pageCounts.get(key) ?? 0) + 1); - } - - for (const [product, section, topic] of expectedTopics) { - expect(pageCounts.get(`${product}/${section}/${topic}/python`)).toBe( - section === 'getting-started' ? 1 : 5 - ); - } - }); -}); diff --git a/libs/cockpit-docs/src/lib/docs-bundle.ts b/libs/cockpit-docs/src/lib/docs-bundle.ts deleted file mode 100644 index b920be64d..000000000 --- a/libs/cockpit-docs/src/lib/docs-bundle.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - cockpitManifest, - type CockpitLanguage, - type CockpitPageId, - type CockpitProduct, -} from '@threadplane/cockpit-registry'; - -export interface DocsBundle { - product: CockpitProduct; - section: 'getting-started' | 'core-capabilities'; - topic: string; - page: CockpitPageId; - language: CockpitLanguage; - title: string; - sourcePath: string; -} - -export interface ResolveDocsBundleOptions { - product: CockpitProduct; - section: DocsBundle['section']; - topic: string; - page: DocsBundle['page']; - language: CockpitLanguage; -} - -const PAGE_TITLE_SUFFIX = { - overview: 'Overview', - build: 'Build', - prompts: 'Prompts', - code: 'Code', - testing: 'Testing', -} as const satisfies Record; - -const CAPABILITY_PAGES: CockpitPageId[] = [ - 'overview', - 'build', - 'prompts', - 'code', - 'testing', -]; - -const docsBundles: DocsBundle[] = cockpitManifest.flatMap((entry) => { - const pages = - entry.entryKind === 'docs-only' ? (['overview'] as CockpitPageId[]) : CAPABILITY_PAGES; - - return pages.map((page) => ({ - product: entry.product, - section: entry.section, - topic: entry.topic, - page, - language: entry.language, - title: - entry.entryKind === 'docs-only' - ? entry.title - : `${entry.title} ${PAGE_TITLE_SUFFIX[page]}`, - sourcePath: `${entry.product}/${entry.section}/${entry.topic}/${entry.language}/${page}.mdx`, - })); -}); - -const findBundle = (options: ResolveDocsBundleOptions): DocsBundle | undefined => - docsBundles.find( - (bundle) => - bundle.product === options.product && - bundle.section === options.section && - bundle.topic === options.topic && - bundle.page === options.page && - bundle.language === options.language - ); - -export const resolveDocsBundle = ( - options: ResolveDocsBundleOptions -): DocsBundle => { - const exactBundle = findBundle(options); - - if (exactBundle) { - return exactBundle; - } - - const matchingManifestEntry = cockpitManifest.find( - (entry) => - entry.product === options.product && - entry.section === options.section && - entry.topic === options.topic - ); - - if (!matchingManifestEntry) { - throw new Error( - `No manifest entry for ${options.product}/${options.section}/${options.topic}` - ); - } - - return findBundle({ - product: options.product, - section: 'getting-started', - topic: 'overview', - page: 'overview', - language: 'python', - }) as DocsBundle; -}; - -export const toDocsSlug = (bundle: DocsBundle): string[] => [ - bundle.product, - bundle.section, - bundle.topic, - bundle.page, - bundle.language, -]; - -export const toCockpitHref = (bundle: DocsBundle): string => - `/${bundle.product}/${bundle.section}/${bundle.topic}/${bundle.page}/${bundle.language}`; - -export const getDocsBundles = (): DocsBundle[] => docsBundles; diff --git a/libs/cockpit-docs/src/lib/docs-mapping-contracts.ts b/libs/cockpit-docs/src/lib/docs-mapping-contracts.ts deleted file mode 100644 index c863da2b2..000000000 --- a/libs/cockpit-docs/src/lib/docs-mapping-contracts.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const COCKPIT_DOCS_MAPPING_KEYS = ['product', 'section', 'topic', 'page', 'language'] as const; - -export type CockpitDocsMappingKey = (typeof COCKPIT_DOCS_MAPPING_KEYS)[number]; - -export interface CockpitDocsMapping { - product: string; - section: string; - topic: string; - page: string; - language: string; - docsPath: string; -} - -export interface CockpitDocsAdapterContract { - name: string; - mappingKeys: readonly CockpitDocsMappingKey[]; - description: string; -} diff --git a/libs/cockpit-docs/tsconfig.json b/libs/cockpit-docs/tsconfig.json deleted file mode 100644 index a29e3d9a4..000000000 --- a/libs/cockpit-docs/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": false, - "emitDeclarationOnly": false - }, - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - } - ] -} diff --git a/libs/cockpit-docs/tsconfig.lib.json b/libs/cockpit-docs/tsconfig.lib.json deleted file mode 100644 index 6383c3cec..000000000 --- a/libs/cockpit-docs/tsconfig.lib.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "declaration": true, - "lib": ["es2022"], - "types": [] - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] -} diff --git a/libs/cockpit-docs/vite.config.mts b/libs/cockpit-docs/vite.config.mts deleted file mode 100644 index 6edeadec8..000000000 --- a/libs/cockpit-docs/vite.config.mts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite'; -import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; - -export default defineConfig({ - plugins: [nxViteTsPaths()], - test: { - environment: 'node', - globals: true, - include: ['src/**/*.spec.ts'], - }, -}); diff --git a/libs/cockpit-registry/src/lib/manifest.ts b/libs/cockpit-registry/src/lib/manifest.ts index 9f2df1697..46bff2eb4 100644 --- a/libs/cockpit-registry/src/lib/manifest.ts +++ b/libs/cockpit-registry/src/lib/manifest.ts @@ -198,8 +198,6 @@ const createEntry = ( entryKind: isDocsOnly ? 'docs-only' : 'capability', runtimeClass: isDocsOnly ? 'docs-only' : getRuntimeClass(topic), docsPath, - workspacePath: `/workspace/${product}/${topic}`, - legacyPath: `/${product}/${section}/${topic}/${page}/python`, runtimeAdapter, availableModes: deriveAvailableModes({ docsPath, diff --git a/libs/cockpit-registry/src/lib/manifest.types.ts b/libs/cockpit-registry/src/lib/manifest.types.ts index 615000c83..0c798509f 100644 --- a/libs/cockpit-registry/src/lib/manifest.types.ts +++ b/libs/cockpit-registry/src/lib/manifest.types.ts @@ -34,8 +34,6 @@ export interface WorkspaceIdentity { language: CockpitLanguage; title: string; docsPath: string; - workspacePath: string; - legacyPath: string; runtimeAdapter: RuntimeAdapter; availableModes: readonly WorkspaceMode[]; } @@ -97,8 +95,6 @@ export interface CockpitManifestEntry extends CockpitManifestIdentity { entryKind: CockpitEntryKind; runtimeClass: CockpitRuntimeClass; docsPath: string; - workspacePath: string; - legacyPath: string; runtimeAdapter: RuntimeAdapter; availableModes: readonly WorkspaceMode[]; promptAssetPaths: string[]; diff --git a/libs/cockpit-registry/src/lib/validate-manifest.spec.ts b/libs/cockpit-registry/src/lib/validate-manifest.spec.ts index f95eb5dbf..689b63ca5 100644 --- a/libs/cockpit-registry/src/lib/validate-manifest.spec.ts +++ b/libs/cockpit-registry/src/lib/validate-manifest.spec.ts @@ -90,19 +90,18 @@ describe('validateCockpitManifest', () => { ); }); - it.each([ - ['stable ID', 'id', 'Duplicate stable ID'], - ['workspace path', 'workspacePath', 'Duplicate workspace path'], - ['legacy path', 'legacyPath', 'Duplicate legacy path'], - ] as const)('rejects duplicate %ss', (_label, field, errorCategory) => { - const first = getLangGraphEntry('streaming'); - const second = getLangGraphEntry('interrupts'); - const invalidManifest = [first, { ...second, [field]: first[field] }]; - - expect(validateManifest(invalidManifest)).toContain( - `${errorCategory}: ${first[field]}` - ); - }); + it.each([['stable ID', 'id', 'Duplicate stable ID']] as const)( + 'rejects duplicate %ss', + (_label, field, errorCategory) => { + const first = getLangGraphEntry('streaming'); + const second = getLangGraphEntry('interrupts'); + const invalidManifest = [first, { ...second, [field]: first[field] }]; + + expect(validateManifest(invalidManifest)).toContain( + `${errorCategory}: ${first[field]}` + ); + } + ); it('rejects two entries that publish the same Docs path', () => { const first = getLangGraphEntry('streaming'); @@ -122,17 +121,16 @@ describe('validateCockpitManifest', () => { ); }); - it.each([ - ['docsPath', 'docs/not-absolute'], - ['workspacePath', 'workspace/langgraph/streaming'], - ['legacyPath', 'langgraph/core-capabilities/streaming/overview/python'], - ] as const)('rejects an invalid %s', (field, value) => { - const entry = getLangGraphEntry('streaming'); + it.each([['docsPath', 'docs/not-absolute']] as const)( + 'rejects an invalid %s', + (field, value) => { + const entry = getLangGraphEntry('streaming'); - expect(validateManifest([{ ...entry, [field]: value }])).toContain( - `Invalid ${field} for ${entry.id}: ${value}` - ); - }); + expect(validateManifest([{ ...entry, [field]: value }])).toContain( + `Invalid ${field} for ${entry.id}: ${value}` + ); + } + ); it('accepts runnable static entries with no configurable runtime adapter', () => { const entry = getLangGraphEntry('streaming'); diff --git a/libs/cockpit-registry/src/lib/validate-manifest.ts b/libs/cockpit-registry/src/lib/validate-manifest.ts index 75142bee8..829aed146 100644 --- a/libs/cockpit-registry/src/lib/validate-manifest.ts +++ b/libs/cockpit-registry/src/lib/validate-manifest.ts @@ -18,8 +18,6 @@ export const validateManifest = ( const errors: string[] = []; const identities = new Set(); const stableIds = new Set(); - const workspacePaths = new Set(); - const legacyPaths = new Set(); for (const entry of manifest) { const key = identityKey(entry); @@ -42,18 +40,6 @@ export const validateManifest = ( } else { stableIds.add(entry.id); } - - if (workspacePaths.has(entry.workspacePath)) { - errors.push(`Duplicate workspace path: ${entry.workspacePath}`); - } else { - workspacePaths.add(entry.workspacePath); - } - - if (legacyPaths.has(entry.legacyPath)) { - errors.push(`Duplicate legacy path: ${entry.legacyPath}`); - } else { - legacyPaths.add(entry.legacyPath); - } } const docsPaths = new Set(); @@ -76,18 +62,6 @@ export const validateManifest = ( ) { errors.push(`Invalid docsPath for ${entry.id}: ${entry.docsPath}`); } - - const expectedWorkspacePath = `/workspace/${entry.product}/${entry.topic}`; - if (entry.workspacePath !== expectedWorkspacePath) { - errors.push( - `Invalid workspacePath for ${entry.id}: ${entry.workspacePath}` - ); - } - - const expectedLegacyPath = `/${entry.product}/${entry.section}/${entry.topic}/${entry.page}/${entry.language}`; - if (entry.legacyPath !== expectedLegacyPath) { - errors.push(`Invalid legacyPath for ${entry.id}: ${entry.legacyPath}`); - } } for (const entry of manifest) { diff --git a/libs/cockpit-registry/src/lib/workspace-resolution.spec.ts b/libs/cockpit-registry/src/lib/workspace-resolution.spec.ts index 7143aa1d0..2389417f0 100644 --- a/libs/cockpit-registry/src/lib/workspace-resolution.spec.ts +++ b/libs/cockpit-registry/src/lib/workspace-resolution.spec.ts @@ -4,10 +4,7 @@ import { getCanonicalWebsiteWorkspaceHref, getWorkspaceDestinationPath, getRouteDefaultMode, - resolveLegacyRequestMode, resolveDocsWorkspace, - resolveLegacyPath, - resolveWorkspacePath, } from './workspace-resolution'; describe('workspace identity resolution', () => { @@ -22,33 +19,6 @@ describe('workspace identity resolution', () => { } }); - it('round-trips canonical workspace and legacy paths', () => { - for (const entry of cockpitManifest) { - expect(resolveWorkspacePath(entry.workspacePath)).toMatchObject({ - kind: 'mapped', - identity: { id: entry.id }, - }); - expect(resolveLegacyPath(entry.legacyPath)).toMatchObject({ - kind: 'mapped', - identity: { id: entry.id }, - }); - } - }); - - it('round-trips every canonical Website destination to the same stable identity', () => { - for (const entry of cockpitManifest) { - const destination = getWorkspaceDestinationPath(entry); - const resolution = destination.startsWith('/docs/') - ? resolveDocsWorkspace(destination, entry.title) - : resolveWorkspacePath(destination); - - expect(resolution).toMatchObject({ - kind: 'mapped', - identity: { id: entry.id }, - }); - } - }); - it('round-trips unique canonical Docs paths', () => { const publishedEntries = cockpitManifest.filter( (entry) => entry.docsPath.length > 0 @@ -94,7 +64,6 @@ describe('workspace identity resolution', () => { getWorkspaceDestinationPath({ id: 'langgraph:core-capabilities:durable-execution:overview:python', docsPath: '/docs/langgraph/guides/durable-execution', - workspacePath: '/workspace/langgraph/durable-execution', }) ).toBe('/docs/langgraph/guides/durable-execution'); }); @@ -113,28 +82,17 @@ describe('workspace identity resolution', () => { }); }); - it('uses Docs for Docs and docs-only routes and Run only for runnable workspace routes', () => { + it('defaults every route to Docs', () => { const mappedDocs = resolveDocsWorkspace( '/docs/langgraph/guides/streaming', 'Streaming' ); - const runnableWorkspace = resolveWorkspacePath( - '/workspace/langgraph/streaming' - ); - const narrativeOnlyWorkspace = resolveWorkspacePath( - '/workspace/langgraph/overview' - ); const docsOnly = resolveDocsWorkspace( '/docs/langgraph/api/inject-agent', 'Inject an agent into Angular' ); - - expect(getRouteDefaultMode(mappedDocs, 'docs')).toBe('Docs'); - expect(getRouteDefaultMode(docsOnly, 'docs')).toBe('Docs'); - expect(getRouteDefaultMode(runnableWorkspace, 'workspace')).toBe('Run'); - expect(getRouteDefaultMode(narrativeOnlyWorkspace, 'workspace')).toBe( - 'Docs' - ); + expect(getRouteDefaultMode(mappedDocs)).toBe('Docs'); + expect(getRouteDefaultMode(docsOnly)).toBe('Docs'); }); it('never fuzzy-matches Docs slugs', () => { @@ -145,80 +103,15 @@ describe('workspace identity resolution', () => { }); describe('canonical Website workspace destinations', () => { - it('applies the legacy Cockpit default to absent, duplicate, invalid, and unavailable modes for every manifest entry', () => { - for (const entry of cockpitManifest) { - const resolution = resolveLegacyPath(entry.legacyPath); - expect(resolution).not.toBeNull(); - if (!resolution) continue; - - const expectedDefault = getRouteDefaultMode(resolution, 'workspace'); - const unavailableMode = (['Run', 'Code', 'API', 'Docs'] as const).find( - (mode) => !entry.availableModes.includes(mode) - ); - - expect(resolveLegacyRequestMode(undefined, resolution)).toBe( - expectedDefault - ); - expect(resolveLegacyRequestMode(['run', 'code'], resolution)).toBe( - expectedDefault - ); - expect(resolveLegacyRequestMode('invalid', resolution)).toBe( - expectedDefault - ); - if (unavailableMode) { - expect( - resolveLegacyRequestMode(unavailableMode.toLowerCase(), resolution) - ).toBe(expectedDefault); - } - } - }); - - it('resolves valid legacy modes case-insensitively and defaults runnable and narrative-only requests correctly', () => { - const runnable = resolveLegacyPath( - '/langgraph/core-capabilities/streaming/overview/python' - ); - const narrativeOnly = resolveLegacyPath( - '/langgraph/getting-started/overview/overview/python' - ); - expect(runnable).not.toBeNull(); - expect(narrativeOnly).not.toBeNull(); - if (!runnable || !narrativeOnly) return; - - expect(resolveLegacyRequestMode('CoDe', runnable)).toBe('Code'); - expect(resolveLegacyRequestMode(undefined, runnable)).toBe('Run'); - expect(resolveLegacyRequestMode('run', narrativeOnly)).toBe('Docs'); - expect(resolveLegacyRequestMode(['docs'], narrativeOnly)).toBe('Docs'); - }); - - it('serializes every manifest entry and mode to its canonical relative Website href', () => { - for (const entry of cockpitManifest) { - const resolution = resolveLegacyPath(entry.legacyPath); - expect(resolution).not.toBeNull(); - if (!resolution) continue; - - const destinationPath = getWorkspaceDestinationPath(entry); - for (const mode of ['Docs', 'Run', 'Code', 'API'] as const) { - const expectedHref = - mode === 'Docs' && destinationPath.startsWith('/docs/') - ? destinationPath - : `${destinationPath}?mode=${mode.toLowerCase()}`; - expect(getCanonicalWebsiteWorkspaceHref(resolution, mode)).toBe( - expectedHref - ); - } - } - }); - it('omits Docs mode on every canonical Docs path', () => { - const primary = resolveLegacyPath( - '/langgraph/core-capabilities/persistence/overview/python' + const primary = resolveDocsWorkspace( + '/docs/langgraph/guides/persistence', + 'Persistence' ); - const secondary = resolveLegacyPath( - '/langgraph/core-capabilities/durable-execution/overview/python' + const secondary = resolveDocsWorkspace( + '/docs/langgraph/guides/durable-execution', + 'Durable Execution' ); - expect(primary).not.toBeNull(); - expect(secondary).not.toBeNull(); - if (!primary || !secondary) return; expect(getCanonicalWebsiteWorkspaceHref(primary, 'Docs')).toBe( '/docs/langgraph/guides/persistence' diff --git a/libs/cockpit-registry/src/lib/workspace-resolution.ts b/libs/cockpit-registry/src/lib/workspace-resolution.ts index 91031c1a2..001dcc95e 100644 --- a/libs/cockpit-registry/src/lib/workspace-resolution.ts +++ b/libs/cockpit-registry/src/lib/workspace-resolution.ts @@ -17,14 +17,12 @@ export const toWorkspaceIdentity = ( language: entry.language, title: entry.title, docsPath: entry.docsPath, - workspacePath: entry.workspacePath, - legacyPath: entry.legacyPath, runtimeAdapter: entry.runtimeAdapter, availableModes: entry.availableModes, }); export const getWorkspaceDestinationPath = ( - identity: Pick + identity: Pick ): string => { if (!identity.docsPath) { throw new Error(`Manifest entry without a docs path: ${identity.id}`); @@ -58,58 +56,9 @@ export const resolveDocsWorkspace = ( }; }; -export const resolveWorkspacePath = ( - workspacePath: string, - manifest: readonly CockpitManifestEntry[] = cockpitManifest -): WorkspaceResolution | null => { - const entry = manifest.find( - (candidate) => candidate.workspacePath === workspacePath - ); - return entry ? mapped(entry) : null; -}; - -export const resolveLegacyPath = ( - legacyPath: string, - manifest: readonly CockpitManifestEntry[] = cockpitManifest -): WorkspaceResolution | null => { - const entry = manifest.find( - (candidate) => candidate.legacyPath === legacyPath - ); - return entry ? mapped(entry) : null; -}; - export const getRouteDefaultMode = ( - resolution: WorkspaceResolution | null, - routeKind: 'docs' | 'workspace' -): WorkspaceMode => { - if (routeKind === 'docs' || !resolution || resolution.kind === 'docs-only') { - return 'Docs'; - } - - return resolution.identity.availableModes.includes('Run') ? 'Run' : 'Docs'; -}; - -const LEGACY_REQUEST_MODES: Readonly> = { - docs: 'Docs', - run: 'Run', - code: 'Code', - api: 'API', -}; - -export const resolveLegacyRequestMode = ( - rawMode: string | string[] | undefined, - resolution: WorkspaceResolution -): WorkspaceMode => { - const fallback = getRouteDefaultMode(resolution, 'workspace'); - if (typeof rawMode !== 'string' || resolution.kind !== 'mapped') { - return fallback; - } - - const requested = LEGACY_REQUEST_MODES[rawMode.toLowerCase()]; - return requested && resolution.identity.availableModes.includes(requested) - ? requested - : fallback; -}; + _resolution: WorkspaceResolution | null +): WorkspaceMode => 'Docs'; export const getCanonicalWebsiteWorkspaceHref = ( resolution: WorkspaceResolution, diff --git a/libs/cockpit-shell/src/lib/workspace-content.spec.ts b/libs/cockpit-shell/src/lib/workspace-content.spec.ts index e8fe1f340..10c00f39b 100644 --- a/libs/cockpit-shell/src/lib/workspace-content.spec.ts +++ b/libs/cockpit-shell/src/lib/workspace-content.spec.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { capabilityModules, cockpitManifest, - resolveWorkspacePath, + resolveDocsWorkspace, type CockpitManifestEntry, } from '@threadplane/cockpit-registry'; import { @@ -351,7 +351,10 @@ describe('getContentBundle', () => { }); it('loads workspace-only capabilities from the same registry assets', async () => { - const resolution = resolveWorkspacePath('/workspace/deep-agents/memory'); + const resolution = resolveDocsWorkspace( + '/docs/deep-agents/capabilities/memory', + 'Deep Agents Memory' + ); expect(resolution).not.toBeNull(); if (!resolution) return; const presentation = getWorkspacePresentation(resolution); diff --git a/libs/cockpit-shell/src/lib/workspace-presentation.spec.ts b/libs/cockpit-shell/src/lib/workspace-presentation.spec.ts index 15185c4c1..0752d5bf3 100644 --- a/libs/cockpit-shell/src/lib/workspace-presentation.spec.ts +++ b/libs/cockpit-shell/src/lib/workspace-presentation.spec.ts @@ -11,7 +11,6 @@ import { import { capabilityModules, resolveDocsWorkspace, - resolveWorkspacePath, } from '@threadplane/cockpit-registry'; describe('resolveCockpitEntry', () => { @@ -431,7 +430,10 @@ describe('getWorkspacePresentation', () => { }); it('shapes a mapped workspace identity with registry-owned descriptor assets', () => { - const resolution = resolveWorkspacePath('/workspace/langgraph/streaming'); + const resolution = resolveDocsWorkspace( + '/docs/langgraph/guides/streaming', + 'Streaming' + ); expect(resolution).not.toBeNull(); if (!resolution) return; diff --git a/libs/cockpit-testing/package.json b/libs/cockpit-testing/package.json deleted file mode 100644 index 11b19ce10..000000000 --- a/libs/cockpit-testing/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@threadplane/cockpit-testing", - "version": "0.0.29", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/cacheplane/angular-agent-framework.git", - "directory": "libs/cockpit-testing" - }, - "homepage": "https://github.com/cacheplane/angular-agent-framework#readme", - "bugs": { - "url": "https://github.com/cacheplane/angular-agent-framework/issues" - }, - "sideEffects": false, - "private": true -} diff --git a/libs/cockpit-testing/project.json b/libs/cockpit-testing/project.json deleted file mode 100644 index 162fb419b..000000000 --- a/libs/cockpit-testing/project.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "cockpit-testing", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "libs/cockpit-testing/src", - "projectType": "library", - "targets": { - "build": { - "executor": "@nx/js:tsc", - "outputs": [ - "{workspaceRoot}/dist/libs/cockpit-testing" - ], - "options": { - "outputPath": "dist/libs/cockpit-testing", - "main": "libs/cockpit-testing/src/index.ts", - "tsConfig": "libs/cockpit-testing/tsconfig.lib.json" - } - } - }, - "tags": [ - "scope:cockpit", - "scope:cockpit-deploy-smoke", - "scope:cockpit-e2e", - "scope:cockpit-examples" - ] -} diff --git a/libs/cockpit-testing/src/index.ts b/libs/cockpit-testing/src/index.ts deleted file mode 100644 index fdac80e58..000000000 --- a/libs/cockpit-testing/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './lib/smoke-harness-home'; -export * from './lib/smoke-assertion-contracts'; diff --git a/libs/cockpit-testing/src/lib/smoke-assertion-contracts.ts b/libs/cockpit-testing/src/lib/smoke-assertion-contracts.ts deleted file mode 100644 index 4f86449af..000000000 --- a/libs/cockpit-testing/src/lib/smoke-assertion-contracts.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const COCKPIT_SMOKE_ASSERTION_STATUSES = ['pending', 'pass', 'fail'] as const; - -export type CockpitSmokeAssertionStatus = (typeof COCKPIT_SMOKE_ASSERTION_STATUSES)[number]; - -export interface CockpitSmokeAssertion { - name: string; - target: string; - description: string; - status: CockpitSmokeAssertionStatus; -} - -export interface CockpitSmokeHarnessContract { - name: string; - assertions: readonly CockpitSmokeAssertion[]; -} diff --git a/libs/cockpit-testing/src/lib/smoke-harness-home.ts b/libs/cockpit-testing/src/lib/smoke-harness-home.ts deleted file mode 100644 index 3027c15ef..000000000 --- a/libs/cockpit-testing/src/lib/smoke-harness-home.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CockpitSmokeHarnessContract } from './smoke-assertion-contracts'; - -export const COCKPIT_SMOKE_HARNESS_HOME: CockpitSmokeHarnessContract = { - name: 'cockpit-smoke-harness', - assertions: [], -}; diff --git a/libs/cockpit-testing/tsconfig.json b/libs/cockpit-testing/tsconfig.json deleted file mode 100644 index 190834ef3..000000000 --- a/libs/cockpit-testing/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": false, - "emitDeclarationOnly": false, - "baseUrl": "." - }, - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - } - ] -} diff --git a/libs/cockpit-testing/tsconfig.lib.json b/libs/cockpit-testing/tsconfig.lib.json deleted file mode 100644 index 6383c3cec..000000000 --- a/libs/cockpit-testing/tsconfig.lib.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "declaration": true, - "lib": ["es2022"], - "types": [] - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] -} diff --git a/libs/cockpit-ui/package.json b/libs/cockpit-ui/package.json deleted file mode 100644 index a07f96d7c..000000000 --- a/libs/cockpit-ui/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@threadplane/cockpit-ui", - "version": "0.0.29", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/cacheplane/angular-agent-framework.git", - "directory": "libs/cockpit-ui" - }, - "homepage": "https://github.com/cacheplane/angular-agent-framework#readme", - "bugs": { - "url": "https://github.com/cacheplane/angular-agent-framework/issues" - }, - "sideEffects": false, - "private": true -} diff --git a/libs/cockpit-ui/project.json b/libs/cockpit-ui/project.json deleted file mode 100644 index f8ace0e44..000000000 --- a/libs/cockpit-ui/project.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "cockpit-ui", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "libs/cockpit-ui/src", - "projectType": "library", - "targets": { - "build": { - "executor": "@nx/js:tsc", - "outputs": [ - "{workspaceRoot}/dist/libs/cockpit-ui" - ], - "options": { - "outputPath": "dist/libs/cockpit-ui", - "main": "libs/cockpit-ui/src/index.ts", - "tsConfig": "libs/cockpit-ui/tsconfig.lib.json" - } - } - }, - "tags": [ - "scope:cockpit", - "scope:cockpit-deploy-smoke", - "scope:cockpit-e2e", - "scope:cockpit-examples" - ] -} diff --git a/libs/cockpit-ui/src/index.ts b/libs/cockpit-ui/src/index.ts deleted file mode 100644 index 2e7cfa387..000000000 --- a/libs/cockpit-ui/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './lib/ui-primitive-home'; -export * from './lib/ui-primitive-contracts'; diff --git a/libs/cockpit-ui/src/lib/ui-primitive-contracts.ts b/libs/cockpit-ui/src/lib/ui-primitive-contracts.ts deleted file mode 100644 index 139784af2..000000000 --- a/libs/cockpit-ui/src/lib/ui-primitive-contracts.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const COCKPIT_UI_VARIANTS = ['neutral', 'accent', 'warning'] as const; - -export type CockpitUiVariant = (typeof COCKPIT_UI_VARIANTS)[number]; - -export interface CockpitUiPrimitive { - name: string; - variant: CockpitUiVariant; - description: string; -} - -export interface CockpitUiPrimitiveSet { - name: string; - primitives: readonly CockpitUiPrimitive[]; -} diff --git a/libs/cockpit-ui/src/lib/ui-primitive-home.ts b/libs/cockpit-ui/src/lib/ui-primitive-home.ts deleted file mode 100644 index 5c396941b..000000000 --- a/libs/cockpit-ui/src/lib/ui-primitive-home.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CockpitUiPrimitiveSet } from './ui-primitive-contracts'; - -export const COCKPIT_UI_PRIMITIVE_HOME: CockpitUiPrimitiveSet = { - name: 'cockpit-ui-primitives', - primitives: [], -}; diff --git a/libs/cockpit-ui/tsconfig.json b/libs/cockpit-ui/tsconfig.json deleted file mode 100644 index 190834ef3..000000000 --- a/libs/cockpit-ui/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": false, - "emitDeclarationOnly": false, - "baseUrl": "." - }, - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - } - ] -} diff --git a/libs/cockpit-ui/tsconfig.lib.json b/libs/cockpit-ui/tsconfig.lib.json deleted file mode 100644 index 6383c3cec..000000000 --- a/libs/cockpit-ui/tsconfig.lib.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "declaration": true, - "lib": ["es2022"], - "types": [] - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] -} diff --git a/libs/workspace-react/src/lib/public-api.spec.tsx b/libs/workspace-react/src/lib/public-api.spec.tsx index c78352e1f..c89a37410 100644 --- a/libs/workspace-react/src/lib/public-api.spec.tsx +++ b/libs/workspace-react/src/lib/public-api.spec.tsx @@ -92,7 +92,6 @@ describe('@threadplane/workspace-react public boundary', () => { docSections: [], narrativeDocs: [], }} - routeKind="docs" routePath={resolution.docsPath} requestedMode="docs" docsSlot={
workspace docs
} diff --git a/libs/workspace-react/src/lib/workspace-provider.spec.tsx b/libs/workspace-react/src/lib/workspace-provider.spec.tsx index 4e3be37db..867c0c69f 100644 --- a/libs/workspace-react/src/lib/workspace-provider.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-provider.spec.tsx @@ -30,8 +30,6 @@ const identity: WorkspaceIdentity = { language: 'python', title: 'Streaming', docsPath: '/docs/langgraph/guides/streaming', - workspacePath: '/workspace/langgraph/streaming', - legacyPath: '/langgraph/core-capabilities/streaming/overview/python', runtimeAdapter: 'langgraph', availableModes: ['Docs', 'Run', 'Code', 'API'], }; @@ -81,7 +79,7 @@ function Readout() { type="button" onClick={() => workspace.hostServices.navigate({ - path: identity.legacyPath, + path: identity.docsPath, restoreFocus: 'mobile-navigation-trigger', }) } @@ -154,8 +152,7 @@ const providerProps = ( resolution, presentation, contentBundle, - routeKind: 'docs', - routePath: identity.docsPath ?? identity.workspacePath, + routePath: identity.docsPath, requestedMode: 'code', docsSlot:
Server docs
, pushIdentity: vi.fn(), @@ -167,11 +164,7 @@ const providerProps = ( describe('WorkspaceProvider route mode ownership', () => { beforeEach(() => { - window.history.replaceState( - {}, - '', - identity.docsPath ?? identity.workspacePath - ); + window.history.replaceState({}, '', identity.docsPath); }); it('initializes a syntactically valid and available query mode', () => { @@ -199,7 +192,6 @@ describe('WorkspaceProvider route mode ownership', () => { {...providerProps({ resolution: limitedResolution, presentation: { ...presentation, identity: limitedIdentity }, - routeKind: 'workspace', requestedMode: 'run', replaceMode, })} @@ -217,7 +209,6 @@ describe('WorkspaceProvider route mode ownership', () => { {...providerProps({ resolution: limitedResolution, presentation: { ...presentation, identity: limitedIdentity }, - routeKind: 'workspace', requestedMode: 'preview', replaceMode, })} @@ -259,8 +250,7 @@ describe('WorkspaceProvider route mode ownership', () => { render( { window.history.replaceState( {}, '', - `${identity.workspacePath}?mode=api&mode=docs` + `${identity.docsPath}?mode=api&mode=docs` ); window.dispatchEvent(new PopStateEvent('popstate')); }); await waitFor(() => - expect(screen.getByLabelText('Active mode').textContent).toBe('Run') + expect(screen.getByLabelText('Active mode').textContent).toBe('Docs') ); - expect(replaceMode).toHaveBeenCalledWith('Run'); + expect(replaceMode).toHaveBeenCalledWith('Docs'); }); it('pushes explicit user mode changes, records Activity, and preserves the mode through utility switches', () => { @@ -331,7 +321,7 @@ describe('WorkspaceProvider route mode ownership', () => { fireEvent.click( screen.getByRole('button', { name: 'Navigate with focus' }) ); - expect(pushIdentity).toHaveBeenCalledWith(identity.legacyPath, { + expect(pushIdentity).toHaveBeenCalledWith(identity.docsPath, { restoreFocus: 'mobile-navigation-trigger', }); }); @@ -347,7 +337,6 @@ describe('WorkspaceProvider route mode ownership', () => { topic: 'memory', title: 'Memory', docsPath: '/docs/langgraph/guides/memory', - workspacePath: '/workspace/langgraph/memory', availableModes: ['Docs', 'Run'], }; const replaceMode = vi.fn(); diff --git a/libs/workspace-react/src/lib/workspace-provider.tsx b/libs/workspace-react/src/lib/workspace-provider.tsx index 83112db79..adc5ed8a6 100644 --- a/libs/workspace-react/src/lib/workspace-provider.tsx +++ b/libs/workspace-react/src/lib/workspace-provider.tsx @@ -59,7 +59,6 @@ export interface WorkspaceProviderProps { readonly resolution: WorkspaceResolution; readonly presentation: WorkspacePresentation; readonly contentBundle: ContentBundle; - readonly routeKind: 'docs' | 'workspace'; /** Path represented by resolution; popstate for any other path belongs to the host router. */ readonly routePath: string; readonly requestedMode: string | null; @@ -133,12 +132,11 @@ const routeIdentityKey = (resolution: WorkspaceResolution): string => const normalizedMode = ( rawMode: string | null, resolution: WorkspaceResolution, - routeKind: 'docs' | 'workspace', availability: WorkspaceModeAvailabilityMap ): WorkspaceMode => { const requested = parseControlPlaneMode(rawMode); if (requested && availability[requested].available) return requested; - return getRouteDefaultMode(resolution, routeKind); + return getRouteDefaultMode(resolution); }; const createLocalActivityInput = ( @@ -157,14 +155,13 @@ export function WorkspaceProvider({ resolution, presentation, contentBundle, - routeKind, routePath, requestedMode, docsSlot = null, pushIdentity, pushMode, replaceMode, - resolveIdentityHref = (entry) => entry.workspacePath, + resolveIdentityHref = (entry) => entry.docsPath, getSessionId, runtimeTelemetry, trackNavigation, @@ -180,7 +177,7 @@ export function WorkspaceProvider({ [presentation, resolution] ); const [activeMode, setActiveMode] = useState(() => - normalizedMode(requestedMode, resolution, routeKind, modeAvailability) + normalizedMode(requestedMode, resolution, modeAvailability) ); const [activeUtility, setActiveUtilityState] = useState(null); @@ -224,12 +221,7 @@ export function WorkspaceProvider({ const applyRouteMode = useCallback( (rawMode: string | null, normalizeUrl: boolean) => { const requested = parseControlPlaneMode(rawMode); - const nextMode = normalizedMode( - rawMode, - resolution, - routeKind, - modeAvailability - ); + const nextMode = normalizedMode(rawMode, resolution, modeAvailability); setActiveMode(nextMode); if ( normalizeUrl && @@ -247,7 +239,7 @@ export function WorkspaceProvider({ lastNormalizationRef.current = null; } }, - [modeAvailability, replaceMode, resolution, routeKind] + [modeAvailability, replaceMode, resolution] ); const resolutionKey = routeIdentityKey(resolution); diff --git a/libs/workspace-react/src/lib/workspace-shell.spec.tsx b/libs/workspace-react/src/lib/workspace-shell.spec.tsx index 41ffdf188..cd0261ffd 100644 --- a/libs/workspace-react/src/lib/workspace-shell.spec.tsx +++ b/libs/workspace-react/src/lib/workspace-shell.spec.tsx @@ -91,11 +91,9 @@ function renderWorkspace(options: { resolution={selectedResolution} presentation={selectedPresentation} contentBundle={selectedContent} - routeKind="docs" routePath={ selectedResolution.kind === 'mapped' - ? selectedResolution.identity.docsPath ?? - selectedResolution.identity.workspacePath + ? selectedResolution.identity.docsPath : selectedResolution.docsPath } requestedMode={options.requestedMode ?? 'docs'} @@ -336,8 +334,7 @@ describe('WorkspaceShell persistent panel composition', () => { resolution={resolution} presentation={presentation} contentBundle={contentBundle} - routeKind="workspace" - routePath={identity.legacyPath} + routePath={identity.docsPath} requestedMode="docs" pushIdentity={vi.fn()} pushMode={vi.fn()} @@ -409,8 +406,7 @@ describe('WorkspaceShell persistent panel composition', () => { resolution={{ kind: 'mapped', identity: limitedIdentity }} presentation={{ ...presentation, identity: limitedIdentity }} contentBundle={contentBundle} - routeKind="docs" - routePath={limitedIdentity.docsPath ?? limitedIdentity.workspacePath} + routePath={limitedIdentity.docsPath} requestedMode="docs" docsSlot={
Limited article
} pushIdentity={vi.fn()} diff --git a/package-lock.json b/package-lock.json index 16970cb8a..12e1ac8a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,15 +114,6 @@ "zod": "^3.25.76" } }, - "apps/cockpit": { - "version": "0.0.1", - "dependencies": { - "@threadplane/cockpit-registry": "*", - "next": "~16.1.6", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } - }, "apps/growth-research": { "name": "@threadplane-internal/growth-research", "version": "0.0.0", @@ -1670,11 +1661,6 @@ } } }, - "libs/cockpit-docs": { - "name": "@threadplane/cockpit-docs", - "version": "0.0.29", - "license": "MIT" - }, "libs/cockpit-registry": { "name": "@threadplane/cockpit-registry", "version": "0.0.29", @@ -1753,16 +1739,6 @@ "web-vitals": "^5.1.0" } }, - "libs/cockpit-testing": { - "name": "@threadplane/cockpit-testing", - "version": "0.0.29", - "license": "MIT" - }, - "libs/cockpit-ui": { - "name": "@threadplane/cockpit-ui", - "version": "0.0.29", - "license": "MIT" - }, "libs/design-tokens": { "name": "@threadplane/design-tokens", "version": "0.0.35", @@ -22506,10 +22482,6 @@ "resolved": "libs/chat", "link": true }, - "node_modules/@threadplane/cockpit-docs": { - "resolved": "libs/cockpit-docs", - "link": true - }, "node_modules/@threadplane/cockpit-registry": { "resolved": "libs/cockpit-registry", "link": true @@ -22526,14 +22498,6 @@ "resolved": "libs/cockpit-telemetry", "link": true }, - "node_modules/@threadplane/cockpit-testing": { - "resolved": "libs/cockpit-testing", - "link": true - }, - "node_modules/@threadplane/cockpit-ui": { - "resolved": "libs/cockpit-ui", - "link": true - }, "node_modules/@threadplane/design-tokens": { "resolved": "libs/design-tokens", "link": true @@ -27391,10 +27355,6 @@ "node": ">=6" } }, - "node_modules/cockpit": { - "resolved": "apps/cockpit", - "link": true - }, "node_modules/code-block-writer": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz", diff --git a/scripts/ag-ui-proxy.ts b/scripts/ag-ui-proxy.ts index c7ff800ad..21c09095c 100644 --- a/scripts/ag-ui-proxy.ts +++ b/scripts/ag-ui-proxy.ts @@ -51,7 +51,6 @@ function upstreamBaseFor(topic: string): string | null { const ALLOWED_ORIGINS = new Set([ 'https://examples.threadplane.ai', - 'https://cockpit.threadplane.ai', 'http://localhost:4320', 'http://localhost:4321', ]); diff --git a/scripts/examples-middleware.ts b/scripts/examples-middleware.ts index d043d2b8c..d97f0b5d3 100644 --- a/scripts/examples-middleware.ts +++ b/scripts/examples-middleware.ts @@ -15,7 +15,6 @@ const SHARED_DEPLOYMENT_URL = 'https://cockpit-dev-219a15942c545a00a03a9a41905d7 const ALLOWED_ORIGINS = [ 'https://examples.threadplane.ai', - 'https://cockpit.threadplane.ai', 'http://localhost:4320', 'http://localhost:4321', ] as const; diff --git a/scripts/examples/capability-wiring.spec.ts b/scripts/examples/capability-wiring.spec.ts index 9810c10ff..4f396524a 100644 --- a/scripts/examples/capability-wiring.spec.ts +++ b/scripts/examples/capability-wiring.spec.ts @@ -548,17 +548,6 @@ function auditThreadsRootProviders( * the whole suite stayed green. These assertions are the missing coupling. */ describe('cockpit capability wiring', () => { - const resolveCockpitConfig = (fileName: string): string => { - const workspaceConfigPath = resolve( - process.cwd(), - 'apps/cockpit', - fileName - ); - return existsSync(workspaceConfigPath) - ? workspaceConfigPath - : resolve(process.cwd(), fileName); - }; - const manifestKey = (e: { product: string; section: string; @@ -2690,31 +2679,4 @@ describe('cockpit capability wiring', () => { ); }); - it('has no direct project references to capability example lanes', () => { - const tsconfig = JSON.parse( - readFileSync(resolveCockpitConfig('tsconfig.json'), 'utf8') - ) as { references?: Array<{ path: string }> }; - - expect( - (tsconfig.references ?? []).filter((reference) => - reference.path.startsWith('../../cockpit/') - ) - ).toEqual([]); - }); - - it('keeps redirect deployment inputs without tracing interactive content assets', () => { - const project = JSON.parse( - readFileSync(resolveCockpitConfig('project.json'), 'utf8') - ) as { - targets: { build: { inputs: string[] } }; - namedInputs: Record; - }; - - expect(project.targets.build.inputs).toEqual([ - 'default', - 'deploymentConfig', - '^default', - ]); - expect(project.namedInputs['contentAssets']).toBeUndefined(); - }); }); diff --git a/scripts/examples/e2e-wiring.spec.ts b/scripts/examples/e2e-wiring.spec.ts index 6107a5652..3ad1b3612 100644 --- a/scripts/examples/e2e-wiring.spec.ts +++ b/scripts/examples/e2e-wiring.spec.ts @@ -137,8 +137,6 @@ describe('cockpit e2e wiring', () => { expect( existsSync(join(repoRoot, 'apps/cockpit/e2e/production-smoke.spec.ts')) ).toBe(false); - expect(smoke).toContain('getCanonicalWebsiteWorkspaceHref'); - expect(smoke).toContain('smokeCase.location'); expect(smoke).toContain('getWorkspaceDestinationPath'); expect(smoke).toContain('EXAMPLES_URL'); expect(smoke).toContain('DEMO_URL'); diff --git a/scripts/generate-runtime-parent-origins.spec.ts b/scripts/generate-runtime-parent-origins.spec.ts index dadc2eb1e..1fa6b2abd 100644 --- a/scripts/generate-runtime-parent-origins.spec.ts +++ b/scripts/generate-runtime-parent-origins.spec.ts @@ -155,10 +155,6 @@ describe('generate runtime parent origins', () => { resolve(__dirname, './project.json'), 'utf8' ); - const cockpitProject = readFileSync( - resolve(__dirname, '../apps/cockpit/project.json'), - 'utf8' - ); const workflow = readFileSync( resolve(__dirname, '../.github/workflows/ci.yml'), 'utf8' @@ -170,8 +166,6 @@ describe('generate runtime parent origins', () => { expect(assembly).toContain('RUNTIME_PARENT_PREVIEW_ORIGINS'); expect(scriptsProject).toContain('runtime-parent-origins.json'); expect(scriptsProject).toContain('generate-runtime-parent-origins.ts'); - expect(cockpitProject).toContain('runtime-parent-origins.json'); - expect(cockpitProject).toContain('generate-runtime-parent-origins.ts'); expect(workflow).toContain('runtime-parent-origins\\.json'); expect(workflow).toContain('generate-runtime-parent-origins'); expect(workflow).toContain('RUNTIME_PARENT_PREVIEW_ORIGINS'); diff --git a/tsconfig.base.json b/tsconfig.base.json index c67e0e3fa..aeebe9b41 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -23,7 +23,6 @@ "@threadplane/chat": ["libs/chat/src/public-api.ts"], "@threadplane/chat/debug": ["libs/chat/debug/public-api.ts"], "@threadplane/chat/testing": ["libs/chat/testing/public-api.ts"], - "@threadplane/cockpit-docs": ["libs/cockpit-docs/src/index.ts"], "@threadplane/cockpit-langgraph-streaming-python": [ "cockpit/langgraph/streaming/python/src/index.ts" ], @@ -35,8 +34,6 @@ "@threadplane/ui-react": ["libs/ui-react/src/index.ts"], "@threadplane/workspace-react": ["libs/workspace-react/src/index.ts"], "@threadplane/cockpit-telemetry": ["libs/cockpit-telemetry/src/index.ts"], - "@threadplane/cockpit-testing": ["libs/cockpit-testing/src/index.ts"], - "@threadplane/cockpit-ui": ["libs/cockpit-ui/src/index.ts"], "@threadplane/example-layouts": ["libs/example-layouts/src/public-api.ts"], "@threadplane/langgraph": ["libs/langgraph/src/public-api.ts"], "@threadplane/marketing-agent": ["marketing/agent/src/index.ts"], diff --git a/tsconfig.json b/tsconfig.json index 8ba07e16a..0db5770f2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -57,9 +57,6 @@ { "path": "./libs/ui-react" }, - { - "path": "./apps/cockpit" - }, { "path": "./libs/a2ui" } diff --git a/vercel.cockpit.json b/vercel.cockpit.json deleted file mode 100644 index 4a744a7e4..000000000 --- a/vercel.cockpit.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": "nextjs", - "buildCommand": "npx nx build cockpit --skip-nx-cache", - "outputDirectory": "dist/apps/cockpit/.next", - "installCommand": "npm ci", - "routes": [ - { - "src": "^(?:.*//.*|.*(?:\\\\|%5[cC]|%2[fF]).*|.*(?:^|/)(?:\\.{1,2}|%2[eE](?:%2[eE])?)(?:/|$).*)$", - "status": 404 - } - ] -} From 3a263b7762c2ab3c41948628af2f4f7f6c14c0cd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 11:40:09 -0700 Subject: [PATCH 4/6] ci: drop the cockpit redirect jobs and deploy steps Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 190 ++----------- CONTRIBUTING.md | 38 ++- libs/a2ui/project.json | 1 - libs/ag-ui/project.json | 1 - libs/chat/project.json | 1 - libs/cockpit-registry/project.json | 1 - libs/cockpit-runtime-bridge/project.json | 1 - libs/cockpit-shell/project.json | 1 - libs/cockpit-telemetry/project.json | 1 - libs/design-tokens/project.json | 1 - libs/e2e-harness/project.json | 1 - libs/example-layouts/project.json | 1 - libs/langgraph/project.json | 1 - libs/render/project.json | 1 - libs/telemetry/project.json | 1 - libs/ui-react/project.json | 1 - scripts/ci-scope.mjs | 6 +- scripts/ci-scope.spec.mjs | 75 ++---- scripts/ci-workflow.spec.mjs | 326 ++--------------------- 19 files changed, 65 insertions(+), 584 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ebef32e2..b1bc15066 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,6 @@ jobs: cockpit: ${{ steps.scope.outputs.cockpit }} cockpit_examples: ${{ steps.scope.outputs.cockpit_examples }} cockpit_smoke: ${{ steps.scope.outputs.cockpit_smoke }} - cockpit_deploy_smoke: ${{ steps.scope.outputs.cockpit_deploy_smoke }} examples_chat: ${{ steps.scope.outputs.examples_chat }} examples_ag_ui: ${{ steps.scope.outputs.examples_ag_ui }} cockpit_e2e: ${{ steps.scope.outputs.cockpit_e2e }} @@ -291,7 +290,7 @@ jobs: - run: npx nx build growth-research cockpit: - name: Cockpit — build / test + name: Workspace libraries — lint / test needs: ci-scope if: github.event_name == 'push' || needs.ci-scope.outputs.cockpit == 'true' runs-on: ubuntu-latest @@ -302,17 +301,17 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npx nx build cockpit --skip-nx-cache - # cockpit-docs, cockpit-registry and workspace-react carry `test` targets - # that nothing in CI invoked: `nx test` does not walk `^test`, and the - # `library` job runs a hardcoded LIBS list that excludes all three. Name - # them here so their specs actually execute. They all share the - # `scope:cockpit` tag, so ci-scope already gates this job correctly for - # changes under any of them. workspace-react's only other scope tag is - # `scope:shared`, which is not a SCOPE_KEY — adding it to LIBS would not - # have run it, because a workspace-react change never flips `library`. + # cockpit-registry, cockpit-shell and workspace-react carry `test` + # targets that nothing in CI invoked: `nx test` does not walk `^test`, + # and the `library` job runs a hardcoded LIBS list that excludes all + # three. Name them here so their specs actually execute. They all share + # the `scope:cockpit` tag, so ci-scope already gates this job correctly + # for changes under any of them. workspace-react's only other scope tag + # is `scope:shared`, which is not a SCOPE_KEY — adding it to LIBS would + # not have run it, because a workspace-react change never flips + # `library`. - run: npx nx lint workspace-react - - run: npx nx run-many -t test --projects=cockpit,cockpit-docs,cockpit-registry,cockpit-shell,workspace-react --skip-nx-cache + - run: npx nx run-many -t test --projects=cockpit-registry,cockpit-shell,workspace-react --skip-nx-cache cockpit-examples-build: name: Cockpit — build all examples needs: ci-scope @@ -358,85 +357,6 @@ jobs: - run: npm ci - run: npx nx run-many -t smoke --projects=cockpit-ag-ui-interrupts-python,cockpit-ag-ui-streaming-python,cockpit-deep-agents-planning-python,cockpit-deep-agents-filesystem-python,cockpit-deep-agents-subagents-python,cockpit-deep-agents-memory-python,cockpit-deep-agents-skills-python,cockpit-langgraph-persistence-python,cockpit-langgraph-durable-execution-python,cockpit-langgraph-streaming-python,cockpit-langgraph-interrupts-python,cockpit-langgraph-memory-python,cockpit-langgraph-subgraphs-python,cockpit-langgraph-time-travel-python,cockpit-langgraph-deployment-runtime-python,cockpit-chat-messages-python,cockpit-render-spec-rendering-python,cockpit-runtimes-microsoft-agent-framework-python,cockpit-runtimes-aws-strands-python --skip-nx-cache - cockpit-deploy-smoke: - name: Cockpit — deploy smoke dry-run - timeout-minutes: 30 # fail fast instead of blocking the main concurrency group on a hang - needs: ci-scope - if: github.event_name == 'push' || needs.ci-scope.outputs.cockpit_deploy_smoke == 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: 22 - cache: npm - - run: npm ci - - run: npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --mode preview --dry-run - - cockpit-preview-smoke: - name: Cockpit — immutable preview smoke - timeout-minutes: 20 - needs: ci-scope - # PR-side twin of the deploy job's cockpit verification. Deploys a - # throwaway preview of the redirect service and runs the exhaustive smoke - # against it, so platform behaviour (deployment protection, the CDN's - # slash collapse, route ordering) is exercised before merge. Needs - # repository secrets, so same-repo PRs and merge-queue candidates only; - # the token-free dry-run job above still covers forks. - if: >- - github.event_name != 'push' && - needs.ci-scope.outputs.cockpit_deploy_smoke == 'true' && - (github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: 22 - cache: npm - - run: npm ci - - name: Prepare cockpit Vercel project (preview) - run: | - mkdir -p .vercel - cat > .vercel/project.json <<'EOF' - {"projectId":"${{ secrets.VERCEL_COCKPIT_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-cockpit"} - EOF - npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} - rm -rf .vercel/output - - name: Build cockpit redirect service (preview) - env: - COCKPIT_WEBSITE_ORIGIN: https://threadplane.ai - run: | - npx vercel build --local-config vercel.cockpit.json --token=${{ secrets.VERCEL_TOKEN }} - - name: Deploy throwaway cockpit preview - id: deploy_cockpit_preview - run: | - set -euo pipefail - url=$(npx vercel deploy --prebuilt --archive=tgz --yes --env COCKPIT_WEBSITE_ORIGIN=https://threadplane.ai --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1) - url=$(node -e 'const parsed = new URL(process.argv[1]); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("Vercel returned a non-origin cockpit preview URL"); process.stdout.write(parsed.origin)' "$url") - echo "deployment_url=$url" >> "$GITHUB_OUTPUT" - - name: Exhaustively verify the cockpit preview - run: | - if [ -z "${VERCEL_AUTOMATION_BYPASS_SECRET}" ]; then - echo "::error::VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET is unset — the protected cockpit preview cannot be verified. Enable 'Protection Bypass for Automation' on the Vercel threadplane-cockpit project and store the value as this repository secret." - exit 1 - fi - npx tsx apps/cockpit/scripts/deploy-smoke.ts --url "${{ steps.deploy_cockpit_preview.outputs.deployment_url }}" --mode preview --retries 20 --retry-delay-ms 5000 - env: - VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET }} - - name: Remove the throwaway cockpit preview - # Removal runs on every outcome, including a failed smoke: the smoke - # log carries the full probe-by-probe report, and keeping a failed - # artifact would only accumulate protected deployments nobody can - # open without the bypass. - # The artifact exists only to be smoked. `--safe` refuses to remove a - # deployment that carries an alias, so this can never touch the - # production artifact. `remove` takes a bare URL, so, like `promote`, - # it needs the team scope spelled out. - if: always() && steps.deploy_cockpit_preview.outputs.deployment_url != '' - continue-on-error: true - run: npx vercel remove "${{ steps.deploy_cockpit_preview.outputs.deployment_url }}" --safe --yes --scope=${{ secrets.VERCEL_ORG_ID }} --token=${{ secrets.VERCEL_TOKEN }} - examples-chat-smoke: name: examples/chat — python smoke needs: ci-scope @@ -875,14 +795,12 @@ jobs: - cockpit - cockpit-examples-build - cockpit-smoke - - cockpit-deploy-smoke - examples-chat-smoke - examples-chat-e2e - examples-ag-ui-e2e - cockpit-e2e-summary - website-e2e - website-preview-e2e - - cockpit-preview-smoke - posthog-sync-plan - scripts-tests - growth-lifecycle @@ -903,14 +821,12 @@ jobs: RESULT_COCKPIT: ${{ needs.cockpit.result }} RESULT_COCKPIT_EXAMPLES: ${{ needs.cockpit-examples-build.result }} RESULT_COCKPIT_SMOKE: ${{ needs.cockpit-smoke.result }} - RESULT_COCKPIT_DEPLOY_SMOKE: ${{ needs.cockpit-deploy-smoke.result }} RESULT_EXAMPLES_CHAT_SMOKE: ${{ needs.examples-chat-smoke.result }} RESULT_EXAMPLES_CHAT_E2E: ${{ needs.examples-chat-e2e.result }} RESULT_EXAMPLES_AG_UI_E2E: ${{ needs.examples-ag-ui-e2e.result }} RESULT_COCKPIT_E2E: ${{ needs.cockpit-e2e-summary.result }} RESULT_WEBSITE_E2E: ${{ needs.website-e2e.result }} RESULT_WEBSITE_PREVIEW_E2E: ${{ needs.website-preview-e2e.result }} - RESULT_COCKPIT_PREVIEW_SMOKE: ${{ needs.cockpit-preview-smoke.result }} RESULT_POSTHOG: ${{ needs.posthog-sync-plan.result }} RESULT_SCRIPTS_TESTS: ${{ needs.scripts-tests.result }} RESULT_GROWTH_LIFECYCLE: ${{ needs.growth-lifecycle.result }} @@ -922,7 +838,6 @@ jobs: SCOPE_COCKPIT: ${{ needs.ci-scope.outputs.cockpit }} SCOPE_COCKPIT_EXAMPLES: ${{ needs.ci-scope.outputs.cockpit_examples }} SCOPE_COCKPIT_SMOKE: ${{ needs.ci-scope.outputs.cockpit_smoke }} - SCOPE_COCKPIT_DEPLOY_SMOKE: ${{ needs.ci-scope.outputs.cockpit_deploy_smoke }} SCOPE_EXAMPLES_CHAT: ${{ needs.ci-scope.outputs.examples_chat }} SCOPE_EXAMPLES_AG_UI: ${{ needs.ci-scope.outputs.examples_ag_ui }} SCOPE_COCKPIT_E2E: ${{ needs.ci-scope.outputs.cockpit_e2e }} @@ -1004,7 +919,6 @@ jobs: require_scoped "cockpit" "Cockpit — build / test" "$RESULT_COCKPIT" "$SCOPE_COCKPIT" require_scoped "cockpit_examples" "Cockpit — build all examples" "$RESULT_COCKPIT_EXAMPLES" "$SCOPE_COCKPIT_EXAMPLES" require_scoped "cockpit_smoke" "Cockpit — representative capability smoke" "$RESULT_COCKPIT_SMOKE" "$SCOPE_COCKPIT_SMOKE" - require_scoped "cockpit_deploy_smoke" "Cockpit — deploy smoke dry-run" "$RESULT_COCKPIT_DEPLOY_SMOKE" "$SCOPE_COCKPIT_DEPLOY_SMOKE" require_scoped "examples_chat" "examples/chat — python smoke" "$RESULT_EXAMPLES_CHAT_SMOKE" "$SCOPE_EXAMPLES_CHAT" require_scoped "examples_chat" "examples/chat — e2e" "$RESULT_EXAMPLES_CHAT_E2E" "$SCOPE_EXAMPLES_CHAT" require_scoped \ @@ -1015,7 +929,6 @@ jobs: require_scoped "cockpit_e2e" "Cockpit — e2e" "$RESULT_COCKPIT_E2E" "$SCOPE_COCKPIT_E2E" require_scoped "website_e2e" "Website — e2e" "$RESULT_WEBSITE_E2E" "$SCOPE_WEBSITE_E2E" require_preview "website_e2e" "Website — e2e (deployed preview)" "$RESULT_WEBSITE_PREVIEW_E2E" "$SCOPE_WEBSITE_E2E" - require_preview "cockpit_deploy_smoke" "Cockpit — immutable preview smoke" "$RESULT_COCKPIT_PREVIEW_SMOKE" "$SCOPE_COCKPIT_DEPLOY_SMOKE" require_scoped "posthog" "PostHog — dashboards-as-code drift check" "$RESULT_POSTHOG" "$SCOPE_POSTHOG" require_scoped "scripts_tests" "Scripts — generator / proxy vitest suites" "$RESULT_SCRIPTS_TESTS" "$SCOPE_SCRIPTS_TESTS" require_scoped "growth_lifecycle" "Growth lifecycle — Node 22" "$RESULT_GROWTH_LIFECYCLE" "$SCOPE_GROWTH_LIFECYCLE" @@ -1037,7 +950,6 @@ jobs: - cockpit - cockpit-examples-build - cockpit-smoke - - cockpit-deploy-smoke - examples-chat-smoke - examples-chat-e2e - cockpit-e2e-summary @@ -1132,7 +1044,7 @@ jobs: changed_files="$(git diff --name-only "$base_sha" "$head_sha")" deploy_relevant=false - if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|runtime-parent-origins\.json|vercel\.(json|cockpit\.json|examples\.json)|apps/(website|cockpit)/.*|cockpit/.*|examples/chat/.*|libs/.*|scripts/(assemble-examples|deploy-smoke|demo-middleware|generate-runtime-parent-origins|langgraph-proxy|rate-limit)\.ts|scripts/assemble-demo\.ts)$' >/dev/null; then + if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|runtime-parent-origins\.json|vercel\.(json|examples\.json)|apps/website/.*|cockpit/.*|examples/chat/.*|libs/.*|scripts/(assemble-examples|demo-middleware|generate-runtime-parent-origins|langgraph-proxy|rate-limit)\.ts|scripts/assemble-demo\.ts)$' >/dev/null; then deploy_relevant=true fi @@ -1173,7 +1085,6 @@ jobs: # VERCEL_TOKEN — vercel.com/account/tokens # VERCEL_ORG_ID — Vercel team id # VERCEL_WEBSITE_PROJECT_ID — website project id - # VERCEL_COCKPIT_PROJECT_ID — cockpit project id # VERCEL_EXAMPLES_PROJECT_ID — examples project id - if: steps.deploy_preflight.outputs.relevant == 'true' || steps.examples_changed.outputs.changed == 'true' run: npm ci @@ -1192,28 +1103,18 @@ jobs: changed_files="$(git diff --name-only "$base_sha" "$head_sha")" website_changed=false - cockpit_changed=false if printf '%s\n' "$affected_projects" | grep -Fx 'website' >/dev/null; then website_changed=true fi - if printf '%s\n' "$affected_projects" | grep -Fx 'cockpit' >/dev/null; then - cockpit_changed=true - fi - if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|vercel\.json)$' >/dev/null; then website_changed=true fi - if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|vercel\.cockpit\.json)$' >/dev/null; then - cockpit_changed=true - fi - echo "website=$website_changed" >> "$GITHUB_OUTPUT" - echo "cockpit=$cockpit_changed" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers - if: steps.freshness.outputs.stale != 'true' && (steps.affected.outputs.website == 'true' || steps.affected.outputs.cockpit == 'true') + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cache/ms-playwright @@ -1221,7 +1122,7 @@ jobs: restore-keys: | playwright-${{ runner.os }}- - name: Install Playwright browsers - if: steps.freshness.outputs.stale != 'true' && (steps.affected.outputs.website == 'true' || steps.affected.outputs.cockpit == 'true') + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' run: npx playwright install --with-deps chromium - name: Prepare website Vercel project if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' @@ -1304,70 +1205,10 @@ jobs: # "Deployment doesn't belong to current team". Scope it explicitly. run: npx vercel promote "${{ steps.deploy_website.outputs.deployment_url }}" --scope=${{ secrets.VERCEL_ORG_ID }} --yes --token=${{ secrets.VERCEL_TOKEN }} - name: Verify deployed website - if: steps.freshness.outputs.stale != 'true' && ((steps.affected.outputs.website == 'true' && steps.website_promotion_freshness.outputs.fresh == 'true') || steps.affected.outputs.cockpit == 'true') + if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' && steps.website_promotion_freshness.outputs.fresh == 'true' run: npx nx e2e website --skip-nx-cache env: BASE_URL: https://threadplane.ai - - name: Prepare cockpit Vercel project - if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' - run: | - mkdir -p .vercel - cat > .vercel/project.json <> "$GITHUB_OUTPUT" - - name: Exhaustively verify immutable cockpit preview - if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' - run: | - # Deployment protection answers every path on the unaliased artifact - # with 302 -> vercel.com/sso-api, which the smoke reports as - # "expected 308, received 302". Bypass secrets are issued per Vercel - # project, so the Website secret cannot open this one. Say so. - if [ -z "${VERCEL_AUTOMATION_BYPASS_SECRET}" ]; then - echo "::error::VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET is unset — the protected immutable cockpit preview cannot be verified. Enable 'Protection Bypass for Automation' on the Vercel threadplane-cockpit project and store the value as this repository secret." - exit 1 - fi - npx tsx apps/cockpit/scripts/deploy-smoke.ts --url "${{ steps.deploy_cockpit.outputs.deployment_url }}" --mode preview --retries 20 --retry-delay-ms 5000 - env: - VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET }} - - name: Check this commit is still the tip before cockpit promotion - if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' - id: cockpit_promotion_freshness - run: | - tip="$(git ls-remote origin refs/heads/main | cut -f1)" - if [ -z "$tip" ]; then - echo "::error::Could not resolve the tip of main after preview verification; refusing the irreversible Cockpit redirect promotion." - exit 1 - fi - if [ "$tip" != "${{ github.sha }}" ]; then - echo "fresh=false" >> "$GITHUB_OUTPUT" - echo "::warning::main advanced to ${tip} during Cockpit preview verification; leaving the production alias untouched for the newer run." - else - echo "fresh=true" >> "$GITHUB_OUTPUT" - fi - - name: Promote verified cockpit artifact unchanged - if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' && steps.cockpit_promotion_freshness.outputs.fresh == 'true' - # `promote` takes a bare deployment URL, so unlike build/deploy/pull it - # cannot read the team from .vercel/project.json and falls back to the - # token's default team — a personal one here, which fails with - # "Deployment doesn't belong to current team". Scope it explicitly. - run: npx vercel promote "${{ steps.deploy_cockpit.outputs.deployment_url }}" --scope=${{ secrets.VERCEL_ORG_ID }} --yes --token=${{ secrets.VERCEL_TOKEN }} - - name: Verify production cockpit redirects - if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' && steps.cockpit_promotion_freshness.outputs.fresh == 'true' - run: npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --mode production --retries 20 --retry-delay-ms 5000 demo-deploy: name: Canonical demo → Vercel @@ -1644,7 +1485,6 @@ jobs: env: PRODUCTION_SMOKE: 'true' BASE_URL: https://threadplane.ai - COCKPIT_URL: https://cockpit.threadplane.ai WEBSITE_URL: https://threadplane.ai EXAMPLES_URL: https://examples.threadplane.ai RUNTIME_PARENT_PREVIEW_ORIGINS: ${{ needs.deploy.outputs.runtime_parent_preview_origin }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ebe26fd0..7c88937a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ cause, and each is fixed by the install above: | --- | --- | | `Cannot find module 'posthog-node'` when building `website` | Installed only into `apps/website/node_modules`, never hoisted. The walk up from the worktree's `apps/website/` never passes through the main checkout's. | | `Could not resolve "node_modules/katex/dist/katex.min.css"` | Referenced by literal path from the workspace root, not by module resolution — so there is no upward walk to inherit through. | -| `Next.js inferred your workspace root, but it may not be correct` when building `cockpit` | Turbopack resolves its own workspace root and will not compile outside it. | +| `Next.js inferred your workspace root, but it may not be correct` when building `website` | Turbopack resolves its own workspace root and will not compile outside it. | Do not fix these by copying individual packages from the main checkout. The list keeps growing, a partial copy pulls in a package without its transitive @@ -48,17 +48,18 @@ Do not run `npm install` in a worktree on macOS either: it rewrites `package-lock.json` and drops the Linux `@next/swc-*` bindings, which breaks CI. `npm ci` is the safe command. -One more worktree-only trap, after install: `nx build cockpit` can die with a +One more worktree-only trap, after install: `nx build website` can die with a Turbopack panic — `FileSystemPath(...).join(...) leaves the filesystem root` — -whenever a website `.next` cache exists anywhere in the workspace -(`apps/website/.next` from `next dev`/e2e, or `dist/apps/website` from a build). -Those caches contain a `node_modules` symlink whose relative target resolves -through the *main* checkout's root; the path is valid on disk, but Turbopack's -virtual filesystem refuses to traverse above its project root and panics. -Pinning `turbopack.root` does not help. Clear the caches instead: +whenever a stale website `.next` cache exists anywhere in the workspace +(`apps/website/.next` from `next dev`/e2e, or `dist/apps/website` from a +previous build). Those caches contain a `node_modules` symlink whose relative +target resolves through the *main* checkout's root; the path is valid on +disk, but Turbopack's virtual filesystem refuses to traverse above its +project root and panics. Pinning `turbopack.root` does not help. Clear the +caches instead: ```bash -rm -rf apps/website/.next dist/apps/website && npx nx build cockpit +rm -rf apps/website/.next dist/apps/website && npx nx build website ``` CI never hits this — its jobs build from fresh checkouts that are not nested @@ -197,8 +198,8 @@ not an alternative. ### PR-side deploy verification -Two lanes run the deploy job's verification on pull requests against real -Vercel previews, so deploy-only failures surface before merge: +One lane runs the deploy job's verification on pull requests against a real +Vercel preview, so deploy-only failures surface before merge: - **Website — e2e (deployed preview)** builds and deploys the Website and the examples as previews under deterministic aliases @@ -209,15 +210,12 @@ Vercel previews, so deploy-only failures surface before merge: the Website alias in their parent-origin policy and Playwright seeds the examples origin's bypass cookie (`apps/website/e2e/runtime-bypass-setup.ts`). A later push re-points both aliases; the deployments behind them are kept. -- **Cockpit — immutable preview smoke** deploys a throwaway cockpit preview, - runs the exhaustive redirect smoke against it, and removes it. - -Both need repository secrets and therefore skip on fork PRs; the required -gate only demands them when they were eligible to run. Each Vercel project -has its own Protection Bypass for Automation secret: -`VERCEL_AUTOMATION_BYPASS_SECRET` (Website), -`VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET`, and -`VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET`. A secret added while a run is in + +It needs repository secrets and therefore skips on fork PRs; the required +gate only demands it when it was eligible to run. Each Vercel project has its +own Protection Bypass for Automation secret: +`VERCEL_AUTOMATION_BYPASS_SECRET` (Website) and +`VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET`. A secret added while a run is in flight does not reach that run; re-run after provisioning. Never pass `--skip-domain` to a preview deploy; Vercel requires it to accompany `--prod`. diff --git a/libs/a2ui/project.json b/libs/a2ui/project.json index 0c0708fd7..646d6f1a3 100644 --- a/libs/a2ui/project.json +++ b/libs/a2ui/project.json @@ -6,7 +6,6 @@ "tags": [ "scope:angular-compatibility", "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples", "scope:cockpit-smoke", diff --git a/libs/ag-ui/project.json b/libs/ag-ui/project.json index 3df97734c..9d93dae89 100644 --- a/libs/ag-ui/project.json +++ b/libs/ag-ui/project.json @@ -7,7 +7,6 @@ "tags": [ "scope:angular-compatibility", "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples", "scope:cockpit-smoke", diff --git a/libs/chat/project.json b/libs/chat/project.json index 2ef4dfdb2..d1c2540f8 100644 --- a/libs/chat/project.json +++ b/libs/chat/project.json @@ -7,7 +7,6 @@ "tags": [ "scope:angular-compatibility", "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples", "scope:cockpit-smoke", diff --git a/libs/cockpit-registry/project.json b/libs/cockpit-registry/project.json index ad4323a09..eba2a2860 100644 --- a/libs/cockpit-registry/project.json +++ b/libs/cockpit-registry/project.json @@ -5,7 +5,6 @@ "projectType": "library", "tags": [ "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples" ], diff --git a/libs/cockpit-runtime-bridge/project.json b/libs/cockpit-runtime-bridge/project.json index 871dcca37..0e19fcaa3 100644 --- a/libs/cockpit-runtime-bridge/project.json +++ b/libs/cockpit-runtime-bridge/project.json @@ -5,7 +5,6 @@ "projectType": "library", "tags": [ "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples" ], diff --git a/libs/cockpit-shell/project.json b/libs/cockpit-shell/project.json index 4c781c70b..2b7da3aeb 100644 --- a/libs/cockpit-shell/project.json +++ b/libs/cockpit-shell/project.json @@ -36,7 +36,6 @@ }, "tags": [ "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples" ] diff --git a/libs/cockpit-telemetry/project.json b/libs/cockpit-telemetry/project.json index fafddaf30..7315d7648 100644 --- a/libs/cockpit-telemetry/project.json +++ b/libs/cockpit-telemetry/project.json @@ -6,7 +6,6 @@ "projectType": "library", "tags": [ "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples" ], diff --git a/libs/design-tokens/project.json b/libs/design-tokens/project.json index 83c8b08fb..737318f79 100644 --- a/libs/design-tokens/project.json +++ b/libs/design-tokens/project.json @@ -5,7 +5,6 @@ "projectType": "library", "tags": [ "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples", "scope:shared", diff --git a/libs/e2e-harness/project.json b/libs/e2e-harness/project.json index c9e4e01ff..a19bd77c6 100644 --- a/libs/e2e-harness/project.json +++ b/libs/e2e-harness/project.json @@ -5,7 +5,6 @@ "sourceRoot": "libs/e2e-harness/src", "tags": [ "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples", "scope:internal" diff --git a/libs/example-layouts/project.json b/libs/example-layouts/project.json index c9bb0dd36..090444d16 100644 --- a/libs/example-layouts/project.json +++ b/libs/example-layouts/project.json @@ -6,7 +6,6 @@ "projectType": "library", "tags": [ "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples" ], diff --git a/libs/langgraph/project.json b/libs/langgraph/project.json index 762264ce5..001a826b3 100644 --- a/libs/langgraph/project.json +++ b/libs/langgraph/project.json @@ -7,7 +7,6 @@ "tags": [ "scope:angular-compatibility", "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples", "scope:cockpit-smoke", diff --git a/libs/render/project.json b/libs/render/project.json index 8ca34a712..05ec45251 100644 --- a/libs/render/project.json +++ b/libs/render/project.json @@ -7,7 +7,6 @@ "tags": [ "scope:angular-compatibility", "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples", "scope:cockpit-smoke", diff --git a/libs/telemetry/project.json b/libs/telemetry/project.json index a26fd7601..25d4dc2fb 100644 --- a/libs/telemetry/project.json +++ b/libs/telemetry/project.json @@ -6,7 +6,6 @@ "tags": [ "scope:angular-compatibility", "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples", "scope:cockpit-smoke", diff --git a/libs/ui-react/project.json b/libs/ui-react/project.json index f8471b012..cffca6966 100644 --- a/libs/ui-react/project.json +++ b/libs/ui-react/project.json @@ -5,7 +5,6 @@ "projectType": "library", "tags": [ "scope:cockpit", - "scope:cockpit-deploy-smoke", "scope:cockpit-e2e", "scope:cockpit-examples", "scope:shared", diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 3b4db1e3f..1c60d5802 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -12,7 +12,6 @@ export const SCOPE_KEYS = [ 'cockpit', 'cockpit_examples', 'cockpit_smoke', - 'cockpit_deploy_smoke', 'cockpit_e2e', 'examples_chat', 'examples_ag_ui', @@ -83,9 +82,8 @@ const LINT_SCOPE_KEYS = [ * cockpit//, which is outside every project root. `nx affected` * therefore attributes them to the `root` project, and `root` carries no * `scope:` tag — so a PR touching only these specs produced an empty scope - * and skipped the very job that runs them. They execute under - * `nx test cockpit` (see apps/cockpit/vite.config.mts), so map them onto the - * cockpit scope by path. */ + * and skipped the job that owns cockpit changes. Map them onto the cockpit + * scope by path so that job still runs. */ const COCKPIT_ROOTLESS_SPEC = /^cockpit\/[^/]+\/[^/]+\.spec\.ts$/; export function emptyScope() { diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index 77dd27637..337992a4b 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -19,7 +19,6 @@ const PUBLISHABLE_LIB_TAGS = [ 'scope:cockpit', 'scope:cockpit-examples', 'scope:cockpit-smoke', - 'scope:cockpit-deploy-smoke', 'scope:cockpit-e2e', 'scope:examples-chat', ]; @@ -33,12 +32,6 @@ const COCKPIT_CAP_PYTHON_TAGS = [ 'scope:cockpit-smoke', ]; const WEBSITE_TAGS = ['scope:website', 'scope:website-e2e']; -const COCKPIT_APP_TAGS = [ - 'scope:cockpit', - 'scope:cockpit-examples', - 'scope:cockpit-deploy-smoke', - 'scope:cockpit-e2e', -]; const EXAMPLES_CHAT_TAGS = [ 'scope:angular-compatibility', 'scope:examples-chat', @@ -132,7 +125,6 @@ describe('classifyFromAffected — lint-only files', () => { assert.equal(scope.cockpit_e2e, false); assert.equal(scope.cockpit_smoke, false); assert.equal(scope.cockpit_examples, false); - assert.equal(scope.cockpit_deploy_smoke, false); assert.equal(scope.posthog, false); }); @@ -281,7 +273,6 @@ describe('classifyFromAffected — rootless cockpit specs', () => { ); assert.equal(scope.cockpit_e2e, false); assert.equal(scope.cockpit_smoke, false); - assert.equal(scope.cockpit_deploy_smoke, false); assert.equal(scope.cockpit_examples, false); }); }); @@ -298,7 +289,6 @@ describe('classifyFromAffected — publishable lib broadcast', () => { assert.equal(scope.cockpit, true); assert.equal(scope.cockpit_examples, true); assert.equal(scope.cockpit_smoke, true); - assert.equal(scope.cockpit_deploy_smoke, true); assert.equal(scope.cockpit_e2e, true); assert.equal(scope.examples_chat, true); assert.equal(scope.angular_compatibility, true); @@ -307,7 +297,7 @@ describe('classifyFromAffected — publishable lib broadcast', () => { }); describe('classifyFromAffected — cockpit runtime bridge', () => { - it('selects Cockpit, examples, deploy smoke, and browser coverage through existing scopes', async () => { + it('selects Cockpit, examples, and browser coverage through existing scopes', async () => { const project = JSON.parse( await readFile('libs/cockpit-runtime-bridge/project.json', 'utf8') ); @@ -318,7 +308,6 @@ describe('classifyFromAffected — cockpit runtime bridge', () => { assert.equal(scope.cockpit, true); assert.equal(scope.cockpit_examples, true); - assert.equal(scope.cockpit_deploy_smoke, true); assert.equal(scope.cockpit_e2e, true); }); }); @@ -475,7 +464,6 @@ describe('classifyFromAffected — apps + fallback paths via namedInputs', () => name: 'cockpit-registry', tags: [ 'scope:cockpit', - 'scope:cockpit-deploy-smoke', 'scope:cockpit-e2e', 'scope:cockpit-examples', ], @@ -484,7 +472,6 @@ describe('classifyFromAffected — apps + fallback paths via namedInputs', () => ); assert.equal(scope.cockpit, true); assert.equal(scope.cockpit_examples, true); - assert.equal(scope.cockpit_deploy_smoke, true); assert.equal(scope.cockpit_e2e, true); }); @@ -499,20 +486,6 @@ describe('classifyFromAffected — apps + fallback paths via namedInputs', () => assert.equal(scope.cockpit_e2e, false); }); - it('the Cockpit Vercel gate selects redirect build and deploy smoke', async () => { - const project = JSON.parse( - await readFile('apps/cockpit/project.json', 'utf8') - ); - const scope = classifyFromAffected( - ['vercel.cockpit.json'], - [{ name: project.name, tags: project.tags }] - ); - - assert.equal(scope.cockpit, true); - assert.equal(scope.cockpit_deploy_smoke, true); - assert.equal(scope.cockpit_e2e, false); - }); - it('examples/chat change → examples_chat only', () => { const scope = classifyFromAffected( ['examples/chat/angular/src/main.ts'], @@ -602,7 +575,7 @@ describe('classifyFromAffected — examples/ag-ui', () => { }); describe('SCOPE_KEYS export', () => { - it('contains the 15 documented scope keys', () => { + it('contains the 14 documented scope keys', () => { assert.deepEqual(SCOPE_KEYS, [ 'library', 'angular_compatibility', @@ -611,7 +584,6 @@ describe('SCOPE_KEYS export', () => { 'cockpit', 'cockpit_examples', 'cockpit_smoke', - 'cockpit_deploy_smoke', 'cockpit_e2e', 'examples_chat', 'examples_ag_ui', @@ -623,51 +595,34 @@ describe('SCOPE_KEYS export', () => { }); }); -describe('classifyFromAffected — cockpit shell does not own the e2e matrix', () => { +describe('classifyFromAffected — cockpit e2e matrix stays scoped to its own caps', () => { // The cockpit-e2e matrix dispatches `nx e2e` for the standalone Angular cap - // apps under cockpit/**; none of them depends on the apps/cockpit Next.js - // shell, and no workflow runs the shell's own `e2e` target. A - // `scope:cockpit-e2e` tag on the shell therefore cannot select any real - // work — it can only over-select. It used to: apps/cockpit imports from - // apps/website, so a website-only PR made the shell nx-affected, flipped - // cockpit_e2e true, and (with no cap affected) hit the dispatcher's - // full-fleet fallback. PR #932 changed three apps/website/src files and ran - // the whole cap matrix. - it('apps/cockpit is not tagged scope:cockpit-e2e', async () => { - const project = JSON.parse( - await readFile('apps/cockpit/project.json', 'utf8') - ); - - assert.ok( - !project.tags.includes('scope:cockpit-e2e'), - 'the cockpit shell must not select the cockpit-e2e cap matrix' - ); - }); - + // apps under cockpit/**. A website-only change must not incidentally flip + // cockpit_e2e just because some sibling cockpit library is also nx-affected + // (PR #932 regressed this when a now-retired app statically imported from + // the Website, so a website-only PR ran the whole cap matrix). it('a website-only change leaves cockpit_e2e false', async () => { - const cockpit = JSON.parse( - await readFile('apps/cockpit/project.json', 'utf8') + const cockpitRegistry = JSON.parse( + await readFile('libs/cockpit-registry/project.json', 'utf8') ); const website = JSON.parse( await readFile('apps/website/project.json', 'utf8') ); - // The real nx-affected set for PR #932 was [website, cockpit, scripts]: - // apps/cockpit statically depends on apps/website. + // libs/cockpit-registry is not affected by a website-only change, so it + // must not appear in the affected set here — only `website` does. const scope = classifyFromAffected( [ 'apps/website/src/app/layout.tsx', 'apps/website/src/components/shared/SiteFooter.tsx', ], - [ - { name: 'website', tags: website.tags }, - { name: 'cockpit', tags: cockpit.tags }, - ] + [{ name: 'website', tags: website.tags }] ); assert.equal(scope.cockpit_e2e, false); - // The shell still builds and tests — it consumes the changed website code. - assert.equal(scope.cockpit, true); assert.equal(scope.website, true); + // cockpit-registry legitimately owns cockpit-e2e when it IS affected — + // this just confirms it was not swept in here. + assert.ok(cockpitRegistry.tags.includes('scope:cockpit-e2e')); }); }); diff --git a/scripts/ci-workflow.spec.mjs b/scripts/ci-workflow.spec.mjs index 7e84bf1ef..e1b313b97 100644 --- a/scripts/ci-workflow.spec.mjs +++ b/scripts/ci-workflow.spec.mjs @@ -84,7 +84,7 @@ describe('CI workflow', () => { .split('\n') .filter((line) => line.includes('vercel promote')); - assert.ok(promotes.length >= 2, 'expected Website and cockpit promotions'); + assert.ok(promotes.length >= 1, 'expected the Website promotion'); for (const line of promotes) { // `promote` takes a bare URL and cannot read .vercel/project.json, so // without --scope it uses the token's default team and fails. @@ -392,255 +392,22 @@ describe('CI workflow', () => { ); }); - it('deploys examples before the Cockpit redirect artifact and preserves fail-fast ordering', async () => { - const deployJob = await readDeployJob(); - const assembleExamples = deployJob.indexOf( - 'Build and assemble Angular examples' - ); - const deployExamples = deployJob.indexOf( - 'Deploy Angular examples to Vercel (production)' - ); - const prepareCockpit = deployJob.indexOf('Prepare cockpit Vercel project'); - const buildCockpit = deployJob.indexOf('Build cockpit redirect service'); - const deployCockpit = deployJob.indexOf( - 'Deploy immutable cockpit artifact' - ); - - for (const [label, position] of [ - ['example assembly', assembleExamples], - ['example deployment', deployExamples], - ['cockpit preparation', prepareCockpit], - ['cockpit build', buildCockpit], - ['cockpit deployment', deployCockpit], - ]) { - assert.notEqual(position, -1, `expected ${label} in the deploy job`); - } - assert.ok(assembleExamples < deployExamples); - assert.ok(deployExamples < prepareCockpit); - assert.ok(prepareCockpit < buildCockpit); - assert.ok(buildCockpit < deployCockpit); - - const examplesBeforeCockpit = deployJob.slice( - assembleExamples, - prepareCockpit - ); - assert.doesNotMatch( - examplesBeforeCockpit, - /continue-on-error:\s*true/, - 'example build/deploy must fail the linear job before Cockpit promotion' - ); - - for (const name of [ - 'Build and assemble Angular examples', - 'Deploy Angular examples to Vercel (production)', - ]) { - const step = readNamedStep(deployJob, name); - assert.doesNotMatch(step, /\|\||;\s*true(?:\s|$)|set\s+\+e/); - } - - for (const name of [ - 'Prepare cockpit Vercel project', - 'Build cockpit redirect service', - 'Deploy immutable cockpit artifact', - 'Exhaustively verify immutable cockpit preview', - 'Promote verified cockpit artifact unchanged', - 'Verify production cockpit redirects', - ]) { - const step = readNamedStep(deployJob, name); - assert.doesNotMatch(step, /continue-on-error:\s*true/); - assert.doesNotMatch(step, /if:[^\n]*(?:always|failure|cancelled)\s*\(/); - } - }); - - it('smokes one immutable Cockpit deployment before promoting that exact URL', async () => { - const deployJob = await readDeployJob(); - const websiteDeploy = deployJob.indexOf( - 'Deploy immutable Website preview' - ); - const websiteSmoke = deployJob.indexOf('Verify deployed website'); - const cockpitBuild = deployJob.indexOf('Build cockpit redirect service'); - const cockpitDeploy = deployJob.indexOf( - 'Deploy immutable cockpit artifact' - ); - const previewSmoke = deployJob.indexOf( - 'Exhaustively verify immutable cockpit preview' - ); - const promotionFreshness = deployJob.indexOf( - 'Check this commit is still the tip before cockpit promotion' - ); - const promote = deployJob.indexOf( - 'Promote verified cockpit artifact unchanged' - ); - const productionSmoke = deployJob.indexOf( - 'Verify production cockpit redirects' - ); - const platformJob = await readProductionSmokeJob(); - const platformSmoke = platformJob.indexOf('Run production smoke tests'); - const marker = platformJob.indexOf('Record this commit as promoted'); - - assert.ok( - websiteDeploy < websiteSmoke && - websiteSmoke < cockpitBuild && - cockpitBuild < cockpitDeploy && - cockpitDeploy < previewSmoke && - previewSmoke < promotionFreshness && - promotionFreshness < promote && - promote < productionSmoke - ); - assert.ok(platformSmoke < marker); - - const deployStep = readNamedStep( - deployJob, - 'Deploy immutable cockpit artifact' - ); - const previewStep = readNamedStep( - deployJob, - 'Exhaustively verify immutable cockpit preview' - ); - const promotionFreshnessStep = readNamedStep( - deployJob, - 'Check this commit is still the tip before cockpit promotion' - ); - const promoteStep = readNamedStep( - deployJob, - 'Promote verified cockpit artifact unchanged' - ); - const productionStep = readNamedStep( - deployJob, - 'Verify production cockpit redirects' - ); - - assert.match(deployStep, /id:\s*deploy_cockpit/); - assert.match(deployStep, /vercel deploy[^\n]*--prod[^\n]*--skip-domain/); - assert.match( - deployStep, - /--env COCKPIT_WEBSITE_ORIGIN=https:\/\/threadplane\.ai/ - ); - assert.match( - previewStep, - /--url "\$\{\{ steps\.deploy_cockpit\.outputs\.deployment_url \}\}"[\s\S]*--mode preview/ - ); - assert.match(promotionFreshnessStep, /id:\s*cockpit_promotion_freshness/); - assert.match( - promotionFreshnessStep, - /git ls-remote origin refs\/heads\/main/ - ); - assert.match(promotionFreshnessStep, /if \[ -z "\$tip" \]/); - assert.match(promotionFreshnessStep, /exit 1/); - assert.match( - promotionFreshnessStep, - /if \[ "\$tip" != "\$\{\{ github\.sha \}\}" \]/ - ); - assert.match(promotionFreshnessStep, /fresh=false.*GITHUB_OUTPUT/); - assert.match(promotionFreshnessStep, /fresh=true.*GITHUB_OUTPUT/); - assert.match( - promoteStep, - /vercel promote "\$\{\{ steps\.deploy_cockpit\.outputs\.deployment_url \}\}" --scope=\$\{\{ secrets\.VERCEL_ORG_ID \}\} --yes/ - ); - assert.match( - promoteStep, - /if:[^\n]*steps\.cockpit_promotion_freshness\.outputs\.fresh\s*==\s*'true'/ - ); - assert.match(productionStep, /--mode production/); - assert.match( - productionStep, - /if:[^\n]*steps\.cockpit_promotion_freshness\.outputs\.fresh\s*==\s*'true'/ - ); - assert.doesNotMatch( - deployJob.slice(cockpitDeploy, promote), - /vercel promote/ - ); - assert.equal( - (deployStep.match(/vercel deploy/g) ?? []).length, - 1, - 'Cockpit deployment must be captured once' - ); - }); - it('verifies every protected immutable preview with its own automation bypass', async () => { // Vercel deployment protection answers every path on an unaliased - // deployment with 302 -> vercel.com/sso-api. Bypass secrets are issued per - // project, so the Website and cockpit checks each need their own, and a - // missing one must fail with a message that says what to provision rather - // than as an opaque "expected 308, received 302". + // deployment with 302 -> vercel.com/sso-api. A missing bypass secret must + // fail with a message that says what to provision rather than as an + // opaque "expected 308, received 302". const deployJob = await readDeployJob(); const websiteStep = readNamedStep( deployJob, 'Verify Website preview runtime embedding policy' ); - const cockpitStep = readNamedStep( - deployJob, - 'Exhaustively verify immutable cockpit preview' - ); assert.match( websiteStep, /VERCEL_AUTOMATION_BYPASS_SECRET:\s*\$\{\{ secrets\.VERCEL_AUTOMATION_BYPASS_SECRET \}\}/ ); assert.match(websiteStep, /-z "\$\{VERCEL_AUTOMATION_BYPASS_SECRET\}"/); - assert.match( - cockpitStep, - /VERCEL_AUTOMATION_BYPASS_SECRET:\s*\$\{\{ secrets\.VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET \}\}/ - ); - assert.match(cockpitStep, /-z "\$\{VERCEL_AUTOMATION_BYPASS_SECRET\}"/); - assert.match(cockpitStep, /::error::[^\n]*threadplane-cockpit/); - assert.match(cockpitStep, /exit 1/); - assert.doesNotMatch( - cockpitStep, - /secrets\.VERCEL_AUTOMATION_BYPASS_SECRET/, - 'the cockpit preview must not reuse the Website project secret' - ); - }); - - it('smokes a throwaway cockpit preview on same-repo PRs and queue candidates', async () => { - const workflow = await readWorkflow(); - const job = readJobBlock(workflow, 'cockpit-preview-smoke'); - const ifBlock = readJobFieldBlock(job, 'if'); - - assert.deepEqual(readJobNeeds(job), ['ci-scope']); - assert.match(ifBlock, /github\.event_name != 'push'/); - assert.match(ifBlock, /needs\.ci-scope\.outputs\.cockpit_deploy_smoke == 'true'/); - assert.match( - ifBlock, - /github\.event_name == 'merge_group' \|\| github\.event\.pull_request\.head\.repo\.full_name == github\.repository/ - ); - - const prepare = readNamedStep(job, 'Prepare cockpit Vercel project (preview)'); - const build = readNamedStep(job, 'Build cockpit redirect service (preview)'); - const deploy = readNamedStep(job, 'Deploy throwaway cockpit preview'); - const smoke = readNamedStep(job, 'Exhaustively verify the cockpit preview'); - - assert.match(prepare, /"projectName":"threadplane-cockpit"/); - assert.match(prepare, /vercel pull --yes --environment=preview/); - assert.match(build, /vercel build --local-config vercel\.cockpit\.json/); - assert.doesNotMatch(build, /--prod/); - assert.match(deploy, /id:\s*deploy_cockpit_preview/); - assert.match(deploy, /vercel deploy --prebuilt --archive=tgz --yes/); - assert.doesNotMatch(deploy, /--skip-domain/); - assert.doesNotMatch(deploy, /--prod/); - assert.match(deploy, /--env COCKPIT_WEBSITE_ORIGIN=https:\/\/threadplane\.ai/); - assert.match( - smoke, - /VERCEL_AUTOMATION_BYPASS_SECRET:\s*\$\{\{ secrets\.VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET \}\}/ - ); - assert.match(smoke, /-z "\$\{VERCEL_AUTOMATION_BYPASS_SECRET\}"/); - assert.match( - smoke, - /--url "\$\{\{ steps\.deploy_cockpit_preview\.outputs\.deployment_url \}\}"[\s\S]*--mode preview/ - ); - assert.doesNotMatch(job, /vercel promote/); - assert.match(deploy, /set -euo pipefail/); - assert.match(deploy, /new URL\(process\.argv\[1\]\)/); - assert.match(smoke, /--retries 20 --retry-delay-ms 5000/); - const cleanup = readNamedStep(job, 'Remove the throwaway cockpit preview'); - assert.match(cleanup, /if:\s*always\(\) && steps\.deploy_cockpit_preview\.outputs\.deployment_url != ''/); - assert.match(cleanup, /continue-on-error:\s*true/); - const rawCleanup = readNamedStep(workflow, 'Remove the throwaway cockpit preview'); - assert.match(rawCleanup, /Removal runs on every outcome/); - assert.match( - cleanup, - /vercel remove "\$\{\{ steps\.deploy_cockpit_preview\.outputs\.deployment_url \}\}" --safe --yes --scope=\$\{\{ secrets\.VERCEL_ORG_ID \}\}/ - ); }); it('runs the Website suite against a deterministic aliased preview with a matching examples preview', async () => { @@ -719,20 +486,15 @@ describe('CI workflow', () => { assert.doesNotMatch(job, /vercel remove/); }); - it('requires both PR-side preview verifications through the scoped gate', async () => { + it('requires the Website preview verification through the scoped gate', async () => { const required = await readRequiredPrChecksJob(); const needs = readJobNeeds(required); assert.ok(needs.includes('website-preview-e2e')); - assert.ok(needs.includes('cockpit-preview-smoke')); assert.match( required, /RESULT_WEBSITE_PREVIEW_E2E:\s*\$\{\{ needs\.website-preview-e2e\.result \}\}/ ); - assert.match( - required, - /RESULT_COCKPIT_PREVIEW_SMOKE:\s*\$\{\{ needs\.cockpit-preview-smoke\.result \}\}/ - ); assert.match( required, /PREVIEW_LANES_ELIGIBLE:\s*\$\{\{ github\.event_name == 'merge_group' \|\| github\.event\.pull_request\.head\.repo\.full_name == github\.repository \}\}/ @@ -742,64 +504,9 @@ describe('CI workflow', () => { required, /require_preview "website_e2e" "Website — e2e \(deployed preview\)" "\$RESULT_WEBSITE_PREVIEW_E2E" "\$SCOPE_WEBSITE_E2E"/ ); - assert.match( - required, - /require_preview "cockpit_deploy_smoke" "Cockpit — immutable preview smoke" "\$RESULT_COCKPIT_PREVIEW_SMOKE" "\$SCOPE_COCKPIT_DEPLOY_SMOKE"/ - ); assert.match(required, /was eligible and in scope \$\{scope_key\} but was skipped/); }); - it('gates Cockpit deployment on the production Website smoke even for Cockpit-only changes', async () => { - const deployJob = await readDeployJob(); - const websiteOrCockpit = - /if:[^\n]*steps\.affected\.outputs\.website\s*==\s*'true'[^\n]*\|\|[^\n]*steps\.affected\.outputs\.cockpit\s*==\s*'true'/; - - for (const name of ['Cache Playwright browsers', 'Install Playwright browsers']) { - const step = readNamedStep(deployJob, name); - assert.match( - step, - websiteOrCockpit, - `${name} must cover Cockpit-only runs` - ); - assert.doesNotMatch(step, /continue-on-error:\s*true/); - } - - const websiteSmokeStep = readNamedStep(deployJob, 'Verify deployed website'); - assert.match(websiteSmokeStep, websiteOrCockpit); - assert.match( - websiteSmokeStep, - /steps\.website_promotion_freshness\.outputs\.fresh\s*==\s*'true'/ - ); - assert.doesNotMatch(websiteSmokeStep, /continue-on-error:\s*true/); - - for (const name of [ - 'Prepare website Vercel project', - 'Deploy immutable Website preview', - ]) { - const step = readNamedStep(deployJob, name); - assert.match( - step, - /if:[^\n]*steps\.affected\.outputs\.website\s*==\s*'true'/ - ); - assert.doesNotMatch( - step, - /steps\.affected\.outputs\.cockpit\s*==\s*'true'/ - ); - } - - const websiteSmoke = deployJob.indexOf('Verify deployed website'); - for (const cockpitStep of [ - 'Prepare cockpit Vercel project', - 'Build cockpit redirect service', - 'Deploy immutable cockpit artifact', - ]) { - assert.ok( - websiteSmoke < deployJob.indexOf(cockpitStep), - `Website production smoke must precede ${cockpitStep}` - ); - } - }); - it('runs production smoke after every platform deployment job', async () => { const productionSmokeJob = await readProductionSmokeJob(); const agUiDemoJob = await readAgUiDemoJob(); @@ -829,10 +536,6 @@ describe('CI workflow', () => { it('runs production smoke against Threadplane domains', async () => { const productionSmokeJob = await readProductionSmokeJob(); - assert.match( - productionSmokeJob, - /COCKPIT_URL:\s*https:\/\/cockpit\.threadplane\.ai/ - ); assert.match( productionSmokeJob, /EXAMPLES_URL:\s*https:\/\/examples\.threadplane\.ai/ @@ -851,10 +554,6 @@ describe('CI workflow', () => { productionSmokeJob, /playwright test apps\/website\/e2e\/platform-production-smoke\.spec\.ts[^\n]*--config apps\/website\/playwright\.config\.ts/ ); - assert.doesNotMatch( - productionSmokeJob, - /apps\/cockpit\/e2e\/production-smoke\.spec\.ts/ - ); }); it('guards every production promotion against a superseded commit', async () => { @@ -892,7 +591,6 @@ describe('CI workflow', () => { const workflow = await readWorkflow(); assert.match(deployJob, /"projectName":"threadplane"/); - assert.match(deployJob, /"projectName":"threadplane-cockpit"/); assert.match(deployJob, /"projectName":"threadplane-examples"/); assert.match(workflow, /"projectName":"threadplane-demo"/); }); @@ -944,10 +642,9 @@ describe('CI workflow', () => { const projects = runMany[1].split(','); for (const project of [ - 'cockpit', - 'cockpit-docs', 'cockpit-registry', 'cockpit-shell', + 'workspace-react', ]) { assert.ok( projects.includes(project), @@ -1225,14 +922,12 @@ describe('CI workflow', () => { 'cockpit', 'cockpit-examples-build', 'cockpit-smoke', - 'cockpit-deploy-smoke', 'examples-chat-smoke', 'examples-chat-e2e', 'examples-ag-ui-e2e', 'cockpit-e2e-summary', 'website-e2e', 'website-preview-e2e', - 'cockpit-preview-smoke', 'posthog-sync-plan', 'scripts-tests', 'growth-lifecycle', @@ -1301,6 +996,15 @@ describe('CI workflow', () => { assert.ok(needs.includes('growth-lifecycle')); assert.ok(needs.includes('lifecycle')); }); + + it('carries no cockpit redirect deployment anywhere', async () => { + const workflow = await readWorkflow(); + assert.doesNotMatch(workflow, /vercel\.cockpit\.json/); + assert.doesNotMatch(workflow, /threadplane-cockpit/); + assert.doesNotMatch(workflow, /VERCEL_COCKPIT_/); + assert.doesNotMatch(workflow, /deploy-smoke\.ts/); + assert.doesNotMatch(workflow, /cockpit\.threadplane\.ai/); + }); }); describe('CI workflow test helpers', () => { From 4aea4bdda0b5a4e2e899766cdd29f9ca9d7cb719 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 11:43:37 -0700 Subject: [PATCH 5/6] ci: label the workspace libraries gate by its new name; keep the cockpit_e2e scope guard two-sided Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- scripts/ci-scope.spec.mjs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1bc15066..fc687ac94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -916,7 +916,7 @@ jobs: "$RESULT_ANGULAR_COMPATIBILITY" \ "$SCOPE_ANGULAR_COMPATIBILITY" require_scoped "website" "Website — lint / test / build" "$RESULT_WEBSITE" "$SCOPE_WEBSITE" - require_scoped "cockpit" "Cockpit — build / test" "$RESULT_COCKPIT" "$SCOPE_COCKPIT" + require_scoped "cockpit" "Workspace libraries — lint / test" "$RESULT_COCKPIT" "$SCOPE_COCKPIT" require_scoped "cockpit_examples" "Cockpit — build all examples" "$RESULT_COCKPIT_EXAMPLES" "$SCOPE_COCKPIT_EXAMPLES" require_scoped "cockpit_smoke" "Cockpit — representative capability smoke" "$RESULT_COCKPIT_SMOKE" "$SCOPE_COCKPIT_SMOKE" require_scoped "examples_chat" "examples/chat — python smoke" "$RESULT_EXAMPLES_CHAT_SMOKE" "$SCOPE_EXAMPLES_CHAT" diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index 337992a4b..79c027838 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -625,4 +625,23 @@ describe('classifyFromAffected — cockpit e2e matrix stays scoped to its own ca // this just confirms it was not swept in here. assert.ok(cockpitRegistry.tags.includes('scope:cockpit-e2e')); }); + + it('a registry change does flip cockpit_e2e, so the negative case above is not vacuous', async () => { + const cockpitRegistry = JSON.parse( + await readFile('libs/cockpit-registry/project.json', 'utf8') + ); + const website = JSON.parse( + await readFile('apps/website/project.json', 'utf8') + ); + + const scope = classifyFromAffected( + ['libs/cockpit-registry/src/lib/manifest.ts'], + [ + { name: 'website', tags: website.tags }, + { name: 'cockpit-registry', tags: cockpitRegistry.tags }, + ] + ); + + assert.equal(scope.cockpit_e2e, true); + }); }); From efc008c43d4a5176e115b06b9b6406fa94e9035b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 11:54:33 -0700 Subject: [PATCH 6/6] test: rewire the rootless example specs into cockpit-registry; drop the dead cockpit launch entry; widen the redirect guard Co-Authored-By: Claude Fable 5.1 --- .claude/launch.json | 9 --------- cockpit/chat/footprint.spec.ts | 4 ++-- cockpit/deep-agents/footprint.spec.ts | 4 ++-- cockpit/render/footprint.spec.ts | 4 ++-- libs/cockpit-registry/vite.config.mts | 27 ++++++++++++++++++++++++++- scripts/ci-scope.mjs | 5 ++++- scripts/ci-workflow.spec.mjs | 16 ++++++++++------ scripts/examples/e2e-wiring.spec.ts | 23 ++++++++++++++++++----- 8 files changed, 64 insertions(+), 28 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index e8808171c..47fdbce32 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -29,15 +29,6 @@ "port": 3000, "autoPort": true }, - { - "name": "cockpit", - "runtimeExecutable": "/bin/bash", - "runtimeArgs": [ - "-c", - "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && npx nx serve cockpit --port 4201" - ], - "port": 4201 - }, { "name": "streaming", "runtimeExecutable": "/bin/bash", diff --git a/cockpit/chat/footprint.spec.ts b/cockpit/chat/footprint.spec.ts index a49386711..f99816ead 100644 --- a/cockpit/chat/footprint.spec.ts +++ b/cockpit/chat/footprint.spec.ts @@ -17,8 +17,8 @@ const topicNames = [ ] as const; // Resolve from this file, not process.cwd(). These specs run under -// `nx test cockpit`, whose cwd is apps/cockpit — a cwd-relative root silently -// points at apps/cockpit/cockpit/... and turns every existence assertion into +// `nx test cockpit-registry`, whose cwd is libs/cockpit-registry — a cwd-relative root silently +// points at libs/cockpit-registry/cockpit/... and turns every existence assertion into // a vacuous pass (or an unrelated ENOENT). const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const chatRoot = path.join(repoRoot, 'cockpit', 'chat'); diff --git a/cockpit/deep-agents/footprint.spec.ts b/cockpit/deep-agents/footprint.spec.ts index fc6b51f85..9815234dc 100644 --- a/cockpit/deep-agents/footprint.spec.ts +++ b/cockpit/deep-agents/footprint.spec.ts @@ -12,8 +12,8 @@ const topicNames = [ ] as const; // Resolve from this file, not process.cwd(). These specs run under -// `nx test cockpit`, whose cwd is apps/cockpit — a cwd-relative root silently -// points at apps/cockpit/cockpit/... and turns every existence assertion into +// `nx test cockpit-registry`, whose cwd is libs/cockpit-registry — a cwd-relative root silently +// points at libs/cockpit-registry/cockpit/... and turns every existence assertion into // a vacuous pass (or an unrelated ENOENT). const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const deepAgentsRoot = path.join(repoRoot, 'cockpit', 'deep-agents'); diff --git a/cockpit/render/footprint.spec.ts b/cockpit/render/footprint.spec.ts index a05faf397..3e896ae73 100644 --- a/cockpit/render/footprint.spec.ts +++ b/cockpit/render/footprint.spec.ts @@ -13,8 +13,8 @@ const topicNames = [ ] as const; // Resolve from this file, not process.cwd(). These specs run under -// `nx test cockpit`, whose cwd is apps/cockpit — a cwd-relative root silently -// points at apps/cockpit/cockpit/... and turns every existence assertion into +// `nx test cockpit-registry`, whose cwd is libs/cockpit-registry — a cwd-relative root silently +// points at libs/cockpit-registry/cockpit/... and turns every existence assertion into // a vacuous pass (or an unrelated ENOENT). const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const renderRoot = path.join(repoRoot, 'cockpit', 'render'); diff --git a/libs/cockpit-registry/vite.config.mts b/libs/cockpit-registry/vite.config.mts index 6edeadec8..cf0f36c9a 100644 --- a/libs/cockpit-registry/vite.config.mts +++ b/libs/cockpit-registry/vite.config.mts @@ -1,11 +1,36 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +const __dirname = dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ plugins: [nxViteTsPaths()], + // The capability matrix specs (see `test.include`) live outside this + // project's directory, and Vite's dev server refuses to serve files above + // its root unless they are allow-listed. Without this the matrix specs + // fail to load with ERR_MODULE_NOT_FOUND on a `/@fs/...` path under + // `nx test cockpit-registry`. + server: { fs: { allow: [resolve(__dirname, '../..')] } }, test: { environment: 'node', globals: true, - include: ['src/**/*.spec.ts'], + include: [ + 'src/**/*.spec.ts', + // The per-product capability matrix specs live beside the examples + // they describe and had no test target of their own, which is how + // their docsPath assertion drifted into asserting a URL shape the + // website has never served. Run them here so `nx test cockpit-registry` + // covers them. + '../../cockpit/*/matrix.spec.ts', + // Same story for the per-product footprint specs (chat, deep-agents, + // render): they sit outside any project root, so no `test` target + // owned them and the deep-agents one drifted into asserting a website + // docs library that does not exist. Glob the whole family rather than + // naming files, so a new `cockpit//footprint.spec.ts` is + // covered the day it lands instead of joining the unrun pile. + '../../cockpit/*/footprint.spec.ts', + ], }, }); diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 1c60d5802..ea330a32c 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -83,7 +83,10 @@ const LINT_SCOPE_KEYS = [ * therefore attributes them to the `root` project, and `root` carries no * `scope:` tag — so a PR touching only these specs produced an empty scope * and skipped the job that owns cockpit changes. Map them onto the cockpit - * scope by path so that job still runs. */ + * scope by path so that job still runs. They actually execute under the + * `cockpit` job through `cockpit-registry`'s vitest `test.include` globs + * (libs/cockpit-registry/vite.config.mts), which is what runs them today — + * this regex only decides which CI scope a change to them lights up. */ const COCKPIT_ROOTLESS_SPEC = /^cockpit\/[^/]+\/[^/]+\.spec\.ts$/; export function emptyScope() { diff --git a/scripts/ci-workflow.spec.mjs b/scripts/ci-workflow.spec.mjs index e1b313b97..b8e41c0d6 100644 --- a/scripts/ci-workflow.spec.mjs +++ b/scripts/ci-workflow.spec.mjs @@ -998,12 +998,16 @@ describe('CI workflow', () => { }); it('carries no cockpit redirect deployment anywhere', async () => { - const workflow = await readWorkflow(); - assert.doesNotMatch(workflow, /vercel\.cockpit\.json/); - assert.doesNotMatch(workflow, /threadplane-cockpit/); - assert.doesNotMatch(workflow, /VERCEL_COCKPIT_/); - assert.doesNotMatch(workflow, /deploy-smoke\.ts/); - assert.doesNotMatch(workflow, /cockpit\.threadplane\.ai/); + const workflowFiles = await readWorkflowFiles(); + assert.ok(workflowFiles.length > 0, 'expected workflow files to inspect'); + + for (const { name, text } of workflowFiles) { + assert.doesNotMatch(text, /vercel\.cockpit\.json/, `${name}`); + assert.doesNotMatch(text, /threadplane-cockpit/, `${name}`); + assert.doesNotMatch(text, /VERCEL_COCKPIT_/, `${name}`); + assert.doesNotMatch(text, /deploy-smoke\.ts/, `${name}`); + assert.doesNotMatch(text, /cockpit\.threadplane\.ai/, `${name}`); + } }); }); diff --git a/scripts/examples/e2e-wiring.spec.ts b/scripts/examples/e2e-wiring.spec.ts index 3ad1b3612..8a61999fd 100644 --- a/scripts/examples/e2e-wiring.spec.ts +++ b/scripts/examples/e2e-wiring.spec.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { dirname, join, relative, resolve } from 'node:path'; +import { basename, dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { capabilities } from '@threadplane/cockpit-registry'; // @ts-expect-error — .mjs ES module without .d.ts; the e2e tsconfig uses @@ -43,7 +43,8 @@ function listProjectJsonFiles(root: string): string[] { function listFiles( root: string, - predicate: (filePath: string) => boolean + predicate: (filePath: string) => boolean, + excludeDirNames: readonly string[] = [] ): string[] { const out: string[] = []; const stack = [root]; @@ -51,6 +52,9 @@ function listFiles( while (stack.length) { const dir = stack.pop()!; for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory() && excludeDirNames.includes(entry.name)) { + continue; + } const fullPath = join(dir, entry.name); if (entry.isDirectory()) { stack.push(fullPath); @@ -134,9 +138,18 @@ describe('cockpit e2e wiring', () => { ); const websiteConfig = readRepoFile('apps/website/playwright.config.ts'); - expect( - existsSync(join(repoRoot, 'apps/cockpit/e2e/production-smoke.spec.ts')) - ).toBe(false); + // Assert this against the whole tree, not just the retired + // apps/cockpit path: any `production-smoke.spec.ts` anywhere else would + // be an undetected duplicate of the one file Website is meant to own. + // Website's file is named `platform-production-smoke.spec.ts` (a + // different basename), so it never matches this exact-name check. + const productionSmokeSpecs = listFiles( + repoRoot, + (filePath) => basename(filePath) === 'production-smoke.spec.ts', + ['node_modules', 'dist', '.next', '.git'] + ).map((filePath) => relative(repoRoot, filePath)); + + expect(productionSmokeSpecs).toEqual([]); expect(smoke).toContain('getWorkspaceDestinationPath'); expect(smoke).toContain('EXAMPLES_URL'); expect(smoke).toContain('DEMO_URL');