qwen-code/scripts/daemon-dev.js
ytahdn 0b8a926348
feat(serve): add workspace-scoped Skills runtime (#10697)
* feat(serve): add workspace-scoped Skills runtime

* test(serve): update Skills capability baseline

* fix(serve): address workspace Skills review findings

* fix(serve): address follow-up Skills review findings

* fix(serve): harden workspace skill management

* fix(serve): harden workspace skills management

* refactor(web-shell): remove inert skill management gate

* fix(web-shell): scope skill actions to active workspace

* fix(web-shell): keep workspace skills state scoped

* fix(web-shell): retry ready skills catalogs

* fix(serve): fail closed on unreadable skill directories

* fix(serve): invalidate global skills cache on errors

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-09-05 08:03:24 +00:00

345 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import http from 'node:http';
import net from 'node:net';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { platform } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const args = process.argv.slice(2);
const isWin = platform() === 'win32';
const serveOptionNames = new Set([
'--port',
'--hostname',
'--token',
'--max-sessions',
'--workspace',
'--max-connections',
'--require-auth',
'--event-ring-size',
'--compacted-replay-max-bytes',
'--initialize-timeout-ms',
]);
function readOption(name) {
const prefix = `${name}=`;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === name) {
const value = args[i + 1];
return value && !value.startsWith('--') ? value : undefined;
}
if (arg.startsWith(prefix)) return arg.slice(prefix.length) || undefined;
}
return undefined;
}
function hasOption(name) {
return readOption(name) !== undefined;
}
function validateLauncherArgs() {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const [name, inlineValue] = arg.split('=', 2);
if (!serveOptionNames.has(name)) {
throw new Error(`Unsupported daemon-dev option: ${arg}`);
}
if (arg === '--require-auth') continue;
if (arg.includes('=')) {
if (!inlineValue) throw new Error(`${name} requires a value.`);
continue;
}
const value = args[i + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${arg} requires a value.`);
}
i += 1;
}
}
function serveArgsFromLauncherArgs() {
const result = ['serve'];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const name = arg.split('=', 1)[0];
if (!serveOptionNames.has(name)) continue;
result.push(arg);
if (!arg.includes('=') && arg !== '--require-auth') {
result.push(args[i + 1]);
i += 1;
}
}
return result;
}
function daemonUrl(hostname, port) {
const host =
hostname.includes(':') && !hostname.startsWith('[')
? `[${hostname}]`
: hostname;
return `http://${host}:${port}`;
}
const MAX_PORT_ATTEMPTS = 10;
function findAvailablePort(host, startPort) {
return new Promise((resolveFind, rejectFind) => {
let attempt = 0;
const tryNext = () => {
const port = startPort + attempt;
if (port > 65535 || attempt >= MAX_PORT_ATTEMPTS) {
rejectFind(
new Error(
`No available port found in range ${startPort}${Math.min(startPort + MAX_PORT_ATTEMPTS - 1, 65535)}`,
),
);
return;
}
const probe = net.createServer();
probe.once('error', (err) => {
probe.close();
if (err.code === 'EADDRINUSE') {
console.log(
`[daemon-dev] port ${port} is in use, trying ${port + 1}...`,
);
attempt++;
tryNext();
} else {
rejectFind(err);
}
});
probe.listen(port, host, () => {
probe.close(() => resolveFind(port));
});
};
tryNext();
});
}
function spawnDevProcess(label, command, commandArgs, options) {
const child = spawn(command, commandArgs, {
stdio: 'inherit',
shell: options.shell ?? false,
detached: !isWin,
...options,
});
child.on('error', (err) => {
console.error(`[${label}] failed to start: ${err.message}`);
shutdown(1);
});
child.on('close', (code, signal) => {
if (shuttingDown) return;
if (signal) {
console.error(`[${label}] exited by signal ${signal}`);
shutdown(1);
return;
}
if (code !== 0) {
console.error(`[${label}] exited with code ${code}`);
}
shutdown(code ?? 0);
});
children.push(child);
return child;
}
function killChild(child) {
if (child.killed) return;
try {
if (!isWin && child.pid) {
process.kill(-child.pid, 'SIGTERM');
} else {
child.kill();
}
} catch {
child.kill();
}
}
function waitForDaemon(url) {
const healthUrl = new URL('/health', url);
const deadline = Date.now() + 30_000;
return new Promise((resolve, reject) => {
const check = () => {
let retried = false;
const retryOnce = () => {
if (retried) return;
retried = true;
retry();
};
const req = http.get(healthUrl, (res) => {
res.resume();
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve();
return;
}
retryOnce();
});
req.on('error', retryOnce);
req.setTimeout(1_000, () => {
req.destroy();
retryOnce();
});
};
const retry = () => {
if (shuttingDown) return;
if (Date.now() >= deadline) {
reject(new Error(`Timed out waiting for ${healthUrl.href}`));
return;
}
setTimeout(check, 250);
};
check();
});
}
function shutdown(code) {
if (shuttingDown) return;
shuttingDown = true;
let pending = 0;
for (const child of children) {
if (child.exitCode !== null || child.signalCode !== null) continue;
pending += 1;
child.on('close', () => {
pending -= 1;
if (pending <= 0) process.exit(code);
});
killChild(child);
}
if (pending === 0) process.exit(code);
setTimeout(() => process.exit(code), 5_000).unref();
}
const children = [];
let shuttingDown = false;
try {
validateLauncherArgs();
} catch (err) {
console.error(
`[daemon-dev] ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
const hostname = readOption('--hostname') || '127.0.0.1';
const rawPort = readOption('--port') || '4170';
const startPort = parseInt(rawPort, 10);
if (!Number.isInteger(startPort) || startPort < 0 || startPort > 65535) {
console.error(
`daemon-dev: --port must be an integer 065535, got "${rawPort}".`,
);
process.exit(1);
}
if (startPort === 0) {
console.error(
'daemon-dev: --port 0 is not supported; the launcher needs a fixed port to poll for health.',
);
process.exit(1);
}
const probeHostname =
hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname;
let port;
try {
port = hasOption('--port')
? String(startPort)
: String(await findAvailablePort(probeHostname, startPort));
} catch (err) {
console.error(
`[daemon-dev] ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
const token =
readOption('--token') ||
process.env.QWEN_SERVER_TOKEN ||
crypto.randomBytes(16).toString('hex');
const workspace = resolve(readOption('--workspace') || process.cwd());
const serveArgs = serveArgsFromLauncherArgs();
if (!hasOption('--port')) serveArgs.push('--port', port);
if (!hasOption('--workspace')) serveArgs.push('--workspace', workspace);
const tsxLoaderUrl = pathToFileURL(
join(root, 'node_modules', 'tsx', 'dist', 'esm', 'index.mjs'),
).href;
const nodeOptions = [process.env.NODE_OPTIONS, `--import ${tsxLoaderUrl}`]
.filter(Boolean)
.join(' ');
const serveEnv = {
...process.env,
QWEN_SERVER_TOKEN: token,
QWEN_CODE_NO_RELAUNCH: 'true',
NODE_OPTIONS: nodeOptions,
// QWEN_CODE_CLI — the entry a `qwen …` subprocess should call to reach this
// build — is NOT set here: `scripts/dev.js`, which the daemon below is launched
// through, stamps it unconditionally. Setting it here too gave it two writers,
// and this one deferred to an inherited value (`??`) — so a daemon started from
// inside another qwen session's shell pointed every subprocess at the OUTER
// session's CLI: the exact skew the variable exists to prevent, one level up.
};
const webEnv = {
...process.env,
QWEN_DAEMON_URL: daemonUrl(hostname, port),
};
console.log(`qwen daemon dev`);
console.log(` daemon: ${webEnv.QWEN_DAEMON_URL}`);
console.log(` workspace: ${workspace}`);
console.log(
` web-shell: opening in browser (auto-increments from 5173 if busy — see Vite output for the actual URL, token: ${token.slice(0, 4)}...)`,
);
console.log('');
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
spawnDevProcess('daemon', 'node', ['scripts/dev.js', ...serveArgs], {
cwd: root,
env: serveEnv,
});
waitForDaemon(webEnv.QWEN_DAEMON_URL)
.then(() => {
spawnDevProcess(
'web-shell',
'npm',
[
'run',
'dev',
'--workspace=packages/web-shell',
'--',
'--open',
`/?token=${encodeURIComponent(token)}`,
],
{
cwd: root,
env: webEnv,
shell: isWin,
},
);
})
.catch((err) => {
console.error(`[daemon] ${err.message}`);
shutdown(1);
});