mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +00:00
* ci(serve): daemon A/B before/after preview on response-surface PRs The daemon analog of the web-shell visual before/after bot. For PRs that touch the qwen serve response surface, build the CLI from BOTH the PR base (main) and head, drive a fixed set of endpoints against each daemon, and diff the JSON responses into a before/after field table posted on the PR. A PR with no response change -> "no change". - serve-ab-diff.mjs: structural JSON diff (set-aware arrays, volatile- field masking) -> before/after table; buildComment assembles all scenarios. Pure helpers unit-tested (12 tests). - serve-ab-drive.mjs: boots the built daemon, drives GET /health, /health?deep=1, /capabilities (no model -- dummy creds), captures JSON. - serve-ab.yml / serve-ab-publish.yml: capture (untrusted, no secrets) + privileged workflow_run publisher (validate+bind PR, dedup, TOCTOU), mirroring the web-shell split. Verified locally: real daemon boot + capture, the engine diffing real capabilities -> the before/after table, and no-change detection. The workflow wiring (2x build + drive in CI) is exercised on the next serve PR. * ci(serve): address before/after review — merge-base, base fail-safe, CI-wire tests Applies the transferable web-shell before/after review findings to the daemon A/B: - Diff against the MERGE-BASE, not the base-branch tip, so a PR branch behind main doesn't show others' already-landed daemon changes reversed as this PR's diff. - continue-on-error on the base checkout so a flaky base degrades to after-only instead of sinking the job (the base build/drive already was). - Wire serve-ab-diff.test.mjs into the github_ci_only test step — it uses only node builtins, so it runs there dependency-free (no npm ci needed). * feat(serve): add a create-session deep-health A/B scenario Add a `health-deep-with-session` scenario: the drive harness gains setup-request support, creates one session (POST /session), then probes GET /health?deep=1 — exercising the session lifecycle + cross-workspace session aggregation (#6961's exact case). Broaden the volatile mask to cover lastActivityAt/idleSinceMs/sessionId/clientId/workspaceCwd so the capture is deterministic, while the meaningful counts (sessions, activePrompts, pendingPermissions, connectedClients, channelAlive) stay diffable. Verified against the real daemon: two runs of the same build diff to "no change" (deterministic), and a sessions 1->2 change surfaces as a before/after table row. * ci(serve): address review — narrow trigger, degraded-base marker, test the entrypoint - Narrow the trigger from packages/core/src/** (~1100 mostly-unrelated files) to packages/cli/src/serve/** — the daemon's actual response surface (core has no serve/health subtree). (Suggestion) - diffCaptureDirs signals baselineMissing when head captures exist but the base build/drive produced none, so the comment says "diff skipped" rather than misreporting every field as added or "no change". (Suggestion) - Export + test diffCaptureDirs (the function the CI `comment` subcommand actually invokes) with temp-fixture dirs, covering the diff + degraded path. (Suggestion) - Wording: "PR base" not "main" (the workflow also runs for release/**). * ci(serve): address review round 2 — escape table values, guard setup/baseline/daemon - fmt escapes `|` and backtick so an arbitrary daemon value can't split a GFM table cell or close the code span. - diffCaptureDirs surfaces base-only (removed/failed-to-capture) scenarios instead of silently dropping them — otherwise a broken scenario lowers the "across N" count and masks the regression. - The drive checks each setup request's status and throws on non-2xx, so a failed POST /session can't let health-deep-with-session capture wrong state (0 sessions) and fake/mask a diff. - The drive awaits daemon exit after SIGTERM (SIGKILL after 5s) so a hung daemon can't linger. * ci(serve): clear-error JSON parse for both capture sides The after-side JSON.parse was unguarded while the before-side wasn't; a readJson helper now gives a 'invalid JSON capture at <path>' error for either side instead of a raw SyntaxError. * ci(serve): review round 3 — merge-base skip, surface malformed base, escape path, kill dead stdout - serve-ab.yml: retry the merge-base compare then SKIP the before build (after-only) instead of falling back to the base-branch tip; gate the base checkout/build on a resolved sha + successful checkout. - diffCaptureDirs: an existing-but-malformed base capture now surfaces (existsSync + readJson) instead of a bare catch treating it as {} (which reported every field as "added"); covered by a new test. - renderTable: escape backticks in the path field too (fmt already did for values). - Drop the dead stdout + its misleading "workflow reads it" comment (the publisher posts whenever body.md exists); log the count to stderr. * ci(serve): renderTable label 'PR base' not 'main' (release/** provenance) buildComment already said 'PR base', but the per-scenario table header + no-change line still said 'main'; align them (the workflow also runs for release/**, whose base is not main). --------- Co-authored-by: wenshao <wenshao@example.com>
186 lines
5.9 KiB
JavaScript
186 lines
5.9 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Drive a built `qwen serve` daemon through a fixed scenario set and capture
|
|
* each endpoint's JSON response to `<outDir>/<scenario>.json`. Run once against
|
|
* the PR-base build and once against the PR-head build; serve-ab-diff.mjs then
|
|
* diffs the two capture dirs per scenario.
|
|
*
|
|
* Deterministic + credential-free: `/health` needs no auth; `/capabilities`
|
|
* uses the local `--token`. No model is contacted (dummy OpenAI creds), so the
|
|
* responses are stable and safe to diff. Scenarios that mutate state (create a
|
|
* session, etc.) can be added here later — mask their volatile fields in
|
|
* serve-ab-diff.mjs.
|
|
*
|
|
* node serve-ab-drive.mjs <cliEntry> <outDir>
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
import { createServer } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
// The fixed scenarios. `auth` sends the bearer token; anything mutating the
|
|
// daemon would push requests here in order.
|
|
export const SCENARIOS = [
|
|
{ name: 'health', method: 'GET', path: '/health', auth: false },
|
|
{ name: 'health-deep', method: 'GET', path: '/health?deep=1', auth: false },
|
|
{ name: 'capabilities', method: 'GET', path: '/capabilities', auth: true },
|
|
{
|
|
// Create one session, THEN probe deep health — exercises the session
|
|
// lifecycle and the cross-workspace session aggregation (#6961's exact
|
|
// case). Runs last so the earlier probes see the idle daemon. The volatile
|
|
// `lastActivityAt` / `idleSinceMs` in the response are masked by
|
|
// serve-ab-diff.mjs; the meaningful counts (sessions, pendingPermissions,
|
|
// activePrompts, connectedClients, channelAlive) are stable.
|
|
name: 'health-deep-with-session',
|
|
setup: [
|
|
{
|
|
method: 'POST',
|
|
path: '/session',
|
|
auth: true,
|
|
body: ({ home }) => ({ clientId: 'serve-ab', workspaceCwd: home }),
|
|
},
|
|
],
|
|
method: 'GET',
|
|
path: '/health?deep=1',
|
|
auth: true,
|
|
},
|
|
];
|
|
|
|
function freePort() {
|
|
return new Promise((resolve, reject) => {
|
|
const s = createServer();
|
|
s.on('error', reject);
|
|
s.listen(0, '127.0.0.1', () => {
|
|
const { port } = s.address();
|
|
s.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForHealth(base, timeoutMs = 30000) {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const r = await fetch(`${base}/health`);
|
|
if (r.ok) return;
|
|
} catch {
|
|
// not up yet
|
|
}
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
}
|
|
throw new Error(`daemon did not become healthy within ${timeoutMs}ms`);
|
|
}
|
|
|
|
export async function driveCli(cliEntry, outDir) {
|
|
mkdirSync(outDir, { recursive: true });
|
|
const home = mkdtempSync(join(tmpdir(), 'serve-ab-home-'));
|
|
const token = 'serve-ab-token';
|
|
const port = await freePort();
|
|
const daemon = spawn(
|
|
'node',
|
|
[
|
|
cliEntry,
|
|
'serve',
|
|
'--port',
|
|
String(port),
|
|
'--token',
|
|
token,
|
|
'--hostname',
|
|
'127.0.0.1',
|
|
'--workspace',
|
|
home,
|
|
],
|
|
{
|
|
// No real model: dummy OpenAI creds so session auth never contacts a
|
|
// backend. HOME/QWEN_HOME isolate any on-disk state per run.
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
QWEN_HOME: join(home, '.qwen'),
|
|
OPENAI_API_KEY: 'fake-key',
|
|
OPENAI_BASE_URL: 'http://127.0.0.1:9/v1',
|
|
OPENAI_MODEL: 'fake-model',
|
|
QWEN_MODEL: 'fake-model',
|
|
},
|
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
},
|
|
);
|
|
const base = `http://127.0.0.1:${port}`;
|
|
try {
|
|
await waitForHealth(base);
|
|
const doRequest = (spec) => {
|
|
const headers = spec.auth ? { Authorization: `Bearer ${token}` } : {};
|
|
let body;
|
|
if (spec.body) {
|
|
headers['Content-Type'] = 'application/json';
|
|
const b =
|
|
typeof spec.body === 'function' ? spec.body({ home }) : spec.body;
|
|
body = JSON.stringify(b);
|
|
}
|
|
return fetch(`${base}${spec.path}`, {
|
|
method: spec.method,
|
|
headers,
|
|
body,
|
|
});
|
|
};
|
|
for (const s of SCENARIOS) {
|
|
// Run any setup requests (e.g. create a session) before the capture.
|
|
for (const step of s.setup ?? []) {
|
|
const r = await doRequest(step);
|
|
// A failed setup (e.g. POST /session non-2xx) would let the capture
|
|
// reflect wrong state (0 sessions) and silently mask or fake a diff —
|
|
// fail loudly instead.
|
|
if (!r.ok) {
|
|
const body = await r.text().catch(() => '');
|
|
throw new Error(
|
|
`setup ${step.method} ${step.path} failed (HTTP ${r.status}) for "${s.name}": ${body.slice(0, 200)}`,
|
|
);
|
|
}
|
|
}
|
|
const res = await doRequest(s);
|
|
const text = await res.text();
|
|
let json;
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
json = { _status: res.status, _nonJson: text.slice(0, 500) };
|
|
}
|
|
writeFileSync(
|
|
join(outDir, `${s.name}.json`),
|
|
JSON.stringify(json, null, 2) + '\n',
|
|
);
|
|
process.stderr.write(` captured ${s.name} (HTTP ${res.status})\n`);
|
|
}
|
|
} finally {
|
|
daemon.kill('SIGTERM');
|
|
// Await exit so a hung daemon (pending async / open WebSockets) can't
|
|
// linger; escalate to SIGKILL if it doesn't stop promptly.
|
|
await new Promise((resolve) => {
|
|
daemon.on('exit', resolve);
|
|
setTimeout(() => {
|
|
daemon.kill('SIGKILL');
|
|
resolve();
|
|
}, 5000);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
const [cliEntry, outDir] = process.argv.slice(2);
|
|
if (!cliEntry || !outDir) {
|
|
process.stderr.write('usage: serve-ab-drive.mjs <cliEntry> <outDir>\n');
|
|
process.exit(2);
|
|
}
|
|
driveCli(cliEntry, outDir).catch((e) => {
|
|
process.stderr.write(`${e?.stack ?? e}\n`);
|
|
process.exit(1);
|
|
});
|
|
}
|