Skip to content

Commit 709eea2

Browse files
authored
fix(ci): align cockpit chat e2e wiring (#403)
1 parent a2359e7 commit 709eea2

5 files changed

Lines changed: 180 additions & 19 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ jobs:
3434
working-directory: cockpit/langgraph/streaming/python
3535
run: uv sync
3636

37+
- name: Install tool-calls Python dependencies
38+
working-directory: cockpit/chat/tool-calls/python
39+
run: uv sync
40+
41+
- name: Install subagents Python dependencies
42+
working-directory: cockpit/chat/subagents/python
43+
run: uv sync
44+
3745
- name: Install Playwright browsers
3846
run: npx playwright install --with-deps chromium
3947

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
2+
import { dirname, join, relative, resolve } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { capabilities } from './scripts/capability-registry';
5+
6+
interface E2eWiring {
7+
angularPort: number;
8+
langgraphCwd: string;
9+
langgraphPort: number;
10+
project: string;
11+
projectRoot: string;
12+
}
13+
14+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
15+
const workflows = [
16+
'.github/workflows/ci.yml',
17+
'.github/workflows/e2e.yml',
18+
] as const;
19+
20+
function readRepoFile(path: string): string {
21+
return readFileSync(join(repoRoot, path), 'utf8');
22+
}
23+
24+
function listProjectJsonFiles(root: string): string[] {
25+
const out: string[] = [];
26+
const stack = [root];
27+
28+
while (stack.length) {
29+
const dir = stack.pop()!;
30+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
31+
const fullPath = join(dir, entry.name);
32+
if (entry.isDirectory()) {
33+
stack.push(fullPath);
34+
} else if (entry.isFile() && entry.name === 'project.json') {
35+
out.push(fullPath);
36+
}
37+
}
38+
}
39+
40+
return out.sort();
41+
}
42+
43+
function parseStringProperty(source: string, key: string): string | undefined {
44+
const match = source.match(new RegExp(`${key}:\\s*['"]([^'"]+)['"]`));
45+
return match?.[1];
46+
}
47+
48+
function parseNumberProperty(source: string, key: string): number | undefined {
49+
const match = source.match(new RegExp(`${key}:\\s*(\\d+)`));
50+
return match ? Number(match[1]) : undefined;
51+
}
52+
53+
function activeCockpitE2eWiring(): E2eWiring[] {
54+
return listProjectJsonFiles(join(repoRoot, 'cockpit'))
55+
.map((projectJsonPath) => {
56+
const project = JSON.parse(readFileSync(projectJsonPath, 'utf8')) as {
57+
name?: string;
58+
targets?: Record<string, unknown>;
59+
};
60+
return { project, projectJsonPath };
61+
})
62+
.filter(({ project, projectJsonPath }) => {
63+
return project.name?.startsWith('cockpit-') &&
64+
projectJsonPath.includes('/angular/') &&
65+
Boolean(project.targets?.['e2e']);
66+
})
67+
.map(({ project, projectJsonPath }) => {
68+
const projectRoot = dirname(projectJsonPath);
69+
const globalSetupPath = join(projectRoot, 'e2e/global-setup-impl.ts');
70+
const globalSetup = readFileSync(globalSetupPath, 'utf8');
71+
const proxyPath = join(projectRoot, 'proxy.conf.json');
72+
const proxy = JSON.parse(readFileSync(proxyPath, 'utf8')) as Record<string, { target?: string }>;
73+
const proxyPort = Number(proxy['/api']?.target?.match(/:(\d+)$/)?.[1]);
74+
const langgraphCwd = parseStringProperty(globalSetup, 'langgraphCwd');
75+
const langgraphPort = parseNumberProperty(globalSetup, 'langgraphPort') ?? proxyPort;
76+
const angularPort = parseNumberProperty(globalSetup, 'angularPort');
77+
78+
if (!project.name || !langgraphCwd || !langgraphPort || !angularPort) {
79+
throw new Error(`Unable to parse e2e wiring for ${relative(repoRoot, projectJsonPath)}`);
80+
}
81+
82+
return {
83+
angularPort,
84+
langgraphCwd,
85+
langgraphPort,
86+
project: project.name,
87+
projectRoot,
88+
};
89+
});
90+
}
91+
92+
describe('cockpit e2e wiring', () => {
93+
it('keeps active cockpit e2e backends aligned across registry, proxy, recorders, and workflows', () => {
94+
const errors: string[] = [];
95+
const activeE2e = activeCockpitE2eWiring();
96+
97+
for (const wiring of activeE2e) {
98+
const capability = capabilities.find((c) => c.angularProject === wiring.project);
99+
if (!capability) {
100+
errors.push(`${wiring.project}: missing capability registry entry`);
101+
continue;
102+
}
103+
104+
if (capability.port !== wiring.angularPort) {
105+
errors.push(`${wiring.project}: registry port ${capability.port} != global setup angularPort ${wiring.angularPort}`);
106+
}
107+
if (capability.pythonPort !== undefined && capability.pythonPort !== wiring.langgraphPort) {
108+
errors.push(`${wiring.project}: registry pythonPort ${capability.pythonPort} != global setup langgraphPort ${wiring.langgraphPort}`);
109+
}
110+
if (capability.pythonDir !== wiring.langgraphCwd) {
111+
errors.push(`${wiring.project}: registry pythonDir ${capability.pythonDir} != global setup langgraphCwd ${wiring.langgraphCwd}`);
112+
}
113+
114+
const proxyPath = join(wiring.projectRoot, 'proxy.conf.json');
115+
if (!existsSync(proxyPath)) {
116+
errors.push(`${wiring.project}: missing proxy.conf.json`);
117+
} else {
118+
const proxy = JSON.parse(readFileSync(proxyPath, 'utf8')) as Record<string, { target?: string }>;
119+
const target = proxy['/api']?.target;
120+
const expectedTarget = `http://localhost:${wiring.langgraphPort}`;
121+
if (target !== expectedTarget) {
122+
errors.push(`${wiring.project}: proxy target ${target} != ${expectedTarget}`);
123+
}
124+
}
125+
126+
const scriptsDir = join(wiring.projectRoot, 'e2e/scripts');
127+
if (existsSync(scriptsDir)) {
128+
for (const script of readdirSync(scriptsDir).filter((name) => name.startsWith('record-'))) {
129+
const scriptPath = join(scriptsDir, script);
130+
const text = readFileSync(scriptPath, 'utf8');
131+
if (!text.includes(wiring.langgraphCwd)) {
132+
errors.push(`${wiring.project}: ${relative(repoRoot, scriptPath)} does not reference ${wiring.langgraphCwd}`);
133+
}
134+
if (wiring.langgraphCwd !== 'cockpit/langgraph/streaming/python' && text.includes('cockpit/langgraph/streaming/python')) {
135+
errors.push(`${wiring.project}: ${relative(repoRoot, scriptPath)} still references cockpit/langgraph/streaming/python`);
136+
}
137+
}
138+
}
139+
140+
for (const workflowPath of workflows) {
141+
const workflow = readRepoFile(workflowPath);
142+
if (!workflow.includes(wiring.project)) {
143+
errors.push(`${wiring.project}: ${workflowPath} does not run the e2e target`);
144+
}
145+
if (!workflow.includes(`working-directory: ${wiring.langgraphCwd}`)) {
146+
errors.push(`${wiring.project}: ${workflowPath} does not pre-sync ${wiring.langgraphCwd}`);
147+
}
148+
}
149+
}
150+
151+
expect(errors).toEqual([]);
152+
});
153+
});

apps/cockpit/scripts/capability-registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ export const capabilities: readonly Capability[] = [
3939
{ id: 'c-messages', product: 'chat', topic: 'messages', angularProject: 'cockpit-chat-messages-angular', port: 4501, pythonPort: 5501, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'c-messages' },
4040
{ id: 'c-input', product: 'chat', topic: 'input', angularProject: 'cockpit-chat-input-angular', port: 4502, pythonPort: 5502, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'c-input' },
4141
{ id: 'c-interrupts', product: 'chat', topic: 'interrupts', angularProject: 'cockpit-chat-interrupts-angular', port: 4503, pythonPort: 5503, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'c-interrupts' },
42-
{ id: 'c-tool-calls', product: 'chat', topic: 'tool-calls', angularProject: 'cockpit-chat-tool-calls-angular', port: 4504, pythonPort: 5504, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'c-tool-calls' },
43-
{ id: 'c-subagents', product: 'chat', topic: 'subagents', angularProject: 'cockpit-chat-subagents-angular', port: 4505, pythonPort: 5505, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'c-subagents' },
42+
{ id: 'c-tool-calls', product: 'chat', topic: 'tool-calls', angularProject: 'cockpit-chat-tool-calls-angular', port: 4504, pythonPort: 5504, pythonDir: 'cockpit/chat/tool-calls/python', graphName: 'c-tool-calls' },
43+
{ id: 'c-subagents', product: 'chat', topic: 'subagents', angularProject: 'cockpit-chat-subagents-angular', port: 4505, pythonPort: 5505, pythonDir: 'cockpit/chat/subagents/python', graphName: 'c-subagents' },
4444
{ id: 'c-threads', product: 'chat', topic: 'threads', angularProject: 'cockpit-chat-threads-angular', port: 4506, pythonPort: 5506, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'c-threads' },
4545
{ id: 'c-timeline', product: 'chat', topic: 'timeline', angularProject: 'cockpit-chat-timeline-angular', port: 4507, pythonPort: 5507, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'c-timeline' },
4646
{ id: 'c-generative-ui', product: 'chat', topic: 'generative-ui', angularProject: 'cockpit-chat-generative-ui-angular', port: 4508, pythonPort: 5508, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'c-generative-ui' },

cockpit/chat/subagents/angular/e2e/scripts/record-c-subagents.sh

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,21 @@ REPO_ROOT="$(cd "$(dirname "$0")/../../../../../.." && pwd)"
1919
cd "$REPO_ROOT"
2020

2121
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
22-
# Try .env (examples first, then streaming as fallback for worktrees)
23-
for env_path in examples/chat/python/.env cockpit/langgraph/streaming/python/.env; do
22+
# Try .env (examples first, then the standalone backend as fallback for worktrees)
23+
for env_path in examples/chat/python/.env cockpit/chat/subagents/python/.env; do
2424
if [[ -f "$env_path" ]]; then
2525
set -a; source "$env_path"; set +a
2626
break
2727
fi
2828
done
2929
fi
3030
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
31-
echo "OPENAI_API_KEY not set (in env or examples/chat/python/.env)" >&2
31+
echo "OPENAI_API_KEY not set (in env, examples/chat/python/.env, or cockpit/chat/subagents/python/.env)" >&2
3232
exit 1
3333
fi
3434

3535
AIMOCK_PORT=19999
36-
LANGGRAPH_PORT=8125
36+
LANGGRAPH_PORT=5505
3737
FIXTURE_OUT="cockpit/chat/subagents/angular/e2e/fixtures/c-subagents.json"
3838
# Aimock --record writes per-request files into <fixtures-base>/recorded/.
3939
# We hand it a dedicated staging dir, then merge all recorded entries into the
@@ -44,12 +44,12 @@ mkdir -p "$RECORD_DIR"
4444
TMP_DIR="$(mktemp -d)"
4545
trap 'rm -rf "$TMP_DIR"' EXIT
4646

47-
# Copy .env into the cockpit-streaming python project (gitignored).
48-
# Use examples/.env when present; otherwise the streaming/.env already exists
49-
# (worktree case where examples/.env hasn't been propagated).
50-
mkdir -p cockpit/langgraph/streaming/python
47+
# Copy .env into the standalone subagents python project (gitignored).
48+
# Use examples/.env when present; otherwise the project .env already exists
49+
# in worktrees where examples/.env hasn't been propagated.
50+
mkdir -p cockpit/chat/subagents/python
5151
if [[ -f "examples/chat/python/.env" ]]; then
52-
cp examples/chat/python/.env cockpit/langgraph/streaming/python/.env
52+
cp examples/chat/python/.env cockpit/chat/subagents/python/.env
5353
fi
5454

5555
# 1. Start aimock in record mode
@@ -95,7 +95,7 @@ else
9595
RUN_PREFIX=""
9696
fi
9797
(
98-
cd cockpit/langgraph/streaming/python
98+
cd cockpit/chat/subagents/python
9999
OPENAI_BASE_URL="http://127.0.0.1:$AIMOCK_PORT/v1" OPENAI_API_KEY="test-record" \
100100
exec $RUN_PREFIX uv run langgraph dev --port "$LANGGRAPH_PORT" --no-browser
101101
) > "$TMP_DIR/langgraph.log" 2>&1 &

cockpit/chat/tool-calls/angular/e2e/scripts/record-c-tool-calls.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
"""Capture parent first-call (tool_call) + continuation (text) for c-tool-calls.
22
3-
Mirrors cockpit/langgraph/streaming/python/src/chat_graphs.py's
4-
_build_tool_calls_graph() LLM setup: ChatOpenAI(gpt-5-mini, streaming=True)
5-
bound with AVIATION_TOOLS, system prompt from prompts/tool-calls.md.
3+
Mirrors cockpit/chat/tool-calls/python/src/graph.py's c-tool-calls LLM setup:
4+
ChatOpenAI(gpt-5-mini, streaming=True) bound with AVIATION_TOOLS, system
5+
prompt from prompts/tool-calls.md.
66
77
Two LLM calls captured, written into one fixture with the hasToolResult
88
discriminator on the continuation entry.
99
1010
Run from repo root:
11-
OPENAI_API_KEY=sk-... uv run --project cockpit/langgraph/streaming/python \
11+
OPENAI_API_KEY=sk-... uv run --project cockpit/chat/tool-calls/python \
1212
python cockpit/chat/tool-calls/angular/e2e/scripts/record-c-tool-calls.py
1313
"""
1414
import asyncio
@@ -18,7 +18,7 @@
1818
import uuid
1919
from pathlib import Path
2020

21-
env_path = Path("cockpit/langgraph/streaming/python/.env")
21+
env_path = Path("cockpit/chat/tool-calls/python/.env")
2222
if env_path.exists():
2323
for line in env_path.read_text().splitlines():
2424
line = line.strip()
@@ -30,7 +30,7 @@
3030
print("OPENAI_API_KEY not set", file=sys.stderr)
3131
sys.exit(1)
3232

33-
sys.path.insert(0, str(Path("cockpit/langgraph/streaming/python/src").resolve()))
33+
sys.path.insert(0, str(Path("cockpit/chat/tool-calls/python/src").resolve()))
3434

3535
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
3636
from langchain_openai import ChatOpenAI
@@ -39,7 +39,7 @@
3939

4040
PROMPT = "What's the status of UA123?"
4141
SYSTEM_PROMPT = (
42-
Path("cockpit/langgraph/streaming/python/prompts/tool-calls.md").read_text()
42+
Path("cockpit/chat/tool-calls/python/prompts/tool-calls.md").read_text()
4343
)
4444

4545
llm = ChatOpenAI(model="gpt-5-mini", temperature=0).bind_tools(AVIATION_TOOLS)

0 commit comments

Comments
 (0)