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:
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 执行。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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>
|
||||
+4771
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user