Skip to content

Commit 36b01fd

Browse files
committed
feat(cockpit): centralize port allocation behind cockpit/ports.mjs registry (#551)
* docs(spec): cockpit port registry design * docs(plan): cockpit port registry implementation plan * feat(ci): cockpit ports registry + verifier spec (skeleton) Registry starts empty; the verifier spec tests both the registry shape (positive int ports, no duplicates, range/convention checks) and cross-file invariants (python --port + playwright baseURL match registry). Empty registry intentionally fails the 'covers every cap' test until Task 2 populates it. Spec: docs/superpowers/specs/2026-05-27-cockpit-port-registry-design.md * feat(ci): populate cockpit ports registry from on-disk values 31 entries, extracted from each cap's proxy.conf.json target + playwright.config.ts baseURL (or langgraph - 1000 for non-e2e caps). Verifier confirms the registry round-trips cleanly against every python/project.json --port literal. * test(ports): verifier skips python --port check for caps without literal Only chat caps have --port in their python/project.json serve target; langgraph/deep-agents/render caps rely on the e2e harness spawning langgraph with explicit --port from global-setup-impl.ts. Skip verifier check for those rather than fail. * refactor(cockpit): 31 proxy.conf.json → proxy.conf.mjs (import ports registry) Every cockpit cap's proxy.conf.json is replaced with proxy.conf.mjs that imports portsFor(capName) from cockpit/ports.mjs and templates the langgraph port into the target URL. Each angular/project.json's serve.options.proxyConfig is updated to point at the .mjs file. cockpit/ag-ui/streaming is excluded — non-LangGraph backend, kept as proxy.conf.json with its /agent → :3000 literal. * refactor(cockpit): 24 e2e configs import ports from registry Each cap's e2e/global-setup-impl.ts now imports portsFor() and reads angular/langgraph ports from the registry instead of literal numbers. playwright.config.ts is updated similarly for baseURL. All 24 e2e tsconfig.json files gain allowJs: true so tsc accepts the .mjs import without a declaration file. The verifier's playwright-baseURL check now skips templated configs (its regex only matches literal URLs); drift is impossible because the value imports directly from the registry. * ci: gate cockpit-ports.spec.mjs in ci-scope test job Appends the new verifier spec to the existing node --test invocation so registry drift is caught at PR time. * test(cockpit): wiring spec reads ports from registry; recognizes .mjs proxy The drift-guard test previously parsed proxy.conf.json target + the literal langgraphPort/angularPort lines in global-setup-impl.ts. Both were removed by the port-registry migration: - proxy.conf.json → proxy.conf.mjs (templates target from registry) - global-setup-impl.ts now reads `ports.langgraph` / `ports.angular` Updated wiring spec: - Imports `portsFor` from cockpit/ports.mjs to look up expected ports by project name. Falls back to literal parsing for the ag-ui exception (not in registry). - Replaces the proxy.conf.json content assertion with a proxy.conf.mjs existence + `portsFor('<name>')` call check. Drift is impossible because the .mjs templates from the same registry. - Legacy proxy.conf.json branch retained for ag-ui (single-cap exception). 79/79 cockpit unit tests pass locally.
1 parent 057eb10 commit 36b01fd

171 files changed

Lines changed: 1915 additions & 457 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
# classifier walk.
4949
- run: npm ci
5050
- name: Test CI scope classifier
51-
run: node --test scripts/ci-scope.spec.mjs scripts/cockpit-matrix.spec.mjs
51+
run: node --test scripts/ci-scope.spec.mjs scripts/cockpit-matrix.spec.mjs scripts/cockpit-ports.spec.mjs
5252
- name: Detect changed CI surfaces
5353
id: scope
5454
run: |

apps/cockpit/cockpit-e2e-wiring.spec.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
22
import { dirname, join, relative, resolve } from 'node:path';
33
import { fileURLToPath } from 'node:url';
44
import { capabilities } from './scripts/capability-registry';
5+
// @ts-expect-error — .mjs ES module without .d.ts; the e2e tsconfig uses
6+
// allowJs:true but this top-level test file doesn't go through that config.
7+
import { portsFor } from '../../cockpit/ports.mjs';
58

69
interface E2eWiring {
710
angularPort: number;
@@ -84,12 +87,23 @@ function activeCockpitE2eWiring(): E2eWiring[] {
8487
const projectRoot = dirname(projectJsonPath);
8588
const globalSetupPath = join(projectRoot, 'e2e/global-setup-impl.ts');
8689
const globalSetup = readFileSync(globalSetupPath, 'utf8');
87-
const proxyPath = join(projectRoot, 'proxy.conf.json');
88-
const proxy = JSON.parse(readFileSync(proxyPath, 'utf8')) as Record<string, { target?: string }>;
89-
const proxyPort = Number(proxy['/api']?.target?.match(/:(\d+)$/)?.[1]);
9090
const langgraphCwd = parseStringProperty(globalSetup, 'langgraphCwd');
91-
const langgraphPort = parseNumberProperty(globalSetup, 'langgraphPort') ?? proxyPort;
92-
const angularPort = parseNumberProperty(globalSetup, 'angularPort');
91+
92+
// Post-port-registry migration: ports are imported from
93+
// cockpit/ports.mjs rather than living as literals in
94+
// global-setup-impl.ts. Look them up by project name.
95+
let langgraphPort: number | undefined;
96+
let angularPort: number | undefined;
97+
try {
98+
const ports = portsFor(project.name) as { angular: number; langgraph: number };
99+
langgraphPort = ports.langgraph;
100+
angularPort = ports.angular;
101+
} catch {
102+
// Cap not in registry (e.g. cockpit-ag-ui-streaming-angular).
103+
// Fall back to parsing literals if global-setup still has them.
104+
langgraphPort = parseNumberProperty(globalSetup, 'langgraphPort');
105+
angularPort = parseNumberProperty(globalSetup, 'angularPort');
106+
}
93107

94108
if (!project.name || !langgraphCwd || !langgraphPort || !angularPort) {
95109
throw new Error(`Unable to parse e2e wiring for ${relative(repoRoot, projectJsonPath)}`);
@@ -153,16 +167,29 @@ describe('cockpit e2e wiring', () => {
153167
errors.push(`${wiring.project}: registry pythonDir ${capability.pythonDir} != global setup langgraphCwd ${wiring.langgraphCwd}`);
154168
}
155169

156-
const proxyPath = join(wiring.projectRoot, 'proxy.conf.json');
157-
if (!existsSync(proxyPath)) {
158-
errors.push(`${wiring.project}: missing proxy.conf.json`);
159-
} else {
160-
const proxy = JSON.parse(readFileSync(proxyPath, 'utf8')) as Record<string, { target?: string }>;
170+
// Post-port-registry: proxy.conf.mjs templates the target from
171+
// cockpit/ports.mjs at runtime. Drift is impossible because the
172+
// value comes from the same registry the test already checks via
173+
// wiring.langgraphPort. So we just assert the .mjs file exists +
174+
// imports portsFor with this cap's name.
175+
const proxyMjs = join(wiring.projectRoot, 'proxy.conf.mjs');
176+
const proxyJson = join(wiring.projectRoot, 'proxy.conf.json');
177+
if (existsSync(proxyMjs)) {
178+
const text = readFileSync(proxyMjs, 'utf8');
179+
if (!text.includes(`portsFor('${wiring.project}')`)) {
180+
errors.push(`${wiring.project}: proxy.conf.mjs does not call portsFor('${wiring.project}')`);
181+
}
182+
} else if (existsSync(proxyJson)) {
183+
// Legacy (allowed for ag-ui exception only); not expected for any
184+
// cap reaching this code path.
185+
const proxy = JSON.parse(readFileSync(proxyJson, 'utf8')) as Record<string, { target?: string }>;
161186
const target = proxy['/api']?.target;
162187
const expectedTarget = `http://localhost:${wiring.langgraphPort}`;
163188
if (target !== expectedTarget) {
164189
errors.push(`${wiring.project}: proxy target ${target} != ${expectedTarget}`);
165190
}
191+
} else {
192+
errors.push(`${wiring.project}: missing proxy.conf (no .mjs or .json)`);
166193
}
167194

168195
const scriptsDir = join(wiring.projectRoot, 'e2e/scripts');
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
// SPDX-License-Identifier: MIT
22
import { resolve } from 'node:path';
3+
import { portsFor } from '../../../../../cockpit/ports.mjs';
34
import { createGlobalSetup } from '@threadplane-internal/e2e-harness';
45

6+
const ports = portsFor('cockpit-chat-a2ui-angular');
7+
58
export default createGlobalSetup({
69
langgraphCwd: 'cockpit/chat/a2ui/python',
7-
langgraphPort: 5511,
10+
langgraphPort: ports.langgraph,
811
angularProject: 'cockpit-chat-a2ui-angular',
9-
angularPort: 4511,
12+
angularPort: ports.angular,
1013
fixturesDir: resolve(__dirname, 'fixtures'),
1114
});

cockpit/chat/a2ui/angular/e2e/playwright.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
// SPDX-License-Identifier: MIT
22
import { defineConfig, devices } from '@playwright/test';
3+
import { portsFor } from '../../../../../cockpit/ports.mjs';
4+
5+
const { angular: angularPort } = portsFor('cockpit-chat-a2ui-angular');
6+
37

48
export default defineConfig({
59
testDir: '.',
@@ -9,7 +13,7 @@ export default defineConfig({
913
retries: process.env.CI ? 2 : 0,
1014
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
1115
use: {
12-
baseURL: 'http://localhost:4511',
16+
baseURL: `http://localhost:${angularPort}`,
1317
trace: 'retain-on-failure',
1418
},
1519
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],

cockpit/chat/a2ui/angular/e2e/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"@threadplane-internal/e2e-harness/global-teardown": [
1919
"libs/e2e-harness/src/global-teardown.ts"
2020
]
21-
}
21+
},
22+
"allowJs": true
2223
},
2324
"include": [
2425
"**/*.ts"

cockpit/chat/a2ui/angular/project.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
},
7979
"defaultConfiguration": "development",
8080
"options": {
81-
"proxyConfig": "cockpit/chat/a2ui/angular/proxy.conf.json"
81+
"proxyConfig": "cockpit/chat/a2ui/angular/proxy.conf.mjs"
8282
}
8383
},
8484
"smoke": {

cockpit/chat/a2ui/angular/proxy.conf.json

Lines changed: 0 additions & 9 deletions
This file was deleted.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// SPDX-License-Identifier: MIT
2+
import { portsFor } from '../../../../cockpit/ports.mjs';
3+
4+
const { langgraph } = portsFor('cockpit-chat-a2ui-angular');
5+
6+
export default {
7+
"/api": {
8+
target: `http://localhost:${langgraph}`,
9+
secure: false,
10+
changeOrigin: true,
11+
pathRewrite: {"^/api":""},
12+
ws: true,
13+
},
14+
};

cockpit/chat/debug/angular/project.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
},
7979
"defaultConfiguration": "development",
8080
"options": {
81-
"proxyConfig": "cockpit/chat/debug/angular/proxy.conf.json"
81+
"proxyConfig": "cockpit/chat/debug/angular/proxy.conf.mjs"
8282
}
8383
},
8484
"smoke": {

cockpit/chat/debug/angular/proxy.conf.json

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)