Merge remote-tracking branch 'origin/codex/unified-creation-flow-phase1'
# Conflicts: # server-rs/crates/api-server/src/wooden_fish.rs
This commit is contained in:
289
scripts/dev.mjs
289
scripts/dev.mjs
@@ -9,7 +9,7 @@ import {
|
||||
watch,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import {basename, relative, resolve} from 'node:path';
|
||||
import {basename, join, relative, resolve} from 'node:path';
|
||||
import {createInterface} from 'node:readline';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from './dev-utils.mjs';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const devStackStatePath = join(repoRoot, '.app/dev-stack.json');
|
||||
const serverRsDir = resolve(repoRoot, 'server-rs');
|
||||
const manifestPath = resolve(serverRsDir, 'Cargo.toml');
|
||||
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
|
||||
@@ -255,6 +256,136 @@ function normalizeServiceName(rawName) {
|
||||
throw new Error(`未知模块: ${rawName}`);
|
||||
}
|
||||
|
||||
function resolveDevStackStatePath(root = repoRoot) {
|
||||
return join(root, '.app/dev-stack.json');
|
||||
}
|
||||
|
||||
function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
|
||||
const services = {};
|
||||
for (const serviceName of SERVICE_NAMES) {
|
||||
services[serviceName] = buildDevStackServiceSnapshot(
|
||||
runner,
|
||||
serviceName,
|
||||
updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
command: runner.command ?? 'all',
|
||||
repoRoot,
|
||||
database: runner.options.database,
|
||||
watch: Boolean(runner.options.watch),
|
||||
updatedAt,
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDevStackServiceSnapshot(runner, serviceName, updatedAt) {
|
||||
const service = runner.services.get(serviceName);
|
||||
const runtime = service?.runtime ?? {};
|
||||
const endpoint = resolveDevStackServiceEndpoint(runner, serviceName);
|
||||
const isReusedSpacetime =
|
||||
serviceName === 'spacetime' && runner.state.spacetimeReused;
|
||||
const runtimeStatus = runtime.status ?? 'idle';
|
||||
const status =
|
||||
runtimeStatus === 'idle' && isReusedSpacetime ? 'reused' : runtimeStatus;
|
||||
const childPid =
|
||||
service?.child && Number.isInteger(service.child.pid)
|
||||
? service.child.pid
|
||||
: null;
|
||||
|
||||
return {
|
||||
status,
|
||||
pid:
|
||||
childPid ??
|
||||
runtime.pid ??
|
||||
(isReusedSpacetime ? (runner.state.spacetimePid ?? null) : null),
|
||||
host: runtime.host ?? endpoint.host,
|
||||
port: runtime.port ?? endpoint.port,
|
||||
url: runtime.url ?? endpoint.url,
|
||||
command: runtime.command ?? resolveDevStackServiceCommand(runner, serviceName),
|
||||
startedAt: runtime.startedAt ?? null,
|
||||
updatedAt: runtime.updatedAt ?? updatedAt,
|
||||
exitCode: runtime.exitCode ?? null,
|
||||
signal: runtime.signal ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDevStackServiceEndpoint(runner, serviceName) {
|
||||
const {options, state} = runner;
|
||||
switch (serviceName) {
|
||||
case 'spacetime':
|
||||
return {
|
||||
host: options.spacetimeHost,
|
||||
port: options.spacetimePort,
|
||||
url: state.spacetimeServer,
|
||||
};
|
||||
case 'api-server':
|
||||
return {
|
||||
host: options.apiHost,
|
||||
port: options.apiPort,
|
||||
url: state.apiTarget,
|
||||
};
|
||||
case 'web':
|
||||
return {
|
||||
host: options.webHost,
|
||||
port: options.webPort,
|
||||
url: `http://${resolveClientHost(options.webHost)}:${options.webPort}`,
|
||||
};
|
||||
case 'admin-web':
|
||||
return {
|
||||
host: options.adminWebHost,
|
||||
port: options.adminWebPort,
|
||||
url: `http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
host: null,
|
||||
port: null,
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDevStackServiceCommand(runner, serviceName) {
|
||||
const {options, state} = runner;
|
||||
switch (serviceName) {
|
||||
case 'spacetime':
|
||||
return state.spacetimeReused
|
||||
? `reuse spacetime standalone ${state.spacetimeServer}`
|
||||
: [
|
||||
'spacetime',
|
||||
'start',
|
||||
'--data-dir',
|
||||
options.spacetimeDataDir,
|
||||
'--listen-addr',
|
||||
`${options.spacetimeHost}:${options.spacetimePort}`,
|
||||
'--non-interactive',
|
||||
].join(' ');
|
||||
case 'api-server':
|
||||
return 'cargo run -p api-server --manifest-path server-rs/Cargo.toml';
|
||||
case 'web':
|
||||
return [
|
||||
'node',
|
||||
relative(repoRoot, viteCliPath),
|
||||
`--port=${options.webPort}`,
|
||||
`--host=${options.webHost}`,
|
||||
'--strictPort',
|
||||
].join(' ');
|
||||
case 'admin-web':
|
||||
return [
|
||||
'node',
|
||||
relative(adminWebDir, viteCliPath),
|
||||
`--host=${options.adminWebHost}`,
|
||||
`--port=${options.adminWebPort}`,
|
||||
'--strictPort',
|
||||
].join(' ');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requireCommand(command) {
|
||||
const result = spawnSync(command, ['--version'], {
|
||||
cwd: repoRoot,
|
||||
@@ -384,14 +515,37 @@ function ensureRequiredFiles(command) {
|
||||
}
|
||||
|
||||
class DevService {
|
||||
constructor(name, startFn) {
|
||||
constructor(name, startFn, onStateChange = null) {
|
||||
this.name = name;
|
||||
this.startFn = startFn;
|
||||
this.onStateChange = onStateChange;
|
||||
this.child = null;
|
||||
this.children = [];
|
||||
this.logStream = null;
|
||||
this.stopping = false;
|
||||
this.restartTimer = null;
|
||||
this.runtime = {
|
||||
status: 'idle',
|
||||
pid: null,
|
||||
host: null,
|
||||
port: null,
|
||||
url: null,
|
||||
command: null,
|
||||
startedAt: null,
|
||||
updatedAt: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
|
||||
updateRuntimeState(patch) {
|
||||
const updatedAt = new Date().toISOString();
|
||||
this.runtime = {
|
||||
...this.runtime,
|
||||
...patch,
|
||||
updatedAt,
|
||||
};
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
async start() {
|
||||
@@ -399,17 +553,36 @@ class DevService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startFn(this);
|
||||
this.updateRuntimeState({status: 'starting', exitCode: null, signal: null});
|
||||
try {
|
||||
await this.startFn(this);
|
||||
} catch (error) {
|
||||
this.updateRuntimeState({status: 'failed', pid: null});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
registerChild(child) {
|
||||
this.child = child;
|
||||
this.updateRuntimeState({
|
||||
status: 'running',
|
||||
pid: Number.isInteger(child.pid) ? child.pid : null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
startedAt: this.runtime.startedAt ?? new Date().toISOString(),
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (this.logStream && !this.logStream.destroyed) {
|
||||
this.logStream.end();
|
||||
}
|
||||
|
||||
this.child = null;
|
||||
this.updateRuntimeState({
|
||||
status: this.stopping ? 'stopped' : code === 0 ? 'stopped' : 'failed',
|
||||
pid: null,
|
||||
exitCode: code ?? null,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
if (this.stopping) {
|
||||
this.stopping = false;
|
||||
return;
|
||||
@@ -428,6 +601,9 @@ class DevService {
|
||||
|
||||
const processes = [this.child, ...this.children].filter(Boolean);
|
||||
this.stopping = processes.length > 0;
|
||||
if (processes.length > 0) {
|
||||
this.updateRuntimeState({status: 'stopping'});
|
||||
}
|
||||
this.child = null;
|
||||
this.children = [];
|
||||
|
||||
@@ -440,6 +616,9 @@ class DevService {
|
||||
}
|
||||
this.logStream = null;
|
||||
this.stopping = false;
|
||||
if (processes.length > 0) {
|
||||
this.updateRuntimeState({status: 'stopped', pid: null});
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRestart(delayMs = 250, restartFn = null, actionLabel = '重启') {
|
||||
@@ -589,6 +768,7 @@ class DevRunner {
|
||||
await this.resolvePorts(command);
|
||||
this.registerServices();
|
||||
this.printSummary(command);
|
||||
this.writeDevStackState();
|
||||
}
|
||||
|
||||
async prepareLinuxPortRange(command) {
|
||||
@@ -703,6 +883,9 @@ class DevRunner {
|
||||
}
|
||||
this.state.spacetimeServer = candidate;
|
||||
this.state.spacetimeReused = true;
|
||||
this.state.spacetimePid = Number.isInteger(pidState.pid)
|
||||
? pidState.pid
|
||||
: null;
|
||||
const pidLabel = Number.isInteger(pidState.pid) ? ` pid=${pidState.pid}` : '';
|
||||
console.log(`[dev:spacetime] 复用已启动实例${pidLabel}: ${candidate}`);
|
||||
return;
|
||||
@@ -807,19 +990,48 @@ class DevRunner {
|
||||
}
|
||||
|
||||
registerServices() {
|
||||
const onStateChange = () => this.writeDevStackState();
|
||||
this.services.set(
|
||||
'spacetime',
|
||||
new DevService('spacetime', async (service) => this.startSpacetime(service)),
|
||||
new DevService(
|
||||
'spacetime',
|
||||
async (service) => this.startSpacetime(service),
|
||||
onStateChange,
|
||||
),
|
||||
);
|
||||
this.services.set(
|
||||
'api-server',
|
||||
new DevService('api-server', async (service) => this.startApiServer(service)),
|
||||
new DevService(
|
||||
'api-server',
|
||||
async (service) => this.startApiServer(service),
|
||||
onStateChange,
|
||||
),
|
||||
);
|
||||
this.services.set(
|
||||
'web',
|
||||
new DevService('web', async (service) => this.startWeb(service), onStateChange),
|
||||
);
|
||||
this.services.set('web', new DevService('web', async (service) => this.startWeb(service)));
|
||||
this.services.set(
|
||||
'admin-web',
|
||||
new DevService('admin-web', async (service) => this.startAdminWeb(service)),
|
||||
new DevService(
|
||||
'admin-web',
|
||||
async (service) => this.startAdminWeb(service),
|
||||
onStateChange,
|
||||
),
|
||||
);
|
||||
|
||||
if (this.state.spacetimeReused) {
|
||||
const spacetimeService = this.services.get('spacetime');
|
||||
const endpoint = resolveDevStackServiceEndpoint(this, 'spacetime');
|
||||
spacetimeService?.updateRuntimeState({
|
||||
status: 'reused',
|
||||
pid: this.state.spacetimePid ?? null,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
url: endpoint.url,
|
||||
command: resolveDevStackServiceCommand(this, 'spacetime'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
printSummary(command) {
|
||||
@@ -843,6 +1055,16 @@ class DevRunner {
|
||||
console.log(`[dev] database: ${options.database}`);
|
||||
}
|
||||
|
||||
writeDevStackState() {
|
||||
try {
|
||||
ensureParentDir(devStackStatePath);
|
||||
const snapshot = buildDevStackSnapshot(this);
|
||||
writeFileSync(devStackStatePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
||||
} catch (error) {
|
||||
console.warn(`[dev] 写入 ${devStackStatePath} 失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async startCommand(command) {
|
||||
if (command === 'all') {
|
||||
await this.startSpacetimeForFullStack();
|
||||
@@ -916,6 +1138,17 @@ class DevRunner {
|
||||
);
|
||||
|
||||
console.log(`[dev:spacetime] log: ${logFile}`);
|
||||
service.updateRuntimeState({
|
||||
status: 'starting',
|
||||
pid: null,
|
||||
host: options.spacetimeHost,
|
||||
port: options.spacetimePort,
|
||||
url: this.state.spacetimeServer,
|
||||
command: resolveDevStackServiceCommand(this, 'spacetime'),
|
||||
startedAt: new Date().toISOString(),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
});
|
||||
const env = {
|
||||
...this.baseEnv,
|
||||
};
|
||||
@@ -969,6 +1202,11 @@ class DevRunner {
|
||||
this.options.spacetimePort = port;
|
||||
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${port}`;
|
||||
recordSpacetimeUrl(this.options.spacetimeDataDir, this.state.spacetimeServer);
|
||||
this.services.get('spacetime')?.updateRuntimeState({
|
||||
host: this.options.spacetimeHost,
|
||||
port,
|
||||
url: this.state.spacetimeServer,
|
||||
});
|
||||
console.log(`[dev:spacetime] actual: ${this.state.spacetimeServer}`);
|
||||
}
|
||||
|
||||
@@ -1065,6 +1303,17 @@ class DevRunner {
|
||||
console.log(
|
||||
`[dev:api-server] SpacetimeDB ${this.options.database} @ ${this.state.spacetimeServer}`,
|
||||
);
|
||||
service.updateRuntimeState({
|
||||
status: 'starting',
|
||||
pid: null,
|
||||
host: this.options.apiHost,
|
||||
port: this.options.apiPort,
|
||||
url: this.state.apiTarget,
|
||||
command: resolveDevStackServiceCommand(this, 'api-server'),
|
||||
startedAt: new Date().toISOString(),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
});
|
||||
|
||||
const child = spawn(
|
||||
'cargo',
|
||||
@@ -1107,6 +1356,7 @@ class DevRunner {
|
||||
|
||||
startWeb(service) {
|
||||
const apiTarget = this.resolveFrontendApiTarget();
|
||||
const endpoint = resolveDevStackServiceEndpoint(this, 'web');
|
||||
const env = {
|
||||
...this.baseEnv,
|
||||
RUST_SERVER_TARGET: apiTarget,
|
||||
@@ -1117,6 +1367,17 @@ class DevRunner {
|
||||
};
|
||||
|
||||
console.log(`[dev:web] api target: ${apiTarget}`);
|
||||
service.updateRuntimeState({
|
||||
status: 'starting',
|
||||
pid: null,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
url: endpoint.url,
|
||||
command: resolveDevStackServiceCommand(this, 'web'),
|
||||
startedAt: new Date().toISOString(),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
});
|
||||
const child = spawn(
|
||||
'node',
|
||||
[
|
||||
@@ -1142,6 +1403,7 @@ class DevRunner {
|
||||
|
||||
startAdminWeb(service) {
|
||||
const apiTarget = this.resolveFrontendApiTarget({admin: true});
|
||||
const endpoint = resolveDevStackServiceEndpoint(this, 'admin-web');
|
||||
const env = {
|
||||
...this.baseEnv,
|
||||
ADMIN_API_TARGET: apiTarget,
|
||||
@@ -1150,6 +1412,17 @@ class DevRunner {
|
||||
};
|
||||
|
||||
console.log(`[dev:admin-web] api target: ${apiTarget}`);
|
||||
service.updateRuntimeState({
|
||||
status: 'starting',
|
||||
pid: null,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
url: endpoint.url,
|
||||
command: resolveDevStackServiceCommand(this, 'admin-web'),
|
||||
startedAt: new Date().toISOString(),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
});
|
||||
const child = spawn(
|
||||
'node',
|
||||
[
|
||||
@@ -1820,12 +2093,14 @@ export {
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
isSpacetimePublishPermissionError,
|
||||
parseSpacetimeToolVersion,
|
||||
parseArgs,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
isSpacetimePublishPermissionError,
|
||||
parseSpacetimeToolVersion,
|
||||
parseArgs,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
} from './dev.mjs';
|
||||
|
||||
@@ -168,6 +170,79 @@ describe('dev scheduler api-server env', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler stack state file', () => {
|
||||
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
|
||||
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
|
||||
join('C:\\repo\\Genarrative', '.app/dev-stack.json'),
|
||||
);
|
||||
});
|
||||
|
||||
test('状态快照记录服务 pid、端口、URL 和当前命令', () => {
|
||||
const updatedAt = '2026-05-29T00:00:00.000Z';
|
||||
const runner = {
|
||||
command: 'web',
|
||||
options: {
|
||||
apiHost: '127.0.0.1',
|
||||
apiPort: 8090,
|
||||
webHost: '0.0.0.0',
|
||||
webPort: 3010,
|
||||
adminWebHost: '127.0.0.1',
|
||||
adminWebPort: 3110,
|
||||
spacetimeHost: '127.0.0.1',
|
||||
spacetimePort: 3120,
|
||||
spacetimeDataDir: 'server-rs/.spacetimedb/local/data',
|
||||
database: 'genarrative-test',
|
||||
watch: false,
|
||||
},
|
||||
state: {
|
||||
apiTarget: 'http://127.0.0.1:8090',
|
||||
adminWebTargetHost: '127.0.0.1',
|
||||
spacetimeServer: 'http://127.0.0.1:3120',
|
||||
},
|
||||
services: new Map([
|
||||
[
|
||||
'web',
|
||||
{
|
||||
child: {pid: 4321},
|
||||
runtime: {
|
||||
status: 'running',
|
||||
pid: 4321,
|
||||
host: '0.0.0.0',
|
||||
port: 3010,
|
||||
url: 'http://127.0.0.1:3010',
|
||||
command: 'node scripts/vite-cli.mjs --port=3010',
|
||||
startedAt: updatedAt,
|
||||
updatedAt,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const snapshot = buildDevStackSnapshot(runner, updatedAt);
|
||||
|
||||
expect(snapshot.schemaVersion).toBe(1);
|
||||
expect(snapshot.command).toBe('web');
|
||||
expect(snapshot.database).toBe('genarrative-test');
|
||||
expect(snapshot.services.web).toMatchObject({
|
||||
status: 'running',
|
||||
pid: 4321,
|
||||
host: '0.0.0.0',
|
||||
port: 3010,
|
||||
url: 'http://127.0.0.1:3010',
|
||||
command: 'node scripts/vite-cli.mjs --port=3010',
|
||||
});
|
||||
expect(snapshot.services['api-server']).toMatchObject({
|
||||
status: 'idle',
|
||||
pid: null,
|
||||
port: 8090,
|
||||
url: 'http://127.0.0.1:8090',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler spacetime reuse guard', () => {
|
||||
test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
|
||||
|
||||
Reference in New Issue
Block a user