Compare commits

..

23 Commits

Author SHA1 Message Date
suzmii e695cdf499 修复序列帧去背景面板导入顺序
Project CI / Repository checks (pull_request) Successful in 1m7s
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 4m14s
Project CI / Native shell tests (pull_request) Successful in 14m27s
按 simple-import-sort 规则调整类型导入顺序
2026-08-17 21:12:13 +08:00
suzmii 4ca93a6f16 合并master并保留双方决策记录
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 3m58s
Project CI / Native shell tests (pull_request) Successful in 14m2s
Project CI / Repository checks (pull_request) Failing after 34s
保留Spine序列帧去背景双模式与悬浮窗决策

保留预览发布记录使用Jenkins构建编号与有限保留决策

同步master预览部署控制面代码与文档变更
2026-08-17 20:48:11 +08:00
suzmii 2f1da79f9c 同步远端分支并保留序列帧去背景修复
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 2m39s
Project CI / Native shell tests (pull_request) Successful in 13m41s
2026-08-17 20:22:20 +08:00
suzmii c59a912eef 完善序列帧去背景交互与队列处理
将序列帧去背景选择框改为贴近画布的非模态悬浮窗

增加原始帧取色、实时色样与自适应方向的像素放大预览

区分提交接受状态与 durable 任务处理状态,入队成功后立即关闭悬浮窗

支持纯色键控轻微色差并补充通用模式与后端回归验证

同步更新Spine技术方案与项目决策记录
2026-08-17 20:16:16 +08:00
suzmii 3d145379f2 明确序列帧去背景处理模式
去背景弹窗要求用户显式选择纯色键控或通用智能分割。

纯色模式支持颜色输入和吸管取色,并以本地算法处理任意键色。

通用模式逐帧使用 BgFilter complex 分割,禁止携带键色。

入队与 durable worker 统一校验模式参数并持久化权威处理元数据。

补充双色键控、模式冲突、双路径路由和前端交互回归。

同步更新 Spine 技术方案与团队决策记录。
2026-08-17 15:27:13 +08:00
suzmii ae5c2d8821 收紧角色外观参考图容量门禁
限制视频模式角色外观图最多九张,并在前端追加、恢复和提交链路统一收口。

在入队归一化和 durable worker 执行前限制单图、累计字节、解码边长与总像素。

补充九/十张、三十兆字节、累计大小和压缩炸弹像素边界回归。

同步更新 Spine 技术方案与团队决策记录。
2026-08-17 15:01:17 +08:00
suzmii 8b4ec2c4d4 Merge branch 'master' into feat/spine-sequence-export-v2
Project CI / Repository checks (pull_request) Successful in 1m7s
Project CI / Frontend tests (pull_request) Successful in 3m4s
Project CI / Backend tests (pull_request) Successful in 4m24s
Project CI / Native shell tests (pull_request) Successful in 13m56s
2026-08-17 11:37:43 +08:00
suzmii 95e34b8760 Merge branch 'master' into feat/spine-sequence-export-v2
Project CI / Frontend tests (pull_request) Successful in 3m33s
Project CI / Repository checks (pull_request) Successful in 1m17s
Project CI / Backend tests (pull_request) Successful in 4m11s
Project CI / Native shell tests (pull_request) Successful in 17m58s
2026-08-15 18:09:16 +08:00
suzmii c51d88fd19 合并 master 的预览部署控制面
Project CI / Repository checks (pull_request) Successful in 55s
Project CI / Frontend tests (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Successful in 3m35s
Project CI / Native shell tests (pull_request) Successful in 17m44s
保留 Spine 序列帧与 Jenkins 容器预览决策记录

合入 preview-deployer-web、preview-deployer-server 及部署脚本
2026-08-15 17:48:16 +08:00
suzmii 4d1c9d9b1b 收紧序列帧预览恢复校验
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Successful in 2m56s
Project CI / Native shell tests (pull_request) Failing after 12m17s
taskId 缺失时不恢复关联预览视频

补充序列帧与预览任务标识缺失的恢复测试
2026-08-15 17:02:52 +08:00
suzmii 90cfdfdf97 修正序列帧计费边界测试数量
将character_animation_assets的持久计费边界预期从3调整为4

覆盖AI生成、视频转换、去背景与视频生成四条内联路由
2026-08-15 15:37:34 +08:00
suzmii f65a813768 限制序列帧去背景仅用于视频转换
后端仅接受 character-animation.convert 结果执行序列帧去背景

前端工具栏与工作流同步收紧视频转换门禁

更新相关测试与Spine序列帧技术方案
2026-08-15 15:35:27 +08:00
suzmii b5dc99e067 Revert "local: stash the document"
Project CI / Frontend tests (pull_request) Successful in 3m12s
Project CI / Native shell tests (pull_request) Successful in 14m9s
Project CI / Backend tests (pull_request) Failing after 2m29s
Project CI / Repository checks (pull_request) Successful in 1m0s
This reverts commit 0123f3db76.
2026-08-15 15:08:12 +08:00
suzmii c94bb6d3ed Merge branch 'master' into feat/spine-sequence-export-v2
Project CI / Repository checks (pull_request) Successful in 1m19s
Project CI / Backend tests (pull_request) Failing after 3m12s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Native shell tests (pull_request) Successful in 14m53s
2026-08-15 15:00:53 +08:00
suzmii 101fd952d5 收紧序列帧去背景来源与绿幕门禁
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Successful in 2m38s
Project CI / Native shell tests (pull_request) Failing after 11m22s
验证去背景每帧正式资产对象的用户归属、对象路径和来源任务
仅允许编辑器生成的纯绿幕角色动画执行算法去背景
补齐去背景生成 action 白名单但保持不可改造
更新工具栏门禁、回归测试和Spine序列帧技术方案
2026-08-15 14:50:18 +08:00
suzmii 2c85ea0b83 修复序列帧后处理结果应用与存储幂等
视频直接转换和序列帧去背景按 operation 隔离对象存储路径
队列后处理失败统一返回脱敏用户文案
inline 转换和去背景结果直接应用服务端项目快照
修复去背景加载状态和相关前端回归测试
2026-08-15 14:27:55 +08:00
suzmii 13afdd7a3b 修复序列帧去背景队列与幂等发布
序列帧去背景改用本地绿幕算法并在 durable 父任务内并行处理。

将去背景操作加入生成结果白名单,并按 operation_id 隔离帧对象路径。

去背景结果发布为新的序列资源、素材和画布图层,同时保留原始序列。

同步前端生成占位、相关测试及队列与资源契约文档。
2026-08-15 11:59:50 +08:00
suzmii 05f0e2db06 合并master并保留Spine序列帧修复
合并master当前工程、后端、前端和文档更新

按已确认方案解决Spine序列帧多模态、帧数和快速编辑冲突

保留当前分支底部工具栏宽度与隐藏滚动条样式
2026-08-14 21:33:26 +08:00
suzmii e368145f9d Merge branch 'master' of https://git.genarrative.world/git/GenarrativeAI/Genarrative 2026-08-13 14:13:47 +08:00
suzmii 0123f3db76 local: stash the document 2026-08-13 10:13:31 +08:00
suzmii d6cb68cf13 Spine序列帧修复与功能收紧 2026-08-13 01:05:57 +08:00
suzmii 132bf7db35 角色多方向图生成 2026-08-12 18:14:25 +08:00
suzmii 1575afc92c Spine序列帧动画多模态生成 2026-08-10 20:14:09 +08:00
892 changed files with 93645 additions and 165021 deletions
@@ -27,7 +27,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
## Essential Invariants
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
- All nine generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
- All eight generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
@@ -106,21 +106,6 @@ client.generate_image(
)
```
For background removal, pass a stable owner-scoped object key, project resource ID, or asset ID; the helper keeps the same asynchronous submission and polling contract:
```python
session = client.prepare_canvas_session("去背景画布")
client.remove_background(
"editor-upload/object.png",
source_width=720,
source_height=1280,
canvasSession=session,
assetLabel="去背景结果",
)
```
Background removal preserves the source pixel size. For normal canvas placement with `canvasSession`, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
For character animation, pass the canvas session and asset label to `animate_character`. The helper submits asynchronously and returns the completed compact result containing the authoritative formal `resource` and `asset`; do not synthesize a library asset from the first frame.
@@ -51,15 +51,14 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| --- | --- | --- | --- |
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
Poll all nine through:
Poll all eight through:
```text
GET /api/external/v1/generations/{operationId}
@@ -73,7 +72,6 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM
- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported.
- UI extraction uses `assetFolderId` and `spritesheetLabel`.
- Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset.
- Background removal derives the final static-image `assetKind` from the authoritative source record. A conflicting request kind or any video, audio, animation, or image-sequence kind returns `400` before queueing. Without `canvasCompletion`, `targetLayerId` must point to the same authoritative object as `sourceImageSrc` (prefer `assetObjectId`, otherwise canonical bucket/object key).
- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`.
- Reload project/library state after completion when full current state is required.
@@ -94,8 +92,6 @@ For image edit/redraw, confirming an upload is not sufficient: create a project
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets.
## Common Values
Use OpenAPI as the final authority; these common values are a routing aid:
@@ -47,7 +47,6 @@ Infer what is already clear and ask only for missing fields that block the selec
| --- | --- |
| Generate a background, character, spec, UI mockup, or publication image | Image generation |
| Redraw, retouch, or replace an existing image | Image edit |
| Remove the background from an existing image | Background removal |
| Generate from a local reference | Upload and confirm the local file, then image generation or edit |
| Build a reusable transparent icon/game atlas from a visual spec | Icon spritesheet generation |
| Extract marked assets from an existing UI design | UI design asset extraction |
@@ -79,9 +78,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop.
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`.
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
@@ -15,7 +15,7 @@ Use this reference to build generation payloads, carry canvas/library context, p
## Asynchronous Submission
All nine generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
All eight generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
```json
{
@@ -71,8 +71,6 @@ status = client.get_generation(operation_id)
completed = client.wait_for_generation(operation_id)
```
Background removal uses the same submission and polling state machine. `sourceImageSrc` must be a stable owner-scoped object key, project resource ID, or asset ID; never pass a Data URL, Blob URL, or expiring signed URL. An explicit resource ID or asset ID is resolved before any object-key fallback. If a raw object key has multiple registrations with conflicting authoritative metadata, pass `sourceResourceId` to disambiguate or the server returns `400`. Use `projectId + canvasCompletion` for normal canvas placement. When `canvasCompletion` is absent, `projectId + targetLayerId` replaces an existing resource-backed layer and is rejected before queueing if the target is invalid; for a raw object key, the target resource becomes the durable source binding rechecked by the Worker. If both placement fields are absent, the server does not add the result to the canvas. The completed compact result contains the stable output object key, dimensions, and persisted resource/asset references when requested.
## Canvas and Asset-Library Completion
For endpoints that support these fields, include:
@@ -100,8 +98,6 @@ A minimal `canvasCompletion` is:
`dialogId` is optional. The placeholder supplies canvas placement and completion coordinates; it is not a final media pixel-size constraint. For successful pixel-art snapping, the result layer uses the final logical-grid PNG dimensions even when they differ from the placeholder. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed.
Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
@@ -561,58 +561,6 @@ class GenarrativeExternalClient:
idempotency_key=idempotency_key,
)
def remove_background(
self,
source_image_src: str,
source_width: int | None = None,
source_height: int | None = None,
**fields: Any,
) -> Any:
source_image_src = normalize_optional_text(source_image_src)
if not source_image_src:
raise GenarrativeApiError(
"source_image_src must be an owner-scoped object key, resource ID, or asset ID"
)
if (source_width is None) != (source_height is None):
raise GenarrativeApiError("source_width and source_height must be provided together")
if source_width is not None and (
source_width <= 0 or source_height is None or source_height <= 0
):
raise GenarrativeApiError("source_width and source_height must be positive integers")
session = fields.get("canvasSession")
if session is None:
session = fields.get("canvas_session")
target_layer_id = normalize_optional_text(fields.get("targetLayerId"))
if target_layer_id and fields.get("canvasCompletion") is not None:
raise GenarrativeApiError(
"targetLayerId and canvasCompletion are mutually exclusive for background removal"
)
canvas_width = fields.get("canvasWidth")
canvas_height = fields.get("canvasHeight")
if (canvas_width is None) != (canvas_height is None):
raise GenarrativeApiError("canvasWidth and canvasHeight must be provided together")
if session is not None and canvas_width is None and not target_layer_id:
if source_width is None or source_height is None:
raise GenarrativeApiError(
"remove_background requires source_width and source_height when canvasSession is used without canvasWidth/canvasHeight"
)
fields["canvasWidth"] = source_width
fields["canvasHeight"] = source_height
self._apply_canvas_session_fields(
fields,
fields.get("assetLabel", "去背景结果"),
source_width or 1,
source_height or 1,
)
if target_layer_id:
fields.pop("canvasCompletion", None)
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/images/background-removals",
{"sourceImageSrc": source_image_src, **fields},
idempotency_key=idempotency_key,
)
def generate_icon_spritesheet(
self,
reference_id: str,
@@ -739,14 +687,11 @@ def _self_test() -> None:
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x02\x00\x00\x00\x03\x08\x06\x00\x00\x00"
)
with tempfile.NamedTemporaryFile(suffix="Hero Image.png", delete=False) as fh:
with tempfile.NamedTemporaryFile(suffix="Hero Image.png") as fh:
fh.write(png)
temp_path = fh.name
try:
assert image_dimensions(temp_path) == (2, 3)
assert source_layer_id_from_path(temp_path).startswith("external-reference-")
finally:
Path(temp_path).unlink(missing_ok=True)
fh.flush()
assert image_dimensions(fh.name) == (2, 3)
assert source_layer_id_from_path(fh.name).startswith("external-reference-")
assert unwrap_envelope({"ok": True, "data": {"upload": 1}}) == {"upload": 1}
client = GenarrativeExternalClient(api_key="test")
session = {"projectId": "proj-demo", "assetFolderId": "editor-asset-folder-demo"}
@@ -828,54 +773,6 @@ def _self_test() -> None:
assert len(result["asset"]["imageSequenceFrames"]) == 2
assert result["asset"]["imageSequenceDurationMs"] == 4000
calls.clear()
background_result = client.remove_background(
"uploads/source.png",
720,
1280,
canvasSession=session,
assetLabel="去背景结果",
)
assert background_result["taskId"] == "task-demo"
assert calls[0]["path"] == "/api/external/v1/editor/images/background-removals"
assert calls[0]["body"]["sourceImageSrc"] == "uploads/source.png"
assert calls[0]["body"]["projectId"] == "proj-demo"
assert calls[0]["body"]["assetFolderId"] == "editor-asset-folder-demo"
assert calls[0]["body"]["assetLabel"] == "去背景结果"
assert calls[0]["body"]["canvasCompletion"]["title"] == "去背景结果"
assert calls[0]["body"]["canvasCompletion"]["placeholder"]["width"] == 720
assert calls[0]["body"]["canvasCompletion"]["placeholder"]["height"] == 1280
calls.clear()
client.remove_background(
"uploads/source.png",
canvasSession=session,
targetLayerId="layer-1",
assetLabel="原位去背景结果",
)
assert calls[0]["body"]["projectId"] == "proj-demo"
assert calls[0]["body"]["assetFolderId"] == "editor-asset-folder-demo"
assert calls[0]["body"]["assetLabel"] == "原位去背景结果"
assert calls[0]["body"]["targetLayerId"] == "layer-1"
assert "canvasCompletion" not in calls[0]["body"]
calls.clear()
try:
client.remove_background(
"uploads/source.png",
canvasSession=session,
targetLayerId="layer-1",
canvasCompletion={"title": "冲突完成指令"},
)
except GenarrativeApiError as error:
assert "targetLayerId and canvasCompletion are mutually exclusive" in str(error)
else:
raise AssertionError("background removal must reject conflicting canvas placement modes")
assert calls == []
try:
client.remove_background("uploads/source.png", canvasSession=session)
except GenarrativeApiError as error:
assert "source_width and source_height" in str(error)
else:
raise AssertionError("canvas background removal must not guess source dimensions")
assert calls == []
client.generate_icon_spritesheet(
"editor-resource-spec",
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
-1
View File
@@ -163,7 +163,6 @@ module.exports = {
'server-rs/target',
'server-rs/target-*',
'apps/desktop-shell/src-tauri/target',
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
'target',
'src/main.tsx',
'src/App.tsx',
+7 -21
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- master
- codex/ai-game-creator-app
pull_request:
workflow_dispatch:
@@ -58,16 +59,6 @@ jobs:
else
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
fi
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
head_ref="$(git rev-parse HEAD)"
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
fi
if [[ -z "${resolved_base_ref}" ]]; then
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
exit 1
fi
base_ref="${resolved_base_ref}"
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
@@ -97,6 +88,9 @@ jobs:
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Install AI game creator dependencies
run: bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell
- name: Run frontend and script tests
run: npm run test
@@ -142,16 +136,6 @@ jobs:
else
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
fi
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
head_ref="$(git rev-parse HEAD)"
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
fi
if [[ -z "${resolved_base_ref}" ]]; then
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
exit 1
fi
base_ref="${resolved_base_ref}"
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
@@ -207,12 +191,14 @@ jobs:
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Install AI game creator dependencies
run: bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell
- name: Prepare native Rust dependencies
shell: bash
run: |
set -euo pipefail
for manifest_path in \
server-rs/Cargo.toml \
apps/desktop-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
for attempt in $(seq 1 5); do
-8
View File
@@ -34,12 +34,6 @@ temp*build*/
/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/src-tauri/resources/codex/win-x64/codex.exe
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/manifest.json
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/bin/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-path/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
@@ -54,8 +48,6 @@ temp*build*/
/public/generated-characters
/.codex-temp
/.app/
/.jenkins-source-commit
/.jenkins-spacetime-schema-base
/target/
/logs
/.claude/settings.local.json
@@ -133,7 +133,7 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap
- [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。
- [ ] BgFilter worker 在 api-server 前 ready,父子共享实际 base URL / TokenRust watch 只触发一次组合重启。
- [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。
- [ ] `npm run agc` 在 Linux 使用用户段 `start + 5`Tauri、Vite、marker 和预检使用同一最终端口。
- [ ] `npm run agc` / `npm run agc:game-chat` 在 Linux 使用用户段 `start + 5`Tauri、Vite、marker 和预检使用同一最终端口。
- [ ] 文档同步更新 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
- [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`
- [ ] 修改中文文件后运行 `npm run check:encoding`
-1
View File
@@ -3,7 +3,6 @@ node_modules
.git
.codex-logs
public/Icons
apps/ai-game-creator-shell/src/features/ui-editor/types/
media
*.log
.preview.*
-1
View File
@@ -26,7 +26,6 @@
- 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”:
1. 不写历史兼容代码。
2. 不写用于维持退役行为的防御性兼容测试。
+54 -21
View File
@@ -1,14 +1,17 @@
# Genarrative / 陶泥儿
# AI Native Visual RPG
一个 AI 原生互动内容与小游戏平台,当前主站聚焦图片画布创作、项目与素材管理,以及账号、钱包和后台管理等平台公共能力
一个以“AI 叙事 + 本地规则 + 像素演出”为核心的视觉 RPG 原型
当前已经具备这些主要能力:
- 图片画布编辑、项目与素材管理
- 图片、视频、音频等外部生成任务
- 账号、钱包、充值、兑换码与个人资料
- 后台运营、配置与生产运维工具
- AI 游戏创作独立 App
- 世界与角色选择
- AI 剧情推进与流式对话
- 战斗演出、NPC 战斗、切磋
- NPC 交易、送礼、求助、招募
- 宝藏交互
- 同伴跟随与战斗
- 游戏主流程内嵌的角色资产工坊、自定义世界实体编辑与角色形象编辑
- 自动存档与继续游戏
## 运行
@@ -24,13 +27,14 @@
npm install
```
该命令会按根 `package.json` 的 npm workspaces 一次安装主站、Admin、Mobile、Desktop、AGC、Preview Deployer、内部 packages 与工具依赖;仓库只使用根 `package-lock.json`,不要在子目录单独执行 `npm install` / `npm ci` 或提交嵌套 lockfile。
准备环境变量:
- 复制 `.env.example``.env.local`
- 只填写本次联调所需配置;不要提交 `.env.local`、密钥、Token 或其它本地认证信息
- api-server 环境变量和 Provider 配置以 `.env.example` 及当前开发运维文档为准
- 填入 `LLM_API_KEY` / `ARK_API_KEY`
- 按需设置 `VITE_LLM_MODEL`
- 如需启用阿里云短信验证码登录,填写 `ALIYUN_SMS_ACCESS_KEY_ID``ALIYUN_SMS_ACCESS_KEY_SECRET`,并确认 `SMS_AUTH_PROVIDER="aliyun"`
- 本地联调短信登录时,建议将 `VITE_AUTH_ALLOW_DEV_GUEST` 设为 `false`,避免开发模式自动进入游客账号而跳过登录页
- 如需打印完整 prompt/output,可把 `VITE_LLM_DEBUG_LOG` 设为 `true`
启动开发环境:
@@ -65,30 +69,59 @@ npm run lint
npm run check:encoding
```
内容引用校验:
```bash
npm run check:data
```
编辑器 override 校验:
```bash
npm run check:overrides
```
关键内容 smoke 检查:
```bash
npm run check:smoke
```
一键内容检查:
```bash
npm run check:content
```
## 主要结构
主运行时:
- [src/active-main.tsx](./src/active-main.tsx)
- [src/ActiveApp.tsx](./src/ActiveApp.tsx)
- [src/App.tsx](./src/App.tsx)
- [src/AuthenticatedApp.tsx](./src/AuthenticatedApp.tsx)
- [src/routing/activeAppRoutes.tsx](./src/routing/activeAppRoutes.tsx)
- [src/components/platform-entry/PlatformEntryActiveFlowShell.tsx](./src/components/platform-entry/PlatformEntryActiveFlowShell.tsx)
- [src/routing/appRoutes.tsx](./src/routing/appRoutes.tsx)
- [src/hooks/useCombatFlow.ts](./src/hooks/useCombatFlow.ts)
创作与项目能力:
主流程内嵌编辑能力:
- [src/components/image-editor/ImageCanvasEditorView.tsx](./src/components/image-editor/ImageCanvasEditorView.tsx)
- [src/components/creation-home/CreationLandingView.tsx](./src/components/creation-home/CreationLandingView.tsx)
- [src/components/project/ProjectGalleryView.tsx](./src/components/project/ProjectGalleryView.tsx)
- [src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx](./src/components/rpg-creation-editor/RpgCreationEntityEditorModal.tsx)
- [src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx](./src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.tsx)
核心数据:
- [src/data/scenePresets.ts](./src/data/scenePresets.ts)
- [src/data/characterPresets.ts](./src/data/characterPresets.ts)
- [src/data/npcInteractions.ts](./src/data/npcInteractions.ts)
- [src/data/treasureInteractions.ts](./src/data/treasureInteractions.ts)
## 文档入口
`docs/` 已在 `2026-08-25` 按当前代码与运行态重新收口。旧 PRD、设计、审计、阶段计划和技术流水账不再作为实现依据;专题文档的现役清单统一从 `docs/README.md` 进入
`docs/` 已在 `2026-05-15` 完成压缩整理,旧 PRD、设计、审计、阶段计划和技术流水账不再作为实现依据。当前只读取
- [docs/README.md](./docs/README.md):当前文档总入口。
- [docs/【项目基线】当前产品与工程约束-2026-05-15.md](./docs/【项目基线】当前产品与工程约束-2026-05-15.md):产品、命名、UI、协作和废弃路线。
- [docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md](./docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)DDD 边界、API 分组、SpacetimeDB schema 规则和表目录。
- [docs/【玩法创作】平台入口与玩法链路-2026-05-15.md](./docs/【玩法创作】平台入口与玩法链路-2026-05-15.md)平台现役入口、项目页和画布链路
- [docs/【玩法创作】平台入口与玩法链路-2026-05-15.md](./docs/【玩法创作】平台入口与玩法链路-2026-05-15.md)创作入口、草稿架和各玩法当前口径
- [docs/【开发运维】本地开发验证与生产运维-2026-05-15.md](./docs/【开发运维】本地开发验证与生产运维-2026-05-15.md):本地启动、检查、部署、埋点和运营查询。
- [docs/project-memory/README.md](./docs/project-memory/README.md):团队共享的当前项目记忆、决策和未关闭事项。
- [UI_CODING_STANDARD.md](./UI_CODING_STANDARD.md):像素 UI 资产与编码规范。
+177
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
# UI Coding Standard
> **当前文档入口**:项目文档总入口为 `docs/README.md`UI 资产和 9-slice 规则以本文为准,平台级 UI 约束见 `docs/【项目基线】当前产品与工程约束-2026-05-15.md`,专题方案只从总入口读取
> **当前文档入口**:项目文档已压缩到 `docs/README.md` 和 4 份当前文档UI 资产和 9-slice 规则以本文为准,平台级 UI 约束见 `docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
## Goal
+1 -5
View File
@@ -10,7 +10,6 @@
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@genarrative/shared": "0.1.0",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
@@ -18,11 +17,8 @@
"vite": "^6.2.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
"typescript": "~5.8.2"
}
}
@@ -2,11 +2,11 @@
"agentMode": "codex_app_server",
"llm": {
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-5.6-sol",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKind": "openai_responses",
"reasoningEffort": "max",
"stream": true,
"reasoningEffort": "high",
"stream": false,
"webSearchEnabled": false,
"contextWindowTokens": 128000,
"autoCompactTokenLimit": 64000,
@@ -16,7 +16,9 @@
"retryBackoffMs": 500
},
"agentLlm": {},
"planning": {
"capabilityEnabled": true
}
"editorApi": {
"baseUrl": "http://127.0.0.1:8082",
"apiKey": ""
},
"mcpServers": {}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>陶泥儿</title>
<title>AI 游戏创作</title>
</head>
<body>
<div id="root"></div>
File diff suppressed because it is too large Load Diff
+5 -17
View File
@@ -1,13 +1,15 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.8",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
"game-chat": "node scripts/start-tauri-dev.mjs --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",
@@ -15,8 +17,6 @@
"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",
"test:plan": "node scripts/agent-swarm-test-chat.mjs --plan --task \"我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。\"",
"test:plan:manual": "node scripts/agent-swarm-test-chat.mjs --plan",
"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",
@@ -31,27 +31,19 @@
"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": "tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
},
"dependencies": {
"@cubone/react-file-manager": "^1.35.0",
"@genarrative/image-canvas-core": "0.1.0",
"@genarrative/image-canvas-react": "0.1.0",
"@genarrative/shared": "0.1.0",
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-arborist": "^3.16.0",
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
@@ -59,15 +51,11 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@openai/codex": "0.147.0",
"@tailwindcss/vite": "^4.1.14",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
"typescript": "~5.8.2"
}
}
File diff suppressed because it is too large Load Diff
@@ -73,7 +73,8 @@ export function isCatalogBoundToolPlanFunctionName(name, protocol) {
if (protocol !== 'native_runtime_tools') return false;
return (
['update_agent_plan', 'respond_to_user'].includes(name) ||
name.startsWith('runtime_tool_')
name.startsWith('runtime_tool_') ||
name.startsWith('mcp_tool_')
);
}
@@ -2405,36 +2406,11 @@ export function isolatedJoinDeliveryTarget(delivery) {
return target;
}
function runtimeMessageCorrelationId(agentId, sessionId, runId) {
return createHash('sha256')
export function finalMessageId(agentId, sessionId, runId) {
const fingerprint = createHash('sha256')
.update(`${agentId}\n${sessionId}\n${runId}`)
.digest('hex');
}
export function finalMessageId(agentId, sessionId, runId) {
return `agent-finalization-${runtimeMessageCorrelationId(
agentId,
sessionId,
runId,
).slice(0, 32)}`;
}
export function runtimePublicStatusMessageId(
agentId,
sessionId,
runId,
status,
) {
const correlationId = runtimeMessageCorrelationId(
agentId,
sessionId,
runId,
).slice(0, 32);
const statusFingerprint = createHash('sha256')
.update(status)
.digest('hex')
.slice(0, 16);
return `runtime-public-status-${correlationId}-${statusFingerprint}`;
return `agent-finalization-${fingerprint.slice(0, 32)}`;
}
export function backgroundTaskMessageId(agentId, sessionId, runId, source) {
@@ -2468,10 +2444,7 @@ export function disposableProjectPathVariants() {
}
export function formalConfigPathVariants() {
return absolutePathVariants(
state.options?.configDir,
state.isolatedRunner?.appDataDir,
);
return absolutePathVariants(state.options?.configDir);
}
export function absolutePathVariants(...values) {
@@ -20,15 +20,7 @@ import {
stopOwnedIsolatedRunner,
} from './harness/app-data.mjs';
import { loadConfig, parseArguments } from './harness/config.mjs';
import {
activeInteractiveCliSessions,
captureOwnedProcessCleanupSnapshot,
closeInteractiveCli,
destroyInteractiveCliOutputStreams,
interactiveCliOutput,
verifyOwnedProcessCleanupSnapshot,
waitForInteractiveCliStdioClose,
} from './harness/process.mjs';
import { closeInteractiveCli } from './harness/process.mjs';
import { checkPrerequisites } from './harness/project.mjs';
import {
buildSummary,
@@ -65,6 +57,14 @@ import {
isGoalRuntimeSuite,
runGoalRuntimeE2e,
} from './suites/goal.mjs';
import {
collectPartialMcpEvidence,
emptyMcpEvidence,
isMcpRuntimeSuite,
mcpPrivateValues,
runMcpRuntimeE2e,
stopMcpHttpFixture,
} from './suites/mcp.mjs';
import {
collectPartialParallelReadEvidence,
emptyParallelReadEvidence,
@@ -175,6 +175,7 @@ if (selfTestRequested) {
if (isContextCompactionSuite()) {
state.evidence = emptyContextCompactionEvidence();
}
if (isMcpRuntimeSuite()) state.evidence = emptyMcpEvidence();
if (isUserInputRuntimeSuite()) state.evidence = emptyUserInputEvidence();
if (isScopedAgentsSuite()) state.evidence = emptyScopedAgentsEvidence();
if (isProjectSkillSuite()) state.evidence = emptyProjectSkillEvidence();
@@ -192,6 +193,7 @@ if (selfTestRequested) {
if (
isWebSearchSuite() ||
isContextCompactionSuite() ||
isMcpRuntimeSuite() ||
isUserInputRuntimeSuite() ||
isScopedAgentsSuite() ||
isProjectSkillSuite() ||
@@ -232,6 +234,8 @@ if (selfTestRequested) {
await runWebSearchE2e();
} else if (isContextCompactionSuite()) {
await runContextCompactionE2e();
} else if (isMcpRuntimeSuite()) {
await runMcpRuntimeE2e();
} else if (isUserInputRuntimeSuite()) {
await runUserInputRuntimeE2e();
} else if (isScopedAgentsSuite()) {
@@ -262,21 +266,6 @@ if (selfTestRequested) {
recordError(error?.code ?? 'unexpected-error', error);
} finally {
state.cleanupInProgress = true;
const stateTrackedInteractiveCliSessions = new Set(
[
state.userInputCliSession,
state.supervisorAutonomousPlayableCliSession,
state.supervisorSwarmCliSession,
].filter(Boolean),
);
const interactiveCliSessions = [
...new Set([
...stateTrackedInteractiveCliSessions,
...activeInteractiveCliSessions,
]),
];
const supervisorAutonomousPlayableCliSession =
state.supervisorAutonomousPlayableCliSession;
if (isUserInputRuntimeSuite() && state.userInputCliSession) {
try {
await closeInteractiveCli(state.userInputCliSession);
@@ -322,51 +311,25 @@ if (selfTestRequested) {
}
state.supervisorSwarmCliSession = null;
}
for (const session of interactiveCliSessions) {
if (stateTrackedInteractiveCliSessions.has(session)) continue;
if (isMcpRuntimeSuite() && state.mcp.httpFixture) {
try {
await closeInteractiveCli(session);
await stopMcpHttpFixture();
state.evidence.httpFixtureStopped = true;
} catch (error) {
state.status = 'FAIL';
recordError('interactive-cli-cleanup-failed', error);
}
}
if (
isSupervisorAutonomousPlayableLaneDefenseSuite() &&
state.isolatedRunner.appDataDir
) {
try {
const runnerPid = state.isolatedRunner.current?.pid ?? null;
const helperPid =
state.isolatedRunner.current?.killHandle?.child?.pid ?? null;
state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot =
await captureOwnedProcessCleanupSnapshot({
runnerPid,
helperPids: Number.isSafeInteger(helperPid) ? [helperPid] : [],
rootPids: [
...interactiveCliSessions.map((session) => session.child?.pid),
...[...activeCommandChildren].map((child) => child.pid),
].filter((pid) => Number.isSafeInteger(pid) && pid > 0),
});
const observed =
state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot
.observedCounts;
assert(
observed.runner === 1 && observed.helper === 1,
'supervisor-autonomous-playable-owned-process-snapshot-incomplete',
);
} catch (error) {
state.status = 'FAIL';
recordError(
'supervisor-autonomous-playable-owned-process-snapshot-failed',
error,
);
recordError('mcp-http-fixture-cleanup-failed', error);
}
}
if (isIsolatedRunnerSuite() && state.isolatedRunner.appDataDir) {
try {
await stopOwnedIsolatedRunner();
state.isolatedRunner.stopped = true;
state.isolatedRunner.cleanupPerformed =
await removeIsolatedSuiteAppData();
if (!state.isolatedRunner.cleanupPerformed) {
state.status = 'FAIL';
recordError('isolated-appdata-cleanup-sentinel-missing');
}
} catch (error) {
state.status = 'FAIL';
const safeCleanupErrorCode =
@@ -379,51 +342,8 @@ if (selfTestRequested) {
state.isolatedRunner.current?.killHandle,
).catch(() => {});
}
}
for (const session of interactiveCliSessions) {
try {
await waitForInteractiveCliStdioClose(session, 10_000);
} catch (error) {
destroyInteractiveCliOutputStreams(session);
state.status = 'FAIL';
recordError(
error?.code === 'interactive-cli-stdio-close-timeout'
? error.code
: 'interactive-cli-stdio-cleanup-failed',
error,
);
}
}
if (supervisorAutonomousPlayableCliSession) {
state.supervisorAutonomousPlayable.cliOutput = interactiveCliOutput(
supervisorAutonomousPlayableCliSession,
);
}
if (isIsolatedRunnerSuite() && state.isolatedRunner.appDataDir) {
if (state.isolatedRunner.stopped) {
try {
state.isolatedRunner.cleanupPerformed =
await removeIsolatedSuiteAppData();
if (!state.isolatedRunner.cleanupPerformed) {
state.status = 'FAIL';
recordError('isolated-appdata-cleanup-sentinel-missing');
}
} catch (error) {
state.status = 'FAIL';
const safeCleanupErrorCode =
isNonEmptyString(error?.code) &&
/^(?:isolated|source)-[a-z0-9-]+$/u.test(error.code)
? error.code
: 'isolated-appdata-cleanup-failed';
recordError(safeCleanupErrorCode, error);
}
}
const killMethod =
state.isolatedRunner.pidfdClaimCount > 0
? process.platform === 'win32'
? 'windows-process-handle'
: 'linux-pidfd'
: null;
state.isolatedRunner.pidfdClaimCount > 0 ? 'linux-pidfd' : null;
if (isSteerRunnerKillSuite()) {
state.evidence.steerRunnerStopped = state.isolatedRunner.stopped;
state.evidence.steerAppDataCleanupPerformed =
@@ -500,6 +420,28 @@ if (selfTestRequested) {
state.status = 'FAIL';
recordError('web-search-formal-config-cli-call-detected');
}
} else if (isMcpRuntimeSuite()) {
state.evidence.mcpRunnerStopped = state.isolatedRunner.stopped;
state.evidence.mcpAppDataCleanupPerformed =
state.isolatedRunner.cleanupPerformed;
state.evidence.mcpRunnerKillMethod = killMethod;
state.evidence.mcpRunnerPidfdClaimCount =
state.isolatedRunner.pidfdClaimCount;
state.evidence.mcpRunnerPidfdSignalCount =
state.isolatedRunner.pidfdSignalCount;
state.evidence.formalConfigCliCallCount =
state.isolatedRunner.sourceConfigCliCallCount;
state.evidence.sourceRunnerEndpointUnchanged =
state.isolatedRunner.sourceRunnerEndpointUnchanged;
state.evidence.sourceConfigReplicaCount =
state.isolatedRunner.configLinks.length;
state.evidence.sourceConfigReplicasVerified =
state.isolatedRunner.sourceConfigLinksVerified;
state.evidence.isolatedAppDataUsed = true;
if (state.isolatedRunner.sourceConfigCliCallCount > 0) {
state.status = 'FAIL';
recordError('mcp-formal-config-cli-call-detected');
}
} else if (isUserInputRuntimeSuite()) {
state.evidence.userInputRunnerStopped = state.isolatedRunner.stopped;
state.evidence.userInputAppDataCleanupPerformed =
@@ -719,61 +661,6 @@ if (selfTestRequested) {
);
}
}
if (
isSupervisorAutonomousPlayableLaneDefenseSuite() &&
state.isolatedRunner.appDataDir
) {
const snapshot =
state.supervisorAutonomousPlayable.ownedProcessCleanupSnapshot;
if (snapshot) {
try {
const cleanup = await verifyOwnedProcessCleanupSnapshot(snapshot);
state.evidence.ownedProcessIdentityCaptured = true;
state.evidence.ownedRunnerObservedCount =
snapshot.observedCounts.runner;
state.evidence.ownedHelperObservedCount =
snapshot.observedCounts.helper;
state.evidence.ownedNodeDescendantObservedCount =
snapshot.observedCounts.node;
state.evidence.ownedBrowserDescendantObservedCount =
snapshot.observedCounts.browser;
state.evidence.ownedCommandDescendantObservedCount =
snapshot.observedCounts.command;
state.evidence.ownedRunnerResidualCount =
cleanup.residualCounts.runner;
state.evidence.ownedHelperResidualCount =
cleanup.residualCounts.helper;
state.evidence.ownedNodeDescendantResidualCount =
cleanup.residualCounts.node;
state.evidence.ownedBrowserDescendantResidualCount =
cleanup.residualCounts.browser;
state.evidence.ownedCommandDescendantResidualCount =
cleanup.residualCounts.command;
state.evidence.activeCommandChildrenAfterCleanup =
cleanup.activeCommandChildCount;
state.evidence.activeInteractiveCliSessionsAfterCleanup =
cleanup.activeInteractiveCliSessionCount;
state.evidence.ownedProcessCleanupPassed = cleanup.clean;
if (!cleanup.clean) {
state.status = 'FAIL';
recordError(
'supervisor-autonomous-playable-owned-process-residual-detected',
);
}
} catch (error) {
state.status = 'FAIL';
recordError(
'supervisor-autonomous-playable-owned-process-verification-failed',
error,
);
}
} else {
state.status = 'FAIL';
recordError(
'supervisor-autonomous-playable-owned-process-snapshot-missing',
);
}
}
if (
isSteerRunnerKillSuite() &&
state.projectRoot &&
@@ -836,6 +723,16 @@ if (selfTestRequested) {
recordError('context-compaction-partial-evidence-read-failed', error);
}
}
if (isMcpRuntimeSuite() && state.projectRoot && state.status !== 'PASS') {
try {
state.evidence = {
...state.evidence,
...(await collectPartialMcpEvidence()),
};
} catch (error) {
recordError('mcp-partial-evidence-read-failed', error);
}
}
if (
isUserInputRuntimeSuite() &&
state.projectRoot &&
@@ -1062,6 +959,19 @@ if (selfTestRequested) {
report = JSON.stringify(summary, null, 2);
}
}
if (isMcpRuntimeSuite()) {
state.mcp.reportLeakCount = countExactSecrets(
Buffer.from(report),
mcpPrivateValues(),
);
state.evidence.mcpReportLeakCount = state.mcp.reportLeakCount;
if (state.mcp.reportLeakCount > 0) {
state.status = 'FAIL';
recordError('mcp-private-context-report-leak-detected');
summary = buildSummary();
report = JSON.stringify(summary, null, 2);
}
}
if (isUserInputRuntimeSuite()) {
state.userInput.reportLeakCount = countExactSecrets(
Buffer.from(report),
@@ -1164,6 +1074,7 @@ if (selfTestRequested) {
if (
isWebSearchSuite() ||
isContextCompactionSuite() ||
isMcpRuntimeSuite() ||
isUserInputRuntimeSuite() ||
isScopedAgentsSuite() ||
isProjectSkillSuite() ||
@@ -1211,6 +1122,9 @@ if (selfTestRequested) {
const remainingWebSearchReportLeakCount = isWebSearchSuite()
? countExactSecrets(Buffer.from(report), webSearchPrivateLeakValues())
: 0;
const remainingMcpReportLeakCount = isMcpRuntimeSuite()
? countExactSecrets(Buffer.from(report), mcpPrivateValues())
: 0;
const remainingUserInputReportLeakCount = isUserInputRuntimeSuite()
? countExactSecrets(
Buffer.from(report),
@@ -1246,6 +1160,7 @@ if (selfTestRequested) {
const remainingFormalConfigPathReportLeakCount =
isWebSearchSuite() ||
isContextCompactionSuite() ||
isMcpRuntimeSuite() ||
isUserInputRuntimeSuite() ||
isScopedAgentsSuite() ||
isProjectSkillSuite() ||
@@ -1259,6 +1174,7 @@ if (selfTestRequested) {
remainingProjectPathReportLeakCount > 0 ||
remainingResponseStreamReportLeakCount > 0 ||
remainingWebSearchReportLeakCount > 0 ||
remainingMcpReportLeakCount > 0 ||
remainingUserInputReportLeakCount > 0 ||
remainingScopedAgentsReportLeakCount > 0 ||
remainingProjectSkillReportLeakCount > 0 ||
@@ -1273,26 +1189,27 @@ if (selfTestRequested) {
? 'disposable-project-path-report-redaction-required'
: remainingResponseStreamReportLeakCount > 0
? 'response-stream-report-redaction-required'
: remainingUserInputReportLeakCount > 0
? 'user-input-report-redaction-required'
: remainingScopedAgentsReportLeakCount > 0
? 'scoped-agents-report-redaction-required'
: remainingProjectSkillReportLeakCount > 0
? 'project-skill-report-redaction-required'
: remainingParallelReadReportLeakCount > 0
? 'parallel-read-report-redaction-required'
: remainingSupervisorAutonomousPlayableReportLeakCount > 0
? 'supervisor-autonomous-playable-report-redaction-required'
: remainingSupervisorSwarmReportLeakCount > 0
? 'supervisor-swarm-report-redaction-required'
: remainingFormalConfigPathReportLeakCount > 0
? 'formal-config-path-report-redaction-required'
: 'web-search-report-redaction-required',
: remainingMcpReportLeakCount > 0
? 'mcp-report-redaction-required'
: remainingUserInputReportLeakCount > 0
? 'user-input-report-redaction-required'
: remainingScopedAgentsReportLeakCount > 0
? 'scoped-agents-report-redaction-required'
: remainingProjectSkillReportLeakCount > 0
? 'project-skill-report-redaction-required'
: remainingParallelReadReportLeakCount > 0
? 'parallel-read-report-redaction-required'
: remainingSupervisorAutonomousPlayableReportLeakCount > 0
? 'supervisor-autonomous-playable-report-redaction-required'
: remainingSupervisorSwarmReportLeakCount > 0
? 'supervisor-swarm-report-redaction-required'
: remainingFormalConfigPathReportLeakCount > 0
? 'formal-config-path-report-redaction-required'
: 'web-search-report-redaction-required',
);
const safeSummary = {
status: state.status,
suite: state.suite,
providerUsed: false,
blocked: state.blocked,
cleanup: {
performed: state.cleanupPerformed,
@@ -1302,6 +1219,7 @@ if (selfTestRequested) {
projectPathReportLeakCount: remainingProjectPathReportLeakCount,
responseStreamReportLeakCount: remainingResponseStreamReportLeakCount,
webSearchReportLeakCount: remainingWebSearchReportLeakCount,
mcpReportLeakCount: remainingMcpReportLeakCount,
userInputReportLeakCount: remainingUserInputReportLeakCount,
scopedAgentsReportLeakCount: remainingScopedAgentsReportLeakCount,
projectSkillReportLeakCount: remainingProjectSkillReportLeakCount,
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import {
contextCompactionSuite,
goalRuntimeSuite,
localConfigFileName,
mcpRuntimeSuite,
parallelReadSuite,
processSessionSuites,
projectSkillSuite,
@@ -58,6 +59,7 @@ export function parseArguments(args) {
suite === responseStreamSuite ||
suite === webSearchSuite ||
suite === contextCompactionSuite ||
suite === mcpRuntimeSuite ||
suite === userInputRuntimeSuite ||
suite === scopedAgentsSuite ||
suite === projectSkillSuite ||
@@ -19,8 +19,6 @@ import {
} from '../suites/supervisor-swarm.mjs';
import { isIsolatedRunnerSuite } from './reporting.mjs';
export const activeInteractiveCliSessions = new Set();
export async function prepareCliBinary() {
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
await runProcess(
@@ -156,70 +154,36 @@ export function startInteractiveCli(args) {
stdio: ['pipe', 'pipe', 'pipe'],
},
);
return createInteractiveCliSession(child);
}
export function createInteractiveCliSession(child) {
activeCommandChildren.add(child);
const session = {
child,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
exited: false,
exitInfo: null,
exitPromise: null,
closed: false,
closeInfo: null,
closePromise: null,
stdioClosed: false,
stdioCloseInfo: null,
spawnError: null,
stdinError: null,
};
activeInteractiveCliSessions.add(session);
session.exitPromise = new Promise((resolve) => {
const settle = (result) => {
if (session.exited) return;
activeCommandChildren.delete(child);
session.exited = true;
session.closed = true;
session.exitInfo = result;
session.closeInfo = result;
resolve(result);
};
child.once('error', (error) => {
session.spawnError = error;
settle({ code: null, signal: null, error });
});
child.once('exit', (code, signal) => {
settle({ code, signal, error: null });
});
});
session.closePromise = new Promise((resolve) => {
child.once('close', (code, signal) => {
child.on('error', (error) => {
activeCommandChildren.delete(child);
activeInteractiveCliSessions.delete(session);
session.stdioClosed = true;
session.stdioCloseInfo = {
code,
signal,
error: session.spawnError,
};
resolve(session.stdioCloseInfo);
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.stdin?.on('error', (error) => {
session.stdinError ??= error;
});
child.stdout.on('data', (chunk) => {
state.transcriptScanner?.scan('interactive-stdout', chunk);
state.projectPathTranscriptScanner?.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.projectPathTranscriptScanner?.scan('interactive-stderr', chunk);
state.formalConfigPathTranscriptScanner?.scan('interactive-stderr', chunk);
session.stderr = appendBounded(session.stderr, chunk, commandOutputLimit);
});
@@ -241,23 +205,16 @@ export async function waitForInteractiveCliOutput(
predicate,
code,
timeoutMs,
{ allowAfterProcessExit = false } = {},
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = interactiveCliOutput(session);
if (predicate(output)) return output;
if (session.exited && !allowAfterProcessExit) {
if (session.closed) {
if (session === state.supervisorSwarmCliSession) {
recordSupervisorSwarmChatSessionFailureDiagnostic(session);
}
throw codedError(`${code}-cli-exited`);
}
if (session.stdioClosed) {
if (session === state.supervisorSwarmCliSession) {
recordSupervisorSwarmChatSessionFailureDiagnostic(session);
}
throw codedError(`${code}-cli-stdio-closed`);
throw codedError(`${code}-cli-closed`);
}
await sleep(50);
}
@@ -266,7 +223,7 @@ export async function waitForInteractiveCliOutput(
export async function waitForInteractiveCliExit(session, timeoutMs) {
const result = await Promise.race([
session.exitPromise,
session.closePromise,
sleep(timeoutMs).then(() => null),
]);
if (!result) throw codedError('interactive-cli-exit-timeout');
@@ -279,52 +236,26 @@ export async function waitForInteractiveCliExit(session, timeoutMs) {
}
export async function closeInteractiveCli(session) {
if (!session) return null;
if (session.exited) return session.exitInfo;
if (
session.child.stdin.writable &&
!session.child.stdin.writableEnded &&
!session.child.stdin.destroyed
) {
if (!session || session.closed) return;
if (session.child.stdin.writable) {
session.child.stdin.write('/quit\n');
}
let result = await Promise.race([
session.exitPromise,
session.closePromise,
sleep(3_000).then(() => null),
]);
if (!result && !session.exited) {
if (!result && !session.closed) {
session.child.kill('SIGTERM');
result = await Promise.race([
session.exitPromise,
session.closePromise,
sleep(2_000).then(() => null),
]);
}
if (!result && !session.exited) {
if (!result && !session.closed) {
session.child.kill('SIGKILL');
result = await Promise.race([
session.exitPromise,
sleep(5_000).then(() => null),
]);
result = await session.closePromise;
}
assert(Boolean(result), 'interactive-cli-cleanup-timeout');
return result;
}
export async function waitForInteractiveCliStdioClose(session, timeoutMs) {
if (!session || session.stdioClosed) return session?.stdioCloseInfo ?? null;
const result = await Promise.race([
session.closePromise,
sleep(timeoutMs).then(() => null),
]);
if (!result) throw codedError('interactive-cli-stdio-close-timeout');
return result;
}
export function destroyInteractiveCliOutputStreams(session) {
if (!session) return;
for (const stream of [session.child.stdout, session.child.stderr]) {
if (stream && !stream.destroyed) stream.destroy();
}
}
export async function runProcess(
@@ -398,214 +329,6 @@ export async function runProcess(
});
}
export async function listSystemProcessIdentities() {
if (process.platform === 'win32') {
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
assert(
typeof systemRoot === 'string' && path.isAbsolute(systemRoot),
'owned-process-snapshot-system-root-invalid',
);
const powershell = path.join(
systemRoot,
'System32/WindowsPowerShell/v1.0/powershell.exe',
);
const metadata = await fs.lstat(powershell);
assert(
metadata.isFile() && !metadata.isSymbolicLink(),
'owned-process-snapshot-powershell-invalid',
);
const result = await runProcess(
powershell,
[
'-NoProfile',
'-NonInteractive',
'-Command',
'$processes = @(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate,Name); $processes | ConvertTo-Json -Compress',
],
{
cwd: appRoot,
timeoutMs: 30_000,
env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' },
},
);
const parsed = JSON.parse(result.stdout);
return (Array.isArray(parsed) ? parsed : [parsed])
.map((record) => ({
pid: Number(record?.ProcessId),
parentPid: Number(record?.ParentProcessId),
startedAt: String(record?.CreationDate ?? ''),
name: String(record?.Name ?? ''),
}))
.filter(validSystemProcessIdentity);
}
assert(
process.platform === 'linux' || process.platform === 'darwin',
'owned-process-snapshot-platform-unsupported',
);
const result = await runProcess(
'ps',
['-A', '-o', 'pid=', '-o', 'ppid=', '-o', 'lstart=', '-o', 'comm='],
{ cwd: appRoot, timeoutMs: 30_000 },
);
return result.stdout
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const fields = line.split(/\s+/u);
return {
pid: Number(fields[0]),
parentPid: Number(fields[1]),
startedAt: fields.slice(2, 7).join(' '),
name: fields.slice(7).join(' '),
};
})
.filter(validSystemProcessIdentity);
}
function validSystemProcessIdentity(record) {
return (
Number.isSafeInteger(record?.pid) &&
record.pid > 0 &&
Number.isSafeInteger(record.parentPid) &&
record.parentPid >= 0 &&
typeof record.startedAt === 'string' &&
record.startedAt.length > 0 &&
typeof record.name === 'string' &&
record.name.length > 0
);
}
export function buildOwnedProcessCleanupSnapshot(
processRecords,
{ rootPids = [], runnerPid = null, helperPids = [] } = {},
) {
assert(
Array.isArray(processRecords) &&
Array.isArray(rootPids) &&
Array.isArray(helperPids),
'owned-process-snapshot-input-invalid',
);
const records = processRecords.filter(validSystemProcessIdentity);
const byPid = new Map(records.map((record) => [record.pid, record]));
const childrenByParent = new Map();
for (const record of records) {
const children = childrenByParent.get(record.parentPid) ?? [];
children.push(record.pid);
childrenByParent.set(record.parentPid, children);
}
const normalizedRunnerPid = Number.isSafeInteger(runnerPid)
? runnerPid
: null;
const helperPidSet = new Set(
helperPids.filter((pid) => Number.isSafeInteger(pid) && pid > 0),
);
const roots = [
...new Set(
[...rootPids, normalizedRunnerPid, ...helperPidSet].filter(
(pid) => Number.isSafeInteger(pid) && pid > 0,
),
),
];
assert(roots.length > 0, 'owned-process-snapshot-root-missing');
const ownedPids = new Set();
const queue = [...roots];
while (queue.length > 0) {
const pid = queue.shift();
if (ownedPids.has(pid)) continue;
ownedPids.add(pid);
queue.push(...(childrenByParent.get(pid) ?? []));
}
const identities = [...ownedPids]
.map((pid) => byPid.get(pid))
.filter(Boolean)
.map((record) => ({
pid: record.pid,
startedAt: record.startedAt,
name: record.name,
kind: ownedProcessKind(record, normalizedRunnerPid, helperPidSet),
}))
.sort((left, right) => left.pid - right.pid);
return {
identities,
observedCounts: countOwnedProcessKinds(identities),
};
}
function ownedProcessKind(record, runnerPid, helperPids) {
if (record.pid === runnerPid) return 'runner';
if (helperPids.has(record.pid)) return 'helper';
const name = path.basename(record.name).toLowerCase();
if (/^node(?:\.exe)?$/u.test(name)) return 'node';
if (/^(?:chrome|chromium|msedge|google-chrome)(?:\.exe)?$/u.test(name)) {
return 'browser';
}
return 'command';
}
function countOwnedProcessKinds(identities) {
const counts = {
runner: 0,
helper: 0,
node: 0,
browser: 0,
command: 0,
total: identities.length,
};
for (const identity of identities) counts[identity.kind] += 1;
return counts;
}
export function inspectOwnedProcessCleanupResiduals(
snapshot,
processRecords,
{ activeCommandChildCount = 0, activeInteractiveCliSessionCount = 0 } = {},
) {
assert(
Array.isArray(snapshot?.identities) && Array.isArray(processRecords),
'owned-process-residual-input-invalid',
);
const currentByPid = new Map(
processRecords
.filter(validSystemProcessIdentity)
.map((record) => [record.pid, record]),
);
const residualIdentities = snapshot.identities.filter((identity) => {
const current = currentByPid.get(identity.pid);
return (
current?.startedAt === identity.startedAt &&
current?.name === identity.name
);
});
return {
residualCounts: countOwnedProcessKinds(residualIdentities),
activeCommandChildCount,
activeInteractiveCliSessionCount,
clean:
residualIdentities.length === 0 &&
activeCommandChildCount === 0 &&
activeInteractiveCliSessionCount === 0,
};
}
export async function captureOwnedProcessCleanupSnapshot(options) {
return buildOwnedProcessCleanupSnapshot(
await listSystemProcessIdentities(),
options,
);
}
export async function verifyOwnedProcessCleanupSnapshot(snapshot) {
return inspectOwnedProcessCleanupResiduals(
snapshot,
await listSystemProcessIdentities(),
{
activeCommandChildCount: activeCommandChildren.size,
activeInteractiveCliSessionCount: activeInteractiveCliSessions.size,
},
);
}
export function appendBounded(current, chunk, limit) {
const combined = Buffer.concat([current, chunk]);
return combined.length <= limit

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