Merge remote-tracking branch 'web/master' into feat/pixel_art2

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
#	docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
#	server-rs/crates/api-server/src/editor_project.rs
This commit is contained in:
2026-07-31 09:11:12 +00:00
570 changed files with 398466 additions and 2274 deletions
@@ -144,6 +144,20 @@ client.generate_image(
)
```
For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation:
```python
client.generate_icon_spritesheet(
"editor-resource-current-art-spec",
["蛇头四方向", "直身与四种转角", "尾部四方向", "四类可区分食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
screenColor="auto",
)
```
Pass the registered visual-spec resource ID as `reference_image_src`; do not pass the UI prototype or a local path.
Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables.
Use this shared base:
@@ -328,6 +342,18 @@ Character image generation (including character redraw through `kind: "character
- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason.
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because a failed transparent post-process never reaches slicing. Since 2026-07-29 a general `warning` may also come from image-style normalization (`unsupported-image-style`) or pixel-art snapping, and those can coexist with `sliceWarning` in the same response. Display both reasons; do not drop either one and do not misclassify a source-preserved result as a slicing-only warning.
For reusable transparent game/UI sheets, do not substitute ordinary image generation merely because it can draw several objects in one image. Use icon spritesheet generation when a stable visual-spec reference and `iconDescriptions` exist; use UI extraction only for an existing annotated UI design. Pass `screenColor: "auto"` unless the art direction requires one of the supported solid chroma colors. A client must verify the returned full sheet really contains transparency before treating it as a transparent spritesheet. If a source-preserved `warning` is present, do not register the opaque provider source as the requested transparent deliverable. When only `sliceWarning` is present, the full transparent sheet remains usable, but no individual slices may be claimed.
## AI Game Creator Canonical Visual DAG
The AI game creator reuses its existing 16-task manifest; do not add a parallel task system or collapse the following artifacts into one ordinary generation request:
1. `art-director` generates `assets/art-spec.png` with `POST /api/external/v1/editor/images/generations`, `kind: "spec"`, and registers it as `assetKind: "icon-spec"`. This is the real visual-spec image. The JSON value in `generationInputs.artSpec` is supporting structured context and does not replace this image.
2. `design-foundation` uses the registered External Editor resource ID for `assets/art-spec.png` in `referenceImageSrcs`, then generates the complete `assets/ui-prototype.png` through `POST /api/external/v1/editor/images/generations` with `kind: "ui-design"`.
3. `art-asset-plan` uses the same registered `assets/art-spec.png` resource ID as the required `referenceImageSrc` for `POST /api/external/v1/editor/icon-spritesheets/generations`, supplies concrete `iconDescriptions`, and registers the transparent full result as `assets/art-spritesheet.png`.
Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec reference. `POST /api/external/v1/editor/ui-designs/assets/extractions` requires an existing UI design image with red-box annotations; it is not UI generation and is not part of this canonical DAG.
## Guardrails
- Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes.
@@ -20,6 +20,14 @@ At the start of a new conversation, ask for a canvas name before the first gener
Before generating art assets, normalize the user's request into a current art spec with `assetType`, `subject`, `style`, `palette`, `composition`, `format`, `constraints`, and `references`. Ask follow-up questions only for missing fields that block the selected endpoint. Reuse the current spec automatically when the user asks for another asset without changing style/spec requirements. Put the spec in `generationInputs.artSpec` and summarize it in the prompt when useful.
For the AI game creator's existing 16-task autonomous build, distinguish that JSON art spec from the required visual-spec image and keep this dependency chain:
1. `art-director` -> `assets/art-spec.png` via `POST /api/external/v1/editor/images/generations`, with `kind=spec` and registered `assetKind=icon-spec`.
2. `design-foundation` -> `assets/ui-prototype.png` via the same image generation endpoint with `kind=ui-design`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` -> transparent `assets/art-spritesheet.png` via `POST /api/external/v1/editor/icon-spritesheets/generations`, using the registered art-spec resource ID as `referenceImageSrc` and providing `iconDescriptions`.
Do not use the UI prototype as the spritesheet specification. UI extraction requires a stable source image with red-box annotations and is outside this canonical DAG.
## Intent Routing
Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous.
@@ -69,8 +77,8 @@ Ask a follow-up only when two routes could both be correct and produce different
| --- | --- | --- | --- |
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` |
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame |
| Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
@@ -460,6 +460,29 @@ class GenarrativeExternalClient:
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
)
def generate_icon_spritesheet(
self,
reference_image_src: str,
icon_descriptions: list[str],
**fields: Any,
) -> Any:
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
if not descriptions:
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
label = fields.get("assetLabel", "图标图集")
self._apply_canvas_session_fields(fields, label, 1024, 1024)
fields.setdefault("screenColor", "auto")
return self.request_json(
"POST",
"/api/external/v1/editor/icon-spritesheets/generations",
{
"referenceImageSrc": reference_image_src,
"iconDescriptions": descriptions,
**fields,
},
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
)
def extract_ui_assets(self, source_image_src: str, image_size: str = "1K", **fields: Any) -> Any:
fields.pop("aspectRatio", None)
self._apply_canvas_session_fields(fields, fields.get("spritesheetLabel", "UI 素材拆分"), 1024, 1024, "spritesheetLabel")
@@ -629,6 +652,17 @@ def _self_test() -> None:
assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画"
assert calls[1]["path"] == "/api/external/v1/editor/assets"
assert result["asset"]["assetId"] == "editor-asset-demo"
calls.clear()
client.generate_icon_spritesheet(
"editor-resource-spec",
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
)
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec"
assert calls[0]["body"]["screenColor"] == "auto"
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
print("self-test ok")
+46 -2
View File
@@ -103,6 +103,9 @@ jobs:
- name: Install npm dependencies
run: npm ci
- name: Install AI game creator dependencies
run: npm ci --prefix apps/ai-game-creator-shell
- name: Run frontend and script tests
run: npm run test
@@ -161,6 +164,23 @@ jobs:
- name: Check server-rs boundaries
run: npm run check:server-rs-ddd
- name: Prepare server-rs Rust dependencies
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path server-rs/Cargo.toml; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo 'server-rs Cargo dependency fetch failed after 5 attempts.' >&2
exit 1
fi
sleep $((attempt * 2))
done
- name: Run server-rs workspace tests
run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml
@@ -186,8 +206,32 @@ jobs:
- name: Install npm dependencies
run: npm ci
- name: Install AI game creator dependencies
run: npm ci --prefix apps/ai-game-creator-shell
- name: Prepare native Rust dependencies
shell: bash
run: |
set -euo pipefail
for manifest_path in \
apps/desktop-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "Cargo dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Run native shell gates
run: npm run check:native-shells
- name: Ensure native lockfile is unchanged
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock
- name: Ensure native lockfiles are unchanged
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
+7
View File
@@ -31,6 +31,12 @@ temp*build*/
/apps/desktop-shell/src-tauri/target/
/apps/desktop-shell/src-tauri/gen/
/apps/desktop-shell/src-tauri/permissions/autogenerated/
/apps/ai-game-creator-shell/src-tauri/target/
/apps/ai-game-creator-shell/src-tauri/gen/
/apps/ai-game-creator-shell/src-tauri/logs/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
/apps/mobile-shell/.expo/
/apps/mobile-shell/.expo-export-smoke/
/server-rs/.spacetimedb/
@@ -47,6 +53,7 @@ temp*build*/
/.playwright-cli/
**/.playwright-cli/
/output/playwright/
/output/external-api-smoke/
/server-rs/crates/*/logs/
.worktrees/
.rag/
+1
View File
@@ -33,6 +33,7 @@
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage``needs-info``ready-for-agent``ready-for-human``wontfix`
- 需要仓库级 Hermes skills/plugins 时,再读取 [`.hermes/README.md`](.hermes/README.md)。
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
- 涉及 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` / `npm run dev:web` / `npm run dev:admin-web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。
- 涉及 SpacetimeDB 的设计、实现、脚本、调试、发布、绑定生成、schema、reducer、procedure、view 或 Rust API 时,必须读取并按 [`spacetimedb-cli`](.codex/skills/spacetimedb-cli/SKILL.md)、[`spacetimedb-rust`](.codex/skills/spacetimedb-rust/SKILL.md)、[`spacetimedb-concepts`](.codex/skills/spacetimedb-concepts/SKILL.md) 中相关 skill 执行。
+4
View File
@@ -16,6 +16,10 @@ _Avoid_: 在玩法页面内手写上传、参考图、重绘、预览、删除
独立 `/editor` 中可保存、恢复和继续编辑的图片画布工作状态,包含画布视图、图层布局和资源引用;用于多图对比、生成结果衍生和画布级编辑,不替代玩法页面内的单图资产编辑。
_Avoid_: 玩法结果页单图槽位、发布态作品、只存在前端内存里的临时画布
**项目开发画布**:
GameAgent 独立客户端中某个本地游戏项目的开发工作区概念,用于承载项目名、路径、首条需求、附件导入结果、最近 run 状态,以及后续真正的项目开发画布与 Agent 协作界面;当前首页改造阶段先落占位页。它属于 AI 游戏创作本地项目域,不等同于 `/editor` 的图片画布工程。
_Avoid_: `/editor` 图片画布工程、画布资源 / 图层布局、启动器 / 主窗口切换概念、只用于首页输入的临时草稿
**画布Agent对话**:
图片画布工程右侧的对话式编辑器工具,用户通过自然语言调度画布已有的图片类生成与编辑能力(生成图片、生成角色形象、生成图标素材、生成 UI 设计图、基于附件的图片修改),并可附加画布素材或素材库图片作为参考;对话归属单个图片画布工程,可保存历史、新开会话和软删会话。属于画布域工具,不承接玩法创作、不产出玩法作品或模板,与「表单/图片输入创作工作台」的 Avoid 边界不冲突。
_Avoid_: 对话式玩法创作工作台、绕过模型定价收口的生成入口、把对话消息当作画布布局真相、复用拼图专用 creative-agent 内存会话
@@ -11,6 +11,7 @@ import {
updateAdminAccount,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
upsertProfileWalletConfig,
} from './adminApiClient';
afterEach(() => {
@@ -71,6 +72,33 @@ test('后台账号创建和更新同时携带 Tab 与独立操作权限', async
);
});
test('账号配置一次提交初始和每日免费泥点', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({configId: 'profile_wallet'}), {
status: 200,
headers: {'content-type': 'application/json'},
}),
);
vi.stubGlobal('fetch', fetchMock);
await upsertProfileWalletConfig('owner-token', {
initialMudPoints: 100,
dailyFreePointsPerDay: 35,
});
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/profile/wallet-config',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
body: JSON.stringify({
initialMudPoints: 100,
dailyFreePointsPerDay: 35,
}),
}),
);
});
test('灰度配置读写只使用通用 feature-gates 管理接口', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+2
View File
@@ -560,6 +560,7 @@ export interface AdminUpsertProfileRechargeProductRequest {
export interface AdminUpsertProfileWalletConfigRequest {
initialMudPoints: number;
dailyFreePointsPerDay: number;
}
export interface ProfileRedeemCodeAdminResponse {
@@ -661,6 +662,7 @@ export interface ProfileRechargeProductConfigAdminListResponse {
export interface ProfileWalletConfigAdminResponse {
configId: string;
initialMudPoints: number;
dailyFreePointsPerDay: number;
createdBy: string;
createdByDisplayName: string;
createdAt: string;
@@ -0,0 +1,58 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import {getProfileWalletConfig, upsertProfileWalletConfig} from '../api/adminApiClient';
import type {ProfileWalletConfigAdminResponse} from '../api/adminApiTypes';
import {AdminProfileWalletConfigPage} from './AdminProfileWalletConfigPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) => error instanceof Error ? error.message : '请求失败'),
getProfileWalletConfig: vi.fn(),
isAdminApiError: vi.fn(() => false),
upsertProfileWalletConfig: vi.fn(),
}));
const configResponse: ProfileWalletConfigAdminResponse = {
configId: 'profile_wallet', initialMudPoints: 100, dailyFreePointsPerDay: 20,
createdBy: 'owner-1', createdByDisplayName: '管理员',
createdAt: '2026-07-31T01:00:00Z', updatedBy: 'owner-1',
updatedByDisplayName: '管理员', updatedAt: '2026-07-31T01:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getProfileWalletConfig).mockResolvedValue(configResponse);
vi.mocked(upsertProfileWalletConfig).mockResolvedValue({...configResponse, initialMudPoints: 120, dailyFreePointsPerDay: 35});
});
test('账号配置页加载并展示每日免费泥点', async () => {
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={vi.fn()} />);
expect((await screen.findByLabelText('每日免费泥点数') as HTMLInputElement).value).toBe('20');
expect(getProfileWalletConfig).toHaveBeenCalledWith('admin-token');
expect(screen.getByText('每日免费泥点')).toBeTruthy();
});
test('账号配置页一次保存初始和每日免费泥点', async () => {
const user = userEvent.setup();
const onResultChange = vi.fn();
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={onResultChange} />);
await screen.findByLabelText('每日免费泥点数');
fireEvent.change(screen.getByLabelText('账号初始泥点数'), {target: {value: '120'}});
fireEvent.change(screen.getByLabelText('每日免费泥点数'), {target: {value: '35'}});
await user.click(screen.getByRole('button', {name: '保存'}));
expect(screen.getByText('初始 120 泥点,每日免费 35 泥点')).toBeTruthy();
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', {initialMudPoints: 120, dailyFreePointsPerDay: 35}));
expect(onResultChange).toHaveBeenLastCalledWith(expect.objectContaining({initialMudPoints: 120, dailyFreePointsPerDay: 35}));
});
test('账号配置页拒绝非正整数每日免费额度', async () => {
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={vi.fn()} />);
const input = await screen.findByLabelText('每日免费泥点数');
fireEvent.change(input, {target: {value: '1.5'}});
expect((screen.getByRole('button', {name: '保存'}) as HTMLButtonElement).disabled).toBe(true);
expect(upsertProfileWalletConfig).not.toHaveBeenCalled();
});
@@ -23,6 +23,7 @@ export function AdminProfileWalletConfigPage({
onResultChange,
}: AdminProfileWalletConfigPageProps) {
const [initialMudPoints, setInitialMudPoints] = useState('100');
const [dailyFreePointsPerDay, setDailyFreePointsPerDay] = useState('20');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [loadErrorMessage, setLoadErrorMessage] = useState('');
@@ -41,6 +42,7 @@ export function AdminProfileWalletConfigPage({
const response = await getProfileWalletConfig(token);
onResultChange(response);
setInitialMudPoints(String(response.initialMudPoints));
setDailyFreePointsPerDay(String(response.dailyFreePointsPerDay));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setLoadErrorMessage);
} finally {
@@ -53,6 +55,13 @@ export function AdminProfileWalletConfigPage({
if (isSaving) {
return;
}
const normalizedDailyFreePointsPerDay = parsePositiveInteger(
dailyFreePointsPerDay,
);
if (!normalizedDailyFreePointsPerDay) {
setErrorMessage('每日免费泥点数必须是大于 0 的整数');
return;
}
const normalizedInitialMudPoints = parsePositiveInteger(initialMudPoints);
if (!normalizedInitialMudPoints) {
@@ -63,7 +72,7 @@ export function AdminProfileWalletConfigPage({
setErrorMessage('');
const confirmed = await confirmWrite({
action: '保存账号配置',
target: `${normalizedInitialMudPoints}泥点`,
target: `初始 ${normalizedInitialMudPoints} 泥点,每日免费 ${normalizedDailyFreePointsPerDay} 泥点`,
});
if (!confirmed) {
return;
@@ -73,9 +82,11 @@ export function AdminProfileWalletConfigPage({
try {
const response = await upsertProfileWalletConfig(token, {
initialMudPoints: normalizedInitialMudPoints,
dailyFreePointsPerDay: normalizedDailyFreePointsPerDay,
});
onResultChange(response);
setInitialMudPoints(String(response.initialMudPoints));
setDailyFreePointsPerDay(String(response.dailyFreePointsPerDay));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
@@ -120,6 +131,17 @@ export function AdminProfileWalletConfigPage({
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={dailyFreePointsPerDay}
onChange={(event) => setDailyFreePointsPerDay(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
@@ -128,7 +150,11 @@ export function AdminProfileWalletConfigPage({
<button
className="admin-primary-button"
disabled={isSaving || !parsePositiveInteger(initialMudPoints)}
disabled={
isSaving ||
!parsePositiveInteger(initialMudPoints) ||
!parsePositiveInteger(dailyFreePointsPerDay)
}
type="submit"
>
<Save size={17} aria-hidden="true" />
@@ -147,6 +173,10 @@ export function AdminProfileWalletConfigPage({
<dt></dt>
<dd>{result.initialMudPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.dailyFreePointsPerDay}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedByDisplayName || '-'}</dd>
@@ -169,6 +199,6 @@ export function AdminProfileWalletConfigPage({
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
}
@@ -0,0 +1,23 @@
{
"llm": {
"apiKey": "",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKind": "openai_responses",
"reasoningEffort": "high",
"stream": false,
"webSearchEnabled": false,
"contextWindowTokens": 128000,
"autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000,
"requestTimeoutMs": 180000,
"maxRetries": 0,
"retryBackoffMs": 500
},
"agentLlm": {},
"editorApi": {
"baseUrl": "http://127.0.0.1:8082",
"apiKey": ""
},
"mcpServers": {}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>AI 游戏创作</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "npm --prefix ../.. exec tauri -- dev",
"game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat",
"dev-server": "node scripts/start-dev-server.mjs",
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "npm --prefix ../.. exec tauri -- build",
"build:game-chat-release": "npm --prefix ../.. exec tauri -- build --config src-tauri/tauri.game-chat-release.conf.json --bundles nsis --features game-chat-release",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"chat": "node scripts/run-cli-with-config.mjs --swarm-chat",
"swarm": "node scripts/run-cli-with-config.mjs --swarm-chat",
"config": "node scripts/game-creator-config-wizard.mjs",
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs",
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
"agent-runtime:collaboration-policy-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-collaboration-policy-mixed-recovery",
"agent-runtime:mixed-swarm-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-static-isolated-autonomous-chat",
"agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat",
"agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e": "node scripts/agent-runtime-deterministic-playable-e2e.mjs",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-self-test": "node scripts/agent-runtime-deterministic-playable-e2e.mjs --self-test",
"agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry",
"agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-final-reply-transient-retry",
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
},
"dependencies": {
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@tauri-apps/cli": "^2.11.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
import './agent-runtime-real-e2e/entry.mjs';
@@ -0,0 +1,176 @@
import { createHash } from '../dependencies.mjs';
import { isPlainObject } from '../harness/config.mjs';
import {
interactiveCliOutput,
writeInteractiveCliLine,
} from '../harness/process.mjs';
import {
shutdownWaiters,
state,
userInputAnswerText,
} from '../runtime-state.mjs';
import { disposableProjectPathVariants } from './runtime.mjs';
export async function answerRemainingInteractiveQuestions(session) {
let answeredPromptCount = 1;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const output = interactiveCliOutput(session);
if (output.includes(`[\u5df2\u56de\u7b54] ${state.userInput.requestId}`))
return;
const promptCount = output.split('或直接输入其他答案:').length - 1;
while (answeredPromptCount < promptCount && answeredPromptCount < 3) {
writeInteractiveCliLine(session, userInputAnswerText);
answeredPromptCount += 1;
}
if (output.includes('[待确认]')) {
throw codedError('user-input-unexpected-tool-confirmation');
}
if (session.closed) throw codedError('user-input-cli-closed-before-answer');
await sleep(100);
}
throw codedError('user-input-answer-timeout');
}
export function parseSingleSwarmTurnReport(output, codePrefix) {
const reportLines = output
.split(/\r?\n/u)
.filter((line) => line.startsWith('[turn.report] '));
assert(reportLines.length === 1, `${codePrefix}-turn-report-count-invalid`);
const report = JSON.parse(reportLines[0].slice('[turn.report] '.length));
assert(
isPlainObject(report) &&
JSON.stringify(Object.keys(report).sort()) ===
JSON.stringify(
[
'schemaVersion',
'outcome',
'parentAgentId',
'sessionId',
'parentRunId',
'runtimeCount',
'busyRuntimeCount',
'pendingTaskCount',
'runningTaskCount',
'waitingForConfirmationCount',
'waitingForUserInputCount',
'newAssistantMessageCount',
'finalReplyChars',
'reconciliationAgentCount',
].sort(),
),
`${codePrefix}-turn-report-shape-invalid`,
);
return report;
}
export function isFailedTask(task) {
return (
['failed', 'cancelled', 'budget-exhausted'].includes(task.status) ||
['failed', 'cancelled', 'budget-exhausted'].includes(task.phase)
);
}
export function prerequisiteLabel(name) {
return {
llmConfigured: 'LLM',
chromeAvailable: 'Chrome/Chromium/Edge',
editorApiConfigured: 'editorApi',
}[name];
}
export function recordError(code, error) {
const detail =
error instanceof Error
? `${error.name}:${error.message}`
: String(error ?? code);
state.errors.push({
code,
detailHash: hashValue(redactSecrets(detail)),
safeFailureDiagnostic: error?.safeFailureDiagnostic ?? null,
});
}
export function summarizeRecordedError(error) {
const diagnostic = error.safeFailureDiagnostic;
return {
code: error.code,
detailHash: error.detailHash,
...(diagnostic
? {
failureKind: diagnostic.failureKind,
exitCode: diagnostic.exitCode,
signal: diagnostic.signal,
processErrorCode: diagnostic.processErrorCode,
stderrChars: diagnostic.stderrChars,
stderrSha256: diagnostic.stderrSha256,
}
: {}),
};
}
export function redactSecrets(value) {
let result = value;
for (const secret of state.secrets)
result = result.split(secret).join('[REDACTED]');
if (state.options?.configDir)
result = result.split(state.options.configDir).join('[CONFIG_DIR]');
for (const projectPath of disposableProjectPathVariants()) {
result = result.split(projectPath).join('[PROJECT]');
}
return result;
}
export function hashValue(value) {
if (!value) return null;
return createHash('sha256')
.update(Buffer.isBuffer(value) ? value : String(value))
.digest('hex');
}
export function canonicalJsonValue(value) {
if (Array.isArray(value)) return value.map(canonicalJsonValue);
if (!isPlainObject(value)) return value;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalJsonValue(value[key])]),
);
}
export function hashJsonValue(value) {
return hashValue(JSON.stringify(canonicalJsonValue(value)));
}
export function codedError(code, cause) {
const error = new Error(code, cause ? { cause } : undefined);
error.code = code;
return error;
}
export function assert(condition, code) {
if (!condition) throw codedError(code);
}
export function throwIfShutdownRequested() {
if (state.shutdownSignal && !state.cleanupInProgress) {
throw codedError(`interrupted-${state.shutdownSignal.toLowerCase()}`);
}
}
export function sleep(milliseconds) {
throwIfShutdownRequested();
return new Promise((resolve, reject) => {
const finish = () => {
shutdownWaiters.delete(interrupt);
resolve();
};
const timer = setTimeout(finish, milliseconds);
const interrupt = () => {
clearTimeout(timer);
shutdownWaiters.delete(interrupt);
reject(codedError(`interrupted-${state.shutdownSignal.toLowerCase()}`));
};
shutdownWaiters.add(interrupt);
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
export { startLlmTransientFaultProxy } from '../llm-transient-fault-proxy.mjs';
export { withLoopbackNoProxy } from '../llm-transient-fault-proxy.mjs';
export { buildProcessSessionFixtureSource } from '../process-session-real-e2e-fixture.mjs';
export { spawn } from 'node:child_process';
export { createHash } from 'node:crypto';
export { randomUUID } from 'node:crypto';
export { constants as fsConstants } from 'node:fs';
export { createReadStream } from 'node:fs';
export { readFileSync } from 'node:fs';
export { watch as watchFileSystem } from 'node:fs';
export { default as fs } from 'node:fs/promises';
export { default as os } from 'node:os';
export { default as path } from 'node:path';
export { fileURLToPath } from 'node:url';
export { TextDecoder } from 'node:util';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,201 @@
import { assert, codedError } from '../assertions/core.mjs';
import { fs, path } from '../dependencies.mjs';
import {
BlockedError,
configFileName,
contextCompactionSuite,
goalRuntimeSuite,
localConfigFileName,
mcpRuntimeSuite,
parallelReadSuite,
processSessionSuites,
projectSkillSuite,
repoRoot,
responseStreamSuite,
scopedAgentsSuite,
steerRunnerKillSuite,
supervisorAutonomousPlayableLaneDefenseSuite,
supervisorSwarmAutonomousChatSuite,
supervisorSwarmCollaborationPolicyMixedRecoverySuite,
supervisorSwarmFinalReplyTransientRetrySuite,
supervisorSwarmStaticIsolatedAutonomousChatSuite,
supervisorSwarmSuite,
supervisorSwarmToolPlanHandoffRunnerKillSuite,
supervisorSwarmTransientRetrySuite,
userInputRuntimeSuite,
webSearchSuite,
} from '../runtime-state.mjs';
import { collectApiKeys, isPathInside } from './io.mjs';
import { decodeUtf8Fatal } from './reporting.mjs';
export function parseArguments(args) {
let configDir;
let suite;
let keepProject = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--config-dir') {
assert(configDir === undefined, 'duplicate-config-dir');
configDir = args[++index];
assert(Boolean(configDir), 'missing-config-dir-value');
} else if (arg === '--suite') {
assert(suite === undefined, 'duplicate-suite');
suite = args[++index];
assert(Boolean(suite), 'missing-suite-value');
} else if (arg === '--keep-project') {
keepProject = true;
} else {
throw codedError('unknown-argument');
}
}
assert(
typeof configDir === 'string' && path.isAbsolute(configDir),
'config-dir-not-absolute',
);
assert(
suite === 'full' ||
suite === 'llm-runtime' ||
suite === goalRuntimeSuite ||
suite === responseStreamSuite ||
suite === webSearchSuite ||
suite === contextCompactionSuite ||
suite === mcpRuntimeSuite ||
suite === userInputRuntimeSuite ||
suite === scopedAgentsSuite ||
suite === projectSkillSuite ||
suite === parallelReadSuite ||
suite === supervisorSwarmSuite ||
suite === supervisorSwarmTransientRetrySuite ||
suite === supervisorSwarmFinalReplyTransientRetrySuite ||
suite === supervisorSwarmToolPlanHandoffRunnerKillSuite ||
suite === supervisorSwarmAutonomousChatSuite ||
suite === supervisorAutonomousPlayableLaneDefenseSuite ||
suite === supervisorSwarmStaticIsolatedAutonomousChatSuite ||
suite === supervisorSwarmCollaborationPolicyMixedRecoverySuite ||
suite === steerRunnerKillSuite ||
processSessionSuites.has(suite),
'unsupported-suite',
);
return { configDir: path.resolve(configDir), suite, keepProject };
}
export async function loadConfig(configDir) {
const [realRepoRoot, realConfigDir] = await Promise.all([
fs.realpath(repoRoot),
fs.realpath(configDir).catch(() => null),
]);
if (!realConfigDir) {
throw new BlockedError(['config']);
}
assert(
realConfigDir !== realRepoRoot &&
!isPathInside(realRepoRoot, realConfigDir),
'config-dir-inside-repository',
);
const effectiveConfig = {};
const secrets = new Set();
for (const name of [configFileName, localConfigFileName]) {
const configPath = path.join(realConfigDir, name);
const metadata = await fs.lstat(configPath).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
if (!metadata) {
if (name === configFileName) throw new BlockedError(['config']);
continue;
}
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw codedError('config-file-not-regular');
}
let fileConfig;
try {
fileConfig = JSON.parse(
decodeUtf8Fatal(await fs.readFile(configPath), 'config-invalid-utf8'),
);
} catch (error) {
throw codedError('config-json-invalid', error);
}
assert(isPlainObject(fileConfig), 'config-root-invalid');
for (const secret of collectApiKeys(fileConfig)) secrets.add(secret);
mergeConfigPatch(effectiveConfig, fileConfig);
}
return {
config: effectiveConfig,
secrets: [...secrets],
realConfigDir,
};
}
export function mergeConfigPatch(target, patch) {
for (const [key, value] of Object.entries(patch)) {
if (['__proto__', 'constructor', 'prototype'].includes(key)) continue;
if (value == null) continue;
if (isPlainObject(value)) {
const current = isPlainObject(target[key]) ? target[key] : {};
target[key] = current;
mergeConfigPatch(current, value);
} else {
target[key] = value;
}
}
return target;
}
export function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function effectiveAgentLlmConfig(config, agentId) {
const globalConfig = isPlainObject(config.llm) ? config.llm : {};
const agentConfig = isPlainObject(config.agentLlm?.[agentId])
? config.agentLlm[agentId]
: {};
const value = (key, fallback) =>
agentConfig[key] ?? globalConfig[key] ?? fallback;
return {
apiKey: value('apiKey', ''),
baseUrl: value('baseUrl', 'https://api.openai.com/v1'),
model: value('model', 'gpt-4.1'),
apiKind: value('apiKind', 'openai_responses'),
reasoningEffort: value('reasoningEffort', 'high'),
stream: value('stream', false),
webSearchEnabled: value('webSearchEnabled', false),
requestTimeoutMs: value('requestTimeoutMs', 180_000),
maxRetries: value('maxRetries', 0),
retryBackoffMs: value('retryBackoffMs', 500),
};
}
export function safeEffectiveAgentLlmPolicy(effective) {
return {
model: effective.model,
apiKind: effective.apiKind,
reasoningEffort: effective.reasoningEffort,
requestTimeoutMs: effective.requestTimeoutMs,
maxRetries: effective.maxRetries,
retryBackoffMs: effective.retryBackoffMs,
stream: effective.stream,
webSearchEnabled: effective.webSearchEnabled,
};
}
export function sameEffectiveAgentLlmWithoutStream(left, right) {
return [
'apiKey',
'baseUrl',
'model',
'apiKind',
'reasoningEffort',
'requestTimeoutMs',
'maxRetries',
'retryBackoffMs',
].every((key) => left[key] === right[key]);
}
export function sameEffectiveAgentLlm(left, right) {
return (
sameEffectiveAgentLlmWithoutStream(left, right) &&
left.stream === right.stream &&
left.webSearchEnabled === right.webSearchEnabled
);
}
@@ -0,0 +1,154 @@
import { assert } from '../assertions/core.mjs';
import { createHash, fs, path } from '../dependencies.mjs';
import { mainAgentId, state } from '../runtime-state.mjs';
import { decodeUtf8Fatal, splitJsonlBufferLines } from './reporting.mjs';
export function collectApiKeys(value, keys = []) {
if (!value || typeof value !== 'object') return keys;
if (Array.isArray(value)) {
for (const item of value) collectApiKeys(item, keys);
return [...new Set(keys)];
}
for (const [key, child] of Object.entries(value)) {
if (
/^api_?key$/i.test(key) &&
typeof child === 'string' &&
child.length > 0
) {
keys.push(child);
} else {
collectApiKeys(child, keys);
}
}
return [...new Set(keys)];
}
export function parseAssignedJson(output, names) {
const matches = [];
for (const line of output.split(/\r?\n/u)) {
for (const name of names) {
if (line.startsWith(`${name}=`)) {
matches.push({ name, payload: line.slice(name.length + 1) });
}
}
}
assert(matches.length === 1, 'cli-assigned-json-output-invalid');
return JSON.parse(matches[0].payload);
}
export async function listFiles(root) {
const files = [];
let metadata;
try {
metadata = await fs.lstat(root);
} catch (error) {
if (error?.code === 'ENOENT') return files;
throw error;
}
if (metadata.isSymbolicLink()) return files;
if (metadata.isFile()) return [root];
const entries = await fs.readdir(root, { withFileTypes: true });
for (const entry of entries) {
const file = path.join(root, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) files.push(...(await listFiles(file)));
else if (entry.isFile()) files.push(file);
}
return files;
}
export async function readJson(file) {
return JSON.parse(await fs.readFile(file, 'utf8'));
}
export function validateCommandOutputSidecar(sidecar, audit, file) {
const relative = relativeProjectPath(file);
assert(
sidecar?.schemaVersion === 'game-creator-command-output.v1' &&
sidecar.outputRef === relative &&
sidecar.identity?.agentId === mainAgentId &&
sidecar.identity?.taskId === audit.taskId &&
sidecar.identity?.sessionId === audit.sessionId &&
sidecar.identity?.runId === state.initialRunId &&
sidecar.identity?.actionId === audit.actionId &&
sidecar.identity?.actionFingerprint === audit.actionFingerprint &&
sidecar.outputSha256 === audit.outputSha256 &&
sidecar.totalLines === audit.totalLines &&
sidecar.captureTruncated === audit.captureTruncated &&
sidecar.exitCode === audit.exitCode &&
sidecar.timedOut === audit.timedOut &&
sidecar.sourceChanged === audit.sourceChanged &&
typeof sidecar.output === 'string' &&
createHash('sha256').update(sidecar.output).digest('hex') ===
sidecar.outputSha256 &&
(sidecar.output.length === 0
? sidecar.totalLines === 0
: sidecar.output.split('\n').length === sidecar.totalLines),
'command-output-sidecar-identity-invalid',
);
}
export async function readJsonl(file) {
const content = await fs.readFile(file);
const records = [];
for (const lineRecord of splitJsonlBufferLines(content)) {
const line = decodeUtf8Fatal(lineRecord.bytes, 'jsonl-invalid-utf8');
if (line.trim().length > 0) records.push(JSON.parse(line));
}
return records;
}
export async function readOptionalJsonl(file) {
try {
return await readJsonl(file);
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
}
export function resolveProjectRelative(value) {
const candidate = path.isAbsolute(value)
? path.resolve(value)
: path.resolve(state.projectRoot, value);
assert(
isPathInside(state.projectRoot, candidate),
'evidence-path-outside-project',
);
return candidate;
}
export function relativeProjectPath(value) {
const relative = path.relative(state.projectRoot, path.resolve(value));
assert(
relative && !relative.startsWith('..') && !path.isAbsolute(relative),
'relative-evidence-path-invalid',
);
return relative.split(path.sep).join('/');
}
export function isPathInside(parent, child) {
const relative = path.relative(path.resolve(parent), path.resolve(child));
return (
relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
);
}
export function isTerminalRuntime(runtime) {
return ['completed', 'failed', 'cancelled', 'budget-exhausted'].includes(
runtime.phase,
);
}
export function isLiveTask(task) {
return (
['pending', 'running', 'waiting-for-confirmation'].includes(task.status) ||
[
'queued',
'running',
'executing',
'finalizing',
'waiting-for-confirmation',
].includes(task.phase)
);
}
@@ -0,0 +1,337 @@
import {
assert,
codedError,
hashValue,
sleep,
throwIfShutdownRequested,
} from '../assertions/core.mjs';
import { fs, path, spawn, withLoopbackNoProxy } from '../dependencies.mjs';
import {
activeCommandChildren,
appRoot,
commandOutputLimit,
manifestPath,
state,
} from '../runtime-state.mjs';
import {
isSupervisorSwarmTransientRetrySuite,
recordSupervisorSwarmChatSessionFailureDiagnostic,
} from '../suites/supervisor-swarm.mjs';
import { isIsolatedRunnerSuite } from './reporting.mjs';
export async function prepareCliBinary() {
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
await runProcess(
cargo,
['build', '--quiet', '--manifest-path', manifestPath],
{
cwd: appRoot,
timeoutMs: 15 * 60 * 1000,
},
);
const metadata = await runProcess(
cargo,
[
'metadata',
'--format-version',
'1',
'--no-deps',
'--manifest-path',
manifestPath,
],
{ cwd: appRoot, timeoutMs: 120_000 },
);
const parsed = JSON.parse(metadata.stdout);
const executable = path.join(
parsed.target_directory,
'debug',
`genarrative-ai-game-creator-shell${process.platform === 'win32' ? '.exe' : ''}`,
);
const binary = await fs.stat(executable).catch(() => null);
assert(binary?.isFile(), 'cli-binary-missing');
return executable;
}
export async function runCli(args, options = {}) {
assert(Boolean(state.cliBinary), 'cli-binary-not-ready');
assert(Boolean(state.runtimeConfigDir), 'runtime-config-dir-not-ready');
if (
isIsolatedRunnerSuite() &&
state.options?.configDir &&
path.resolve(state.runtimeConfigDir) ===
path.resolve(state.options.configDir)
) {
state.isolatedRunner.sourceConfigCliCallCount += 1;
}
return runProcess(
state.cliBinary,
[...args, '--config-dir', state.runtimeConfigDir],
{
cwd: appRoot,
timeoutMs: options.timeoutMs ?? 60_000,
stdin: options.stdin,
allowNonZero: options.allowNonZero ?? false,
env: buildCliChildEnvironment(),
},
);
}
export function buildCliChildEnvironment() {
const environment = {
...process.env,
NO_COLOR: '1',
RUST_BACKTRACE: '0',
};
return isSupervisorSwarmTransientRetrySuite()
? withLoopbackNoProxy(environment)
: environment;
}
export function safeProcessFailureDiagnostic({
code = null,
signal = null,
error = null,
stdout = '',
stderr = '',
} = {}) {
const stdoutText = Buffer.isBuffer(stdout)
? stdout.toString('utf8')
: String(stdout);
const stderrText = Buffer.isBuffer(stderr)
? stderr.toString('utf8')
: String(stderr);
const combined = `${error?.message ?? ''}\n${stdoutText}\n${stderrText}`;
const processErrorCode = /^[A-Z0-9_]+$/u.test(error?.code ?? '')
? error.code
: 'none';
const closeSignal = /^[A-Z0-9]+$/u.test(signal ?? '') ? signal : 'none';
let failureKind = 'closed';
if (
processErrorCode === 'ENOSPC' ||
/(?:\bENOSPC\b|No space left on device|os error 28)/iu.test(combined)
) {
failureKind = 'enospc';
} else if (processErrorCode !== 'none') {
failureKind = 'process-error';
} else if (closeSignal !== 'none') {
failureKind = 'signal';
} else if (Number.isInteger(code) && code !== 0) {
failureKind = 'nonzero-exit';
}
return {
failureKind,
exitCode: Number.isInteger(code) ? String(code) : 'none',
signal: closeSignal,
processErrorCode,
stderrChars: [...stderrText].length,
stderrSha256: hashValue(stderrText) ?? 'none',
};
}
export function codedProcessError(code, details) {
const error = codedError(code, details?.error);
error.safeFailureDiagnostic = safeProcessFailureDiagnostic(details);
return error;
}
export function startInteractiveCli(args) {
assert(Boolean(state.cliBinary), 'interactive-cli-binary-not-ready');
assert(Boolean(state.runtimeConfigDir), 'interactive-config-dir-not-ready');
if (
isIsolatedRunnerSuite() &&
state.options?.configDir &&
path.resolve(state.runtimeConfigDir) ===
path.resolve(state.options.configDir)
) {
state.isolatedRunner.sourceConfigCliCallCount += 1;
}
const child = spawn(
state.cliBinary,
[...args, '--config-dir', state.runtimeConfigDir],
{
cwd: appRoot,
env: buildCliChildEnvironment(),
stdio: ['pipe', 'pipe', 'pipe'],
},
);
activeCommandChildren.add(child);
const session = {
child,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
closed: false,
closeInfo: null,
closePromise: null,
};
session.closePromise = new Promise((resolve) => {
child.on('error', (error) => {
activeCommandChildren.delete(child);
session.closed = true;
session.closeInfo = { code: null, signal: null, error };
resolve(session.closeInfo);
});
child.on('close', (code, signal) => {
activeCommandChildren.delete(child);
session.closed = true;
session.closeInfo = { code, signal, error: null };
resolve(session.closeInfo);
});
});
child.stdout.on('data', (chunk) => {
state.transcriptScanner?.scan('interactive-stdout', chunk);
state.formalConfigPathTranscriptScanner?.scan('interactive-stdout', chunk);
session.stdout = appendBounded(session.stdout, chunk, commandOutputLimit);
});
child.stderr.on('data', (chunk) => {
state.transcriptScanner?.scan('interactive-stderr', chunk);
state.formalConfigPathTranscriptScanner?.scan('interactive-stderr', chunk);
session.stderr = appendBounded(session.stderr, chunk, commandOutputLimit);
});
return session;
}
export function interactiveCliOutput(session) {
return `${session.stdout.toString('utf8')}\n${session.stderr.toString('utf8')}`;
}
export function writeInteractiveCliLine(session, line) {
assert(!session.closed, 'interactive-cli-already-closed');
assert(session.child.stdin.writable, 'interactive-cli-stdin-not-writable');
session.child.stdin.write(`${line}\n`);
}
export async function waitForInteractiveCliOutput(
session,
predicate,
code,
timeoutMs,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = interactiveCliOutput(session);
if (predicate(output)) return output;
if (session.closed) {
if (session === state.supervisorSwarmCliSession) {
recordSupervisorSwarmChatSessionFailureDiagnostic(session);
}
throw codedError(`${code}-cli-closed`);
}
await sleep(50);
}
throw codedError(code);
}
export async function waitForInteractiveCliExit(session, timeoutMs) {
const result = await Promise.race([
session.closePromise,
sleep(timeoutMs).then(() => null),
]);
if (!result) throw codedError('interactive-cli-exit-timeout');
if (result.error) throw codedError('interactive-cli-process-error');
assert(
result.code === 0 && result.signal === null,
'interactive-cli-exit-invalid',
);
return result;
}
export async function closeInteractiveCli(session) {
if (!session || session.closed) return;
if (session.child.stdin.writable) {
session.child.stdin.write('/quit\n');
}
let result = await Promise.race([
session.closePromise,
sleep(3_000).then(() => null),
]);
if (!result && !session.closed) {
session.child.kill('SIGTERM');
result = await Promise.race([
session.closePromise,
sleep(2_000).then(() => null),
]);
}
if (!result && !session.closed) {
session.child.kill('SIGKILL');
result = await session.closePromise;
}
assert(Boolean(result), 'interactive-cli-cleanup-timeout');
}
export async function runProcess(
program,
args,
{ cwd, timeoutMs, stdin, allowNonZero = false, env = null },
) {
throwIfShutdownRequested();
return new Promise((resolve, reject) => {
const child = spawn(program, args, {
cwd,
env: env ?? { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' },
stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
});
activeCommandChildren.add(child);
let stdout = Buffer.alloc(0);
let stderr = Buffer.alloc(0);
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
}, timeoutMs);
child.stdout.on('data', (chunk) => {
state.transcriptScanner?.scan('stdout', chunk);
state.projectPathTranscriptScanner?.scan('stdout', chunk);
state.formalConfigPathTranscriptScanner?.scan('stdout', chunk);
stdout = appendBounded(stdout, chunk, commandOutputLimit);
});
child.stderr.on('data', (chunk) => {
state.transcriptScanner?.scan('stderr', chunk);
state.projectPathTranscriptScanner?.scan('stderr', chunk);
state.formalConfigPathTranscriptScanner?.scan('stderr', chunk);
stderr = appendBounded(stderr, chunk, commandOutputLimit);
});
child.on('error', (error) => {
clearTimeout(timer);
activeCommandChildren.delete(child);
reject(
codedProcessError('process-spawn-failed', {
error,
stdout,
stderr,
}),
);
});
child.on('close', (code, signal) => {
clearTimeout(timer);
activeCommandChildren.delete(child);
const result = {
stdout: stdout.toString('utf8'),
stderr: stderr.toString('utf8'),
code,
signal,
};
if (timedOut) {
reject(codedProcessError('process-timeout', result));
} else if (state.shutdownSignal && !state.cleanupInProgress) {
reject(
codedProcessError(
`interrupted-${state.shutdownSignal.toLowerCase()}`,
result,
),
);
} else if (code !== 0 && !allowNonZero) {
reject(codedProcessError('cli-command-failed', result));
} else {
resolve(result);
}
});
if (stdin !== undefined) child.stdin.end(stdin);
});
}
export function appendBounded(current, chunk, limit) {
const combined = Buffer.concat([current, chunk]);
return combined.length <= limit
? combined
: combined.subarray(combined.length - limit);
}

Some files were not shown because too many files have changed in this diff Show More