完成 Editor Agent Mock Agent P1 收尾

接入 Web Project 契约、SpacetimeDB 表与 api-server 控制面
新增 Mock Agent、静态构建 runner 与独立预览网关
补齐 /editor/agent 前端页面、服务客户端和 SSE 订阅
修复 sandbox 预览资源跨域加载并补充并发保护
接入本地 dev 预览端口漂移与服务身份初始化
更新 P1 技术方案、验收清单和 Hermes 共享记忆
This commit is contained in:
2026-06-16 17:31:25 +08:00
parent 80a382b034
commit 4b09ce3096
404 changed files with 14886 additions and 2497 deletions

View File

@@ -1,24 +1,29 @@
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {join, resolve} from 'node:path';
import {afterEach, describe, expect, test, vi} from 'vitest';
import {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
authorizeWebProjectServiceIdentity,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimeProcedureUrl,
buildSpacetimePublishArgs,
cacheSpacetimeIdentity,
createDevServerSpawnOptions,
createWatchConfigs,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseWebProjectServiceIdentityResult,
parseArgs,
parseSpacetimeToolVersion,
readCachedSpacetimeIdentity,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
} from './dev.mjs';
@@ -109,7 +114,7 @@ describe('dev scheduler argument routing', () => {
);
});
linuxTest('Linux 启动时按系统级端口段映射四个 dev 端口', async () => {
linuxTest('Linux 启动时按系统级端口段映射 dev 端口和预览网关端口', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
try {
const {command, explicitOptions, options} = parseArgs([], {
@@ -132,8 +137,10 @@ describe('dev scheduler argument routing', () => {
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
expect(runner.options.webProjectPreviewPort).toBe(22004);
expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001');
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
expect(runner.state.webProjectPreviewPublicBaseUrl).toBe('http://127.0.0.1:22004');
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
@@ -163,6 +170,7 @@ describe('dev scheduler argument routing', () => {
expect(runner.options.apiPort).toBe(8082);
expect(runner.options.spacetimePort).toBe(3101);
expect(runner.options.adminWebPort).toBe(3102);
expect(runner.options.webProjectPreviewPort).toBe(3104);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
@@ -183,6 +191,42 @@ describe('dev scheduler api-server env', () => {
expect(env.GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED).toBe('true');
expect(env.GENARRATIVE_API_PORT).toBe('9091');
expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199');
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_HOST).toBe('127.0.0.1');
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_PORT).toBe('3104');
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_PUBLIC_BASE_URL).toBe('http://127.0.0.1:3104');
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS).toBe(
'http://127.0.0.1:3000 http://localhost:3000',
);
});
test('dev 脚本默认注入 Web Project runner 二进制路径', () => {
const {options} = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: {},
options,
state: {spacetimeServer: 'http://127.0.0.1:3101'},
});
const runnerName =
process.platform === 'win32' ? 'web-project-runner.exe' : 'web-project-runner';
expect(env.GENARRATIVE_WEB_PROJECT_RUNNER_BIN).toBe(
resolve('server-rs', 'target/debug', runnerName),
);
});
test('dev 脚本保留显式 Web Project runner 二进制路径', () => {
const {options} = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: {
GENARRATIVE_WEB_PROJECT_RUNNER_BIN: 'C:\\tools\\web-project-runner.exe',
},
options,
state: {spacetimeServer: 'http://127.0.0.1:3101'},
});
expect(env.GENARRATIVE_WEB_PROJECT_RUNNER_BIN).toBe(
'C:\\tools\\web-project-runner.exe',
);
});
});
@@ -199,6 +243,7 @@ describe('dev scheduler Rust build env', () => {
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
expect(env.RUSTC_WRAPPER).toBe(process.platform === 'win32' ? '' : '/usr/bin/env');
});
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
@@ -233,6 +278,8 @@ describe('dev scheduler stack state file', () => {
webPort: 3010,
adminWebHost: '127.0.0.1',
adminWebPort: 3110,
webProjectPreviewHost: '127.0.0.1',
webProjectPreviewPort: 3112,
spacetimeHost: '127.0.0.1',
spacetimePort: 3120,
spacetimeDataDir: 'server-rs/.spacetimedb/local/data',
@@ -243,6 +290,7 @@ describe('dev scheduler stack state file', () => {
apiTarget: 'http://127.0.0.1:8090',
adminWebTargetHost: '127.0.0.1',
spacetimeServer: 'http://127.0.0.1:3120',
webProjectPreviewPublicBaseUrl: 'http://127.0.0.1:3112',
},
services: new Map([
[
@@ -285,6 +333,14 @@ describe('dev scheduler stack state file', () => {
port: 8090,
url: 'http://127.0.0.1:8090',
});
expect(snapshot.services['web-project-preview']).toMatchObject({
status: 'idle',
pid: null,
host: '127.0.0.1',
port: 3112,
url: 'http://127.0.0.1:3112',
command: 'api-server embedded Web Project preview gateway',
});
});
});
@@ -500,6 +556,85 @@ spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
);
});
test('发布 spacetime-module 时准备 Web Project 服务身份引导密钥', () => {
const {explicitOptions, options} = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
const env = {};
runner.prepareWebProjectServiceBootstrapSecret(env);
expect(env.GENARRATIVE_SPACETIME_WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET).toHaveLength(64);
expect(runner.state.webProjectServiceBootstrapSecretPrepared).toBe(true);
});
test('Web Project 服务身份授权使用本地 Web token 调用 procedure', async () => {
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
ok: true,
service_identity_hex: 'c200localidentity',
error_message: null,
}),
})) as unknown as typeof fetch;
await authorizeWebProjectServiceIdentity({
serverUrl: 'http://127.0.0.1:3101',
database: 'genarrative-dev',
token: 'local-web-token',
bootstrapSecret: '0123456789abcdef',
serviceIdentity: 'c200localidentity',
});
expect(globalThis.fetch).toHaveBeenCalledWith(
buildSpacetimeProcedureUrl(
'http://127.0.0.1:3101',
'genarrative-dev',
'authorize_web_project_service_identity',
),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer local-web-token',
}),
body: JSON.stringify([
{
bootstrap_secret: '0123456789abcdef',
service_identity_hex: 'c200localidentity',
note: 'local api-server web project',
},
]),
}),
);
});
test('Web Project 服务身份授权返回值兼容 BSATN 数组形态', () => {
expect(parseWebProjectServiceIdentityResult('[true,[0,"c200localidentity"],[1]]')).toEqual({
ok: true,
service_identity_hex: 'c200localidentity',
error_message: null,
});
});
test('本地 api-server SpacetimeDB identity 写入并复用 data-dir 缓存', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
try {
cacheSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101', {
identity: 'c200cachedidentity',
token: 'cached-token',
});
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101')).toEqual({
identity: 'c200cachedidentity',
token: 'cached-token',
});
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3199')).toBeNull();
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('手动刷新 spacetime 只重新发布模块,不重启 standalone 进程', async () => {
const {explicitOptions, options} = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
@@ -533,39 +668,55 @@ spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
});
test('启动 api-server 前为空 token 自动创建本地 Web identity', async () => {
const {explicitOptions, options} = parseArgs([], {
GENARRATIVE_SPACETIME_TOKEN: '',
});
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
identity: 'c200localidentity',
token: 'local-web-token',
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
try {
const {explicitOptions, options} = parseArgs(
['--spacetime-data-dir', tempDir],
{
GENARRATIVE_SPACETIME_TOKEN: '',
},
);
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
identity: 'c200localidentity',
token: 'local-web-token',
}),
})) as unknown as typeof fetch;
await runner.ensureApiServerSpacetimeToken();
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('local-web-token');
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101')).toEqual({
identity: 'c200localidentity',
token: 'local-web-token',
});
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3101/v1/identity',
expect.objectContaining({
method: 'POST',
}),
})) as unknown as typeof fetch;
await runner.ensureApiServerSpacetimeToken();
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('local-web-token');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3101/v1/identity',
expect.objectContaining({
method: 'POST',
}),
);
);
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('本地 SpacetimeDB 不信任 env 文件中的陈旧 token', async () => {
const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN;
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
try {
const {explicitOptions, options} = parseArgs([], {
GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token',
});
const {explicitOptions, options} = parseArgs(
['--spacetime-data-dir', tempDir],
{
GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token',
},
);
const runner = new DevRunner(options, {GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token'}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
globalThis.fetch = vi.fn(async () => ({
@@ -582,6 +733,7 @@ spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('fresh-web-token');
} finally {
rmSync(tempDir, {recursive: true, force: true});
if (originalToken === undefined) {
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
} else {