From c75588ef3e27ad1e5e65a7010638e2ad6e929fc2 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 14:28:08 +0800 Subject: [PATCH 01/26] docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freeze the S1 baseline before any behaviour change: repo/skills SHAs, Node/pnpm, baseline test results (363 pass), read-only meshyd cross-check notes, the frozen endpoint contract for the 16 task resources + 8 Creative Lab stage resources + balance/animation-catalog/showcases queries, design decisions D-001..D-024, legacy→CLI migration notes with intentional differences, and the synthetic fixtures (OBJ oracle, rigging task, SSE error stream). Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/baseline-delta.md | 16 + docs/skill-parity/baseline.json | 89 ++ docs/skill-parity/capability-matrix.json | 802 ++++++++++++++++++ docs/skill-parity/decisions.md | 215 +++++ docs/skill-parity/endpoint-contracts.json | 120 +++ docs/skill-parity/migration-notes.md | 117 +++ docs/skill-parity/verification.json | 25 + .../skill-parity/box-height-80.expected.json | 106 +++ tests/fixtures/skill-parity/box-y-up.obj | 28 + tests/fixtures/skill-parity/box.mtl | 3 + .../skill-parity/task-error.synthetic.sse | 6 + .../skill-parity/task-rigging.synthetic.json | 18 + 12 files changed, 1545 insertions(+) create mode 100644 docs/skill-parity/baseline-delta.md create mode 100644 docs/skill-parity/baseline.json create mode 100644 docs/skill-parity/capability-matrix.json create mode 100644 docs/skill-parity/decisions.md create mode 100644 docs/skill-parity/endpoint-contracts.json create mode 100644 docs/skill-parity/migration-notes.md create mode 100644 docs/skill-parity/verification.json create mode 100644 tests/fixtures/skill-parity/box-height-80.expected.json create mode 100644 tests/fixtures/skill-parity/box-y-up.obj create mode 100644 tests/fixtures/skill-parity/box.mtl create mode 100644 tests/fixtures/skill-parity/task-error.synthetic.sse create mode 100644 tests/fixtures/skill-parity/task-rigging.synthetic.json diff --git a/docs/skill-parity/baseline-delta.md b/docs/skill-parity/baseline-delta.md new file mode 100644 index 0000000..952469f --- /dev/null +++ b/docs/skill-parity/baseline-delta.md @@ -0,0 +1,16 @@ +# Baseline delta + +Checked 2026-09-07 before any change: + +- `meshy-dev/meshy-cli` remote `main` = `fd94490916376e691efcea51324ac4326b459e1f`, + identical to the implementation-package baseline. No mapping of plan line references + is needed; the symbols named in IMPLEMENTATION_PLAN.md §2.1 exist as described + (`makeFetcher` in `src/client/index.ts`, `buildResourceCommand` / + `emitTerminalOutcome` in `src/internal/task-command.ts`, `runChain` in + `src/cmd/make.ts`, `enumerateArtifacts` / `downloadArtifact` in + `src/internal/download.ts`, `buildRuntime` cache in `src/internal/runtime.ts`, + `refreshCache()` first in `src/index.ts`). +- `meshy-dev/meshy-3d-agent` remote HEAD = `b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610`, + identical to the package baseline. +- Work happens on branch `feat/skill-parity-s1` in a fresh clone; the user's research + checkout under `meshy-agent-integrations-research/sources/` is untouched. diff --git a/docs/skill-parity/baseline.json b/docs/skill-parity/baseline.json new file mode 100644 index 0000000..8139475 --- /dev/null +++ b/docs/skill-parity/baseline.json @@ -0,0 +1,89 @@ +{ + "schema_version": 1, + "recorded_at": "2026-09-07T06:22:37Z", + "implementation_package": { + "version": "2026-09-07 / v1", + "source": "meshy-cli-implementation (IMPLEMENTATION_PLAN.md, COMMAND_CONTRACTS.md, TEST_PLAN.md, capability-matrix.json)" + }, + "repositories": { + "cli": { + "url": "https://github.com/meshy-dev/meshy-cli", + "plan_baseline_sha": "fd94490916376e691efcea51324ac4326b459e1f", + "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", + "remote_main_at_start": "fd94490916376e691efcea51324ac4326b459e1f", + "delta_from_plan_baseline": "none", + "package_version_at_start": "0.2.0", + "work_branch": "feat/skill-parity-s1", + "workspace": "fresh clone; no pre-existing uncommitted changes", + "repo_instructions": "no AGENTS.md / CLAUDE.md present at baseline" + }, + "skills": { + "url": "https://github.com/meshy-dev/meshy-3d-agent", + "plan_baseline_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610", + "remote_head_at_start": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610", + "delta_from_plan_baseline": "none", + "claude_manifest_version": "0.4.1", + "usage": "read-only capability baseline; not modified" + }, + "meshyd_read_only_crosscheck": { + "commit": "69b7ff5dd29f7a75a3220987985037ecb5c13c2e", + "files_consulted": [ + "pkg/server/server.go (public route registration: every task API group registers POST '', GET '', GET /:id, GET /:id/stream, DELETE /:id; openapi /v1/showcases; /web/public/animations/resources)", + "pkg/server/api_stream_handlers.go (SSE: event 'message' carries task DTO; event 'error' carries {message,status_code}; keep-alive re-emits 'message' every 10s; stream closes after terminal status)", + "pkg/server/internal/httpapi/response.go (APIStreamErrorResponse = {message, status_code})", + "pkg/server/internal/httpapi/dto.go (task DTO: model_urls object keys glb/fbx/gltf/usdz/obj/mtl/vox/blend/stl/3mf/pre_remeshed_glb; thumbnail_urls is an object keyed by view; alpha_thumbnail_url; consumed_credits optional; no face_count field)", + "pkg/server/creativelab/openapi_routes.go + api_creative_lab_{figure,lamp,keychain,fridge_magnet}_handlers.go (request structs)", + "pkg/model/task_creative_lab_{lamp,keychain,fridge_magnet}.go (options/output enums, artifact keys)", + "pkg/server/api.go (ListShowcasesForOpenAPIRequest: format oneof glb fbx usdz obj; showcase_type oneof all animate static; GetShowcaseForOpenAPIResult fields; ListAnimationsResourcesRequest: q/category/subCategory; AnimationResource fields)" + ], + "notes": [ + "internal source copies are not included in this repository", + "production deployment state, account gates and billing were not tested" + ] + } + }, + "official_sources_checked": [ + "https://docs.meshy.ai/en/api/uv-unwrap", + "https://docs.meshy.ai/en/api/creative-lab-figure", + "https://docs.meshy.ai/en/api/creative-lab-lamp", + "https://docs.meshy.ai/en/api/creative-lab-keychain", + "https://docs.meshy.ai/en/api/creative-lab-fridge-magnet", + "https://docs.meshy.ai/en/api/enterprise-api", + "https://docs.meshy.ai/en/api/rigging" + ], + "environment": { + "os": "macOS 26.6 (Darwin 25.6.0) arm64", + "node": "v24.20.0 (installed via fnm; repo .node-version = 24; engines >=24)", + "pnpm": "11.24.0 (corepack, package.json#packageManager)", + "python": "not required by the CLI; not used by any test" + }, + "baseline_checks_before_changes": [ + { "command": "pnpm install --frozen-lockfile", "exit_code": 0, "duration_s": 10, "notes": "prepare script built dist/" }, + { "command": "pnpm typecheck", "exit_code": 0, "duration_s": 0 }, + { "command": "pnpm test", "exit_code": 0, "duration_s": 41, "tests": 363, "pass": 363, "fail": 0, "skipped": 0 }, + { "command": "pnpm build", "exit_code": 0, "duration_s": 1 } + ], + "legacy_script_copies": { + "scripts/src/meshy_task.py": "5c5dc16337580ccd97cee49c657d5395fb20ab9f1b66c8b7c41e364c545e7b2d", + "skills/*/scripts/meshy_task.py (3 identical copies)": "ba436fb36b2412db2f199f39804a0b788dd8c2d39aefd9e7153da2c671158c4d", + "skills/meshy-3d-printing/scripts/fix_obj.py": "2fa2ac5a7c26856f2818e23791ef5b82501411a94c82e7448e1253abc330be1c", + "skills/meshy-openclaw/scripts/fix_obj.py": "50e9fc00a83bc85067955fef2c61d94a8163e0002012961fbae9bad1e0cf1b35", + "skills/meshy-3d-printing/scripts/slicers.py": "343934ac49b43183bce364be86a801e03ecf40e4b722156e0da8d322cd18388d", + "skills/meshy-openclaw/scripts/slicers.py": "fe28032870de5a7de134278e8a9a39d3a7997e9bcc1ff2db9abc2a1c4f261683", + "note": "the distributed copies differ from scripts/src only by the generated header comment; no extra executable capability was found in any copy" + }, + "fixtures": { + "tests/fixtures/skill-parity/box-y-up.obj": "beb379900c2c9a204db4a2cbeadd536292f30ac70ce849adc0762c9a88b5949b", + "tests/fixtures/skill-parity/box.mtl": "a11dcb465d7f74b03c4c007a5fbcba08233a392d4d526a82e2b513412aa2a556", + "tests/fixtures/skill-parity/box-height-80.expected.json": "f887f40c18d068145bbc1ffb03fc5c42f73f056ecf30b622a65a01edd7f2ed5d", + "tests/fixtures/skill-parity/task-rigging.synthetic.json": "8dca5b6abed0ae161d2cb0140d68d4c38341cfa48841ecdabd001427b77d039e", + "tests/fixtures/skill-parity/task-error.synthetic.sse": "b9542722988c854626a3007a0d7ec771a308a54945331f97788036f687c95300", + "origin": "synthetic fixtures copied from the implementation package; none are live API records" + }, + "environment_limits": [ + "no Meshy API credential is configured in this session; live API checks are not_run", + "only macOS arm64 is available; Windows/Linux slicer detection is covered by platform fixtures only", + "no slicer application is verified on this host", + "Enterprise showcases, UV Unwrap and Creative Lab live calls are billable/gated and are not_run" + ] +} diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json new file mode 100644 index 0000000..93a7b45 --- /dev/null +++ b/docs/skill-parity/capability-matrix.json @@ -0,0 +1,802 @@ +{ + "schema_version": 1, + "title": "Meshy CLI S1 capability matrix — implementation tracking", + "date": "2026-09-07", + "status": "in_progress", + "baseline": { + "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", + "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" + }, + "completion_rule": "All mandatory capabilities need implementation and evidence for G1-code. G1-release additionally requires review, applicable live/OS checks and a released pinned version. Not-run is not pass.", + "capabilities": [ + { + "id": "CAP-001", + "title": "任务创建与异步单次提交", + "area": "create", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "legacy scripts/src/meshy_task.py:create_task/_cmd_create", + "target": "meshy create --async", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-040", + "T-043", + "T-044", + "T-045" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-002", + "title": "完整任务查询与字段", + "area": "get", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "meshy_task.py:_cmd_get", + "target": "meshy get --include-raw --save-json", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-006", + "T-008", + "T-010" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-003", + "title": "有界等待与恢复", + "area": "wait", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "meshy_task.py:poll_task/_cmd_poll", + "target": "meshy wait", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-007", + "T-047", + "T-048" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-004", + "title": "SSE 状态流", + "area": "stream", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "skills/*/reference.md:SSE examples", + "target": "meshy stream", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-049", + "T-050", + "T-051", + "T-052", + "T-053" + ], + "evidence": [ + "meshyd api_stream_handlers.go: event message (task), event error {message,status_code}, keep-alive re-emits message every 10 s" + ], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-005", + "title": "余额查询、列表与删除", + "area": "list/delete", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "meshy_task.py:balance; Skill REST list/delete examples", + "target": "meshy balance; meshy list|delete", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-001", + "T-011", + "T-020", + "T-103" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-006", + "title": "旧工作流模型参数和默认值对齐", + "area": "payload", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "skills/*/references/pipelines.md", + "target": "existing commands with explicit supported payload", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-028", + "T-033", + "T-034" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-007", + "title": "本地媒体/URL/data URI", + "area": "media", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "pipelines.md:base64 Python snippets", + "target": "typed media flags and final payload normalization", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-029", + "T-030", + "T-031" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-008", + "title": "UV Unwrap", + "area": "uv-unwrap", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:UV Unwrap API", + "target": "meshy uv-unwrap create/get/list/wait/stream/delete", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-021", + "T-020" + ], + "evidence": [ + "docs.meshy.ai/en/api/uv-unwrap (input_task_id|model_url, GLB only, 40k faces, Statsig-gated 404)", + "meshyd server.go: /openapi/v1/uv-unwrap registers POST, GET list, GET :id, GET :id/stream, DELETE :id" + ], + "intentional_differences": [], + "external_verification": "required_when_applicable_not_run" + }, + { + "id": "CAP-009", + "title": "Creative Lab Figure", + "area": "creative-lab.figure", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Creative Lab API", + "target": "meshy creative-lab figure prototype|build ", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-022", + "T-023", + "T-024", + "T-025" + ], + "evidence": [ + "docs.meshy.ai/en/api/creative-lab-figure (prototype image_urls; build glb/obj/mtl + texture base_color; webapp prototypes 404)" + ], + "intentional_differences": [], + "external_verification": "required_when_applicable_not_run" + }, + { + "id": "CAP-010", + "title": "Creative Lab Lamp", + "area": "creative-lab.lamp", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Creative Lab API", + "target": "meshy creative-lab lamp prototype|build ", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-022", + "T-023", + "T-024", + "T-025" + ], + "evidence": [ + "docs.meshy.ai/en/api/creative-lab-lamp (image_subject; prototype model_urls.glb; build options/output stl|zip; lamp_stl/base_stl/bundle_zip)", + "meshyd pkg/model/task_creative_lab_lamp.go artifact keys and enums" + ], + "intentional_differences": [], + "external_verification": "required_when_applicable_not_run" + }, + { + "id": "CAP-011", + "title": "Creative Lab Keychain", + "area": "creative-lab.keychain", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Creative Lab API", + "target": "meshy creative-lab keychain prototype|build ", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-022", + "T-023", + "T-024", + "T-025" + ], + "evidence": [ + "docs.meshy.ai/en/api/creative-lab-keychain (relief options; output glb|obj|zip; obj is a ZIP bundle)", + "meshyd pkg/model/task_creative_lab_keychain.go" + ], + "intentional_differences": [], + "external_verification": "required_when_applicable_not_run" + }, + { + "id": "CAP-012", + "title": "Creative Lab Fridge Magnet", + "area": "creative-lab.fridge-magnet", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Creative Lab API", + "target": "meshy creative-lab fridge-magnet prototype|build ", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-022", + "T-023", + "T-024", + "T-025" + ], + "evidence": [ + "docs.meshy.ai/en/api/creative-lab-fridge-magnet (own defaults: rounded-rect, 60 mm, 3.3 mm relief, 2.0 mm base)", + "meshyd pkg/model/task_creative_lab_fridge_magnet.go" + ], + "intentional_differences": [], + "external_verification": "required_when_applicable_not_run" + }, + { + "id": "CAP-013", + "title": "动画目录", + "area": "animation-catalog", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "pipelines.md:/web/public/animations/resources", + "target": "meshy animation-catalog list", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-026" + ], + "evidence": [ + "Skill pipelines.md: GET /web/public/animations/resources?category=…; response {result:{total,list}}", + "meshyd api.go AnimationResource + ListAnimationsResourcesRequest (q, category, subCategory)" + ], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-014", + "title": "Enterprise showcases", + "area": "showcases", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "reference.md:Enterprise API", + "target": "meshy showcases list", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-027", + "T-111" + ], + "evidence": [ + "docs.meshy.ai/en/api/enterprise-api (1 credit per request; page_size 1-10; sort_by; format; showcase_type)", + "meshyd api.go ListShowcasesForOpenAPIRequest: showcase_type oneof all animate static (docs say animated)" + ], + "intentional_differences": [], + "external_verification": "required_when_applicable_not_run" + }, + { + "id": "CAP-015", + "title": "完整资产枚举", + "area": "artifacts", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "pipelines.md:rig/basic_animations/thumbnail", + "target": "download asset registry with role and dependencies", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-060", + "T-062", + "T-071" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-016", + "title": "选择性下载与缩略图", + "area": "selection", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:_cmd_download/_cmd_thumbnail", + "target": "meshy download --asset/--kind/--model-format", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-061", + "T-063" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-017", + "title": "下载最终路径与中断恢复", + "area": "download", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:download; CLI existing download.ts", + "target": "safe download with actual final-path manifest", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-064", + "T-065", + "T-066", + "T-067", + "T-068", + "T-069", + "T-070" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-018", + "title": "完整 JSON 保存", + "area": "save-json", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:_cmd_get/_cmd_poll", + "target": "--save-json and project task snapshots", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-010", + "T-063" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-019", + "title": "项目初始化", + "area": "project.init", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:get_project_dir", + "target": "meshy project init", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-072", + "T-077" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-020", + "title": "任务历史记录", + "area": "project.record", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:record_task", + "target": "meshy project record/show", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-073", + "T-074", + "T-075" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-021", + "title": "项目索引与恢复", + "area": "project.index", + "mandatory": true, + "phase": "S1", + "task": "C04", + "source_hint": "meshy_task.py:record_task history.json", + "target": "meshy project list/rebuild-index", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-073", + "T-075", + "T-076", + "T-077" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-022", + "title": "面数检查", + "area": "inspect.faces", + "mandatory": true, + "phase": "S1", + "task": "C05", + "source_hint": "meshy_task.py:_cmd_check_faces", + "target": "meshy inspect faces", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-080", + "T-081", + "T-082" + ], + "evidence": [ + "meshyd httpapi/dto.go: public task DTO has no face_count → unknown semantics required" + ], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-023", + "title": "OBJ 打印变换", + "area": "mesh.prepare-print", + "mandatory": true, + "phase": "S1", + "task": "C06", + "source_hint": "fix_obj.py:fix_obj_for_printing", + "target": "meshy mesh prepare-print", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-083", + "T-084", + "T-085", + "T-086", + "T-087" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-024", + "title": "七种切片器检测", + "area": "slicer.detect", + "mandatory": true, + "phase": "S1", + "task": "C06", + "source_hint": "slicers.py:detect_slicers/SLICER_MAP", + "target": "meshy slicer detect", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-088", + "T-091" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-025", + "title": "切片器启动", + "area": "slicer.open", + "mandatory": true, + "phase": "S1", + "task": "C06", + "source_hint": "slicers.py:open_in_slicer", + "target": "meshy slicer open", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-089", + "T-090", + "T-091", + "T-112" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "required_when_applicable_not_run" + }, + { + "id": "CAP-026", + "title": "环境诊断", + "area": "doctor", + "mandatory": true, + "phase": "S1", + "task": "C07", + "source_hint": "meshy_task.py:_cmd_check_env", + "target": "meshy doctor", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-102", + "T-103" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-027", + "title": "env-file 与凭据优先级", + "area": "config", + "mandatory": true, + "phase": "S1", + "task": "C07", + "source_hint": "meshy_task.py:load_api_key", + "target": "--env-file without automatic dotenv/shell scanning", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-100", + "T-101" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-028", + "title": "现有 Key/OAuth 登录兼容", + "area": "auth", + "mandatory": true, + "phase": "S1", + "task": "C07", + "source_hint": "CLI auth/config/credentials/runtime", + "target": "preserve existing auth commands and token protocol", + "baseline_status": "existing_needs_regression", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-104", + "T-105" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "required_when_applicable_not_run" + }, + { + "id": "CAP-029", + "title": "稳定 JSON 与退出码", + "area": "output", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "CLI task-command/output/errors", + "target": "--output-schema v1; legacy compatibility", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-001", + "T-002", + "T-003", + "T-004", + "T-005", + "T-011" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-030", + "title": "make async 语义", + "area": "make", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "CLI cmd/make.ts:runChain", + "target": "make --async / --stop-after-first", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-041", + "T-042" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-031", + "title": "本地 operation 恢复记录", + "area": "operations", + "mandatory": true, + "phase": "S1", + "task": "C03", + "source_hint": "old workflows persist task ids; new recovery requirement", + "target": "operation journal; never claim server idempotency", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-043", + "T-044", + "T-045", + "T-046", + "T-048" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-032", + "title": "认证 transport 与公共 fetch 隔离", + "area": "transport", + "mandatory": true, + "phase": "S1", + "task": "C02", + "source_hint": "CLI client/index.ts and file-input/download", + "target": "explicit origin and no auth on public/assets", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-031", + "T-032", + "T-105", + "T-106" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-033", + "title": "无 Key/无网络本地运行", + "area": "local-runtime", + "mandatory": true, + "phase": "S1", + "task": "C07", + "source_hint": "CLI runtime.ts/index.ts plus local Skill helpers", + "target": "local runtime; update-check control", + "baseline_status": "missing", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-012", + "T-072", + "T-102" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + }, + { + "id": "CAP-034", + "title": "打包与无 Python 安装", + "area": "packaging", + "mandatory": true, + "phase": "S1", + "task": "C08", + "source_hint": "CLI package.json/bin and Skill install metadata", + "target": "candidate tarball; both bins; Node24; no Python", + "baseline_status": "existing_needs_regression", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-107", + "T-108", + "T-109" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "required_when_applicable_not_run" + }, + { + "id": "CAP-035", + "title": "帮助与能力发现", + "area": "discovery", + "mandatory": true, + "phase": "S1", + "task": "C08", + "source_hint": "CLI root/resources; CLI bundled skill", + "target": "registry-driven help and capability/version metadata", + "baseline_status": "partial", + "implementation_status": "not_started", + "review_status": "not_run", + "test_ids": [ + "T-020", + "T-108" + ], + "evidence": [], + "intentional_differences": [], + "external_verification": "offline_primary" + } + ], + "knowledge_only": [ + { + "id": "KN-001", + "title": "Webhook 网页配置和领域流程知识", + "reason": "No executable webhook management API in the frozen Skill baseline; keep documentation, do not implement a daemon." + } + ], + "deferred_new_scope": [ + { + "id": "DF-001", + "title": "基线 Skill 未使用的新 Creative Lab 产品、Usage API、托管 Agent/MCP/A2A", + "reason": "Do not expand current parity scope based solely on newer server/docs discovery." + } + ], + "implementation": { + "repo": "https://github.com/meshy-dev/meshy-cli", + "branch": "feat/skill-parity-s1", + "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", + "package_version_at_start": "0.2.0", + "updated_at": "2026-09-07T06:26:21.988Z" + } +} diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md new file mode 100644 index 0000000..2ee10f7 --- /dev/null +++ b/docs/skill-parity/decisions.md @@ -0,0 +1,215 @@ +# Skill-parity S1 — design decisions + +Each entry records a choice that is not derivable from the code alone, the evidence +behind it, and what a reviewer should check. IDs are stable; append, do not renumber. + +## D-001 Workspace and toolchain + +- Fresh clone of `meshy-dev/meshy-cli` at `fd94490` (main == plan baseline, no delta), + branch `feat/skill-parity-s1`. Skills baseline `b9db44b` is read-only. +- Node 24.20.0 via fnm (`.node-version` = 24, `engines >=24`); pnpm 11.24.0 via corepack + from `package.json#packageManager`. Baseline `pnpm test` = 363 pass / 0 fail before any change. +- No AGENTS.md / CLAUDE.md exist in the repo; the existing conventions (TypeScript ESM, + Commander, Zod, node:test + tsx, no lint script) are followed. + +## D-002 `--output-schema v1` is a root-parsed global flag + +- Commander parses options declared on the root command wherever they appear in argv + (positional options are disabled), so `meshy text-to-3d get ID --output-schema v1` + and `meshy --output-schema v1 text-to-3d get ID` resolve identically. Verified + empirically on 0.2.0 (`resources --format pretty` renders pretty). +- Existing commands default to `legacy`; new commands (uv-unwrap, creative-lab, + animation-catalog, showcases, download, project, inspect, mesh, slicer, doctor) + always emit v1. Passing `--output-schema legacy` to a v1-only command is a usage + error (exit 2) rather than a silent no-op. +- `--json` keeps its existing priority over `--format`. + +## D-003 Commander parse errors are usage errors (exit 2) + +- Unknown options/commands, missing arguments and invalid choices are routed through + `exitOverride()` into the unified error exit: exit 2, error payload on stdout + (legacy shape or v1 envelope depending on the resolved schema), human line on stderr. +- 0.2.0 exited 1 for these while README already documented `2 usage`; this is a + documented bug fix, listed in migration-notes. +- `--help` / `--version` remain plain text with exit 0. + +## D-004 `get` never fails on task status + +- `get` returning a valid task exits 0 in both schemas (0.2.0 exited 1 for + PENDING/IN_PROGRESS — the probe in the implementation package reproduces it). +- Legacy `get` of a FAILED/CANCELED task keeps exit 1 (existing consumers may rely on + it). v1 `get` is a query: `ok:true`, exit 0, `task.status` carries the server state. +- `wait`/`stream`/sync `create` ending in FAILED/CANCELED exit 1 with the full task in + `result.task`. + +## D-005 `make --async` returns after the first POST + +- `--async` submits step 1 and returns `accepted` + `pending_steps`; zero polling. +- The previous behaviour (poll step 1, then stop) is available as `--stop-after-first`. + Both flags together are a usage error before any request. +- Default sync behaviour is unchanged (text: preview → refine; image: textured). + +## D-006 Registry-driven resources; `rigging list` enabled + +- `src/client/resource-registry.ts` is the single source for command paths, API + base, relative path, supported verbs, billing and media fields. `docs/skill-parity/ + endpoint-contracts.json` documents the same data and a test asserts they agree. +- 0.2.0 marked `rigging list` unsupported. The official rigging page lists + `GET /openapi/v1/rigging` and the server route table registers it, so `list` is + enabled. Recorded as an intentional difference from 0.2.0, not from the Skills. + +## D-007 `showcases --showcase-type animated` is sent as `animate` + +- The public docs and the Skill reference say `all | animated | static`; the server + binding (checked read-only, meshyd `ListShowcasesForOpenAPIRequest`) accepts + `all | animate | static` and would reject `animated` with 400. +- The CLI accepts both spellings, sends `animate`, and adds a warning + `showcase_type_alias` so the translation is visible. Live verification is not_run. + +## D-008 Animation catalog search is local + +- The frozen Skill baseline only uses `?category=`. The server also accepts `q` and + `subCategory`, but those are outside the baseline and are not exposed in S1. +- `--search` filters the fetched batch case-insensitively over `name`, `key` and + `subCategory`; the result says `search_scope: "local"` so nobody mistakes it for a + server-side search. No pagination exists on this endpoint (`total` == list length). + +## D-009 `face_count` is unknown unless the server sends it + +- The public task DTO has no `face_count` field (meshyd `httpapi/dto.go`, read-only + check). `inspect faces --resource … --task-id …` therefore usually yields + `verdict: unknown`, exit 13. The legacy `check-faces` defaulted a missing value to 0 + and printed a passing line for a model it never measured; that is not reproduced. +- `--task-json` files that carry `face_count` (e.g. saved tasks from a future server + version, or fixtures) produce pass/fail normally. + +## D-010 Operation journal + +- Location: `/operations/.json`, + private mode, written atomically under a lock (`operations/locks/`). Tests inject a + root through the existing `MESHY_CONFIG_DIR` variable — no new env var. +- States: `started` → `accepted | rejected | unknown | not_submitted`. Records hold + resource, endpoint, API origin, credential fingerprint (sha256 of profile/origin, + never the key), payload fingerprint (sha256 of canonical JSON with media data URIs + replaced by their length), timestamps and, when known, task id / request id. +- A second invocation with the same `--operation-id` and identical fingerprints returns + the stored record without a new POST; a mismatch is `operation_conflict` (exit 2). + This is a local record only and is never described as server idempotency. + +## D-011 Exclusive file publish + +- No-overwrite writes go to a temp file in the target directory, then + `fs.linkSync(tmp, target)` (atomic EEXIST failure on POSIX and NTFS) followed by + unlinking the temp. Where hard links are unsupported (EPERM/ENOTSUP/EXDEV) the + fallback opens the target with `wx` and copies — still exclusive, not atomic, and + reported in `warnings`. +- `--overwrite` uses `rename` over a target that `lstat` reports as a regular file; + directories and symlinks are never replaced. + +## D-012 Multi-file downloads are per-file atomic with a manifest + +- Every asset is published individually; the envelope lists each file with + `status: written | skipped | failed`. A failure after some files were written + returns `ok:false` with the completed list — no rollback deletes user data and no + cross-file transaction is claimed. + +## D-013 Credential origin policy + +- Stored profiles (OAuth or API key from `credentials*.json`) are sent only to the v1 + origin they were resolved for, to the v2 origin, and to a creative-lab base whose + origin equals the v1 origin. A creative-lab override on a different origin requires + an explicit key (`--api-key`, `MESHY_API_KEY`, or `--env-file`). +- Pre-existing behaviour: a `--base-url-v2` on a different origin than v1 already + receives the stored profile. Unchanged in S1 and listed as a compatibility + difference for review. +- Public catalog, media preflight and asset downloads use a transport with no + Authorization header and refuse cross-origin redirects. + +## D-014 Update-check policy + +- The background npm check is skipped when `--no-update-check` is given, when the + existing `MESHY_CLI_NO_UPDATE_NOTIFIER` / CI variables are set, for local commands + (`resources`, `project`, `inspect`, `mesh`, `slicer`, `doctor`, `download` from a + local source, `animation-catalog`), and for `make --dry-run`. The decision is taken + from argv before `refreshCache()` runs. + +## D-015 SSE handling + +- Parser follows the WHATWG EventSource algorithm: UTF-8 across chunks, CR/LF/CRLF + line endings, multi-line `data:` joined with `\n`, comments ignored, `event`, `id`, + `retry` fields honoured for bookkeeping only. +- `event: message` → task; `event: error` → `{message,status_code}` mapped like an + HTTP status; other event names → warning. No reconnect in S1; recovery is `get` or + `wait`. +- `--timeout` is the total deadline; `--idle-timeout` (default 60 s) is reset by any + bytes including keep-alives. Terminal status aborts the reader immediately. + +## D-016 `--env-file` + +- Only `MESHY_API_KEY` is read. Grammar: optional `export `, `KEY=value`, `#` comments, + blank lines, single/double quotes (inner text verbatim), unquoted `#` after + whitespace starts a comment. `${…}`, backticks and `$(…)` are never expanded; a key + containing them is invalid. Duplicate `MESHY_API_KEY` lines are an error. +- An unreadable or malformed explicit file is an error even when a higher-priority key + is present. No `.env` auto-discovery, ever. + +## D-017 Timeouts + +- `requestJson` keeps the deadline armed until the body is fully consumed. +- `MESHY_CONNECT_TIMEOUT_MS` is still read for compatibility but fetch offers no + separate connect timeout; the README says so instead of claiming one. + +## D-018 Engineering limits + +- Media 50 MiB, task JSON 16 MiB, SSE event 1 MiB, download/OBJ 2 GiB, 5 redirects, + preflight 10 s, download 300 s, lock wait 10 s. Enforced while reading, never by + truncation. Tests inject smaller values through function parameters. + +## D-019 v1 envelope identity + +- `schema_version: "meshy.cli/v1"`. `command` is the dotted command path plus the verb + (`text-to-3d.get`, `creative-lab.figure.prototype.create`, `animation-catalog.list`, + `project.record`, `inspect.faces`, `mesh.prepare-print`, `slicer.open`, `doctor`). +- Fixed keys: `schema_version, command, ok, result, error, warnings`. Stream ndjson adds + `event` and `sequence`. + +## D-020 Tests build `dist/` first + +- Subprocess tests spawn `dist/index.js` (the existing pattern in runtime.test.ts). + A `pretest` script runs `tsc` so `pnpm test` always exercises the current sources. + +## D-021 Project store + +- `metadata.json` gains `schema_version: 2` plus `resource`, `endpoint`, + `parent_task_id`, `status`, `task_json`, `operation_id` per task; legacy files + without `schema_version` are read as v1, migrated on first write with a + `metadata.json.bak-` copy, unknown fields preserved. +- `history.json` keeps `{version: 1, projects: [...]}` and is an index only; + `rebuild-index` regenerates it from the project folders. The CLI download `meta.json` + is a third format and is never written to either of the other two paths. +- Locking: project lock (`/.meshy.lock`) → commit metadata → release → root + lock (`/.meshy-history.lock`) → update index. Never nested the other way. + +## D-022 OBJ transform + +- Two passes over the file: pass 1 computes the rotated bounding box, pass 2 rewrites + lines through a stream. Default output is `.print.obj` beside the input; + `--in-place` is an explicit replacement via temp file + rename; `--output` to another + directory requires the referenced `mtllib` files to be copied alongside (done for + local relative references) or `--geometry-only`. +- Numbers use the fixture oracle tolerance `1e-5 mm + 1e-9 * height`. + +## D-023 Slicers + +- Seven registered slicers with the legacy `multicolor` capability flag. macOS checks + `/Applications` and `~/Applications` bundles; Windows checks `%ProgramFiles%` and + `%ProgramFiles(x86)%` including glob suffixes; Linux checks PATH for the three + registered executables only (others report `unsupported_on_platform`). +- `open` spawns the detected path with `shell: false`, detached, and reports + `launch_requested` + pid. No default-application fallback, no shell strings. + +## D-024 doctor + +- Default is fully local: versions, command inventory, config sources present (without + reading secrets), workspace writability. `--check-api` performs one `GET /balance`; + `--check-slicers` runs detection. Nothing else is contacted. diff --git a/docs/skill-parity/endpoint-contracts.json b/docs/skill-parity/endpoint-contracts.json new file mode 100644 index 0000000..36d658e --- /dev/null +++ b/docs/skill-parity/endpoint-contracts.json @@ -0,0 +1,120 @@ +{ + "schema_version": 1, + "title": "Meshy CLI S1 endpoint contracts (frozen 2026-09-07)", + "notes": [ + "This file is documentation of the frozen contract. src/client/resource-registry.ts is the executable source; tests/resource-registry.test.ts asserts both agree.", + "billing 'may-charge' means the operation can consume credits on success; 'none' means the request is free. automaticRetry is false everywhere in S1.", + "Relative paths are appended to the base URL of their API family. Default bases: v1 https://api.meshy.ai/openapi/v1, v2 https://api.meshy.ai/openapi/v2, creative-lab /openapi/creative-lab, public-web /web/public." + ], + "bases": { + "v1": { "default": "https://api.meshy.ai/openapi/v1", "env": "MESHY_BASE_URL_V1", "flag": "--base-url-v1", "auth": "bearer" }, + "v2": { "default": "https://api.meshy.ai/openapi/v2", "env": "MESHY_BASE_URL_V2", "flag": "--base-url-v2", "auth": "bearer" }, + "creative-lab": { "default": "derived: /openapi/creative-lab; explicit override required when v1 is a custom proxy path", "env": "MESHY_BASE_URL_CREATIVE_LAB", "flag": "--base-url-creative-lab", "auth": "bearer", "stored_profile_policy": "stored OAuth/API-key profiles are only sent when the creative-lab origin equals the v1 origin; a different origin requires an explicit --api-key / MESHY_API_KEY / --env-file key" }, + "public-web": { "default": "derived: /web/public", "auth": "none", "notes": "never carries Authorization or cookies" } + }, + "task_resources": [ + { "id": "text-to-3d", "commandPath": ["text-to-3d"], "base": "v2", "relativePath": "/text-to-3d", "legacyEndpoint": "/openapi/v2/text-to-3d", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "texture_image_url", "kind": "image", "many": false } ], "taskTypes": ["text-to-3d-preview", "text-to-3d-refine"], "billing": { "create": "may-charge" } }, + { "id": "image-to-3d", "commandPath": ["image-to-3d"], "base": "v1", "relativePath": "/image-to-3d", "legacyEndpoint": "/openapi/v1/image-to-3d", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false } ], "taskTypes": ["image-to-3d"], "billing": { "create": "may-charge" } }, + { "id": "multi-image-to-3d", "commandPath": ["multi-image-to-3d"], "base": "v1", "relativePath": "/multi-image-to-3d", "legacyEndpoint": "/openapi/v1/multi-image-to-3d", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_urls", "kind": "image", "many": true } ], "taskTypes": ["multi-image-to-3d"], "billing": { "create": "may-charge" } }, + { "id": "remesh", "commandPath": ["remesh"], "base": "v1", "relativePath": "/remesh", "legacyEndpoint": "/openapi/v1/remesh", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["remesh"], "billing": { "create": "may-charge" } }, + { "id": "convert", "commandPath": ["convert"], "base": "v1", "relativePath": "/convert", "legacyEndpoint": "/openapi/v1/convert", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["convert"], "billing": { "create": "may-charge" } }, + { "id": "resize", "commandPath": ["resize"], "base": "v1", "relativePath": "/resize", "legacyEndpoint": "/openapi/v1/resize", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["resize"], "billing": { "create": "may-charge" } }, + { "id": "rigging", "commandPath": ["rigging"], "base": "v1", "relativePath": "/rigging", "legacyEndpoint": "/openapi/v1/rigging", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false, "formats": ["glb"] }, { "path": "texture_image_url", "kind": "image", "many": false } ], "taskTypes": ["rig"], "billing": { "create": "may-charge" }, "notes": "CLI 0.2.0 marked list unsupported; official docs (rigging page) and the server route table both provide GET /rigging, so list is enabled in S1 (see decisions D-006)." }, + { "id": "animate", "commandPath": ["animate"], "base": "v1", "relativePath": "/animations", "legacyEndpoint": "/openapi/v1/animations", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["animation"], "billing": { "create": "may-charge" } }, + { "id": "retexture", "commandPath": ["retexture"], "base": "v1", "relativePath": "/retexture", "legacyEndpoint": "/openapi/v1/retexture", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false }, { "path": "image_style_url", "kind": "image", "many": false }, { "path": "multiview_image_urls", "kind": "image", "many": true } ], "taskTypes": ["retexture"], "billing": { "create": "may-charge" } }, + { "id": "text-to-image", "commandPath": ["text-to-image"], "base": "v1", "relativePath": "/text-to-image", "legacyEndpoint": "/openapi/v1/text-to-image", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["text-to-image"], "billing": { "create": "may-charge" } }, + { "id": "text-to-motion", "commandPath": ["text-to-motion"], "base": "v1", "relativePath": "/text-to-motion", "legacyEndpoint": "/openapi/v1/text-to-motion", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["text-to-motion"], "billing": { "create": "may-charge" } }, + { "id": "image-to-image", "commandPath": ["image-to-image"], "base": "v1", "relativePath": "/image-to-image", "legacyEndpoint": "/openapi/v1/image-to-image", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "reference_image_urls", "kind": "image", "many": true } ], "taskTypes": ["image-to-image"], "billing": { "create": "may-charge" } }, + { "id": "multi-color-print", "commandPath": ["multi-color-print"], "base": "v1", "relativePath": "/print/multi-color", "legacyEndpoint": "/openapi/v1/print/multi-color", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["print-multi-color"], "billing": { "create": "may-charge" } }, + { "id": "analyze-printability", "commandPath": ["analyze-printability"], "base": "v1", "relativePath": "/print/analyze", "legacyEndpoint": "/openapi/v1/print/analyze", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["print-analyze"], "billing": { "create": "none" }, "notes": "free endpoint (0 credits) but still creates a server task; never retried automatically" }, + { "id": "repair-printability", "commandPath": ["repair-printability"], "base": "v1", "relativePath": "/print/repair", "legacyEndpoint": "/openapi/v1/print/repair", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false } ], "taskTypes": ["print-repair"], "billing": { "create": "may-charge" } }, + { "id": "uv-unwrap", "commandPath": ["uv-unwrap"], "base": "v1", "relativePath": "/uv-unwrap", "legacyEndpoint": "/openapi/v1/uv-unwrap", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "model_url", "kind": "model", "many": false, "formats": ["glb"] } ], "taskTypes": ["uv-unwrap"], "billing": { "create": "may-charge" }, "create_rules": { "sources": ["input_task_id", "model_url"], "exclusive": true, "server_precedence_if_both": "input_task_id (CLI refuses both instead of relying on it)", "model_format": "glb only", "face_ceiling": 40000, "rollout": "docs describe accounts gated by a Statsig flag receiving 404; the CLI reports 404 as not_found without guessing the cause" } }, + { "id": "creative-lab.figure.prototype", "commandPath": ["creative-lab", "figure", "prototype"], "base": "creative-lab", "relativePath": "/figure/v1/prototype", "legacyEndpoint": "/openapi/creative-lab/figure/v1/prototype", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false, "formats": ["jpg", "jpeg", "png", "webp"] } ], "taskTypes": ["creative-lab-figure-prototype"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.figure.build", "commandPath": ["creative-lab", "figure", "build"], "base": "creative-lab", "relativePath": "/figure/v1/build", "legacyEndpoint": "/openapi/creative-lab/figure/v1/build", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["creative-lab-figure-build"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.lamp.prototype", "commandPath": ["creative-lab", "lamp", "prototype"], "base": "creative-lab", "relativePath": "/lamp/v1/prototype", "legacyEndpoint": "/openapi/creative-lab/lamp/v1/prototype", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false, "formats": ["jpg", "jpeg", "png", "webp"] } ], "taskTypes": ["creative-lab-lamp-prototype"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.lamp.build", "commandPath": ["creative-lab", "lamp", "build"], "base": "creative-lab", "relativePath": "/lamp/v1/build", "legacyEndpoint": "/openapi/creative-lab/lamp/v1/build", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["creative-lab-lamp-build"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.keychain.prototype", "commandPath": ["creative-lab", "keychain", "prototype"], "base": "creative-lab", "relativePath": "/keychain/v1/prototype", "legacyEndpoint": "/openapi/creative-lab/keychain/v1/prototype", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false, "formats": ["jpg", "jpeg", "png", "webp"] } ], "taskTypes": ["creative-lab-keychain-prototype"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.keychain.build", "commandPath": ["creative-lab", "keychain", "build"], "base": "creative-lab", "relativePath": "/keychain/v1/build", "legacyEndpoint": "/openapi/creative-lab/keychain/v1/build", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["creative-lab-keychain-build"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.fridge-magnet.prototype", "commandPath": ["creative-lab", "fridge-magnet", "prototype"], "base": "creative-lab", "relativePath": "/fridge-magnet/v1/prototype", "legacyEndpoint": "/openapi/creative-lab/fridge-magnet/v1/prototype", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false, "formats": ["jpg", "jpeg", "png", "webp"] } ], "taskTypes": ["creative-lab-fridge-magnet-prototype"], "billing": { "create": "may-charge" } }, + { "id": "creative-lab.fridge-magnet.build", "commandPath": ["creative-lab", "fridge-magnet", "build"], "base": "creative-lab", "relativePath": "/fridge-magnet/v1/build", "legacyEndpoint": "/openapi/creative-lab/fridge-magnet/v1/build", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["creative-lab-fridge-magnet-build"], "billing": { "create": "may-charge" } } + ], + "creative_lab_products": { + "figure": { + "prototype": { + "request": { "image_url": { "type": "string", "required": true, "media": "image jpg/jpeg/png/webp URL or data URI" }, "name": { "type": "string", "maxLength": 100 }, "remove_background": { "type": "boolean", "default": false } }, + "response": { "image_urls": "array of 1 concept image URL", "consumed_credits": "integer", "type": "creative-lab-figure-prototype" }, + "meaning": "styled concept image (2D)" + }, + "build": { + "request": { "input_task_id": { "type": "string(uuid)", "required": true, "rule": "SUCCEEDED prototype of the same product created through this API with the same key; web-app prototypes return 404" }, "name": { "type": "string", "maxLength": 100 } }, + "response": { "model_urls": ["glb", "obj", "mtl"], "thumbnail_url": "string", "texture_urls": "[{ base_color }]", "type": "creative-lab-figure-build" }, + "assets": "independent GLB, OBJ, MTL and base-color texture; OBJ depends on MTL + texture" + }, + "pricing_evidence": { "skill_reference": "prototype 6 / build 30", "official_figure_page": "prototype 6 / build 20", "status": "conflicting sources; the CLI does not price Creative Lab" } + }, + "lamp": { + "prototype": { + "request": { "image_url": { "type": "string", "required": true }, "image_subject": { "type": "string", "enum": ["character", "landscape"], "default": "character" }, "name": { "type": "string", "maxLength": 100 }, "remove_background": { "type": "boolean", "default": false }, "text": { "deprecated": true, "cli": "rejected before submission" } }, + "response": { "image_urls": "array of 1 concept image URL", "model_urls": ["glb"], "thumbnail_url": "string", "type": "creative-lab-lamp-prototype" }, + "meaning": "concept image AND a hollow matte-white lampshade GLB" + }, + "build": { + "request": { "input_task_id": { "type": "string(uuid)", "required": true }, "name": { "type": "string", "maxLength": 100 }, "options": { "diameter_mm": { "type": "number", "range": "[50,400]", "default": 150 }, "thickness_mm": { "type": "number", "range": "(0,10]", "default": 1 }, "cut_amount_percent": { "type": "number", "range": "[1,100]", "default": 1 }, "light_source_preset": { "type": "string", "enum": ["bambu_mh001_60mm", "none"], "default": "bambu_mh001_60mm" }, "fixture_offset_x_mm": { "type": "number", "range": "[-80,80]", "default": 0 }, "fixture_offset_z_mm": { "type": "number", "range": "[-80,80]", "default": 0 }, "rotate_x_deg": { "type": "number", "range": "[-360,360]", "default": 0 }, "rotate_y_deg": { "type": "number", "range": "[-360,360]", "default": 0 }, "rotate_z_deg": { "type": "number", "range": "[-360,360]", "default": 0 }, "include_result_json": { "type": "boolean", "default": false, "rule": "requires output.format=zip" } }, "output": { "format": { "type": "string", "enum": ["stl", "zip"], "default": "stl" } } }, + "response": { "model_urls": { "lamp_stl": "stl (format=stl)", "base_stl": "stl (format=stl and light_source_preset != none)", "bundle_zip": "zip (format=zip)" }, "type": "creative-lab-lamp-build" }, + "assets": "part keys are not file extensions: lamp_stl/base_stl are STL files, bundle_zip is a ZIP" + } + }, + "keychain": { + "prototype": { + "request": { "image_url": { "type": "string", "required": true }, "name": { "type": "string", "maxLength": 100 }, "remove_background": { "type": "boolean", "default": false } }, + "response": { "image_urls": "array of 1 concept image URL", "type": "creative-lab-keychain-prototype" } + }, + "build": { + "request": { "input_task_id": { "type": "string(uuid)", "required": true }, "name": { "type": "string", "maxLength": 100 }, "options": { "badge_shape": { "enum": ["circle", "rounded-rect", "hexagon", "shield", "star"], "default": "circle" }, "size_mm": { "range": "(0,400]", "default": 40 }, "relief_height_mm": { "range": "[0,20]", "default": 2.2 }, "relief_offset_mm": { "range": "[0,20]", "default": 0 }, "base_thickness_mm": { "range": "[0,20]", "default": 0.1 }, "has_closed_back": { "type": "boolean", "default": true }, "relief_curve": { "enum": ["linear", "gamma", "s-curve"], "default": "linear" }, "curve_param": { "range": "(0,10]", "default": 1.0 }, "invert_depth": { "type": "boolean", "default": false }, "smoothing": { "range": "[0,10]", "default": 0.24 }, "relief_scale": { "range": "(0,10]", "default": 1.0 }, "depth_threshold": { "range": "[0,1]", "default": 0.1 }, "remove_background": { "type": "boolean", "default": true, "note": "distinct from the prototype remove_background" }, "export_resolution": { "type": "integer", "range": "[64,2048]", "default": 512 } }, "output": { "format": { "enum": ["glb", "obj", "zip"], "default": "glb" } } }, + "response": { "model_urls": { "glb": "glb (format=glb)", "obj": "ZIP container holding model.obj + model.mtl + texture.png (format=obj)", "bundle_zip": "zip (format=zip)" }, "type": "creative-lab-keychain-build" } + } + }, + "fridge-magnet": { + "prototype": { + "request": { "image_url": { "type": "string", "required": true }, "name": { "type": "string", "maxLength": 100 }, "remove_background": { "type": "boolean", "default": false } }, + "response": { "image_urls": "array of 1 concept image URL", "type": "creative-lab-fridge-magnet-prototype" } + }, + "build": { + "request": { "input_task_id": { "type": "string(uuid)", "required": true }, "name": { "type": "string", "maxLength": 100 }, "options": { "badge_shape": { "enum": ["circle", "rounded-rect", "hexagon", "shield", "star"], "default": "rounded-rect" }, "size_mm": { "range": "(0,400]", "default": 60 }, "relief_height_mm": { "range": "[0,20]", "default": 3.3 }, "relief_offset_mm": { "range": "[0,20]", "default": 0 }, "base_thickness_mm": { "range": "[0,20]", "default": 2.0 }, "has_closed_back": { "type": "boolean", "default": true }, "relief_curve": { "enum": ["linear", "gamma", "s-curve"], "default": "linear" }, "curve_param": { "range": "(0,10]", "default": 1.0 }, "invert_depth": { "type": "boolean", "default": false }, "smoothing": { "range": "[0,10]", "default": 0.24 }, "relief_scale": { "range": "(0,10]", "default": 1.0 }, "depth_threshold": { "range": "[0,1]", "default": 0.1 }, "remove_background": { "type": "boolean", "default": true }, "export_resolution": { "type": "integer", "range": "[64,2048]", "default": 512 } }, "output": { "format": { "enum": ["glb", "obj", "zip"], "default": "glb" } } }, + "response": { "model_urls": { "glb": "glb (format=glb)", "obj": "ZIP container holding model.obj + model.mtl + texture.png (format=obj)", "bundle_zip": "zip (format=zip)" }, "type": "creative-lab-fridge-magnet-build" } + } + } + }, + "query_resources": [ + { "id": "balance", "commandPath": ["balance"], "base": "v1", "method": "GET", "relativePath": "/balance", "auth": "bearer", "billing": "none", "response": { "balance": "number" } }, + { "id": "animation-catalog", "commandPath": ["animation-catalog", "list"], "base": "public-web", "method": "GET", "relativePath": "/animations/resources", "auth": "none", "billing": "none", "query": { "category": "string (WalkAndRun | BodyMovements | DailyActions | Fighting | Dancing); verified in the frozen Skill baseline" }, "cli_only": { "search": "case-insensitive local match on name/key/subCategory over the fetched batch; not a server-side search" }, "response": { "result": { "total": "integer", "list": [ { "id": "integer (= action_id)", "key": "string", "name": "string", "category": "string", "subCategory": "string", "previewUrl": "string", "rigType": "string", "isDefault": "boolean", "isFree": "boolean" } ] } }, "pagination": "none observed; total equals list length" }, + { "id": "showcases", "commandPath": ["showcases", "list"], "base": "v1", "method": "GET", "relativePath": "/showcases", "auth": "bearer", "billing": "may-charge (1 credit per request; Enterprise tier only, 403 otherwise)", "automaticRetry": false, "query": { "page_size": { "type": "integer", "range": "[1,10]", "default": 3 }, "sort_by": { "enum": ["+created_at", "-created_at", "+updated_at", "-updated_at", "+downloads", "-downloads"], "default": "-created_at" }, "search": "string", "format": { "enum": ["glb", "fbx", "obj", "usdz"], "default": "glb", "cli_flag": "--model-format" }, "showcase_type": { "server_enum": ["all", "animate", "static"], "docs_enum": ["all", "animated", "static"], "cli": "accepts both; 'animated' is sent as 'animate' with a warning (decisions D-007)" } }, "response": { "result": "array of items: id, result_id, name, object_prompt, style_prompt, neg_prompt, art_style, resolution, seed, texture_richness, ai_model, model_url, conversion_status ('' | success | failed), community_url, thumbnail_url, solid_thumbnail_url, alpha_thumbnail_url, alpha_solid_thumbnail_url, image_urls, categories, tags, mode, topology, created_at, updated_at (items are passed through unchanged)" } } + ], + "local_tools": [ + { "commandPath": ["download"], "network": "asset GET only (no Authorization); one task GET when --resource/--task-id is used" }, + { "commandPath": ["project", "init|record|show|list|rebuild-index"], "network": "none" }, + { "commandPath": ["inspect", "faces"], "network": "one task GET when --resource/--task-id is used" }, + { "commandPath": ["mesh", "prepare-print"], "network": "none" }, + { "commandPath": ["slicer", "detect|open"], "network": "none" }, + { "commandPath": ["doctor"], "network": "none by default; --check-api performs one free GET /balance" } + ], + "sse_protocol": { + "request": { "method": "GET", "path": "//stream", "headers": { "Accept": "text/event-stream", "Authorization": "Bearer " } }, + "events": { + "message": "data is the same task JSON as GET; re-emitted every ~10s as keep-alive even without progress change", + "error": "data is {\"message\": string, \"status_code\": integer}; may arrive after HTTP 200; status_code is mapped like an HTTP status (404 -> not_found exit 5, 400 -> validation exit 4, 500 -> server exit 1)", + "comment lines (':')": "ignored; treated as heartbeat for idle-timeout purposes", + "unknown event names": "reported as a warning, never treated as task success" + }, + "termination": "server closes after the first terminal message (SUCCEEDED/FAILED/CANCELED); the CLI also aborts the reader itself on terminal status", + "reconnect": "not implemented in S1; recovery is `get`/`wait` with the same task id" + }, + "task_view_fields": ["task_id", "resource", "endpoint", "type", "name", "status", "progress", "preceding_tasks", "created_at", "started_at", "finished_at", "expires_at", "face_count", "consumed_credits", "model_urls", "image_urls", "texture_urls", "thumbnail_url", "thumbnail_urls", "alpha_thumbnail_url", "result", "printability", "task_error"], + "task_view_notes": [ + "face_count: the public task DTO does not include face_count (meshyd httpapi/dto.go, checked 2026-09-07); it is null unless the server sends it. The legacy check-faces treated the missing field as 0 and could pass a model it never measured.", + "consumed_credits: optional integer; null when absent; 0 is preserved as 0.", + "thumbnail_urls: object keyed by view (front/right/back/left) when multi_view_thumbnails was requested." + ], + "exit_codes": { "0": "ok", "1": "task_failed / unclassified", "2": "usage", "3": "auth", "4": "validation", "5": "not_found", "6": "rate_limit", "7": "network", "8": "timed_out", "9": "credit", "10": "submission_unknown", "11": "local_io", "12": "check_failed", "13": "check_unknown", "130": "interrupted" }, + "limits": { "media_file_bytes": 52428800, "task_json_bytes": 16777216, "sse_event_bytes": 1048576, "download_bytes": 2147483648, "obj_bytes": 2147483648, "redirects_max": 5, "media_preflight_ms": 10000, "download_ms": 300000, "lock_wait_ms": 10000, "note": "engineering defaults, not Meshy product limits; tests may inject smaller values" } +} diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md new file mode 100644 index 0000000..d28add7 --- /dev/null +++ b/docs/skill-parity/migration-notes.md @@ -0,0 +1,117 @@ +# Skill-parity S1 — migration notes + +Mapping from the legacy Python helpers bundled with the Meshy Skills +(`meshy_task.py`, `fix_obj.py`, `slicers.py` at `b9db44b`) to `meshy-cli`, plus every +intentional difference. S2 (Skill rewrite) must use `--output-schema v1 --format json` +explicitly in its examples. + +## 1. Command mapping + +| Legacy call | CLI equivalent | Notes | +| --- | --- | --- | +| `meshy_task.py check-env` | `meshy doctor [--env-file .env] [--check-api]` | Local by default; `--check-api` does one free balance call. No `.env` auto-scan (see 2.6). | +| `meshy_task.py balance` | `meshy balance --output-schema v1` | unchanged endpoint | +| `meshy_task.py create --endpoint E --payload J` | `meshy create --data '' --async --output-schema v1` | one POST, no poll; `result.task.task_id` | +| `meshy_task.py poll --endpoint E --task-id ID [--project-dir D]` | `meshy wait ID --timeout 600 [--project D] --output-schema v1` | saves `task_.json` into the project when `--project` is given | +| `meshy_task.py get --endpoint E --task-id ID [--save F]` | `meshy get ID [--save-json F] [--include-raw] --output-schema v1` | non-terminal status exits 0 | +| SSE curl/Python examples | `meshy stream ID --format ndjson --output-schema v1` | new | +| `meshy_task.py download --task-json F --format glb --output P` | `meshy download --task-json F --model-format glb --output P --output-schema v1` | selection is explicit | +| `meshy_task.py download --url U --output P` | `meshy download --url U --output P --output-schema v1` | no Authorization is sent to asset hosts | +| nested `result.basic_animations.walking_glb_url` via python | `meshy download --task-json F --asset result.basic_animations.walking_glb_url --output walking.glb` | stable asset keys | +| `meshy_task.py thumbnail --project-dir D --task-json F` | `meshy download --task-json F --kind thumbnail --output-dir D` | fails loudly instead of swallowing errors | +| `meshy_task.py project-dir --task-id ID --prompt P` | `meshy project init --root ./meshy_output --task-id ID --name P --output-schema v1` | folder slug + timestamp + random suffix | +| `meshy_task.py record --project-dir D --task-id ID --task-type T --stage S --files a,b` | `meshy project record --project D --task-id ID --resource T --stage S --file a --file b` | `--file` repeats; `(task_id, stage)` de-duplicates | +| — | `meshy project show / list / rebuild-index` | new; history is a rebuildable index | +| `meshy_task.py check-faces --endpoint E --task-id ID --max-faces N` | `meshy inspect faces --resource R --task-id ID --max-faces N` | `--max-faces` is required; missing face count = `unknown` (exit 13), never 0 | +| `fix_obj.py model.obj --height-mm 75` | `meshy mesh prepare-print model.obj --height-mm 75 [--output F | --in-place]` | default writes `model.print.obj`, no in-place overwrite unless asked | +| `slicers.py detect` | `meshy slicer detect --output-schema v1` | same seven slicers + `multicolor` | +| `slicers.py open --file F --slicer S` | `meshy slicer open --slicer S --file F --output-schema v1` | no default-app fallback; detected path only | +| curl `/web/public/animations/resources?category=C` + python filter | `meshy animation-catalog list --category C --search wave` | no key, no Authorization; search is local | +| curl `/openapi/v1/showcases?...` | `meshy showcases list --search car --page-size 3 --model-format glb` | billable GET, single request, no retry | +| curl `/openapi/v1/uv-unwrap` | `meshy uv-unwrap create --input-task-id ID` or `--model-url m.glb` | exactly one source | +| curl `/openapi/creative-lab/{product}/v1/prototype` | `meshy creative-lab prototype create --image-url photo.png [--name N] [--remove-background] [--image-subject …]` | per-product schema | +| curl `/openapi/creative-lab/{product}/v1/build` | `meshy creative-lab build create --input-task-id ID [--options ''] [--model-format …]` | per-product options | + +## 2. Intentional differences + +Each difference names the legacy behaviour, the CLI behaviour, and the evidence. + +### 2.1 Missing `face_count` is `unknown`, not 0 +- Legacy: `task.get("face_count", 0)` then `0 > max` → prints a passing line. +- CLI: `verdict: unknown`, exit 13, `reason: face_count missing`. Evidence: public task + DTO has no `face_count` (meshyd `httpapi/dto.go`, read-only 2026-09-07); Rigging docs + only describe the server-side 300k gate. + +### 2.2 OBJ transform never overwrites by default +- Legacy `fix_obj.py`: default `output_path = input_path`; degenerate height silently + used scale 1.0; NaN passed through. +- CLI: default sibling `.print.obj`; `--in-place` opt-in; NaN/Infinity, empty or + degenerate (height ≤ 1e-6) input is a validation error and no file is written. + Numbers follow the same rotation/scale/translation formulas (fixture oracle + `box-height-80.expected.json`). + +### 2.3 Slicer launch uses the detected executable +- Legacy Windows path looked up the exe on PATH and fell back to `os.startfile` + (default application); Linux fell back to `xdg-open`. +- CLI: launches only registered slicers at their detected path; missing slicer or + file is an explicit error (exit 11 / 5), no default-application fallback. + +### 2.4 Downloads are selective +- Legacy `download --format` defaulted to `glb`; thumbnails were swallowed on error. +- CLI `download` requires a selector (`--asset`, `--model-format`, `--kind`, `--all`) + when more than one asset exists and reports every failure. The legacy `-o` on task + commands keeps downloading everything for compatibility. + +### 2.5 Keychain / fridge-magnet OBJ output is a ZIP +- Legacy docs generalised "textured GLB / OBJ+MTL" for every build. +- CLI records `model_format: obj, container_format: zip, extracted: false` and saves the + file as `.zip`; lamp parts `lamp_stl`/`base_stl`/`bundle_zip` are saved as `.stl` / + `.zip`. Evidence: official lamp/keychain/fridge-magnet pages and meshyd artifact keys. + +### 2.6 No implicit `.env` discovery +- Legacy: read `.env` / `.env.local` from cwd automatically. +- CLI: `--env-file ` must be explicit; `doctor` may report that a candidate file + exists in cwd but never reads it. Priority: `--api-key` > `MESHY_API_KEY` > + `--env-file` > stored profile; an empty/invalid higher-priority source is an error, + not a fall-through. + +### 2.7 `showcase_type` spelling +- Docs/Skill: `animated`. Server enum: `animate`. CLI accepts both, sends `animate`, and + warns (`showcase_type_alias`). Needs live confirmation (not_run). + +### 2.8 Lamp `text` prototype input is rejected +- The server struct still carries a deprecated `text` field; the CLI rejects `text` in + `--data` for lamp prototypes before submission and points to `--image-url`. + +### 2.9 Rigging `list` +- CLI 0.2.0 said "Meshy does not expose a list endpoint for rigging". Official docs and + the server route table do; `meshy rigging list` now works. (Difference from 0.2.0, + not from the Skills.) + +### 2.10 Data URIs +- CLI 0.2.0 rejected `data:` URIs on typed media flags while accepting them inside + `--data`. S1 accepts well-formed `data:` URIs on both paths with the same size/format + checks (Skill examples build data URIs in Python). + +### 2.11 No automatic retries +- Legacy troubleshooting suggested auto-retrying 429/5xx creates. The CLI never + re-sends a billable POST or the billable showcases GET; retries are the caller's + decision with the recorded task/operation id. + +### 2.12 Webhooks stay documentation +- No webhook management endpoint exists in the frozen baseline; the CLI adds no daemon. + +## 3. Compatibility notes for existing CLI users (legacy schema) + +| Behaviour | 0.2.0 | S1 | Why | +| --- | --- | --- | --- | +| `get` of PENDING/IN_PROGRESS task | exit 1 | exit 0 | a successful query is not a failure | +| `make --async` | POST + poll step 1, then stop | POST only, return `pending_steps` | contract; old behaviour is `--stop-after-first` | +| Commander parse errors | exit 1 | exit 2 | README already documented `2 usage` | +| `rigging list` | usage error | works | see 2.9 | +| everything else (flags, payload defaults, `-o` download layout, `meta.json`, auth) | unchanged | unchanged | legacy output preserved; v1 is opt-in | + +New global flags: `--output-schema`, `--env-file`, `--workspace`, `--no-update-check`, +`--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, +`--include-raw`, `--project`, `--operation-id`, `--stop-after-first` (make), +`--idle-timeout` (stream). diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json new file mode 100644 index 0000000..8747277 --- /dev/null +++ b/docs/skill-parity/verification.json @@ -0,0 +1,25 @@ +{ + "schema_version": 1, + "status": "in_progress", + "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", + "environment": { + "os": "macOS 26.6 (Darwin 25.6.0) arm64", + "node": "v24.20.0", + "pnpm": "11.24.0" + }, + "baseline_runs": [ + { "command": "pnpm install --frozen-lockfile", "sha": "fd94490916376e691efcea51324ac4326b459e1f", "exit_code": 0, "duration_s": 10, "result": "passed" }, + { "command": "pnpm typecheck", "sha": "fd94490916376e691efcea51324ac4326b459e1f", "exit_code": 0, "duration_s": 0, "result": "passed" }, + { "command": "pnpm test", "sha": "fd94490916376e691efcea51324ac4326b459e1f", "exit_code": 0, "duration_s": 41, "result": "passed", "tests": 363, "pass": 363, "fail": 0 }, + { "command": "pnpm build", "sha": "fd94490916376e691efcea51324ac4326b459e1f", "exit_code": 0, "duration_s": 1, "result": "passed" } + ], + "final_runs": [], + "behavior_tests": {}, + "live_verification": [ + { "id": "T-104", "scope": "API Key and OAuth profile regression against a real account", "result": "not_run", "reason": "no credential configured in this session" }, + { "id": "T-109", "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "not_run", "reason": "only macOS arm64 available; recorded per OS when run" }, + { "id": "T-110", "scope": "real free queries, existing task get, asset download", "result": "not_run", "reason": "no credential configured" }, + { "id": "T-111", "scope": "UV Unwrap / Creative Lab / showcases smoke", "result": "not_run", "reason": "billable or account-gated; no budget authorised" }, + { "id": "T-112", "scope": "real slicer open", "result": "not_run", "reason": "no slicer installed on this host" } + ] +} diff --git a/tests/fixtures/skill-parity/box-height-80.expected.json b/tests/fixtures/skill-parity/box-height-80.expected.json new file mode 100644 index 0000000..e1f6e67 --- /dev/null +++ b/tests/fixtures/skill-parity/box-height-80.expected.json @@ -0,0 +1,106 @@ +{ + "fixture_origin": "synthetic independent geometry oracle; not generated from the implementation under test", + "input": "box-y-up.obj", + "height_mm": 80, + "scale": 20, + "rotation": "(x,y,z)->(x,-z,y)", + "translation": [ + 0, + 0, + -40 + ], + "vertices": [ + [ + -20, + 60, + 0 + ], + [ + 20, + 60, + 0 + ], + [ + 20, + 60, + 80 + ], + [ + -20, + 60, + 80 + ], + [ + -20, + -60, + 0 + ], + [ + 20, + -60, + 0 + ], + [ + 20, + -60, + 80 + ], + [ + -20, + -60, + 80 + ] + ], + "normals": [ + [ + 0, + 1, + 0 + ], + [ + 0, + -1, + 0 + ], + [ + -1, + 0, + 0 + ], + [ + 1, + 0, + 0 + ], + [ + 0, + 0, + -1 + ], + [ + 0, + 0, + 1 + ] + ], + "bbox_min": [ + -20, + -60, + 0 + ], + "bbox_max": [ + 20, + 60, + 80 + ], + "face_count": 6, + "vertex_count": 8, + "uv_count": 4, + "preserve_vertex_extra_fields": true, + "preserve_face_vertex_uv_normal_indices": true, + "material_dependency": "box.mtl", + "tolerance": { + "absolute_mm": 0.00001, + "relative_to_height": 1e-9 + } +} diff --git a/tests/fixtures/skill-parity/box-y-up.obj b/tests/fixtures/skill-parity/box-y-up.obj new file mode 100644 index 0000000..9b62019 --- /dev/null +++ b/tests/fixtures/skill-parity/box-y-up.obj @@ -0,0 +1,28 @@ +# Synthetic fixture: Y-up box, extent x[-1,1] y[2,6] z[-3,3] +mtllib box.mtl +o parity_box +v -1 2 -3 1 0 0 +v 1 2 -3 0 1 0 +v 1 6 -3 0 0 1 +v -1 6 -3 1 1 1 +v -1 2 3 1 0 1 +v 1 2 3 0 1 1 +v 1 6 3 1 1 0 +v -1 6 3 0.5 0.5 0.5 +vt 0 0 +vt 1 0 +vt 1 1 +vt 0 1 +vn 0 0 -1 +vn 0 0 1 +vn -1 0 0 +vn 1 0 0 +vn 0 -1 0 +vn 0 1 0 +usemtl test_material +f 1/1/1 4/2/1 3/3/1 2/4/1 +f 5/1/2 6/2/2 7/3/2 8/4/2 +f 1/1/3 5/2/3 8/3/3 4/4/3 +f 2/1/4 3/2/4 7/3/4 6/4/4 +f 1/1/5 2/2/5 6/3/5 5/4/5 +f 4/1/6 8/2/6 7/3/6 3/4/6 diff --git a/tests/fixtures/skill-parity/box.mtl b/tests/fixtures/skill-parity/box.mtl new file mode 100644 index 0000000..48fffc3 --- /dev/null +++ b/tests/fixtures/skill-parity/box.mtl @@ -0,0 +1,3 @@ +# Synthetic, no external textures +newmtl test_material +Kd 0.8 0.8 0.8 diff --git a/tests/fixtures/skill-parity/task-error.synthetic.sse b/tests/fixtures/skill-parity/task-error.synthetic.sse new file mode 100644 index 0000000..4e1cd02 --- /dev/null +++ b/tests/fixtures/skill-parity/task-error.synthetic.sse @@ -0,0 +1,6 @@ +: synthetic HTTP 200 stream whose application result is an error + +event: error +data: {"message":"Synthetic task not found","status_code":404} + + diff --git a/tests/fixtures/skill-parity/task-rigging.synthetic.json b/tests/fixtures/skill-parity/task-rigging.synthetic.json new file mode 100644 index 0000000..65bfd7e --- /dev/null +++ b/tests/fixtures/skill-parity/task-rigging.synthetic.json @@ -0,0 +1,18 @@ +{ + "id": "fixture-rig-1", + "type": "rigging", + "status": "SUCCEEDED", + "progress": 100, + "face_count": 250000, + "consumed_credits": 5, + "created_at": 0, + "finished_at": 1000, + "task_error": null, + "result": { + "rigged_character_glb_url": "https://assets.example.invalid/rigged.glb", + "basic_animations": { + "walking_glb_url": "https://assets.example.invalid/walk.glb", + "running_glb_url": "https://assets.example.invalid/run.glb" + } + } +} From e7ea5776345bede76bd306b2b8488f9ad826f467 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 14:43:28 +0800 Subject: [PATCH 02/26] feat(cli): B01 v1 envelope, exit codes, local runtime, --api-key-file and unified error exit - add the meshy.cli/v1 envelope (result.ts), CliError with the stable code vocabulary and exit codes 10-13/130, classifyError for every thrown type - global flags: --output-schema, --api-key-file, --workspace, --no-update-check, --base-url-creative-lab; Commander parse errors now exit 2 through the same exit as command failures (legacy payload or v1 envelope) - runtime: no cross-call cache, buildLocalRuntime for credential-free commands, schema resolution (legacy default for 0.2.0 commands, v1-only for new ones) - config: --api-key-file (dotenv-style, only MESHY_API_KEY, strict), creative-lab and public-web base derivation, stored-profile origin policy - update check skipped for local commands, dry-run and --no-update-check; update-state cache honours MESHY_CONFIG_DIR - SIGINT becomes a cooperative abort (exit 130); stdout is flushed before exit - balance/api/delete gain v1 output and --save-json (exclusive publish) - atomic-file (link-based exclusive publish) and paths (realpath containment) - tests: env-file grammar, error classification, black-box CLI contract with a loopback API; pretest builds dist so subprocess tests run current sources --env-file is deliberately not the flag name: Node scans the whole argv for it and loads the file itself (decisions D-025). Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/decisions.md | 21 +- docs/skill-parity/migration-notes.md | 16 +- package.json | 3 +- src/client/endpoints/balance.ts | 7 +- src/client/index.ts | 20 +- src/client/resource-registry.ts | 249 +++++++++++++++++++++++ src/cmd/api.ts | 35 +++- src/cmd/balance.ts | 16 +- src/cmd/delete.ts | 18 +- src/index.ts | 137 +++++++++++-- src/internal/atomic-file.ts | 170 ++++++++++++++++ src/internal/command-helpers.ts | 83 ++++++++ src/internal/config.ts | 112 ++++++++++- src/internal/context.ts | 51 +++++ src/internal/env-file.ts | 101 ++++++++++ src/internal/errors.ts | 233 +++++++++++++++++++++- src/internal/global-options.ts | 40 +++- src/internal/output.ts | 40 +++- src/internal/paths.ts | 109 ++++++++++ src/internal/result.ts | 76 +++++++ src/internal/runtime.ts | 107 ++++++++-- src/internal/update-notifier.ts | 10 +- src/root.ts | 67 ++++++- tests/cli-contract.test.ts | 284 +++++++++++++++++++++++++++ tests/client.test.ts | 2 + tests/env-file.test.ts | 81 ++++++++ tests/helpers/cli.ts | 184 +++++++++++++++++ tests/result.test.ts | 96 +++++++++ 28 files changed, 2274 insertions(+), 94 deletions(-) create mode 100644 src/client/resource-registry.ts create mode 100644 src/internal/atomic-file.ts create mode 100644 src/internal/command-helpers.ts create mode 100644 src/internal/context.ts create mode 100644 src/internal/env-file.ts create mode 100644 src/internal/paths.ts create mode 100644 src/internal/result.ts create mode 100644 tests/cli-contract.test.ts create mode 100644 tests/env-file.test.ts create mode 100644 tests/helpers/cli.ts create mode 100644 tests/result.test.ts diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index 2ee10f7..d78e67f 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -118,7 +118,7 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu - Stored profiles (OAuth or API key from `credentials*.json`) are sent only to the v1 origin they were resolved for, to the v2 origin, and to a creative-lab base whose origin equals the v1 origin. A creative-lab override on a different origin requires - an explicit key (`--api-key`, `MESHY_API_KEY`, or `--env-file`). + an explicit key (`--api-key`, `MESHY_API_KEY`, or `--api-key-file`). - Pre-existing behaviour: a `--base-url-v2` on a different origin than v1 already receives the stored profile. Unchanged in S1 and listed as a compatibility difference for review. @@ -144,7 +144,7 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu - `--timeout` is the total deadline; `--idle-timeout` (default 60 s) is reset by any bytes including keep-alives. Terminal status aborts the reader immediately. -## D-016 `--env-file` +## D-016 `--api-key-file` (the contract's `--env-file`) - Only `MESHY_API_KEY` is read. Grammar: optional `export `, `KEY=value`, `#` comments, blank lines, single/double quotes (inner text verbatim), unquoted `#` after @@ -152,6 +152,7 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu containing them is invalid. Duplicate `MESHY_API_KEY` lines are an error. - An unreadable or malformed explicit file is an error even when a higher-priority key is present. No `.env` auto-discovery, ever. +- The flag is named `--api-key-file`, not `--env-file` — see D-025. ## D-017 Timeouts @@ -213,3 +214,19 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu - Default is fully local: versions, command inventory, config sources present (without reading secrets), workspace writability. `--check-api` performs one `GET /balance`; `--check-slicers` runs detection. Nothing else is contacted. + +## D-025 `--env-file` cannot be offered: Node.js intercepts it + +- Verified on Node 22.23.2 and 24.20.0 (`node script.js balance --env-file X`): Node + scans the *whole* argv for `--env-file`, even after the script name. When the file + exists Node loads every variable into `process.env` before the CLI starts + (`NODE_OPTIONS` included, which Node then honours); when it is missing Node exits 9 + with its own message and the CLI never runs. Both contradict the contract ("only + `MESHY_API_KEY`, never executed, no process reconfiguration"). +- Therefore the CLI flag is `--api-key-file ` with exactly the contract's + semantics. `--env-file` stays registered as a hidden option whose only behaviour is a + usage error pointing at `--api-key-file`; the CLI cannot undo what Node already did, + so the error also explains that. S2 Skill examples must use `--api-key-file`. +- Empty or placeholder `--api-key` / `MESHY_API_KEY` values keep meaning "unset" + (0.2.0 behaviour that CI and the runtime tests depend on); only the explicit key + file is strict. diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index d28add7..12a2999 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -9,7 +9,7 @@ explicitly in its examples. | Legacy call | CLI equivalent | Notes | | --- | --- | --- | -| `meshy_task.py check-env` | `meshy doctor [--env-file .env] [--check-api]` | Local by default; `--check-api` does one free balance call. No `.env` auto-scan (see 2.6). | +| `meshy_task.py check-env` | `meshy doctor [--api-key-file .env] [--check-api]` | Local by default; `--check-api` does one free balance call. No `.env` auto-scan (see 2.6). | | `meshy_task.py balance` | `meshy balance --output-schema v1` | unchanged endpoint | | `meshy_task.py create --endpoint E --payload J` | `meshy create --data '' --async --output-schema v1` | one POST, no poll; `result.task.task_id` | | `meshy_task.py poll --endpoint E --task-id ID [--project-dir D]` | `meshy wait ID --timeout 600 [--project D] --output-schema v1` | saves `task_.json` into the project when `--project` is given | @@ -70,10 +70,14 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. ### 2.6 No implicit `.env` discovery - Legacy: read `.env` / `.env.local` from cwd automatically. -- CLI: `--env-file ` must be explicit; `doctor` may report that a candidate file - exists in cwd but never reads it. Priority: `--api-key` > `MESHY_API_KEY` > - `--env-file` > stored profile; an empty/invalid higher-priority source is an error, - not a fall-through. +- CLI: `--api-key-file ` must be explicit; `doctor` may report that a candidate + file exists in cwd but never reads it. Priority: `--api-key` > `MESHY_API_KEY` > + `--api-key-file` > stored profile. A named file that is missing, malformed or + key-less is an error, never a fall-through to another account. Empty/placeholder + `--api-key` and `MESHY_API_KEY` still mean "unset" (0.2.0 behaviour). +- The flag is not called `--env-file` because Node.js itself intercepts that name + anywhere in argv (loads the whole file into the environment, exits 9 when missing); + see decisions D-025. Passing `--env-file` to the CLI yields a usage error. ### 2.7 `showcase_type` spelling - Docs/Skill: `animated`. Server enum: `animate`. CLI accepts both, sends `animate`, and @@ -111,7 +115,7 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | `rigging list` | usage error | works | see 2.9 | | everything else (flags, payload defaults, `-o` download layout, `meta.json`, auth) | unchanged | unchanged | legacy output preserved; v1 is opt-in | -New global flags: `--output-schema`, `--env-file`, `--workspace`, `--no-update-check`, +New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, `--include-raw`, `--project`, `--operation-id`, `--stop-after-first` (make), `--idle-timeout` (stream). diff --git a/package.json b/package.json index d45a80e..30b662a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meshy-cli", "version": "0.2.0", - "description": "Official command-line interface for the Meshy AI API — text-to-3D, image-to-3D, text-to-motion, remesh, rigging, animate, retexture, 2D images, multi-color print, balance.", + "description": "Official command-line interface for the Meshy AI API \u2014 text-to-3D, image-to-3D, text-to-motion, remesh, rigging, animate, retexture, 2D images, multi-color print, balance.", "license": "MIT", "type": "module", "packageManager": "pnpm@11.24.0", @@ -47,6 +47,7 @@ "clean": "rm -rf dist", "dev": "tsx src/index.ts", "start": "node dist/index.js", + "pretest": "tsc", "test": "tsx --test tests/**/*.test.ts", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "prepare": "test -f dist/index.js || npm run build", diff --git a/src/client/endpoints/balance.ts b/src/client/endpoints/balance.ts index 365e89a..39db98d 100644 --- a/src/client/endpoints/balance.ts +++ b/src/client/endpoints/balance.ts @@ -12,6 +12,11 @@ export class BalanceEndpoint { } async get(): Promise { + return (await this.getWithRaw()).balance; + } + + /** The parsed balance plus the untouched response body (for --save-json). */ + async getWithRaw(): Promise<{ balance: Balance; raw: unknown }> { const resp = await this.http("/balance", { method: "GET" }); if (!resp.ok) throw await mapHttpError(resp, "/balance"); const raw: unknown = await resp.json(); @@ -25,6 +30,6 @@ export class BalanceEndpoint { body: raw, }); } - return parsed.data; + return { balance: parsed.data, raw }; } } diff --git a/src/client/index.ts b/src/client/index.ts index a5bf2d4..3e30b48 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -20,7 +20,8 @@ import { TextTo3DEndpoint } from "./endpoints/text-to-3d.js"; import { TextToImageEndpoint } from "./endpoints/text-to-image.js"; import { TextToMotionEndpoint } from "./endpoints/text-to-motion.js"; import { mapHttpError, MeshyApiError } from "./errors.js"; -import type { MeshyConfig } from "../internal/config.js"; +import { assertCredentialAllowedForOrigin, type MeshyConfig } from "../internal/config.js"; +import { UsageError } from "../internal/errors.js"; import { logger } from "../internal/logger.js"; import { USER_AGENT } from "../internal/user-agent.js"; @@ -96,11 +97,15 @@ export class MeshyClient { private readonly v1Fetch: HttpFetch; private readonly v2Fetch: HttpFetch; + private readonly creativeLabFetch: HttpFetch | null; constructor(config: MeshyConfig) { this.config = config; this.v1Fetch = makeFetcher(config.baseUrlV1, config.apiKey, config.readTimeoutMs, config.credentialKind); this.v2Fetch = makeFetcher(config.baseUrlV2, config.apiKey, config.readTimeoutMs, config.credentialKind); + this.creativeLabFetch = config.baseUrlCreativeLab + ? makeFetcher(config.baseUrlCreativeLab, config.apiKey, config.readTimeoutMs, config.credentialKind) + : null; this.balance = new BalanceEndpoint(this.v1Fetch); this.textTo3d = new TextTo3DEndpoint(this.v2Fetch); @@ -140,13 +145,22 @@ export class MeshyClient { } } - /** Raw HTTP passthrough for `meshy api …` — selects v1 or v2 by prefix. */ + /** Raw HTTP passthrough for `meshy api …` — selects the API family by flag. */ async raw( - apiVersion: "v1" | "v2", + apiVersion: "v1" | "v2" | "creative-lab", method: string, path: string, init?: RequestInit, ): Promise { + if (apiVersion === "creative-lab") { + if (!this.creativeLabFetch) { + throw new UsageError( + "the Creative Lab base URL cannot be derived from --base-url-v1; pass --base-url-creative-lab (or MESHY_BASE_URL_CREATIVE_LAB)", + ); + } + assertCredentialAllowedForOrigin(this.config, this.config.baseUrlCreativeLab!, "Creative Lab"); + return this.creativeLabFetch(path, { ...init, method }); + } const fetcher = apiVersion === "v2" ? this.v2Fetch : this.v1Fetch; return fetcher(path, { ...init, method }); } diff --git a/src/client/resource-registry.ts b/src/client/resource-registry.ts new file mode 100644 index 0000000..04a8e97 --- /dev/null +++ b/src/client/resource-registry.ts @@ -0,0 +1,249 @@ +/** + * Resource registry — the single source for what the CLI can call. + * + * Every task resource (the 16 first-class endpoints plus the 4 × 2 Creative + * Lab stages) is described once: command path, API family, relative path, + * supported verbs, billing class and the payload fields that carry media. + * Commands, the `resources` index, payload normalisation and the transport + * all read from here; nothing else hard-codes a path. + * + * docs/skill-parity/endpoint-contracts.json documents the same data and + * tests/resource-registry.test.ts fails when the two drift apart. + */ + +export type ApiBase = "v1" | "v2" | "creative-lab"; +export type MediaKind = "image" | "model"; +export type Billing = "none" | "may-charge"; +export type CreativeLabProduct = "figure" | "lamp" | "keychain" | "fridge-magnet"; +export type CreativeLabStage = "prototype" | "build"; +export type Verb = "create" | "get" | "list" | "delete" | "stream"; + +export interface MediaField { + /** snake_case payload field that carries the media reference. */ + path: string; + kind: MediaKind; + many: boolean; + /** When set, only these file extensions/formats are accepted for local files. */ + formats?: readonly string[]; +} + +export interface VerbSupport { + create: boolean; + get: boolean; + list: boolean; + delete: boolean; + stream: boolean; +} + +export interface TaskResourceDescriptor { + /** Stable id, dotted for Creative Lab: `creative-lab.figure.prototype`. */ + id: string; + /** Command tokens after `meshy`. */ + commandPath: readonly string[]; + base: ApiBase; + /** Appended to the base URL of the API family. */ + relativePath: string; + /** Full path as the Skills documented it, for `resources` and endpoint-contracts. */ + legacyEndpoint: string; + supports: VerbSupport; + mediaFields: readonly MediaField[]; + /** `type` values the server reports for this resource. */ + taskTypes: readonly string[]; + billing: { create: Billing }; + /** Never true in S1: nothing billable is retried for the caller. */ + automaticRetry: false; + creativeLab?: { product: CreativeLabProduct; stage: CreativeLabStage }; + summary: string; +} + +const ALL_VERBS: VerbSupport = { create: true, get: true, list: true, delete: true, stream: true }; + +const image = (path: string, many = false, formats?: readonly string[]): MediaField => + formats ? { path, kind: "image", many, formats } : { path, kind: "image", many }; +const model = (path: string, formats?: readonly string[]): MediaField => + formats ? { path, kind: "model", many: false, formats } : { path, kind: "model", many: false }; + +function v1(id: string, relativePath: string, opts: { + mediaFields?: readonly MediaField[]; + taskTypes: readonly string[]; + billing?: Billing; + summary: string; +}): TaskResourceDescriptor { + return { + id, + commandPath: [id], + base: "v1", + relativePath, + legacyEndpoint: `/openapi/v1${relativePath}`, + supports: ALL_VERBS, + mediaFields: opts.mediaFields ?? [], + taskTypes: opts.taskTypes, + billing: { create: opts.billing ?? "may-charge" }, + automaticRetry: false, + summary: opts.summary, + }; +} + +export const CREATIVE_LAB_PRODUCTS: readonly CreativeLabProduct[] = ["figure", "lamp", "keychain", "fridge-magnet"]; +export const CREATIVE_LAB_STAGES: readonly CreativeLabStage[] = ["prototype", "build"]; +const CREATIVE_LAB_IMAGE_FORMATS = ["jpg", "jpeg", "png", "webp"] as const; + +function creativeLab(product: CreativeLabProduct, stage: CreativeLabStage, summary: string): TaskResourceDescriptor { + const relativePath = `/${product}/v1/${stage}`; + return { + id: `creative-lab.${product}.${stage}`, + commandPath: ["creative-lab", product, stage], + base: "creative-lab", + relativePath, + legacyEndpoint: `/openapi/creative-lab${relativePath}`, + supports: ALL_VERBS, + mediaFields: stage === "prototype" ? [image("image_url", false, CREATIVE_LAB_IMAGE_FORMATS)] : [], + taskTypes: [`creative-lab-${product}-${stage}`], + billing: { create: "may-charge" }, + automaticRetry: false, + creativeLab: { product, stage }, + summary, + }; +} + +export const TASK_RESOURCES: readonly TaskResourceDescriptor[] = [ + { + id: "text-to-3d", + commandPath: ["text-to-3d"], + base: "v2", + relativePath: "/text-to-3d", + legacyEndpoint: "/openapi/v2/text-to-3d", + supports: ALL_VERBS, + mediaFields: [image("texture_image_url")], + taskTypes: ["text-to-3d-preview", "text-to-3d-refine"], + billing: { create: "may-charge" }, + automaticRetry: false, + summary: "two-stage 3D generation from text (preview → refine)", + }, + v1("image-to-3d", "/image-to-3d", { mediaFields: [image("image_url")], taskTypes: ["image-to-3d"], summary: "3D from a single image (standard or smart-topology low-poly)" }), + v1("multi-image-to-3d", "/multi-image-to-3d", { mediaFields: [image("image_urls", true)], taskTypes: ["multi-image-to-3d"], summary: "3D from multiple views (beta; prefer image-to-3d)" }), + v1("remesh", "/remesh", { mediaFields: [model("model_url")], taskTypes: ["remesh"], summary: "retopologize / change polycount" }), + v1("convert", "/convert", { mediaFields: [model("model_url")], taskTypes: ["convert"], summary: "change file format only" }), + v1("resize", "/resize", { mediaFields: [model("model_url")], taskTypes: ["resize"], summary: "resize to real-world dimensions" }), + v1("rigging", "/rigging", { mediaFields: [model("model_url", ["glb"]), image("texture_image_url")], taskTypes: ["rig"], summary: "rig a humanoid mesh (+ bundled walk/run animations)" }), + v1("animate", "/animations", { taskTypes: ["animation"], summary: "apply an animation clip to a rigged mesh" }), + v1("retexture", "/retexture", { mediaFields: [model("model_url"), image("image_style_url"), image("multiview_image_urls", true)], taskTypes: ["retexture"], summary: "regenerate textures" }), + v1("text-to-image", "/text-to-image", { taskTypes: ["text-to-image"], summary: "2D image generation" }), + v1("text-to-motion", "/text-to-motion", { taskTypes: ["text-to-motion"], summary: "generate a standalone skeletal motion clip from text" }), + v1("image-to-image", "/image-to-image", { mediaFields: [image("reference_image_urls", true)], taskTypes: ["image-to-image"], summary: "2D image editing" }), + v1("multi-color-print", "/print/multi-color", { mediaFields: [model("model_url")], taskTypes: ["print-multi-color"], summary: "color-separated 3D print output" }), + v1("analyze-printability", "/print/analyze", { mediaFields: [model("model_url")], taskTypes: ["print-analyze"], billing: "none", summary: "inspect a model for 3D-printing issues (free)" }), + v1("repair-printability", "/print/repair", { mediaFields: [model("model_url")], taskTypes: ["print-repair"], summary: "fix non-watertight / non-manifold geometry" }), + v1("uv-unwrap", "/uv-unwrap", { mediaFields: [model("model_url", ["glb"])], taskTypes: ["uv-unwrap"], summary: "generate fresh UVs for a GLB (≤40k faces) — a UV white model for external texturing" }), + creativeLab("figure", "prototype", "Creative Lab figure: photo → styled concept image"), + creativeLab("figure", "build", "Creative Lab figure: prototype → textured GLB/OBJ/MTL"), + creativeLab("lamp", "prototype", "Creative Lab lamp: photo → concept image + lampshade GLB"), + creativeLab("lamp", "build", "Creative Lab lamp: prototype → lamp_stl/base_stl or bundle_zip"), + creativeLab("keychain", "prototype", "Creative Lab keychain: photo → styled concept image"), + creativeLab("keychain", "build", "Creative Lab keychain: prototype → relief GLB / OBJ bundle (zip) / zip"), + creativeLab("fridge-magnet", "prototype", "Creative Lab fridge magnet: photo → styled concept image"), + creativeLab("fridge-magnet", "build", "Creative Lab fridge magnet: prototype → relief GLB / OBJ bundle (zip) / zip"), +]; + +const BY_ID = new Map(TASK_RESOURCES.map((d) => [d.id, d] as const)); + +export function findTaskResource(id: string): TaskResourceDescriptor | undefined { + return BY_ID.get(id); +} + +export function requireTaskResource(id: string): TaskResourceDescriptor { + const d = BY_ID.get(id); + if (!d) throw new Error(`unknown task resource '${id}'`); + return d; +} + +export function taskResourceByCommandPath(path: readonly string[]): TaskResourceDescriptor | undefined { + return TASK_RESOURCES.find((d) => d.commandPath.length === path.length && d.commandPath.every((p, i) => p === path[i])); +} + +/** Resolve a Creative Lab descriptor from user-supplied tokens without ever building a path from them. */ +export function creativeLabResource(product: string, stage: string): TaskResourceDescriptor | undefined { + if (!(CREATIVE_LAB_PRODUCTS as readonly string[]).includes(product)) return undefined; + if (!(CREATIVE_LAB_STAGES as readonly string[]).includes(stage)) return undefined; + return BY_ID.get(`creative-lab.${product}.${stage}`); +} + +/** Resource ids that map 1:1 onto a top-level command (used by the legacy `--resource` flag and the index). */ +export const TOP_LEVEL_TASK_RESOURCE_IDS: readonly string[] = TASK_RESOURCES.filter((d) => d.commandPath.length === 1).map((d) => d.id); + +export interface QueryResourceDescriptor { + id: string; + commandPath: readonly string[]; + base: "v1" | "public-web"; + method: "GET"; + relativePath: string; + auth: "bearer" | "none"; + billing: Billing; + summary: string; +} + +export const QUERY_RESOURCES: readonly QueryResourceDescriptor[] = [ + { id: "balance", commandPath: ["balance"], base: "v1", method: "GET", relativePath: "/balance", auth: "bearer", billing: "none", summary: "remaining credit balance" }, + { id: "animation-catalog", commandPath: ["animation-catalog", "list"], base: "public-web", method: "GET", relativePath: "/animations/resources", auth: "none", billing: "none", summary: "public animation library (action ids); no key needed" }, + { id: "showcases", commandPath: ["showcases", "list"], base: "v1", method: "GET", relativePath: "/showcases", auth: "bearer", billing: "may-charge", summary: "Enterprise community showcases (1 credit per request)" }, +]; + +export interface LocalToolDescriptor { + id: string; + commandPath: readonly string[]; + summary: string; +} + +export const LOCAL_TOOLS: readonly LocalToolDescriptor[] = [ + { id: "download", commandPath: ["download"], summary: "download selected assets of a task (from a saved task JSON, a URL or the API)" }, + { id: "project", commandPath: ["project"], summary: "meshy_output project folders: init | record | show | list | rebuild-index" }, + { id: "inspect.faces", commandPath: ["inspect", "faces"], summary: "face-count gate: pass | fail | unknown" }, + { id: "mesh.prepare-print", commandPath: ["mesh", "prepare-print"], summary: "OBJ Y-up → Z-up, scale to a target height, centre, bottom at Z=0" }, + { id: "slicer", commandPath: ["slicer"], summary: "detect installed slicers | open a file in one" }, + { id: "doctor", commandPath: ["doctor"], summary: "local environment diagnosis (no network by default)" }, + { id: "delete", commandPath: ["delete"], summary: "delete any task, whatever its resource" }, +]; + +export type ResourceIndexKind = "task" | "query" | "local"; + +export interface ResourceIndexEntry { + name: string; + kind: ResourceIndexKind; + command: string; + summary: string; + endpoint: string | null; + verbs: Verb[] | null; +} + +/** The v1 `resources` index: every task resource, query and local tool. */ +export function resourceIndex(): ResourceIndexEntry[] { + const verbsOf = (s: VerbSupport): Verb[] => + (["create", "get", "list", "wait", "stream", "delete"] as const) + .filter((v) => (v === "wait" ? s.get : s[v])) + .map((v) => v as Verb); + const tasks: ResourceIndexEntry[] = TASK_RESOURCES.map((d) => ({ + name: d.id, + kind: "task", + command: `meshy ${d.commandPath.join(" ")}`, + summary: d.summary, + endpoint: d.legacyEndpoint, + verbs: verbsOf(d.supports), + })); + const queries: ResourceIndexEntry[] = QUERY_RESOURCES.map((q) => ({ + name: q.id, + kind: "query", + command: `meshy ${q.commandPath.join(" ")}`, + summary: q.summary, + endpoint: q.base === "public-web" ? `/web/public${q.relativePath}` : `/openapi/v1${q.relativePath}`, + verbs: null, + })); + const locals: ResourceIndexEntry[] = LOCAL_TOOLS.map((l) => ({ + name: l.id, + kind: "local", + command: `meshy ${l.commandPath.join(" ")}`, + summary: l.summary, + endpoint: null, + verbs: null, + })); + return [...tasks, ...queries, ...locals]; +} diff --git a/src/cmd/api.ts b/src/cmd/api.ts index 1218e6a..4e55585 100644 --- a/src/cmd/api.ts +++ b/src/cmd/api.ts @@ -1,14 +1,18 @@ /** - * Raw API passthrough: `meshy-cli api [--v1|--v2] [--data ] [--params ]` + * Raw API passthrough: `meshy-cli api [--v1|--v2|--creative-lab] [--data ] [--params ]` * * Prints the JSON body when the response is JSON, or raw text otherwise. - * Non-2xx responses exit with code 1 and print an error summary on stderr. + * Non-2xx responses exit non-zero with an error payload. Write verbs are + * never retried: the passthrough is a controlled escape hatch, not a client. */ -import { Command } from "commander"; -import { emit } from "../internal/output.js"; +import { Command, Option } from "commander"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { UsageError } from "../internal/errors.js"; import { parseJsonFlag } from "../internal/payload.js"; -import { buildRuntime, readGlobalFlags } from "../internal/runtime.js"; +import { buildRuntime } from "../internal/runtime.js"; + +const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); export const apiCommand = new Command("api") .description("Raw HTTP passthrough to the Meshy API (JSON only)") @@ -16,12 +20,20 @@ export const apiCommand = new Command("api") .argument("", "API path (e.g. /text-to-3d or /balance)") .option("--v2", "use the v2 base URL (default: v1)") .option("--v1", "force v1 (default)") + .addOption(new Option("--creative-lab", "use the Creative Lab base URL (paths like /figure/v1/prototype)")) .option("--data ", "request body JSON (or @file.json)") .option("--params ", "query params JSON object") + .option("--save-json ", "v1: also save the raw response body to this file (never overwrites)") .action(async (method: string, path: string, opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const apiVersion = opts.v2 ? "v2" : "v1"; + const opened = openCommand(thisCmd, "api", "legacy"); + rejectOutputFlagForV1(opened, opts.saveJson as string | undefined); const verb = method.toUpperCase(); + if (!METHODS.has(verb)) throw new UsageError(`unsupported HTTP method '${method}'. Expected: GET | POST | PUT | PATCH | DELETE`); + if ((opts.v2 ? 1 : 0) + (opts.v1 ? 1 : 0) + (opts.creativeLab ? 1 : 0) > 1) { + throw new UsageError("--v1, --v2 and --creative-lab are mutually exclusive"); + } + const apiVersion = opts.v2 ? "v2" : opts.creativeLab ? "creative-lab" : "v1"; + const runtime = await buildRuntime(opened.flags); let finalPath = path.startsWith("/") ? path : `/${path}`; const params = parseJsonFlag(opts.params as string | undefined, "--params"); @@ -48,5 +60,12 @@ export const apiCommand = new Command("api") } catch { /* leave as text */ } - emit(parsed, { format: runtime.flags.format, file: runtime.flags.output }); + const saveJson = opts.saveJson as string | undefined; + const saved = saveJson ? saveRawJson(saveJson, parsed, { workspace: opened.flags.workspace }) : null; + await emitResult( + opened, + parsed, + { http_status: resp.status, method: verb, path: finalPath, api: apiVersion, body: parsed, saved_json: saved }, + { legacyFile: opened.flags.output }, + ); }); diff --git a/src/cmd/balance.ts b/src/cmd/balance.ts index 3aa41df..aefacad 100644 --- a/src/cmd/balance.ts +++ b/src/cmd/balance.ts @@ -3,13 +3,17 @@ */ import { Command } from "commander"; -import { emit } from "../internal/output.js"; -import { buildRuntime, readGlobalFlags } from "../internal/runtime.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { buildRuntime } from "../internal/runtime.js"; export const balanceCommand = new Command("balance") .description("Show the current API key's credit balance") - .action(async (_opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const balance = await runtime.client.balance.get(); - emit(balance, { format: runtime.flags.format, file: runtime.flags.output }); + .option("--save-json ", "v1: also save the raw API response to this file (never overwrites)") + .action(async (opts: { saveJson?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "balance", "legacy"); + rejectOutputFlagForV1(opened, opts.saveJson); + const runtime = await buildRuntime(opened.flags); + const { balance, raw } = await runtime.client.balance.getWithRaw(); + const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + await emitResult(opened, balance, { balance: balance.balance, saved_json: saved }, { legacyFile: opened.flags.output }); }); diff --git a/src/cmd/delete.ts b/src/cmd/delete.ts index 3a220cd..0a505d9 100644 --- a/src/cmd/delete.ts +++ b/src/cmd/delete.ts @@ -4,17 +4,21 @@ */ import { Command } from "commander"; -import { emit } from "../internal/output.js"; -import { buildRuntime, readGlobalFlags } from "../internal/runtime.js"; +import { emitResult, openCommand, rejectOutputFlagForV1 } from "../internal/command-helpers.js"; +import { buildRuntime } from "../internal/runtime.js"; export const deleteCommand = new Command("delete") .description("Delete any task by id") .argument("", "Meshy task id") .action(async (taskId: string, _opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); + const opened = openCommand(thisCmd, "delete", "legacy"); + rejectOutputFlagForV1(opened, undefined); + const runtime = await buildRuntime(opened.flags); await runtime.client.textTo3d.delete(taskId); - emit({ task_id: taskId, deleted: true }, { - format: runtime.flags.format, - file: runtime.flags.output, - }); + await emitResult( + opened, + { task_id: taskId, deleted: true }, + { task_id: taskId, resource: null, endpoint: "/openapi/v2/text-to-3d", deleted: true }, + { legacyFile: opened.flags.output }, + ); }); diff --git a/src/index.ts b/src/index.ts index e2b36a0..19a47cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,107 @@ #!/usr/bin/env node /** * meshy-cli entry point. + * + * Responsibilities kept here on purpose: + * - decide whether the background update check may run before anything else; + * - turn SIGINT into a cooperative abort (exit 130) instead of a hard kill; + * - route every failure — including Commander parse errors — through one + * exit that renders the payload in the schema the command would have used; + * - flush stdout before exiting. */ import type { Command } from "commander"; -import { buildRootCommand } from "./root.js"; -import { exitCodeFor, reportError } from "./internal/errors.js"; -import { refreshCache } from "./internal/update-notifier.js"; +import { buildRootCommand, V1_ONLY_COMMANDS, LOCAL_COMMANDS } from "./root.js"; +import { currentCommand, markInterrupted, wasInterrupted } from "./internal/context.js"; +import { exitCodeFor, isCommanderInformational, reportError, CliError } from "./internal/errors.js"; +import { emitEnvelope, writeStdout, type OutputFormat } from "./internal/output.js"; +import { errorEnvelope, type OutputSchema } from "./internal/result.js"; +import { refreshCache, shouldSkip } from "./internal/update-notifier.js"; async function main(): Promise { - refreshCache(); // fire-and-forget; internally guarded; never throws + const argv = process.argv; + if (argv.includes("--no-update-check")) { + // Children (none today, but any future spawn) inherit the opt-out. + process.env["MESHY_CLI_NO_UPDATE_NOTIFIER"] = "1"; + } + if (shouldRunUpdateCheck(argv)) refreshCache(); // fire-and-forget; internally guarded; never throws + + installSignalHandlers(); + const program = buildRootCommand(); try { - await program.parseAsync(process.argv); + await program.parseAsync(argv); const code = process.exitCode ?? 0; return typeof code === "number" ? code : 1; } catch (err) { - const fmt = resolveErrorFormat(program); - reportError(err, fmt); - return exitCodeFor(err); + if (isCommanderInformational(err)) return err.exitCode; + return await reportFailure(program, argv, err); } } +/** + * The update check never runs for local work: no network, no detached child. + * Local means the command touches neither the API nor the update state, or + * the caller said so. + */ +export function shouldRunUpdateCheck(argv: string[], env: NodeJS.ProcessEnv = process.env): boolean { + if (shouldSkip(env)) return false; + if (argv.includes("--no-update-check")) return false; + const rest = argv.slice(2); + if (rest.length === 0) return false; + if (rest.some((a) => a === "--help" || a === "-h" || a === "--version" || a === "-V")) return false; + const first = rest.find((a) => !a.startsWith("-")); + if (!first) return false; + if (LOCAL_COMMANDS.has(first)) return false; + if (first === "make" && rest.includes("--dry-run")) return false; + return true; +} + +function installSignalHandlers(): void { + let count = 0; + process.on("SIGINT", () => { + count += 1; + if (count === 1) { + markInterrupted(); + process.stderr.write("\ninterrupted — finishing local bookkeeping; press Ctrl-C again to force exit\n"); + return; + } + process.exit(130); + }); +} + +async function reportFailure(program: Command, argv: string[], err: unknown): Promise { + const ctx = currentCommand(); + const schema: OutputSchema = ctx?.schema ?? resolveSchemaHeuristically(program, argv); + const format: OutputFormat = ctx?.format ?? resolveErrorFormat(program); + const interruptedErr = + wasInterrupted() && !(err instanceof CliError && err.code === "interrupted") + ? new CliError({ code: "interrupted", message: "interrupted by SIGINT", cause: err }) + : err; + + if (schema === "v1") { + const command = ctx?.command ?? commandNameFromArgv(program, argv); + const { envelope, exitCode } = errorEnvelope(command, interruptedErr); + process.stderr.write(`error: ${envelope.error?.message ?? "unknown error"}\n`); + if (envelope.error?.hint) process.stderr.write(`hint: ${envelope.error.hint}\n`); + try { + await emitEnvelope(envelope, format); + } catch { + /* stderr already carries the human message */ + } + return exitCode; + } + + reportError(interruptedErr, format); + try { + await writeStdout(""); + } catch { + /* nothing to flush */ + } + return exitCodeFor(interruptedErr); +} + /** * Resolve the output format for the error path. * @@ -38,9 +117,7 @@ async function main(): Promise { * the user passed it) — scanning only root opts would miss * `meshy --format pretty auth login --json`. */ -function resolveErrorFormat( - program: Command, -): "json" | "pretty" | "ndjson" { +function resolveErrorFormat(program: Command): OutputFormat { const anyJson = (cmd: Command): boolean => Boolean(cmd.opts()["json"]) || cmd.commands.some(anyJson); if (anyJson(program)) return "json"; @@ -49,4 +126,42 @@ function resolveErrorFormat( return v === "pretty" || v === "ndjson" ? v : "json"; } +/** + * Before an action ran we only know argv. `--output-schema` is a root option, + * so a parsed value is authoritative; otherwise the first command token decides + * (new commands are v1-only, everything else is legacy). + */ +function resolveSchemaHeuristically(program: Command, argv: string[]): OutputSchema { + const parsed = program.opts()["outputSchema"]; + if (parsed === "v1" || parsed === "legacy") return parsed; + const idx = argv.indexOf("--output-schema"); + if (idx !== -1) { + const v = argv[idx + 1]; + if (v === "v1" || v === "legacy") return v; + } + const first = firstCommandToken(program, argv); + return first && V1_ONLY_COMMANDS.has(first) ? "v1" : "legacy"; +} + +function firstCommandToken(program: Command, argv: string[]): string | undefined { + const names = new Set(program.commands.map((c) => c.name())); + return argv.slice(2).find((a) => names.has(a)); +} + +function commandNameFromArgv(program: Command, argv: string[]): string { + const first = firstCommandToken(program, argv); + if (!first) return "meshy"; + const rest = argv.slice(argv.indexOf(first) + 1).filter((a) => !a.startsWith("-")); + const sub = program.commands.find((c) => c.name() === first); + const path = [first]; + let node = sub; + for (const token of rest) { + const next = node?.commands.find((c) => c.name() === token); + if (!next) break; + path.push(token); + node = next; + } + return path.join("."); +} + main().then((code) => process.exit(code)); diff --git a/src/internal/atomic-file.ts b/src/internal/atomic-file.ts new file mode 100644 index 0000000..62b40fd --- /dev/null +++ b/src/internal/atomic-file.ts @@ -0,0 +1,170 @@ +/** + * Exclusive and atomic file publication. + * + * Two contracts: + * - no-overwrite (default): the target must not exist when the file lands. + * `exists → rename` is a race (POSIX rename replaces a concurrent + * winner), so the temp file is published with `link()`, which fails + * atomically with EEXIST. Where hard links are unsupported the fallback + * opens the target with O_EXCL and copies — still exclusive, no longer + * atomic, and reported as such. + * - overwrite: `rename()` over a target that is a regular file (or absent). + * Directories and symlinks are never replaced. + * + * Temp files always live in the target directory (same filesystem) and are + * removed on every failure path. + */ + +import { + closeSync, + copyFileSync, + constants as fsConstants, + linkSync, + lstatSync, + mkdirSync, + openSync, + renameSync, + unlinkSync, + writeFileSync, + writeSync, + readFileSync, +} from "node:fs"; +import { randomBytes } from "node:crypto"; +import { basename, dirname, join } from "node:path"; +import { CliError } from "./errors.js"; + +export type PublishMethod = "link" | "rename" | "copy-exclusive"; + +export interface PublishOptions { + overwrite?: boolean; +} + +export interface PublishResult { + path: string; + method: PublishMethod; +} + +export function tempPathFor(target: string): string { + return join(dirname(target), `.${basename(target)}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`); +} + +function errno(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code; +} + +function removeQuietly(path: string): void { + try { + unlinkSync(path); + } catch { + /* already gone */ + } +} + +export function refuseOverwriteError(target: string): CliError { + return new CliError({ + code: "local_io", + message: `refusing to overwrite existing file: ${target} (pass --overwrite, or choose another path)`, + recovery: { action: "choose_path", automatic: false }, + }); +} + +/** + * Move a fully written temp file onto `target`. + * The temp file is consumed (removed) whether or not publication succeeds. + */ +export function publishTempFile(tmp: string, target: string, opts: PublishOptions = {}): PublishResult { + try { + if (opts.overwrite) { + let existing: ReturnType | null = null; + try { + existing = lstatSync(target); + } catch (err) { + if (errno(err) !== "ENOENT") throw err; + } + if (existing && !existing.isFile()) { + throw new CliError({ + code: "local_io", + message: `refusing to replace ${target}: it is not a regular file`, + }); + } + renameSync(tmp, target); + return { path: target, method: "rename" }; + } + try { + linkSync(tmp, target); + unlinkSync(tmp); + return { path: target, method: "link" }; + } catch (err) { + const code = errno(err); + if (code === "EEXIST") throw refuseOverwriteError(target); + if (code !== "EPERM" && code !== "ENOTSUP" && code !== "EXDEV" && code !== "EOPNOTSUPP" && code !== "EACCES" && code !== "EMLINK") { + throw err; + } + // Hard links unavailable on this filesystem: exclusive create + copy. + let fd: number; + try { + fd = openSync(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600); + } catch (openErr) { + if (errno(openErr) === "EEXIST") throw refuseOverwriteError(target); + throw openErr; + } + try { + const data = readFileSync(tmp); + let offset = 0; + while (offset < data.length) { + offset += writeSync(fd, data, offset, data.length - offset); + } + } finally { + closeSync(fd); + } + unlinkSync(tmp); + return { path: target, method: "copy-exclusive" }; + } + } catch (err) { + removeQuietly(tmp); + if (err instanceof CliError) throw err; + throw new CliError({ + code: "local_io", + message: `failed to write ${target}: ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } +} + +/** Serialise `value` as pretty JSON and publish it exclusively (or atomically replace with overwrite). */ +export function writeJsonFile( + target: string, + value: unknown, + opts: PublishOptions & { mode?: number } = {}, +): PublishResult { + mkdirSync(dirname(target), { recursive: true }); + const tmp = tempPathFor(target); + try { + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: opts.mode ?? 0o600 }); + } catch (err) { + removeQuietly(tmp); + throw new CliError({ + code: "local_io", + message: `failed to write ${target}: ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } + return publishTempFile(tmp, target, opts); +} + +/** Copy an existing file to `target` under the same publish rules. */ +export function copyFilePublished(source: string, target: string, opts: PublishOptions = {}): PublishResult { + mkdirSync(dirname(target), { recursive: true }); + const tmp = tempPathFor(target); + try { + copyFileSync(source, tmp); + } catch (err) { + removeQuietly(tmp); + throw new CliError({ + code: "local_io", + message: `failed to copy ${source} → ${target}: ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } + return publishTempFile(tmp, target, opts); +} diff --git a/src/internal/command-helpers.ts b/src/internal/command-helpers.ts new file mode 100644 index 0000000..f24aaf7 --- /dev/null +++ b/src/internal/command-helpers.ts @@ -0,0 +1,83 @@ +/** + * Small shared pieces every command action uses: + * - open the command context (name, schema, format) so the error exit can + * render in the right shape; + * - render a result in either schema; + * - save the raw API JSON (--save-json) with exclusive publication. + */ + +import { Command } from "commander"; +import { dirname, resolve as resolvePath } from "node:path"; +import { beginCommand, type CommandContext } from "./context.js"; +import { UsageError, type Warning } from "./errors.js"; +import { emit, emitEnvelope, type OutputFormat } from "./output.js"; +import { okEnvelope, type OutputSchema } from "./result.js"; +import { readGlobalFlags, resolveSchema, type GlobalFlags } from "./runtime.js"; +import { writeJsonFile } from "./atomic-file.js"; +import { resolveWithinRoot } from "./paths.js"; + +export interface OpenedCommand extends CommandContext { + flags: GlobalFlags; +} + +/** Read global flags, decide the schema and register the command context. */ +export function openCommand(thisCmd: Command, command: string, defaultSchema: OutputSchema): OpenedCommand { + const flags = readGlobalFlags(thisCmd); + const schema = resolveSchema(flags, defaultSchema); + const ctx = beginCommand({ command, schema, format: flags.format }); + return { ...ctx, flags }; +} + +/** + * Print a command result. Legacy keeps the bare payload (and the historical + * `-o ` meaning "write the JSON there" for non-task commands); v1 prints + * the envelope and treats `-o` on a non-task command as a usage error that + * points at --save-json. + */ +export async function emitResult( + opened: OpenedCommand, + legacyValue: unknown, + v1Result: unknown, + opts: { warnings?: Warning[]; legacyFile?: string | undefined; format?: OutputFormat } = {}, +): Promise { + const format = opts.format ?? opened.format; + if (opened.schema === "v1") { + await emitEnvelope(okEnvelope(opened.command, v1Result, opts.warnings ?? []), format); + return; + } + emit(legacyValue, { format, file: opts.legacyFile }); +} + +/** v1: `-o` is reserved for assets; JSON goes through --save-json. */ +export function rejectOutputFlagForV1(opened: OpenedCommand, saveJson: string | undefined): void { + if (opened.schema !== "v1") return; + if (opened.flags.output && saveJson) { + throw new UsageError("--output/-o and --save-json cannot be combined on this command; -o is for assets, --save-json for the raw JSON"); + } + if (opened.flags.output) { + throw new UsageError("this command produces JSON, not assets: use --save-json instead of --output/-o under --output-schema v1"); + } +} + +export interface SavedJson { + path: string; + bytes: number; +} + +/** + * Save the raw JSON the API returned (never the CLI envelope). The file is + * published exclusively; an existing file is an error unless `overwrite`. + */ +export function saveRawJson( + target: string, + raw: unknown, + opts: { workspace?: string | undefined; overwrite?: boolean; cwd?: string } = {}, +): SavedJson { + const cwd = opts.cwd ?? process.cwd(); + const abs = resolvePath(cwd, target); + const root = opts.workspace ?? dirname(abs); + const resolved = resolveWithinRoot(abs, root, { cwd, label: "--save-json target" }); + const text = `${JSON.stringify(raw, null, 2)}\n`; + writeJsonFile(resolved.path, raw, { overwrite: opts.overwrite ?? false }); + return { path: resolved.path, bytes: Buffer.byteLength(text, "utf8") }; +} diff --git a/src/internal/config.ts b/src/internal/config.ts index 053ba07..70584fd 100644 --- a/src/internal/config.ts +++ b/src/internal/config.ts @@ -1,31 +1,50 @@ /** * Resolve config in this priority order: - * 1. CLI flags (--api-key, --base-url-v1, --base-url-v2, --verbose) + * 1. CLI flags (--api-key, --base-url-v1, --base-url-v2, --base-url-creative-lab, --verbose) * 2. Environment variables (MESHY_*) - * 3. The active profile in the credentials file (written by `meshy auth login`) - * 4. Built-in defaults + * 3. An explicit --api-key-file (only MESHY_API_KEY is read from it) + * 4. The active profile in the credentials file (written by `meshy auth login`) + * 5. Built-in defaults * * The env var keeps priority over the stored credential on purpose: CI and * containers export MESHY_API_KEY and must not be silently overridden by * whatever a developer once logged into on that machine. * + * An empty or placeholder --api-key / MESHY_API_KEY counts as "unset" (0.2.0 + * behaviour; CI and the runtime tests rely on `MESHY_API_KEY=""` meaning + * "fall through to the stored profile"). An explicit --api-key-file is different: + * naming a file is an instruction, so an unreadable, malformed or key-less + * file is an error, never a fall-through to another account. + * * Fail-fast on a missing/placeholder credential — with the command that fixes * it attached, not just a complaint. */ import { credentialsPath, resolveStoredCredential, type CredentialKind } from "./credentials.js"; -import { authRequiredError } from "./errors.js"; +import { loadEnvFile } from "./env-file.js"; +import { authRequiredError, CliError } from "./errors.js"; import { setLogLevel, type LogLevel } from "./logger.js"; const PLACEHOLDER_KEYS = new Set(["", "YOUR_MESHY_API_KEY_HERE"]); +export const DEFAULT_BASE_URL_V1 = "https://api.meshy.ai/openapi/v1"; +export const DEFAULT_BASE_URL_V2 = "https://api.meshy.ai/openapi/v2"; + /** Where the credential in use came from — surfaced by `meshy auth status`. */ -export type CredentialSource = "flag" | "env" | "file"; +export type CredentialSource = "flag" | "env" | "env-file" | "file"; export interface MeshyConfig { apiKey: string; baseUrlV1: string; baseUrlV2: string; + /** + * Creative Lab base. Derived from the v1 origin when v1 uses the standard + * `/openapi/v1` path; null when it cannot be derived and no explicit + * override was given (commands that need it then ask for one). + */ + baseUrlCreativeLab: string | null; + /** Public, unauthenticated catalog base: `/web/public`. */ + publicWebBase: string; connectTimeoutMs: number; readTimeoutMs: number; pollIntervalMs: number; @@ -33,11 +52,13 @@ export interface MeshyConfig { credentialSource: CredentialSource; /** Set only when credentialSource === "file". */ credentialProfile?: string; + /** Set only when credentialSource === "env-file" (the --api-key-file path). */ + envFilePath?: string; /** The credentials file consulted for this invocation, whether or not it exists. */ credentialsFile: string; /** * The kind of credential in use — "oauth" for browser-login tokens, "api_key" for static keys. - * "api_key" when credentialSource is "flag" or "env" (those paths only accept static keys). + * "api_key" when credentialSource is "flag", "env" or "env-file" (those paths only accept static keys). * Derived from the stored profile kind when credentialSource is "file". */ credentialKind: CredentialKind; @@ -47,6 +68,8 @@ export interface ConfigOverrides { apiKey?: string; baseUrlV1?: string; baseUrlV2?: string; + baseUrlCreativeLab?: string; + envFile?: string; logLevel?: LogLevel; } @@ -73,20 +96,63 @@ function stripTrail(s: string): string { return s.replace(/\/+$/, ""); } +/** Origin (scheme://host[:port]) of a base URL, or null when it does not parse. */ +export function originOf(baseUrl: string): string | null { + try { + return new URL(baseUrl).origin; + } catch { + return null; + } +} + +/** + * Derive the Creative Lab base from the v1 base. Only the standard + * `/openapi/v1` layout is derivable; a custom proxy path makes the + * derivation a guess, and a guessed base must never fall back to production. + */ +export function deriveCreativeLabBase(baseUrlV1: string): string | null { + try { + const u = new URL(baseUrlV1); + if (u.pathname.replace(/\/+$/, "") !== "/openapi/v1") return null; + return `${u.origin}/openapi/creative-lab`; + } catch { + return null; + } +} + +export function derivePublicWebBase(baseUrlV1: string): string { + const origin = originOf(baseUrlV1) ?? "https://api.meshy.ai"; + return `${origin}/web/public`; +} + export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { // Base URLs resolve first: they decide which credentials file applies // (production vs. a staging override). const baseUrlV1 = stripTrail( - overrides.baseUrlV1 ?? process.env.MESHY_BASE_URL_V1 ?? "https://api.meshy.ai/openapi/v1", + overrides.baseUrlV1 ?? process.env.MESHY_BASE_URL_V1 ?? DEFAULT_BASE_URL_V1, ); const baseUrlV2 = stripTrail( - overrides.baseUrlV2 ?? process.env.MESHY_BASE_URL_V2 ?? "https://api.meshy.ai/openapi/v2", + overrides.baseUrlV2 ?? process.env.MESHY_BASE_URL_V2 ?? DEFAULT_BASE_URL_V2, ); + const explicitCreativeLab = overrides.baseUrlCreativeLab ?? process.env.MESHY_BASE_URL_CREATIVE_LAB; + const baseUrlCreativeLab = explicitCreativeLab + ? stripTrail(explicitCreativeLab) + : deriveCreativeLabBase(baseUrlV1); const credFile = credentialsPath(baseUrlV1); const flagKey = overrides.apiKey?.trim() ?? ""; const envKey = process.env.MESHY_API_KEY?.trim() ?? ""; + // An explicit env file is validated even when a higher-priority key wins: + // the user named it, so a broken file is a mistake worth reporting now. + let envFileKey = ""; + let envFilePath: string | undefined; + if (overrides.envFile) { + const loaded = loadEnvFile(overrides.envFile); + envFilePath = loaded.path; + envFileKey = loaded.apiKey?.trim() ?? ""; + } + let apiKey = ""; let credentialSource: CredentialSource = "flag"; let credentialProfile: string | undefined; @@ -100,6 +166,16 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { apiKey = envKey; credentialSource = "env"; credentialKind = "api_key"; + } else if (overrides.envFile) { + if (PLACEHOLDER_KEYS.has(envFileKey)) { + throw new CliError({ + code: "auth", + message: `--api-key-file ${overrides.envFile} does not define a usable MESHY_API_KEY (missing, empty or placeholder). Fix the file or drop --api-key-file to use another credential source.`, + }); + } + apiKey = envFileKey; + credentialSource = "env-file"; + credentialKind = "api_key"; } else { // A corrupt credentials file throws out of resolveStoredCredential rather // than being swallowed into "not logged in" — see credentials.ts. @@ -121,12 +197,15 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { apiKey, baseUrlV1, baseUrlV2, + baseUrlCreativeLab, + publicWebBase: derivePublicWebBase(baseUrlV1), connectTimeoutMs: readNumber("MESHY_CONNECT_TIMEOUT_MS", 10_000), readTimeoutMs: readNumber("MESHY_READ_TIMEOUT_MS", 120_000), pollIntervalMs: readNumber("MESHY_POLL_INTERVAL_MS", 3_000), logLevel: overrides.logLevel ?? readLogLevel("warn"), credentialSource, credentialProfile, + envFilePath, credentialsFile: credFile, credentialKind, }; @@ -134,3 +213,20 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { setLogLevel(cfg.logLevel); return cfg; } + +/** + * Stored profiles were issued for the v1 origin they were resolved against. + * A different Creative Lab origin only receives an explicitly supplied key. + */ +export function assertCredentialAllowedForOrigin(cfg: MeshyConfig, targetBase: string, label: string): void { + if (cfg.credentialSource !== "file") return; + const v1Origin = originOf(cfg.baseUrlV1); + const targetOrigin = originOf(targetBase); + if (v1Origin && targetOrigin && v1Origin === targetOrigin) return; + throw new CliError({ + code: "auth", + message: + `${label} base ${targetBase} is on a different origin than the v1 API (${cfg.baseUrlV1}); ` + + "the stored profile is not sent there. Pass --api-key, MESHY_API_KEY or --api-key-file for that origin.", + }); +} diff --git a/src/internal/context.ts b/src/internal/context.ts new file mode 100644 index 0000000..0913d00 --- /dev/null +++ b/src/internal/context.ts @@ -0,0 +1,51 @@ +/** + * Per-invocation command context. The CLI runs one command per process; the + * action that owns the command registers its name, output schema and format + * here so the top-level error exit can render the failure in the same shape + * the success path would have used. Parse errors happen before any action + * runs, so the entry point falls back to argv heuristics when this is unset. + * + * The shared AbortController is what SIGINT trips; long operations (wait, + * stream, download) observe `signal` and convert an abort into exit 130. + */ + +import type { OutputFormat } from "./output.js"; +import type { OutputSchema } from "./result.js"; + +export interface CommandContext { + command: string; + schema: OutputSchema; + format: OutputFormat; +} + +let current: CommandContext | null = null; +const controller = new AbortController(); +let interrupted = false; + +export function beginCommand(ctx: CommandContext): CommandContext { + current = ctx; + return ctx; +} + +export function currentCommand(): CommandContext | null { + return current; +} + +export function abortSignal(): AbortSignal { + return controller.signal; +} + +export function markInterrupted(): void { + if (interrupted) return; + interrupted = true; + controller.abort(new Error("interrupted by SIGINT")); +} + +export function wasInterrupted(): boolean { + return interrupted; +} + +/** Test hook: reset module state between in-process runs. */ +export function resetCommandContextForTests(): void { + current = null; +} diff --git a/src/internal/env-file.ts b/src/internal/env-file.ts new file mode 100644 index 0000000..95c84aa --- /dev/null +++ b/src/internal/env-file.ts @@ -0,0 +1,101 @@ +/** + * Explicit `--api-key-file` support (a dotenv-style file read for MESHY_API_KEY only). + * + * Only MESHY_API_KEY is read. The file is parsed, never executed: no variable + * expansion, no command substitution, no `source`. Other keys are ignored and + * never change process configuration (PATH, NODE_OPTIONS, base URLs …). + * + * Grammar (one assignment per line): + * [export ]KEY=value # value may be 'single' or "double" quoted + * # comment # blank lines and comments are skipped + * An unquoted value ends at the first ` #` (whitespace then hash). Quoted + * values keep their text verbatim, including `#`, `$` and spaces. `${…}`, + * backticks and `$(…)` are not expanded — a key containing them is invalid. + */ + +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute, resolve as resolvePath } from "node:path"; +import { CliError } from "./errors.js"; + +export const ENV_FILE_MAX_BYTES = 64 * 1024; +const TARGET_KEY = "MESHY_API_KEY"; + +export interface EnvFileResult { + path: string; + /** null when the file is valid but carries no MESHY_API_KEY assignment. */ + apiKey: string | null; + /** Other keys present in the file, reported so doctor can list them without values. */ + otherKeys: string[]; +} + +export function parseEnvFile(text: string, path = ""): { apiKey: string | null; otherKeys: string[] } { + let apiKey: string | null = null; + let seenTarget = false; + const otherKeys: string[] = []; + const lines = text.split(/\r?\n|\r/); + for (let i = 0; i < lines.length; i++) { + const rawLine = lines[i] ?? ""; + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); + if (!m) { + throw invalid(path, `line ${i + 1} is not a KEY=value assignment`); + } + const key = m[1]!; + const rest = m[2] ?? ""; + let value: string; + if (rest.startsWith('"') || rest.startsWith("'")) { + const quote = rest[0]!; + const end = rest.indexOf(quote, 1); + if (end === -1) throw invalid(path, `line ${i + 1} has an unterminated ${quote} quote`); + value = rest.slice(1, end); + const trailing = rest.slice(end + 1).trim(); + if (trailing && !trailing.startsWith("#")) { + throw invalid(path, `line ${i + 1} has unexpected text after the closing quote`); + } + } else { + // Unquoted: comment starts at whitespace followed by '#'. + const hash = rest.search(/\s#/); + value = (hash === -1 ? rest : rest.slice(0, hash)).trim(); + } + if (key !== TARGET_KEY) { + otherKeys.push(key); + continue; + } + if (seenTarget) throw invalid(path, `${TARGET_KEY} is assigned more than once`); + seenTarget = true; + if (/\$\{|\$\(|`|\$[A-Za-z_]/.test(value)) { + throw new CliError({ + code: "auth", + message: `${path}: ${TARGET_KEY} contains shell expansion syntax; env files are never evaluated — write the literal key`, + }); + } + if (/\s/.test(value)) { + throw new CliError({ code: "auth", message: `${path}: ${TARGET_KEY} contains whitespace; quote the value or remove the stray text` }); + } + apiKey = value; + } + return { apiKey, otherKeys }; +} + +/** Resolve, check and parse an explicit env file. Throws for anything that is not a readable, valid file. */ +export function loadEnvFile(path: string, cwd: string = process.cwd()): EnvFileResult { + const abs = isAbsolute(path) ? path : resolvePath(cwd, path); + let st: ReturnType; + try { + st = statSync(abs); + } catch { + throw new CliError({ code: "usage", message: `--api-key-file: file not found: ${path}` }); + } + if (!st.isFile()) throw new CliError({ code: "usage", message: `--api-key-file: not a regular file: ${path}` }); + if (st.size > ENV_FILE_MAX_BYTES) { + throw new CliError({ code: "usage", message: `--api-key-file: ${path} is larger than ${ENV_FILE_MAX_BYTES} bytes; env files hold keys, not data` }); + } + const text = readFileSync(abs, "utf8"); + const parsed = parseEnvFile(text, path); + return { path: abs, apiKey: parsed.apiKey, otherKeys: parsed.otherKeys }; +} + +function invalid(path: string, detail: string): CliError { + return new CliError({ code: "usage", message: `--api-key-file: ${path}: ${detail}` }); +} diff --git a/src/internal/errors.ts b/src/internal/errors.ts index 484db0b..e62ca30 100644 --- a/src/internal/errors.ts +++ b/src/internal/errors.ts @@ -1,9 +1,16 @@ /** * Terminal error presentation — routes a caught error to stderr with a * human-friendly line, emits the structured payload when --format=json, and - * maps MeshyApiError codes to conventional exit codes for scripting. + * maps error classes to conventional exit codes for scripting. + * + * Two output schemas share this module: + * - legacy: the additive `{name,message,status,code,path,hint,docs}` payload + * that 0.2.0 consumers parse (toErrorPayload / reportError). + * - v1: `classifyError` yields the stable code/exit/http/recovery tuple that + * result.ts wraps into the `meshy.cli/v1` envelope. */ +import { CommanderError } from "commander"; import { MeshyApiError } from "../client/errors.js"; import { emit, type OutputFormat } from "./output.js"; @@ -18,8 +25,69 @@ export const EXIT_CODES = { NETWORK: 7, TIMED_OUT: 8, CREDIT: 9, + SUBMISSION_UNKNOWN: 10, + LOCAL_IO: 11, + CHECK_FAILED: 12, + CHECK_UNKNOWN: 13, + INTERRUPTED: 130, } as const; +/** Stable machine vocabulary for v1 `error.code`. */ +export type CliErrorCode = + | "usage" + | "auth" + | "validation" + | "not_found" + | "rate_limit" + | "network" + | "timed_out" + | "credit" + | "server" + | "task_failed" + | "submission_unknown" + | "local_io" + | "check_failed" + | "check_unknown" + | "interrupted" + | "protocol" + | "operation_conflict" + | "internal"; + +export const EXIT_FOR_CODE: Record = { + usage: EXIT_CODES.USAGE, + auth: EXIT_CODES.AUTH, + validation: EXIT_CODES.VALIDATION, + not_found: EXIT_CODES.NOT_FOUND, + rate_limit: EXIT_CODES.RATE_LIMIT, + network: EXIT_CODES.NETWORK, + timed_out: EXIT_CODES.TIMED_OUT, + credit: EXIT_CODES.CREDIT, + server: EXIT_CODES.GENERIC, + task_failed: EXIT_CODES.GENERIC, + submission_unknown: EXIT_CODES.SUBMISSION_UNKNOWN, + local_io: EXIT_CODES.LOCAL_IO, + check_failed: EXIT_CODES.CHECK_FAILED, + check_unknown: EXIT_CODES.CHECK_UNKNOWN, + interrupted: EXIT_CODES.INTERRUPTED, + protocol: EXIT_CODES.GENERIC, + operation_conflict: EXIT_CODES.USAGE, + internal: EXIT_CODES.GENERIC, +}; + +export interface ErrorRecovery { + /** What the caller should do: reconcile | retry | wait | login | none … */ + action: string; + /** Whether the CLI performed it. Always false in S1 — nothing is retried for the caller. */ + automatic: boolean; + /** A command the caller can run verbatim, when one is known. */ + command?: string; +} + +export interface Warning { + code: string; + message: string; +} + export class UsageError extends Error { constructor(message: string) { super(message); @@ -27,6 +95,49 @@ export class UsageError extends Error { } } +/** + * Structured failure with everything the v1 envelope needs. `result` carries + * whatever partial state must survive the failure (a task id after a journal + * write error, the files already downloaded, the last task seen by a stream). + */ +export class CliError extends Error { + readonly code: CliErrorCode; + readonly exitCode: number; + readonly httpStatus: number | null; + readonly retryable: boolean; + readonly recovery: ErrorRecovery | null; + readonly result: Record | null; + readonly warnings: Warning[]; + readonly hint?: string; + readonly details?: unknown; + + constructor(params: { + code: CliErrorCode; + message: string; + exitCode?: number; + httpStatus?: number | null; + retryable?: boolean; + recovery?: ErrorRecovery | null; + result?: Record | null; + warnings?: Warning[]; + hint?: string; + details?: unknown; + cause?: unknown; + }) { + super(params.message, params.cause !== undefined ? { cause: params.cause } : undefined); + this.name = "CliError"; + this.code = params.code; + this.exitCode = params.exitCode ?? EXIT_FOR_CODE[params.code]; + this.httpStatus = params.httpStatus ?? null; + this.retryable = params.retryable ?? false; + this.recovery = params.recovery ?? null; + this.result = params.result ?? null; + this.warnings = params.warnings ?? []; + this.hint = params.hint; + this.details = params.details; + } +} + /** * An error that carries the command which fixes it. * @@ -100,8 +211,23 @@ function hintForApiError(err: MeshyApiError): { hint?: string; docs?: string } { } } +/** Commander error codes that mean "help/version was printed", not a failure. */ +export function isCommanderInformational(err: unknown): err is CommanderError { + return ( + err instanceof CommanderError && + (err.code === "commander.helpDisplayed" || + err.code === "commander.version" || + err.code === "commander.help" || + err.exitCode === 0) + ); +} + export function exitCodeFor(err: unknown): number { + if (err instanceof CliError) return err.exitCode; if (err instanceof UsageError) return EXIT_CODES.USAGE; + if (err instanceof CommanderError) { + return isCommanderInformational(err) ? err.exitCode : EXIT_CODES.USAGE; + } if (err instanceof HintedError) { // An explicit exitCode wins — the raiser knew the conventional code for // its own failure (a chained `make` step that times out is a timeout). @@ -128,6 +254,98 @@ export function exitCodeFor(err: unknown): number { return EXIT_CODES.GENERIC; } +export interface ClassifiedError { + code: CliErrorCode; + exitCode: number; + message: string; + httpStatus: number | null; + retryable: boolean; + recovery: ErrorRecovery | null; + hint?: string; + details?: unknown; + result: Record | null; + warnings: Warning[]; +} + +/** Map any thrown value onto the stable v1 error tuple. Never throws. */ +export function classifyError(err: unknown): ClassifiedError { + if (err instanceof CliError) { + return { + code: err.code, + exitCode: err.exitCode, + message: err.message, + httpStatus: err.httpStatus, + retryable: err.retryable, + recovery: err.recovery, + hint: err.hint, + details: err.details, + result: err.result, + warnings: err.warnings, + }; + } + if (err instanceof UsageError) { + return base("usage", err.message); + } + if (err instanceof CommanderError) { + return base("usage", err.message.replace(/^error:\s*/i, "").trim(), { details: { code: err.code } }); + } + if (err instanceof MeshyApiError) { + const code: CliErrorCode = err.code === "server" ? "server" : err.code; + const hints = hintForApiError(err); + const recovery: ErrorRecovery | null = + err.code === "auth" + ? { action: "login", automatic: false, command: "meshy auth login" } + : err.code === "rate_limit" + ? { action: "wait", automatic: false } + : err.code === "credit" + ? { action: "top_up", automatic: false, command: "meshy balance" } + : null; + return { + ...base(code, err.message, { httpStatus: err.status || null, details: { path: err.path, body: err.body } }), + recovery, + hint: hints.hint, + }; + } + if (err instanceof HintedError) { + let code: CliErrorCode = "internal"; + if (err.code === "unauthenticated") code = "auth"; + else if (err.code === "oauth_timeout" || err.code === "step_timeout") code = "timed_out"; + else if (err.code === "step_failed") code = "task_failed"; + const exitCode = err.exitCode ?? EXIT_FOR_CODE[code]; + return { + ...base(code, err.message, { details: { code: err.code, docs: err.docs } }), + exitCode, + hint: err.hint, + recovery: err.hint ? { action: "run_hint", automatic: false, command: err.hint } : null, + }; + } + if (err instanceof Error) { + if (err.name === "CredentialsFileError") return base("local_io", err.message); + if (err.name === "AbortError") return base("interrupted", err.message); + return base("internal", err.message, { details: { name: err.name } }); + } + return base("internal", String(err)); +} + +function base( + code: CliErrorCode, + message: string, + extra: { httpStatus?: number | null; details?: unknown } = {}, +): ClassifiedError { + return { + code, + exitCode: EXIT_FOR_CODE[code], + message, + httpStatus: extra.httpStatus ?? null, + retryable: false, + recovery: null, + details: extra.details, + result: null, + warnings: [], + }; +} + +/** Legacy stderr + payload reporter (unchanged shape for 0.2.0 consumers). */ export function reportError(err: unknown, format: OutputFormat): void { const payload = toErrorPayload(err); process.stderr.write(`error: ${payload.message}\n`); @@ -154,6 +372,19 @@ export function toErrorPayload(err: unknown): { name: string; message: string; [ if (err instanceof MeshyApiError) { return { ...err.toJSON(), ...hintForApiError(err), name: err.name, message: err.message }; } + if (err instanceof CliError) { + return { + name: err.name, + message: err.message, + code: err.code, + ...(err.httpStatus !== null ? { status: err.httpStatus } : {}), + ...(err.hint ? { hint: err.hint } : {}), + ...(err.result ? { result: err.result } : {}), + }; + } + if (err instanceof CommanderError) { + return { name: "UsageError", message: err.message.replace(/^error:\s*/i, "").trim(), code: err.code }; + } if (err instanceof HintedError) { // DeviceFlowError (a HintedError subclass) carries an oauthErrorCode field // that machine consumers need to distinguish access_denied / invalid_grant / diff --git a/src/internal/global-options.ts b/src/internal/global-options.ts index 95f872b..fc6481d 100644 --- a/src/internal/global-options.ts +++ b/src/internal/global-options.ts @@ -3,10 +3,11 @@ * regardless of position in argv (e.g. `meshy-cli balance --format pretty` * as well as `meshy-cli --format pretty balance`). * - * We declare them once on the root with visible help + defaults, and mirror - * them (hidden, no defaults) onto every descendant command. `optsWithGlobals` - * then merges any set value — a flag on the innermost command wins, and - * unset leaf options fall through to the parent's value or its default. + * Commander parses the root command's known options wherever they appear in + * argv (positional options are disabled), so a flag declared here is consumed + * by the root even when typed after the subcommand. The hidden mirrors on + * every descendant keep `optsWithGlobals()` uniform and make the flag visible + * to ` --help` walkers. */ import { Command, Option } from "commander"; @@ -17,13 +18,40 @@ const FACTORIES: OptionFactory[] = [ () => new Option("--api-key ", "Meshy API key (overrides MESHY_API_KEY)"), () => new Option("--base-url-v1 ", "override v1 base URL"), () => new Option("--base-url-v2 ", "override v2 base URL"), + () => + new Option( + "--base-url-creative-lab ", + "override the Creative Lab base URL (default: /openapi/creative-lab)", + ), () => new Option("--format ", "output format").choices(["json", "pretty", "ndjson"]), () => new Option("--json", "output as JSON (alias for --format json)"), + () => + new Option( + "--output-schema ", + "stdout data model: legacy (0.2.0-compatible, default for existing commands) | v1 (stable envelope; default for new commands)", + ).choices(["legacy", "v1"]), () => new Option( "-o, --output ", "download artifacts + write meta.json; switches stdout to a status report", ), + () => + new Option( + "--api-key-file ", + "read MESHY_API_KEY from this dotenv-style file (only that key is read; parsed, never executed; no .env auto-discovery)", + ), + // Node.js itself scans the whole argv for `--env-file` — even after the + // script name — loads the entire file into process.env (NODE_OPTIONS + // included) and exits 9 when it is missing. The flag therefore cannot be + // offered safely; it is registered only so a habitual `--env-file` gets an + // explanation instead of a silent, Node-side environment load. + () => new Option("--env-file ", "(unsupported) use --api-key-file").hideHelp(), + () => + new Option( + "--workspace ", + "restrict every file written by local tools and downloads to this directory", + ), + () => new Option("--no-update-check", "never query npm for a newer version in this process"), () => new Option("-v, --verbose", "debug logging"), () => new Option("--log-level ", "log level").choices([ @@ -57,6 +85,10 @@ export function mirrorGlobalOptionsToDescendants(root: Command): void { }); } +export function walkCommands(cmd: Command, visit: (c: Command) => void): void { + walk(cmd, visit); +} + function walk(cmd: Command, visit: (c: Command) => void): void { visit(cmd); for (const sub of cmd.commands) walk(sub, visit); diff --git a/src/internal/output.ts b/src/internal/output.ts index 0c61811..37c758a 100644 --- a/src/internal/output.ts +++ b/src/internal/output.ts @@ -2,12 +2,18 @@ * Output rendering. Keep stdout machine-parseable by default; `pretty` is * opt-in for human eyes. * - * Note: stdout JSON may carry `_notice.update` when a newer meshy-cli version - * is available. See update-notifier.ts for the two-channel design. + * Legacy path (`emit`): bare payloads, optionally decorated with + * `_notice.update` when a newer meshy-cli version is available. See + * update-notifier.ts for the two-channel design. + * + * v1 path (`emitEnvelope`): exactly one envelope object, never decorated — + * the six top-level keys are the contract. Humans on a TTY still get the + * update hint on stderr. */ import { writeFileSync } from "node:fs"; import { attachUpdateNotice, getUpdateNotice, printHumanUpdateHint } from "./update-notifier.js"; +import type { StreamEventEnvelope, V1Envelope } from "./result.js"; export type OutputFormat = "json" | "pretty" | "ndjson"; @@ -31,7 +37,33 @@ export function emit(value: unknown, opts: OutputOptions): void { printHumanUpdateHint(notice, process); } -function render(value: unknown, format: OutputFormat): string { +/** + * Write to stdout and resolve once the bytes are handed to the OS. Needed + * before an explicit process.exit(), which does not wait for pending writes + * on pipes — large JSON or a cancelled stream would otherwise be truncated. + */ +export function writeStdout(text: string): Promise { + return new Promise((resolve, reject) => { + const ok = process.stdout.write(text, (err) => (err ? reject(err) : resolve())); + if (ok) { + // Callback still fires; nothing else to do. + } + }); +} + +/** Print one v1 envelope in the requested rendering. */ +export async function emitEnvelope(envelope: V1Envelope, format: OutputFormat): Promise { + const text = format === "pretty" ? renderPretty(envelope) : format === "ndjson" ? JSON.stringify(envelope) : JSON.stringify(envelope, null, 2); + await writeStdout(`${text}\n`); + printHumanUpdateHint(getUpdateNotice(), process); +} + +/** Print one stream event (ndjson only; json/pretty callers print the final envelope instead). */ +export async function emitStreamEvent(event: StreamEventEnvelope): Promise { + await writeStdout(`${JSON.stringify(event)}\n`); +} + +export function render(value: unknown, format: OutputFormat): string { switch (format) { case "json": return JSON.stringify(value, null, 2); @@ -43,7 +75,7 @@ function render(value: unknown, format: OutputFormat): string { } } -function renderPretty(value: unknown, indent = 0): string { +export function renderPretty(value: unknown, indent = 0): string { const pad = " ".repeat(indent); if (value === null || value === undefined) return `${pad}-`; if (typeof value !== "object") return `${pad}${String(value)}`; diff --git a/src/internal/paths.ts b/src/internal/paths.ts new file mode 100644 index 0000000..5f4d4dd --- /dev/null +++ b/src/internal/paths.ts @@ -0,0 +1,109 @@ +/** + * Path containment and safe naming. + * + * Every file the CLI writes is checked against an authorised root: the + * explicit --workspace when given, otherwise the root the command itself + * authorised (a project directory, or the parent of an explicit output path). + * Containment is decided on real paths — the deepest existing ancestor is + * resolved with realpath so a symlinked directory cannot redirect a write — + * never with a string prefix test. + */ + +import { lstatSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve as resolvePath, sep, basename } from "node:path"; +import { CliError } from "./errors.js"; + +export interface ResolvedTarget { + /** Absolute, normalised path as given (symlink-free for the existing part). */ + path: string; + /** The authorised root the path was checked against. */ + root: string; + /** Path relative to root, POSIX separators. */ + relative: string; +} + +function errno(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code; +} + +/** Resolve the real path of the deepest existing ancestor and re-append the missing tail. */ +export function realpathLenient(path: string): string { + const abs = resolvePath(path); + const missing: string[] = []; + let cursor = abs; + for (;;) { + try { + const real = realpathSync.native(cursor); + return missing.length ? resolvePath(real, ...missing.reverse()) : real; + } catch (err) { + if (errno(err) !== "ENOENT" && errno(err) !== "ENOTDIR") throw err; + const parent = dirname(cursor); + if (parent === cursor) return abs; + missing.push(basename(cursor)); + cursor = parent; + } + } +} + +export function isInside(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** + * Resolve `target` (file or directory) against cwd and prove it lies inside + * `root`. A symlink at the leaf is rejected for write targets: replacing or + * following it could land the bytes outside the root. + */ +export function resolveWithinRoot( + target: string, + root: string, + opts: { cwd?: string; allowSymlinkLeaf?: boolean; label?: string } = {}, +): ResolvedTarget { + const cwd = opts.cwd ?? process.cwd(); + const label = opts.label ?? "path"; + const rootReal = realpathLenient(resolvePath(cwd, root)); + const absTarget = resolvePath(cwd, target); + let leaf: ReturnType | null = null; + try { + leaf = lstatSync(absTarget); + } catch (err) { + if (errno(err) !== "ENOENT" && errno(err) !== "ENOTDIR") throw err; + } + if (leaf?.isSymbolicLink() && !opts.allowSymlinkLeaf) { + throw new CliError({ + code: "local_io", + message: `${label} ${target} is a symbolic link; refusing to write through it`, + }); + } + const real = realpathLenient(absTarget); + if (!isInside(rootReal, real)) { + throw new CliError({ + code: "local_io", + message: `${label} ${target} resolves outside the authorised root ${rootReal}`, + }); + } + return { path: real, root: rootReal, relative: relative(rootReal, real).split(sep).join("/") }; +} + +const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i; + +/** + * Turn an arbitrary string (asset key, task id, project name) into a single + * safe path segment: lowercase-insensitive ASCII letters, digits, `.`, `_`, + * `-`; everything else becomes `_`; no leading dots, no reserved names, no + * empty result. + */ +export function safeSegment(input: string, fallback = "item"): string { + let s = input.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "").replace(/\.+$/, ""); + s = s.replace(/_{2,}/g, "_"); + if (!s) s = fallback; + if (WINDOWS_RESERVED.test(s)) s = `_${s}`; + return s.slice(0, 120); +} + +/** Lowercase file extension without the dot, or "" when the name has none or it is not a plain token. */ +export function safeExtension(ext: string): string { + const e = ext.replace(/^\./, "").toLowerCase(); + return /^[a-z0-9]{1,8}$/.test(e) ? e : ""; +} diff --git a/src/internal/result.ts b/src/internal/result.ts new file mode 100644 index 0000000..64cbeec --- /dev/null +++ b/src/internal/result.ts @@ -0,0 +1,76 @@ +/** + * The `meshy.cli/v1` machine contract. + * + * Every v1 command prints exactly one envelope on stdout (stream mode prints + * one per event, see StreamEventEnvelope). The six top-level keys never + * change; `result` is shaped per command. `ok` reports whether the CLI + * operation completed — a `get` that returns a FAILED task is `ok:true`, a + * `wait` that ends on a FAILED task is `ok:false` with the task preserved. + */ + +import { classifyError, type ErrorRecovery, type Warning } from "./errors.js"; + +export const SCHEMA_VERSION = "meshy.cli/v1"; + +export type OutputSchema = "legacy" | "v1"; + +export interface V1Error { + code: string; + message: string; + http_status: number | null; + retryable: boolean; + recovery: ErrorRecovery | null; + hint?: string; + details?: unknown; +} + +export interface V1Envelope { + schema_version: typeof SCHEMA_VERSION; + command: string; + ok: boolean; + result: R | null; + error: V1Error | null; + warnings: Warning[]; +} + +/** ndjson stream events add a local sequence and the event kind. */ +export interface StreamEventEnvelope extends V1Envelope { + event: "task" | "outcome" | "warning"; + sequence: number; +} + +export function okEnvelope(command: string, result: R, warnings: Warning[] = []): V1Envelope { + return { schema_version: SCHEMA_VERSION, command, ok: true, result, error: null, warnings }; +} + +/** + * Build a failure envelope from any thrown value. A partial `result` supplied + * by the caller wins over the error's own; both are kept when the caller passes + * nothing (a CliError carries the state that must outlive the failure). + */ +export function errorEnvelope( + command: string, + err: unknown, + opts: { result?: unknown; warnings?: Warning[] } = {}, +): { envelope: V1Envelope; exitCode: number } { + const c = classifyError(err); + const error: V1Error = { + code: c.code, + message: c.message, + http_status: c.httpStatus, + retryable: c.retryable, + recovery: c.recovery, + ...(c.hint ? { hint: c.hint } : {}), + ...(c.details !== undefined ? { details: c.details } : {}), + }; + const result = opts.result !== undefined ? opts.result : c.result; + const warnings = [...c.warnings, ...(opts.warnings ?? [])]; + return { + envelope: { schema_version: SCHEMA_VERSION, command, ok: false, result: result ?? null, error, warnings }, + exitCode: c.exitCode, + }; +} + +export function warning(code: string, message: string): Warning { + return { code, message }; +} diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index 37d872b..16340cd 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -1,28 +1,41 @@ /** - * Shared execution context bound to the root command — lazy-loads the client - * so sub-commands that don't need credentials (e.g. --help) still work. + * Execution contexts. * - * buildRuntime is async so it can silently refresh an expiring OAuth token - * before constructing the client. All callers are async command actions. + * buildRuntime — authenticated API runtime: resolves config (flags → + * env → env-file → stored profile), silently refreshes an + * expiring OAuth token, constructs the client. Built per + * call; nothing is cached across invocations so tests and + * chained commands never see a stale key/URL/format. + * buildLocalRuntime — local runtime for commands that must work without a + * credential or network: never touches the credential + * store, never refreshes anything. */ import { Command } from "commander"; import { MeshyClient } from "../client/index.js"; import { loadConfig, type ConfigOverrides, type MeshyConfig } from "./config.js"; import { credentialsPath, readCredentials, saveProfile } from "./credentials.js"; -import { authRequiredError } from "./errors.js"; -import { logger } from "./logger.js"; +import { authRequiredError, UsageError } from "./errors.js"; +import { logger, setLogLevel } from "./logger.js"; import { refreshTokens } from "./oauth.js"; import type { LogLevel } from "./logger.js"; import type { OutputFormat } from "./output.js"; +import type { OutputSchema } from "./result.js"; export interface GlobalFlags { apiKey?: string; baseUrlV1?: string; baseUrlV2?: string; + baseUrlCreativeLab?: string; format: OutputFormat; json?: boolean; + outputSchema?: OutputSchema; output?: string; + /** Path given to --api-key-file (only MESHY_API_KEY is read from it). */ + envFile?: string; + workspace?: string; + /** false when --no-update-check was given. */ + updateCheck: boolean; verbose: boolean; logLevel?: LogLevel; } @@ -33,7 +46,9 @@ export interface Runtime { readonly client: MeshyClient; } -let cached: Runtime | null = null; +export interface LocalRuntime { + readonly flags: GlobalFlags; +} /** 60-second skew window: refresh if token expires within this many ms. */ const REFRESH_SKEW_MS = 60_000; @@ -141,8 +156,19 @@ export async function refreshOAuthCredentialIfNeeded( } } +export function configOverridesFrom(flags: GlobalFlags): ConfigOverrides { + return { + apiKey: flags.apiKey, + baseUrlV1: flags.baseUrlV1, + baseUrlV2: flags.baseUrlV2, + baseUrlCreativeLab: flags.baseUrlCreativeLab, + envFile: flags.envFile, + logLevel: flags.verbose ? "debug" : flags.logLevel, + }; +} + /** - * Build (or return the cached) runtime context. + * Build the authenticated runtime for this invocation. * * When the active credential is an OAuth profile with a refresh_token and the * access_token is within REFRESH_SKEW_MS of expiry (or already expired), this @@ -154,20 +180,21 @@ export async function refreshOAuthCredentialIfNeeded( * and refresh fails, throws authRequiredError pointing at `meshy auth login`. */ export async function buildRuntime(flags: GlobalFlags): Promise { - if (cached) return cached; - - const overrides: ConfigOverrides = { - apiKey: flags.apiKey, - baseUrlV1: flags.baseUrlV1, - baseUrlV2: flags.baseUrlV2, - logLevel: flags.verbose ? "debug" : flags.logLevel, - }; - + const overrides = configOverridesFrom(flags); const config = await refreshOAuthCredentialIfNeeded(loadConfig(overrides), overrides); - const client = new MeshyClient(config); - cached = { flags, config, client }; - return cached; + return { flags, config, client }; +} + +/** Runtime for commands that need neither a credential nor the API. */ +export function buildLocalRuntime(flags: GlobalFlags): LocalRuntime { + const envLevel = (process.env.MESHY_LOG_LEVEL || "").toLowerCase(); + const fallback: LogLevel = + envLevel === "debug" || envLevel === "info" || envLevel === "warn" || envLevel === "error" || envLevel === "silent" + ? (envLevel as LogLevel) + : "warn"; + setLogLevel(flags.verbose ? "debug" : flags.logLevel ?? fallback); + return { flags }; } /** Pull the resolved global flags from the top-level command. */ @@ -176,26 +203,66 @@ export function readGlobalFlags(cmd: Command): GlobalFlags { apiKey?: string; baseUrlV1?: string; baseUrlV2?: string; + baseUrlCreativeLab?: string; format?: string; json?: boolean; + outputSchema?: string; output?: string; + apiKeyFile?: string; + envFile?: string; + workspace?: string; + updateCheck?: boolean; verbose?: boolean; logLevel?: string; }>(); + if (opts.envFile !== undefined) { + throw new UsageError( + "--env-file is intercepted by Node.js itself (it loads the whole file into the environment before meshy-cli starts and exits 9 when the file is missing). Use --api-key-file ; only MESHY_API_KEY is read from it.", + ); + } // --json is an alias for --format json; --json wins if both are set. const format = opts.json ? "json" : normalizeFormat(opts.format); return { apiKey: opts.apiKey, baseUrlV1: opts.baseUrlV1, baseUrlV2: opts.baseUrlV2, + baseUrlCreativeLab: opts.baseUrlCreativeLab, format, json: opts.json, + outputSchema: normalizeSchema(opts.outputSchema), output: opts.output, + envFile: opts.apiKeyFile, + workspace: opts.workspace, + updateCheck: opts.updateCheck !== false, verbose: Boolean(opts.verbose), logLevel: normalizeLogLevel(opts.logLevel), }; } +/** + * Decide which stdout data model a command uses. + * - `legacy` commands (everything 0.2.0 shipped) default to legacy and opt + * into v1 with `--output-schema v1`. + * - `v1` commands (everything new in S1) always emit v1; asking them for + * legacy is a usage error rather than a silent no-op. + */ +export function resolveSchema(flags: GlobalFlags, commandDefault: OutputSchema): OutputSchema { + if (commandDefault === "v1") { + if (flags.outputSchema === "legacy") { + throw new UsageError("--output-schema legacy is not available for this command; it only emits the v1 envelope"); + } + return "v1"; + } + return flags.outputSchema ?? "legacy"; +} + +function normalizeSchema(raw: string | undefined): OutputSchema | undefined { + if (raw === undefined) return undefined; + const v = raw.toLowerCase(); + if (v === "v1" || v === "legacy") return v; + throw new UsageError(`invalid --output-schema '${raw}'. Expected: legacy | v1`); +} + function normalizeFormat(raw: string | undefined): OutputFormat { const v = (raw ?? "json").toLowerCase(); if (v === "json" || v === "pretty" || v === "ndjson") return v; diff --git a/src/internal/update-notifier.ts b/src/internal/update-notifier.ts index 1adef7a..c3e1183 100644 --- a/src/internal/update-notifier.ts +++ b/src/internal/update-notifier.ts @@ -15,9 +15,9 @@ */ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { spawn } from "node:child_process"; +import { configDir } from "./credentials.js"; import { VERSION } from "./version.js"; import type { OutputFormat } from "./output.js"; @@ -64,9 +64,13 @@ interface UpdateState { // Path helpers // --------------------------------------------------------------------------- -/** Returns the path to the update-state cache file. */ +/** + * Returns the path to the update-state cache file. Lives in the config + * directory (MESHY_CONFIG_DIR when set, else ~/.config/meshy) so an isolated + * config dir also isolates the update cache. + */ export function stateFilePath(): string { - return join(homedir(), ".config", "meshy", "update-state.json"); + return join(configDir(), "update-state.json"); } // --------------------------------------------------------------------------- diff --git a/src/root.ts b/src/root.ts index 5a6935f..b8cf85f 100644 --- a/src/root.ts +++ b/src/root.ts @@ -15,6 +15,7 @@ import { REFRESH_COMMAND, runRefreshCommand } from "./internal/update-notifier.j import { mirrorGlobalOptionsToDescendants, registerRootGlobalOptions, + walkCommands, } from "./internal/global-options.js"; import { analyzePrintabilityCommand } from "./cmd/analyze-printability.js"; import { animateCommand } from "./cmd/animate.js"; @@ -38,6 +39,39 @@ import { textTo3dCommand } from "./cmd/text-to-3d.js"; import { textToImageCommand } from "./cmd/text-to-image.js"; import { textToMotionCommand } from "./cmd/text-to-motion.js"; +/** + * Commands added in S1. They only speak the v1 envelope; the error exit uses + * this set to pick the schema when a parse error happens before any action. + */ +export const V1_ONLY_COMMANDS: ReadonlySet = new Set([ + "uv-unwrap", + "creative-lab", + "animation-catalog", + "showcases", + "download", + "project", + "inspect", + "mesh", + "slicer", + "doctor", +]); + +/** + * Commands that never need the authenticated API. The background update check + * is skipped for them so local work spawns no network child. + */ +export const LOCAL_COMMANDS: ReadonlySet = new Set([ + "resources", + "project", + "inspect", + "mesh", + "slicer", + "doctor", + "download", + "animation-catalog", + REFRESH_COMMAND, +]); + const ROOT_LONG = `meshy-cli — command-line interface for the Meshy AI API. USAGE: @@ -53,7 +87,7 @@ EXAMPLES: meshy make "a red sports car" --max-credits 25 # refuse if the estimate is over # every endpoint, one at a time - meshy resources # index of the 17 commands + meshy resources # index of every command meshy text-to-3d create --mode preview --prompt "a red sports car" meshy image-to-3d get meshy delete # unified: works for any task @@ -70,26 +104,32 @@ AUTHENTICATION: auth use switch the active profile auth logout [--all] forget the stored credential - Resolution order: --api-key > MESHY_API_KEY > stored profile. The env var - stays ahead of the stored credential so CI is never overridden by whatever - a developer logged into on that machine. Non-production --base-url-v1 uses a - separate credentials.dev.json, so staging cannot clobber a production login. + Resolution order: --api-key > MESHY_API_KEY > --api-key-file > stored profile. The + env var stays ahead of the stored credential so CI is never overridden by + whatever a developer logged into on that machine. Non-production --base-url-v1 + uses a separate credentials.dev.json, so staging cannot clobber a production login. RESOURCE COMMANDS: - All 17 endpoint commands stay available and stay supported — they are - indexed by \`meshy resources\` instead of listed here, so this help does not - grow with the API. \`meshy resources --help\` also carries the shared - create/get/list/wait/delete verb contract. + All endpoint commands stay available and stay supported — they are indexed + by \`meshy resources\` instead of listed here, so this help does not grow + with the API. \`meshy resources --help\` also carries the shared + create/get/list/wait/stream/delete verb contract. GLOBAL FLAGS (accepted at any position in the command line): --api-key override MESHY_API_KEY --base-url-v1 override MESHY_BASE_URL_V1 --base-url-v2 override MESHY_BASE_URL_V2 + --base-url-creative-lab override the Creative Lab base (default: /openapi/creative-lab) --format json (default) | pretty | ndjson + --output-schema legacy (default for existing commands) | v1 (stable envelope; new commands) --output, -o download task artifacts to a file or directory, write meta.json alongside, and replace stdout with a status report (without -o: stdout keeps the JSON summary). + --api-key-file read MESHY_API_KEY from an explicit dotenv-style file + (only that key; never auto-discovered; do not use Node's --env-file) + --workspace confine every written file to this directory + --no-update-check skip the background npm version check --verbose, -v enable debug logging to stderr --log-level debug | info | warn | error | silent @@ -99,6 +139,7 @@ ENVIRONMENT: MESHY_CREDENTIALS_PATH exact credentials file (wins over the above) MESHY_BASE_URL_V1 default: https://api.meshy.ai/openapi/v1 MESHY_BASE_URL_V2 default: https://api.meshy.ai/openapi/v2 + MESHY_BASE_URL_CREATIVE_LAB default: derived from the v1 origin MESHY_OAUTH_AUTHORIZE_URL override the OAuth authorize page (staging/testing) MESHY_CLI_NO_BROWSER set to 1 to skip browser open (headless/agent use) MESHY_POLL_INTERVAL_MS default: 3000 @@ -157,5 +198,13 @@ export function buildRootCommand(): Command { mirrorGlobalOptionsToDescendants(program); + // Parse errors (unknown option/command, missing argument) must reach the + // unified exit in index.ts instead of Commander's own process.exit(1), so + // every command in the tree throws CommanderError. addCommand() does not + // inherit this setting, hence the walk. + walkCommands(program, (cmd) => { + cmd.exitOverride(); + }); + return program; } diff --git a/tests/cli-contract.test.ts b/tests/cli-contract.test.ts new file mode 100644 index 0000000..526aab9 --- /dev/null +++ b/tests/cli-contract.test.ts @@ -0,0 +1,284 @@ +/** + * Black-box contract tests for the output schema layer (T-001..T-005, T-010, + * T-012, T-100, T-102). Every run is a real subprocess against dist/ with an + * isolated config dir; API calls go to a loopback mock. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + isolatedEnv, + jsonReply, + parseSingleJson, + runCli, + startMockApi, + tmpDir, +} from "./helpers/cli.js"; + +const SIX = ["schema_version", "command", "ok", "result", "error", "warnings"]; + +test("--version and --help stay plain text and exit 0", async () => { + const v = await runCli(["--version"]); + assert.equal(v.code, 0); + assert.match(v.stdout.trim(), /^\d+\.\d+\.\d+/); + const h = await runCli(["--help"]); + assert.equal(h.code, 0); + assert.match(h.stdout, /USAGE:/); + const sub = await runCli(["balance", "--help"]); + assert.equal(sub.code, 0); + assert.match(sub.stdout, /--save-json/); +}); + +test("T-005 legacy: unknown option exits 2 with a parseable payload and no API request", async () => { + const r = await runCli(["balance", "--bogus"]); + assert.equal(r.code, 2, r.stderr); + const payload = parseSingleJson(r.stdout) as Record; + assert.equal(payload["name"], "UsageError"); + assert.match(String(payload["message"]), /unknown option/); +}); + +test("T-005 v1: unknown command, missing argument and bad --format are usage errors in the envelope", async () => { + const unknown = await runCli(["--output-schema", "v1", "no-such-command"]); + assert.equal(unknown.code, 2, unknown.stderr); + const env1 = parseSingleJson(unknown.stdout) as Record; + assert.deepEqual(Object.keys(env1), SIX); + assert.equal(env1["ok"], false); + assert.equal((env1["error"] as Record)["code"], "usage"); + + const missing = await runCli(["text-to-3d", "get", "--output-schema", "v1"]); + assert.equal(missing.code, 2, missing.stderr); + const env2 = parseSingleJson(missing.stdout) as Record; + assert.equal(env2["command"], "text-to-3d.get"); + assert.equal((env2["error"] as Record)["code"], "usage"); + + const badFormat = await runCli(["--output-schema", "v1", "--format", "yaml", "balance"]); + assert.equal(badFormat.code, 2, badFormat.stderr); + const env3 = parseSingleJson(badFormat.stdout) as Record; + assert.equal(env3["ok"], false); +}); + +test("T-002 v1 success: balance emits one six-key envelope; pretty and ndjson share semantics (T-003)", async () => { + const api = await startMockApi((req, res) => { + if (req.path === "/openapi/v1/balance") return jsonReply(res, 200, { balance: 42, currency: "credits" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const json = await runCli(["balance", "--output-schema", "v1"], { env: api.env() }); + assert.equal(json.code, 0, json.stderr); + const env = parseSingleJson(json.stdout) as Record; + assert.deepEqual(Object.keys(env), SIX); + assert.equal(env["schema_version"], "meshy.cli/v1"); + assert.equal(env["command"], "balance"); + assert.equal(env["ok"], true); + assert.deepEqual(env["result"], { balance: 42, saved_json: null }); + assert.equal(env["error"], null); + assert.deepEqual(env["warnings"], []); + assert.equal(api.requests.length, 1); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_fixture_key_loopback_only"); + + const nd = await runCli(["--output-schema", "v1", "--format", "ndjson", "balance"], { env: api.env() }); + assert.equal(nd.code, 0); + assert.equal(nd.stdout.trim().split("\n").length, 1); + assert.deepEqual(JSON.parse(nd.stdout.trim())["result"], { balance: 42, saved_json: null }); + + const pretty = await runCli(["balance", "--format", "pretty", "--output-schema", "v1"], { env: api.env() }); + assert.equal(pretty.code, 0); + assert.match(pretty.stdout, /^schema_version: meshy\.cli\/v1$/m); + assert.match(pretty.stdout, /balance: 42/); + } finally { + await api.close(); + } +}); + +test("T-001 legacy: balance output shape is unchanged without --output-schema", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, { balance: 7 })); + try { + const r = await runCli(["balance"], { env: api.env() }); + assert.equal(r.code, 0, r.stderr); + assert.equal(r.stdout, '{\n "balance": 7\n}\n'); + const file = join(tmpDir(), "out.json"); + const r2 = await runCli(["balance", "-o", file], { env: api.env() }); + assert.equal(r2.code, 0); + assert.equal(r2.stdout, ""); + assert.equal(readFileSync(file, "utf8"), '{\n "balance": 7\n}\n'); + } finally { + await api.close(); + } +}); + +test("T-004 global flags resolve identically at root, middle and end", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, { balance: 1 })); + try { + const a = await runCli(["--output-schema", "v1", "--format", "ndjson", "balance"], { env: api.env() }); + const b = await runCli(["balance", "--output-schema", "v1", "--format", "ndjson"], { env: api.env() }); + const c = await runCli(["--format", "pretty", "balance", "--json", "--output-schema", "v1"], { env: api.env() }); + assert.equal(a.stdout, b.stdout); + // --json wins over --format pretty. + assert.equal((parseSingleJson(c.stdout) as Record)["ok"], true); + } finally { + await api.close(); + } +}); + +test("T-010 v1: --save-json stores the raw body, -o is refused, existing files are never overwritten", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, { balance: 3, extra: { nested: true } })); + try { + const dir = tmpDir(); + const target = join(dir, "raw.json"); + const ok = await runCli(["balance", "--output-schema", "v1", "--save-json", target], { env: api.env(), cwd: dir }); + assert.equal(ok.code, 0, ok.stderr); + assert.deepEqual(JSON.parse(readFileSync(target, "utf8")), { balance: 3, extra: { nested: true } }); + const env = parseSingleJson(ok.stdout) as { result: { saved_json: { path: string; bytes: number } } }; + assert.equal(env.result.saved_json.path, realpathSync(target)); + + const again = await runCli(["balance", "--output-schema", "v1", "--save-json", target], { env: api.env(), cwd: dir }); + assert.equal(again.code, 11, again.stderr); + const err = parseSingleJson(again.stdout) as { error: { code: string } }; + assert.equal(err.error.code, "local_io"); + assert.deepEqual(JSON.parse(readFileSync(target, "utf8")), { balance: 3, extra: { nested: true } }, "original file untouched"); + + const withOutput = await runCli(["balance", "--output-schema", "v1", "-o", join(dir, "x.json")], { env: api.env(), cwd: dir }); + assert.equal(withOutput.code, 2); + assert.ok(!existsSync(join(dir, "x.json"))); + // -o was rejected before any request. + assert.equal(api.requests.length, 2); + } finally { + await api.close(); + } +}); + +test("T-011 v1: API failures map to codes/exits and never include the key", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 402, { message: "Insufficient credits" })); + try { + const r = await runCli(["balance", "--output-schema", "v1"], { env: api.env() }); + assert.equal(r.code, 9, r.stderr); + const env = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string; http_status: number; recovery: unknown } }; + assert.equal(env.ok, false); + assert.equal(env.error.code, "credit"); + assert.equal(env.error.http_status, 402); + assert.ok(!r.stdout.includes("msy_fixture_key_loopback_only")); + assert.ok(!r.stderr.includes("msy_fixture_key_loopback_only")); + } finally { + await api.close(); + } +}); + +test("--env-file is refused with an explanation (Node.js intercepts it)", async () => { + const dir = tmpDir(); + const file = join(dir, "k.env"); + writeFileSync(file, "MESHY_API_KEY=msy_x\n"); + const r = await runCli(["balance", "--env-file", file, "--output-schema", "v1"], { cwd: dir }); + assert.equal(r.code, 2, r.stderr); + const env = parseSingleJson(r.stdout) as { error: { message: string } }; + assert.match(env.error.message, /--api-key-file/); +}); + +test("T-100 credential priority: flag > env > api-key-file > profile; a broken key file never falls through", async () => { + const seen: string[] = []; + const api = await startMockApi((req, res) => { + seen.push(String(req.headers["authorization"])); + jsonReply(res, 200, { balance: 1 }); + }); + try { + const dir = tmpDir(); + const envFile = join(dir, "keys.env"); + writeFileSync(envFile, "MESHY_API_KEY=msy_from_file\n"); + + // env-file wins when neither flag nor env var is set. + const noEnv = await runCli(["balance", "--api-key-file", envFile], { env: api.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(noEnv.code, 0, noEnv.stderr); + assert.equal(seen.at(-1), "Bearer msy_from_file"); + + // env var beats env-file. + const withEnv = await runCli(["balance", "--api-key-file", envFile], { env: api.env(), cwd: dir }); + assert.equal(withEnv.code, 0, withEnv.stderr); + assert.equal(seen.at(-1), "Bearer msy_fixture_key_loopback_only"); + + // flag beats both. + const withFlag = await runCli(["balance", "--api-key-file", envFile, "--api-key", "msy_flag"], { env: api.env(), cwd: dir }); + assert.equal(withFlag.code, 0, withFlag.stderr); + assert.equal(seen.at(-1), "Bearer msy_flag"); + + // A malformed explicit file is an error even though the env var could have been used. + writeFileSync(envFile, "MESHY_API_KEY=$(curl evil)\n"); + const broken = await runCli(["balance", "--api-key-file", envFile, "--output-schema", "v1"], { env: api.env(), cwd: dir }); + assert.equal(broken.code, 3, broken.stderr); + const before = seen.length; + // A missing file is a usage error and makes no request. + const missing = await runCli(["balance", "--api-key-file", join(dir, "absent.env"), "--output-schema", "v1"], { env: api.env(), cwd: dir }); + assert.equal(missing.code, 2, missing.stderr); + assert.equal(seen.length, before); + // A file without the key does not fall back to the stored profile. + writeFileSync(envFile, "OTHER=1\n"); + const keyless = await runCli(["balance", "--api-key-file", envFile, "--output-schema", "v1"], { env: api.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(keyless.code, 3, keyless.stderr); + assert.equal(seen.length, before); + } finally { + await api.close(); + } +}); + +test("T-102 local commands run without a credential, without network and without an update child", async () => { + // A loopback "npm registry": the detached refresh child would GET it. The + // isolated config dir has no update cache, so any API command that is + // allowed to check for updates spawns the child (positive control) while a + // local command must not. + let registryHits = 0; + const registry = await startMockApi((_req, res) => { + registryHits += 1; + jsonReply(res, 200, { version: "0.0.1" }); + }); + const api = await startMockApi((_req, res) => jsonReply(res, 200, { balance: 1 })); + try { + const env = api.env({ + MESHY_CLI_NO_UPDATE_NOTIFIER: undefined, + MESHY_CLI_UPDATE_REGISTRY_URL: `${registry.url}/meshy-cli/latest`, + MESHY_API_KEY: undefined, + }); + const r = await runCli(["resources"], { env }); + assert.equal(r.code, 0, r.stderr); + const list = parseSingleJson(r.stdout) as unknown[]; + assert.ok(Array.isArray(list) && list.length > 0); + await new Promise((resolve) => setTimeout(resolve, 1500)); + assert.equal(registryHits, 0, "a local command must not spawn the update child"); + assert.ok(!existsSync(join(String(env["MESHY_CONFIG_DIR"]), "update-state.json"))); + + // --no-update-check also silences an API command. + const quiet = await runCli(["balance", "--no-update-check"], { env: { ...env, MESHY_API_KEY: "msy_x" } }); + assert.equal(quiet.code, 0, quiet.stderr); + await new Promise((resolve) => setTimeout(resolve, 1500)); + assert.equal(registryHits, 0, "--no-update-check must not spawn the update child"); + + // Positive control: the same API command without the opt-out does check. + const loud = await runCli(["balance"], { env: { ...env, MESHY_API_KEY: "msy_x" } }); + assert.equal(loud.code, 0, loud.stderr); + const deadline = Date.now() + 8000; + while (registryHits === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.ok(registryHits >= 1, "an API command with a stale cache must refresh it"); + } finally { + await registry.close(); + await api.close(); + } +}); + +test("T-012 consecutive runs never reuse a previous key or URL (no runtime cache across processes)", async () => { + const seenA: string[] = []; + const seenB: string[] = []; + const a = await startMockApi((req, res) => { seenA.push(String(req.headers["authorization"])); jsonReply(res, 200, { balance: 1 }); }); + const b = await startMockApi((req, res) => { seenB.push(String(req.headers["authorization"])); jsonReply(res, 200, { balance: 2 }); }); + try { + const ra = await runCli(["balance"], { env: a.env({ MESHY_API_KEY: "msy_a" }) }); + const rb = await runCli(["balance"], { env: b.env({ MESHY_API_KEY: "msy_b" }) }); + assert.equal(ra.code, 0); + assert.equal(rb.code, 0); + assert.deepEqual(seenA, ["Bearer msy_a"]); + assert.deepEqual(seenB, ["Bearer msy_b"]); + } finally { + await a.close(); + await b.close(); + } +}); diff --git a/tests/client.test.ts b/tests/client.test.ts index 5f3d667..f06d936 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -40,6 +40,8 @@ function buildConfig(): MeshyConfig { apiKey: "msy_test_key", baseUrlV1: "https://api.example.com/v1", baseUrlV2: "https://api.example.com/v2", + baseUrlCreativeLab: "https://api.example.com/openapi/creative-lab", + publicWebBase: "https://api.example.com/web/public", connectTimeoutMs: 1000, readTimeoutMs: 5000, pollIntervalMs: 10, diff --git a/tests/env-file.test.ts b/tests/env-file.test.ts new file mode 100644 index 0000000..61c640b --- /dev/null +++ b/tests/env-file.test.ts @@ -0,0 +1,81 @@ +/** + * --api-key-file parsing: only MESHY_API_KEY is read, nothing is evaluated, and a + * broken explicit file is an error rather than a fall-through (T-100, T-101). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadEnvFile, parseEnvFile } from "../src/internal/env-file.js"; +import { CliError } from "../src/internal/errors.js"; + +test("parseEnvFile — plain, quoted, export-prefixed and commented lines", () => { + const text = [ + "# leading comment", + "", + "OTHER=ignored value", + 'export MESHY_API_KEY="msy_quoted_key" # trailing comment', + "NODE_OPTIONS=--require ./evil.js", + "PATH=/tmp/evil:$PATH", + ].join("\n"); + const out = parseEnvFile(text); + assert.equal(out.apiKey, "msy_quoted_key"); + assert.deepEqual(out.otherKeys, ["OTHER", "NODE_OPTIONS", "PATH"]); + // Nothing leaked into the process. + assert.notEqual(process.env["NODE_OPTIONS"], "--require ./evil.js"); +}); + +test("parseEnvFile — unquoted value stops at a comment, single quotes keep # and $", () => { + assert.equal(parseEnvFile("MESHY_API_KEY=msy_a # c").apiKey, "msy_a"); + assert.equal(parseEnvFile("MESHY_API_KEY='msy_b#$'").apiKey, "msy_b#$"); + assert.equal(parseEnvFile("MESHY_API_KEY=\r\nOTHER=1\r\n").apiKey, ""); +}); + +test("parseEnvFile — CRLF line endings and Windows-style files parse", () => { + assert.equal(parseEnvFile("MESHY_API_KEY=msy_crlf\r\nX=1\r\n").apiKey, "msy_crlf"); +}); + +test("parseEnvFile — duplicate MESHY_API_KEY is an error", () => { + assert.throws( + () => parseEnvFile("MESHY_API_KEY=a\nMESHY_API_KEY=b"), + (err: unknown) => err instanceof CliError && err.code === "usage" && /more than once/.test(err.message), + ); +}); + +test("parseEnvFile — shell expansion syntax in the key is refused, not expanded", () => { + for (const bad of ["MESHY_API_KEY=${HOME}", "MESHY_API_KEY=`whoami`", "MESHY_API_KEY=$(id)", "MESHY_API_KEY=$FOO"]) { + assert.throws(() => parseEnvFile(bad), (err: unknown) => err instanceof CliError && err.code === "auth", bad); + } +}); + +test("parseEnvFile — malformed lines are errors, not silently skipped", () => { + assert.throws(() => parseEnvFile("this is not an assignment"), /not a KEY=value assignment/); + assert.throws(() => parseEnvFile('MESHY_API_KEY="unterminated'), /unterminated/); +}); + +test("parseEnvFile — file without the key returns apiKey null", () => { + const out = parseEnvFile("A=1\nB=2\n"); + assert.equal(out.apiKey, null); + assert.deepEqual(out.otherKeys, ["A", "B"]); +}); + +test("loadEnvFile — missing file, directory and oversized file are usage errors", () => { + const dir = mkdtempSync(join(tmpdir(), "meshy-envfile-")); + assert.throws(() => loadEnvFile(join(dir, "nope.env")), (e: unknown) => e instanceof CliError && e.code === "usage"); + const sub = join(dir, "adir"); + mkdirSync(sub); + assert.throws(() => loadEnvFile(sub), /not a regular file/); + const big = join(dir, "big.env"); + writeFileSync(big, `X=${"a".repeat(70 * 1024)}\n`); + assert.throws(() => loadEnvFile(big), /larger than/); +}); + +test("loadEnvFile — relative paths resolve against the given cwd", () => { + const dir = mkdtempSync(join(tmpdir(), "meshy-envfile-")); + writeFileSync(join(dir, ".env"), "MESHY_API_KEY=msy_rel\n"); + const out = loadEnvFile(".env", dir); + assert.equal(out.apiKey, "msy_rel"); + assert.equal(out.path, join(dir, ".env")); +}); diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts new file mode 100644 index 0000000..fddcaeb --- /dev/null +++ b/tests/helpers/cli.ts @@ -0,0 +1,184 @@ +/** + * Subprocess harness for black-box CLI tests. + * + * Every run gets an isolated MESHY_CONFIG_DIR (so no developer credential or + * update cache leaks in), the update notifier disabled, MESHY_API_KEY cleared + * unless the test sets it, and a private cwd. Tests inject a loopback API via + * MESHY_BASE_URL_V1/V2 when they need one. + */ + +import { spawn } from "node:child_process"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +export const DIST_ENTRY = join(repoRoot, "dist", "index.js"); + +export interface RunResult { + code: number; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +export interface RunOptions { + env?: Record; + cwd?: string; + timeoutMs?: number; + /** Send SIGINT after this many ms. */ + sigintAfterMs?: number; + stdin?: string; +} + +export function tmpDir(prefix = "meshy-cli-test-"): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function isolatedEnv(extra: Record = {}): Record { + const configDir = tmpDir("meshy-config-"); + const env: Record = { + PATH: process.env["PATH"], + HOME: process.env["HOME"], + TMPDIR: process.env["TMPDIR"], + SystemRoot: process.env["SystemRoot"], + MESHY_CLI_NO_UPDATE_NOTIFIER: "1", + MESHY_CLI_NO_BROWSER: "1", + MESHY_CONFIG_DIR: configDir, + MESHY_API_KEY: undefined, + MESHY_BASE_URL_V1: undefined, + MESHY_BASE_URL_V2: undefined, + MESHY_BASE_URL_CREATIVE_LAB: undefined, + MESHY_CREDENTIALS_PATH: undefined, + ...extra, + }; + for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k]; + return env; +} + +export function runCli(args: string[], opts: RunOptions = {}): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [DIST_ENTRY, ...args], { + env: opts.env ?? isolatedEnv(), + cwd: opts.cwd ?? tmpDir("meshy-cwd-"), + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs ?? 20_000); + let sigint: NodeJS.Timeout | undefined; + if (opts.sigintAfterMs !== undefined) sigint = setTimeout(() => child.kill("SIGINT"), opts.sigintAfterMs); + child.stdout.on("data", (c: Buffer) => (stdout += c.toString())); + child.stderr.on("data", (c: Buffer) => (stderr += c.toString())); + child.on("error", reject); + child.on("close", (code, signal) => { + clearTimeout(timer); + if (sigint) clearTimeout(sigint); + resolve({ code: code ?? -1, signal, stdout, stderr }); + }); + if (opts.stdin !== undefined) child.stdin.write(opts.stdin); + child.stdin.end(); + }); +} + +export interface RecordedRequest { + method: string; + url: string; + path: string; + headers: Record; + body: string; + json: unknown; +} + +export type Handler = (req: RecordedRequest, res: ServerResponse, raw: IncomingMessage) => void | Promise; + +export interface MockApi { + url: string; + origin: string; + requests: RecordedRequest[]; + close(): Promise; + /** Environment pointing every base URL at this server. */ + env(extra?: Record): Record; +} + +/** Loopback HTTP server; handler receives the buffered request. */ +export async function startMockApi(handler: Handler): Promise { + const requests: RecordedRequest[] = []; + const server = createServer(async (raw, res) => { + let body = ""; + for await (const chunk of raw) body += chunk; + let json: unknown = undefined; + try { + json = body ? JSON.parse(body) : undefined; + } catch { + json = undefined; + } + const rec: RecordedRequest = { + method: raw.method ?? "GET", + url: raw.url ?? "/", + path: (raw.url ?? "/").split("?")[0] ?? "/", + headers: raw.headers, + body, + json, + }; + requests.push(rec); + try { + await handler(rec, res, raw); + } catch (err) { + if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: String(err) })); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const addr = server.address(); + if (!addr || typeof addr !== "object") throw new Error("no address"); + const origin = `http://127.0.0.1:${addr.port}`; + return { + url: origin, + origin, + requests, + close: () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + env: (extra = {}) => + isolatedEnv({ + MESHY_API_KEY: "msy_fixture_key_loopback_only", + MESHY_BASE_URL_V1: `${origin}/openapi/v1`, + MESHY_BASE_URL_V2: `${origin}/openapi/v2`, + MESHY_POLL_INTERVAL_MS: "20", + ...extra, + }), + }; +} + +export function jsonReply(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +/** Parse stdout as exactly one JSON document (fails on stray text). */ +export function parseSingleJson(stdout: string): unknown { + const trimmed = stdout.trim(); + assertNoTrailingGarbage(trimmed); + return JSON.parse(trimmed); +} + +function assertNoTrailingGarbage(text: string): void { + // JSON.parse throws on trailing content; the helper exists to name the failure. + try { + JSON.parse(text); + } catch (err) { + throw new Error(`stdout is not a single JSON document:\n${text}\n(${(err as Error).message})`); + } +} + +export function parseNdjson(stdout: string): unknown[] { + return stdout + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l)); +} diff --git a/tests/result.test.ts b/tests/result.test.ts new file mode 100644 index 0000000..47ba370 --- /dev/null +++ b/tests/result.test.ts @@ -0,0 +1,96 @@ +/** + * v1 envelope construction and the error classification behind it (T-002, T-011). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { CommanderError } from "commander"; +import { MeshyApiError } from "../src/client/errors.js"; +import { + CliError, + classifyError, + exitCodeFor, + EXIT_CODES, + HintedError, + UsageError, + authRequiredError, +} from "../src/internal/errors.js"; +import { errorEnvelope, okEnvelope, SCHEMA_VERSION } from "../src/internal/result.js"; + +const SIX = ["schema_version", "command", "ok", "result", "error", "warnings"]; + +test("okEnvelope — exactly the six fixed keys", () => { + const env = okEnvelope("balance", { balance: 1 }); + assert.deepEqual(Object.keys(env), SIX); + assert.equal(env.schema_version, SCHEMA_VERSION); + assert.equal(env.ok, true); + assert.equal(env.error, null); + assert.deepEqual(env.warnings, []); +}); + +test("errorEnvelope — CliError carries code, exit, http status, recovery and partial result", () => { + const err = new CliError({ + code: "submission_unknown", + message: "request sent, outcome unknown", + recovery: { action: "reconcile", automatic: false }, + result: { submission: { state: "unknown", operation_id: "op-1" }, task: null }, + }); + const { envelope, exitCode } = errorEnvelope("text-to-3d.create", err); + assert.deepEqual(Object.keys(envelope), SIX); + assert.equal(exitCode, EXIT_CODES.SUBMISSION_UNKNOWN); + assert.equal(envelope.ok, false); + assert.equal(envelope.error?.code, "submission_unknown"); + assert.equal(envelope.error?.http_status, null); + assert.equal(envelope.error?.retryable, false); + assert.deepEqual(envelope.error?.recovery, { action: "reconcile", automatic: false }); + assert.deepEqual(envelope.result, { submission: { state: "unknown", operation_id: "op-1" }, task: null }); +}); + +test("classifyError — API errors map status to code/exit without leaking the credential", async () => { + const cases: Array<[number, string, number]> = [ + [400, "validation", 4], + [401, "auth", 3], + [402, "credit", 9], + [404, "not_found", 5], + [429, "rate_limit", 6], + [500, "server", 1], + ]; + for (const [status, code, exit] of cases) { + const err = new MeshyApiError({ message: `meshy api ${status} on /x: nope`, status, code: code === "server" ? "server" : (code as never), path: "/x" }); + const c = classifyError(err); + assert.equal(c.code, code, String(status)); + assert.equal(c.exitCode, exit, String(status)); + assert.equal(c.httpStatus, status); + assert.ok(!JSON.stringify(c).includes("Bearer")); + } + const net = classifyError(new MeshyApiError({ message: "network error", status: 0, code: "network", path: "/x" })); + assert.equal(net.code, "network"); + assert.equal(net.exitCode, 7); + assert.equal(net.httpStatus, null); +}); + +test("classifyError — usage, commander, hinted and unknown errors", () => { + assert.equal(classifyError(new UsageError("bad")).exitCode, 2); + const cmdErr = new CommanderError(1, "commander.unknownOption", "error: unknown option '--bogus'"); + const c = classifyError(cmdErr); + assert.equal(c.code, "usage"); + assert.equal(c.exitCode, 2); + assert.equal(c.message, "unknown option '--bogus'"); + assert.equal(classifyError(authRequiredError()).code, "auth"); + assert.equal(classifyError(authRequiredError()).exitCode, 3); + const timeout = new HintedError({ message: "t", code: "step_timeout", exitCode: 8 }); + assert.equal(classifyError(timeout).code, "timed_out"); + assert.equal(classifyError(timeout).exitCode, 8); + assert.equal(classifyError(new Error("boom")).code, "internal"); + assert.equal(classifyError("str").exitCode, 1); +}); + +test("exitCodeFor — legacy mapping keeps 0.2.0 codes and adds the new ones", () => { + assert.equal(exitCodeFor(new CommanderError(1, "commander.unknownOption", "x")), 2); + assert.equal(exitCodeFor(new CommanderError(0, "commander.helpDisplayed", "")), 0); + assert.equal(exitCodeFor(new CliError({ code: "local_io", message: "x" })), 11); + assert.equal(exitCodeFor(new CliError({ code: "check_failed", message: "x" })), 12); + assert.equal(exitCodeFor(new CliError({ code: "check_unknown", message: "x" })), 13); + assert.equal(exitCodeFor(new CliError({ code: "interrupted", message: "x" })), 130); + assert.equal(exitCodeFor(new UsageError("x")), 2); +}); From f05ff8720d418b6bea249355a69aca2b134e243f Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 14:56:21 +0800 Subject: [PATCH 03/26] feat(client): B02 transport, resource registry, catalog and showcases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transport.ts: per-family transports with path validation (no scheme-relative, userinfo, foreign-origin or '..' paths), redirect refusal, body-inclusive deadlines, body caps and failure phases (connect = never sent) - resource-registry.ts: 16 task resources + 4×2 Creative Lab stages, query resources and local tools; docs/skill-parity/endpoint-contracts.json is asserted against it - MeshyClient built from the registry; Creative Lab transport derived from the v1 origin with the stored-profile origin policy; public catalog transport never carries a credential; per-resource endpoint classes removed - TaskSchema gains face_count/consumed_credits/thumbnail_urls/alpha_thumbnail_url; task-view.ts builds the v1 TaskView from raw JSON (missing stays null) - media normalisation moves to the merged payload (declared fields only), accepts data: URIs, enforces size/format limits; texture_image_url covered - new commands: animation-catalog list (no key, local search), showcases list (single billable GET, alias warning); resources index is registry-driven - tests: transport boundaries, registry-vs-docs, TaskView, catalog/showcases black-box, media normalisation Co-Authored-By: Claude Fable 5.1 --- src/client/endpoints/analyze-printability.ts | 7 - src/client/endpoints/animate.ts | 8 - src/client/endpoints/animation-catalog.ts | 62 +++ src/client/endpoints/base.ts | 120 ++++-- src/client/endpoints/convert.ts | 7 - src/client/endpoints/image-to-3d.ts | 7 - src/client/endpoints/image-to-image.ts | 7 - src/client/endpoints/multi-color-print.ts | 7 - src/client/endpoints/multi-image-to-3d.ts | 7 - src/client/endpoints/remesh.ts | 7 - src/client/endpoints/repair-printability.ts | 7 - src/client/endpoints/resize.ts | 7 - src/client/endpoints/retexture.ts | 7 - src/client/endpoints/rigging.ts | 8 - src/client/endpoints/showcases.ts | 62 +++ src/client/endpoints/text-to-3d.ts | 8 - src/client/endpoints/text-to-image.ts | 7 - src/client/endpoints/text-to-motion.ts | 8 - src/client/index.ts | 237 +++++------- src/client/resource-registry.ts | 2 +- src/client/transport.ts | 373 +++++++++++++++++++ src/client/types.ts | 15 + src/cmd/animation-catalog.ts | 101 +++++ src/cmd/resources.ts | 41 +- src/cmd/showcases.ts | 82 ++++ src/internal/file-input.ts | 244 +++++++++--- src/internal/result.ts | 2 + src/internal/task-command.ts | 32 +- src/internal/task-view.ts | 131 +++++++ src/root.ts | 4 + tests/catalog-showcases.test.ts | 113 ++++++ tests/file-input.test.ts | 54 ++- tests/resource-registry.test.ts | 104 ++++++ tests/task-view.test.ts | 69 ++++ tests/transport.test.ts | 140 +++++++ 35 files changed, 1737 insertions(+), 360 deletions(-) delete mode 100644 src/client/endpoints/analyze-printability.ts delete mode 100644 src/client/endpoints/animate.ts create mode 100644 src/client/endpoints/animation-catalog.ts delete mode 100644 src/client/endpoints/convert.ts delete mode 100644 src/client/endpoints/image-to-3d.ts delete mode 100644 src/client/endpoints/image-to-image.ts delete mode 100644 src/client/endpoints/multi-color-print.ts delete mode 100644 src/client/endpoints/multi-image-to-3d.ts delete mode 100644 src/client/endpoints/remesh.ts delete mode 100644 src/client/endpoints/repair-printability.ts delete mode 100644 src/client/endpoints/resize.ts delete mode 100644 src/client/endpoints/retexture.ts delete mode 100644 src/client/endpoints/rigging.ts create mode 100644 src/client/endpoints/showcases.ts delete mode 100644 src/client/endpoints/text-to-3d.ts delete mode 100644 src/client/endpoints/text-to-image.ts delete mode 100644 src/client/endpoints/text-to-motion.ts create mode 100644 src/client/transport.ts create mode 100644 src/cmd/animation-catalog.ts create mode 100644 src/cmd/showcases.ts create mode 100644 src/internal/task-view.ts create mode 100644 tests/catalog-showcases.test.ts create mode 100644 tests/resource-registry.test.ts create mode 100644 tests/task-view.test.ts create mode 100644 tests/transport.test.ts diff --git a/src/client/endpoints/analyze-printability.ts b/src/client/endpoints/analyze-printability.ts deleted file mode 100644 index 7a8e4f8..0000000 --- a/src/client/endpoints/analyze-printability.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class AnalyzePrintabilityEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/print/analyze"); - } -} diff --git a/src/client/endpoints/animate.ts b/src/client/endpoints/animate.ts deleted file mode 100644 index bff07da..0000000 --- a/src/client/endpoints/animate.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -/** POST /openapi/v1/animations — plural path. */ -export class AnimateEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/animations"); - } -} diff --git a/src/client/endpoints/animation-catalog.ts b/src/client/endpoints/animation-catalog.ts new file mode 100644 index 0000000..80ee235 --- /dev/null +++ b/src/client/endpoints/animation-catalog.ts @@ -0,0 +1,62 @@ +/** + * GET /web/public/animations/resources — the public Animation Library. + * + * No credential is ever attached: the endpoint is public and the transport + * used here has none. `category` is the only server-side filter the frozen + * Skill baseline relies on; the response is `{result:{total,list}}`. + */ + +import { z } from "zod"; +import { MeshyApiError } from "../errors.js"; +import type { Transport } from "../transport.js"; + +export const AnimationCatalogEntrySchema = z + .object({ + id: z.number(), + key: z.string().optional(), + name: z.string().optional(), + category: z.string().optional(), + subCategory: z.string().optional(), + previewUrl: z.string().optional(), + rigType: z.string().optional(), + isDefault: z.boolean().optional(), + isFree: z.boolean().optional(), + }) + .passthrough(); +export type AnimationCatalogEntry = z.infer; + +const ResponseSchema = z + .object({ + result: z.object({ total: z.number().optional(), list: z.array(AnimationCatalogEntrySchema) }).passthrough(), + }) + .passthrough(); + +export const ANIMATION_CATEGORIES = ["WalkAndRun", "BodyMovements", "DailyActions", "Fighting", "Dancing"] as const; + +export class AnimationCatalogEndpoint { + static readonly PATH = "/animations/resources"; + private readonly transport: Transport; + + constructor(transport: Transport) { + if (transport.authenticated) throw new Error("the animation catalog must use an unauthenticated transport"); + this.transport = transport; + } + + async list(params: { category?: string } = {}, extras: { signal?: AbortSignal } = {}): Promise<{ entries: AnimationCatalogEntry[]; total: number | null; raw: unknown }> { + const resp = await this.transport.requestJson("GET", AnimationCatalogEndpoint.PATH, { + query: { category: params.category }, + signal: extras.signal, + }); + const parsed = ResponseSchema.safeParse(resp.json); + if (!parsed.success) { + throw new MeshyApiError({ + message: `unexpected catalog shape from ${AnimationCatalogEndpoint.PATH}: ${parsed.error.message}`, + status: resp.status, + code: "server", + path: AnimationCatalogEndpoint.PATH, + body: resp.json, + }); + } + return { entries: parsed.data.result.list, total: parsed.data.result.total ?? null, raw: resp.json }; + } +} diff --git a/src/client/endpoints/base.ts b/src/client/endpoints/base.ts index f6dbfc9..0312eb7 100644 --- a/src/client/endpoints/base.ts +++ b/src/client/endpoints/base.ts @@ -1,18 +1,24 @@ /** - * Base class for async-task Meshy endpoints: + * Async-task endpoint over one Transport: * POST / → { result: } * GET //:id → Task * GET / → Task[] * DELETE //:id → 200 + * GET //:id/stream → text/event-stream + * + * The `*Detailed` variants also return the raw JSON exactly as received, which + * is what --save-json, --include-raw and the v1 TaskView are built from. */ -import { mapHttpError, MeshyApiError } from "../errors.js"; +import { MeshyApiError } from "../errors.js"; +import type { Transport, StreamHandle } from "../transport.js"; import { TaskCreateResponseSchema, TaskSchema, type Task, } from "../types.js"; +/** Legacy fetch signature kept for the `api` passthrough and older call sites. */ export type HttpFetch = (path: string, init?: RequestInit) => Promise; export interface ListParams { @@ -21,78 +27,120 @@ export interface ListParams { sort_by?: string; } +export interface CreateResult { + taskId: string; + raw: unknown; + requestId: string | null; +} + +export interface RetrieveResult { + task: Task; + raw: unknown; +} + +export interface ListResult { + tasks: Task[]; + raw: unknown; +} + +export interface RequestExtras { + signal?: AbortSignal; + timeoutMs?: number; +} + export class TaskEndpoint { readonly resourcePath: string; - protected readonly http: HttpFetch; + protected readonly transport: Transport; - constructor(http: HttpFetch, resourcePath: string) { + constructor(transport: Transport, resourcePath: string) { if (!resourcePath.startsWith("/")) { throw new Error(`resourcePath must start with "/" (got ${resourcePath})`); } - this.http = http; + this.transport = transport; this.resourcePath = resourcePath; } - async create(payload: Record): Promise { - const resp = await this.http(this.resourcePath, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload ?? {}), + async create(payload: Record, extras: RequestExtras = {}): Promise { + return (await this.createDetailed(payload, extras)).taskId; + } + + async createDetailed(payload: Record, extras: RequestExtras = {}): Promise { + const resp = await this.transport.requestJson("POST", this.resourcePath, { + body: payload ?? {}, + signal: extras.signal, + timeoutMs: extras.timeoutMs, }); - if (!resp.ok) throw await mapHttpError(resp, this.resourcePath); - const raw: unknown = await resp.json(); - const parsed = TaskCreateResponseSchema.safeParse(raw); + const parsed = TaskCreateResponseSchema.safeParse(resp.json); if (!parsed.success) { throw new MeshyApiError({ message: `unexpected response from POST ${this.resourcePath}: ${parsed.error.message}`, status: resp.status, code: "server", path: this.resourcePath, - body: raw, + body: resp.json, }); } - return parsed.data.result; + return { taskId: parsed.data.result, raw: resp.json, requestId: resp.requestId }; } - async retrieve(taskId: string): Promise { + async retrieve(taskId: string, extras: RequestExtras = {}): Promise { + return (await this.retrieveDetailed(taskId, extras)).task; + } + + async retrieveDetailed(taskId: string, extras: RequestExtras = {}): Promise { if (!taskId) throw new Error("task_id is required"); const path = `${this.resourcePath}/${encodeURIComponent(taskId)}`; - const resp = await this.http(path, { method: "GET" }); - if (!resp.ok) throw await mapHttpError(resp, path); - const raw: unknown = await resp.json(); - const parsed = TaskSchema.safeParse(raw); + const resp = await this.transport.requestJson("GET", path, { signal: extras.signal, timeoutMs: extras.timeoutMs }); + const parsed = TaskSchema.safeParse(resp.json); if (!parsed.success) { throw new MeshyApiError({ message: `unexpected task shape from GET ${path}: ${parsed.error.message}`, status: resp.status, code: "server", path, - body: raw, + body: resp.json, }); } - return parsed.data; + return { task: parsed.data, raw: resp.json }; } - async list(params: ListParams = {}): Promise { - const search = new URLSearchParams(); - search.set("page_num", String(params.page_num ?? 1)); - search.set("page_size", String(params.page_size ?? 10)); - search.set("sort_by", params.sort_by ?? "-created_at"); - const path = `${this.resourcePath}?${search.toString()}`; - const resp = await this.http(path, { method: "GET" }); - if (!resp.ok) throw await mapHttpError(resp, this.resourcePath); - const raw: unknown = await resp.json(); - if (!Array.isArray(raw)) return []; - return raw.map((t) => { + async list(params: ListParams = {}, extras: RequestExtras = {}): Promise { + return (await this.listDetailed(params, extras)).tasks; + } + + async listDetailed(params: ListParams = {}, extras: RequestExtras = {}): Promise { + const resp = await this.transport.requestJson("GET", this.resourcePath, { + query: { + page_num: params.page_num ?? 1, + page_size: params.page_size ?? 10, + sort_by: params.sort_by ?? "-created_at", + }, + signal: extras.signal, + timeoutMs: extras.timeoutMs, + }); + const raw = resp.json; + if (!Array.isArray(raw)) return { tasks: [], raw }; + const tasks = raw.map((t) => { const parsed = TaskSchema.safeParse(t); return parsed.success ? parsed.data : (t as Task); }); + return { tasks, raw }; } - async delete(taskId: string): Promise { + async delete(taskId: string, extras: RequestExtras = {}): Promise { if (!taskId) throw new Error("task_id is required"); const path = `${this.resourcePath}/${encodeURIComponent(taskId)}`; - const resp = await this.http(path, { method: "DELETE" }); - if (!resp.ok) throw await mapHttpError(resp, path); + const resp = await this.transport.requestJson("DELETE", path, { signal: extras.signal, timeoutMs: extras.timeoutMs }); + return resp.json; + } + + streamPath(taskId: string): string { + if (!taskId) throw new Error("task_id is required"); + return `${this.resourcePath}/${encodeURIComponent(taskId)}/stream`; + } + + /** Open the SSE connection; the caller parses events and owns the deadlines. */ + async openStream(taskId: string, opts: { signal?: AbortSignal; connectTimeoutMs?: number } = {}): Promise { + return this.transport.openStream(this.streamPath(taskId), opts); } } diff --git a/src/client/endpoints/convert.ts b/src/client/endpoints/convert.ts deleted file mode 100644 index 749fdc9..0000000 --- a/src/client/endpoints/convert.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class ConvertEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/convert"); - } -} diff --git a/src/client/endpoints/image-to-3d.ts b/src/client/endpoints/image-to-3d.ts deleted file mode 100644 index 0a7eddc..0000000 --- a/src/client/endpoints/image-to-3d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class ImageTo3DEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/image-to-3d"); - } -} diff --git a/src/client/endpoints/image-to-image.ts b/src/client/endpoints/image-to-image.ts deleted file mode 100644 index de0cf1e..0000000 --- a/src/client/endpoints/image-to-image.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class ImageToImageEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/image-to-image"); - } -} diff --git a/src/client/endpoints/multi-color-print.ts b/src/client/endpoints/multi-color-print.ts deleted file mode 100644 index a5cd81c..0000000 --- a/src/client/endpoints/multi-color-print.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class MultiColorPrintEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/print/multi-color"); - } -} diff --git a/src/client/endpoints/multi-image-to-3d.ts b/src/client/endpoints/multi-image-to-3d.ts deleted file mode 100644 index d269f16..0000000 --- a/src/client/endpoints/multi-image-to-3d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class MultiImageTo3DEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/multi-image-to-3d"); - } -} diff --git a/src/client/endpoints/remesh.ts b/src/client/endpoints/remesh.ts deleted file mode 100644 index 27deeed..0000000 --- a/src/client/endpoints/remesh.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class RemeshEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/remesh"); - } -} diff --git a/src/client/endpoints/repair-printability.ts b/src/client/endpoints/repair-printability.ts deleted file mode 100644 index 56f0e2b..0000000 --- a/src/client/endpoints/repair-printability.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class RepairPrintabilityEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/print/repair"); - } -} diff --git a/src/client/endpoints/resize.ts b/src/client/endpoints/resize.ts deleted file mode 100644 index 8a78b3e..0000000 --- a/src/client/endpoints/resize.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class ResizeEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/resize"); - } -} diff --git a/src/client/endpoints/retexture.ts b/src/client/endpoints/retexture.ts deleted file mode 100644 index c32e948..0000000 --- a/src/client/endpoints/retexture.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class RetextureEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/retexture"); - } -} diff --git a/src/client/endpoints/rigging.ts b/src/client/endpoints/rigging.ts deleted file mode 100644 index 2b1a05f..0000000 --- a/src/client/endpoints/rigging.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -/** Note: Meshy does not expose a list endpoint for rigging. */ -export class RiggingEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/rigging"); - } -} diff --git a/src/client/endpoints/showcases.ts b/src/client/endpoints/showcases.ts new file mode 100644 index 0000000..b07a674 --- /dev/null +++ b/src/client/endpoints/showcases.ts @@ -0,0 +1,62 @@ +/** + * GET /openapi/v1/showcases — Enterprise showcase search. + * + * Every call may be billed (1 credit per request per the official docs), so + * the endpoint performs exactly one GET, never retries and never paginates on + * its own. Items are passed through untouched. + */ + +import { MeshyApiError } from "../errors.js"; +import type { Transport } from "../transport.js"; + +export const SHOWCASE_SORT_BY = ["+created_at", "-created_at", "+updated_at", "-updated_at", "+downloads", "-downloads"] as const; +export const SHOWCASE_FORMATS = ["glb", "fbx", "obj", "usdz"] as const; +/** Server enum (checked read-only against the server binding); the docs spell the second value `animated`. */ +export const SHOWCASE_TYPES = ["all", "animate", "static"] as const; + +export interface ShowcaseListParams { + page_size?: number; + sort_by?: string; + search?: string; + format?: string; + showcase_type?: string; +} + +export class ShowcasesEndpoint { + static readonly PATH = "/showcases"; + private readonly transport: Transport; + + constructor(transport: Transport) { + this.transport = transport; + } + + async list(params: ShowcaseListParams, extras: { signal?: AbortSignal } = {}): Promise<{ items: Record[]; raw: unknown }> { + const resp = await this.transport.requestJson("GET", ShowcasesEndpoint.PATH, { + query: { + page_size: params.page_size, + sort_by: params.sort_by, + search: params.search, + format: params.format, + showcase_type: params.showcase_type, + }, + signal: extras.signal, + }); + const raw = resp.json; + const list = Array.isArray(raw) + ? raw + : raw && typeof raw === "object" && Array.isArray((raw as Record)["result"]) + ? ((raw as Record)["result"] as unknown[]) + : null; + if (!list) { + throw new MeshyApiError({ + message: `unexpected showcases shape from ${ShowcasesEndpoint.PATH}`, + status: resp.status, + code: "server", + path: ShowcasesEndpoint.PATH, + body: raw, + }); + } + const items = list.filter((x): x is Record => Boolean(x) && typeof x === "object" && !Array.isArray(x)); + return { items, raw }; + } +} diff --git a/src/client/endpoints/text-to-3d.ts b/src/client/endpoints/text-to-3d.ts deleted file mode 100644 index f6b68d9..0000000 --- a/src/client/endpoints/text-to-3d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -/** POST /openapi/v2/text-to-3d — the only v2-hosted endpoint. */ -export class TextTo3DEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/text-to-3d"); - } -} diff --git a/src/client/endpoints/text-to-image.ts b/src/client/endpoints/text-to-image.ts deleted file mode 100644 index 4161949..0000000 --- a/src/client/endpoints/text-to-image.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -export class TextToImageEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/text-to-image"); - } -} diff --git a/src/client/endpoints/text-to-motion.ts b/src/client/endpoints/text-to-motion.ts deleted file mode 100644 index 69c3ecd..0000000 --- a/src/client/endpoints/text-to-motion.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { TaskEndpoint, type HttpFetch } from "./base.js"; - -/** POST/GET/LIST/DELETE /openapi/v1/text-to-motion. */ -export class TextToMotionEndpoint extends TaskEndpoint { - constructor(http: HttpFetch) { - super(http, "/text-to-motion"); - } -} diff --git a/src/client/index.ts b/src/client/index.ts index 3e30b48..c4f5eb9 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,150 +1,115 @@ /** - * MeshyClient — two fetchers (v1 + v2), typed endpoint namespaces. + * MeshyClient — one transport per API family, endpoints resolved from the + * resource registry. + * + * v1 / v2 bearer credential, resolved by config + * creative-lab bearer credential; base derived from the v1 origin or given + * explicitly; a stored profile is never sent to another origin + * public-web no credential (animation catalog) */ -import { AnalyzePrintabilityEndpoint } from "./endpoints/analyze-printability.js"; -import { AnimateEndpoint } from "./endpoints/animate.js"; import { BalanceEndpoint } from "./endpoints/balance.js"; -import { ConvertEndpoint } from "./endpoints/convert.js"; -import { ResizeEndpoint } from "./endpoints/resize.js"; -import { ImageTo3DEndpoint } from "./endpoints/image-to-3d.js"; -import { ImageToImageEndpoint } from "./endpoints/image-to-image.js"; -import { MultiColorPrintEndpoint } from "./endpoints/multi-color-print.js"; -import { MultiImageTo3DEndpoint } from "./endpoints/multi-image-to-3d.js"; -import { RemeshEndpoint } from "./endpoints/remesh.js"; -import { RepairPrintabilityEndpoint } from "./endpoints/repair-printability.js"; -import { RetextureEndpoint } from "./endpoints/retexture.js"; -import { RiggingEndpoint } from "./endpoints/rigging.js"; -import { TaskEndpoint, type HttpFetch } from "./endpoints/base.js"; -import { TextTo3DEndpoint } from "./endpoints/text-to-3d.js"; -import { TextToImageEndpoint } from "./endpoints/text-to-image.js"; -import { TextToMotionEndpoint } from "./endpoints/text-to-motion.js"; -import { mapHttpError, MeshyApiError } from "./errors.js"; +import { TaskEndpoint } from "./endpoints/base.js"; +import { AnimationCatalogEndpoint } from "./endpoints/animation-catalog.js"; +import { ShowcasesEndpoint } from "./endpoints/showcases.js"; +import { + creativeLabResource, + requireTaskResource, + TASK_RESOURCES, + type ApiBase, + type CreativeLabProduct, + type CreativeLabStage, + type TaskResourceDescriptor, +} from "./resource-registry.js"; +import { createTransport, type Transport } from "./transport.js"; import { assertCredentialAllowedForOrigin, type MeshyConfig } from "../internal/config.js"; import { UsageError } from "../internal/errors.js"; -import { logger } from "../internal/logger.js"; -import { USER_AGENT } from "../internal/user-agent.js"; -function makeFetcher( - baseUrl: string, - apiKey: string, - readTimeoutMs: number, - credentialKind: "oauth" | "api_key", -): HttpFetch { - return async function (path: string, init?: RequestInit): Promise { - const url = path.startsWith("http") ? path : `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`; - const headers = new Headers(init?.headers); - headers.set("Authorization", `Bearer ${apiKey}`); - headers.set("User-Agent", USER_AGENT); - if (!headers.has("Accept")) headers.set("Accept", "application/json"); - - const controller = new AbortController(); - const timer = setTimeout( - () => controller.abort(new Error(`read timeout after ${readTimeoutMs}ms`)), - readTimeoutMs, - ); - try { - logger.debug(`HTTP ${init?.method ?? "GET"} ${url}`); - const resp = await fetch(url, { ...init, headers, signal: controller.signal }); - logger.debug(`HTTP ${resp.status} ${url}`); - if (!resp.ok) { - throw await mapHttpError(resp, path, credentialKind); - } - return resp; - } catch (err) { - if (err instanceof MeshyApiError) throw err; - if (err instanceof Error && err.name === "AbortError") { - throw new MeshyApiError({ - message: `request to ${url} timed out after ${readTimeoutMs}ms`, - status: 0, - code: "network", - path, - credentialKind, - }); - } - const msg = err instanceof Error ? err.message : String(err); - throw new MeshyApiError({ - message: `network error calling ${url}: ${msg}`, - status: 0, - code: "network", - path, - credentialKind, - }); - } finally { - clearTimeout(timer); - } - }; -} +export type { HttpFetch } from "./endpoints/base.js"; export class MeshyClient { readonly config: MeshyConfig; readonly balance: BalanceEndpoint; - readonly textTo3d: TextTo3DEndpoint; - readonly imageTo3d: ImageTo3DEndpoint; - readonly multiImageTo3d: MultiImageTo3DEndpoint; - readonly remesh: RemeshEndpoint; - readonly convert: ConvertEndpoint; - readonly resize: ResizeEndpoint; - readonly rigging: RiggingEndpoint; - readonly animate: AnimateEndpoint; - readonly retexture: RetextureEndpoint; - readonly textToImage: TextToImageEndpoint; - readonly textToMotion: TextToMotionEndpoint; - readonly imageToImage: ImageToImageEndpoint; - readonly multiColorPrint: MultiColorPrintEndpoint; - readonly analyzePrintability: AnalyzePrintabilityEndpoint; - readonly repairPrintability: RepairPrintabilityEndpoint; + readonly showcases: ShowcasesEndpoint; + /** Public catalog — built on a transport without any credential. */ + readonly catalog: AnimationCatalogEndpoint; - private readonly v1Fetch: HttpFetch; - private readonly v2Fetch: HttpFetch; - private readonly creativeLabFetch: HttpFetch | null; + private readonly v1: Transport; + private readonly v2: Transport; + private creativeLabTransport: Transport | null = null; + private readonly endpoints = new Map(); - constructor(config: MeshyConfig) { + constructor(config: MeshyConfig, opts: { fetchImpl?: typeof fetch } = {}) { this.config = config; - this.v1Fetch = makeFetcher(config.baseUrlV1, config.apiKey, config.readTimeoutMs, config.credentialKind); - this.v2Fetch = makeFetcher(config.baseUrlV2, config.apiKey, config.readTimeoutMs, config.credentialKind); - this.creativeLabFetch = config.baseUrlCreativeLab - ? makeFetcher(config.baseUrlCreativeLab, config.apiKey, config.readTimeoutMs, config.credentialKind) - : null; - - this.balance = new BalanceEndpoint(this.v1Fetch); - this.textTo3d = new TextTo3DEndpoint(this.v2Fetch); - this.imageTo3d = new ImageTo3DEndpoint(this.v1Fetch); - this.multiImageTo3d = new MultiImageTo3DEndpoint(this.v1Fetch); - this.remesh = new RemeshEndpoint(this.v1Fetch); - this.convert = new ConvertEndpoint(this.v1Fetch); - this.resize = new ResizeEndpoint(this.v1Fetch); - this.rigging = new RiggingEndpoint(this.v1Fetch); - this.animate = new AnimateEndpoint(this.v1Fetch); - this.retexture = new RetextureEndpoint(this.v1Fetch); - this.textToImage = new TextToImageEndpoint(this.v1Fetch); - this.textToMotion = new TextToMotionEndpoint(this.v1Fetch); - this.imageToImage = new ImageToImageEndpoint(this.v1Fetch); - this.multiColorPrint = new MultiColorPrintEndpoint(this.v1Fetch); - this.analyzePrintability = new AnalyzePrintabilityEndpoint(this.v1Fetch); - this.repairPrintability = new RepairPrintabilityEndpoint(this.v1Fetch); + const common = { readTimeoutMs: config.readTimeoutMs, fetchImpl: opts.fetchImpl, credentialKind: config.credentialKind }; + this.v1 = createTransport({ ...common, baseUrl: config.baseUrlV1, apiKey: config.apiKey }); + this.v2 = createTransport({ ...common, baseUrl: config.baseUrlV2, apiKey: config.apiKey }); + const publicTransport = createTransport({ baseUrl: config.publicWebBase, readTimeoutMs: config.readTimeoutMs, fetchImpl: opts.fetchImpl }); + this.balance = new BalanceEndpoint((path, init) => this.v1.fetchRaw(path, init)); + this.showcases = new ShowcasesEndpoint(this.v1); + this.catalog = new AnimationCatalogEndpoint(publicTransport); } - endpointFor(resource: ResourceName): TaskEndpoint { - switch (resource) { - case "text-to-3d": return this.textTo3d; - case "image-to-3d": return this.imageTo3d; - case "multi-image-to-3d": return this.multiImageTo3d; - case "remesh": return this.remesh; - case "convert": return this.convert; - case "resize": return this.resize; - case "rigging": return this.rigging; - case "animate": return this.animate; - case "retexture": return this.retexture; - case "text-to-image": return this.textToImage; - case "text-to-motion": return this.textToMotion; - case "image-to-image": return this.imageToImage; - case "multi-color-print": return this.multiColorPrint; - case "analyze-printability": return this.analyzePrintability; - case "repair-printability": return this.repairPrintability; + private transportFor(base: ApiBase): Transport { + switch (base) { + case "v1": + return this.v1; + case "v2": + return this.v2; + case "creative-lab": { + if (this.creativeLabTransport) return this.creativeLabTransport; + const baseUrl = this.config.baseUrlCreativeLab; + if (!baseUrl) { + throw new UsageError( + "the Creative Lab base URL cannot be derived from --base-url-v1; pass --base-url-creative-lab (or MESHY_BASE_URL_CREATIVE_LAB)", + ); + } + assertCredentialAllowedForOrigin(this.config, baseUrl, "Creative Lab"); + this.creativeLabTransport = createTransport({ + baseUrl, + apiKey: this.config.apiKey, + credentialKind: this.config.credentialKind, + readTimeoutMs: this.config.readTimeoutMs, + }); + return this.creativeLabTransport; + } } } + /** Endpoint for any registered task resource (`text-to-3d`, `creative-lab.lamp.build`, …). */ + endpointFor(resource: string | TaskResourceDescriptor): TaskEndpoint { + const d = typeof resource === "string" ? requireTaskResource(resource) : resource; + const existing = this.endpoints.get(d.id); + if (existing) return existing; + const ep = new TaskEndpoint(this.transportFor(d.base), d.relativePath); + this.endpoints.set(d.id, ep); + return ep; + } + + creativeLab(product: CreativeLabProduct | string, stage: CreativeLabStage | string): TaskEndpoint { + const d = creativeLabResource(product, stage); + if (!d) throw new UsageError(`unknown Creative Lab product/stage '${product}/${stage}'`); + return this.endpointFor(d); + } + + // Named accessors kept for existing call sites and tests. + get textTo3d(): TaskEndpoint { return this.endpointFor("text-to-3d"); } + get imageTo3d(): TaskEndpoint { return this.endpointFor("image-to-3d"); } + get multiImageTo3d(): TaskEndpoint { return this.endpointFor("multi-image-to-3d"); } + get remesh(): TaskEndpoint { return this.endpointFor("remesh"); } + get convert(): TaskEndpoint { return this.endpointFor("convert"); } + get resize(): TaskEndpoint { return this.endpointFor("resize"); } + get rigging(): TaskEndpoint { return this.endpointFor("rigging"); } + get animate(): TaskEndpoint { return this.endpointFor("animate"); } + get retexture(): TaskEndpoint { return this.endpointFor("retexture"); } + get textToImage(): TaskEndpoint { return this.endpointFor("text-to-image"); } + get textToMotion(): TaskEndpoint { return this.endpointFor("text-to-motion"); } + get imageToImage(): TaskEndpoint { return this.endpointFor("image-to-image"); } + get multiColorPrint(): TaskEndpoint { return this.endpointFor("multi-color-print"); } + get analyzePrintability(): TaskEndpoint { return this.endpointFor("analyze-printability"); } + get repairPrintability(): TaskEndpoint { return this.endpointFor("repair-printability"); } + get uvUnwrap(): TaskEndpoint { return this.endpointFor("uv-unwrap"); } + /** Raw HTTP passthrough for `meshy api …` — selects the API family by flag. */ async raw( apiVersion: "v1" | "v2" | "creative-lab", @@ -152,20 +117,11 @@ export class MeshyClient { path: string, init?: RequestInit, ): Promise { - if (apiVersion === "creative-lab") { - if (!this.creativeLabFetch) { - throw new UsageError( - "the Creative Lab base URL cannot be derived from --base-url-v1; pass --base-url-creative-lab (or MESHY_BASE_URL_CREATIVE_LAB)", - ); - } - assertCredentialAllowedForOrigin(this.config, this.config.baseUrlCreativeLab!, "Creative Lab"); - return this.creativeLabFetch(path, { ...init, method }); - } - const fetcher = apiVersion === "v2" ? this.v2Fetch : this.v1Fetch; - return fetcher(path, { ...init, method }); + return this.transportFor(apiVersion).fetchRaw(path, { ...init, method }); } } +/** The 0.2.0 resource names, still valid ids in the registry. */ export const RESOURCE_NAMES = [ "text-to-3d", "image-to-3d", @@ -186,4 +142,7 @@ export const RESOURCE_NAMES = [ export type ResourceName = (typeof RESOURCE_NAMES)[number]; +/** Every registered task resource id (15 legacy + uv-unwrap + Creative Lab stages). */ +export const TASK_RESOURCE_IDS: readonly string[] = TASK_RESOURCES.map((d) => d.id); + export { MeshyApiError } from "./errors.js"; diff --git a/src/client/resource-registry.ts b/src/client/resource-registry.ts index 04a8e97..af889c6 100644 --- a/src/client/resource-registry.ts +++ b/src/client/resource-registry.ts @@ -185,7 +185,7 @@ export interface QueryResourceDescriptor { export const QUERY_RESOURCES: readonly QueryResourceDescriptor[] = [ { id: "balance", commandPath: ["balance"], base: "v1", method: "GET", relativePath: "/balance", auth: "bearer", billing: "none", summary: "remaining credit balance" }, { id: "animation-catalog", commandPath: ["animation-catalog", "list"], base: "public-web", method: "GET", relativePath: "/animations/resources", auth: "none", billing: "none", summary: "public animation library (action ids); no key needed" }, - { id: "showcases", commandPath: ["showcases", "list"], base: "v1", method: "GET", relativePath: "/showcases", auth: "bearer", billing: "may-charge", summary: "Enterprise community showcases (1 credit per request)" }, + { id: "showcases", commandPath: ["showcases", "list"], base: "v1", method: "GET", relativePath: "/showcases", auth: "bearer", billing: "may-charge", summary: "Enterprise community showcases (every request is billed)" }, ]; export interface LocalToolDescriptor { diff --git a/src/client/transport.ts b/src/client/transport.ts new file mode 100644 index 0000000..7dfabfa --- /dev/null +++ b/src/client/transport.ts @@ -0,0 +1,373 @@ +/** + * HTTP transport with explicit boundaries. + * + * createAuthenticatedTransport — one API family (v1 / v2 / creative-lab): + * the bearer credential is attached only to paths that resolve inside that + * family's base URL. Absolute URLs are accepted only when they are the + * same origin *and* under the base path; scheme-relative, userinfo, + * foreign schemes and `..` segments are refused before any request. + * Redirects are never followed with a credential. + * createPublicTransport — no credential ever (catalog, media preflight, + * asset downloads). + * + * `requestJson` keeps the deadline armed until the body is fully read — a slow + * body counts against the timeout, unlike a fetcher that clears its timer as + * soon as headers arrive. `openStream` hands back the response plus an + * explicit `close()`; the caller owns idle/total deadlines from there. + * + * Failures carry a `phase` so callers can tell "the request never left" + * (connect) from "it may have been processed" (request/response/body), which + * is the whole difference between not_submitted and submission_unknown. + */ + +import { mapHttpError, MeshyApiError } from "./errors.js"; +import { logger } from "../internal/logger.js"; +import { USER_AGENT } from "../internal/user-agent.js"; + +export type TransportPhase = "validate" | "connect" | "request" | "response" | "body" | "timeout" | "aborted"; + +export class TransportError extends MeshyApiError { + readonly phase: TransportPhase; + readonly errno?: string; + + constructor(params: { message: string; phase: TransportPhase; path?: string; errno?: string; credentialKind?: "oauth" | "api_key"; cause?: unknown }) { + super({ message: params.message, status: 0, code: "network", path: params.path, credentialKind: params.credentialKind }); + this.name = "TransportError"; + this.phase = params.phase; + this.errno = params.errno; + if (params.cause !== undefined) (this as { cause?: unknown }).cause = params.cause; + } + + /** True when there is transport-level evidence the request never reached a server. */ + get neverSent(): boolean { + return this.phase === "validate" || this.phase === "connect"; + } +} + +export interface JsonResponse { + status: number; + headers: Headers; + /** Parsed body, or null for an empty body. */ + json: unknown; + /** Raw text as received. */ + text: string; + requestId: string | null; +} + +export interface RequestOptions { + body?: unknown; + query?: Record; + headers?: Record; + signal?: AbortSignal; + /** Overrides the transport default (covers headers + body). */ + timeoutMs?: number; + /** Cap on the response body; exceeding it is a protocol failure, never a truncation. */ + maxBodyBytes?: number; +} + +export interface StreamHandle { + response: Response; + /** Abort the underlying request and release the body reader. Idempotent. */ + close(): void; + signal: AbortSignal; +} + +export interface Transport { + readonly baseUrl: string; + readonly authenticated: boolean; + requestJson(method: string, path: string, opts?: RequestOptions): Promise; + /** Legacy-style fetch: validates the path, attaches auth, throws MeshyApiError on non-2xx. Body reading is the caller's. */ + fetchRaw(path: string, init?: RequestInit & { timeoutMs?: number }): Promise; + openStream(path: string, opts?: { signal?: AbortSignal; headers?: Record; connectTimeoutMs?: number }): Promise; +} + +export interface TransportConfig { + baseUrl: string; + apiKey?: string; + credentialKind?: "oauth" | "api_key"; + readTimeoutMs: number; + fetchImpl?: typeof fetch; + userAgent?: string; + defaultMaxBodyBytes?: number; +} + +export const DEFAULT_MAX_JSON_BODY_BYTES = 16 * 1024 * 1024; + +function stripTrail(s: string): string { + return s.replace(/\/+$/, ""); +} + +/** + * Resolve a caller path against the base. Only two shapes are accepted: + * - a relative path starting with a single "/" (no `..` segments), or + * - an absolute http(s) URL with the same origin whose path starts with the + * base path (an explicitly same-family URL, e.g. one the API returned). + */ +export function resolveApiUrl(baseUrl: string, path: string): URL { + const base = new URL(`${stripTrail(baseUrl)}/`); + if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) { + let target: URL; + try { + target = new URL(path); + } catch { + throw new TransportError({ message: `invalid URL: ${path}`, phase: "validate", path }); + } + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw new TransportError({ message: `refusing ${target.protocol} URL for an API request`, phase: "validate", path }); + } + if (target.username || target.password) { + throw new TransportError({ message: "refusing URL with embedded credentials", phase: "validate", path }); + } + if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname.replace(/\/$/, ""))) { + throw new TransportError({ + message: `refusing to send this API family's credential to ${target.origin}${target.pathname}; it is outside ${stripTrail(baseUrl)}`, + phase: "validate", + path, + }); + } + return target; + } + if (!path.startsWith("/")) path = `/${path}`; + const segments = path.split("?")[0]!.split("/"); + if (segments.some((s) => s === "..")) { + throw new TransportError({ message: `refusing path with '..' segment: ${path}`, phase: "validate", path }); + } + // URL() would treat a leading "//" as scheme-relative — already rejected above. + const target = new URL(`.${path}`, base); + if (target.origin !== base.origin || !target.pathname.startsWith(base.pathname.replace(/\/$/, ""))) { + throw new TransportError({ message: `path ${path} escapes the API base`, phase: "validate", path }); + } + return target; +} + +function errnoOf(err: unknown): string | undefined { + const e = err as { code?: unknown; cause?: { code?: unknown } } | undefined; + const code = e?.cause?.code ?? e?.code; + return typeof code === "string" ? code : undefined; +} + +const CONNECT_ERRNOS = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_CONNECT_TIMEOUT", "ERR_TLS_CERT_ALTNAME_INVALID", "CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "SELF_SIGNED_CERT_IN_CHAIN"]); + +function classifyFetchFailure(err: unknown, phase: TransportPhase): TransportPhase { + const errno = errnoOf(err); + if (errno && CONNECT_ERRNOS.has(errno)) return "connect"; + return phase; +} + +function combineSignals(...signals: Array): AbortSignal | undefined { + const present = signals.filter((s): s is AbortSignal => Boolean(s)); + if (present.length === 0) return undefined; + if (present.length === 1) return present[0]; + return AbortSignal.any(present); +} + +export function createTransport(cfg: TransportConfig): Transport { + const baseUrl = stripTrail(cfg.baseUrl); + const authenticated = Boolean(cfg.apiKey); + const userAgent = cfg.userAgent ?? USER_AGENT; + const maxBody = cfg.defaultMaxBodyBytes ?? DEFAULT_MAX_JSON_BODY_BYTES; + + function headersFor(extra?: ConstructorParameters[0]): Headers { + const headers = new Headers(extra); + if (cfg.apiKey) headers.set("Authorization", `Bearer ${cfg.apiKey}`); + else headers.delete("Authorization"); + headers.delete("Cookie"); + headers.set("User-Agent", userAgent); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + return headers; + } + + async function doFetch(url: URL, init: RequestInit, timeoutMs: number, externalSignal: AbortSignal | undefined, path: string): Promise<{ resp: Response; release: () => void; timeoutSignal: AbortSignal }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs); + const signal = combineSignals(controller.signal, externalSignal)!; + const release = () => clearTimeout(timer); + const fetchImpl = cfg.fetchImpl ?? globalThis.fetch; + try { + logger.debug(`HTTP ${init.method ?? "GET"} ${url.href}`); + const resp = await fetchImpl(url, { ...init, signal, redirect: "manual" }); + logger.debug(`HTTP ${resp.status} ${url.href}`); + return { resp, release, timeoutSignal: controller.signal }; + } catch (err) { + release(); + throw wrapFetchError(err, "request", path, externalSignal, controller.signal, timeoutMs); + } + } + + function wrapFetchError(err: unknown, phase: TransportPhase, path: string, externalSignal: AbortSignal | undefined, timeoutSignal: AbortSignal, timeoutMs: number): TransportError { + if (externalSignal?.aborted) { + return new TransportError({ message: `request to ${path} aborted`, phase: "aborted", path, cause: err, credentialKind: cfg.credentialKind }); + } + if (timeoutSignal.aborted) { + return new TransportError({ message: `request to ${path} timed out after ${timeoutMs}ms`, phase: "timeout", path, cause: err, credentialKind: cfg.credentialKind }); + } + const errno = errnoOf(err); + const msg = err instanceof Error ? err.message : String(err); + return new TransportError({ + message: `network error calling ${path}: ${msg}${errno ? ` (${errno})` : ""}`, + phase: classifyFetchFailure(err, phase), + path, + errno, + cause: err, + credentialKind: cfg.credentialKind, + }); + } + + function refuseRedirect(resp: Response, path: string): void { + if (resp.status >= 300 && resp.status < 400) { + throw new TransportError({ + message: `refusing to follow HTTP ${resp.status} redirect from ${path} (redirects are never followed with an API credential)`, + phase: "response", + path, + credentialKind: cfg.credentialKind, + }); + } + } + + async function readBodyWithin(resp: Response, cap: number, path: string, externalSignal: AbortSignal | undefined, timeoutSignal: AbortSignal, timeoutMs: number): Promise { + if (!resp.body) return ""; + const reader = resp.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + total += value.byteLength; + if (total > cap) { + await reader.cancel().catch(() => undefined); + throw new MeshyApiError({ message: `response body from ${path} exceeds ${cap} bytes`, status: resp.status, code: "server", path }); + } + chunks.push(value); + } + } + } catch (err) { + if (err instanceof MeshyApiError) throw err; + throw wrapFetchError(err, "body", path, externalSignal, timeoutSignal, timeoutMs); + } + const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), total); + return buf.toString("utf8"); + } + + const transport: Transport = { + baseUrl, + authenticated, + + async requestJson(method, path, opts = {}) { + const url = resolveApiUrl(baseUrl, path); + if (opts.query) { + for (const [k, v] of Object.entries(opts.query)) { + if (v === undefined || v === null) continue; + url.searchParams.set(k, String(v)); + } + } + const headers = headersFor(opts.headers); + const init: RequestInit = { method }; + if (opts.body !== undefined) { + headers.set("Content-Type", "application/json"); + init.body = JSON.stringify(opts.body); + } + init.headers = headers; + const timeoutMs = opts.timeoutMs ?? cfg.readTimeoutMs; + const { resp, release, timeoutSignal } = await doFetch(url, init, timeoutMs, opts.signal, path); + try { + refuseRedirect(resp, path); + if (!resp.ok) { + // mapHttpError reads the body itself; it is small (an error message). + throw await mapHttpError(resp, path, cfg.credentialKind); + } + const text = await readBodyWithin(resp, opts.maxBodyBytes ?? maxBody, path, opts.signal, timeoutSignal, timeoutMs); + let json: unknown = null; + if (text.trim().length > 0) { + try { + json = JSON.parse(text); + } catch (err) { + throw new MeshyApiError({ + message: `invalid JSON in response from ${path}: ${err instanceof Error ? err.message : String(err)}`, + status: resp.status, + code: "server", + path, + body: text.slice(0, 200), + }); + } + } + return { status: resp.status, headers: resp.headers, json, text, requestId: resp.headers.get("x-request-id") }; + } finally { + release(); + } + }, + + async fetchRaw(path, init = {}) { + const url = resolveApiUrl(baseUrl, path); + const headers = headersFor(init.headers); + const timeoutMs = init.timeoutMs ?? cfg.readTimeoutMs; + const { resp, release } = await doFetch(url, { ...init, headers }, timeoutMs, init.signal ?? undefined, path); + try { + refuseRedirect(resp, path); + if (!resp.ok) throw await mapHttpError(resp, path, cfg.credentialKind); + // The caller reads the body; give it the whole remaining budget by + // leaving the timer armed until the body is consumed or GC'd. + const body = resp.body; + if (!body) { + release(); + return resp; + } + const watched = new Response( + body.pipeThrough( + new TransformStream({ + flush() { + release(); + }, + }), + ), + { status: resp.status, statusText: resp.statusText, headers: resp.headers }, + ); + return watched; + } catch (err) { + release(); + throw err; + } + }, + + async openStream(path, opts = {}) { + const url = resolveApiUrl(baseUrl, path); + const headers = headersFor({ Accept: "text/event-stream", ...(opts.headers ?? {}) }); + const controller = new AbortController(); + const signal = combineSignals(controller.signal, opts.signal)!; + const connectTimeoutMs = opts.connectTimeoutMs ?? cfg.readTimeoutMs; + const connectTimer = setTimeout(() => controller.abort(new Error(`stream connect timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs); + const fetchImpl = cfg.fetchImpl ?? globalThis.fetch; + let resp: Response; + try { + logger.debug(`HTTP GET (stream) ${url.href}`); + resp = await fetchImpl(url, { method: "GET", headers, signal, redirect: "manual" }); + } catch (err) { + clearTimeout(connectTimer); + throw wrapFetchError(err, "request", path, opts.signal, controller.signal, connectTimeoutMs); + } + clearTimeout(connectTimer); + const close = () => { + if (!controller.signal.aborted) controller.abort(new Error("stream closed by client")); + resp.body?.cancel().catch(() => undefined); + }; + try { + refuseRedirect(resp, path); + if (!resp.ok) throw await mapHttpError(resp, path, cfg.credentialKind); + } catch (err) { + close(); + throw err; + } + return { response: resp, close, signal }; + }, + }; + return transport; +} + +export function createAuthenticatedTransport(cfg: TransportConfig & { apiKey: string }): Transport { + return createTransport(cfg); +} + +export function createPublicTransport(cfg: Omit): Transport { + return createTransport({ ...cfg, apiKey: undefined, credentialKind: undefined }); +} diff --git a/src/client/types.ts b/src/client/types.ts index 2f6d671..06b8905 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -1,5 +1,9 @@ /** * Zod schemas for Meshy request/response shapes (permissive passthrough). + * + * `TaskSchema` fills a few defaults so 0.2.0 summaries keep their shape. The + * v1 TaskView is built from the *raw* JSON (see task-view.ts), never from the + * defaulted object, so a field the server did not send stays null there. */ import { z } from "zod"; @@ -38,6 +42,10 @@ export type TaskStatus = z.infer; export const TERMINAL_STATUSES = new Set(["SUCCEEDED", "FAILED", "CANCELED"]); +export function isTerminalStatus(status: string | null | undefined): boolean { + return typeof status === "string" && TERMINAL_STATUSES.has(status); +} + export const PrintabilitySchema = z .object({ _version: z.string().optional(), @@ -55,6 +63,7 @@ export const TaskSchema = z .object({ id: z.string(), type: z.string().default(""), + name: z.string().nullable().optional(), status: z.string().default(""), progress: z.number().default(0), preceding_tasks: z.number().default(0), @@ -69,6 +78,8 @@ export const TaskSchema = z model_urls: z.record(z.string(), z.string().nullable()).nullable().optional(), texture_urls: z.array(TextureSetSchema).nullable().optional(), thumbnail_url: z.string().nullable().optional(), + thumbnail_urls: z.record(z.string(), z.string().nullable()).nullable().optional(), + alpha_thumbnail_url: z.string().nullable().optional(), image_urls: z.array(z.string()).nullable().optional(), @@ -76,6 +87,9 @@ export const TaskSchema = z printability: PrintabilitySchema.nullable().optional(), + face_count: z.number().nullable().optional(), + consumed_credits: z.number().nullable().optional(), + ai_model: z.string().nullable().optional(), prompt: z.string().nullable().optional(), texture_prompt: z.string().nullable().optional(), @@ -108,6 +122,7 @@ export interface TaskSummary { elapsed_seconds?: number; } +/** Legacy (0.2.0) summary — shape preserved for existing consumers. */ export function summarizeTask(task: Task, elapsedSeconds?: number): TaskSummary { const summary: TaskSummary = { id: task.id, diff --git a/src/cmd/animation-catalog.ts b/src/cmd/animation-catalog.ts new file mode 100644 index 0000000..c239c0f --- /dev/null +++ b/src/cmd/animation-catalog.ts @@ -0,0 +1,101 @@ +/** + * animation-catalog — the public Animation Library behind `animate --action-id`. + * + * GET /web/public/animations/resources[?category=…]. No credential + * is loaded or sent; the command works without any Meshy account. `--search` + * filters the fetched batch locally (name / key / subCategory, case-insensitive) + * and says so in the result — it is not a server-side search. + */ + +import { Command, Option } from "commander"; +import { AnimationCatalogEndpoint, ANIMATION_CATEGORIES, type AnimationCatalogEntry } from "../client/endpoints/animation-catalog.js"; +import { createPublicTransport } from "../client/transport.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { DEFAULT_BASE_URL_V1, derivePublicWebBase } from "../internal/config.js"; +import { abortSignal } from "../internal/context.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; +import { warning, type Warning } from "../internal/result.js"; + +export interface CatalogItem { + action_id: number; + name: string | null; + key: string | null; + category: string | null; + sub_category: string | null; + preview_url: string | null; + rig_type: string | null; + is_default: boolean | null; + is_free: boolean | null; +} + +export function toCatalogItem(e: AnimationCatalogEntry): CatalogItem { + return { + action_id: e.id, + name: e.name ?? null, + key: e.key ?? null, + category: e.category ?? null, + sub_category: e.subCategory ?? null, + preview_url: e.previewUrl ?? null, + rig_type: e.rigType ?? null, + is_default: e.isDefault ?? null, + is_free: e.isFree ?? null, + }; +} + +/** Case-insensitive substring match over name, key and sub-category. */ +export function matchesSearch(item: CatalogItem, search: string): boolean { + const needle = search.trim().toLowerCase(); + if (!needle) return true; + return [item.name, item.key, item.sub_category].some((v) => typeof v === "string" && v.toLowerCase().includes(needle)); +} + +const listCommand = new Command("list") + .description("Fetch the public animation catalog (no API key needed) and list action ids") + .addOption(new Option("--category ", "server-side category filter").choices([...ANIMATION_CATEGORIES])) + .option("--search ", "local, case-insensitive match on name / key / sub-category") + .option("--include-raw", "include the untouched catalog response under result.raw") + .option("--save-json ", "save the untouched catalog response to this file (never overwrites)") + .action(async (opts: { category?: string; search?: string; includeRaw?: boolean; saveJson?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "animation-catalog.list", "v1"); + rejectOutputFlagForV1(opened, opts.saveJson); + buildLocalRuntime(opened.flags); + const baseV1 = opened.flags.baseUrlV1 ?? process.env["MESHY_BASE_URL_V1"] ?? DEFAULT_BASE_URL_V1; + const publicBase = derivePublicWebBase(baseV1); + const transport = createPublicTransport({ baseUrl: publicBase, readTimeoutMs: readTimeout() }); + const endpoint = new AnimationCatalogEndpoint(transport); + const { entries, total, raw } = await endpoint.list({ category: opts.category }, { signal: abortSignal() }); + const all = entries.map(toCatalogItem); + const items = opts.search ? all.filter((i) => matchesSearch(i, opts.search!)) : all; + const warnings: Warning[] = []; + if (total !== null && total !== entries.length) { + warnings.push(warning("catalog_total_mismatch", `server reported total=${total} but returned ${entries.length} entries; only the returned batch was searched`)); + } + const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + await emitResult( + opened, + items, + { + items, + count: items.length, + fetched: entries.length, + total, + filters: { category: opts.category ?? null, search: opts.search ?? null }, + search_scope: "local", + source: `${publicBase}${AnimationCatalogEndpoint.PATH}`, + authenticated: false, + saved_json: saved, + ...(opts.includeRaw ? { raw } : {}), + }, + { warnings }, + ); + }); + +function readTimeout(): number { + const raw = process.env["MESHY_READ_TIMEOUT_MS"]; + const n = raw ? Number(raw) : NaN; + return Number.isFinite(n) && n > 0 ? n : 120_000; +} + +export const animationCatalogCommand = new Command("animation-catalog") + .description("Public animation library lookups (no API key required)") + .addCommand(listCommand); diff --git a/src/cmd/resources.ts b/src/cmd/resources.ts index fa0274e..19aca4a 100644 --- a/src/cmd/resources.ts +++ b/src/cmd/resources.ts @@ -5,11 +5,17 @@ * the root help leads with. An agent that reads `--help` on every invocation * pays for the whole surface every time, so the root lists the one verb most * callers need and points here for the rest. + * + * legacy: the 0.2.0 array of `{name, summary}` (new commands appended). + * v1: `result.items` with `kind` (task | query | local), the command line, + * the endpoint and the supported verbs — all generated from the + * registry and filtered to what this build actually registers. */ import { Command } from "commander"; -import { emit } from "../internal/output.js"; -import { readGlobalFlags } from "../internal/runtime.js"; +import { resourceIndex, type ResourceIndexEntry } from "../client/resource-registry.js"; +import { emitResult, openCommand } from "../internal/command-helpers.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; interface ResourceEntry { name: string; @@ -35,18 +41,23 @@ export const RESOURCES: ResourceEntry[] = [ { name: "repair-printability", summary: "fix non-watertight / non-manifold geometry" }, { name: "balance", summary: "remaining credit balance" }, { name: "delete", summary: "delete any task, whatever its resource" }, + // Added in S1 (appended so 0.2.0 consumers keep their positions). + { name: "animation-catalog", summary: "public animation library (action ids); no key needed" }, + { name: "showcases", summary: "Enterprise community showcases (every request is billed)" }, ]; -const VERBS = `Every resource carries the same verbs: +const VERBS = `Every task resource carries the same verbs: meshy create [flags] [--data ''] [--async] [--timeout ] meshy get meshy list [--page ] [--page-size ] [--sort-by ] meshy wait [--timeout ] + meshy stream [--timeout ] [--idle-timeout ] meshy delete \`create\` is sync by default — it blocks until the task is terminal. Pass ---async for the task id straight away, then \`wait\`/\`get\` it later. +--async for the task id straight away, then \`wait\`/\`get\`/\`stream\` it later. +Add --output-schema v1 for the stable machine envelope. Endpoints without a dedicated command are reachable through the passthrough: @@ -56,13 +67,23 @@ Endpoints without a dedicated command are reachable through the passthrough: export const resourcesCommand = new Command("resources") .description("List the per-endpoint commands and the verbs they share") .addHelpText("after", `\n${VERBS}\n`) - .action((_opts: Record, thisCmd: Command) => { - const { format } = readGlobalFlags(thisCmd); - if (format === "pretty") { - const width = Math.max(...RESOURCES.map((r) => r.name.length)); - const lines = RESOURCES.map((r) => ` ${r.name.padEnd(width)} ${r.summary}`); + .action(async (_opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, "resources", "legacy"); + buildLocalRuntime(opened.flags); + const registered = new Set((thisCmd.parent?.commands ?? []).map((c) => c.name())); + const items = resourceIndex().filter((e) => registered.has(e.command.split(" ")[1] ?? "")); + const legacy = RESOURCES.filter((r) => registered.has(r.name)); + + if (opened.schema === "legacy" && opened.format === "pretty") { + const width = Math.max(...legacy.map((r) => r.name.length)); + const lines = legacy.map((r) => ` ${r.name.padEnd(width)} ${r.summary}`); process.stdout.write(`${lines.join("\n")}\n\n${VERBS}\n`); return; } - emit(RESOURCES, { format }); + const byKind = (kind: ResourceIndexEntry["kind"]) => items.filter((e) => e.kind === kind).length; + await emitResult(opened, legacy, { + items, + counts: { task: byKind("task"), query: byKind("query"), local: byKind("local") }, + verbs_help: "meshy resources --help", + }); }); diff --git a/src/cmd/showcases.ts b/src/cmd/showcases.ts new file mode 100644 index 0000000..92f2f1a --- /dev/null +++ b/src/cmd/showcases.ts @@ -0,0 +1,82 @@ +/** + * showcases — GET /openapi/v1/showcases (Enterprise tier). + * + * Every request may cost a credit, so this command performs exactly one GET + * with the parameters given, never retries, never paginates, and is never + * called by doctor or any smoke check. Items are passed through unchanged. + */ + +import { Command, Option } from "commander"; +import { SHOWCASE_FORMATS, SHOWCASE_SORT_BY, SHOWCASE_TYPES } from "../client/endpoints/showcases.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { abortSignal } from "../internal/context.js"; +import { UsageError } from "../internal/errors.js"; +import { parseInt10 } from "../internal/flags.js"; +import { warning, type Warning } from "../internal/result.js"; +import { buildRuntime } from "../internal/runtime.js"; + +const listCommand = new Command("list") + .description("Search community showcase models (Enterprise tier; every request is billed)") + .option("--search ", "text search in model names (server-side)") + .option("--page-size ", "1-10 (default: 3)", parseInt10) + .addOption(new Option("--sort-by ", "sort order (default: -created_at)").choices([...SHOWCASE_SORT_BY])) + .addOption(new Option("--model-format ", "model format to return (default: glb)").choices([...SHOWCASE_FORMATS])) + .addOption( + new Option("--showcase-type ", "all (default) | animate | static (the docs spell it 'animated'; both are accepted)").choices([ + ...SHOWCASE_TYPES, + "animated", + ]), + ) + .option("--include-raw", "include the untouched response under result.raw") + .option("--save-json ", "save the untouched response to this file (never overwrites)") + .action( + async ( + opts: { search?: string; pageSize?: number; sortBy?: string; modelFormat?: string; showcaseType?: string; includeRaw?: boolean; saveJson?: string }, + thisCmd: Command, + ) => { + const opened = openCommand(thisCmd, "showcases.list", "v1"); + rejectOutputFlagForV1(opened, opts.saveJson); + if (opts.pageSize !== undefined && (opts.pageSize < 1 || opts.pageSize > 10)) { + throw new UsageError("--page-size must be between 1 and 10"); + } + const warnings: Warning[] = []; + let showcaseType = opts.showcaseType; + if (showcaseType === "animated") { + showcaseType = "animate"; + warnings.push( + warning( + "showcase_type_alias", + "the server enum spells this value 'animate' (the docs say 'animated'); sent as showcase_type=animate", + ), + ); + } + const request = { + page_size: opts.pageSize, + sort_by: opts.sortBy, + search: opts.search, + format: opts.modelFormat, + showcase_type: showcaseType, + }; + const runtime = await buildRuntime(opened.flags); + const { items, raw } = await runtime.client.showcases.list(request, { signal: abortSignal() }); + const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + await emitResult( + opened, + items, + { + items, + count: items.length, + request, + billing: "may-charge", + requests_made: 1, + saved_json: saved, + ...(opts.includeRaw ? { raw } : {}), + }, + { warnings }, + ); + }, + ); + +export const showcasesCommand = new Command("showcases") + .description("Enterprise showcase search (every request is billed)") + .addCommand(listCommand); diff --git a/src/internal/file-input.ts b/src/internal/file-input.ts index b832c09..01c6d1e 100644 --- a/src/internal/file-input.ts +++ b/src/internal/file-input.ts @@ -1,25 +1,57 @@ /** - * Resolve user-supplied image and model inputs into something Meshy accepts. + * Media inputs: turn what the user typed into what the API accepts. * - * Accepted inputs (both images and 3D models): - * - http(s) URLs — preflighted with HEAD so unreachable sources fail fast. - * - Local file paths (absolute or relative to cwd) — read, MIME-sniffed, - * and inlined as data URIs. Users don't have to host files themselves. + * Accepted for every declared media field (see resource-registry mediaFields): + * - http(s) URLs — preflighted with an unauthenticated HEAD (GET fallback, + * aborted right after the headers) so unreachable sources fail before a + * billable POST. The API credential is never attached to a preflight. + * - data: URIs — validated (base64, sane MIME, size cap) and passed through. + * - Local file paths (absolute or relative to cwd) — must be regular files + * under the size cap and of an accepted format; read, MIME-sniffed and + * inlined as data URIs. * - * Base64 data URIs on the command line aren't accepted — the whole point of - * local-path support is to spare the user from encoding them manually. + * Normalisation runs on the *final merged payload* (defaults < --data < + * flags), so a path inside `--data '{"texture_image_url":"./tex.png"}'` is + * handled exactly like the typed flag. Only fields the resource declares are + * touched — no string that merely looks like a path is ever read. */ import { readFileSync, statSync } from "node:fs"; import { extname, isAbsolute, resolve as resolvePath } from "node:path"; +import type { MediaField, MediaKind } from "../client/resource-registry.js"; import { UsageError } from "./errors.js"; import { logger } from "./logger.js"; +import { USER_AGENT } from "./user-agent.js"; -/** camelCase option names that hold image inputs. */ +export const DEFAULT_MEDIA_LIMITS = { + /** 50 MiB — an engineering default, not a Meshy product limit. */ + maxFileBytes: 50 * 1024 * 1024, + preflightTimeoutMs: 10_000, +} as const; + +export type MediaLimits = { maxFileBytes: number; preflightTimeoutMs: number }; + +export interface NormalizeOptions { + cwd?: string; + limits?: Partial; + fetchImpl?: typeof fetch; + signal?: AbortSignal; +} + +export interface NormalizedMedia { + field: string; + index: number | null; + source: "url" | "data-uri" | "local-file"; + mime: string | null; + bytes: number | null; +} + +/** camelCase option names that hold image inputs (legacy flag-level API). */ export const IMAGE_FIELDS = { scalar: [ "imageUrl", // image-to-3d "imageStyleUrl", // retexture + "textureImageUrl", // text-to-3d refine / rigging ] as const, list: [ "imageUrls", // multi-image-to-3d @@ -28,32 +60,30 @@ export const IMAGE_FIELDS = { ] as const, }; -/** camelCase option names that hold 3D model inputs. */ +/** camelCase option names that hold 3D model inputs (legacy flag-level API). */ export const MODEL_FIELDS = { - scalar: ["modelUrl"] as const, // remesh, retexture, rigging + scalar: ["modelUrl"] as const, // remesh, retexture, rigging, uv-unwrap … list: [] as const, }; -export async function resolveImageFields(opts: Record): Promise { - await resolveGroup(opts, IMAGE_FIELDS, detectImageMime, "image"); +export async function resolveImageFields(opts: Record, o: NormalizeOptions = {}): Promise { + await resolveGroup(opts, IMAGE_FIELDS, "image", o); } -export async function resolveModelFields(opts: Record): Promise { - await resolveGroup(opts, MODEL_FIELDS, detectModelMime, "3d model"); +export async function resolveModelFields(opts: Record, o: NormalizeOptions = {}): Promise { + await resolveGroup(opts, MODEL_FIELDS, "model", o); } -type MimeDetector = (buffer: Buffer, path: string) => string; - async function resolveGroup( opts: Record, fields: { scalar: readonly string[]; list: readonly string[] }, - detect: MimeDetector, - kind: string, + kind: MediaKind, + o: NormalizeOptions, ): Promise { for (const key of fields.scalar) { const v = opts[key]; if (typeof v === "string" && v) { - opts[key] = await resolveOne(v, `--${toFlag(key)}`, detect, kind); + opts[key] = (await resolveOne(v, `--${toFlag(key)}`, kind, undefined, o)).value; } } for (const key of fields.list) { @@ -62,7 +92,7 @@ async function resolveGroup( const resolved: string[] = []; for (const entry of v) { if (typeof entry === "string" && entry) { - resolved.push(await resolveOne(entry, `--${toFlag(key)}`, detect, kind)); + resolved.push((await resolveOne(entry, `--${toFlag(key)}`, kind, undefined, o)).value); } } opts[key] = resolved; @@ -74,35 +104,136 @@ function toFlag(camel: string): string { return camel.replace(/([A-Z])/g, "-$1").toLowerCase(); } +function flagForField(path: string): string { + return `--${path.replace(/_/g, "-")}`; +} + +/** + * Normalise every declared media field of a final payload in place-free + * fashion (a new object is returned). Non-string values are left untouched + * and reported by the endpoint schema instead. + */ +export async function normalizeMediaPayload( + payload: Record, + fields: readonly MediaField[], + o: NormalizeOptions = {}, +): Promise<{ payload: Record; media: NormalizedMedia[] }> { + const out: Record = { ...payload }; + const media: NormalizedMedia[] = []; + for (const field of fields) { + const value = out[field.path]; + if (value === undefined || value === null) continue; + const label = flagForField(field.path); + if (field.many) { + if (!Array.isArray(value)) continue; + const resolved: unknown[] = []; + for (let i = 0; i < value.length; i++) { + const entry = value[i]; + if (typeof entry !== "string" || !entry) { + resolved.push(entry); + continue; + } + const r = await resolveOne(entry, `${label}[${i}]`, field.kind, field.formats, o); + resolved.push(r.value); + media.push({ field: field.path, index: i, source: r.source, mime: r.mime, bytes: r.bytes }); + } + out[field.path] = resolved; + } else if (typeof value === "string" && value) { + const r = await resolveOne(value, label, field.kind, field.formats, o); + out[field.path] = r.value; + media.push({ field: field.path, index: null, source: r.source, mime: r.mime, bytes: r.bytes }); + } + } + return { payload: out, media }; +} + +interface Resolved { + value: string; + source: NormalizedMedia["source"]; + mime: string | null; + bytes: number | null; +} + async function resolveOne( input: string, flagLabel: string, - detect: MimeDetector, - kind: string, -): Promise { - if (input.startsWith("data:")) { - throw new UsageError( - `${flagLabel}: data: URIs aren't accepted on the command line — pass a local file path instead`, - ); + kind: MediaKind, + formats: readonly string[] | undefined, + o: NormalizeOptions, +): Promise { + const limits: MediaLimits = { ...DEFAULT_MEDIA_LIMITS, ...(o.limits ?? {}) }; + if (/^data:/i.test(input)) { + return validateDataUri(input, flagLabel, kind, formats, limits); } if (/^https?:\/\//i.test(input)) { - await preflightUrl(input, flagLabel); - return input; + await preflightUrl(input, flagLabel, limits, o); + return { value: input, source: "url", mime: null, bytes: null }; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) { + throw new UsageError(`${flagLabel}: only http(s) URLs, data: URIs and local file paths are accepted (got ${input.split(":")[0]}: URL)`); } - return loadLocalFile(input, flagLabel, detect, kind); + return loadLocalFile(input, flagLabel, kind, formats, limits, o.cwd ?? process.cwd()); } -async function preflightUrl(url: string, flagLabel: string): Promise { +const DATA_URI_RE = /^data:([a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+)((?:;[a-z0-9-]+=[^;,]*)*)(;base64)?,(.*)$/is; + +function validateDataUri( + input: string, + flagLabel: string, + kind: MediaKind, + formats: readonly string[] | undefined, + limits: MediaLimits, +): Resolved { + const m = DATA_URI_RE.exec(input); + if (!m) throw new UsageError(`${flagLabel}: malformed data: URI (expected data:;base64,)`); + const mime = m[1]!.toLowerCase(); + const isBase64 = Boolean(m[3]); + const body = m[4] ?? ""; + if (!isBase64) throw new UsageError(`${flagLabel}: data: URI must be base64-encoded`); + if (!/^[A-Za-z0-9+/=\s]*$/.test(body)) throw new UsageError(`${flagLabel}: data: URI payload is not valid base64`); + const bytes = Math.floor((body.replace(/\s+/g, "").length * 3) / 4); + if (bytes > limits.maxFileBytes) { + throw new UsageError(`${flagLabel}: inline data is ${bytes} bytes, above the ${limits.maxFileBytes}-byte limit`); + } + if (kind === "image" && !mime.startsWith("image/")) { + throw new UsageError(`${flagLabel}: expected an image data: URI, got ${mime}`); + } + if (kind === "model" && !(mime.startsWith("model/") || mime === "application/octet-stream")) { + throw new UsageError(`${flagLabel}: expected a 3D-model data: URI (model/* or application/octet-stream), got ${mime}`); + } + if (formats && formats.length > 0) { + const ext = extForMime(mime); + if (!ext || !formats.includes(ext)) { + throw new UsageError(`${flagLabel}: this field accepts ${formats.join("/")} only (data: URI is ${mime})`); + } + } + return { value: input, source: "data-uri", mime, bytes }; +} + +async function preflightUrl(url: string, flagLabel: string, limits: MediaLimits, o: NormalizeOptions): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new UsageError(`${flagLabel}: invalid URL ${url}`); + } + if (parsed.username || parsed.password) throw new UsageError(`${flagLabel}: URLs with embedded credentials are not accepted`); + const fetchImpl = o.fetchImpl ?? globalThis.fetch; + const headers = { "User-Agent": USER_AGENT }; + const timeout = AbortSignal.timeout(limits.preflightTimeoutMs); + const signal = o.signal ? AbortSignal.any([timeout, o.signal]) : timeout; let resp: Response; try { - resp = await fetch(url, { method: "HEAD", redirect: "follow" }); + resp = await fetchImpl(url, { method: "HEAD", redirect: "follow", headers, signal }); if (resp.status === 405 || resp.status === 501) { const controller = new AbortController(); - resp = await fetch(url, { method: "GET", signal: controller.signal, redirect: "follow" }); + const getSignal = AbortSignal.any([controller.signal, signal]); + resp = await fetchImpl(url, { method: "GET", redirect: "follow", headers, signal: getSignal }); controller.abort(); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); + if (timeout.aborted) throw new UsageError(`${flagLabel}: preflight of ${url} timed out after ${limits.preflightTimeoutMs}ms`); throw new UsageError(`${flagLabel}: cannot reach ${url} (${msg})`); } if (!resp.ok) { @@ -114,10 +245,12 @@ async function preflightUrl(url: string, flagLabel: string): Promise { function loadLocalFile( input: string, flagLabel: string, - detect: MimeDetector, - kind: string, -): string { - const absPath = isAbsolute(input) ? input : resolvePath(process.cwd(), input); + kind: MediaKind, + formats: readonly string[] | undefined, + limits: MediaLimits, + cwd: string, +): Resolved { + const absPath = isAbsolute(input) ? input : resolvePath(cwd, input); let stat: ReturnType; try { stat = statSync(absPath); @@ -127,16 +260,43 @@ function loadLocalFile( if (!stat.isFile()) { throw new UsageError(`${flagLabel}: not a regular file: ${input}`); } + if (stat.size > limits.maxFileBytes) { + throw new UsageError(`${flagLabel}: ${input} is ${stat.size} bytes, above the ${limits.maxFileBytes}-byte limit`); + } const buffer = readFileSync(absPath); - const mime = detect(buffer, absPath); + const mime = kind === "image" ? detectImageMime(buffer, absPath) : detectModelMime(buffer, absPath); if (!mime) { - throw new UsageError(`${flagLabel}: could not detect ${kind} MIME type for ${absPath}`); + throw new UsageError(`${flagLabel}: could not detect ${kind === "image" ? "image" : "3d model"} MIME type for ${absPath}`); + } + if (formats && formats.length > 0) { + const ext = extForMime(mime) ?? extname(absPath).slice(1).toLowerCase(); + if (!formats.includes(ext)) { + throw new UsageError(`${flagLabel}: this field accepts ${formats.join("/")} only (got ${ext || "unknown"})`); + } } logger.debug(`${flagLabel} inlined ${absPath} as ${mime} data URI (${buffer.length} bytes)`); - return `data:${mime};base64,${buffer.toString("base64")}`; + return { value: `data:${mime};base64,${buffer.toString("base64")}`, source: "local-file", mime, bytes: buffer.length }; +} + +function extForMime(mime: string): string | null { + const map: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/bmp": "bmp", + "model/gltf-binary": "glb", + "model/gltf+json": "gltf", + "model/obj": "obj", + "model/stl": "stl", + "model/vnd.usdz+zip": "usdz", + "model/3mf": "3mf", + "application/octet-stream": "fbx", + }; + return map[mime] ?? null; } -function detectImageMime(buffer: Buffer, path: string): string { +export function detectImageMime(buffer: Buffer, path: string): string { if (buffer.length >= 12) { if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg"; if ( @@ -164,7 +324,7 @@ function detectImageMime(buffer: Buffer, path: string): string { return map[ext] ?? ""; } -function detectModelMime(buffer: Buffer, path: string): string { +export function detectModelMime(buffer: Buffer, path: string): string { // GLB: magic "glTF" at offset 0 if ( buffer.length >= 4 && diff --git a/src/internal/result.ts b/src/internal/result.ts index 64cbeec..e11df89 100644 --- a/src/internal/result.ts +++ b/src/internal/result.ts @@ -10,6 +10,8 @@ import { classifyError, type ErrorRecovery, type Warning } from "./errors.js"; +export type { Warning } from "./errors.js"; + export const SCHEMA_VERSION = "meshy.cli/v1"; export type OutputSchema = "legacy" | "v1"; diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index 2268d5c..343577a 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -6,9 +6,10 @@ import { Command, Option } from "commander"; import type { TaskEndpoint } from "../client/endpoints/base.js"; -import type { MeshyClient, ResourceName } from "../client/index.js"; +import type { MeshyClient } from "../client/index.js"; +import { requireTaskResource } from "../client/resource-registry.js"; import { downloadArtifacts } from "./download.js"; -import { resolveImageFields, resolveModelFields } from "./file-input.js"; +import { normalizeMediaPayload } from "./file-input.js"; import { pollUntilTerminal } from "./poll.js"; import { emit } from "./output.js"; import { mergePayload, parseJsonFlag } from "./payload.js"; @@ -34,14 +35,18 @@ export interface CreateSpec { } export interface ResourceCommandSpec { - name: ResourceName; + /** Registry id of the task resource this command drives. */ + name: string; description: string; supportsList?: boolean; create: CreateSpec; - endpointOf(client: MeshyClient): TaskEndpoint; + /** Optional override; defaults to the registry endpoint for `name`. */ + endpointOf?(client: MeshyClient): TaskEndpoint; } export function buildResourceCommand(spec: ResourceCommandSpec): Command { + const descriptor = requireTaskResource(spec.name); + const endpointOf = spec.endpointOf ?? ((client: MeshyClient) => client.endpointFor(descriptor)); const cmd = new Command(spec.name).description(spec.description); // create @@ -58,15 +63,14 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { .addOption(new Option("--timeout ", "max seconds to poll in sync mode").default("600")) .action(async (opts: Record, thisCmd: Command) => { const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const endpoint = spec.endpointOf(runtime.client); + const endpoint = endpointOf(runtime.client); const data = parseJsonFlag(opts.data as string | undefined, "--data"); - // Resolve local file paths + preflight URLs for image and 3D-model - // inputs before building the payload — fail fast on missing inputs. - await resolveImageFields(opts); - await resolveModelFields(opts); const flagPayload = spec.create.toPayload(opts); const defaults = spec.create.toDefaults?.(opts) ?? {}; - const payload = mergePayload(defaults, data, flagPayload); + // Media is normalised on the merged payload so a local path or URL + // works identically from a typed flag and from --data; only the fields + // the registry declares are touched. Missing files fail before any POST. + const { payload } = await normalizeMediaPayload(mergePayload(defaults, data, flagPayload), descriptor.mediaFields); logger.debug("create payload", payload); const taskId = await endpoint.create(payload); const runAsync = Boolean(opts.async); @@ -99,7 +103,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { .description("Retrieve a single task by id") .action(async (taskId: string, _opts: Record, thisCmd: Command) => { const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const task = await spec.endpointOf(runtime.client).retrieve(taskId); + const task = await endpointOf(runtime.client).retrieve(taskId); await emitTerminalOutcome(task, false, undefined, spec.name, runtime); }); @@ -110,7 +114,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { .option("--timeout ", "max seconds to wait", "600") .action(async (taskId: string, opts: { timeout?: string }, thisCmd: Command) => { const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const endpoint = spec.endpointOf(runtime.client); + const endpoint = endpointOf(runtime.client); const timeoutSeconds = Number(opts.timeout ?? 600); const started = Date.now(); const { task, timedOut } = await pollUntilTerminal(endpoint, taskId, { @@ -127,7 +131,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { .description("Delete a task") .action(async (taskId: string, _opts: Record, thisCmd: Command) => { const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - await spec.endpointOf(runtime.client).delete(taskId); + await endpointOf(runtime.client).delete(taskId); emit({ resource: spec.name, task_id: taskId, deleted: true }, { format: runtime.flags.format, file: runtime.flags.output, @@ -144,7 +148,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { .option("--sort-by ", "sort order (e.g. -created_at)", "-created_at") .action(async (opts: { page?: string; pageSize?: string; sortBy?: string }, thisCmd: Command) => { const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const tasks = await spec.endpointOf(runtime.client).list({ + const tasks = await endpointOf(runtime.client).list({ page_num: Number(opts.page ?? 1), page_size: Number(opts.pageSize ?? 10), sort_by: opts.sortBy ?? "-created_at", diff --git a/src/internal/task-view.ts b/src/internal/task-view.ts new file mode 100644 index 0000000..50f18ff --- /dev/null +++ b/src/internal/task-view.ts @@ -0,0 +1,131 @@ +/** + * TaskView — the canonical v1 task summary. + * + * Built from the raw JSON the server returned, not from the Zod-defaulted + * object: a field the server omitted is `null` here (never 0 or ""), so a + * missing face_count cannot masquerade as "zero faces". `raw` is attached only + * when the caller asked for it (--include-raw). + */ + +import type { TaskResourceDescriptor } from "../client/resource-registry.js"; + +export interface TaskView { + task_id: string; + resource: string | null; + endpoint: string | null; + type: string | null; + name: string | null; + status: string | null; + progress: number | null; + preceding_tasks: number | null; + created_at: number | null; + started_at: number | null; + finished_at: number | null; + expires_at: number | null; + face_count: number | null; + consumed_credits: number | null; + model_urls: Record; + image_urls: string[]; + texture_urls: Array>; + thumbnail_url: string | null; + thumbnail_urls: Record | null; + alpha_thumbnail_url: string | null; + result: Record | null; + printability: Record | null; + task_error: { message: string | null; [k: string]: unknown } | null; + /** Full response as received, only when --include-raw was given. */ + raw?: unknown; +} + +function num(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +function str(v: unknown): string | null { + return typeof v === "string" ? v : null; +} + +function strRecord(v: unknown): Record | null { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + out[k] = typeof val === "string" ? val : null; + } + return out; +} + +export interface TaskViewOptions { + descriptor?: TaskResourceDescriptor | null; + includeRaw?: boolean; + /** Explicit endpoint when there is no descriptor (e.g. a saved task JSON without resource). */ + endpoint?: string | null; +} + +/** + * Normalise a raw task object. Throws only when there is no usable `id`; every + * other field degrades to null so a partial server payload still yields a view. + */ +export function toTaskView(raw: unknown, opts: TaskViewOptions = {}): TaskView { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("task payload is not an object"); + } + const r = raw as Record; + const id = typeof r["id"] === "string" ? r["id"] : typeof r["task_id"] === "string" ? (r["task_id"] as string) : ""; + if (!id) throw new Error("task payload has no id"); + const taskError = r["task_error"]; + const view: TaskView = { + task_id: id, + resource: opts.descriptor?.id ?? null, + endpoint: opts.descriptor?.legacyEndpoint ?? opts.endpoint ?? null, + type: str(r["type"]), + name: str(r["name"]), + status: str(r["status"]), + progress: num(r["progress"]), + preceding_tasks: num(r["preceding_tasks"]), + created_at: num(r["created_at"]), + started_at: num(r["started_at"]), + finished_at: num(r["finished_at"]), + expires_at: num(r["expires_at"]), + face_count: num(r["face_count"]), + consumed_credits: num(r["consumed_credits"]), + model_urls: strRecord(r["model_urls"]) ?? {}, + image_urls: Array.isArray(r["image_urls"]) ? (r["image_urls"] as unknown[]).filter((u): u is string => typeof u === "string") : [], + texture_urls: Array.isArray(r["texture_urls"]) + ? (r["texture_urls"] as unknown[]).map((set) => strRecord(set) ?? {}) + : [], + thumbnail_url: str(r["thumbnail_url"]), + thumbnail_urls: strRecord(r["thumbnail_urls"]), + alpha_thumbnail_url: str(r["alpha_thumbnail_url"]), + result: r["result"] && typeof r["result"] === "object" && !Array.isArray(r["result"]) ? (r["result"] as Record) : null, + printability: r["printability"] && typeof r["printability"] === "object" ? (r["printability"] as Record) : null, + task_error: + taskError && typeof taskError === "object" + ? { ...(taskError as Record), message: str((taskError as Record)["message"]) } + : null, + }; + if (opts.includeRaw) view.raw = raw; + return view; +} + +/** Accept a task JSON in any of the shapes the CLI has written or the API returns. */ +export function extractTaskObject(input: unknown): { task: Record; source: "api" | "meta.json" | "v1-envelope" | "v1-result" } | null { + if (!input || typeof input !== "object" || Array.isArray(input)) return null; + const o = input as Record; + if (typeof o["id"] === "string" && typeof o["status"] === "string") return { task: o, source: "api" }; + const meta = o["task"]; + if (meta && typeof meta === "object" && typeof (meta as Record)["id"] === "string") { + return { task: meta as Record, source: "meta.json" }; + } + const result = o["result"]; + if (result && typeof result === "object") { + const rt = (result as Record)["task"]; + if (rt && typeof rt === "object" && typeof (rt as Record)["task_id"] === "string") { + const view = rt as Record; + // A v1 TaskView carries the same fields under task_id; fold it back. + const raw = view["raw"]; + if (raw && typeof raw === "object") return { task: raw as Record, source: "v1-envelope" }; + return { task: { ...view, id: view["task_id"] }, source: "v1-result" }; + } + } + return null; +} diff --git a/src/root.ts b/src/root.ts index b8cf85f..1f3433c 100644 --- a/src/root.ts +++ b/src/root.ts @@ -19,6 +19,7 @@ import { } from "./internal/global-options.js"; import { analyzePrintabilityCommand } from "./cmd/analyze-printability.js"; import { animateCommand } from "./cmd/animate.js"; +import { animationCatalogCommand } from "./cmd/animation-catalog.js"; import { apiCommand } from "./cmd/api.js"; import { authCommand } from "./cmd/auth.js"; import { balanceCommand } from "./cmd/balance.js"; @@ -35,6 +36,7 @@ import { resizeCommand } from "./cmd/resize.js"; import { resourcesCommand } from "./cmd/resources.js"; import { retextureCommand } from "./cmd/retexture.js"; import { riggingCommand } from "./cmd/rigging.js"; +import { showcasesCommand } from "./cmd/showcases.js"; import { textTo3dCommand } from "./cmd/text-to-3d.js"; import { textToImageCommand } from "./cmd/text-to-image.js"; import { textToMotionCommand } from "./cmd/text-to-motion.js"; @@ -186,6 +188,8 @@ export function buildRootCommand(): Command { analyzePrintabilityCommand, repairPrintabilityCommand, deleteCommand, + animationCatalogCommand, + showcasesCommand, ]) { program.addCommand(cmd, { hidden: true }); } diff --git a/tests/catalog-showcases.test.ts b/tests/catalog-showcases.test.ts new file mode 100644 index 0000000..78ec042 --- /dev/null +++ b/tests/catalog-showcases.test.ts @@ -0,0 +1,113 @@ +/** + * animation-catalog (T-026) and showcases (T-027) as black-box subprocesses. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { jsonReply, parseSingleJson, runCli, startMockApi } from "./helpers/cli.js"; + +const CATALOG = { + result: { + total: 3, + list: [ + { id: 290, key: "wave_one_hand", name: "Wave One Hand", category: "DailyActions", subCategory: "Interacting", previewUrl: "https://cdn.example.invalid/wave.gif", rigType: "biped", isDefault: false, isFree: true }, + { id: -1, key: "idle", name: "Idle", category: "DailyActions", subCategory: "Idle", previewUrl: "https://cdn.example.invalid/idle.gif", rigType: "biped", isDefault: true, isFree: true }, + { id: 12, key: "run", name: "Running", category: "WalkAndRun", subCategory: "Run", previewUrl: "https://cdn.example.invalid/run.gif", rigType: "biped", isDefault: false, isFree: false }, + ], + }, +}; + +test("T-026 catalog: public path, no Authorization/Cookie, no credential required, local search", async () => { + const api = await startMockApi((req, res) => { + if (req.path === "/web/public/animations/resources") { + const q = new URL(req.url, "http://x").searchParams; + const list = q.get("category") ? CATALOG.result.list.filter((e) => e.category === q.get("category")) : CATALOG.result.list; + return jsonReply(res, 200, { result: { total: list.length, list } }); + } + return jsonReply(res, 404, { message: "nope" }); + }); + try { + // No MESHY_API_KEY, no profile: the command must still work. + const env = api.env({ MESHY_API_KEY: undefined }); + const r = await runCli(["animation-catalog", "list", "--category", "DailyActions", "--search", "WAVE"], { env }); + assert.equal(r.code, 0, r.stderr); + const out = parseSingleJson(r.stdout) as { command: string; result: { items: Array<{ action_id: number; name: string }>; search_scope: string; fetched: number; total: number; authenticated: boolean } }; + assert.equal(out.command, "animation-catalog.list"); + assert.deepEqual(out.result.items.map((i) => i.action_id), [290]); + assert.equal(out.result.search_scope, "local"); + assert.equal(out.result.fetched, 2); + assert.equal(out.result.authenticated, false); + assert.equal(api.requests.length, 1); + const req = api.requests[0]!; + assert.equal(req.method, "GET"); + assert.equal(req.url, "/web/public/animations/resources?category=DailyActions"); + assert.equal(req.headers["authorization"], undefined); + assert.equal(req.headers["cookie"], undefined); + + // Negative ids and an empty result are fine; nothing is fabricated. + const empty = await runCli(["animation-catalog", "list", "--search", "does-not-exist"], { env }); + assert.equal(empty.code, 0); + assert.deepEqual((parseSingleJson(empty.stdout) as { result: { items: unknown[] } }).result.items, []); + const all = parseSingleJson((await runCli(["animation-catalog", "list"], { env })).stdout) as { result: { items: Array<{ action_id: number }> } }; + assert.ok(all.result.items.some((i) => i.action_id === -1)); + + // Legacy schema is not available for a v1-only command. + const legacy = await runCli(["animation-catalog", "list", "--output-schema", "legacy"], { env }); + assert.equal(legacy.code, 2); + } finally { + await api.close(); + } +}); + +test("T-027 showcases: exactly one billable GET with the given params; items pass through; alias warns", async () => { + const items = [{ id: "s1", result_id: "t1", name: "Car", author: "a", community_url: "https://www.meshy.ai/x", model_url: "https://assets.example.invalid/m.glb", conversion_status: "", mode: "refine", extra: { kept: true } }]; + const api = await startMockApi((req, res) => { + if (req.path === "/openapi/v1/showcases") return jsonReply(res, 200, { result: items }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const r = await runCli(["showcases", "list", "--search", "car", "--page-size", "3", "--model-format", "glb", "--showcase-type", "animated", "--sort-by", "-downloads"], { env: api.env() }); + assert.equal(r.code, 0, r.stderr); + const out = parseSingleJson(r.stdout) as { result: { items: unknown[]; requests_made: number; billing: string }; warnings: Array<{ code: string }> }; + assert.deepEqual(out.result.items, items); + assert.equal(out.result.requests_made, 1); + assert.equal(out.result.billing, "may-charge"); + assert.equal(out.warnings[0]?.code, "showcase_type_alias"); + assert.equal(api.requests.length, 1); + const q = new URL(api.requests[0]!.url, "http://x").searchParams; + assert.equal(q.get("search"), "car"); + assert.equal(q.get("page_size"), "3"); + assert.equal(q.get("format"), "glb"); + assert.equal(q.get("showcase_type"), "animate"); + assert.equal(q.get("sort_by"), "-downloads"); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_fixture_key_loopback_only"); + } finally { + await api.close(); + } +}); + +test("T-027 showcases: non-Enterprise 403 and a network error are single attempts, never retried", async () => { + let hits = 0; + const api = await startMockApi((_req, res) => { + hits += 1; + jsonReply(res, 403, { message: "This endpoint is only available for enterprise users" }); + }); + try { + const r = await runCli(["showcases", "list"], { env: api.env() }); + assert.equal(r.code, 1, r.stderr); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { http_status: number; message: string } }; + assert.equal(out.ok, false); + assert.equal(out.error.http_status, 403); + assert.match(out.error.message, /enterprise/i); + assert.equal(hits, 1); + const bad = await runCli(["showcases", "list", "--page-size", "11"], { env: api.env() }); + assert.equal(bad.code, 2); + assert.equal(hits, 1, "usage errors make no request"); + } finally { + await api.close(); + } + const dead = await startMockApi(() => undefined); + await dead.close(); + const r = await runCli(["showcases", "list"], { env: dead.env() }); + assert.equal(r.code, 7, r.stderr); +}); diff --git a/tests/file-input.test.ts b/tests/file-input.test.ts index b361412..d23c9c2 100644 --- a/tests/file-input.test.ts +++ b/tests/file-input.test.ts @@ -162,11 +162,13 @@ test("resolveImageFields — retexture's multi-view list resolves local paths to } }); -test("resolveImageFields — data: URIs rejected with helpful message", async () => { - await assert.rejects( - () => resolveImageFields({ imageUrl: "data:image/png;base64,iVBORw0KGgo=" }), - /data: URIs aren't accepted on the command line/, - ); +test("resolveImageFields — well-formed data: URIs pass through; malformed ones are refused", async () => { + const opts = { imageUrl: "data:image/png;base64,iVBORw0KGgo=" }; + await resolveImageFields(opts); + assert.equal(opts.imageUrl, "data:image/png;base64,iVBORw0KGgo="); + await assert.rejects(() => resolveImageFields({ imageUrl: "data:image/png,notbase64" }), /must be base64/); + await assert.rejects(() => resolveImageFields({ imageUrl: "data:model/gltf-binary;base64,Z2xURg==" }), /expected an image/); + await assert.rejects(() => resolveImageFields({ imageUrl: "data:garbage" }), /malformed data: URI/); }); test("resolveImageFields — fields that aren't image inputs are ignored", async () => { @@ -259,9 +261,43 @@ test("resolveModelFields — URL preflighted, passed through on 2xx", async () = } }); -test("resolveModelFields — data: URIs rejected", async () => { - await assert.rejects( - () => resolveModelFields({ modelUrl: "data:model/gltf-binary;base64,Z2xURg==" }), - /data: URIs aren't accepted on the command line/, +test("resolveModelFields — model data: URIs pass through; image data: URIs are refused", async () => { + const opts = { modelUrl: "data:model/gltf-binary;base64,Z2xURg==" }; + await resolveModelFields(opts); + assert.equal(opts.modelUrl, "data:model/gltf-binary;base64,Z2xURg=="); + await assert.rejects(() => resolveModelFields({ modelUrl: "data:image/png;base64,iVBORw0KGgo=" }), /expected a 3D-model/); +}); + +test("normalizeMediaPayload — only declared fields are touched; size cap and format lists are enforced (T-029, T-030)", async () => { + const { normalizeMediaPayload } = await import("../src/internal/file-input.js"); + const dir = mkdtempSync(join(tmpdir(), "meshy-media-")); + const png = join(dir, "a.png"); + writeFileSync(png, await tinyPng()); + const glbPath = join(dir, "m.glb"); + writeFileSync(glbPath, fakeGlb()); + const objPath = join(dir, "m.obj"); + writeFileSync(objPath, "v 0 0 0\n"); + + const fields = [ + { path: "image_url", kind: "image" as const, many: false }, + { path: "model_url", kind: "model" as const, many: false, formats: ["glb"] }, + { path: "image_urls", kind: "image" as const, many: true }, + ]; + const { payload, media } = await normalizeMediaPayload( + { image_url: png, model_url: glbPath, image_urls: [png, "data:image/png;base64,iVBORw0KGgo="], prompt: "./looks/like/a/path.png", untouched: 1 }, + fields, ); + assert.match(payload.image_url as string, /^data:image\/png;base64,/); + assert.match(payload.model_url as string, /^data:model\/gltf-binary;base64,/); + assert.equal(payload.prompt, "./looks/like/a/path.png", "undeclared strings are never read as files"); + assert.equal(payload.untouched, 1); + assert.equal((payload.image_urls as string[]).length, 2); + assert.deepEqual(media.map((m) => [m.field, m.index, m.source]), [["image_url", null, "local-file"], ["model_url", null, "local-file"], ["image_urls", 0, "local-file"], ["image_urls", 1, "data-uri"]]); + + // Format list: uv-unwrap/rigging accept GLB only. + await assert.rejects(() => normalizeMediaPayload({ model_url: objPath }, [fields[1]!]), /accepts glb only/); + // Size cap is enforced before reading. + await assert.rejects(() => normalizeMediaPayload({ image_url: png }, [fields[0]!], { limits: { maxFileBytes: 10 } }), /above the 10-byte limit/); + // Non-http schemes are refused, not read. + await assert.rejects(() => normalizeMediaPayload({ image_url: "file:///etc/passwd" }, [fields[0]!]), /only http\(s\) URLs/); }); diff --git a/tests/resource-registry.test.ts b/tests/resource-registry.test.ts new file mode 100644 index 0000000..9c9715b --- /dev/null +++ b/tests/resource-registry.test.ts @@ -0,0 +1,104 @@ +/** + * The registry is the executable contract; docs/skill-parity/endpoint-contracts.json + * is its documentation. They must agree field by field (T-020, T-022). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + CREATIVE_LAB_PRODUCTS, + creativeLabResource, + QUERY_RESOURCES, + resourceIndex, + TASK_RESOURCES, + taskResourceByCommandPath, +} from "../src/client/resource-registry.js"; + +interface DocResource { + id: string; + commandPath: string[]; + base: string; + relativePath: string; + legacyEndpoint: string; + supports: Record; + mediaFields: Array<{ path: string; kind: string; many: boolean; formats?: string[] }>; + taskTypes: string[]; + billing: { create: string }; +} + +const docs = JSON.parse(readFileSync(new URL("../docs/skill-parity/endpoint-contracts.json", import.meta.url), "utf8")) as { + task_resources: DocResource[]; + query_resources: Array<{ id: string; commandPath: string[]; base: string; relativePath: string; auth: string }>; +}; + +test("every documented task resource exists in the registry with the same routing and media fields", () => { + assert.equal(TASK_RESOURCES.length, docs.task_resources.length, "resource count drifted"); + for (const d of docs.task_resources) { + const r = TASK_RESOURCES.find((x) => x.id === d.id); + assert.ok(r, `registry is missing ${d.id}`); + assert.deepEqual([...r.commandPath], d.commandPath, `${d.id} commandPath`); + assert.equal(r.base, d.base, `${d.id} base`); + assert.equal(r.relativePath, d.relativePath, `${d.id} relativePath`); + assert.equal(r.legacyEndpoint, d.legacyEndpoint, `${d.id} legacyEndpoint`); + assert.deepEqual({ ...r.supports }, d.supports, `${d.id} supports`); + assert.deepEqual( + r.mediaFields.map((m) => ({ path: m.path, kind: m.kind, many: m.many, ...(m.formats ? { formats: [...m.formats] } : {}) })), + d.mediaFields, + `${d.id} mediaFields`, + ); + assert.deepEqual([...r.taskTypes], d.taskTypes, `${d.id} taskTypes`); + assert.equal(r.billing.create, d.billing.create, `${d.id} billing`); + assert.equal(r.automaticRetry, false); + } +}); + +test("query resources agree with the documentation and the catalog carries no credential", () => { + for (const d of docs.query_resources) { + const q = QUERY_RESOURCES.find((x) => x.id === d.id); + assert.ok(q, `registry is missing query ${d.id}`); + assert.deepEqual([...q.commandPath], d.commandPath); + assert.equal(q.base, d.base); + assert.equal(q.relativePath, d.relativePath); + assert.equal(q.auth, d.auth); + } + assert.equal(QUERY_RESOURCES.find((q) => q.id === "animation-catalog")?.auth, "none"); + assert.equal(QUERY_RESOURCES.find((q) => q.id === "showcases")?.billing, "may-charge"); +}); + +test("Creative Lab: 4 products × 2 stages, exact paths, no path built from user strings (T-022, T-023)", () => { + for (const product of CREATIVE_LAB_PRODUCTS) { + for (const stage of ["prototype", "build"] as const) { + const r = creativeLabResource(product, stage); + assert.ok(r, `${product}/${stage}`); + assert.equal(r.relativePath, `/${product}/v1/${stage}`); + assert.equal(r.legacyEndpoint, `/openapi/creative-lab/${product}/v1/${stage}`); + assert.equal(r.creativeLab?.product, product); + assert.equal(r.creativeLab?.stage, stage); + assert.equal(r.mediaFields.length, stage === "prototype" ? 1 : 0); + } + } + for (const [p, s] of [["../figure", "prototype"], ["figure", "../build"], ["https://evil.example/", "build"], ["figure prototype", "build"], ["Figure", "prototype"], ["", "prototype"], ["keycap", "build"]]) { + assert.equal(creativeLabResource(p!, s!), undefined, `${p}/${s} must not resolve`); + } + assert.equal(taskResourceByCommandPath(["creative-lab", "lamp", "build"])?.id, "creative-lab.lamp.build"); + assert.equal(taskResourceByCommandPath(["creative-lab", "lamp"]), undefined); +}); + +test("legacy routing facts: animate → /animations, text-to-3d on v2, rigging list enabled", () => { + assert.equal(TASK_RESOURCES.find((r) => r.id === "animate")?.relativePath, "/animations"); + assert.equal(TASK_RESOURCES.find((r) => r.id === "text-to-3d")?.base, "v2"); + assert.equal(TASK_RESOURCES.find((r) => r.id === "rigging")?.supports.list, true); + assert.equal(TASK_RESOURCES.find((r) => r.id === "analyze-printability")?.billing.create, "none"); +}); + +test("resourceIndex classifies every entry and never invents a verb", () => { + const idx = resourceIndex(); + const kinds = new Set(idx.map((e) => e.kind)); + assert.deepEqual([...kinds].sort(), ["local", "query", "task"]); + for (const e of idx.filter((x) => x.kind === "task")) { + assert.deepEqual(e.verbs, ["create", "get", "list", "wait", "stream", "delete"]); + assert.match(e.command, /^meshy /); + } + for (const e of idx.filter((x) => x.kind !== "task")) assert.equal(e.verbs, null); +}); diff --git a/tests/task-view.test.ts b/tests/task-view.test.ts new file mode 100644 index 0000000..8ccbe37 --- /dev/null +++ b/tests/task-view.test.ts @@ -0,0 +1,69 @@ +/** + * TaskView normalisation (T-008): 0 stays 0, missing stays null, extra fields + * survive in raw, and every saved-JSON shape yields the same task. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { extractTaskObject, toTaskView } from "../src/internal/task-view.js"; +import { requireTaskResource } from "../src/client/resource-registry.js"; + +const rigFixture = JSON.parse(readFileSync(new URL("./fixtures/skill-parity/task-rigging.synthetic.json", import.meta.url), "utf8")) as Record; + +test("face_count and consumed_credits: 0 is 0, null is null, missing is null", () => { + const base = { id: "t", status: "SUCCEEDED" }; + assert.equal(toTaskView({ ...base, face_count: 0, consumed_credits: 0 }).face_count, 0); + assert.equal(toTaskView({ ...base, face_count: 0, consumed_credits: 0 }).consumed_credits, 0); + assert.equal(toTaskView({ ...base, face_count: null }).face_count, null); + assert.equal(toTaskView(base).face_count, null); + assert.equal(toTaskView(base).consumed_credits, null); + assert.equal(toTaskView({ ...base, face_count: "1234" }).face_count, null, "strings are not silently parsed"); + assert.equal(toTaskView({ ...base, face_count: Number.NaN }).face_count, null); +}); + +test("missing optional fields are null/empty, never defaulted to 0 or ''", () => { + const v = toTaskView({ id: "t" }); + assert.equal(v.status, null); + assert.equal(v.progress, null); + assert.equal(v.created_at, null); + assert.equal(v.type, null); + assert.deepEqual(v.model_urls, {}); + assert.deepEqual(v.image_urls, []); + assert.equal(v.thumbnail_urls, null); + assert.equal(v.task_error, null); +}); + +test("descriptor supplies resource/endpoint; include-raw keeps every extra field and the original shape", () => { + const raw = { ...rigFixture, novel_field: { deep: [1, 2] } }; + const v = toTaskView(raw, { descriptor: requireTaskResource("rigging"), includeRaw: true }); + assert.equal(v.resource, "rigging"); + assert.equal(v.endpoint, "/openapi/v1/rigging"); + assert.equal(v.face_count, 250000); + assert.equal(v.consumed_credits, 5); + assert.deepEqual(v.result?.["basic_animations"], (rigFixture["result"] as Record)["basic_animations"]); + assert.deepEqual((v.raw as Record)["novel_field"], { deep: [1, 2] }); + assert.equal("raw" in toTaskView(raw), false); +}); + +test("thumbnail_urls object keyed by view is preserved; non-string entries become null", () => { + const v = toTaskView({ id: "t", thumbnail_urls: { front: "https://a/f.png", back: 3 } }); + assert.deepEqual(v.thumbnail_urls, { front: "https://a/f.png", back: null }); +}); + +test("extractTaskObject accepts API task, meta.json, v1 envelope with raw and plain v1 result", () => { + const api = extractTaskObject(rigFixture); + assert.equal(api?.source, "api"); + const meta = extractTaskObject({ resource: "rigging", task: rigFixture, saved_files: [] }); + assert.equal(meta?.source, "meta.json"); + assert.equal(meta?.task["id"], "fixture-rig-1"); + const view = toTaskView(rigFixture, { includeRaw: true }); + const env = extractTaskObject({ schema_version: "meshy.cli/v1", command: "rigging.get", ok: true, result: { task: view }, error: null, warnings: [] }); + assert.equal(env?.source, "v1-envelope"); + assert.equal(env?.task["id"], "fixture-rig-1"); + const plain = extractTaskObject({ result: { task: toTaskView(rigFixture) } }); + assert.equal(plain?.source, "v1-result"); + assert.equal(plain?.task["id"], "fixture-rig-1"); + assert.equal(extractTaskObject({ hello: 1 }), null); + assert.equal(extractTaskObject([1]), null); +}); diff --git a/tests/transport.test.ts b/tests/transport.test.ts new file mode 100644 index 0000000..6d8a4af --- /dev/null +++ b/tests/transport.test.ts @@ -0,0 +1,140 @@ +/** + * Transport boundaries (T-031, T-032): credential scope, redirect refusal, + * body-inclusive deadlines, body caps, and the not-sent vs unknown phase. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import { createTransport, resolveApiUrl, TransportError } from "../src/client/transport.js"; +import { MeshyApiError } from "../src/client/errors.js"; + +const BASE = "https://api.example.com/openapi/v1"; + +test("resolveApiUrl — relative paths stay inside the base; escapes are refused before any request", () => { + assert.equal(resolveApiUrl(BASE, "/balance").href, "https://api.example.com/openapi/v1/balance"); + assert.equal(resolveApiUrl(BASE, "text-to-3d/abc%2Fslash").href, "https://api.example.com/openapi/v1/text-to-3d/abc%2Fslash"); + assert.equal(resolveApiUrl(BASE, "https://api.example.com/openapi/v1/rigging/x").href, "https://api.example.com/openapi/v1/rigging/x"); + for (const bad of [ + "//evil.example/openapi/v1/balance", + "https://evil.example/openapi/v1/balance", + "https://api.example.com/openapi/v2/text-to-3d", + "https://user:pw@api.example.com/openapi/v1/balance", + "ftp://api.example.com/openapi/v1/balance", + "/../v2/text-to-3d", + "/text-to-3d/../../admin", + ]) { + assert.throws(() => resolveApiUrl(BASE, bad), (e: unknown) => e instanceof TransportError && e.phase === "validate", bad); + } +}); + +async function listen(handler: Parameters[1]): Promise<{ server: Server; url: string }> { + const server = createServer(handler); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const addr = server.address(); + if (!addr || typeof addr !== "object") throw new Error("no addr"); + return { server, url: `http://127.0.0.1:${addr.port}` }; +} + +function close(server: Server): Promise { + return new Promise((r) => { + server.closeAllConnections(); + server.close(() => r()); + }); +} + +test("authenticated transport sends the bearer, public transport never does", async () => { + const seen: Array = []; + const { server, url } = await listen((req, res) => { + seen.push(req.headers["authorization"]); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + try { + const auth = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + const pub = createTransport({ baseUrl: `${url}/web/public`, readTimeoutMs: 5000 }); + await auth.requestJson("GET", "/balance"); + await pub.requestJson("GET", "/animations/resources", { headers: { Authorization: "Bearer leaked", Cookie: "a=b" } }); + assert.deepEqual(seen, ["Bearer msy_k", undefined]); + } finally { + await close(server); + } +}); + +test("redirects are refused rather than followed with a credential", async () => { + const hits: string[] = []; + const { server, url } = await listen((req, res) => { + hits.push(req.url ?? ""); + res.writeHead(302, { location: "https://evil.example/steal" }); + res.end(); + }); + try { + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + await assert.rejects(t.requestJson("GET", "/balance"), (e: unknown) => e instanceof TransportError && e.phase === "response" && /redirect/.test(e.message)); + assert.deepEqual(hits, ["/openapi/v1/balance"]); + } finally { + await close(server); + } +}); + +test("the deadline covers the body: headers then a stalled body time out and report phase timeout", async () => { + const { server, url } = await listen((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.write('{"partial":'); + // never end + }); + try { + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 300 }); + const started = Date.now(); + await assert.rejects(t.requestJson("GET", "/slow"), (e: unknown) => e instanceof TransportError && e.phase === "timeout"); + assert.ok(Date.now() - started < 5000); + } finally { + await close(server); + } +}); + +test("oversized bodies fail explicitly instead of being truncated", async () => { + const { server, url } = await listen((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ blob: "x".repeat(5000) })); + }); + try { + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + await assert.rejects(t.requestJson("GET", "/big", { maxBodyBytes: 1000 }), (e: unknown) => e instanceof MeshyApiError && /exceeds 1000 bytes/.test(e.message)); + const ok = await t.requestJson("GET", "/big"); + assert.equal((ok.json as { blob: string }).blob.length, 5000); + } finally { + await close(server); + } +}); + +test("connection refused is classified as never sent; an abort mid-flight is 'aborted'", async () => { + const { server, url } = await listen(() => undefined); + await close(server); // port is now closed + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 2000 }); + await assert.rejects(t.requestJson("POST", "/text-to-3d", { body: {} }), (e: unknown) => e instanceof TransportError && e.phase === "connect" && e.neverSent); + + const stalled = await listen(() => undefined); + try { + const t2 = createTransport({ baseUrl: `${stalled.url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + const ac = new AbortController(); + const p = t2.requestJson("POST", "/text-to-3d", { body: {}, signal: ac.signal }); + setTimeout(() => ac.abort(), 50); + await assert.rejects(p, (e: unknown) => e instanceof TransportError && e.phase === "aborted" && !e.neverSent); + } finally { + await close(stalled.server); + } +}); + +test("non-2xx bodies map to MeshyApiError with the status and message", async () => { + const { server, url } = await listen((_req, res) => { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "Task not found" })); + }); + try { + const t = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + await assert.rejects(t.requestJson("GET", "/uv-unwrap/x"), (e: unknown) => e instanceof MeshyApiError && e.status === 404 && e.code === "not_found" && /Task not found/.test(e.message)); + } finally { + await close(server); + } +}); From 67843098daa30d40db076d5c123ff89d308bac17 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 15:12:16 +0800 Subject: [PATCH 04/26] =?UTF-8?q?feat(tasks):=20B03=20task=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20v1=20verbs,=20journaled=20single=20POST,=20SSE=20st?= =?UTF-8?q?ream,=20make=20async,=20uv-unwrap,=20creative-lab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - task-command: create/get/wait/stream/delete/list in both schemas; get of any status is exit 0; wait/stream FAILED exit 1 with the task kept; --timeout 0 is one query; --save-json/--include-raw; SIGINT → 130 with resume command - operation-store + lock: journal written before the POST; 5xx / malformed 2xx / lost response → submission_unknown (exit 10) with reconcile guidance, never a retry; --operation-id replays or conflicts; no key material on disk - stream.ts: WHATWG-style SSE parser (byte splits, CRLF, UTF-8, multi-line data, comments) and streamTask with total/idle deadlines; error events after HTTP 200 mapped by status_code; ndjson task/outcome events with sequence - poll.ts: monotonic deadline, cancellable sleep, finite timeout validation - make: --async is one POST with pending_steps, --stop-after-first keeps the old behaviour, both together refused; steps journaled - new commands: uv-unwrap (exclusive source, GLB-only) and creative-lab figure|lamp|keychain|fridge-magnet prototype|build with per-product option validation; --data parse errors are usage errors - tests: sse, operation-store (incl. cross-process race), task-lifecycle, uv/creative-lab/make black-box; capability matrix marks 20 items implemented Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/capability-matrix.json | 130 ++-- src/client/endpoints/base.ts | 5 + src/client/errors.ts | 2 +- src/cmd/creative-lab.ts | 287 +++++++++ src/cmd/make.ts | 263 +++++++- src/cmd/resources.ts | 2 + src/cmd/uv-unwrap.ts | 54 ++ src/internal/lock.ts | 71 +++ src/internal/operation-store.ts | 207 ++++++ src/internal/payload.ts | 16 +- src/internal/poll.ts | 74 ++- src/internal/stream.ts | 298 +++++++++ src/internal/task-command.ts | 770 +++++++++++++++++++---- src/root.ts | 4 + tests/helpers/begin-operation-child.ts | 19 + tests/operation-store.test.ts | 90 +++ tests/poll.test.ts | 13 +- tests/sse.test.ts | 232 +++++++ tests/task-lifecycle.test.ts | 292 +++++++++ tests/uv-creative-lab.test.ts | 256 ++++++++ 20 files changed, 2880 insertions(+), 205 deletions(-) create mode 100644 src/cmd/creative-lab.ts create mode 100644 src/cmd/uv-unwrap.ts create mode 100644 src/internal/lock.ts create mode 100644 src/internal/operation-store.ts create mode 100644 src/internal/stream.ts create mode 100644 tests/helpers/begin-operation-child.ts create mode 100644 tests/operation-store.test.ts create mode 100644 tests/sse.test.ts create mode 100644 tests/task-lifecycle.test.ts create mode 100644 tests/uv-creative-lab.test.ts diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index 93a7b45..d14596b 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -19,7 +19,7 @@ "source_hint": "legacy scripts/src/meshy_task.py:create_task/_cmd_create", "target": "meshy create --async", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-040", @@ -27,7 +27,9 @@ "T-044", "T-045" ], - "evidence": [], + "evidence": [ + "src/internal/task-command.ts submitOnce: journal → single POST → journal; tests/task-lifecycle.test.ts T-040/T-043/T-044" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -41,14 +43,16 @@ "source_hint": "meshy_task.py:_cmd_get", "target": "meshy get --include-raw --save-json", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-006", "T-008", "T-010" ], - "evidence": [], + "evidence": [ + "task-view.ts + task-command get: any status exit 0, --include-raw, --save-json; tests T-006/T-008/T-009/T-010" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -62,14 +66,16 @@ "source_hint": "meshy_task.py:poll_task/_cmd_poll", "target": "meshy wait", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-007", "T-047", "T-048" ], - "evidence": [], + "evidence": [ + "poll.ts monotonic deadline, cancellable sleep, --timeout 0 single query; tests T-007/T-047/T-048" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -83,7 +89,7 @@ "source_hint": "skills/*/reference.md:SSE examples", "target": "meshy stream", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-049", @@ -93,7 +99,8 @@ "T-053" ], "evidence": [ - "meshyd api_stream_handlers.go: event message (task), event error {message,status_code}, keep-alive re-emits message every 10 s" + "meshyd api_stream_handlers.go: event message (task), event error {message,status_code}, keep-alive re-emits message every 10 s", + "stream.ts SSE parser + streamTask; task-command stream (ndjson events + outcome, json envelope); tests/sse.test.ts, T-053" ], "intentional_differences": [], "external_verification": "offline_primary" @@ -108,7 +115,7 @@ "source_hint": "meshy_task.py:balance; Skill REST list/delete examples", "target": "meshy balance; meshy list|delete", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-001", @@ -116,8 +123,12 @@ "T-020", "T-103" ], - "evidence": [], - "intentional_differences": [], + "evidence": [ + "balance/list/delete v1 + legacy; rigging list enabled per registry; tests cli-contract T-001, T-011" + ], + "intentional_differences": [ + "rigging list enabled (0.2.0 marked it unsupported; official docs and server routes provide it)" + ], "external_verification": "offline_primary" }, { @@ -130,14 +141,16 @@ "source_hint": "skills/*/references/pipelines.md", "target": "existing commands with explicit supported payload", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-028", "T-033", "T-034" ], - "evidence": [], + "evidence": [ + "payload merge defaults < --data < flags with explicit false/0 preserved; tests T-028; surface.test.ts unchanged" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -151,14 +164,16 @@ "source_hint": "pipelines.md:base64 Python snippets", "target": "typed media flags and final payload normalization", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-029", "T-030", "T-031" ], - "evidence": [], + "evidence": [ + "file-input.ts normalizeMediaPayload on merged payload: URL preflight (no auth), data URI, local file; tests T-029/T-030" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -172,7 +187,7 @@ "source_hint": "reference.md:UV Unwrap API", "target": "meshy uv-unwrap create/get/list/wait/stream/delete", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-021", @@ -180,7 +195,8 @@ ], "evidence": [ "docs.meshy.ai/en/api/uv-unwrap (input_task_id|model_url, GLB only, 40k faces, Statsig-gated 404)", - "meshyd server.go: /openapi/v1/uv-unwrap registers POST, GET list, GET :id, GET :id/stream, DELETE :id" + "meshyd server.go: /openapi/v1/uv-unwrap registers POST, GET list, GET :id, GET :id/stream, DELETE :id", + "src/cmd/uv-unwrap.ts: exclusive source, GLB-only, 404 reported as not_found; tests T-021" ], "intentional_differences": [], "external_verification": "required_when_applicable_not_run" @@ -195,7 +211,7 @@ "source_hint": "reference.md:Creative Lab API", "target": "meshy creative-lab figure prototype|build ", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-022", @@ -204,7 +220,8 @@ "T-025" ], "evidence": [ - "docs.meshy.ai/en/api/creative-lab-figure (prototype image_urls; build glb/obj/mtl + texture base_color; webapp prototypes 404)" + "docs.meshy.ai/en/api/creative-lab-figure (prototype image_urls; build glb/obj/mtl + texture base_color; webapp prototypes 404)", + "src/cmd/creative-lab.ts figure prototype/build; tests T-022..T-025" ], "intentional_differences": [], "external_verification": "required_when_applicable_not_run" @@ -219,7 +236,7 @@ "source_hint": "reference.md:Creative Lab API", "target": "meshy creative-lab lamp prototype|build ", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-022", @@ -229,9 +246,12 @@ ], "evidence": [ "docs.meshy.ai/en/api/creative-lab-lamp (image_subject; prototype model_urls.glb; build options/output stl|zip; lamp_stl/base_stl/bundle_zip)", - "meshyd pkg/model/task_creative_lab_lamp.go artifact keys and enums" + "meshyd pkg/model/task_creative_lab_lamp.go artifact keys and enums", + "src/cmd/creative-lab.ts lamp: image_subject, text rejected, options/output stl|zip, include_result_json rule; tests T-022..T-025" + ], + "intentional_differences": [ + "deprecated lamp `text` prototype input is rejected before submission; official lamp page documents image_url" ], - "intentional_differences": [], "external_verification": "required_when_applicable_not_run" }, { @@ -244,7 +264,7 @@ "source_hint": "reference.md:Creative Lab API", "target": "meshy creative-lab keychain prototype|build ", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-022", @@ -254,7 +274,8 @@ ], "evidence": [ "docs.meshy.ai/en/api/creative-lab-keychain (relief options; output glb|obj|zip; obj is a ZIP bundle)", - "meshyd pkg/model/task_creative_lab_keychain.go" + "meshyd pkg/model/task_creative_lab_keychain.go", + "src/cmd/creative-lab.ts keychain relief options, output glb|obj|zip; tests T-022..T-025" ], "intentional_differences": [], "external_verification": "required_when_applicable_not_run" @@ -269,7 +290,7 @@ "source_hint": "reference.md:Creative Lab API", "target": "meshy creative-lab fridge-magnet prototype|build ", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-022", @@ -279,7 +300,8 @@ ], "evidence": [ "docs.meshy.ai/en/api/creative-lab-fridge-magnet (own defaults: rounded-rect, 60 mm, 3.3 mm relief, 2.0 mm base)", - "meshyd pkg/model/task_creative_lab_fridge_magnet.go" + "meshyd pkg/model/task_creative_lab_fridge_magnet.go", + "src/cmd/creative-lab.ts fridge-magnet relief options; tests T-022..T-025" ], "intentional_differences": [], "external_verification": "required_when_applicable_not_run" @@ -294,14 +316,15 @@ "source_hint": "pipelines.md:/web/public/animations/resources", "target": "meshy animation-catalog list", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-026" ], "evidence": [ "Skill pipelines.md: GET /web/public/animations/resources?category=…; response {result:{total,list}}", - "meshyd api.go AnimationResource + ListAnimationsResourcesRequest (q, category, subCategory)" + "meshyd api.go AnimationResource + ListAnimationsResourcesRequest (q, category, subCategory)", + "src/cmd/animation-catalog.ts: public transport, no credential, local search; tests T-026" ], "intentional_differences": [], "external_verification": "offline_primary" @@ -316,7 +339,7 @@ "source_hint": "reference.md:Enterprise API", "target": "meshy showcases list", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-027", @@ -324,9 +347,12 @@ ], "evidence": [ "docs.meshy.ai/en/api/enterprise-api (1 credit per request; page_size 1-10; sort_by; format; showcase_type)", - "meshyd api.go ListShowcasesForOpenAPIRequest: showcase_type oneof all animate static (docs say animated)" + "meshyd api.go ListShowcasesForOpenAPIRequest: showcase_type oneof all animate static (docs say animated)", + "src/cmd/showcases.ts: single billable GET, alias warning, no retry; tests T-027" + ], + "intentional_differences": [ + "showcase_type accepts docs spelling \"animated\" and sends the server enum \"animate\" with a warning (decisions D-007)" ], - "intentional_differences": [], "external_verification": "required_when_applicable_not_run" }, { @@ -596,14 +622,18 @@ "source_hint": "meshy_task.py:load_api_key", "target": "--env-file without automatic dotenv/shell scanning", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-100", "T-101" ], - "evidence": [], - "intentional_differences": [], + "evidence": [ + "config.ts + env-file.ts (--api-key-file); tests env-file.test.ts, T-100" + ], + "intentional_differences": [ + "flag is --api-key-file, not --env-file: Node.js intercepts --env-file anywhere in argv (decisions D-025)" + ], "external_verification": "offline_primary" }, { @@ -636,7 +666,7 @@ "source_hint": "CLI task-command/output/errors", "target": "--output-schema v1; legacy compatibility", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-001", @@ -646,7 +676,9 @@ "T-005", "T-011" ], - "evidence": [], + "evidence": [ + "result.ts envelope, errors.ts codes/exits, Commander errors → exit 2; tests result.test.ts, cli-contract T-002..T-005" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -660,13 +692,15 @@ "source_hint": "CLI cmd/make.ts:runChain", "target": "make --async / --stop-after-first", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-041", "T-042" ], - "evidence": [], + "evidence": [ + "src/cmd/make.ts --async single POST, --stop-after-first, mutual exclusion; tests T-041/T-042" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -680,7 +714,7 @@ "source_hint": "old workflows persist task ids; new recovery requirement", "target": "operation journal; never claim server idempotency", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-043", @@ -689,7 +723,9 @@ "T-046", "T-048" ], - "evidence": [], + "evidence": [ + "operation-store.ts journal with lock, replay and conflict; tests operation-store.test.ts, T-046" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -703,7 +739,7 @@ "source_hint": "CLI client/index.ts and file-input/download", "target": "explicit origin and no auth on public/assets", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-031", @@ -711,7 +747,9 @@ "T-105", "T-106" ], - "evidence": [], + "evidence": [ + "transport.ts path validation, redirect refusal, public transport; config.ts origin policy; tests transport.test.ts" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -725,14 +763,16 @@ "source_hint": "CLI runtime.ts/index.ts plus local Skill helpers", "target": "local runtime; update-check control", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-012", "T-072", "T-102" ], - "evidence": [], + "evidence": [ + "runtime.ts buildLocalRuntime; index.ts update-check policy; tests T-102, T-012" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -797,6 +837,6 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-07T06:26:21.988Z" + "updated_at": "2026-09-07T07:11:01.359Z" } } diff --git a/src/client/endpoints/base.ts b/src/client/endpoints/base.ts index 0312eb7..69ebde0 100644 --- a/src/client/endpoints/base.ts +++ b/src/client/endpoints/base.ts @@ -60,6 +60,11 @@ export class TaskEndpoint { this.resourcePath = resourcePath; } + /** Base URL of the API family this endpoint talks to (for journaling the origin). */ + get transportBaseUrl(): string { + return this.transport.baseUrl; + } + async create(payload: Record, extras: RequestExtras = {}): Promise { return (await this.createDetailed(payload, extras)).taskId; } diff --git a/src/client/errors.ts b/src/client/errors.ts index 59fea3b..72a32bf 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -48,7 +48,7 @@ export class MeshyApiError extends Error { } } -function codeForStatus(status: number): MeshyErrorCode { +export function codeForStatus(status: number): MeshyErrorCode { if (status === 400 || status === 422) return "validation"; if (status === 401) return "auth"; if (status === 402) return "credit"; diff --git a/src/cmd/creative-lab.ts b/src/cmd/creative-lab.ts new file mode 100644 index 0000000..ffa7bb6 --- /dev/null +++ b/src/cmd/creative-lab.ts @@ -0,0 +1,287 @@ +/** + * creative-lab — https://docs.meshy.ai/en/api/creative-lab-{figure,lamp,keychain,fridge-magnet} + * + * Four products × two stages, each its own registered resource with the same + * create/get/list/wait/stream/delete verbs. Nothing here builds a path from a + * user string: product and stage are resolved through the registry. + * + * Stage meanings differ by product and are not generalised: + * figure / keychain / fridge-magnet prototype → a styled concept image + * lamp prototype → concept image + lampshade GLB + * figure build → GLB, OBJ, MTL, base-color texture + * lamp build → lamp_stl (+ base_stl) or bundle_zip + * keychain / fridge-magnet build → glb, obj (a ZIP bundle) or bundle_zip + * + * Build options are validated per product with the ranges the API documents + * so a bad value fails before a billable POST; unknown option keys are passed + * through (the server is the final validator). + */ + +import { Command, Option } from "commander"; +import { z } from "zod"; +import { CREATIVE_LAB_PRODUCTS, type CreativeLabProduct, type CreativeLabStage } from "../client/resource-registry.js"; +import { UsageError } from "../internal/errors.js"; +import { parseJsonFlag } from "../internal/payload.js"; +import { buildResourceCommand, type ResourceCommandSpec } from "../internal/task-command.js"; + +const NAME_MAX = 100; + +const lampOptionsSchema = z + .object({ + diameter_mm: z.number().min(50).max(400).optional(), + thickness_mm: z.number().gt(0).max(10).optional(), + cut_amount_percent: z.number().min(1).max(100).optional(), + light_source_preset: z.enum(["bambu_mh001_60mm", "none"]).optional(), + fixture_offset_x_mm: z.number().min(-80).max(80).optional(), + fixture_offset_z_mm: z.number().min(-80).max(80).optional(), + rotate_x_deg: z.number().min(-360).max(360).optional(), + rotate_y_deg: z.number().min(-360).max(360).optional(), + rotate_z_deg: z.number().min(-360).max(360).optional(), + include_result_json: z.boolean().optional(), + }) + .passthrough(); + +const reliefOptionsSchema = z + .object({ + badge_shape: z.enum(["circle", "rounded-rect", "hexagon", "shield", "star"]).optional(), + size_mm: z.number().gt(0).max(400).optional(), + relief_height_mm: z.number().min(0).max(20).optional(), + relief_offset_mm: z.number().min(0).max(20).optional(), + base_thickness_mm: z.number().min(0).max(20).optional(), + has_closed_back: z.boolean().optional(), + relief_curve: z.enum(["linear", "gamma", "s-curve"]).optional(), + curve_param: z.number().gt(0).max(10).optional(), + invert_depth: z.boolean().optional(), + smoothing: z.number().min(0).max(10).optional(), + relief_scale: z.number().gt(0).max(10).optional(), + depth_threshold: z.number().min(0).max(1).optional(), + remove_background: z.boolean().optional(), + export_resolution: z.number().int().min(64).max(2048).optional(), + }) + .passthrough(); + +interface ProductDescriptor { + product: CreativeLabProduct; + label: string; + prototypeMeaning: string; + buildMeaning: string; + /** Extra prototype flags beyond image/name/remove-background. */ + prototypeExtra?: (cmd: Command) => Command; + prototypeExtraPayload?: (opts: Record) => Record; + validatePrototype?: (payload: Record) => void; + /** Build output formats (`output.format`); undefined when the product has none. */ + buildFormats?: readonly string[]; + optionsSchema?: z.ZodTypeAny; + /** Whether the product accepts `options`/`output` at all. */ + hasBuildOptions: boolean; + validateBuild?: (payload: Record) => void; +} + +const PRODUCTS: Record = { + figure: { + product: "figure", + label: "figure", + prototypeMeaning: "styled concept image of the figure", + buildMeaning: "textured figure: GLB, OBJ + MTL and a base-color texture", + hasBuildOptions: false, + }, + lamp: { + product: "lamp", + label: "lamp", + prototypeMeaning: "concept image plus a hollow matte-white lampshade GLB", + buildMeaning: "printable lamp parts: lamp_stl (+ base_stl with a light-source preset) or bundle_zip", + prototypeExtra: (cmd) => + cmd.addOption(new Option("--image-subject ", "what the photo shows (default: character)").choices(["character", "landscape"])), + prototypeExtraPayload: (opts) => ({ image_subject: opts.imageSubject }), + validatePrototype: (payload) => { + if ("text" in payload) { + throw new UsageError("lamp prototype: the `text` input is deprecated and not accepted by this CLI; provide --image-url (a photo) instead"); + } + if (payload.image_subject !== undefined && payload.image_subject !== "character" && payload.image_subject !== "landscape") { + throw new UsageError("image_subject must be 'character' or 'landscape'"); + } + }, + buildFormats: ["stl", "zip"], + optionsSchema: lampOptionsSchema, + hasBuildOptions: true, + validateBuild: (payload) => { + const options = (payload.options ?? {}) as Record; + const output = (payload.output ?? {}) as Record; + if (options.include_result_json === true && output.format !== "zip") { + throw new UsageError("lamp build: include_result_json=true requires --model-format zip (the STL output has no place for result.json)"); + } + }, + }, + keychain: { + product: "keychain", + label: "keychain", + prototypeMeaning: "styled concept image of the keychain", + buildMeaning: "relief keychain: glb, obj (a ZIP bundle with model.obj/model.mtl/texture.png) or bundle_zip", + buildFormats: ["glb", "obj", "zip"], + optionsSchema: reliefOptionsSchema, + hasBuildOptions: true, + }, + "fridge-magnet": { + product: "fridge-magnet", + label: "fridge magnet", + prototypeMeaning: "styled concept image of the magnet", + buildMeaning: "relief fridge magnet: glb, obj (a ZIP bundle with model.obj/model.mtl/texture.png) or bundle_zip", + buildFormats: ["glb", "obj", "zip"], + optionsSchema: reliefOptionsSchema, + hasBuildOptions: true, + }, +}; + +function checkName(payload: Record): void { + if (payload.name === undefined || payload.name === null) return; + if (typeof payload.name !== "string") throw new UsageError("name must be a string"); + if (payload.name.length > NAME_MAX) throw new UsageError(`name must be at most ${NAME_MAX} characters (got ${payload.name.length})`); +} + +function prototypeSpec(p: ProductDescriptor): ResourceCommandSpec { + return { + name: `creative-lab.${p.product}.prototype`, + commandName: "prototype", + defaultSchema: "v1", + description: `Creative Lab ${p.label} — prototype stage: photo → ${p.prototypeMeaning}`, + create: { + description: `Create a ${p.label} prototype from a photo`, + configure(cmd) { + const base = cmd + .option("--image-url ", "photo as http(s) URL, data: URI or local jpg/jpeg/png/webp path (required)") + .option("--name ", `optional name, at most ${NAME_MAX} characters`) + .option("--remove-background", "return the concept image as a transparent RGBA PNG (default: false)"); + return p.prototypeExtra ? p.prototypeExtra(base) : base; + }, + toPayload(opts) { + return { + image_url: opts.imageUrl, + name: opts.name, + remove_background: opts.removeBackground === true ? true : undefined, + ...(p.prototypeExtraPayload ? p.prototypeExtraPayload(opts) : {}), + }; + }, + validatePayload(payload) { + if (payload.image_url === undefined || payload.image_url === null || payload.image_url === "") { + throw new UsageError("provide --image-url (a photo URL, data: URI or local file)"); + } + checkName(payload); + if (payload.remove_background !== undefined && typeof payload.remove_background !== "boolean") { + throw new UsageError("remove_background must be a boolean"); + } + p.validatePrototype?.(payload); + }, + }, + }; +} + +function buildSpec(p: ProductDescriptor): ResourceCommandSpec { + return { + name: `creative-lab.${p.product}.build`, + commandName: "build", + defaultSchema: "v1", + description: `Creative Lab ${p.label} — build stage: SUCCEEDED prototype → ${p.buildMeaning}`, + create: { + description: `Create a ${p.label} build from a prototype task created through this API`, + configure(cmd) { + cmd + .option("--input-task-id ", "SUCCEEDED prototype task of the same product created with the same API key (required)") + .option("--name ", `optional name, at most ${NAME_MAX} characters`); + if (p.hasBuildOptions) { + cmd.option("--options ", "product-specific build options as JSON (or @file.json); merged into payload.options, typed flags win"); + if (p.buildFormats) { + cmd.addOption(new Option("--model-format ", `output.format (default: ${p.buildFormats[0]})`).choices([...p.buildFormats])); + } + if (p.product === "lamp") { + cmd.option("--include-result-json", "include result.json in the bundle (requires --model-format zip)"); + } + } + return cmd; + }, + toPayload(opts) { + const payload: Record = { + input_task_id: opts.inputTaskId, + name: opts.name, + }; + if (p.hasBuildOptions) { + const optionsFlag = parseJsonFlag(opts.options as string | undefined, "--options"); + const options: Record = { ...optionsFlag }; + if (p.product === "lamp" && opts.includeResultJson === true) options.include_result_json = true; + if (Object.keys(options).length > 0) payload.options = options; + if (opts.modelFormat) payload.output = { format: opts.modelFormat }; + } + return payload; + }, + validatePayload(payload) { + if (payload.input_task_id === undefined || payload.input_task_id === null || payload.input_task_id === "") { + throw new UsageError("provide --input-task-id (the SUCCEEDED prototype task)"); + } + if (typeof payload.input_task_id !== "string") throw new UsageError("input_task_id must be a string"); + checkName(payload); + if (!p.hasBuildOptions) { + if (payload.options !== undefined || payload.output !== undefined) { + throw new UsageError(`${p.label} build has no options/output parameters; remove them from --data`); + } + return; + } + if (payload.options !== undefined) { + if (!payload.options || typeof payload.options !== "object" || Array.isArray(payload.options)) { + throw new UsageError("options must be a JSON object"); + } + const parsed = p.optionsSchema!.safeParse(payload.options); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + throw new UsageError(`${p.label} build options invalid: ${issue ? `${issue.path.join(".")}: ${issue.message}` : parsed.error.message}`); + } + } + if (payload.output !== undefined) { + const out = payload.output as Record | null; + if (!out || typeof out !== "object" || Array.isArray(out)) throw new UsageError("output must be a JSON object"); + if (out.format !== undefined && !(p.buildFormats ?? []).includes(String(out.format))) { + throw new UsageError(`${p.label} build output.format must be one of ${(p.buildFormats ?? []).join(" | ")}`); + } + } + p.validateBuild?.(payload); + }, + }, + }; +} + +/** + * Deep-merge `--options`/`--data.options` objects: typed values win, unspecified + * keys survive, explicit false/0 are kept. mergePayload works on top-level + * keys only, so the nested object is merged here before validation. + */ +export function mergeOptionObjects(fromData: unknown, fromFlags: unknown): Record | undefined { + const a = fromData && typeof fromData === "object" && !Array.isArray(fromData) ? (fromData as Record) : undefined; + const b = fromFlags && typeof fromFlags === "object" && !Array.isArray(fromFlags) ? (fromFlags as Record) : undefined; + if (!a && !b) return undefined; + return { ...(a ?? {}), ...(b ?? {}) }; +} + +function productCommand(product: CreativeLabProduct): Command { + const p = PRODUCTS[product]; + const cmd = new Command(product).description(`Creative Lab ${p.label}: prototype (${p.prototypeMeaning}) then build (${p.buildMeaning})`); + cmd.addCommand(buildResourceCommand(prototypeSpec(p))); + cmd.addCommand(buildResourceCommand(buildSpec(p))); + return cmd; +} + +export const creativeLabCommand = new Command("creative-lab") + .description("Creative Lab physical products from a photo: figure | lamp | keychain | fridge-magnet, each with prototype and build stages") + .addHelpText( + "after", + ` +Stages differ per product — prototype is a concept image (lamp also yields a lampshade GLB); +build consumes a SUCCEEDED prototype created through this API with the same key. Web-app +prototypes are rejected by the server (404). Build never re-runs a prototype. + + meshy creative-lab figure prototype create --image-url ./photo.png --name demo --async + meshy creative-lab figure build create --input-task-id --async + meshy creative-lab lamp build create --input-task-id --model-format zip --options '{"diameter_mm":180}' +`, + ); + +for (const product of CREATIVE_LAB_PRODUCTS) creativeLabCommand.addCommand(productCommand(product)); + +export const CREATIVE_LAB_STAGE_NAMES: readonly CreativeLabStage[] = ["prototype", "build"]; diff --git a/src/cmd/make.ts b/src/cmd/make.ts index 747aaba..ee19bf5 100644 --- a/src/cmd/make.ts +++ b/src/cmd/make.ts @@ -14,24 +14,42 @@ * someone else's credits on its own opinion. The resource commands remain the * way to compose anything else. * - * When a later step fails, the error carries the finished step's task id and - * the command that resumes from it, so a retry never re-runs — or re-bills — - * work that already succeeded. + * Three ways to stop early, none of them a re-run: + * --async submit step 1 and return immediately (no polling); + * `pending_steps` lists what was not executed. + * --stop-after-first run step 1 to completion, then return the resume plan. + * a failed later step keeps the finished step's task id and the command that + * resumes from it, so a retry never re-bills done work. */ import { Command, Option } from "commander"; -import { HintedError, UsageError } from "../internal/errors.js"; +import { abortSignal } from "../internal/context.js"; +import { CliError, HintedError, UsageError } from "../internal/errors.js"; import { parseInt10 } from "../internal/flags.js"; -import { resolveImageFields } from "../internal/file-input.js"; +import { normalizeMediaPayload } from "../internal/file-input.js"; import { logger } from "../internal/logger.js"; import { planMake, type MakePlan, type MakeStep } from "../internal/make-plan.js"; -import { emit } from "../internal/output.js"; -import { pollUntilTerminal } from "../internal/poll.js"; +import { emit, emitEnvelope } from "../internal/output.js"; +import { emitResult, openCommand, type OpenedCommand } from "../internal/command-helpers.js"; +import { parseTimeoutSeconds, pollUntilTerminal } from "../internal/poll.js"; import { PRICING_DOCS } from "../internal/pricing.js"; -import { buildRuntime, readGlobalFlags, type Runtime } from "../internal/runtime.js"; -import { emitTerminalOutcome } from "../internal/task-command.js"; +import { okEnvelope, warning, type Warning } from "../internal/result.js"; +import { buildRuntime, type Runtime } from "../internal/runtime.js"; +import { emitTerminalOutcome, buildTaskResult, taskNextCommands } from "../internal/task-command.js"; +import { requireTaskResource } from "../client/resource-registry.js"; import type { Task } from "../client/types.js"; import type { TaskEndpoint } from "../client/endpoints/base.js"; +import { MeshyApiError } from "../client/errors.js"; +import { TransportError } from "../client/transport.js"; +import { + beginOperation, + credentialFingerprint, + newOperationId, + operationsRoot, + payloadFingerprint, + updateOperation, +} from "../internal/operation-store.js"; +import { originOf } from "../internal/config.js"; const DESCRIPTION = "Generate a 3D model in one command. A prompt runs the two-stage text flow " + @@ -42,6 +60,7 @@ interface MakeOptions { dryRun?: boolean; maxCredits?: number; async?: boolean; + stopAfterFirst?: boolean; timeout?: string; } @@ -61,13 +80,23 @@ export const makeCommand = new Command("make") .addOption( new Option( "--async", - "start the first step and return its task id instead of running the whole chain", + "submit the first step and return its task id immediately (no polling); later steps are reported as pending", + ).default(false), + ) + .addOption( + new Option( + "--stop-after-first", + "run the first step to completion, then return the resume plan instead of continuing", ).default(false), ) .addOption(new Option("--timeout ", "max seconds to poll each step").default("600")) .action(async (input: string, opts: MakeOptions, thisCmd: Command) => { + const opened = openCommand(thisCmd, "make", "legacy"); + if (opts.async && opts.stopAfterFirst) { + throw new UsageError("--async and --stop-after-first are mutually exclusive (--async submits and returns; --stop-after-first waits for step 1)"); + } + const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); const plan = planMake(input); - const flags = readGlobalFlags(thisCmd); // Budget check before anything is created: refusing costs nothing, and a // refusal after step one has already billed is not a budget at all. @@ -79,16 +108,40 @@ export const makeCommand = new Command("make") } if (opts.dryRun) { - emit(planPayload(plan), { format: flags.format }); + await emitResult(opened, planPayload(plan), { ...planPayload(plan), dry_run: true, requests_made: 0 }); return; } - await runChain(plan, opts, await buildRuntime(flags)); + await runChain(plan, opts, timeoutSeconds, await buildRuntime(opened.flags), opened); }); -async function runChain(plan: MakePlan, opts: MakeOptions, runtime: Runtime): Promise { - const timeoutSeconds = Number(opts.timeout ?? 600); +interface ExecutedStep { + step: number; + resource: string; + action: string; + task_id: string; + status: string | null; + operation_id: string; +} + +function pendingSteps(plan: MakePlan, from: number, parentTaskId: string | null, output: string | undefined): Array> { + return plan.steps.slice(from).map((s) => ({ + step: s.index, + resource: s.resource, + action: s.action, + estimated_credits: s.credits, + requires: `step ${s.index - 1} SUCCEEDED`, + command: + s.resource === "text-to-3d" && s.action === "refine" + ? `meshy text-to-3d create --mode refine --preview-task-id ${parentTaskId ?? ""}${output ? ` -o ${output}` : ""}` + : null, + })); +} + +async function runChain(plan: MakePlan, opts: MakeOptions, timeoutSeconds: number, runtime: Runtime, opened: OpenedCommand): Promise { const payloadFor = await buildPayloads(plan); + const executed: ExecutedStep[] = []; + const warnings: Warning[] = []; /** Task id of the last step that reached SUCCEEDED — what a resume hangs off. */ let completedTaskId = ""; @@ -101,29 +154,92 @@ async function runChain(plan: MakePlan, opts: MakeOptions, runtime: Runtime): Pr // The id is announced before polling starts: an interrupted run leaves a // task progressing server-side, and the caller needs its id to exist // somewhere other than this process's memory. - const taskId = await endpoint.create(payload); + const { taskId, operationId } = await submitStep(runtime, step, endpoint, payload); announceStart(plan, step, taskId); + executed.push({ step: step.index, resource: step.resource, action: step.action, task_id: taskId, status: null, operation_id: operationId }); + + if (opts.async) { + // Exactly one POST, zero polls: the caller owns the rest of the plan. + const pending = pendingSteps(plan, step.index, null, runtime.flags.output); + if (opened.schema === "v1") { + await emitEnvelope( + okEnvelope("make", { + route: plan.route, + submitted: { step: step.index, resource: step.resource, action: step.action, task_id: taskId, operation_id: operationId }, + submission: { state: "accepted", operation_id: operationId, task_id: taskId }, + task: null, + executed, + pending_steps: pending, + estimated_credits: plan.estimatedCredits, + next: taskNextCommands(requireTaskResource(step.resource), taskId), + }), + opened.format, + ); + return; + } + emit( + { + command: "make", + route: plan.route, + submitted: step.action, + task_id: taskId, + status: null, + operation_id: operationId, + pending_steps: pending, + hint: `meshy ${step.resource} wait ${taskId}`, + }, + { format: runtime.flags.format }, + ); + return; + } - const started = Date.now(); - const { task, timedOut } = await pollUntilTerminal(endpoint, taskId, { + const started = performance.now(); + const poll = await pollUntilTerminal(endpoint, taskId, { timeoutSeconds, intervalMs: runtime.config.pollIntervalMs, + signal: abortSignal(), }); - const elapsed = (Date.now() - started) / 1000; + const { task, raw, timedOut, aborted } = poll; + const elapsed = (performance.now() - started) / 1000; + executed[executed.length - 1]!.status = task.status; announceOutcome(task, timedOut, elapsed); + if (aborted) { + throw new CliError({ + code: "interrupted", + message: `make: interrupted while waiting for ${step.label} (task ${taskId}); the server keeps running it`, + recovery: { action: "wait", automatic: false, command: `meshy ${step.resource} wait ${taskId}` }, + result: { route: plan.route, executed, task: buildTaskResult({ task, raw, descriptor: requireTaskResource(step.resource), includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: taskId } }).task, pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output) }, + }); + } + if (timedOut || task.status !== "SUCCEEDED") { - throw stepFailure(plan, step, task, timedOut, completedTaskId, runtime); + throw stepFailure(plan, step, task, raw, timedOut, completedTaskId, runtime, executed, opened); } if (step.index === plan.steps.length) { - await emitTerminalOutcome(task, false, elapsed, step.resource, runtime); + await finalOutcome(opened, runtime, plan, step, task, raw, elapsed, executed, warnings); return; } completedTaskId = task.id; - if (opts.async) { + if (opts.stopAfterFirst) { + const pending = pendingSteps(plan, step.index, task.id, runtime.flags.output); + if (opened.schema === "v1") { + await emitEnvelope( + okEnvelope("make", { + route: plan.route, + stopped_after: { step: step.index, resource: step.resource, action: step.action, task_id: task.id, status: task.status }, + task: buildTaskResult({ task, raw, descriptor: requireTaskResource(step.resource), includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: task.id } }).task, + executed, + pending_steps: pending, + resume: resumeCommand(plan, task.id, runtime) ?? null, + }, warnings), + opened.format, + ); + return; + } emit( { command: "make", @@ -140,6 +256,77 @@ async function runChain(plan: MakePlan, opts: MakeOptions, runtime: Runtime): Pr } } +async function finalOutcome( + opened: OpenedCommand, + runtime: Runtime, + plan: MakePlan, + step: MakeStep, + task: Task, + raw: unknown, + elapsed: number, + executed: ExecutedStep[], + warnings: Warning[], +): Promise { + if (opened.schema !== "v1") { + await emitTerminalOutcome(task, false, elapsed, step.resource, runtime); + return; + } + const descriptor = requireTaskResource(step.resource); + const base = buildTaskResult({ task, raw, descriptor, includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: task.id } }); + let downloads = base.downloads as Record; + if (runtime.flags.output) { + const { downloadArtifacts } = await import("../internal/download.js"); + try { + const { savedFiles, metadataPath } = await downloadArtifacts(task, runtime.flags.output, step.resource); + downloads = { state: "completed", files: savedFiles.map((p) => ({ path: p, status: "written" })), metadata_path: metadataPath }; + } catch (err) { + throw new CliError({ + code: "local_io", + message: `make finished (task ${task.id}) but downloading its assets failed: ${err instanceof Error ? err.message : String(err)}`, + result: { route: plan.route, executed, task: base.task, downloads: { state: "failed", files: [], metadata_path: null } }, + cause: err, + }); + } + } + await emitEnvelope(okEnvelope("make", { route: plan.route, executed, task: base.task, submission: base.submission, downloads, pending_steps: [] }, warnings), opened.format); +} + +/** Journal + single POST for one chain step; shares the journal contract with the resource commands. */ +async function submitStep(runtime: Runtime, step: MakeStep, endpoint: TaskEndpoint, payload: Record): Promise<{ taskId: string; operationId: string }> { + const descriptor = requireTaskResource(step.resource); + const root = operationsRoot(); + const operationId = newOperationId(); + const apiOrigin = originOf(endpoint.transportBaseUrl) ?? endpoint.transportBaseUrl; + beginOperation(root, operationId, { + resource: descriptor.id, + endpoint: descriptor.legacyEndpoint, + apiOrigin, + credentialFingerprint: credentialFingerprint({ source: runtime.config.credentialSource, profile: runtime.config.credentialProfile ?? null, origin: apiOrigin, kind: runtime.config.credentialKind }), + payloadFingerprint: payloadFingerprint(payload), + }); + try { + const created = await endpoint.createDetailed(payload, { signal: abortSignal() }); + updateOperation(root, operationId, { state: "accepted", task_id: created.taskId, request_id: created.requestId, http_status: 200 }); + return { taskId: created.taskId, operationId }; + } catch (err) { + const definite = err instanceof TransportError ? err.neverSent : err instanceof MeshyApiError && err.status >= 400 && err.status < 500; + const state = definite ? (err instanceof TransportError ? "not_submitted" : "rejected") : "unknown"; + try { + updateOperation(root, operationId, { state, error: err instanceof Error ? err.message : String(err), http_status: err instanceof MeshyApiError && err.status ? err.status : null }); + } catch { + /* journal failure is reported through the primary error */ + } + if (definite) throw err; + throw new CliError({ + code: "submission_unknown", + message: `make: the ${step.label} request was sent but its outcome is unknown (${err instanceof Error ? err.message : String(err)}); the server may have created a task`, + recovery: { action: "reconcile", automatic: false, command: `meshy ${step.resource} list --output-schema v1 # match operation ${operationId} before creating again` }, + result: { submission: { state: "unknown", operation_id: operationId, task_id: null }, task: null, step: step.index }, + cause: err, + }); + } +} + /** * One payload builder per step, taking the previous step's task id so the * chain stays a function of the plan plus what actually ran. @@ -166,12 +353,11 @@ async function buildPayloads( // Resolve the image before anything is created: a missing file or an // unreachable URL must fail while the run is still free. - const resolved: Record = { imageUrl: plan.input }; - await resolveImageFields(resolved); + const { payload } = await normalizeMediaPayload({ image_url: plan.input }, requireTaskResource("image-to-3d").mediaFields, { signal: abortSignal() }); return [ () => ({ - image_url: resolved.imageUrl, + image_url: payload.image_url, should_texture: true, enable_pbr: true, texture_resolution: "4k", @@ -204,10 +390,32 @@ function stepFailure( plan: MakePlan, step: MakeStep, task: Task, + raw: unknown, timedOut: boolean, completedTaskId: string, runtime: Runtime, -): HintedError { + executed: ExecutedStep[], + opened: OpenedCommand, +): Error { + const descriptor = requireTaskResource(step.resource); + const taskView = buildTaskResult({ task, raw, descriptor, includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: task.id } }).task; + const resume = resumeCommand(plan, completedTaskId, runtime); + if (opened.schema === "v1") { + if (timedOut) { + return new CliError({ + code: "timed_out", + message: `make: ${step.label} did not finish within the timeout — task ${task.id} is still running`, + recovery: { action: "wait", automatic: false, command: `meshy ${step.resource} wait ${task.id}` }, + result: { route: plan.route, executed, task: taskView, pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output), resume: resume ?? null }, + }); + } + return new CliError({ + code: "task_failed", + message: task.task_error?.message || `make: ${step.label} ended as ${task.status} — task ${task.id}`, + recovery: resume ? { action: "resume", automatic: false, command: resume } : null, + result: { route: plan.route, executed, task: taskView, pending_steps: pendingSteps(plan, step.index, completedTaskId || null, runtime.flags.output), resume: resume ?? null }, + }); + } if (timedOut) { return new HintedError({ message: `make: ${step.label} did not finish within the timeout — task ${task.id} is still running`, @@ -219,7 +427,6 @@ function stepFailure( // A resume is only offered when an earlier step actually succeeded; without // one, a suggested command would be a guess, and a wrong command is worse // than none. - const resume = resumeCommand(plan, completedTaskId, runtime); return new HintedError({ message: task.task_error?.message || @@ -257,3 +464,5 @@ function announceOutcome(task: Task, timedOut: boolean, elapsed: number): void { const mark = timedOut ? "timed out" : task.status === "SUCCEEDED" ? "ok" : task.status; process.stderr.write(` ${mark} in ${elapsed.toFixed(0)}s\n`); } + +export { warning as _makeWarning }; diff --git a/src/cmd/resources.ts b/src/cmd/resources.ts index 19aca4a..09659f8 100644 --- a/src/cmd/resources.ts +++ b/src/cmd/resources.ts @@ -42,6 +42,8 @@ export const RESOURCES: ResourceEntry[] = [ { name: "balance", summary: "remaining credit balance" }, { name: "delete", summary: "delete any task, whatever its resource" }, // Added in S1 (appended so 0.2.0 consumers keep their positions). + { name: "uv-unwrap", summary: "generate fresh UVs for a GLB (≤40k faces) — a UV white model for external texturing" }, + { name: "creative-lab", summary: "photo → printable product: figure | lamp | keychain | fridge-magnet (prototype then build)" }, { name: "animation-catalog", summary: "public animation library (action ids); no key needed" }, { name: "showcases", summary: "Enterprise community showcases (every request is billed)" }, ]; diff --git a/src/cmd/uv-unwrap.ts b/src/cmd/uv-unwrap.ts new file mode 100644 index 0000000..e180935 --- /dev/null +++ b/src/cmd/uv-unwrap.ts @@ -0,0 +1,54 @@ +/** + * uv-unwrap — https://docs.meshy.ai/en/api/uv-unwrap + * + * Generates fresh UVs for a GLB (≤ 40,000 faces) and returns a "UV white + * model" (grey placeholder material, no textures) for external texturing. + * + * Exactly one source is accepted — `--input-task-id` or `--model-url` — also + * when supplied through --data. The server would prefer input_task_id if both + * were sent; refusing the ambiguity here is cheaper than a surprise. Input + * files must be GLB. A face count above 40k is the server's call (400); the + * CLI never downloads a model just to pre-count faces — `meshy inspect faces` + * answers that from a saved task when the field is available. + */ + +import { UsageError } from "../internal/errors.js"; +import { buildResourceCommand, type ResourceCommandSpec } from "../internal/task-command.js"; + +export const UV_UNWRAP_FACE_CEILING = 40_000; + +const spec: ResourceCommandSpec = { + name: "uv-unwrap", + defaultSchema: "v1", + description: + "Generate fresh UVs for a GLB model (input task or GLB file, at most 40k faces) — outputs a UV " + + "white model for external texturing. Remesh first if the mesh is denser", + create: { + description: "Create a uv-unwrap task", + configure(cmd) { + return cmd + .option("--input-task-id ", "SUCCEEDED source task with a GLB output (mutually exclusive with --model-url)") + .option("--model-url ", "GLB model as http(s) URL, data: URI or local .glb path (mutually exclusive with --input-task-id)"); + }, + toPayload(opts) { + return { + input_task_id: opts.inputTaskId, + model_url: opts.modelUrl, + }; + }, + validatePayload(payload) { + const hasTask = payload.input_task_id !== undefined && payload.input_task_id !== null && payload.input_task_id !== ""; + const hasModel = payload.model_url !== undefined && payload.model_url !== null && payload.model_url !== ""; + if (hasTask && hasModel) { + throw new UsageError("uv-unwrap takes exactly one source: --input-task-id or --model-url (also inside --data), not both"); + } + if (!hasTask && !hasModel) { + throw new UsageError("provide --input-task-id or --model-url"); + } + if (hasTask && typeof payload.input_task_id !== "string") throw new UsageError("input_task_id must be a string"); + if (hasModel && typeof payload.model_url !== "string") throw new UsageError("model_url must be a string"); + }, + }, +}; + +export const uvUnwrapCommand = buildResourceCommand(spec); diff --git a/src/internal/lock.ts b/src/internal/lock.ts new file mode 100644 index 0000000..2053051 --- /dev/null +++ b/src/internal/lock.ts @@ -0,0 +1,71 @@ +/** + * Cross-process exclusive lock on a directory-scoped lock file. + * + * O_EXCL creation is the atomic primitive (Node exposes no flock). A lock + * older than `staleMs` is treated as abandoned by a crashed process and + * broken. Waiting is a blocking sleep because every store that uses this is + * synchronous by design; the wait is bounded and a timeout is a structured + * error rather than a hang. + */ + +import { closeSync, mkdirSync, openSync, statSync, unlinkSync } from "node:fs"; +import { dirname } from "node:path"; +import { CliError } from "./errors.js"; + +export interface LockOptions { + timeoutMs?: number; + staleMs?: number; + /** Test hook to observe contention. */ + onWait?: () => void; +} + +export const DEFAULT_LOCK_TIMEOUT_MS = 10_000; +export const DEFAULT_LOCK_STALE_MS = 60_000; + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +export function withFileLock(lockPath: string, fn: () => T, opts: LockOptions = {}): T { + const timeoutMs = opts.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS; + const staleMs = opts.staleMs ?? DEFAULT_LOCK_STALE_MS; + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + const deadline = Date.now() + timeoutMs; + let fd: number; + for (;;) { + try { + fd = openSync(lockPath, "wx", 0o600); + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + try { + if (Date.now() - statSync(lockPath).mtimeMs > staleMs) { + unlinkSync(lockPath); + continue; + } + } catch { + // Holder released between open and stat — retry immediately. + continue; + } + if (Date.now() >= deadline) { + throw new CliError({ + code: "local_io", + message: `timed out after ${timeoutMs}ms waiting for the lock ${lockPath}. If no other meshy process is running, delete that file.`, + recovery: { action: "retry", automatic: false }, + }); + } + opts.onWait?.(); + sleepSync(25); + } + } + try { + return fn(); + } finally { + closeSync(fd); + try { + unlinkSync(lockPath); + } catch { + /* already gone */ + } + } +} diff --git a/src/internal/operation-store.ts b/src/internal/operation-store.ts new file mode 100644 index 0000000..88fd64b --- /dev/null +++ b/src/internal/operation-store.ts @@ -0,0 +1,207 @@ +/** + * Operation journal — a local, best-effort record of every billable create. + * + * It exists so a lost response can be reconciled instead of blindly re-sent: + * the record is written *before* the POST (state `started`), then updated to + * `accepted` (task id known), `rejected` (server said no), `not_submitted` + * (transport proves the request never left) or `unknown` (anything else after + * the request was sent). A repeated `--operation-id` returns the stored record + * instead of submitting again when the request fingerprints match, and refuses + * with `operation_conflict` when they do not. + * + * This is a local record only. It is not a server-side idempotency key and it + * cannot guarantee the server did not bill a request whose response was lost. + * Nothing secret is stored: no key material, no base64 media, no signed URLs. + */ + +import { createHash, randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { writeJsonFile } from "./atomic-file.js"; +import { configDir } from "./credentials.js"; +import { CliError } from "./errors.js"; +import { withFileLock } from "./lock.js"; +import { safeSegment } from "./paths.js"; +import { VERSION } from "./version.js"; + +export type OperationState = "started" | "accepted" | "rejected" | "unknown" | "not_submitted"; + +export interface OperationRecord { + schema_version: 1; + operation_id: string; + state: OperationState; + resource: string; + endpoint: string; + api_origin: string; + credential_fingerprint: string; + payload_fingerprint: string; + started_at: string; + updated_at: string; + task_id: string | null; + request_id: string | null; + http_status: number | null; + error: string | null; + pid: number; + cli_version: string; + /** Optional link to the project that owns the task (set by --project). */ + project: string | null; +} + +export interface OperationIdentity { + resource: string; + endpoint: string; + apiOrigin: string; + credentialFingerprint: string; + payloadFingerprint: string; + project?: string | null; +} + +export function operationsRoot(env: NodeJS.ProcessEnv = process.env): string { + return join(configDir(env), "operations"); +} + +export function newOperationId(): string { + return randomUUID(); +} + +/** `sha256(source|profile|origin)` — enough to detect a different identity, never reversible to a key. */ +export function credentialFingerprint(parts: { source: string; profile?: string | null; origin: string; kind?: string }): string { + return sha256(`${parts.source}|${parts.profile ?? ""}|${parts.kind ?? ""}|${parts.origin}`); +} + +/** + * Canonical JSON with data URIs reduced to their MIME and length so two + * submissions of the same local file match while the journal never holds the + * file content. + */ +export function payloadFingerprint(payload: unknown): string { + return sha256(canonical(payload)); +} + +function canonical(v: unknown): string { + if (v === null || typeof v !== "object") { + if (typeof v === "string" && v.startsWith("data:")) { + const semi = v.indexOf(";"); + const comma = v.indexOf(","); + const mime = v.slice(5, semi === -1 ? comma : Math.min(semi, comma === -1 ? v.length : comma)); + return JSON.stringify(`data:${mime};len=${v.length}`); + } + return JSON.stringify(v); + } + if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`; + const keys = Object.keys(v as Record).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical((v as Record)[k])}`).join(",")}}`; +} + +function sha256(s: string): string { + return createHash("sha256").update(s).digest("hex"); +} + +function recordPath(root: string, id: string): string { + return join(root, `${safeSegment(id, "op")}.json`); +} + +function lockPath(root: string): string { + return join(root, "locks", "operations.lock"); +} + +export function readOperation(root: string, id: string): OperationRecord | null { + try { + const raw = JSON.parse(readFileSync(recordPath(root, id), "utf8")) as OperationRecord; + return raw && raw.schema_version === 1 ? raw : null; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new CliError({ code: "local_io", message: `operation record ${recordPath(root, id)} is unreadable: ${(err as Error).message}` }); + } +} + +export interface BeginResult { + /** `created`: this process owns the submission. `existing`: an earlier run already has a record. */ + outcome: "created" | "existing"; + record: OperationRecord; +} + +/** + * Under the journal lock: return the existing record when one exists for + * `operationId` with the same identity, throw `operation_conflict` when the + * identity differs, or write a fresh `started` record and return it. + */ +export function beginOperation(root: string, operationId: string, identity: OperationIdentity, now: () => Date = () => new Date()): BeginResult { + mkdirSync(root, { recursive: true, mode: 0o700 }); + return withFileLock(lockPath(root), () => { + const existing = readOperation(root, operationId); + if (existing) { + if ( + existing.resource !== identity.resource || + existing.api_origin !== identity.apiOrigin || + existing.credential_fingerprint !== identity.credentialFingerprint || + existing.payload_fingerprint !== identity.payloadFingerprint + ) { + throw new CliError({ + code: "operation_conflict", + message: `operation ${operationId} already exists for a different request (resource/origin/credential/payload differ); nothing was submitted`, + result: { submission: { state: existing.state, operation_id: operationId, task_id: existing.task_id } }, + }); + } + return { outcome: "existing", record: existing }; + } + const ts = now().toISOString(); + const record: OperationRecord = { + schema_version: 1, + operation_id: operationId, + state: "started", + resource: identity.resource, + endpoint: identity.endpoint, + api_origin: identity.apiOrigin, + credential_fingerprint: identity.credentialFingerprint, + payload_fingerprint: identity.payloadFingerprint, + started_at: ts, + updated_at: ts, + task_id: null, + request_id: null, + http_status: null, + error: null, + pid: process.pid, + cli_version: VERSION, + project: identity.project ?? null, + }; + writeJsonFile(recordPath(root, operationId), record, { overwrite: false, mode: 0o600 }); + return { outcome: "created", record }; + }); +} + +export function updateOperation( + root: string, + operationId: string, + patch: Partial>, + now: () => Date = () => new Date(), +): OperationRecord { + return withFileLock(lockPath(root), () => { + const existing = readOperation(root, operationId); + if (!existing) { + throw new CliError({ code: "local_io", message: `operation record ${operationId} disappeared before it could be updated` }); + } + const next: OperationRecord = { ...existing, ...patch, updated_at: now().toISOString() }; + writeJsonFile(recordPath(root, operationId), next, { overwrite: true, mode: 0o600 }); + return next; + }); +} + +export function listOperations(root: string): OperationRecord[] { + let names: string[]; + try { + names = readdirSync(root).filter((n) => n.endsWith(".json")); + } catch { + return []; + } + const out: OperationRecord[] = []; + for (const n of names) { + try { + const rec = JSON.parse(readFileSync(join(root, n), "utf8")) as OperationRecord; + if (rec && rec.schema_version === 1) out.push(rec); + } catch { + /* skip unreadable */ + } + } + return out.sort((a, b) => a.started_at.localeCompare(b.started_at)); +} diff --git a/src/internal/payload.ts b/src/internal/payload.ts index 6ea0adb..3044789 100644 --- a/src/internal/payload.ts +++ b/src/internal/payload.ts @@ -5,20 +5,30 @@ */ import { readFileSync } from "node:fs"; +import { UsageError } from "./errors.js"; export function parseJsonFlag(raw: string | undefined, flag: string): Record { if (!raw) return {}; const trimmed = raw.trim(); - const text = trimmed.startsWith("@") ? readFileSync(trimmed.slice(1), "utf8") : trimmed; + let text: string; + if (trimmed.startsWith("@")) { + try { + text = readFileSync(trimmed.slice(1), "utf8"); + } catch (err) { + throw new UsageError(`${flag}: cannot read ${trimmed.slice(1)}: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + text = trimmed; + } let parsed: unknown; try { parsed = JSON.parse(text); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - throw new Error(`invalid JSON passed to ${flag}: ${msg}`); + throw new UsageError(`invalid JSON passed to ${flag}: ${msg}`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error(`${flag} must be a JSON object (got ${Array.isArray(parsed) ? "array" : typeof parsed})`); + throw new UsageError(`${flag} must be a JSON object (got ${Array.isArray(parsed) ? "array" : typeof parsed})`); } return parsed as Record; } diff --git a/src/internal/poll.ts b/src/internal/poll.ts index 50fc4e3..c7b6930 100644 --- a/src/internal/poll.ts +++ b/src/internal/poll.ts @@ -1,20 +1,58 @@ /** - * Poll a task endpoint until it reaches a terminal status or the deadline hits. - * Backoff grows 1.5× per iteration up to a 20s cap. + * Poll a task endpoint until it reaches a terminal status or the deadline + * hits. Backoff grows 1.5× per iteration up to a 20s cap. + * + * The deadline is monotonic, the sleep is cancellable, and one slow response + * consumes the same budget as any other second — a `--timeout 0` performs + * exactly one query and returns whatever status it saw. */ import type { TaskEndpoint } from "../client/endpoints/base.js"; -import { TERMINAL_STATUSES, type Task } from "../client/types.js"; +import { isTerminalStatus, type Task } from "../client/types.js"; +import { UsageError } from "./errors.js"; export interface PollOptions { timeoutSeconds: number; intervalMs: number; - onTick?: (task: Task) => void; + onTick?: (task: Task, raw: unknown) => void; + signal?: AbortSignal; + /** Test hook: injectable clock and sleep. */ + now?: () => number; + sleep?: (ms: number, signal?: AbortSignal) => Promise; } export interface PollResult { task: Task; + raw: unknown; timedOut: boolean; + polls: number; + /** True when the external signal fired before a terminal status. */ + aborted: boolean; +} + +/** Accept only finite, non-negative seconds. NaN/Infinity/negative are usage errors, never "wait forever". */ +export function parseTimeoutSeconds(raw: unknown, flag = "--timeout"): number { + const text = String(raw ?? "").trim(); + const n = typeof raw === "number" ? raw : Number(text); + if (text === "" || !Number.isFinite(n) || n < 0) { + throw new UsageError(`${flag} must be a finite number of seconds >= 0 (got '${String(raw)}')`); + } + return n; +} + +export function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) return resolve(); + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + function onAbort(): void { + clearTimeout(timer); + resolve(); + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); } export async function pollUntilTerminal( @@ -22,24 +60,26 @@ export async function pollUntilTerminal( taskId: string, opts: PollOptions, ): Promise { - const deadline = Date.now() + Math.max(0, opts.timeoutSeconds) * 1000; + const now = opts.now ?? (() => performance.now()); + const sleep = opts.sleep ?? sleepWithSignal; + const deadline = now() + Math.max(0, opts.timeoutSeconds) * 1000; const baseInterval = Math.max(250, opts.intervalMs); let interval = baseInterval; const cap = Math.max(baseInterval, 20_000); + let polls = 0; - while (true) { - const task = await endpoint.retrieve(taskId); - opts.onTick?.(task); - if (TERMINAL_STATUSES.has(task.status)) { - return { task, timedOut: false }; + for (;;) { + const { task, raw } = await endpoint.retrieveDetailed(taskId, { signal: opts.signal }); + polls += 1; + opts.onTick?.(task, raw); + if (isTerminalStatus(task.status)) { + return { task, raw, timedOut: false, polls, aborted: false }; } - const remaining = deadline - Date.now(); - if (remaining <= 0) return { task, timedOut: true }; - await sleep(Math.min(interval, Math.max(remaining, 100))); + if (opts.signal?.aborted) return { task, raw, timedOut: false, polls, aborted: true }; + const remaining = deadline - now(); + if (remaining <= 0) return { task, raw, timedOut: true, polls, aborted: false }; + await sleep(Math.min(interval, Math.max(remaining, 100)), opts.signal); + if (opts.signal?.aborted) return { task, raw, timedOut: false, polls, aborted: true }; interval = Math.min(cap, Math.floor(interval * 1.5)); } } - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/internal/stream.ts b/src/internal/stream.ts new file mode 100644 index 0000000..fbb35c6 --- /dev/null +++ b/src/internal/stream.ts @@ -0,0 +1,298 @@ +/** + * Server-Sent Events for task status. + * + * The parser follows the WHATWG EventSource algorithm: UTF-8 is decoded + * across chunk boundaries, lines end at CR, LF or CRLF, an empty line + * dispatches the event, multi-line `data:` fields are joined with "\n", + * comment lines (leading ':') are ignored, `id:` and `retry:` are recorded + * but never used to fabricate a Last-Event-ID we did not receive. + * + * Meshy's stream emits `event: message` with the full task JSON (also as the + * ~10 s keep-alive) and `event: error` with `{message, status_code}` — which + * can arrive after an HTTP 200, so it is mapped exactly like an HTTP failure. + */ + +import { codeForStatus, MeshyApiError } from "../client/errors.js"; +import type { TaskEndpoint } from "../client/endpoints/base.js"; +import { TaskSchema, isTerminalStatus, type Task } from "../client/types.js"; +import { CliError } from "./errors.js"; + +export const DEFAULT_MAX_SSE_EVENT_BYTES = 1024 * 1024; + +export interface SseEvent { + event: string; + data: string; + id: string | null; + retry: number | null; +} + +export interface SseParser { + /** Feed raw bytes; returns every event completed by this chunk. */ + feed(chunk: Uint8Array): SseEvent[]; + /** Flush at end of stream (a final event without a trailing blank line is dispatched). */ + end(): SseEvent[]; +} + +export function createSseParser(opts: { maxEventBytes?: number } = {}): SseParser { + const maxEventBytes = opts.maxEventBytes ?? DEFAULT_MAX_SSE_EVENT_BYTES; + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + let eventType = ""; + let dataLines: string[] = []; + let lastId: string | null = null; + let retry: number | null = null; + let pendingBytes = 0; + let first = true; + + function dispatch(out: SseEvent[]): void { + if (dataLines.length === 0) { + eventType = ""; + pendingBytes = 0; + return; + } + out.push({ event: eventType || "message", data: dataLines.join("\n"), id: lastId, retry }); + eventType = ""; + dataLines = []; + pendingBytes = 0; + } + + function processLine(line: string, out: SseEvent[]): void { + if (line === "") { + dispatch(out); + return; + } + if (line.startsWith(":")) return; // comment / heartbeat + let field: string; + let value: string; + const colon = line.indexOf(":"); + if (colon === -1) { + field = line; + value = ""; + } else { + field = line.slice(0, colon); + value = line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + } + switch (field) { + case "event": + eventType = value; + break; + case "data": + pendingBytes += Buffer.byteLength(value, "utf8") + 1; + if (pendingBytes > maxEventBytes) { + throw new CliError({ code: "protocol", message: `SSE event exceeds ${maxEventBytes} bytes` }); + } + dataLines.push(value); + break; + case "id": + if (!value.includes(" ")) lastId = value; + break; + case "retry": { + const n = Number(value); + if (Number.isInteger(n) && n >= 0) retry = n; + break; + } + default: + break; // unknown fields are ignored per spec + } + } + + function consume(text: string, out: SseEvent[], final: boolean): void { + buffer += text; + if (first && buffer.startsWith("\uFEFF")) buffer = buffer.slice(1); + first = false; + let start = 0; + for (;;) { + const cr = buffer.indexOf("\r", start); + const lf = buffer.indexOf("\n", start); + let end: number; + let next: number; + if (cr === -1 && lf === -1) break; + if (cr !== -1 && (lf === -1 || cr < lf)) { + // CR, possibly followed by LF. A trailing CR at the very end of the + // buffer may be half of a CRLF — wait for more bytes unless final. + if (cr === buffer.length - 1 && !final) break; + end = cr; + next = buffer[cr + 1] === "\n" ? cr + 2 : cr + 1; + } else { + end = lf; + next = lf + 1; + } + processLine(buffer.slice(start, end), out); + start = next; + } + buffer = buffer.slice(start); + if (buffer.length > maxEventBytes) { + throw new CliError({ code: "protocol", message: `SSE line exceeds ${maxEventBytes} bytes without a line ending` }); + } + } + + return { + feed(chunk) { + const out: SseEvent[] = []; + consume(decoder.decode(chunk, { stream: true }), out, false); + return out; + }, + end() { + const out: SseEvent[] = []; + consume(decoder.decode(), out, true); + if (buffer.length > 0) { + processLine(buffer, out); + buffer = ""; + } + dispatch(out); + return out; + }, + }; +} + +export type StreamEndReason = "terminal" | "timeout" | "idle_timeout" | "disconnected" | "error" | "interrupted" | "protocol"; + +export interface StreamOutcome { + reason: StreamEndReason; + task: Task | null; + raw: unknown; + events: number; + /** Set when the stream carried an API error event or a protocol failure. */ + error: MeshyApiError | CliError | null; + elapsedMs: number; +} + +export interface StreamTaskOptions { + timeoutMs: number; + idleTimeoutMs: number; + signal?: AbortSignal; + onTask?: (task: Task, raw: unknown) => void | Promise; + onUnknownEvent?: (event: SseEvent) => void; + maxEventBytes?: number; +} + +/** + * Open the task stream and consume it until a terminal task, an API error + * event, a deadline, a disconnect or an abort. The connection is always closed + * before this resolves. + */ +export async function streamTask(endpoint: TaskEndpoint, taskId: string, opts: StreamTaskOptions): Promise { + const started = performance.now(); + const total = new AbortController(); + const idle = new AbortController(); + const totalTimer = setTimeout(() => total.abort(new Error("total timeout")), opts.timeoutMs); + let idleTimer: NodeJS.Timeout | undefined; + const resetIdle = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => idle.abort(new Error("idle timeout")), opts.idleTimeoutMs); + }; + const signals: AbortSignal[] = [total.signal, idle.signal]; + if (opts.signal) signals.push(opts.signal); + const signal = AbortSignal.any(signals); + + const state: { task: Task | null; raw: unknown; events: number } = { task: null, raw: null, events: 0 }; + const finish = (reason: StreamEndReason, error: MeshyApiError | CliError | null = null): StreamOutcome => ({ + reason, + task: state.task, + raw: state.raw, + events: state.events, + error, + elapsedMs: performance.now() - started, + }); + const classifyAbort = (): StreamEndReason => { + if (opts.signal?.aborted) return "interrupted"; + if (total.signal.aborted) return "timeout"; + if (idle.signal.aborted) return "idle_timeout"; + return "disconnected"; + }; + + let handle: Awaited> | null = null; + try { + resetIdle(); + try { + handle = await endpoint.openStream(taskId, { signal, connectTimeoutMs: Math.min(opts.timeoutMs, opts.idleTimeoutMs) }); + } catch (err) { + if (signal.aborted) return finish(classifyAbort()); + throw err; + } + const contentType = handle.response.headers.get("content-type") ?? ""; + if (!/text\/event-stream/i.test(contentType)) { + return finish("protocol", new CliError({ code: "protocol", message: `stream endpoint answered with content-type '${contentType || "(none)"}' instead of text/event-stream` })); + } + const body = handle.response.body; + if (!body) return finish("protocol", new CliError({ code: "protocol", message: "stream response has no body" })); + const reader = body.getReader(); + const parser = createSseParser({ maxEventBytes: opts.maxEventBytes }); + + const handleEvent = async (ev: SseEvent): Promise => { + if (ev.event === "error") { + let payload: { message?: unknown; status_code?: unknown } = {}; + try { + payload = JSON.parse(ev.data) as typeof payload; + } catch { + return finish("protocol", new CliError({ code: "protocol", message: `stream error event is not JSON: ${ev.data.slice(0, 200)}` })); + } + const status = typeof payload.status_code === "number" ? payload.status_code : 0; + const message = typeof payload.message === "string" ? payload.message : "stream error"; + const apiErr = new MeshyApiError({ + message: `meshy stream ${status || "error"} on ${endpoint.streamPath(taskId)}: ${message}`, + status, + code: status ? codeForStatus(status) : "server", + path: endpoint.streamPath(taskId), + body: payload, + }); + return finish("error", apiErr); + } + if (ev.event !== "message") { + opts.onUnknownEvent?.(ev); + return null; + } + let json: unknown; + try { + json = JSON.parse(ev.data); + } catch { + return finish("protocol", new CliError({ code: "protocol", message: `stream message is not JSON: ${ev.data.slice(0, 200)}` })); + } + const parsed = TaskSchema.safeParse(json); + if (!parsed.success) { + return finish("protocol", new CliError({ code: "protocol", message: `stream message is not a task: ${parsed.error.message}` })); + } + state.task = parsed.data; + state.raw = json; + state.events += 1; + await opts.onTask?.(parsed.data, json); + if (isTerminalStatus(parsed.data.status)) return finish("terminal"); + return null; + }; + + for (;;) { + let chunk: Awaited>; + try { + chunk = await reader.read(); + } catch (err) { + if (signal.aborted) return finish(classifyAbort()); + return finish("disconnected", new CliError({ code: "network", message: `stream disconnected: ${err instanceof Error ? err.message : String(err)}` })); + } + if (signal.aborted) return finish(classifyAbort()); + if (chunk.done) { + for (const ev of parser.end()) { + const out = await handleEvent(ev); + if (out) return out; + } + if (state.task === null) return finish("protocol", new CliError({ code: "protocol", message: "stream ended without any task event" })); + return finish("disconnected", new CliError({ code: "network", message: `stream ended before the task reached a terminal status (last status: ${state.task.status})` })); + } + resetIdle(); // any bytes — keep-alives, comments, partial lines — count as liveness + let parsedEvents: SseEvent[]; + try { + parsedEvents = parser.feed(chunk.value); + } catch (err) { + return finish("protocol", err instanceof CliError ? err : new CliError({ code: "protocol", message: String(err) })); + } + for (const ev of parsedEvents) { + const out = await handleEvent(ev); + if (out) return out; + } + } + } finally { + clearTimeout(totalTimer); + if (idleTimer) clearTimeout(idleTimer); + handle?.close(); + } +} diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index 343577a..e800b57 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -1,24 +1,54 @@ /** - * Factory that wires the uniform get/list/delete/wait/create subcommands for - * any async-task resource. Keeps the per-resource modules focused on their - * unique create-flag shape. + * Factory that wires the uniform create/get/list/wait/stream/delete + * subcommands for any registered task resource. Per-resource modules only + * contribute their unique `create` flag shape and payload validation. + * + * Two output schemas share one execution path: + * legacy — the 0.2.0 payloads and `-o` status report, with two documented + * fixes: `get` of a non-terminal task exits 0, and async create + * never polls. + * v1 — one `meshy.cli/v1` envelope whose result carries the TaskView, + * the submission record and the download manifest. + * + * Money rules enforced here, independent of schema: + * - every create is exactly one POST, journaled before it is sent; + * - a lost or malformed response is `submission_unknown` (exit 10), never a + * retry and never a "please run it again" hint; + * - SIGINT stops waiting/streaming (exit 130) and never deletes anything. */ import { Command, Option } from "commander"; import type { TaskEndpoint } from "../client/endpoints/base.js"; +import { MeshyApiError } from "../client/errors.js"; import type { MeshyClient } from "../client/index.js"; -import { requireTaskResource } from "../client/resource-registry.js"; +import { requireTaskResource, type TaskResourceDescriptor } from "../client/resource-registry.js"; +import { TransportError } from "../client/transport.js"; +import { isTerminalStatus, summarizeTask, type Task } from "../client/types.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson, type OpenedCommand } from "./command-helpers.js"; +import { abortSignal, wasInterrupted } from "./context.js"; import { downloadArtifacts } from "./download.js"; +import { CliError, UsageError, type Warning } from "./errors.js"; import { normalizeMediaPayload } from "./file-input.js"; -import { pollUntilTerminal } from "./poll.js"; -import { emit } from "./output.js"; +import { logger } from "./logger.js"; import { mergePayload, parseJsonFlag } from "./payload.js"; +import { emitEnvelope, emitStreamEvent, emit } from "./output.js"; +import { parseTimeoutSeconds, pollUntilTerminal } from "./poll.js"; import { printReport } from "./report.js"; +import { errorEnvelope, okEnvelope, warning, type StreamEventEnvelope } from "./result.js"; +import { buildRuntime, type Runtime } from "./runtime.js"; import { getUpdateNotice } from "./update-notifier.js"; -import { buildRuntime, type Runtime, readGlobalFlags } from "./runtime.js"; -import { summarizeTask, TERMINAL_STATUSES, type Task } from "../client/types.js"; -import { UsageError } from "./errors.js"; -import { logger } from "./logger.js"; +import { streamTask } from "./stream.js"; +import { toTaskView, type TaskView } from "./task-view.js"; +import { + beginOperation, + credentialFingerprint, + newOperationId, + operationsRoot, + payloadFingerprint, + updateOperation, + type OperationRecord, +} from "./operation-store.js"; +import { originOf } from "./config.js"; export interface CreateSpec { description: string; @@ -32,131 +62,258 @@ export interface CreateSpec { * not throw (validation belongs in toPayload). */ toDefaults?(opts: Record): Record; + /** + * Validate the merged payload (defaults < --data < flags) before media is + * normalised and before anything is sent. Throw UsageError / CliError. + */ + validatePayload?(payload: Record, opts: Record): void; } export interface ResourceCommandSpec { /** Registry id of the task resource this command drives. */ name: string; + /** Commander name when it differs from the id (Creative Lab stages). */ + commandName?: string; + /** Dotted prefix for v1 `command` (defaults to the id). */ + commandPrefix?: string; description: string; supportsList?: boolean; + /** Default output schema for this resource's verbs (`legacy` for 0.2.0 commands). */ + defaultSchema?: "legacy" | "v1"; create: CreateSpec; /** Optional override; defaults to the registry endpoint for `name`. */ endpointOf?(client: MeshyClient): TaskEndpoint; } +const TASK_JSON_OPTIONS = (cmd: Command): Command => + cmd + .option("--save-json ", "save the full task JSON as received (never overwrites)") + .option("--include-raw", "v1: include the untouched task response under result.task.raw"); + +interface DownloadOutcome { + state: "not_requested" | "not_ready" | "completed" | "failed"; + files: Array<{ path: string; status: "written" }>; + metadata_path: string | null; +} + +interface TaskResultOptions { + task: Task | null; + raw: unknown; + descriptor: TaskResourceDescriptor; + includeRaw: boolean; + submission: { state: string; operation_id: string | null; task_id?: string | null; request_id?: string | null }; + downloads?: DownloadOutcome; + savedJson?: { path: string; bytes: number } | null; + extra?: Record; +} + +function taskResult(o: TaskResultOptions): Record { + return { + task: o.task ? toTaskView(o.raw ?? o.task, { descriptor: o.descriptor, includeRaw: o.includeRaw }) : null, + submission: o.submission, + downloads: o.downloads ?? { state: "not_requested", files: [], metadata_path: null }, + saved_json: o.savedJson ?? null, + ...(o.extra ?? {}), + }; +} + +function nextCommands(descriptor: TaskResourceDescriptor, taskId: string): Record { + const base = `meshy ${descriptor.commandPath.join(" ")}`; + return { + get: `${base} get ${taskId} --output-schema v1`, + wait: `${base} wait ${taskId} --output-schema v1`, + stream: `${base} stream ${taskId} --format ndjson --output-schema v1`, + }; +} + export function buildResourceCommand(spec: ResourceCommandSpec): Command { const descriptor = requireTaskResource(spec.name); const endpointOf = spec.endpointOf ?? ((client: MeshyClient) => client.endpointFor(descriptor)); - const cmd = new Command(spec.name).description(spec.description); + const prefix = spec.commandPrefix ?? spec.name; + const defaultSchema = spec.defaultSchema ?? "legacy"; + const cmd = new Command(spec.commandName ?? spec.name).description(spec.description); // create const createCmd = new Command("create").description(spec.create.description); spec.create.configure(createCmd); - createCmd - .addOption(new Option("--data ", "raw JSON payload (or @file.json); merges with flags")) + TASK_JSON_OPTIONS(createCmd) + .addOption(new Option("--data ", "raw JSON payload (or @file.json); merges with flags (flags win)")) .addOption( new Option( "--async", - "return the task_id immediately without polling; query later with ` wait ` or `get `", + "submit once and return the task id immediately (no polling); query later with `get`, `wait` or `stream`", ).default(false), ) .addOption(new Option("--timeout ", "max seconds to poll in sync mode").default("600")) + .addOption( + new Option( + "--operation-id ", + "local journal id for this submission; repeating it returns the recorded outcome instead of submitting again (local record only, not a server idempotency key)", + ), + ) .action(async (opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); + const opened = openCommand(thisCmd, `${prefix}.create`, defaultSchema); + const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); + const runAsync = Boolean(opts.async); + const runtime = await buildRuntime(opened.flags); const endpoint = endpointOf(runtime.client); + + // 1. Build and validate the payload — every local failure happens here, before the journal and the POST. const data = parseJsonFlag(opts.data as string | undefined, "--data"); const flagPayload = spec.create.toPayload(opts); const defaults = spec.create.toDefaults?.(opts) ?? {}; - // Media is normalised on the merged payload so a local path or URL - // works identically from a typed flag and from --data; only the fields - // the registry declares are touched. Missing files fail before any POST. - const { payload } = await normalizeMediaPayload(mergePayload(defaults, data, flagPayload), descriptor.mediaFields); - logger.debug("create payload", payload); - const taskId = await endpoint.create(payload); - const runAsync = Boolean(opts.async); - const timeoutSeconds = Number(opts.timeout ?? 600); + const merged = mergePayload(defaults, data, flagPayload); + spec.create.validatePayload?.(merged, opts); + const { payload } = await normalizeMediaPayload(merged, descriptor.mediaFields, { signal: abortSignal() }); + logger.debug("create payload", redactForLog(payload)); + + // 2. Journal, then exactly one POST. + const submitted = await submitOnce(runtime, descriptor, endpoint, payload, (opts.operationId as string | undefined) ?? null, opened); + const { taskId, raw: createRaw, operationId, warnings } = submitted; + if (runAsync) { + const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, createRaw, { workspace: opened.flags.workspace }) : null; + if (opened.schema === "v1") { + await emitEnvelope( + okEnvelope( + opened.command, + taskResult({ + task: null, + raw: null, + descriptor, + includeRaw: false, + submission: { state: "accepted", operation_id: operationId, task_id: taskId, request_id: submitted.requestId }, + savedJson, + extra: { task_id: taskId, next: nextCommands(descriptor, taskId) }, + }), + warnings, + ), + opened.format, + ); + return; + } emit( { resource: spec.name, task_id: taskId, status: "PENDING", - hint: `meshy-cli ${spec.name} wait ${taskId}`, + hint: `meshy-cli ${descriptor.commandPath.join(" ")} wait ${taskId}`, + operation_id: operationId, }, - { format: runtime.flags.format, file: undefined }, + { format: opened.format, file: undefined }, ); return; } - const started = Date.now(); - const { task, timedOut } = await pollUntilTerminal(endpoint, taskId, { - timeoutSeconds, - intervalMs: runtime.config.pollIntervalMs, - }); - const elapsed = (Date.now() - started) / 1000; - await emitTerminalOutcome(task, timedOut, elapsed, spec.name, runtime); + + // 3. Sync: poll the id we just recorded. + await waitAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, opts, { + state: "accepted", + operation_id: operationId, + task_id: taskId, + request_id: submitted.requestId, + }, warnings); }); cmd.addCommand(createCmd); // get - cmd - .command("get ") - .description("Retrieve a single task by id") - .action(async (taskId: string, _opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const task = await endpointOf(runtime.client).retrieve(taskId); - await emitTerminalOutcome(task, false, undefined, spec.name, runtime); - }); + TASK_JSON_OPTIONS(cmd.command("get ").description("Retrieve a single task by id (any status is a successful query)")).action( + async (taskId: string, opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, `${prefix}.get`, defaultSchema); + const runtime = await buildRuntime(opened.flags); + const { task, raw } = await endpointOf(runtime.client).retrieveDetailed(taskId, { signal: abortSignal() }); + const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspace }) : null; + if (opened.schema === "v1") { + const downloads = await maybeDownloadV1(opened, task, spec.name); + await emitEnvelope( + okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw: Boolean(opts.includeRaw), submission: { state: "accepted", operation_id: null }, downloads, savedJson })), + opened.format, + ); + return; + } + await emitLegacyOutcome(task, false, undefined, spec.name, runtime, { query: true }); + }, + ); // wait - cmd - .command("wait ") - .description("Poll until the task reaches a terminal status") - .option("--timeout ", "max seconds to wait", "600") - .action(async (taskId: string, opts: { timeout?: string }, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const endpoint = endpointOf(runtime.client); - const timeoutSeconds = Number(opts.timeout ?? 600); - const started = Date.now(); - const { task, timedOut } = await pollUntilTerminal(endpoint, taskId, { - timeoutSeconds, - intervalMs: runtime.config.pollIntervalMs, - }); - const elapsed = (Date.now() - started) / 1000; - await emitTerminalOutcome(task, timedOut, elapsed, spec.name, runtime); - }); + TASK_JSON_OPTIONS( + cmd + .command("wait ") + .description("Poll until the task reaches a terminal status") + .option("--timeout ", "max seconds to wait (0 = a single query)", "600"), + ).action(async (taskId: string, opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, `${prefix}.wait`, defaultSchema); + const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); + const runtime = await buildRuntime(opened.flags); + const endpoint = endpointOf(runtime.client); + await waitAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, opts, { state: "accepted", operation_id: null }, []); + }); + + // stream + TASK_JSON_OPTIONS( + cmd + .command("stream ") + .description("Follow the task over Server-Sent Events until it is terminal") + .option("--timeout ", "total deadline for the stream", "600") + .option("--idle-timeout ", "abort when no bytes arrive for this long (keep-alives count)", "60"), + ).action(async (taskId: string, opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, `${prefix}.stream`, defaultSchema); + if (!descriptor.supports.stream) throw new UsageError(`${spec.name} does not support stream`); + const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); + const idleSeconds = parseTimeoutSeconds(opts.idleTimeout ?? "60", "--idle-timeout"); + if (timeoutSeconds === 0) throw new UsageError("--timeout 0 is not meaningful for stream; use `get` for a single query"); + const runtime = await buildRuntime(opened.flags); + const endpoint = endpointOf(runtime.client); + await streamAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, idleSeconds, opts); + }); // delete cmd .command("delete ") .description("Delete a task") .action(async (taskId: string, _opts: Record, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - await endpointOf(runtime.client).delete(taskId); - emit({ resource: spec.name, task_id: taskId, deleted: true }, { - format: runtime.flags.format, - file: runtime.flags.output, - }); + const opened = openCommand(thisCmd, `${prefix}.delete`, defaultSchema); + rejectOutputFlagForV1(opened, undefined); + const runtime = await buildRuntime(opened.flags); + const raw = await endpointOf(runtime.client).delete(taskId, { signal: abortSignal() }); + await emitResult( + opened, + { resource: spec.name, task_id: taskId, deleted: true }, + { resource: spec.name, endpoint: descriptor.legacyEndpoint, task_id: taskId, deleted: true, response: raw ?? null }, + { legacyFile: opened.flags.output }, + ); }); - // list (optional — some endpoints don't support it) - if (spec.supportsList !== false) { + // list + if (spec.supportsList !== false && descriptor.supports.list) { cmd .command("list") .description("List recent tasks") .option("--page ", "page number (1-based)", "1") .option("--page-size ", "page size", "10") .option("--sort-by ", "sort order (e.g. -created_at)", "-created_at") - .action(async (opts: { page?: string; pageSize?: string; sortBy?: string }, thisCmd: Command) => { - const runtime = await buildRuntime(readGlobalFlags(thisCmd)); - const tasks = await endpointOf(runtime.client).list({ - page_num: Number(opts.page ?? 1), - page_size: Number(opts.pageSize ?? 10), - sort_by: opts.sortBy ?? "-created_at", - }); - emit(tasks.map((t) => summarizeTask(t)), { - format: runtime.flags.format, - file: runtime.flags.output, - }); + .option("--save-json ", "save the raw list response (never overwrites)") + .option("--include-raw", "v1: include each raw task under items[].raw") + .action(async (opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, `${prefix}.list`, defaultSchema); + rejectOutputFlagForV1(opened, opts.saveJson as string | undefined); + const runtime = await buildRuntime(opened.flags); + const page = { page_num: Number(opts.page ?? 1), page_size: Number(opts.pageSize ?? 10), sort_by: String(opts.sortBy ?? "-created_at") }; + if (!Number.isInteger(page.page_num) || page.page_num < 1) throw new UsageError("--page must be a positive integer"); + if (!Number.isInteger(page.page_size) || page.page_size < 1) throw new UsageError("--page-size must be a positive integer"); + const { tasks, raw } = await endpointOf(runtime.client).listDetailed(page, { signal: abortSignal() }); + const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspace }) : null; + const rawItems = Array.isArray(raw) ? raw : []; + await emitResult( + opened, + tasks.map((t) => summarizeTask(t)), + { + items: tasks.map((t, i) => toTaskView(rawItems[i] ?? t, { descriptor, includeRaw: Boolean(opts.includeRaw) })), + count: tasks.length, + page, + saved_json: savedJson, + }, + { legacyFile: opened.flags.output }, + ); }); } else { cmd @@ -170,18 +327,401 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { return cmd; } +/** Strip media payloads before logging. */ +function redactForLog(payload: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(payload)) { + if (typeof v === "string" && v.startsWith("data:")) out[k] = `data:<${v.length} chars>`; + else if (Array.isArray(v)) out[k] = v.map((x) => (typeof x === "string" && x.startsWith("data:") ? `data:<${x.length} chars>` : x)); + else out[k] = v; + } + return out; +} + +interface Submitted { + taskId: string; + raw: unknown; + requestId: string | null; + operationId: string; + warnings: Warning[]; +} + /** - * Decide how to report a task whose status is (expected to be) terminal: - * - with `-o`: download artifacts to the filesystem and print the status - * report. Failed/timed-out tasks print the FAIL form without downloads. - * - without `-o`: emit the familiar JSON/pretty summary to stdout. + * Journal → single POST → journal update. Every failure path leaves a record + * that says what is known; the unknown state is reported as exit 10 with the + * operation id and never as a suggestion to submit again. */ -export async function emitTerminalOutcome( +async function submitOnce( + runtime: Runtime, + descriptor: TaskResourceDescriptor, + endpoint: TaskEndpoint, + payload: Record, + requestedOperationId: string | null, + opened: OpenedCommand, +): Promise { + const root = operationsRoot(); + const operationId = requestedOperationId ?? newOperationId(); + const apiOrigin = originOf(endpoint.transportBaseUrl) ?? endpoint.transportBaseUrl; + const identity = { + resource: descriptor.id, + endpoint: descriptor.legacyEndpoint, + apiOrigin, + credentialFingerprint: credentialFingerprint({ + source: runtime.config.credentialSource, + profile: runtime.config.credentialProfile ?? null, + origin: apiOrigin, + kind: runtime.config.credentialKind, + }), + payloadFingerprint: payloadFingerprint(payload), + }; + const warnings: Warning[] = []; + + let begin: ReturnType; + try { + begin = beginOperation(root, operationId, identity); + } catch (err) { + if (err instanceof CliError) throw err; + throw new CliError({ code: "local_io", message: `cannot write the operation journal under ${root}: ${err instanceof Error ? err.message : String(err)}; nothing was submitted`, cause: err }); + } + + if (begin.outcome === "existing") { + return replayExisting(begin.record, descriptor, operationId); + } + + if (abortSignal().aborted) { + updateOperation(root, operationId, { state: "not_submitted", error: "interrupted before the request was sent" }); + throw new CliError({ code: "interrupted", message: "interrupted before the request was sent; nothing was submitted", result: { submission: { state: "not_submitted", operation_id: operationId }, task: null } }); + } + + let created: { taskId: string; raw: unknown; requestId: string | null }; + try { + created = await endpoint.createDetailed(payload, { signal: abortSignal() }); + } catch (err) { + const submission = classifySubmissionFailure(err); + try { + updateOperation(root, operationId, { + state: submission.state, + http_status: err instanceof MeshyApiError && err.status ? err.status : null, + error: err instanceof Error ? err.message : String(err), + }); + } catch (journalErr) { + warnings.push(warning("journal_write_failed", `operation journal update failed: ${journalErr instanceof Error ? journalErr.message : String(journalErr)}`)); + } + if (submission.state === "rejected" || submission.state === "not_submitted") { + // Definite outcomes keep their own classification (validation/auth/network …). + if (err instanceof CliError) throw err; + const wrapped = err instanceof MeshyApiError ? err : err instanceof Error ? err : new Error(String(err)); + throw attachSubmission(wrapped, { state: submission.state, operation_id: operationId }, warnings); + } + const interrupted = wasInterrupted(); + throw new CliError({ + code: interrupted ? "interrupted" : "submission_unknown", + message: interrupted + ? `interrupted while the create request was in flight; the server may or may not have created a task (operation ${operationId})` + : `the create request was sent but its outcome is unknown (${err instanceof Error ? err.message : String(err)}); the server may or may not have created a task`, + httpStatus: err instanceof MeshyApiError && err.status ? err.status : null, + recovery: { + action: "reconcile", + automatic: false, + command: `meshy ${descriptor.commandPath.join(" ")} list --output-schema v1 # then match operation ${operationId} by time/prompt before creating again`, + }, + result: { submission: { state: "unknown", operation_id: operationId, task_id: null }, task: null, downloads: { state: "not_requested", files: [], metadata_path: null } }, + warnings, + cause: err, + }); + } + + try { + updateOperation(root, operationId, { state: "accepted", task_id: created.taskId, request_id: created.requestId, http_status: 200 }); + } catch (journalErr) { + // The server has the task; the id must survive this failure. + throw new CliError({ + code: "local_io", + message: `task ${created.taskId} was created but the operation journal could not be updated: ${journalErr instanceof Error ? journalErr.message : String(journalErr)}`, + result: { submission: { state: "accepted", operation_id: operationId, task_id: created.taskId }, task: null, task_id: created.taskId, next: nextCommands(descriptor, created.taskId) }, + cause: journalErr, + }); + } + void opened; + return { taskId: created.taskId, raw: created.raw, requestId: created.requestId, operationId, warnings }; +} + +function replayExisting(record: OperationRecord, descriptor: TaskResourceDescriptor, operationId: string): Submitted { + if (record.state === "accepted" && record.task_id) { + return { + taskId: record.task_id, + raw: { result: record.task_id, replayed_from_journal: true }, + requestId: record.request_id, + operationId, + warnings: [warning("operation_replayed", `operation ${operationId} was already accepted as task ${record.task_id} on ${record.updated_at}; no new request was sent`)], + }; + } + if (record.state === "unknown" || record.state === "started") { + throw new CliError({ + code: "submission_unknown", + message: `operation ${operationId} is recorded as '${record.state}' since ${record.updated_at}; reconcile it before submitting again (nothing was sent now)`, + recovery: { action: "reconcile", automatic: false, command: `meshy ${descriptor.commandPath.join(" ")} list --output-schema v1` }, + result: { submission: { state: "unknown", operation_id: operationId, task_id: record.task_id }, task: null }, + }); + } + throw new CliError({ + code: "operation_conflict", + message: `operation ${operationId} was already ${record.state} on ${record.updated_at} (${record.error ?? "no detail"}); use a new --operation-id to submit again`, + result: { submission: { state: record.state, operation_id: operationId, task_id: record.task_id }, task: null }, + }); +} + +function classifySubmissionFailure(err: unknown): { state: "rejected" | "not_submitted" | "unknown" } { + if (err instanceof TransportError) { + return { state: err.neverSent ? "not_submitted" : "unknown" }; + } + if (err instanceof MeshyApiError) { + if (err.status >= 400 && err.status < 500) return { state: "rejected" }; + return { state: "unknown" }; // 5xx, malformed 2xx, unknown status + } + return { state: "unknown" }; +} + +function attachSubmission(err: Error, submission: { state: string; operation_id: string }, warnings: Warning[]): Error { + (err as Error & { submission?: unknown; warnings?: Warning[] }).submission = submission; + (err as Error & { submission?: unknown; warnings?: Warning[] }).warnings = warnings; + return err; +} + +async function maybeDownloadV1(opened: OpenedCommand, task: Task, resourceName: string): Promise { + const output = opened.flags.output; + if (!output) return { state: "not_requested", files: [], metadata_path: null }; + if (task.status !== "SUCCEEDED") return { state: "not_ready", files: [], metadata_path: null }; + try { + const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName); + return { state: "completed", files: savedFiles.map((p) => ({ path: p, status: "written" as const })), metadata_path: metadataPath }; + } catch (err) { + throw new CliError({ + code: "local_io", + message: `task ${task.id} is SUCCEEDED but downloading its assets failed: ${err instanceof Error ? err.message : String(err)}`, + result: { task: toTaskView(task), downloads: { state: "failed", files: [], metadata_path: null } }, + cause: err, + }); + } +} + +async function waitAndReport( + opened: OpenedCommand, + runtime: Runtime, + descriptor: TaskResourceDescriptor, + endpoint: TaskEndpoint, + taskId: string, + timeoutSeconds: number, + opts: Record, + submission: { state: string; operation_id: string | null; task_id?: string | null; request_id?: string | null }, + warnings: Warning[], +): Promise { + const started = performance.now(); + let last: { task: Task; raw: unknown } | null = null; + let polls = 0; + let poll: Awaited>; + try { + poll = await pollUntilTerminal(endpoint, taskId, { + timeoutSeconds, + intervalMs: runtime.config.pollIntervalMs, + signal: abortSignal(), + onTick: (task, raw) => { + last = { task, raw }; + polls += 1; + if (opened.schema === "v1" && opened.format !== "ndjson") { + process.stderr.write(`[${descriptor.id}] ${task.status}${typeof task.progress === "number" ? ` ${task.progress}%` : ""}\n`); + } + }, + }); + } catch (err) { + if (wasInterrupted() || abortSignal().aborted) { + throw interruptedError(descriptor, taskId, last, submission, opts, opened); + } + throw err; + } + const elapsed = (performance.now() - started) / 1000; + const { task, raw, timedOut, aborted } = poll; + const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspace }) : null; + + if (aborted) throw interruptedError(descriptor, taskId, { task, raw }, submission, opts, opened, savedJson); + + if (opened.schema !== "v1") { + await emitLegacyOutcome(task, timedOut, elapsed, descriptor.id, runtime, { query: false }); + return; + } + + const includeRaw = Boolean(opts.includeRaw); + const waitInfo = { timed_out: timedOut, elapsed_seconds: Number(elapsed.toFixed(2)), polls }; + if (timedOut) { + throw new CliError({ + code: "timed_out", + message: `task ${taskId} did not reach a terminal status within ${timeoutSeconds}s (last status: ${task.status}); the server keeps running it`, + recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, + result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { wait: waitInfo, next: nextCommands(descriptor, taskId) } }), + warnings, + }); + } + if (task.status !== "SUCCEEDED") { + throw new CliError({ + code: "task_failed", + message: task.task_error?.message ? `task ${taskId} ${task.status}: ${task.task_error.message}` : `task ${taskId} ended as ${task.status}`, + result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { wait: waitInfo } }), + warnings, + }); + } + const downloads = await maybeDownloadV1(opened, task, descriptor.id); + await emitEnvelope( + okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw, submission, downloads, savedJson, extra: { wait: waitInfo } }), warnings), + opened.format, + ); +} + +function interruptedError( + descriptor: TaskResourceDescriptor, + taskId: string, + last: { task: Task; raw: unknown } | null, + submission: { state: string; operation_id: string | null; task_id?: string | null }, + opts: Record, + opened: OpenedCommand, + savedJson: { path: string; bytes: number } | null = null, +): CliError { + let saved = savedJson; + if (!saved && opts.saveJson && last) { + try { + saved = saveRawJson(opts.saveJson as string, last.raw, { workspace: opened.flags.workspace }); + } catch { + saved = null; + } + } + return new CliError({ + code: "interrupted", + message: `interrupted while waiting for task ${taskId}; the server keeps running it — resume with \`${nextCommands(descriptor, taskId).wait}\``, + recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, + result: taskResult({ task: last?.task ?? null, raw: last?.raw ?? null, descriptor, includeRaw: Boolean(opts.includeRaw), submission, savedJson: saved, extra: { task_id: taskId, next: nextCommands(descriptor, taskId) } }), + }); +} + +async function streamAndReport( + opened: OpenedCommand, + runtime: Runtime, + descriptor: TaskResourceDescriptor, + endpoint: TaskEndpoint, + taskId: string, + timeoutSeconds: number, + idleSeconds: number, + opts: Record, +): Promise { + const includeRaw = Boolean(opts.includeRaw); + const ndjson = opened.format === "ndjson"; + let sequence = 0; + const submission = { state: "accepted", operation_id: null }; + const warnings: Warning[] = []; + + const outcome = await streamTask(endpoint, taskId, { + timeoutMs: timeoutSeconds * 1000, + idleTimeoutMs: idleSeconds * 1000, + signal: abortSignal(), + onUnknownEvent: (ev) => { + warnings.push(warning("unknown_sse_event", `ignored SSE event '${ev.event}'`)); + }, + onTask: async (task, raw) => { + if (opened.schema === "v1" && ndjson) { + sequence += 1; + const event: StreamEventEnvelope = { + ...okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw, submission })), + event: "task", + sequence, + }; + await emitStreamEvent(event); + } else if (opened.schema === "v1") { + process.stderr.write(`[${descriptor.id}] ${task.status}${typeof task.progress === "number" ? ` ${task.progress}%` : ""}\n`); + } + }, + }); + + const savedJson = opts.saveJson && outcome.raw ? saveRawJson(opts.saveJson as string, outcome.raw, { workspace: opened.flags.workspace }) : null; + const streamInfo = { events: outcome.events, ended: outcome.reason, elapsed_seconds: Number((outcome.elapsedMs / 1000).toFixed(2)) }; + const result = taskResult({ task: outcome.task, raw: outcome.raw, descriptor, includeRaw, submission, savedJson, extra: { stream: streamInfo, task_id: taskId } }); + + let finalError: CliError | null = null; + switch (outcome.reason) { + case "terminal": + if (outcome.task && outcome.task.status !== "SUCCEEDED") { + finalError = new CliError({ + code: "task_failed", + message: outcome.task.task_error?.message ? `task ${taskId} ${outcome.task.status}: ${outcome.task.task_error.message}` : `task ${taskId} ended as ${outcome.task.status}`, + result, + warnings, + }); + } + break; + case "timeout": + finalError = new CliError({ code: "timed_out", message: `stream deadline of ${timeoutSeconds}s reached (last status: ${outcome.task?.status ?? "none"}); the server keeps running the task`, recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, result, warnings }); + break; + case "idle_timeout": + finalError = new CliError({ code: "network", message: `no bytes received for ${idleSeconds}s on the stream (last status: ${outcome.task?.status ?? "none"})`, recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, result, warnings }); + break; + case "disconnected": + finalError = new CliError({ code: "network", message: outcome.error?.message ?? "stream disconnected before a terminal status", recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, result, warnings }); + break; + case "interrupted": + finalError = new CliError({ code: "interrupted", message: `interrupted while streaming task ${taskId}; the server keeps running it`, recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, result, warnings }); + break; + case "error": + finalError = wrapStreamError(outcome.error, result, warnings); + break; + case "protocol": + finalError = wrapStreamError(outcome.error, result, warnings); + break; + } + + if (opened.schema !== "v1") { + // Legacy has no stream shape to preserve: reuse the terminal summary path. + if (finalError) throw finalError; + if (outcome.task) await emitLegacyOutcome(outcome.task, false, outcome.elapsedMs / 1000, descriptor.id, runtime, { query: false }); + return; + } + + if (ndjson) { + sequence += 1; + const body = finalError ? errorEnvelope(opened.command, finalError).envelope : okEnvelope(opened.command, result, warnings); + const event: StreamEventEnvelope = { ...body, event: "outcome", sequence }; + await emitStreamEvent(event); + if (finalError) { + // The outcome line already carries the error; exit with its code without a second envelope. + process.exitCode = finalError.exitCode; + return; + } + return; + } + if (finalError) throw finalError; + const downloads = outcome.task ? await maybeDownloadV1(opened, outcome.task, descriptor.id) : undefined; + await emitEnvelope(okEnvelope(opened.command, { ...result, downloads: downloads ?? result["downloads"] }, warnings), opened.format); +} + +function wrapStreamError(err: MeshyApiError | CliError | null, result: Record, warnings: Warning[]): CliError { + if (err instanceof CliError) { + return new CliError({ code: err.code, message: err.message, httpStatus: err.httpStatus, recovery: err.recovery, result, warnings: [...err.warnings, ...warnings], cause: err }); + } + if (err instanceof MeshyApiError) { + const code = err.code === "server" ? "server" : err.code; + return new CliError({ code, message: err.message, httpStatus: err.status || null, result, warnings, cause: err }); + } + return new CliError({ code: "protocol", message: "stream ended abnormally", result, warnings }); +} + +/** + * Legacy reporter. `query: true` is `get`: a valid task is a successful query + * whatever its status (0.2.0 exited 1 for PENDING/IN_PROGRESS — fixed); a + * terminal FAILED/CANCELED still exits 1 for compatibility. + */ +async function emitLegacyOutcome( task: Task, timedOut: boolean, elapsedSeconds: number | undefined, resourceName: string, runtime: Runtime, + mode: { query: boolean }, ): Promise { const output = runtime.flags.output; const succeeded = !timedOut && task.status === "SUCCEEDED"; @@ -199,35 +739,49 @@ export async function emitTerminalOutcome( const successNotice = getUpdateNotice(); if (successNotice) successReport._notice = successNotice; printReport(successReport); - } else { - const report: Parameters[0] = { - status: "FAIL", - taskId: task.id, - type: task.type || resourceName, - }; - if (timedOut) report.timedOut = true; - else if (task.task_error?.message) report.error = task.task_error.message; - else if (task.status) report.error = `task status: ${task.status}`; - const failNotice = getUpdateNotice(); - if (failNotice) report._notice = failNotice; - printReport(report); - process.exitCode = timedOut ? 8 : 1; + return; } - if (succeeded) return; - } else { - // No -o: keep the JSON/pretty summary for machine consumption. - emit( - { - resource: resourceName, - ...summarizeTask(task, elapsedSeconds), - ...(timedOut ? { timed_out: true } : {}), - }, - { format: runtime.flags.format }, - ); - } - - if (!output) { + const report: Parameters[0] = { + status: "FAIL", + taskId: task.id, + type: task.type || resourceName, + }; + if (timedOut) report.timedOut = true; + else if (task.task_error?.message) report.error = task.task_error.message; + else if (task.status) report.error = `task status: ${task.status}`; + const failNotice = getUpdateNotice(); + if (failNotice) report._notice = failNotice; + printReport(report); if (timedOut) process.exitCode = 8; - else if (!TERMINAL_STATUSES.has(task.status) || task.status !== "SUCCEEDED") process.exitCode = 1; + else if (mode.query && !isTerminalStatus(task.status)) process.exitCode = 0; + else process.exitCode = 1; + return; } + + emit( + { + resource: resourceName, + ...summarizeTask(task, elapsedSeconds), + ...(timedOut ? { timed_out: true } : {}), + }, + { format: runtime.flags.format }, + ); + if (timedOut) process.exitCode = 8; + else if (task.status === "SUCCEEDED") process.exitCode = 0; + else if (!isTerminalStatus(task.status)) process.exitCode = mode.query ? 0 : 1; + else process.exitCode = 1; } + +/** Kept for make.ts (legacy path). */ +export async function emitTerminalOutcome( + task: Task, + timedOut: boolean, + elapsedSeconds: number | undefined, + resourceName: string, + runtime: Runtime, +): Promise { + await emitLegacyOutcome(task, timedOut, elapsedSeconds, resourceName, runtime, { query: false }); +} + +export { taskResult as buildTaskResult, nextCommands as taskNextCommands }; +export type { DownloadOutcome, TaskView }; diff --git a/src/root.ts b/src/root.ts index 1f3433c..24b86f2 100644 --- a/src/root.ts +++ b/src/root.ts @@ -24,6 +24,7 @@ import { apiCommand } from "./cmd/api.js"; import { authCommand } from "./cmd/auth.js"; import { balanceCommand } from "./cmd/balance.js"; import { convertCommand } from "./cmd/convert.js"; +import { creativeLabCommand } from "./cmd/creative-lab.js"; import { deleteCommand } from "./cmd/delete.js"; import { imageTo3dCommand } from "./cmd/image-to-3d.js"; import { imageToImageCommand } from "./cmd/image-to-image.js"; @@ -40,6 +41,7 @@ import { showcasesCommand } from "./cmd/showcases.js"; import { textTo3dCommand } from "./cmd/text-to-3d.js"; import { textToImageCommand } from "./cmd/text-to-image.js"; import { textToMotionCommand } from "./cmd/text-to-motion.js"; +import { uvUnwrapCommand } from "./cmd/uv-unwrap.js"; /** * Commands added in S1. They only speak the v1 envelope; the error exit uses @@ -188,6 +190,8 @@ export function buildRootCommand(): Command { analyzePrintabilityCommand, repairPrintabilityCommand, deleteCommand, + uvUnwrapCommand, + creativeLabCommand, animationCatalogCommand, showcasesCommand, ]) { diff --git a/tests/helpers/begin-operation-child.ts b/tests/helpers/begin-operation-child.ts new file mode 100644 index 0000000..c1ca5c5 --- /dev/null +++ b/tests/helpers/begin-operation-child.ts @@ -0,0 +1,19 @@ +/** + * Child process for the operation-journal race test: begins the same + * operation id and prints the outcome. argv: + */ +import { beginOperation } from "../../src/internal/operation-store.js"; + +const [root, id] = process.argv.slice(2); +if (!root || !id) { + process.stderr.write("usage: begin-operation-child \n"); + process.exit(2); +} +const res = beginOperation(root, id, { + resource: "text-to-3d", + endpoint: "/openapi/v2/text-to-3d", + apiOrigin: "http://127.0.0.1:1", + credentialFingerprint: "cred", + payloadFingerprint: "payload", +}); +process.stdout.write(`${JSON.stringify({ outcome: res.outcome, state: res.record.state })}\n`); diff --git a/tests/operation-store.test.ts b/tests/operation-store.test.ts new file mode 100644 index 0000000..8722712 --- /dev/null +++ b/tests/operation-store.test.ts @@ -0,0 +1,90 @@ +/** + * Operation journal (T-043..T-046): start → accepted/unknown/rejected, replay + * on the same id, conflict on a different request, no secrets on disk. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + beginOperation, + credentialFingerprint, + listOperations, + payloadFingerprint, + readOperation, + updateOperation, +} from "../src/internal/operation-store.js"; +import { CliError } from "../src/internal/errors.js"; +import { tmpDir } from "./helpers/cli.js"; + +const identity = { + resource: "text-to-3d", + endpoint: "/openapi/v2/text-to-3d", + apiOrigin: "http://127.0.0.1:1", + credentialFingerprint: credentialFingerprint({ source: "env", origin: "http://127.0.0.1:1", kind: "api_key" }), + payloadFingerprint: payloadFingerprint({ mode: "preview", prompt: "a", image_url: "data:image/png;base64,AAAA" }), +}; + +test("begin → accepted, then a repeat with the same id replays without conflict", () => { + const root = tmpDir("ops-"); + const a = beginOperation(root, "op-1", identity); + assert.equal(a.outcome, "created"); + assert.equal(a.record.state, "started"); + updateOperation(root, "op-1", { state: "accepted", task_id: "task-9", http_status: 200 }); + const b = beginOperation(root, "op-1", identity); + assert.equal(b.outcome, "existing"); + assert.equal(b.record.state, "accepted"); + assert.equal(b.record.task_id, "task-9"); + assert.equal(listOperations(root).length, 1); +}); + +test("a different payload, credential or origin under the same id is an operation_conflict", () => { + const root = tmpDir("ops-"); + beginOperation(root, "op-2", identity); + for (const variant of [ + { ...identity, payloadFingerprint: payloadFingerprint({ mode: "preview", prompt: "b" }) }, + { ...identity, credentialFingerprint: credentialFingerprint({ source: "file", profile: "work", origin: identity.apiOrigin }) }, + { ...identity, apiOrigin: "http://127.0.0.1:2" }, + { ...identity, resource: "image-to-3d" }, + ]) { + assert.throws(() => beginOperation(root, "op-2", variant), (e: unknown) => e instanceof CliError && e.code === "operation_conflict"); + } +}); + +test("fingerprints hide media content and canonicalise key order", () => { + const a = payloadFingerprint({ b: 1, image_url: `data:image/png;base64,${"A".repeat(400)}` }); + const b = payloadFingerprint({ image_url: `data:image/png;base64,${"B".repeat(400)}`, b: 1 }); + assert.equal(a, b, "same length data URI of the same MIME → same fingerprint, content is not stored"); + assert.notEqual(a, payloadFingerprint({ b: 2, image_url: `data:image/png;base64,${"A".repeat(400)}` })); + assert.notEqual(credentialFingerprint({ source: "env", origin: "x" }), credentialFingerprint({ source: "file", profile: "p", origin: "x" })); +}); + +test("records never contain the key, base64 media or signed URLs; files are private", () => { + const root = tmpDir("ops-"); + beginOperation(root, "op-3", identity); + updateOperation(root, "op-3", { state: "unknown", error: "socket hang up" }); + const files = readdirSync(root).filter((f) => f.endsWith(".json")); + assert.equal(files.length, 1); + const text = readFileSync(join(root, files[0]!), "utf8"); + assert.ok(!text.includes("AAAA"), "no base64 payload"); + assert.ok(!text.includes("msy_"), "no key material"); + assert.equal(readOperation(root, "op-3")?.state, "unknown"); +}); + +test("T-046 two processes racing on the same operation id: exactly one creates, the other sees it", () => { + const root = tmpDir("ops-"); + const script = fileURLToPath(new URL("./helpers/begin-operation-child.ts", import.meta.url)); + const runs = [1, 2, 3].map(() => + spawnSync(process.execPath, ["--import", "tsx", script, root, "race-1"], { encoding: "utf8", env: { ...process.env, MESHY_CLI_NO_UPDATE_NOTIFIER: "1" } }), + ); + const outcomes = runs.map((r) => { + assert.equal(r.status, 0, r.stderr); + return JSON.parse(r.stdout.trim()) as { outcome: string }; + }); + const created = outcomes.filter((o) => o.outcome === "created").length; + assert.equal(created, 1, JSON.stringify(outcomes)); + assert.equal(outcomes.length - created, 2); +}); diff --git a/tests/poll.test.ts b/tests/poll.test.ts index 1feb880..39f0d2d 100644 --- a/tests/poll.test.ts +++ b/tests/poll.test.ts @@ -11,11 +11,16 @@ import type { Task } from "../src/client/types.js"; function fakeEndpoint(statuses: string[]): TaskEndpoint { let i = 0; + const retrieve = async (id: string): Promise => { + const status = statuses[Math.min(i, statuses.length - 1)] ?? "PENDING"; + i += 1; + return { id, status, type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; + }; return { - async retrieve(id: string): Promise { - const status = statuses[Math.min(i, statuses.length - 1)] ?? "PENDING"; - i += 1; - return { id, status, type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; + retrieve, + async retrieveDetailed(id: string): Promise<{ task: Task; raw: unknown }> { + const task = await retrieve(id); + return { task, raw: task }; }, } as unknown as TaskEndpoint; } diff --git a/tests/sse.test.ts b/tests/sse.test.ts new file mode 100644 index 0000000..33c8aa1 --- /dev/null +++ b/tests/sse.test.ts @@ -0,0 +1,232 @@ +/** + * SSE parsing and stream semantics (T-049..T-053). The parser is exercised + * with arbitrary byte splits, CRLF, UTF-8 boundaries, comments and multi-line + * data; streamTask is exercised against a loopback server. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import { createSseParser, streamTask, type SseEvent } from "../src/internal/stream.js"; +import { createTransport } from "../src/client/transport.js"; +import { TaskEndpoint } from "../src/client/endpoints/base.js"; +import { MeshyApiError } from "../src/client/errors.js"; +import { CliError } from "../src/internal/errors.js"; + +function parseAll(text: string, chunkSize: number): SseEvent[] { + const bytes = Buffer.from(text, "utf8"); + const parser = createSseParser(); + const out: SseEvent[] = []; + for (let i = 0; i < bytes.length; i += chunkSize) out.push(...parser.feed(bytes.subarray(i, i + chunkSize))); + out.push(...parser.end()); + return out; +} + +const STREAM = [ + ": heartbeat comment", + "", + "event: message", + 'data: {"id":"t","status":"IN_PROGRESS",', + 'data: "progress":10,"name":"naïve ☃ 模型"}', + "id: 7", + "", + ":keep-alive", + "event: message", + 'data: {"id":"t","status":"SUCCEEDED","progress":100}', + "", +].join("\n"); + +test("T-049 parser: identical events for every chunk size, LF/CR/CRLF, multi-line data, comments", () => { + const reference = parseAll(STREAM, STREAM.length); + assert.equal(reference.length, 2); + assert.equal(reference[0]!.event, "message"); + assert.equal(JSON.parse(reference[0]!.data).name, "naïve ☃ 模型"); + assert.equal(reference[0]!.id, "7"); + assert.equal(JSON.parse(reference[1]!.data).status, "SUCCEEDED"); + for (const size of [1, 2, 3, 5, 7, 11, 64]) { + assert.deepEqual(parseAll(STREAM, size), reference, `chunk size ${size}`); + } + assert.deepEqual(parseAll(STREAM.replace(/\n/g, "\r\n"), 3), reference, "CRLF"); + assert.deepEqual(parseAll(STREAM.replace(/\n/g, "\r"), 4), reference, "CR"); + // A final event without a trailing blank line is dispatched at end(). + const noTrailer = parseAll('data: {"a":1}', 2); + assert.equal(noTrailer.length, 1); + assert.equal(noTrailer[0]!.event, "message"); +}); + +test("parser: the synthetic error fixture yields one error event with status_code 404", () => { + const fixture = readFileSync(new URL("./fixtures/skill-parity/task-error.synthetic.sse", import.meta.url)); + for (const size of [1, 4, 16, fixture.length]) { + const parser = createSseParser(); + const out: SseEvent[] = []; + for (let i = 0; i < fixture.length; i += size) out.push(...parser.feed(fixture.subarray(i, i + size))); + out.push(...parser.end()); + assert.equal(out.length, 1, `chunk ${size}`); + assert.equal(out[0]!.event, "error"); + assert.deepEqual(JSON.parse(out[0]!.data), { message: "Synthetic task not found", status_code: 404 }); + } +}); + +test("parser: oversized events are a protocol error, not a truncation", () => { + const parser = createSseParser({ maxEventBytes: 64 }); + assert.throws(() => parser.feed(Buffer.from(`data: ${"x".repeat(100)}\n\n`)), (e: unknown) => e instanceof CliError && e.code === "protocol"); +}); + +// --------------------------------------------------------------------------- +// streamTask against a loopback server +// --------------------------------------------------------------------------- + +interface Scenario { + headers?: Record; + status?: number; + /** Chunks written with the given delays (ms). A null chunk closes the response. */ + script: Array<{ delay: number; chunk: string | null }>; + keepOpen?: boolean; +} + +async function serve(scenario: Scenario): Promise<{ server: Server; endpoint: TaskEndpoint; url: string; opened: number }> { + const state = { opened: 0 }; + const server = createServer((req, res) => { + state.opened += 1; + res.writeHead(scenario.status ?? 200, { "content-type": "text/event-stream", "cache-control": "no-cache", ...(scenario.headers ?? {}) }); + res.flushHeaders(); + let t = 0; + for (const step of scenario.script) { + t += step.delay; + setTimeout(() => { + if (req.destroyed) return; + if (step.chunk === null) res.end(); + else res.write(step.chunk); + }, t); + } + req.on("close", () => undefined); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const addr = server.address() as { port: number }; + const url = `http://127.0.0.1:${addr.port}`; + const transport = createTransport({ baseUrl: `${url}/openapi/v1`, apiKey: "msy_k", readTimeoutMs: 5000 }); + return { server, endpoint: new TaskEndpoint(transport, "/image-to-3d"), url, get opened() { return state.opened; } } as never; +} + +function closeServer(server: Server): Promise { + return new Promise((r) => { + server.closeAllConnections(); + server.close(() => r()); + }); +} + +const msg = (obj: unknown) => `event: message\ndata: ${JSON.stringify(obj)}\n\n`; + +test("T-050 terminal message closes the connection promptly and yields exactly one outcome", async () => { + const s = await serve({ + script: [ + { delay: 10, chunk: msg({ id: "t", status: "IN_PROGRESS", progress: 30 }) }, + { delay: 30, chunk: msg({ id: "t", status: "SUCCEEDED", progress: 100, model_urls: { glb: "https://assets.example.invalid/m.glb" } }) }, + // Server keeps the socket open afterwards (misbehaving server); client must not wait for it. + ], + keepOpen: true, + }); + try { + const seen: string[] = []; + const started = Date.now(); + const out = await streamTask(s.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000, onTask: (task) => { seen.push(task.status); } }); + assert.equal(out.reason, "terminal"); + assert.equal(out.task?.status, "SUCCEEDED"); + assert.deepEqual(seen, ["IN_PROGRESS", "SUCCEEDED"]); + assert.equal(out.events, 2); + assert.ok(Date.now() - started < 1500, "did not wait for the idle timeout after the terminal event"); + } finally { + await closeServer(s.server); + } +}); + +test("T-051 error event after HTTP 200 maps to not_found; non-JSON and wrong content-type are protocol errors", async () => { + const errFixture = readFileSync(new URL("./fixtures/skill-parity/task-error.synthetic.sse", import.meta.url), "utf8"); + const s1 = await serve({ script: [{ delay: 5, chunk: errFixture.slice(0, 40) }, { delay: 20, chunk: errFixture.slice(40) }] }); + try { + const out = await streamTask(s1.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "error"); + assert.ok(out.error instanceof MeshyApiError); + assert.equal((out.error as MeshyApiError).status, 404); + assert.equal((out.error as MeshyApiError).code, "not_found"); + assert.equal(out.task, null); + } finally { + await closeServer(s1.server); + } + const s2 = await serve({ script: [{ delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS" }) }, { delay: 10, chunk: "event: message\ndata: {not json\n\n" }] }); + try { + const out = await streamTask(s2.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "protocol"); + assert.equal(out.task?.status, "IN_PROGRESS", "last good task is kept"); + } finally { + await closeServer(s2.server); + } + const s3 = await serve({ headers: { "content-type": "application/json" }, script: [{ delay: 5, chunk: '{"id":"t","status":"SUCCEEDED"}' }, { delay: 10, chunk: null }] }); + try { + const out = await streamTask(s3.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "protocol"); + assert.equal(out.task, null, "an application/json body is never treated as a successful task"); + } finally { + await closeServer(s3.server); + } + const s4 = await serve({ script: [{ delay: 5, chunk: ": hello\n\n" }, { delay: 10, chunk: null }] }); + try { + const out = await streamTask(s4.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "protocol"); + assert.match(out.error?.message ?? "", /without any task event/); + } finally { + await closeServer(s4.server); + } +}); + +test("T-051 disconnect before a terminal status keeps the last task and reports disconnected", async () => { + const s = await serve({ script: [{ delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS", progress: 55 }) }, { delay: 15, chunk: null }] }); + try { + const out = await streamTask(s.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 2000 }); + assert.equal(out.reason, "disconnected"); + assert.equal(out.task?.progress, 55); + } finally { + await closeServer(s.server); + } +}); + +test("T-052 heartbeats reset the idle timer but not the total deadline; timers are cleaned up", async () => { + const s = await serve({ + script: [ + { delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS", progress: 1 }) }, + ...Array.from({ length: 20 }, (_, i) => ({ delay: 60, chunk: i % 2 ? ": hb\n\n" : msg({ id: "t", status: "IN_PROGRESS", progress: 1 }) })), + ], + keepOpen: true, + }); + try { + const started = Date.now(); + const out = await streamTask(s.endpoint, "t", { timeoutMs: 400, idleTimeoutMs: 200 }); + assert.equal(out.reason, "timeout", "heartbeats every 60ms kept idle alive; the total deadline still fired"); + const elapsed = Date.now() - started; + assert.ok(elapsed >= 350 && elapsed < 2000, `elapsed ${elapsed}`); + const idle = await serve({ script: [{ delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS" }) }], keepOpen: true }); + try { + const out2 = await streamTask(idle.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 150 }); + assert.equal(out2.reason, "idle_timeout"); + assert.equal(out2.task?.status, "IN_PROGRESS"); + } finally { + await closeServer(idle.server); + } + } finally { + await closeServer(s.server); + } +}); + +test("an external abort mid-stream is reported as interrupted and closes the connection", async () => { + const s = await serve({ script: [{ delay: 5, chunk: msg({ id: "t", status: "IN_PROGRESS" }) }], keepOpen: true }); + try { + const ac = new AbortController(); + setTimeout(() => ac.abort(), 60); + const out = await streamTask(s.endpoint, "t", { timeoutMs: 5000, idleTimeoutMs: 5000, signal: ac.signal }); + assert.equal(out.reason, "interrupted"); + assert.equal(out.task?.status, "IN_PROGRESS"); + } finally { + await closeServer(s.server); + } +}); diff --git a/tests/task-lifecycle.test.ts b/tests/task-lifecycle.test.ts new file mode 100644 index 0000000..c69acdd --- /dev/null +++ b/tests/task-lifecycle.test.ts @@ -0,0 +1,292 @@ +/** + * Task verbs end to end (T-006, T-007, T-009, T-040, T-043..T-045, T-047, + * T-048, T-053): real subprocesses against a loopback API that records every + * request, so "exactly one POST" is asserted, not assumed. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { jsonReply, parseNdjson, parseSingleJson, runCli, startMockApi, tmpDir, type MockApi } from "./helpers/cli.js"; + +const SIX = ["schema_version", "command", "ok", "result", "error", "warnings"]; + +function taskBody(status: string, extra: Record = {}): Record { + return { id: "task-1", type: "text-to-3d-preview", status, progress: status === "SUCCEEDED" ? 100 : 40, created_at: 1, started_at: 2, finished_at: 0, expires_at: 0, task_error: null, ...extra }; +} + +function envOf(api: MockApi, extra: Record = {}) { + return api.env(extra); +} + +test("T-006 get: every valid status is a successful query (exit 0) in v1 and legacy; FAILED stays exit 1 in legacy only", async () => { + const statuses = ["PENDING", "IN_PROGRESS", "SUCCEEDED", "FAILED", "CANCELED", "SOMETHING_NEW"]; + let current = "PENDING"; + const api = await startMockApi((req, res) => { + if (req.method === "GET" && req.path === "/openapi/v2/text-to-3d/task-1") return jsonReply(res, 200, taskBody(current, { face_count: 0, consumed_credits: 0, novel: { x: 1 } })); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + for (const status of statuses) { + current = status; + const v1 = await runCli(["text-to-3d", "get", "task-1", "--output-schema", "v1", "--include-raw"], { env: envOf(api) }); + assert.equal(v1.code, 0, `${status}: ${v1.stderr}`); + const env = parseSingleJson(v1.stdout) as { ok: boolean; command: string; result: { task: Record; submission: unknown; downloads: { state: string } } }; + assert.deepEqual(Object.keys(env), SIX); + assert.equal(env.ok, true); + assert.equal(env.command, "text-to-3d.get"); + assert.equal(env.result.task["status"], status, "status is preserved verbatim"); + assert.equal(env.result.task["face_count"], 0, "0 stays 0"); + assert.equal(env.result.task["consumed_credits"], 0); + assert.deepEqual((env.result.task["raw"] as Record)["novel"], { x: 1 }); + assert.equal(env.result.downloads.state, "not_requested"); + + const legacy = await runCli(["text-to-3d", "get", "task-1"], { env: envOf(api) }); + const expected = status === "FAILED" || status === "CANCELED" ? 1 : 0; + assert.equal(legacy.code, expected, `legacy ${status}: ${legacy.stderr}`); + const payload = parseSingleJson(legacy.stdout) as Record; + assert.equal(payload["status"], status); + assert.equal(payload["resource"], "text-to-3d"); + } + // Missing face_count is null, never 0. + current = "SUCCEEDED"; + const api2 = await startMockApi((_req, res) => jsonReply(res, 200, taskBody("SUCCEEDED"))); + try { + const r = await runCli(["text-to-3d", "get", "task-1", "--output-schema", "v1"], { env: envOf(api2) }); + const env = parseSingleJson(r.stdout) as { result: { task: Record } }; + assert.equal(env.result.task["face_count"], null); + assert.equal(env.result.task["consumed_credits"], null); + } finally { + await api2.close(); + } + } finally { + await api.close(); + } +}); + +test("T-040 create --async: exactly one POST, zero GETs, accepted + id + operation id, journal written", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST" && req.path === "/openapi/v2/text-to-3d") return jsonReply(res, 200, { result: "task-new" }); + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const env = envOf(api); + const r = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "a cactus", "--async", "--output-schema", "v1"], { env }); + assert.equal(r.code, 0, r.stderr); + const out = parseSingleJson(r.stdout) as { ok: boolean; result: { task: unknown; submission: { state: string; operation_id: string; task_id: string }; task_id: string; next: Record } }; + assert.equal(out.ok, true); + assert.equal(out.result.task, null, "accepted but not queried: no fabricated PENDING task"); + assert.equal(out.result.submission.state, "accepted"); + assert.equal(out.result.submission.task_id, "task-new"); + assert.match(out.result.submission.operation_id, /^[0-9a-f-]{36}$/); + assert.match(out.result.next.wait, /text-to-3d wait task-new/); + assert.deepEqual(api.requests.map((q) => q.method), ["POST"]); + assert.deepEqual(api.requests[0]!.json, { mode: "preview", prompt: "a cactus", target_formats: ["glb"] }); + const ops = readdirSync(join(String(env["MESHY_CONFIG_DIR"]), "operations")).filter((f) => f.endsWith(".json")); + assert.equal(ops.length, 1); + const rec = JSON.parse(readFileSync(join(String(env["MESHY_CONFIG_DIR"]), "operations", ops[0]!), "utf8")) as { state: string; task_id: string }; + assert.equal(rec.state, "accepted"); + assert.equal(rec.task_id, "task-new"); + + // Legacy async keeps the 0.2.0 shape (plus operation_id) and also makes exactly one request. + const legacy = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "a cactus", "--async"], { env: envOf(api) }); + assert.equal(legacy.code, 0, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as Record; + assert.equal(lp["task_id"], "task-new"); + assert.equal(lp["status"], "PENDING"); + assert.match(String(lp["hint"]), /text-to-3d wait task-new/); + assert.deepEqual(api.requests.map((q) => q.method), ["POST", "POST"]); + } finally { + await api.close(); + } +}); + +test("T-043/T-044 create: 5xx and malformed 2xx are submission_unknown (exit 10) after exactly one POST; 4xx is a definite rejection", async () => { + let mode: "500" | "malformed" | "400" | "hang" = "500"; + const api = await startMockApi((req, res) => { + if (req.method !== "POST") return jsonReply(res, 404, { message: "nope" }); + if (mode === "500") return jsonReply(res, 500, { message: "boom" }); + if (mode === "malformed") return jsonReply(res, 200, { unexpected: true }); + if (mode === "400") return jsonReply(res, 400, { message: "prompt too long" }); + // hang: accept the request, never answer → client read timeout + }); + try { + for (const m of ["500", "malformed", "hang"] as const) { + mode = m; + api.requests.length = 0; + const r = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--async", "--output-schema", "v1"], { env: envOf(api, { MESHY_READ_TIMEOUT_MS: "500" }), timeoutMs: 15000 }); + assert.equal(r.code, 10, `${m}: ${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string; recovery: { action: string; automatic: boolean; command: string } }; result: { submission: { state: string; operation_id: string } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "submission_unknown"); + assert.equal(out.error.recovery.action, "reconcile"); + assert.equal(out.error.recovery.automatic, false); + assert.ok(!/create again|re-run|retry the create/i.test(r.stdout + r.stderr), "no advice to resubmit"); + assert.equal(out.result.submission.state, "unknown"); + assert.ok(out.result.submission.operation_id); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, `${m}: exactly one POST`); + } + mode = "400"; + api.requests.length = 0; + const rejected = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--async", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(rejected.code, 4, rejected.stderr); + const out = parseSingleJson(rejected.stdout) as { error: { code: string; http_status: number } }; + assert.equal(out.error.code, "validation"); + assert.equal(out.error.http_status, 400); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); + +test("T-046 --operation-id: the second run with the same request replays the journal and sends nothing", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "task-once" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const env = envOf(api); + const args = ["text-to-3d", "create", "--mode", "preview", "--prompt", "same", "--async", "--operation-id", "op-fixed", "--output-schema", "v1"]; + const first = await runCli(args, { env }); + assert.equal(first.code, 0, first.stderr); + const second = await runCli(args, { env }); + assert.equal(second.code, 0, second.stderr); + const out = parseSingleJson(second.stdout) as { result: { submission: { task_id: string } }; warnings: Array<{ code: string }> }; + assert.equal(out.result.submission.task_id, "task-once"); + assert.equal(out.warnings[0]?.code, "operation_replayed"); + assert.equal(api.requests.length, 1, "no second POST"); + // Same id, different payload → conflict, still no POST. + const conflict = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "different", "--async", "--operation-id", "op-fixed", "--output-schema", "v1"], { env }); + assert.equal(conflict.code, 2, conflict.stderr); + assert.equal((parseSingleJson(conflict.stdout) as { error: { code: string } }).error.code, "operation_conflict"); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); + +test("T-007 wait: FAILED with null task_error exits 1 with the task preserved; SUCCEEDED exits 0", async () => { + let n = 0; + const api = await startMockApi((req, res) => { + if (req.method !== "GET") return jsonReply(res, 404, { message: "nope" }); + n += 1; + return jsonReply(res, 200, taskBody(n < 3 ? "IN_PROGRESS" : "FAILED", { task_error: null })); + }); + try { + const r = await runCli(["text-to-3d", "wait", "task-1", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(r.code, 1, r.stderr); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string }; result: { task: { task_id: string; status: string }; wait: { polls: number } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "task_failed"); + assert.equal(out.result.task.task_id, "task-1"); + assert.equal(out.result.task.status, "FAILED"); + assert.equal(out.result.wait.polls, 3); + } finally { + await api.close(); + } +}); + +test("T-047 wait --timeout: 0 = one query then exit 8; negative/NaN/Infinity make no request", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, taskBody("IN_PROGRESS"))); + try { + const zero = await runCli(["text-to-3d", "wait", "task-1", "--timeout", "0", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(zero.code, 8, zero.stderr); + const out = parseSingleJson(zero.stdout) as { error: { code: string; recovery: { command: string } }; result: { task: { status: string }; wait: { timed_out: boolean; polls: number } } }; + assert.equal(out.error.code, "timed_out"); + assert.equal(out.result.task.status, "IN_PROGRESS"); + assert.equal(out.result.wait.timed_out, true); + assert.equal(out.result.wait.polls, 1); + assert.match(out.error.recovery.command, /wait task-1/); + assert.equal(api.requests.length, 1); + for (const bad of ["-1", "NaN", "Infinity", "abc", ""]) { + const r = await runCli(["text-to-3d", "wait", "task-1", "--timeout", bad, "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(r.code, 2, `${bad}: ${r.stderr}`); + } + assert.equal(api.requests.length, 1, "invalid timeouts never reach the API"); + } finally { + await api.close(); + } +}); + +test("T-009 get -o: non-terminal → downloads.not_ready, exit 0, no asset request; --save-json stores the raw task", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, taskBody("IN_PROGRESS", { model_urls: { glb: "http://127.0.0.1:9/never.glb" } }))); + try { + const dir = tmpDir(); + const r = await runCli(["text-to-3d", "get", "task-1", "-o", join(dir, "out"), "--save-json", join(dir, "task.json"), "--output-schema", "v1"], { env: envOf(api), cwd: dir }); + assert.equal(r.code, 0, r.stderr); + const out = parseSingleJson(r.stdout) as { result: { downloads: { state: string }; saved_json: { path: string } } }; + assert.equal(out.result.downloads.state, "not_ready"); + assert.deepEqual(JSON.parse(readFileSync(join(dir, "task.json"), "utf8")).id, "task-1"); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); + +test("T-048 SIGINT during wait exits 130, sends no DELETE and no second POST, keeps the task id", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "task-slow" }); + return jsonReply(res, 200, { ...taskBody("IN_PROGRESS"), id: "task-slow" }); + }); + try { + const r = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "slow", "--output-schema", "v1"], { env: envOf(api, { MESHY_POLL_INTERVAL_MS: "300" }), sigintAfterMs: 1200, timeoutMs: 15000 }); + assert.equal(r.code, 130, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { error: { code: string; recovery: { command: string } }; result: { task_id: string; submission: { state: string; task_id: string } } }; + assert.equal(out.error.code, "interrupted"); + assert.equal(out.result.task_id, "task-slow"); + assert.equal(out.result.submission.state, "accepted"); + assert.match(out.error.recovery.command, /wait task-slow/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + assert.equal(api.requests.filter((q) => q.method === "DELETE").length, 0); + } finally { + await api.close(); + } +}); + +test("T-053 stream: ndjson emits task events + one outcome; json emits one envelope; error event → exit 5", async () => { + const sse = (obj: unknown) => `event: message\ndata: ${JSON.stringify(obj)}\n\n`; + const api = await startMockApi((req, res, raw) => { + if (req.path === "/openapi/v2/text-to-3d/task-1/stream") { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write(": hello\n\n"); + setTimeout(() => res.write(sse(taskBody("IN_PROGRESS"))), 20); + setTimeout(() => res.write(sse(taskBody("SUCCEEDED", { model_urls: { glb: "https://assets.example.invalid/m.glb" } }))), 60); + raw.on("close", () => res.end()); + return; + } + if (req.path === "/openapi/v2/text-to-3d/missing/stream") { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(readFileSync(new URL("./fixtures/skill-parity/task-error.synthetic.sse", import.meta.url))); + return; + } + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const nd = await runCli(["text-to-3d", "stream", "task-1", "--format", "ndjson", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(nd.code, 0, nd.stderr); + const lines = parseNdjson(nd.stdout) as Array<{ event: string; sequence: number; ok: boolean; result: { task: { status: string } } }>; + assert.deepEqual(lines.map((l) => l.event), ["task", "task", "outcome"]); + assert.deepEqual(lines.map((l) => l.sequence), [1, 2, 3]); + assert.equal(lines[2]!.ok, true); + assert.equal(lines[2]!.result.task.status, "SUCCEEDED"); + for (const l of lines) assert.deepEqual(Object.keys(l).slice(0, 6), SIX); + assert.equal(nd.stderr.trim(), "", "ndjson keeps stderr quiet"); + + const js = await runCli(["text-to-3d", "stream", "task-1", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(js.code, 0, js.stderr); + const env = parseSingleJson(js.stdout) as { result: { stream: { events: number; ended: string } } }; + assert.equal(env.result.stream.events, 2); + assert.equal(env.result.stream.ended, "terminal"); + assert.match(js.stderr, /IN_PROGRESS/, "progress goes to stderr in json mode"); + + const missing = await runCli(["text-to-3d", "stream", "missing", "--output-schema", "v1"], { env: envOf(api) }); + assert.equal(missing.code, 5, missing.stderr); + const errEnv = parseSingleJson(missing.stdout) as { ok: boolean; error: { code: string; http_status: number } }; + assert.equal(errEnv.ok, false); + assert.equal(errEnv.error.code, "not_found"); + assert.equal(errEnv.error.http_status, 404); + } finally { + await api.close(); + } +}); diff --git a/tests/uv-creative-lab.test.ts b/tests/uv-creative-lab.test.ts new file mode 100644 index 0000000..e9f3917 --- /dev/null +++ b/tests/uv-creative-lab.test.ts @@ -0,0 +1,256 @@ +/** + * UV Unwrap (T-021), Creative Lab routing/validation (T-022..T-025), payload + * merge semantics (T-028) and make async/stop-after-first (T-041, T-042). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +function glbBytes(): Buffer { + const buf = Buffer.alloc(12); + buf.write("glTF", 0, "ascii"); + buf.writeUInt32LE(2, 4); + buf.writeUInt32LE(12, 8); + return buf; +} + +test("T-021 uv-unwrap: one source accepted, both/none/non-GLB refused before any request; exact v1 route", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST" && req.path === "/openapi/v1/uv-unwrap") return jsonReply(res, 200, { result: "uv-1" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const dir = tmpDir(); + const glb = join(dir, "m.glb"); + writeFileSync(glb, glbBytes()); + const obj = join(dir, "m.obj"); + writeFileSync(obj, "v 0 0 0\n"); + + const byTask = await runCli(["uv-unwrap", "create", "--input-task-id", "src-1", "--async"], { env: api.env(), cwd: dir }); + assert.equal(byTask.code, 0, byTask.stderr); + assert.deepEqual(api.requests.at(-1)!.json, { input_task_id: "src-1" }); + const out = parseSingleJson(byTask.stdout) as { command: string; result: { submission: { task_id: string } } }; + assert.equal(out.command, "uv-unwrap.create"); + assert.equal(out.result.submission.task_id, "uv-1"); + + const byFile = await runCli(["uv-unwrap", "create", "--model-url", glb, "--async"], { env: api.env(), cwd: dir }); + assert.equal(byFile.code, 0, byFile.stderr); + assert.match(String((api.requests.at(-1)!.json as { model_url: string }).model_url), /^data:model\/gltf-binary;base64,/); + + const before = api.requests.length; + const both = await runCli(["uv-unwrap", "create", "--input-task-id", "src-1", "--model-url", glb, "--async"], { env: api.env(), cwd: dir }); + assert.equal(both.code, 2, both.stderr); + const bothData = await runCli(["uv-unwrap", "create", "--input-task-id", "src-1", "--data", '{"model_url":"https://x.example/m.glb"}', "--async"], { env: api.env(), cwd: dir }); + assert.equal(bothData.code, 2, bothData.stderr); + const none = await runCli(["uv-unwrap", "create", "--async"], { env: api.env(), cwd: dir }); + assert.equal(none.code, 2); + const notGlb = await runCli(["uv-unwrap", "create", "--model-url", obj, "--async"], { env: api.env(), cwd: dir }); + assert.equal(notGlb.code, 2, notGlb.stderr); + assert.match((parseSingleJson(notGlb.stdout) as { error: { message: string } }).error.message, /glb only/); + assert.equal(api.requests.length, before, "invalid combinations never reach the API"); + + // 404 is reported as not_found without guessing why. + const gated = await startMockApi((_req, res) => jsonReply(res, 404, { message: "Not Found" })); + try { + const r = await runCli(["uv-unwrap", "create", "--input-task-id", "src-1", "--async"], { env: gated.env() }); + assert.equal(r.code, 5, r.stderr); + const e = parseSingleJson(r.stdout) as { error: { code: string; message: string } }; + assert.equal(e.error.code, "not_found"); + assert.ok(!/rollout|unauthori|not enabled/i.test(e.error.message)); + } finally { + await gated.close(); + } + // list/get/stream/delete routes exist. + const list = await runCli(["uv-unwrap", "list"], { env: api.env() }); + assert.equal(api.requests.at(-1)!.path, "/openapi/v1/uv-unwrap"); + assert.equal(list.code, 5); // mock answers 404 for GET list; the route is what matters here + } finally { + await api.close(); + } +}); + +test("T-022/T-025 Creative Lab: exact product/v1/stage paths and per-product payloads", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST" && /^\/openapi\/creative-lab\/(figure|lamp|keychain|fridge-magnet)\/v1\/(prototype|build)$/.test(req.path)) return jsonReply(res, 200, { result: "cl-1" }); + if (req.method === "GET" && /\/v1\/(prototype|build)\/cl-1$/.test(req.path)) return jsonReply(res, 200, { id: "cl-1", status: "SUCCEEDED", progress: 100, type: "creative-lab-x" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const dir = tmpDir(); + const png = join(dir, "p.png"); + writeFileSync(png, await sharp({ create: { width: 2, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } } }).png().toBuffer()); + + for (const product of ["figure", "lamp", "keychain", "fridge-magnet"]) { + const r = await runCli(["creative-lab", product, "prototype", "create", "--image-url", png, "--name", "demo", "--async"], { env: api.env(), cwd: dir }); + assert.equal(r.code, 0, `${product}: ${r.stderr}`); + const req = api.requests.at(-1)!; + assert.equal(req.path, `/openapi/creative-lab/${product}/v1/prototype`); + const body = req.json as Record; + assert.match(String(body.image_url), /^data:image\/png;base64,/); + assert.equal(body.name, "demo"); + assert.equal("remove_background" in body, false, "not sent unless asked"); + const out = parseSingleJson(r.stdout) as { command: string }; + assert.equal(out.command, `creative-lab.${product}.prototype.create`); + + const g = await runCli(["creative-lab", product, "build", "get", "cl-1"], { env: api.env() }); + assert.equal(g.code, 0, g.stderr); + assert.equal(api.requests.at(-1)!.path, `/openapi/creative-lab/${product}/v1/build/cl-1`); + } + + // lamp prototype: image_subject; deprecated text refused; remove-background flag sent as true. + const lamp = await runCli(["creative-lab", "lamp", "prototype", "create", "--image-url", png, "--image-subject", "landscape", "--remove-background", "--async"], { env: api.env(), cwd: dir }); + assert.equal(lamp.code, 0, lamp.stderr); + assert.equal((api.requests.at(-1)!.json as Record).image_subject, "landscape"); + assert.equal((api.requests.at(-1)!.json as Record).remove_background, true); + const n = api.requests.length; + const lampText = await runCli(["creative-lab", "lamp", "prototype", "create", "--data", '{"text":"a lamp"}', "--async"], { env: api.env(), cwd: dir }); + assert.equal(lampText.code, 2, lampText.stderr); + assert.equal(api.requests.length, n); + + // lamp build: options + output.format; include_result_json needs zip. + const lampBuild = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "cl-1", "--model-format", "zip", "--include-result-json", "--options", '{"diameter_mm":180,"light_source_preset":"none"}', "--async"], { env: api.env() }); + assert.equal(lampBuild.code, 0, lampBuild.stderr); + assert.deepEqual(api.requests.at(-1)!.json, { input_task_id: "cl-1", options: { diameter_mm: 180, light_source_preset: "none", include_result_json: true }, output: { format: "zip" } }); + const lampBad = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "cl-1", "--include-result-json", "--async"], { env: api.env() }); + assert.equal(lampBad.code, 2, lampBad.stderr); + const lampRange = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "cl-1", "--options", '{"diameter_mm":10}', "--async"], { env: api.env() }); + assert.equal(lampRange.code, 2, lampRange.stderr); + assert.match((parseSingleJson(lampRange.stdout) as { error: { message: string } }).error.message, /diameter_mm/); + + // keychain build: relief options with explicit false preserved; obj format allowed; figure has no options. + const kc = await runCli(["creative-lab", "keychain", "build", "create", "--input-task-id", "cl-1", "--model-format", "obj", "--data", '{"options":{"has_closed_back":false,"remove_background":false,"badge_shape":"star"}}', "--async"], { env: api.env() }); + assert.equal(kc.code, 0, kc.stderr); + assert.deepEqual(api.requests.at(-1)!.json, { input_task_id: "cl-1", options: { has_closed_back: false, remove_background: false, badge_shape: "star" }, output: { format: "obj" } }); + const kcBad = await runCli(["creative-lab", "keychain", "build", "create", "--input-task-id", "cl-1", "--data", '{"options":{"badge_shape":"triangle"}}', "--async"], { env: api.env() }); + assert.equal(kcBad.code, 2, kcBad.stderr); + const fig = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "cl-1", "--data", '{"options":{"x":1}}', "--async"], { env: api.env() }); + assert.equal(fig.code, 2, fig.stderr); + const figFmt = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "cl-1", "--model-format", "glb", "--async"], { env: api.env() }); + assert.equal(figFmt.code, 2, "figure build has no --model-format"); + const noStage = await runCli(["creative-lab", "figure", "get", "cl-1"], { env: api.env() }); + assert.equal(noStage.code, 2, "there is no stage-less get"); + const longName = await runCli(["creative-lab", "figure", "prototype", "create", "--image-url", png, "--name", "x".repeat(101), "--async"], { env: api.env(), cwd: dir }); + assert.equal(longName.code, 2); + } finally { + await api.close(); + } +}); + +test("T-023 Creative Lab: injected product/stage tokens never build a path", async () => { + const api = await startMockApi((_req, res) => jsonReply(res, 200, { result: "x" })); + try { + for (const args of [ + ["creative-lab", "../figure", "prototype", "create", "--image-url", "https://x.example/a.png", "--async"], + ["creative-lab", "figure", "../build", "create", "--input-task-id", "a", "--async"], + ["creative-lab", "keycap", "prototype", "create", "--image-url", "https://x.example/a.png", "--async"], + ["creative-lab", "figure prototype", "create"], + ]) { + const r = await runCli(args, { env: api.env() }); + assert.equal(r.code, 2, args.join(" ")); + } + assert.equal(api.requests.length, 0); + } finally { + await api.close(); + } +}); + +test("T-024 Creative Lab build: server rejections are preserved, no retry and no prototype re-creation", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST" && req.path === "/openapi/creative-lab/figure/v1/build") return jsonReply(res, 404, { message: "prototype task not found" }); + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const r = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "webapp-proto", "--async"], { env: api.env() }); + assert.equal(r.code, 5, r.stderr); + const out = parseSingleJson(r.stdout) as { error: { code: string; message: string } }; + assert.equal(out.error.code, "not_found"); + assert.match(out.error.message, /prototype task not found/); + assert.deepEqual(api.requests.map((q) => `${q.method} ${q.path}`), ["POST /openapi/creative-lab/figure/v1/build"]); + } finally { + await api.close(); + } +}); + +test("T-028 --data merge: flags beat JSON, JSON beats defaults, explicit false/0 survive, arrays replace, non-object refused", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "t" }) : jsonReply(res, 404, {}))); + try { + const r = await runCli( + ["text-to-3d", "create", "--mode", "refine", "--preview-task-id", "p1", "--texture-resolution", "2k", "--data", '{"enable_pbr":false,"texture_resolution":"8k","target_formats":["obj","fbx"],"seed":0}', "--async"], + { env: api.env() }, + ); + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(api.requests[0]!.json, { + mode: "refine", + preview_task_id: "p1", + enable_pbr: false, // --data switches a default off + texture_resolution: "2k", // typed flag wins over --data + remove_lighting: true, // untouched default + target_formats: ["obj", "fbx"], // array replaced wholesale + seed: 0, // explicit 0 kept + }); + const arr = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--data", "[1]", "--async"], { env: api.env() }); + assert.equal(arr.code, 2); + const bad = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--data", "{oops", "--async"], { env: api.env() }); + assert.equal(bad.code, 2); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); + +test("T-041/T-042 make: --async is one POST and zero polls with pending_steps; --stop-after-first polls step 1 only; both flags refused", async () => { + let gets = 0; + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "prev-1" }); + gets += 1; + return jsonReply(res, 200, { id: "prev-1", status: "SUCCEEDED", progress: 100, type: "text-to-3d-preview" }); + }); + try { + const a = await runCli(["make", "a red sports car", "--async", "--output-schema", "v1"], { env: api.env() }); + assert.equal(a.code, 0, a.stderr); + const out = parseSingleJson(a.stdout) as { result: { submitted: { task_id: string }; pending_steps: Array<{ step: number; action: string; command: string | null }>; task: unknown } }; + assert.equal(out.result.submitted.task_id, "prev-1"); + assert.equal(out.result.task, null); + assert.equal(out.result.pending_steps.length, 1); + assert.equal(out.result.pending_steps[0]!.action, "refine"); + assert.deepEqual(api.requests.map((q) => q.method), ["POST"]); + assert.equal(gets, 0); + + api.requests.length = 0; + const legacyAsync = await runCli(["make", "a red sports car", "--async"], { env: api.env() }); + assert.equal(legacyAsync.code, 0, legacyAsync.stderr); + const lp = parseSingleJson(legacyAsync.stdout) as Record; + assert.equal(lp["task_id"], "prev-1"); + assert.equal(lp["submitted"], "preview"); + assert.deepEqual(api.requests.map((q) => q.method), ["POST"]); + + api.requests.length = 0; + const stop = await runCli(["make", "a red sports car", "--stop-after-first", "--output-schema", "v1"], { env: api.env() }); + assert.equal(stop.code, 0, stop.stderr); + const so = parseSingleJson(stop.stdout) as { result: { stopped_after: { action: string; status: string }; resume: string } }; + assert.equal(so.result.stopped_after.action, "preview"); + assert.equal(so.result.stopped_after.status, "SUCCEEDED"); + assert.match(so.result.resume, /--preview-task-id prev-1/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, "refine was not started"); + assert.ok(api.requests.some((q) => q.method === "GET")); + + const both = await runCli(["make", "a red sports car", "--async", "--stop-after-first"], { env: api.env() }); + assert.equal(both.code, 2); + + // Image route with a URL: async is one POST, no preflight of the mock is needed for data URIs. + api.requests.length = 0; + const dir = tmpDir(); + const png = join(dir, "cat.png"); + writeFileSync(png, await sharp({ create: { width: 2, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } } }).png().toBuffer()); + const img = await runCli(["make", png, "--async", "--output-schema", "v1"], { env: api.env(), cwd: dir }); + assert.equal(img.code, 0, img.stderr); + assert.deepEqual(api.requests.map((q) => `${q.method} ${q.path}`), ["POST /openapi/v1/image-to-3d"]); + assert.equal((parseSingleJson(img.stdout) as { result: { pending_steps: unknown[] } }).result.pending_steps.length, 0); + } finally { + await api.close(); + } +}); From 3935f350fe09a24ff124ed907eea73737b75b9e9 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 15:25:43 +0800 Subject: [PATCH 05/26] feat(download): B04 asset enumeration, selective download and safe file placement - artifacts.ts: stable asset keys for model formats (lamp parts as STL/ZIP, keychain/fridge-magnet OBJ as an unextracted ZIP bundle), images, textures, primary/multi-view/alpha thumbnails, rig + nested basic_animations, motion clips, printability reports; unknown URLs are listed, never fetched; OBJ declares MTL/texture dependencies; legacy key aliases - download.ts: fetchToTemp with http(s)-only, no credentials, hop-by-hop redirect re-validation, private-network refusal, streaming size cap and sha256; MIME-driven extension reconciliation; magic/HTML content checks; exclusive publish via link with realpath containment (leaf symlinks refused before fetching); legacy -o wrapper keeps the 0.2.0 layout on the same core - meshy download: sources task-json | url | resource+task-id, selectors --asset/--model-format/--kind/--all/--list, --output vs --output-dir, --overwrite, --with-dependencies/--geometry-only, one bounded URL refresh for API sources, partial manifests on failure - tests: enumeration/selection units and black-box downloads (T-060..T-071) Co-Authored-By: Claude Fable 5.1 --- src/cmd/download.ts | 271 +++++++++++++ src/cmd/resources.ts | 1 + src/internal/artifacts.ts | 311 +++++++++++++++ src/internal/download.ts | 684 ++++++++++++++++++++++++++------- src/root.ts | 2 + tests/artifacts.test.ts | 118 ++++++ tests/download-command.test.ts | 316 +++++++++++++++ tests/task-lifecycle.test.ts | 7 +- 8 files changed, 1578 insertions(+), 132 deletions(-) create mode 100644 src/cmd/download.ts create mode 100644 src/internal/artifacts.ts create mode 100644 tests/artifacts.test.ts create mode 100644 tests/download-command.test.ts diff --git a/src/cmd/download.ts b/src/cmd/download.ts new file mode 100644 index 0000000..6c3bde3 --- /dev/null +++ b/src/cmd/download.ts @@ -0,0 +1,271 @@ +/** + * download — selective asset download for a task. + * + * Sources (exactly one): --task-json (API task, legacy meta.json or a + * v1 envelope/result), --url , or --resource --task-id + * (one GET, then the assets). Selection (at most one): --asset …, + * --model-format , --kind , --all; with several assets and no + * selector the command lists the candidates and exits 2 instead of guessing. + * + * Output: --output for exactly one asset, --output-dir otherwise. + * Files are published exclusively; --overwrite replaces atomically. Nothing + * is extracted, nothing is re-generated: an expired URL from a task JSON is + * reported as such, an API-sourced task is refreshed once. + */ + +import { Command, Option } from "commander"; +import { readFileSync, statSync } from "node:fs"; +import { basename, dirname, resolve as resolvePath } from "node:path"; +import { findTaskResource, TASK_RESOURCES, type TaskResourceDescriptor } from "../client/resource-registry.js"; +import { enumerateAssets, selectAssets, SelectionError, type Asset, type AssetKind } from "../internal/artifacts.js"; +import { emitResult, openCommand, saveRawJson, type OpenedCommand } from "../internal/command-helpers.js"; +import { abortSignal } from "../internal/context.js"; +import { downloadAssets, type DownloadedFile } from "../internal/download.js"; +import { CliError, UsageError, type Warning } from "../internal/errors.js"; +import { safeSegment } from "../internal/paths.js"; +import { warning } from "../internal/result.js"; +import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; +import { extractTaskObject } from "../internal/task-view.js"; + +const TASK_JSON_MAX_BYTES = 16 * 1024 * 1024; +const KINDS: readonly AssetKind[] = ["model", "image", "texture", "thumbnail", "rig", "animation", "motion", "report"]; + +interface DownloadOpts { + taskJson?: string; + url?: string; + resource?: string; + taskId?: string; + asset?: string[]; + modelFormat?: string; + kind?: string; + all?: boolean; + list?: boolean; + outputDir?: string; + overwrite?: boolean; + withDependencies?: boolean; + geometryOnly?: boolean; + saveJson?: string; + includeRaw?: boolean; +} + +function collect(v: string, prev: string[] = []): string[] { + return [...prev, v]; +} + +function resourceForTask(task: Record, hint: string | null): TaskResourceDescriptor | null { + if (hint) { + const d = findTaskResource(hint); + if (d) return d; + } + const type = task["type"]; + if (typeof type !== "string") return null; + return TASK_RESOURCES.find((d) => d.taskTypes.includes(type)) ?? null; +} + +function readTaskJson(path: string): { task: Record; resourceHint: string | null; shape: string } { + const abs = resolvePath(path); + let st: ReturnType; + try { + st = statSync(abs); + } catch { + throw new UsageError(`--task-json: file not found: ${path}`); + } + if (!st.isFile()) throw new UsageError(`--task-json: not a regular file: ${path}`); + if (st.size > TASK_JSON_MAX_BYTES) throw new UsageError(`--task-json: ${path} is larger than ${TASK_JSON_MAX_BYTES} bytes`); + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(abs, "utf8")); + } catch (err) { + throw new UsageError(`--task-json: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)})`); + } + const extracted = extractTaskObject(parsed); + if (!extracted) throw new UsageError(`--task-json: ${path} does not contain a task (expected an API task, a meta.json, or a v1 envelope)`); + const o = parsed as Record; + let hint: string | null = null; + if (extracted.source === "meta.json" && typeof o["resource"] === "string") hint = o["resource"] as string; + const view = (o["result"] as Record | undefined)?.["task"] as Record | undefined; + if ((extracted.source === "v1-envelope" || extracted.source === "v1-result") && typeof view?.["resource"] === "string") hint = view["resource"] as string; + return { task: extracted.task, resourceHint: hint, shape: extracted.source }; +} + +export const downloadCommand = new Command("download") + .description("Download selected assets of a task (from a saved task JSON, a URL, or the API) with explicit selection and safe file placement") + .option("--task-json ", "task JSON saved earlier (API task, meta.json or v1 envelope)") + .option("--url ", "download exactly one asset URL (no task context)") + .option("--resource ", "with --task-id: fetch the task from the API first (one GET)") + .option("--task-id ", "with --resource: the task to fetch") + .option("--asset ", "stable asset key to download (repeatable), e.g. model.glb, thumbnail.primary, result.basic_animations.walking_glb_url", collect) + .option("--model-format ", "select the model of this format (glb, obj, fbx, usdz, stl, 3mf, …)") + .addOption(new Option("--kind ", "select every asset of one kind").choices([...KINDS])) + .option("--all", "select every asset") + .option("--list", "list the task's assets and exit without downloading") + .option("--output-dir ", "directory to write into (for several assets); mutually exclusive with --output/-o") + .option("--overwrite", "replace existing files atomically (never directories or symlinks)") + .option("--with-dependencies", "for OBJ selections also fetch the MTL and textures (default)") + .option("--geometry-only", "for OBJ selections fetch the OBJ alone") + .option("--save-json ", "save the raw task JSON (API source) alongside") + .option("--include-raw", "v1: include the raw task under result.source.raw") + .action(async (opts: DownloadOpts, thisCmd: Command) => { + const opened = openCommand(thisCmd, "download", "v1"); + const sources = [opts.taskJson ? "task-json" : null, opts.url ? "url" : null, opts.resource || opts.taskId ? "api" : null].filter(Boolean); + if (sources.length !== 1) throw new UsageError("provide exactly one source: --task-json , --url , or --resource --task-id "); + if ((opts.resource && !opts.taskId) || (!opts.resource && opts.taskId)) throw new UsageError("--resource and --task-id go together"); + const selectors = [opts.asset?.length ? "asset" : null, opts.modelFormat ? "model-format" : null, opts.kind ? "kind" : null, opts.all ? "all" : null].filter(Boolean); + if (selectors.length > 1) throw new UsageError(`selectors are mutually exclusive (got ${selectors.map((s) => `--${s}`).join(", ")})`); + if (opts.withDependencies && opts.geometryOnly) throw new UsageError("--with-dependencies and --geometry-only are mutually exclusive"); + if (opened.flags.output && opts.outputDir) throw new UsageError("--output/-o and --output-dir are mutually exclusive"); + const warnings: Warning[] = []; + + // ---- source ---- + let task: Record | null = null; + let raw: unknown = null; + let descriptor: TaskResourceDescriptor | null = null; + let sourceInfo: Record; + let refreshUrls: (() => Promise | null>) | undefined; + + if (opts.url) { + buildLocalRuntime(opened.flags); + sourceInfo = { kind: "url", url: opts.url.split("?")[0] }; + if (opts.list || selectors.length > 0) throw new UsageError("--url downloads exactly one URL; selectors and --list do not apply"); + } else if (opts.taskJson) { + buildLocalRuntime(opened.flags); + const read = readTaskJson(opts.taskJson); + task = read.task; + raw = read.task; + descriptor = resourceForTask(task, read.resourceHint); + sourceInfo = { kind: "task-json", path: resolvePath(opts.taskJson), shape: read.shape, resource: descriptor?.id ?? null, task_id: task["id"] ?? null }; + } else { + const d = findTaskResource(opts.resource!); + if (!d) throw new UsageError(`unknown resource '${opts.resource}'; run \`meshy resources\``); + descriptor = d; + const runtime = await buildRuntime(opened.flags); + const endpoint = runtime.client.endpointFor(d); + const got = await endpoint.retrieveDetailed(opts.taskId!, { signal: abortSignal() }); + task = got.raw as Record; + raw = got.raw; + sourceInfo = { kind: "api", resource: d.id, task_id: opts.taskId, status: (task["status"] as string | undefined) ?? null }; + refreshUrls = async () => { + const again = await endpoint.retrieveDetailed(opts.taskId!, { signal: abortSignal() }); + const fresh = enumerateAssets(again.raw as Record, d); + return new Map(fresh.assets.filter((a) => a.url).map((a) => [a.key, a.url!] as const)); + }; + } + const savedJson = opts.saveJson && raw ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + + // ---- enumerate + select ---- + let selected: Asset[]; + let dependencies: Asset[] = []; + let enumeration: ReturnType | null = null; + if (opts.url) { + const name = safeSegment(basename(new URL(opts.url).pathname) || "asset", "asset"); + selected = [{ key: "url", kind: "model", url: opts.url, format: null, containerFormat: null, modelFormat: null, sourcePath: "url", filename: name, dependencies: [] }]; + // Kind is unknown for a bare URL: content validation only checks for HTML pages. + selected[0]!.kind = "image"; + } else { + enumeration = enumerateAssets(task!, descriptor); + if (task!["status"] !== "SUCCEEDED" && enumeration.assets.length === 0) { + await emitResult(opened, null, { source: sourceInfo, assets: [], downloads: { state: "not_ready", files: [], metadata_path: null }, saved_json: savedJson, unknown_urls: enumeration.unknown_urls }, { + warnings: [warning("task_not_ready", `task status is ${String(task!["status"])}; no assets to download yet`)], + }); + return; + } + if (opts.list) { + await emitResult(opened, null, { + source: sourceInfo, + assets: enumeration.assets.map(describeAsset), + unknown_urls: enumeration.unknown_urls, + product: enumeration.product, + saved_json: savedJson, + ...(opts.includeRaw ? { raw } : {}), + }); + return; + } + const withDeps = !opts.geometryOnly; + try { + const sel = selectors.length === 0 + ? (enumeration.assets.length === 1 + ? { keys: [enumeration.assets[0]!.key] } + : (() => { + throw new SelectionError(`task exposes ${enumeration.assets.length} assets; choose with --asset, --model-format, --kind or --all`, enumeration.assets); + })()) + : { keys: opts.asset, modelFormat: opts.modelFormat, kind: opts.kind as AssetKind | undefined, all: opts.all }; + const result = selectAssets(enumeration, sel, { withDependencies: withDeps }); + selected = result.selected; + dependencies = result.dependencies; + for (const m of result.missingDependencies) warnings.push(warning("material_dependency_missing", `${m} is not available in this task; the OBJ will be delivered without it`)); + } catch (err) { + if (err instanceof SelectionError) { + throw new CliError({ code: "usage", message: err.message, result: { source: sourceInfo, assets: err.candidates } }); + } + throw err; + } + if (opts.geometryOnly && selected.some((a) => a.modelFormat === "obj" && a.containerFormat === null)) { + warnings.push(warning("geometry_only", "OBJ delivered without its MTL/textures as requested")); + } + } + + const toDownload = [...selected, ...dependencies]; + // ---- output placement ---- + const outputFile = opened.flags.output; + const outputDir = opts.outputDir; + if (!outputFile && !outputDir) throw new UsageError("pass --output (single asset) or --output-dir "); + if (outputFile && toDownload.length > 1) { + throw new UsageError(`${toDownload.length} files would be written (${toDownload.map((a) => a.key).join(", ")}); --output names one file — use --output-dir `); + } + const dir = outputFile ? dirname(resolvePath(outputFile)) : resolvePath(outputDir!); + const root = opened.flags.workspace ? resolvePath(opened.flags.workspace) : dir; + + const files: DownloadedFile[] = []; + let result; + try { + result = await downloadAssets(toDownload, { + targetFile: outputFile ? resolvePath(outputFile) : undefined, + targetDir: outputFile ? undefined : dir, + overwrite: Boolean(opts.overwrite), + root, + signal: abortSignal(), + refreshUrls, + onFile: (f) => files.push(f), + }); + } catch (err) { + if (err instanceof CliError) { + const expired = err.httpStatus === 401 || err.httpStatus === 403 || err.httpStatus === 410; + const hint = expired && !refreshUrls && descriptor && task + ? `signed URL rejected and a task JSON cannot refresh it; re-fetch with \`meshy ${descriptor.commandPath.join(" ")} get ${String(task["id"])} --save-json --output-schema v1\` (no new task is created)` + : undefined; + throw new CliError({ + code: err.code, + message: hint ? `${err.message}. ${hint}` : err.message, + httpStatus: err.httpStatus, + recovery: hint && descriptor && task ? { action: "refresh_task", automatic: false, command: `meshy ${descriptor.commandPath.join(" ")} get ${String(task["id"])} --save-json --output-schema v1` } : err.recovery, + result: { source: sourceInfo, ...(err.result ?? {}), saved_json: savedJson }, + warnings: [...warnings, ...err.warnings], + cause: err, + }); + } + throw err; + } + warnings.push(...result.warnings.map((w) => warning(w.code, w.message))); + await emitResult(opened, null, { + source: sourceInfo, + selection: { selected: selected.map((a) => a.key), dependencies: dependencies.map((a) => a.key) }, + downloads: { state: result.complete ? "completed" : "partial", files: result.files, metadata_path: null }, + unknown_urls: enumeration?.unknown_urls ?? [], + saved_json: savedJson, + ...(opts.includeRaw ? { raw } : {}), + }, { warnings }); + }); + +function describeAsset(a: Asset): Record { + return { + key: a.key, + kind: a.kind, + format: a.format, + model_format: a.modelFormat, + container_format: a.containerFormat, + filename: a.filename, + dependencies: a.dependencies, + has_url: a.url !== null, + ...(a.notes ?? {}), + }; +} diff --git a/src/cmd/resources.ts b/src/cmd/resources.ts index 09659f8..0e054ea 100644 --- a/src/cmd/resources.ts +++ b/src/cmd/resources.ts @@ -46,6 +46,7 @@ export const RESOURCES: ResourceEntry[] = [ { name: "creative-lab", summary: "photo → printable product: figure | lamp | keychain | fridge-magnet (prototype then build)" }, { name: "animation-catalog", summary: "public animation library (action ids); no key needed" }, { name: "showcases", summary: "Enterprise community showcases (every request is billed)" }, + { name: "download", summary: "download selected assets of a task (saved task JSON, URL, or API)" }, ]; const VERBS = `Every task resource carries the same verbs: diff --git a/src/internal/artifacts.ts b/src/internal/artifacts.ts new file mode 100644 index 0000000..ac05d16 --- /dev/null +++ b/src/internal/artifacts.ts @@ -0,0 +1,311 @@ +/** + * Asset enumeration — which files a task exposes, under stable keys. + * + * Only known response fields are walked; an unknown string URL anywhere in + * `result` is reported under `unknown_urls` and never downloaded. Keys are + * the stable selectors of `meshy download --asset` and of the v1 download + * manifest; the file name is derived separately and never from the key or + * the URL alone. + * + * Product knowledge that changes the delivery format lives here too: + * - lamp build: `lamp_stl` / `base_stl` are STL files, `bundle_zip` is a ZIP; + * - keychain / fridge-magnet build: `model_urls.obj` is a ZIP bundle + * (model.obj + model.mtl + texture.png), delivered as `.zip`, unextracted; + * - motion clips carry their format in `result.motion_format`. + */ + +import type { TaskResourceDescriptor } from "../client/resource-registry.js"; +import { safeExtension, safeSegment } from "./paths.js"; + +export type AssetKind = "model" | "image" | "texture" | "thumbnail" | "rig" | "animation" | "motion" | "report"; + +export interface Asset { + /** Stable selector, e.g. `model.glb`, `thumbnail.primary`, `result.basic_animations.walking_glb_url`. */ + key: string; + kind: AssetKind; + url: string | null; + /** File format the bytes are expected to be in (extension without dot), or null when unknown. */ + format: string | null; + /** Set when the file is a container for the nominal format (keychain OBJ is a ZIP). */ + containerFormat: string | null; + /** Nominal model format when it differs from the file (e.g. `obj` inside a ZIP). */ + modelFormat: string | null; + /** Response path the asset came from. */ + sourcePath: string; + /** Suggested safe file name (directory mode). */ + filename: string; + /** Keys this asset needs to be usable (OBJ → MTL → textures). */ + dependencies: string[]; + /** Report assets carry their JSON here instead of a URL. */ + report?: unknown; + /** Extra flags for the manifest (e.g. extracted:false for bundles). */ + notes?: Record; +} + +export interface AssetEnumeration { + assets: Asset[]; + /** String URLs found in `result` that no rule recognised. */ + unknown_urls: Array<{ path: string; url: string }>; + product: string | null; +} + +const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "webp", "gif"]); + +function isUrl(v: unknown): v is string { + return typeof v === "string" && /^https?:\/\//i.test(v); +} + +/** Creative Lab product from a task type such as `creative-lab-keychain-build`. */ +export function productFromTaskType(type: unknown): { product: string; stage: string } | null { + if (typeof type !== "string") return null; + const m = /^creative-lab-([a-z-]+?)-(prototype|build)$/.exec(type); + return m ? { product: m[1]!, stage: m[2]! } : null; +} + +function modelAsset(fmtKey: string, url: string, product: string | null): Asset { + const key = `model.${fmtKey}`; + const sourcePath = `model_urls.${fmtKey}`; + switch (fmtKey) { + case "lamp_stl": + return { key, kind: "model", url, format: "stl", containerFormat: null, modelFormat: "stl", sourcePath, filename: "lamp.stl", dependencies: [], notes: { part: "lampshade" } }; + case "base_stl": + return { key, kind: "model", url, format: "stl", containerFormat: null, modelFormat: "stl", sourcePath, filename: "base.stl", dependencies: [], notes: { part: "fixture_base" } }; + case "bundle_zip": + return { key, kind: "model", url, format: "zip", containerFormat: "zip", modelFormat: null, sourcePath, filename: "bundle.zip", dependencies: [], notes: { extracted: false } }; + case "pre_remeshed_glb": + return { key, kind: "model", url, format: "glb", containerFormat: null, modelFormat: "glb", sourcePath, filename: "model.pre_remeshed.glb", dependencies: [] }; + default: { + const ext = safeExtension(fmtKey) || null; + if (ext === "obj" && (product === "keychain" || product === "fridge-magnet")) { + return { key, kind: "model", url, format: "zip", containerFormat: "zip", modelFormat: "obj", sourcePath, filename: "model.obj.zip", dependencies: [], notes: { extracted: false, bundle_contents: ["model.obj", "model.mtl", "texture.png"] } }; + } + const deps: string[] = ext === "obj" ? ["model.mtl"] : []; + return { key, kind: "model", url, format: ext, containerFormat: null, modelFormat: ext, sourcePath, filename: ext ? `model.${ext}` : `model.${safeSegment(fmtKey)}`, dependencies: deps }; + } + } +} + +/** + * Enumerate every downloadable asset of a task object (raw API shape). + * `descriptor` (when known) supplies the product for Creative Lab builds. + */ +export function enumerateAssets(task: Record, descriptor?: TaskResourceDescriptor | null): AssetEnumeration { + const assets: Asset[] = []; + const unknown: Array<{ path: string; url: string }> = []; + const product = descriptor?.creativeLab?.product ?? productFromTaskType(task["type"])?.product ?? null; + + const modelUrls = task["model_urls"]; + if (modelUrls && typeof modelUrls === "object" && !Array.isArray(modelUrls)) { + for (const [fmt, url] of Object.entries(modelUrls as Record)) { + if (isUrl(url)) assets.push(modelAsset(fmt, url, product)); + } + } + // OBJ depends on MTL only when the MTL exists in this task. + const hasMtl = assets.some((a) => a.key === "model.mtl"); + for (const a of assets) { + if (a.key === "model.obj" && a.containerFormat === null) { + a.dependencies = hasMtl ? ["model.mtl", ...textureKeys(task)] : []; + } + } + + if (isUrl(task["thumbnail_url"])) { + assets.push({ key: "thumbnail.primary", kind: "thumbnail", url: task["thumbnail_url"], format: "png", containerFormat: null, modelFormat: null, sourcePath: "thumbnail_url", filename: "thumbnail.png", dependencies: [] }); + } + const thumbs = task["thumbnail_urls"]; + if (thumbs && typeof thumbs === "object") { + if (Array.isArray(thumbs)) { + thumbs.forEach((url, i) => { + if (isUrl(url)) assets.push({ key: `thumbnail.${i}`, kind: "thumbnail", url, format: "png", containerFormat: null, modelFormat: null, sourcePath: `thumbnail_urls[${i}]`, filename: `thumbnail_${i}.png`, dependencies: [] }); + }); + } else { + for (const [view, url] of Object.entries(thumbs as Record)) { + if (isUrl(url)) assets.push({ key: `thumbnail.${safeSegment(view)}`, kind: "thumbnail", url, format: "png", containerFormat: null, modelFormat: null, sourcePath: `thumbnail_urls.${view}`, filename: `thumbnail_${safeSegment(view)}.png`, dependencies: [], notes: { view } }); + } + } + } + if (isUrl(task["alpha_thumbnail_url"])) { + assets.push({ key: "thumbnail.alpha", kind: "thumbnail", url: task["alpha_thumbnail_url"], format: "png", containerFormat: null, modelFormat: null, sourcePath: "alpha_thumbnail_url", filename: "thumbnail_alpha.png", dependencies: [] }); + } + + const textures = task["texture_urls"]; + if (Array.isArray(textures)) { + textures.forEach((set, i) => { + if (!set || typeof set !== "object") return; + for (const [channel, url] of Object.entries(set as Record)) { + if (isUrl(url)) { + assets.push({ key: `texture.${i}.${safeSegment(channel)}`, kind: "texture", url, format: "png", containerFormat: null, modelFormat: null, sourcePath: `texture_urls[${i}].${channel}`, filename: `texture_${i}_${safeSegment(channel)}.png`, dependencies: [] }); + } + } + }); + } + + const images = task["image_urls"]; + if (Array.isArray(images)) { + images.forEach((url, i) => { + if (isUrl(url)) assets.push({ key: `image.${i}`, kind: "image", url, format: null, containerFormat: null, modelFormat: null, sourcePath: `image_urls[${i}]`, filename: `image_${i}`, dependencies: [] }); + }); + } + + const result = task["result"]; + if (result && typeof result === "object" && !Array.isArray(result)) { + const r = result as Record; + const motionFormat = typeof r["motion_format"] === "string" ? safeExtension(r["motion_format"] as string) : ""; + for (const [field, value] of Object.entries(r)) { + if (field === "basic_animations" && value && typeof value === "object" && !Array.isArray(value)) { + for (const [sub, url] of Object.entries(value as Record)) { + if (!isUrl(url)) continue; + const ext = extFromFieldName(sub); + assets.push({ key: `result.basic_animations.${safeSegment(sub)}`, kind: "animation", url, format: ext, containerFormat: null, modelFormat: ext, sourcePath: `result.basic_animations.${sub}`, filename: `${safeSegment(sub.replace(/_url$/, ""))}${ext ? `.${ext}` : ""}`, dependencies: [] }); + } + continue; + } + if (!isUrl(value)) { + if (value && typeof value === "object") collectUnknownUrls(value, `result.${field}`, unknown); + continue; + } + if (field === "motion_url") { + const ext = motionFormat === "fbx" || motionFormat === "bvh" ? motionFormat : null; + assets.push({ key: "result.motion_url", kind: "motion", url: value, format: ext, containerFormat: null, modelFormat: ext, sourcePath: "result.motion_url", filename: ext ? `motion.${ext}` : "motion", dependencies: [] }); + continue; + } + if (/^rigged_character_(glb|fbx)_url$/.test(field)) { + const ext = extFromFieldName(field); + assets.push({ key: `result.${field}`, kind: "rig", url: value, format: ext, containerFormat: null, modelFormat: ext, sourcePath: `result.${field}`, filename: `rigged_character.${ext}`, dependencies: [] }); + continue; + } + if (/^(animation_(glb|fbx)_url|processed_[a-z0-9_]+_url)$/.test(field)) { + const ext = extFromFieldName(field); + assets.push({ key: `result.${field}`, kind: "animation", url: value, format: ext, containerFormat: null, modelFormat: ext, sourcePath: `result.${field}`, filename: `${safeSegment(field.replace(/_url$/, ""))}${ext ? `.${ext}` : ""}`, dependencies: [] }); + continue; + } + unknown.push({ path: `result.${field}`, url: value }); + } + } + + const printability = task["printability"]; + if (printability && typeof printability === "object") { + assets.push({ key: "report.printability", kind: "report", url: null, format: "json", containerFormat: null, modelFormat: null, sourcePath: "printability", filename: "printability.json", dependencies: [], report: printability }); + } + + return { assets, unknown_urls: unknown, product }; +} + +function textureKeys(task: Record): string[] { + const keys: string[] = []; + const textures = task["texture_urls"]; + if (Array.isArray(textures)) { + textures.forEach((set, i) => { + if (!set || typeof set !== "object") return; + for (const [channel, url] of Object.entries(set as Record)) { + if (isUrl(url)) keys.push(`texture.${i}.${safeSegment(channel)}`); + } + }); + } + return keys; +} + +function extFromFieldName(field: string): string | null { + const m = /_(glb|fbx|usdz|obj|bvh|png|jpg|jpeg|webp|gltf|stl|3mf)_url$/i.exec(field); + if (m) return m[1]!.toLowerCase(); + // processed_usdz_url → usdz ; processed_armature_fbx_url → fbx handled above ; processed_animation_fps_fbx_url → fbx + return null; +} + +function collectUnknownUrls(value: unknown, path: string, out: Array<{ path: string; url: string }>): void { + if (isUrl(value)) { + out.push({ path, url: value }); + return; + } + if (Array.isArray(value)) { + value.forEach((v, i) => collectUnknownUrls(v, `${path}[${i}]`, out)); + return; + } + if (value && typeof value === "object") { + for (const [k, v] of Object.entries(value as Record)) collectUnknownUrls(v, `${path}.${k}`, out); + } +} + +/** 0.2.0 artifact keys → stable keys, so old scripts and internal tests keep resolving. */ +export const LEGACY_KEY_ALIASES: Readonly> = { + thumbnail: "thumbnail.primary", + motion_url: "result.motion_url", +}; + +export function resolveAssetKey(requested: string, assets: readonly Asset[]): Asset | undefined { + const direct = assets.find((a) => a.key === requested); + if (direct) return direct; + const alias = LEGACY_KEY_ALIASES[requested]; + if (alias) return assets.find((a) => a.key === alias); + let m = /^model_([a-z0-9_]+)$/i.exec(requested); + if (m) return assets.find((a) => a.key === `model.${m![1]}`); + m = /^image_(\d+)$/.exec(requested); + if (m) return assets.find((a) => a.key === `image.${m![1]}`); + m = /^texture_(\d+)_([a-z0-9_]+)$/i.exec(requested); + if (m) return assets.find((a) => a.key === `texture.${m![1]}.${m![2]}`); + m = /^([a-z0-9_]+_url)$/i.exec(requested); + if (m) return assets.find((a) => a.key === `result.${m![1]}` || a.key === `result.basic_animations.${m![1]}`); + return undefined; +} + +export interface Selection { + keys?: string[]; + modelFormat?: string; + kind?: AssetKind; + all?: boolean; +} + +export interface SelectionResult { + selected: Asset[]; + /** Dependencies pulled in for selected OBJ files (empty when geometryOnly). */ + dependencies: Asset[]; + missingDependencies: string[]; +} + +/** + * Apply a selector to the enumeration. Exactly one selector kind must be + * given; contradictory combinations are rejected by the caller (usage). + * With `withDependencies`, an OBJ selection pulls its MTL and textures. + */ +export function selectAssets(enumeration: AssetEnumeration, sel: Selection, opts: { withDependencies: boolean }): SelectionResult { + const { assets } = enumeration; + let selected: Asset[]; + if (sel.all) selected = assets.filter((a) => a.url !== null || a.kind === "report"); + else if (sel.keys && sel.keys.length > 0) { + selected = []; + for (const k of sel.keys) { + const a = resolveAssetKey(k, assets); + if (!a) throw new SelectionError(`no asset with key '${k}'`, assets); + if (!selected.includes(a)) selected.push(a); + } + } else if (sel.modelFormat) { + const fmt = sel.modelFormat.toLowerCase(); + selected = assets.filter((a) => a.kind === "model" && (a.key === `model.${fmt}` || a.modelFormat === fmt || a.format === fmt)); + if (selected.length === 0) throw new SelectionError(`no model asset in format '${sel.modelFormat}'`, assets); + } else if (sel.kind) { + selected = assets.filter((a) => a.kind === sel.kind); + if (selected.length === 0) throw new SelectionError(`no asset of kind '${sel.kind}'`, assets); + } else { + throw new SelectionError("no selector given", assets); + } + const dependencies: Asset[] = []; + const missing: string[] = []; + if (opts.withDependencies) { + for (const a of selected) { + for (const dep of a.dependencies) { + const d = assets.find((x) => x.key === dep); + if (!d) missing.push(dep); + else if (!selected.includes(d) && !dependencies.includes(d)) dependencies.push(d); + } + } + } + return { selected, dependencies, missingDependencies: missing }; +} + +export class SelectionError extends Error { + readonly candidates: Array<{ key: string; kind: AssetKind; format: string | null }>; + constructor(message: string, assets: readonly Asset[]) { + super(message); + this.name = "SelectionError"; + this.candidates = assets.map((a) => ({ key: a.key, kind: a.kind, format: a.format })); + } +} diff --git a/src/internal/download.ts b/src/internal/download.ts index d9f264d..8ad0b28 100644 --- a/src/internal/download.ts +++ b/src/internal/download.ts @@ -1,32 +1,523 @@ /** - * Download a completed task's artifacts to the local filesystem. + * Asset downloads with explicit boundaries. * - * Two target shapes: - * 1. File path (e.g. `character/front.jpeg`) — used when the task has a - * single downloadable artifact (most 2D image tasks). The Content-Type - * of the HTTP response is authoritative; if the user-supplied extension - * disagrees, the file is saved with the correct extension instead. - * 2. Directory path (anything without a recognized file extension) — used - * when the task emits multiple artifacts (3D models, thumbnails, - * textures, animation outputs). Each artifact is named after its role - * (model.glb, thumbnail.png, texture_0_base_color.png, ...). + * Every byte that lands on disk goes through `fetchToTemp`: http(s) only, no + * embedded credentials, no Authorization or Cookie ever attached (asset hosts + * are not the API), redirects re-validated hop by hop (max 5), private + * network literals refused (loopback is allowed for local test servers), a + * hard size cap enforced while streaming, sha256 computed on the way. The + * temp file lives in the target directory and is published exclusively + * (`link`), or replaced atomically with --overwrite; the final path — after + * any MIME-driven extension change — is checked against the authorised root. * - * A `meta.json` alongside the artifacts records the full task response plus - * the saved paths for later reference. + * Two entry points share the core: + * downloadArtifacts — the 0.2.0 `-o` behaviour (all artifacts, role-based + * names, meta.json sidecar); output shape unchanged. + * downloadAssets — the selective downloader behind `meshy download` and + * the v1 manifest. */ -import { createWriteStream, existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { dirname, extname, join } from "node:path"; +import { createHash } from "node:crypto"; +import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { Transform } from "node:stream"; +import { basename, dirname, extname, join, relative, resolve as resolvePath } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import sharp from "sharp"; import type { Task } from "../client/types.js"; -import { UsageError } from "./errors.js"; +import { publishTempFile, tempPathFor, writeJsonFile } from "./atomic-file.js"; +import { CliError, UsageError } from "./errors.js"; import { logger } from "./logger.js"; +import { isInside, realpathLenient, resolveWithinRoot, safeExtension, safeSegment } from "./paths.js"; +import { USER_AGENT } from "./user-agent.js"; +import type { Asset } from "./artifacts.js"; /** Extensions sharp can transcode between. */ const CONVERTIBLE_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "gif", "tiff", "tif", "avif"]); +export const DEFAULT_DOWNLOAD_LIMITS = { + /** 2 GiB — engineering default, not a Meshy limit. */ + maxBytes: 2 * 1024 * 1024 * 1024, + timeoutMs: 300_000, + maxRedirects: 5, +} as const; + +export interface DownloadLimits { + maxBytes: number; + timeoutMs: number; + maxRedirects: number; +} + +export interface DownloadPolicy { + /** Plain http is accepted only for loopback hosts (local test servers). */ + allowHttpLoopback: boolean; + /** Private-network literals (10/8, 172.16/12, 192.168/16, link-local) are refused unless set. */ + allowPrivateNetwork: boolean; +} + +export const DEFAULT_DOWNLOAD_POLICY: DownloadPolicy = { allowHttpLoopback: true, allowPrivateNetwork: false }; + +export interface FetchOptions { + limits?: Partial; + policy?: Partial; + fetchImpl?: typeof fetch; + signal?: AbortSignal; +} + +export interface FetchedFile { + tmpPath: string; + bytes: number; + sha256: string; + contentType: string | null; + finalUrl: string; + status: number; +} + +function isLoopbackHost(host: string): boolean { + const h = host.replace(/^\[|\]$/g, "").toLowerCase(); + return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h); +} + +function isPrivateLiteral(host: string): boolean { + const h = host.replace(/^\[|\]$/g, "").toLowerCase(); + const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h); + if (v4) { + const [a, b] = [Number(v4[1]), Number(v4[2])]; + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a === 0) return true; + return false; + } + if (h.includes(":")) { + if (/^f[cd]/.test(h)) return true; // fc00::/7 + if (/^fe[89ab]/.test(h)) return true; // fe80::/10 + return false; + } + return false; +} + +/** Validate an asset URL against the policy; throws CliError (local_io) with a clear reason. */ +export function validateAssetUrl(raw: string, policy: DownloadPolicy, label = "asset URL"): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new CliError({ code: "validation", message: `${label} is not a valid URL: ${raw}` }); + } + if (url.username || url.password) throw new CliError({ code: "validation", message: `${label} carries embedded credentials; refused` }); + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new CliError({ code: "validation", message: `${label} must be http(s), got ${url.protocol}` }); + } + const loopback = isLoopbackHost(url.hostname); + if (!loopback && isPrivateLiteral(url.hostname) && !policy.allowPrivateNetwork) { + throw new CliError({ code: "validation", message: `${label} points at a private network address (${url.hostname}); refused` }); + } + if (url.protocol === "http:" && !(loopback && policy.allowHttpLoopback)) { + throw new CliError({ code: "validation", message: `${label} uses plain http to ${url.hostname}; only https (or http to a loopback test host) is accepted` }); + } + return url; +} + +/** + * Stream a URL into a temp file next to `target`, following at most + * `maxRedirects` re-validated redirects, without any credential header. + */ +export async function fetchToTemp(rawUrl: string, target: string, opts: FetchOptions = {}): Promise { + const limits: DownloadLimits = { ...DEFAULT_DOWNLOAD_LIMITS, ...(opts.limits ?? {}) }; + const policy: DownloadPolicy = { ...DEFAULT_DOWNLOAD_POLICY, ...(opts.policy ?? {}) }; + const fetchImpl = opts.fetchImpl ?? globalThis.fetch; + mkdirSync(dirname(target), { recursive: true }); + const tmpPath = tempPathFor(target); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error(`download timed out after ${limits.timeoutMs}ms`)), limits.timeoutMs); + const signal = opts.signal ? AbortSignal.any([controller.signal, opts.signal]) : controller.signal; + + let url = validateAssetUrl(rawUrl, policy); + try { + let resp: Response | null = null; + for (let hop = 0; ; hop++) { + let r: Response; + try { + r = await fetchImpl(url, { method: "GET", redirect: "manual", signal, headers: { "User-Agent": USER_AGENT, Accept: "*/*" } }); + } catch (err) { + if (opts.signal?.aborted) throw new CliError({ code: "interrupted", message: `download of ${redact(url)} interrupted` }); + if (controller.signal.aborted) throw new CliError({ code: "network", message: `download of ${redact(url)} timed out after ${limits.timeoutMs}ms` }); + throw new CliError({ code: "network", message: `download of ${redact(url)} failed: ${err instanceof Error ? err.message : String(err)}`, cause: err }); + } + if (r.status >= 300 && r.status < 400) { + const loc = r.headers.get("location"); + await r.body?.cancel().catch(() => undefined); + if (!loc) throw new CliError({ code: "network", message: `redirect from ${redact(url)} without a Location header` }); + if (hop >= limits.maxRedirects) throw new CliError({ code: "network", message: `too many redirects downloading ${redact(url)}` }); + const next = new URL(loc, url); + if (url.protocol === "https:" && next.protocol === "http:") { + throw new CliError({ code: "validation", message: `refusing https → http downgrade redirect from ${redact(url)}` }); + } + url = validateAssetUrl(next.href, policy, "redirect target"); + continue; + } + resp = r; + break; + } + if (!resp.ok) { + await resp.body?.cancel().catch(() => undefined); + throw new CliError({ + code: resp.status === 404 ? "not_found" : resp.status === 401 || resp.status === 403 || resp.status === 410 ? "validation" : "network", + message: `download failed for ${redact(url)} (HTTP ${resp.status}${resp.statusText ? ` ${resp.statusText}` : ""})`, + httpStatus: resp.status, + details: { expired_or_denied: resp.status === 401 || resp.status === 403 || resp.status === 410 }, + }); + } + const declared = resp.headers.get("content-length"); + if (declared && Number(declared) > limits.maxBytes) { + await resp.body?.cancel().catch(() => undefined); + throw new CliError({ code: "local_io", message: `asset ${redact(url)} declares ${declared} bytes, above the ${limits.maxBytes}-byte limit` }); + } + const hash = createHash("sha256"); + let bytes = 0; + const source = resp.body ? Readable.fromWeb(resp.body as unknown as import("node:stream/web").ReadableStream) : Readable.from([]); + const counter = new Transform({ + transform(chunk: Buffer, _enc, cb) { + bytes += chunk.length; + if (bytes > limits.maxBytes) { + cb(new CliError({ code: "local_io", message: `asset ${redact(url)} exceeds the ${limits.maxBytes}-byte limit` })); + return; + } + hash.update(chunk); + cb(null, chunk); + }, + }); + try { + await pipeline(source, counter, createWriteStream(tmpPath, { mode: 0o600 })); + } catch (err) { + removeQuietly(tmpPath); + if (err instanceof CliError) throw err; + if (opts.signal?.aborted) throw new CliError({ code: "interrupted", message: `download of ${redact(url)} interrupted` }); + if (controller.signal.aborted) throw new CliError({ code: "network", message: `download of ${redact(url)} timed out after ${limits.timeoutMs}ms` }); + throw new CliError({ code: "network", message: `download of ${redact(url)} failed mid-stream: ${err instanceof Error ? err.message : String(err)}`, cause: err }); + } + return { tmpPath, bytes, sha256: hash.digest("hex"), contentType: resp.headers.get("content-type"), finalUrl: url.href, status: resp.status }; + } finally { + clearTimeout(timer); + } +} + +/** URL without its query string — signed parameters never reach logs or messages. */ +export function redact(url: URL | string): string { + try { + const u = typeof url === "string" ? new URL(url) : url; + return `${u.origin}${u.pathname}`; + } catch { + return ""; + } +} + +function removeQuietly(path: string): void { + try { + unlinkSync(path); + } catch { + /* gone */ + } +} + +// --------------------------------------------------------------------------- +// Content checks +// --------------------------------------------------------------------------- + +export function extFromContentType(ct: string | null): string { + if (!ct) return ""; + const base = ct.split(";")[0]!.trim().toLowerCase(); + const map: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/tiff": "tiff", + "image/bmp": "bmp", + "model/gltf-binary": "glb", + "model/gltf+json": "gltf", + "model/obj": "obj", + "model/vnd.usdz+zip": "usdz", + "model/stl": "stl", + "model/3mf": "3mf", + "application/zip": "zip", + "application/json": "json", + "video/mp4": "mp4", + }; + return map[base] ?? ""; +} + +function extEquivalent(a: string, b: string): boolean { + const groups = [new Set(["jpg", "jpeg"]), new Set(["tif", "tiff"])]; + return groups.some((g) => g.has(a) && g.has(b)); +} + +function readHead(path: string, n = 16): Buffer { + const fd = openSync(path, "r"); + try { + const buf = Buffer.alloc(n); + const read = readSync(fd, buf, 0, n, 0); + return buf.subarray(0, read); + } finally { + closeSync(fd); + } +} + +/** + * Reject bodies that cannot be what the asset claims: an HTML error page + * served as a model, a non-GLB under .glb, a non-ZIP under .zip. + */ +export function validateContent(tmpPath: string, expectedFormat: string | null, kind: Asset["kind"], contentType: string | null): void { + const ct = (contentType ?? "").split(";")[0]!.trim().toLowerCase(); + const head = readHead(tmpPath, 16); + const text = head.toString("latin1").toLowerCase(); + if ((kind === "model" || kind === "rig" || kind === "animation" || kind === "motion") && (ct === "text/html" || text.startsWith("= 4 && head.subarray(0, 4).toString("ascii") === "glTF")) { + throw new CliError({ code: "validation", message: "asset saved under .glb does not start with the glTF magic; refusing to keep an invalid GLB" }); + } + if (expectedFormat === "zip" && !(head.length >= 2 && head[0] === 0x50 && head[1] === 0x4b)) { + throw new CliError({ code: "validation", message: "asset expected to be a ZIP container does not start with the PK magic" }); + } +} + +// --------------------------------------------------------------------------- +// Selective downloader (meshy download, v1) +// --------------------------------------------------------------------------- + +export interface DownloadedFile { + key: string; + path: string; + relative_path: string | null; + bytes: number; + sha256: string; + content_type: string | null; + format: string | null; + container_format: string | null; + extracted: boolean | null; + status: "written" | "failed" | "skipped"; + error: string | null; + publish_method: string | null; +} + +export interface DownloadAssetsOptions extends FetchOptions { + /** Directory mode. */ + targetDir?: string; + /** Single-file mode (exactly one asset). */ + targetFile?: string; + overwrite?: boolean; + /** Authorised root every final path must stay inside. */ + root: string; + /** Validate magic/content-type for models (the legacy wrapper turns this off). */ + validateContent?: boolean; + /** Bounded URL refresh hook (API source): returns fresh URLs by key or null. */ + refreshUrls?: () => Promise | null>; + onFile?: (file: DownloadedFile) => void; +} + +export interface DownloadAssetsResult { + files: DownloadedFile[]; + /** True when every requested asset was written. */ + complete: boolean; + warnings: Array<{ code: string; message: string }>; +} + +function plannedName(asset: Asset, targetFile: string | undefined): string { + if (targetFile) return basename(targetFile); + return asset.filename; +} + +/** + * Download the given assets one by one. Each file is published on its own; + * a failure stops the loop and the result lists what was written so far. + * No rollback deletes anything the user already had. + */ +export async function downloadAssets(assets: readonly Asset[], opts: DownloadAssetsOptions): Promise { + if (opts.targetFile && assets.length !== 1) { + throw new UsageError(`--output names a single file but ${assets.length} assets were selected; pass --output-dir instead`); + } + if (!opts.targetFile && !opts.targetDir) throw new UsageError("an output file or directory is required"); + const files: DownloadedFile[] = []; + const warnings: Array<{ code: string; message: string }> = []; + const dir = opts.targetFile ? dirname(resolvePath(opts.targetFile)) : resolvePath(opts.targetDir!); + const rootReal = realpathLenient(opts.root); + mkdirSync(dir, { recursive: true }); + // The directory and every planned leaf must be inside the root (and no + // symlink) before anything is fetched; the check repeats after any + // MIME-driven rename. + resolveWithinRoot(dir, rootReal, { label: "output directory" }); + for (const asset of assets) { + resolveWithinRoot(join(dir, plannedName(asset, opts.targetFile)), rootReal, { label: "planned download path" }); + } + + let refreshed: Map | null | undefined; + for (const asset of assets) { + const planned = join(dir, plannedName(asset, opts.targetFile)); + let entry: DownloadedFile; + try { + if (asset.kind === "report") { + const target = resolveWithinRoot(planned.endsWith(".json") ? planned : `${planned}.json`, rootReal, { label: "report path" }).path; + const res = writeJsonFile(target, asset.report, { overwrite: opts.overwrite ?? false }); + const bytes = statSync(target).size; + entry = { key: asset.key, path: target, relative_path: rel(rootReal, target), bytes, sha256: createHash("sha256").update(readFileSync(target)).digest("hex"), content_type: "application/json", format: "json", container_format: null, extracted: null, status: "written", error: null, publish_method: res.method }; + } else { + if (!asset.url) throw new CliError({ code: "validation", message: `asset ${asset.key} has no URL` }); + let url = asset.url; + let fetched: FetchedFile; + try { + fetched = await fetchToTemp(url, planned, opts); + } catch (err) { + const expired = err instanceof CliError && (err.details as { expired_or_denied?: boolean } | undefined)?.expired_or_denied; + if (expired && opts.refreshUrls) { + if (refreshed === undefined) refreshed = await opts.refreshUrls(); + const fresh = refreshed?.get(asset.key); + if (fresh && fresh !== url) { + warnings.push({ code: "asset_url_refreshed", message: `${asset.key}: signed URL rejected; refreshed once from the task` }); + url = fresh; + fetched = await fetchToTemp(url, planned, opts); + } else throw err; + } else throw err; + } + entry = await placeFetched(asset, fetched, planned, rootReal, opts, warnings); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const failed: DownloadedFile = { key: asset.key, path: planned, relative_path: rel(rootReal, planned), bytes: 0, sha256: "", content_type: null, format: asset.format, container_format: asset.containerFormat, extracted: null, status: "failed", error: message, publish_method: null }; + files.push(failed); + opts.onFile?.(failed); + const code = err instanceof CliError ? err.code : "local_io"; + throw new CliError({ + code: code === "interrupted" ? "interrupted" : code === "network" || code === "not_found" || code === "validation" ? code : "local_io", + message: `${asset.key}: ${message}`, + httpStatus: err instanceof CliError ? err.httpStatus : null, + result: { downloads: { state: files.some((f) => f.status === "written") ? "partial" : "failed", files, metadata_path: null } }, + warnings, + cause: err, + }); + } + files.push(entry); + opts.onFile?.(entry); + } + return { files, complete: files.every((f) => f.status === "written"), warnings }; +} + +function rel(root: string, path: string): string | null { + const r = relative(root, path); + return isInside(root, path) ? r.split(/[\\/]/).join("/") : null; +} + +/** Reconcile the extension with the real content type, validate, and publish exclusively. */ +async function placeFetched( + asset: Asset, + fetched: FetchedFile, + planned: string, + rootReal: string, + opts: DownloadAssetsOptions, + warnings: Array<{ code: string; message: string }>, +): Promise { + const actualExt = extFromContentType(fetched.contentType); + const requestedExt = safeExtension(extname(planned)); + let finalPath = planned; + let tmp = fetched.tmpPath; + let bytes = fetched.bytes; + let sha = fetched.sha256; + + if (asset.containerFormat === "zip") { + // Bundles are delivered as ZIP whatever the content-type says. + if (requestedExt !== "zip") finalPath = `${stripExt(planned)}.zip`; + } else if (!requestedExt && actualExt) { + finalPath = `${planned}.${actualExt}`; + } else if (requestedExt && actualExt && requestedExt !== actualExt && !extEquivalent(requestedExt, actualExt)) { + if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) { + const converted = `${tmp}.conv`; + await convertImage(readFileSync(tmp), requestedExt, converted); + removeQuietly(tmp); + tmp = converted; + bytes = statSync(converted).size; + sha = createHash("sha256").update(readFileSync(converted)).digest("hex"); + warnings.push({ code: "image_transcoded", message: `${asset.key}: server sent ${actualExt}, transcoded to ${requestedExt} as requested` }); + } else { + finalPath = `${stripExt(planned)}.${actualExt}`; + warnings.push({ code: "extension_corrected", message: `${asset.key}: requested .${requestedExt} but the server sent ${fetched.contentType ?? "unknown"}; saved as ${basename(finalPath)}` }); + } + } + + const expectedFormat = asset.containerFormat === "zip" ? "zip" : safeExtension(extname(finalPath)) || null; + if (opts.validateContent !== false) { + try { + validateContent(tmp, expectedFormat, asset.kind, fetched.contentType); + } catch (err) { + removeQuietly(tmp); + throw err; + } + } + + // The final path — possibly renamed — must still be inside the root, and + // must not be a symlink or an existing file (unless --overwrite). + let resolved: string; + try { + resolved = resolveWithinRoot(finalPath, rootReal, { label: "final download path" }).path; + } catch (err) { + removeQuietly(tmp); + throw err; + } + const res = publishTempFile(tmp, resolved, { overwrite: opts.overwrite ?? false }); + return { + key: asset.key, + path: resolved, + relative_path: rel(rootReal, resolved), + bytes, + sha256: sha, + content_type: fetched.contentType, + format: asset.containerFormat === "zip" ? "zip" : safeExtension(extname(resolved)) || asset.format, + container_format: asset.containerFormat, + extracted: asset.containerFormat ? false : null, + status: "written", + error: null, + publish_method: res.method, + }; +} + +function stripExt(path: string): string { + const ext = extname(path); + return ext ? path.slice(0, -ext.length) : path; +} + +async function convertImage(buffer: Buffer, targetExt: string, targetPath: string): Promise { + const pipe = sharp(buffer); + switch (targetExt) { + case "jpg": + case "jpeg": + await pipe.jpeg({ quality: 92 }).toFile(targetPath); + return; + case "png": + await pipe.png().toFile(targetPath); + return; + case "webp": + await pipe.webp({ quality: 92 }).toFile(targetPath); + return; + case "gif": + await pipe.gif().toFile(targetPath); + return; + case "tiff": + case "tif": + await pipe.tiff().toFile(targetPath); + return; + case "avif": + await pipe.avif({ quality: 60 }).toFile(targetPath); + return; + default: + throw new Error(`unsupported image target extension: ${targetExt}`); + } +} + +// --------------------------------------------------------------------------- +// Legacy `-o` wrapper (0.2.0 layout preserved) +// --------------------------------------------------------------------------- + export interface Artifact { /** Stable slot name ("model_glb", "image_0", "texture_0_base_color", ...) */ key: string; @@ -69,10 +560,19 @@ export function enumerateArtifacts(task: Task): Artifact[] { }); } - // Rigging / animate-style endpoints nest outputs under `result`. + // Rigging / animate-style endpoints nest outputs under `result`; the + // bundled walking/running clips live one level deeper. if (task.result && typeof task.result === "object") { const motionFormat = task.result["motion_format"]; for (const [key, value] of Object.entries(task.result)) { + if (key === "basic_animations" && value && typeof value === "object" && !Array.isArray(value)) { + for (const [sub, url] of Object.entries(value as Record)) { + if (typeof url !== "string" || !/^https?:/.test(url)) continue; + const extMatch = sub.match(/_(fbx|glb|usdz|obj|png|jpg|jpeg|webp)_url$/i); + out.push({ key: `basic_animations_${sub}`, url, preferredExt: extMatch ? extMatch[1]!.toLowerCase() : "" }); + } + continue; + } if (typeof value !== "string" || !/^https?:/.test(value)) continue; const extMatch = key.match(/_(fbx|glb|usdz|obj|png|jpg|jpeg|webp)_url$/i); let preferredExt = extMatch ? extMatch[1]!.toLowerCase() : ""; @@ -150,13 +650,14 @@ export async function downloadArtifacts( } mkdirSync(targetDir, { recursive: true }); + const root = realpathLenient(targetDir); const saved: string[] = []; if (singleFileMode) { - saved.push(await downloadArtifact(artifacts[0]!, outputPath)); + saved.push(await downloadArtifact(artifacts[0]!, outputPath, root)); } else { for (const artifact of artifacts) { const targetPath = join(targetDir, deriveFilename(artifact)); - saved.push(await downloadArtifact(artifact, targetPath)); + saved.push(await downloadArtifact(artifact, targetPath, root)); } } writeMeta(task, resource, metadataPath, saved); @@ -180,122 +681,45 @@ function deriveFilename(artifact: Artifact): string { return artifact.preferredExt ? `${stem}.${artifact.preferredExt}` : stem; } -function stripExt(path: string): string { - const ext = extname(path); - return ext ? path.slice(0, -ext.length) : path; -} - -async function downloadArtifact(artifact: Artifact, targetPath: string): Promise { - logger.debug(`GET ${artifact.url}`); - const resp = await fetch(artifact.url); - if (!resp.ok) { - throw new Error(`download failed for ${artifact.key} (${resp.status} ${resp.statusText})`); +async function downloadArtifact(artifact: Artifact, targetPath: string, root: string): Promise { + logger.debug(`GET ${redact(artifact.url)}`); + let fetched: FetchedFile; + try { + fetched = await fetchToTemp(artifact.url, targetPath); + } catch (err) { + throw new Error(`download failed for ${artifact.key} (${err instanceof Error ? err.message : String(err)})`); } - if (!resp.body) throw new Error(`empty body for ${artifact.url}`); - - const contentType = resp.headers.get("content-type") ?? ""; - const actualExt = extFromContentType(contentType); + const actualExt = extFromContentType(fetched.contentType); const requestedExt = extname(targetPath).slice(1).toLowerCase(); - mkdirSync(dirname(targetPath), { recursive: true }); + let finalPath = targetPath; + let tmp = fetched.tmpPath; - // No extension requested (e.g. unknown-format image artifact) — pick one - // from the content-type so the file has a reasonable suffix. if (!requestedExt && actualExt) { - const finalPath = `${targetPath}.${actualExt}`; - await pipeline( - Readable.fromWeb(resp.body as unknown as import("node:stream/web").ReadableStream), - createWriteStream(finalPath), - ); - return finalPath; - } - - // Fast path: extensions agree (or the server didn't specify one) — stream through. - if ( - !actualExt || - !requestedExt || - actualExt === requestedExt || - extEquivalent(actualExt, requestedExt) - ) { - await pipeline( - Readable.fromWeb(resp.body as unknown as import("node:stream/web").ReadableStream), - createWriteStream(targetPath), - ); - return targetPath; - } - - // Extensions differ. Buffer the body so we can either transcode or rename. - const buffer = Buffer.from(await resp.arrayBuffer()); - - // Both sides are image formats sharp understands — convert. - if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) { - logger.debug(`converting ${actualExt} → ${requestedExt} for ${artifact.key}`); - await convertImage(buffer, requestedExt, targetPath); - return targetPath; - } - - // Can't convert safely — save with the true extension so the file isn't a lie. - const base = targetPath.slice(0, targetPath.length - (requestedExt.length + 1)); - const fallback = `${base}.${actualExt}`; - logger.warn( - `extension mismatch for ${artifact.key}: requested .${requestedExt}, got ${contentType || "?"}; ` + - `cannot transcode ${actualExt} → ${requestedExt}, saving as ${fallback}`, - ); - await pipeline(Readable.from(buffer), createWriteStream(fallback)); - return fallback; -} - -async function convertImage(buffer: Buffer, targetExt: string, targetPath: string): Promise { - const pipe = sharp(buffer); - switch (targetExt) { - case "jpg": - case "jpeg": - await pipe.jpeg({ quality: 92 }).toFile(targetPath); - return; - case "png": - await pipe.png().toFile(targetPath); - return; - case "webp": - await pipe.webp({ quality: 92 }).toFile(targetPath); - return; - case "gif": - await pipe.gif().toFile(targetPath); - return; - case "tiff": - case "tif": - await pipe.tiff().toFile(targetPath); - return; - case "avif": - await pipe.avif({ quality: 60 }).toFile(targetPath); - return; - default: - throw new Error(`unsupported image target extension: ${targetExt}`); + // No extension requested (e.g. unknown-format image artifact) — pick one + // from the content-type so the file has a reasonable suffix. + finalPath = `${targetPath}.${actualExt}`; + } else if (actualExt && requestedExt && actualExt !== requestedExt && !extEquivalent(actualExt, requestedExt)) { + if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) { + // Both sides are image formats sharp understands — convert. + logger.debug(`converting ${actualExt} → ${requestedExt} for ${artifact.key}`); + const converted = `${tmp}.conv`; + await convertImage(readFileSync(tmp), requestedExt, converted); + removeQuietly(tmp); + tmp = converted; + } else { + // Can't convert safely — save with the true extension so the file isn't a lie. + finalPath = `${targetPath.slice(0, targetPath.length - (requestedExt.length + 1))}.${actualExt}`; + logger.warn( + `extension mismatch for ${artifact.key}: requested .${requestedExt}, got ${fetched.contentType || "?"}; ` + + `cannot transcode ${actualExt} → ${requestedExt}, saving as ${finalPath}`, + ); + } } -} - -function extFromContentType(ct: string): string { - const base = ct.split(";")[0]!.trim().toLowerCase(); - const map: Record = { - "image/jpeg": "jpg", - "image/png": "png", - "image/webp": "webp", - "image/gif": "gif", - "image/tiff": "tiff", - "image/bmp": "bmp", - "model/gltf-binary": "glb", - "model/gltf+json": "gltf", - "model/obj": "obj", - "model/vnd.usdz+zip": "usdz", - "model/stl": "stl", - "model/3mf": "3mf", - "application/json": "json", - "video/mp4": "mp4", - }; - return map[base] ?? ""; -} - -function extEquivalent(a: string, b: string): boolean { - const groups = [new Set(["jpg", "jpeg"]), new Set(["tif", "tiff"])]; - return groups.some((g) => g.has(a) && g.has(b)); + // The final name may differ from the pre-checked one; it still may not + // clobber anything and must stay under the output directory. + const resolved = resolveWithinRoot(finalPath, root, { label: "download target" }).path; + publishTempFile(tmp, resolved, { overwrite: false }); + return finalPath === targetPath ? targetPath : finalPath; } /** @@ -352,3 +776,5 @@ function writeMeta(task: Task, resource: string, path: string, savedFiles: strin mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(meta, null, 2)}\n`, "utf8"); } + +export { safeSegment as _safeSegmentForTests }; diff --git a/src/root.ts b/src/root.ts index 24b86f2..49c354c 100644 --- a/src/root.ts +++ b/src/root.ts @@ -26,6 +26,7 @@ import { balanceCommand } from "./cmd/balance.js"; import { convertCommand } from "./cmd/convert.js"; import { creativeLabCommand } from "./cmd/creative-lab.js"; import { deleteCommand } from "./cmd/delete.js"; +import { downloadCommand } from "./cmd/download.js"; import { imageTo3dCommand } from "./cmd/image-to-3d.js"; import { imageToImageCommand } from "./cmd/image-to-image.js"; import { makeCommand } from "./cmd/make.js"; @@ -194,6 +195,7 @@ export function buildRootCommand(): Command { creativeLabCommand, animationCatalogCommand, showcasesCommand, + downloadCommand, ]) { program.addCommand(cmd, { hidden: true }); } diff --git a/tests/artifacts.test.ts b/tests/artifacts.test.ts new file mode 100644 index 0000000..ec4691c --- /dev/null +++ b/tests/artifacts.test.ts @@ -0,0 +1,118 @@ +/** + * Asset enumeration and selection (T-060, T-061, T-062, T-063, T-071). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { enumerateAssets, resolveAssetKey, selectAssets, SelectionError } from "../src/internal/artifacts.js"; +import { requireTaskResource } from "../src/client/resource-registry.js"; +import { extractTaskObject, toTaskView } from "../src/internal/task-view.js"; + +const rig = JSON.parse(readFileSync(new URL("./fixtures/skill-parity/task-rigging.synthetic.json", import.meta.url), "utf8")) as Record; + +test("T-060 rigging: rig + nested walking/running animations are enumerated with role keys", () => { + const e = enumerateAssets(rig, requireTaskResource("rigging")); + assert.deepEqual( + e.assets.map((a) => [a.key, a.kind, a.format]), + [ + ["result.rigged_character_glb_url", "rig", "glb"], + ["result.basic_animations.walking_glb_url", "animation", "glb"], + ["result.basic_animations.running_glb_url", "animation", "glb"], + ], + ); + assert.deepEqual(e.unknown_urls, []); +}); + +test("T-060 textured model: model formats, MTL dependency, textures, primary/multiview/alpha thumbnails", () => { + const task = { + id: "t", + type: "text-to-3d-refine", + status: "SUCCEEDED", + model_urls: { glb: "https://a.example/m.glb", obj: "https://a.example/m.obj", mtl: "https://a.example/m.mtl", fbx: "https://a.example/m.fbx", usdz: null }, + thumbnail_url: "https://a.example/t.png", + thumbnail_urls: { front: "https://a.example/f.png", back: "https://a.example/b.png" }, + alpha_thumbnail_url: "https://a.example/alpha.png", + texture_urls: [{ base_color: "https://a.example/bc.png", normal: "https://a.example/n.png", metallic: null }], + }; + const e = enumerateAssets(task, requireTaskResource("text-to-3d")); + const keys = e.assets.map((a) => a.key); + assert.deepEqual(keys, [ + "model.glb", "model.obj", "model.mtl", "model.fbx", + "thumbnail.primary", "thumbnail.front", "thumbnail.back", "thumbnail.alpha", + "texture.0.base_color", "texture.0.normal", + ]); + const obj = e.assets.find((a) => a.key === "model.obj")!; + assert.deepEqual(obj.dependencies, ["model.mtl", "texture.0.base_color", "texture.0.normal"]); + assert.equal(e.assets.find((a) => a.key === "thumbnail.front")?.notes?.["view"], "front"); +}); + +test("T-060 lamp build parts are STL/ZIP, never `.lamp_stl`; keychain obj is a ZIP container", () => { + const lamp = enumerateAssets({ id: "l", type: "creative-lab-lamp-build", status: "SUCCEEDED", model_urls: { lamp_stl: "https://a.example/lamp", base_stl: "https://a.example/base", bundle_zip: "https://a.example/bundle" } }); + assert.deepEqual(lamp.assets.map((a) => [a.key, a.format, a.filename]), [ + ["model.lamp_stl", "stl", "lamp.stl"], + ["model.base_stl", "stl", "base.stl"], + ["model.bundle_zip", "zip", "bundle.zip"], + ]); + const kc = enumerateAssets({ id: "k", type: "creative-lab-keychain-build", status: "SUCCEEDED", model_urls: { obj: "https://a.example/kc" } }); + const obj = kc.assets[0]!; + assert.equal(obj.format, "zip"); + assert.equal(obj.containerFormat, "zip"); + assert.equal(obj.modelFormat, "obj"); + assert.equal(obj.filename, "model.obj.zip"); + assert.equal(obj.notes?.["extracted"], false); + assert.deepEqual(obj.dependencies, []); + // With the descriptor instead of the type. + const fm = enumerateAssets({ id: "f", status: "SUCCEEDED", model_urls: { obj: "https://a.example/fm" } }, requireTaskResource("creative-lab.fridge-magnet.build")); + assert.equal(fm.assets[0]!.containerFormat, "zip"); + // A plain text-to-3d OBJ is a real OBJ. + const plain = enumerateAssets({ id: "p", type: "text-to-3d-refine", status: "SUCCEEDED", model_urls: { obj: "https://a.example/p.obj" } }); + assert.equal(plain.assets[0]!.containerFormat, null); + assert.equal(plain.assets[0]!.filename, "model.obj"); +}); + +test("T-060 motion clips use motion_format; report-only tasks yield a JSON report asset; unknown URLs are listed, not fetched", () => { + const motion = enumerateAssets({ id: "m", type: "text-to-motion", status: "SUCCEEDED", result: { motion_url: "https://a.example/clip", motion_format: "bvh", duration_ms: 3000 } }); + assert.deepEqual(motion.assets.map((a) => [a.key, a.kind, a.format, a.filename]), [["result.motion_url", "motion", "bvh", "motion.bvh"]]); + const report = enumerateAssets({ id: "r", type: "print-analyze", status: "SUCCEEDED", printability: { status: "healthy" } }); + assert.equal(report.assets[0]!.kind, "report"); + assert.equal(report.assets[0]!.url, null); + assert.deepEqual(report.assets[0]!.report, { status: "healthy" }); + const odd = enumerateAssets({ id: "o", status: "SUCCEEDED", result: { mystery_url: "https://a.example/x", nested: { deeper: "https://a.example/y" } } }); + assert.deepEqual(odd.assets, []); + assert.deepEqual(odd.unknown_urls.map((u) => u.path), ["result.mystery_url", "result.nested.deeper"]); +}); + +test("T-061 selection: keys (with legacy aliases), model format, kind, all; contradictions and misses throw with candidates", () => { + const task = { id: "t", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: "https://a/g", obj: "https://a/o", mtl: "https://a/m" }, thumbnail_url: "https://a/t.png", texture_urls: [{ base_color: "https://a/bc.png" }] }; + const e = enumerateAssets(task); + assert.deepEqual(selectAssets(e, { keys: ["model.glb", "thumbnail"] }, { withDependencies: true }).selected.map((a) => a.key), ["model.glb", "thumbnail.primary"]); + assert.deepEqual(selectAssets(e, { keys: ["model_glb", "texture_0_base_color"] }, { withDependencies: true }).selected.map((a) => a.key), ["model.glb", "texture.0.base_color"]); + assert.deepEqual(selectAssets(e, { modelFormat: "GLB" }, { withDependencies: true }).selected.map((a) => a.key), ["model.glb"]); + assert.deepEqual(selectAssets(e, { kind: "texture" }, { withDependencies: true }).selected.map((a) => a.key), ["texture.0.base_color"]); + assert.equal(selectAssets(e, { all: true }, { withDependencies: false }).selected.length, 5); + const objSel = selectAssets(e, { keys: ["model.obj"] }, { withDependencies: true }); + assert.deepEqual(objSel.dependencies.map((a) => a.key), ["model.mtl", "texture.0.base_color"]); + assert.deepEqual(selectAssets(e, { keys: ["model.obj"] }, { withDependencies: false }).dependencies, []); + assert.throws(() => selectAssets(e, { keys: ["model.usdz"] }, { withDependencies: true }), (err: unknown) => err instanceof SelectionError && err.candidates.length === 5); + assert.throws(() => selectAssets(e, { modelFormat: "3mf" }, { withDependencies: true }), SelectionError); + assert.throws(() => selectAssets(e, {}, { withDependencies: true }), SelectionError); + assert.equal(resolveAssetKey("animation_glb_url", enumerateAssets({ id: "a", status: "SUCCEEDED", result: { animation_glb_url: "https://a/x.glb" } }).assets)?.key, "result.animation_glb_url"); + assert.equal(resolveAssetKey("walking_glb_url", enumerateAssets(rig).assets)?.key, "result.basic_animations.walking_glb_url"); +}); + +test("T-062 OBJ without MTL in the task reports the missing dependency instead of pretending", () => { + const e = enumerateAssets({ id: "t", type: "remesh", status: "SUCCEEDED", model_urls: { obj: "https://a/o" } }); + const sel = selectAssets(e, { keys: ["model.obj"] }, { withDependencies: true }); + assert.deepEqual(sel.dependencies, []); + assert.deepEqual(sel.missingDependencies, []); + assert.deepEqual(e.assets[0]!.dependencies, [], "no MTL in the task → nothing to depend on"); +}); + +test("T-063 the same task yields the same assets from an API task, a meta.json and a v1 envelope", () => { + const fromApi = enumerateAssets(rig).assets.map((a) => a.key); + const meta = extractTaskObject({ resource: "rigging", task: rig, saved_files: [], downloaded_at: "x" })!; + const env = extractTaskObject({ schema_version: "meshy.cli/v1", command: "rigging.get", ok: true, result: { task: toTaskView(rig, { includeRaw: true }) }, error: null, warnings: [] })!; + assert.deepEqual(enumerateAssets(meta.task).assets.map((a) => a.key), fromApi); + assert.deepEqual(enumerateAssets(env.task).assets.map((a) => a.key), fromApi); +}); diff --git a/tests/download-command.test.ts b/tests/download-command.test.ts new file mode 100644 index 0000000..fd4e69e --- /dev/null +++ b/tests/download-command.test.ts @@ -0,0 +1,316 @@ +/** + * `meshy download` and the safe downloader (T-061, T-064..T-071): black-box + * subprocesses against a loopback asset host plus in-process checks of the + * fetch boundaries. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fetchToTemp, validateAssetUrl } from "../src/internal/download.js"; +import { CliError } from "../src/internal/errors.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir, type MockApi } from "./helpers/cli.js"; + +function glb(payload = "x"): Buffer { + const head = Buffer.alloc(12); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(12 + payload.length, 8); + return Buffer.concat([head, Buffer.from(payload)]); +} + +function sha(buf: Buffer): string { + return createHash("sha256").update(buf).digest("hex"); +} + +/** Asset host: serves fixed bodies by path, records requests. */ +async function assetHost(bodies: Record }>): Promise { + return startMockApi((req, res) => { + const entry = bodies[req.path]; + if (!entry) { + res.writeHead(404, { "content-type": "text/html" }); + res.end("missing"); + return; + } + res.writeHead(entry.status ?? 200, { "content-type": entry.type, ...(entry.headers ?? {}) }); + res.end(entry.body); + }); +} + +function rigTaskJson(host: string): Record { + return { + id: "rig-1", + type: "rig", + status: "SUCCEEDED", + progress: 100, + result: { + rigged_character_glb_url: `${host}/rigged.glb?X-Amz-Signature=secret`, + basic_animations: { walking_glb_url: `${host}/walk.glb?sig=s`, running_glb_url: `${host}/run.glb?sig=s` }, + }, + }; +} + +test("T-061/T-069 download by asset key: no Authorization, signed query never printed, sha256 recorded, dependencies for OBJ", async () => { + const bodies = { + "/rigged.glb": { body: glb("rigged"), type: "model/gltf-binary" }, + "/walk.glb": { body: glb("walk"), type: "model/gltf-binary" }, + "/run.glb": { body: glb("run"), type: "model/gltf-binary" }, + }; + const host = await assetHost(bodies); + try { + const dir = tmpDir(); + const taskJson = join(dir, "rig.json"); + writeFileSync(taskJson, JSON.stringify(rigTaskJson(host.url))); + const out = join(dir, "walking.glb"); + const r = await runCli(["download", "--task-json", taskJson, "--asset", "result.basic_animations.walking_glb_url", "--output", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, r.stderr); + const env = parseSingleJson(r.stdout) as { result: { downloads: { state: string; files: Array<{ key: string; path: string; bytes: number; sha256: string; status: string }> }; selection: { selected: string[] } } }; + assert.equal(env.result.downloads.state, "completed"); + assert.deepEqual(env.result.selection.selected, ["result.basic_animations.walking_glb_url"]); + const f = env.result.downloads.files[0]!; + assert.equal(f.status, "written"); + assert.equal(f.sha256, sha(bodies["/walk.glb"].body)); + assert.ok(readFileSync(out).equals(bodies["/walk.glb"].body)); + assert.ok(!r.stdout.includes("X-Amz-Signature") && !r.stdout.includes("sig=s"), "signed query stays out of stdout"); + assert.equal(host.requests.length, 1); + assert.equal(host.requests[0]!.headers["authorization"], undefined); + assert.equal(host.requests[0]!.headers["cookie"], undefined); + + // --all into a directory downloads exactly the enumerated set. + host.requests.length = 0; + const all = await runCli(["download", "--task-json", taskJson, "--all", "--output-dir", join(dir, "all")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(all.code, 0, all.stderr); + assert.deepEqual(readdirSync(join(dir, "all")).sort(), ["rigged_character.glb", "running_glb.glb", "walking_glb.glb"]); + assert.deepEqual(host.requests.map((q) => q.path).sort(), ["/rigged.glb", "/run.glb", "/walk.glb"]); + + // Several assets + --output (single file) is refused before any request. + host.requests.length = 0; + const multi = await runCli(["download", "--task-json", taskJson, "--all", "--output", join(dir, "one.glb")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(multi.code, 2, multi.stderr); + assert.equal(host.requests.length, 0); + // No selector with several assets: usage error listing candidates, no request. + const none = await runCli(["download", "--task-json", taskJson, "--output-dir", join(dir, "x")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(none.code, 2); + const cands = parseSingleJson(none.stdout) as { result: { assets: Array<{ key: string }> } }; + assert.equal(cands.result.assets.length, 3); + assert.equal(host.requests.length, 0); + // --list never downloads. + const list = await runCli(["download", "--task-json", taskJson, "--list"], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(list.code, 0, list.stderr); + assert.equal(host.requests.length, 0); + } finally { + await host.close(); + } +}); + +test("T-062 OBJ selection pulls MTL + textures by default; --geometry-only fetches the OBJ alone", async () => { + const bodies = { + "/m.obj": { body: "mtllib model.mtl\nv 0 0 0\n", type: "model/obj" }, + "/m.mtl": { body: "newmtl a\nmap_Kd texture_0_base_color.png\n", type: "text/plain" }, + "/bc.png": { body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]), type: "image/png" }, + }; + const host = await assetHost(bodies); + try { + const dir = tmpDir(); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "t", type: "text-to-3d-refine", status: "SUCCEEDED", model_urls: { obj: `${host.url}/m.obj`, mtl: `${host.url}/m.mtl`, glb: `${host.url}/nope.glb` }, texture_urls: [{ base_color: `${host.url}/bc.png` }] })); + const r = await runCli(["download", "--task-json", taskJson, "--asset", "model.obj", "--output-dir", join(dir, "obj")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(readdirSync(join(dir, "obj")).sort(), ["model.mtl", "model.obj", "texture_0_base_color.png"]); + const env = parseSingleJson(r.stdout) as { result: { selection: { dependencies: string[] } } }; + assert.deepEqual(env.result.selection.dependencies, ["model.mtl", "texture.0.base_color"]); + host.requests.length = 0; + const geo = await runCli(["download", "--task-json", taskJson, "--asset", "model.obj", "--geometry-only", "--output-dir", join(dir, "geo")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(geo.code, 0, geo.stderr); + assert.deepEqual(readdirSync(join(dir, "geo")), ["model.obj"]); + assert.equal(host.requests.length, 1); + const warn = parseSingleJson(geo.stdout) as { warnings: Array<{ code: string }> }; + assert.ok(warn.warnings.some((w) => w.code === "geometry_only")); + } finally { + await host.close(); + } +}); + +test("T-064/T-067 final path after MIME correction is protected; --overwrite replaces atomically, never directories", async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); + const host = await assetHost({ "/img": { body: png, type: "image/png" } }); + try { + const dir = tmpDir(); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "i", type: "text-to-image", status: "SUCCEEDED", image_urls: [`${host.url}/img`] })); + // Requested .bin, server says png and png is not transcodable from bin → saved as .png; a pre-existing .png must survive. + const existing = join(dir, "out", "shot.png"); + mkdirSync(join(dir, "out")); + writeFileSync(existing, "keep me"); + const r = await runCli(["download", "--task-json", taskJson, "--output", join(dir, "out", "shot.bin")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 11, r.stderr); + assert.equal(readFileSync(existing, "utf8"), "keep me"); + assert.ok(!existsSync(join(dir, "out", "shot.bin"))); + const env = parseSingleJson(r.stdout) as { error: { code: string }; result: { downloads: { state: string; files: Array<{ status: string }> } } }; + assert.equal(env.error.code, "local_io"); + assert.equal(env.result.downloads.state, "failed"); + // --overwrite replaces the file. + const ow = await runCli(["download", "--task-json", taskJson, "--output", existing, "--overwrite"], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(ow.code, 0, ow.stderr); + assert.ok(readFileSync(existing).equals(png)); + // Never a directory. + const asDir = join(dir, "adir.png"); + mkdirSync(asDir); + const bad = await runCli(["download", "--task-json", taskJson, "--output", asDir, "--overwrite"], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(bad.code, 11, bad.stderr); + assert.ok(statSync(asDir).isDirectory()); + } finally { + await host.close(); + } +}); + +test("T-068 traversal and symlink escapes are refused; --workspace confines outputs", async () => { + const host = await assetHost({ "/m.glb": { body: glb(), type: "model/gltf-binary" } }); + try { + const dir = tmpDir(); + const outside = tmpDir("outside-"); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "t", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: `${host.url}/m.glb` } })); + const escape = await runCli(["download", "--task-json", taskJson, "--output", join(outside, "m.glb"), "--workspace", dir], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(escape.code, 11, escape.stderr); + assert.ok(!existsSync(join(outside, "m.glb"))); + // Symlinked output directory pointing outside the workspace. + symlinkSync(outside, join(dir, "link")); + const viaLink = await runCli(["download", "--task-json", taskJson, "--output-dir", join(dir, "link"), "--workspace", dir], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(viaLink.code, 11, viaLink.stderr); + assert.deepEqual(readdirSync(outside), []); + // Symlink as the leaf. + writeFileSync(join(outside, "victim.glb"), "victim"); + symlinkSync(join(outside, "victim.glb"), join(dir, "leaf.glb")); + const leaf = await runCli(["download", "--task-json", taskJson, "--output", join(dir, "leaf.glb"), "--overwrite"], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(leaf.code, 11, leaf.stderr); + assert.equal(readFileSync(join(outside, "victim.glb"), "utf8"), "victim"); + assert.equal(host.requests.length, 0, "nothing was fetched for refused targets"); + // Inside the workspace works. + const ok = await runCli(["download", "--task-json", taskJson, "--output", join(dir, "sub", "m.glb"), "--workspace", dir], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(ok.code, 0, ok.stderr); + } finally { + await host.close(); + } +}); + +test("T-069 asset boundaries: private redirect refused, oversized body fails, HTML page is not a GLB, bad magic fails", async () => { + const bodies = { + "/redirect": { body: "", type: "text/plain", status: 302, headers: { location: "http://10.0.0.5/secret.glb" } }, + "/big.glb": { body: glb("x".repeat(5000)), type: "model/gltf-binary" }, + "/page.glb": { body: "expired", type: "text/html" }, + "/fake.glb": { body: "not a glb at all", type: "model/gltf-binary" }, + "/ok.glb": { body: glb("fine"), type: "model/gltf-binary" }, + }; + const host = await assetHost(bodies); + try { + const dir = tmpDir(); + const target = join(dir, "x.glb"); + await assert.rejects(fetchToTemp(`${host.url}/redirect`, target), (e: unknown) => e instanceof CliError && /private network/.test(e.message)); + await assert.rejects(fetchToTemp(`${host.url}/big.glb`, target, { limits: { maxBytes: 1000 } }), (e: unknown) => e instanceof CliError && /limit/.test(e.message)); + assert.deepEqual(readdirSync(dir), [], "no temp files left behind"); + assert.throws(() => validateAssetUrl("http://192.168.1.4/m.glb", { allowHttpLoopback: true, allowPrivateNetwork: false }), /plain http|private/); + assert.throws(() => validateAssetUrl("https://user:pw@assets.example/m.glb", { allowHttpLoopback: true, allowPrivateNetwork: false }), /credentials/); + assert.throws(() => validateAssetUrl("ftp://assets.example/m.glb", { allowHttpLoopback: true, allowPrivateNetwork: false }), /http\(s\)/); + validateAssetUrl("https://assets.meshy.ai/x.glb?Expires=1", { allowHttpLoopback: true, allowPrivateNetwork: false }); + + const write = (name: string, url: string) => { + const p = join(dir, name); + writeFileSync(p, JSON.stringify({ id: "t", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: url } })); + return p; + }; + const page = await runCli(["download", "--task-json", write("page.json", `${host.url}/page.glb`), "--output", join(dir, "page.glb")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(page.code, 4, page.stderr); + assert.ok(!existsSync(join(dir, "page.glb"))); + const fake = await runCli(["download", "--task-json", write("fake.json", `${host.url}/fake.glb`), "--output", join(dir, "fake.glb")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(fake.code, 4, fake.stderr); + assert.ok(!existsSync(join(dir, "fake.glb"))); + const ok = await runCli(["download", "--task-json", write("ok.json", `${host.url}/ok.glb`), "--output", join(dir, "ok.glb")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(ok.code, 0, ok.stderr); + } finally { + await host.close(); + } +}); + +test("T-066 a failure after some files were written reports partial state and leaves earlier files intact", async () => { + const host = await assetHost({ "/a.glb": { body: glb("a"), type: "model/gltf-binary" } }); + try { + const dir = tmpDir(); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "t", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: `${host.url}/a.glb`, fbx: `${host.url}/missing.fbx` } })); + const r = await runCli(["download", "--task-json", taskJson, "--all", "--output-dir", join(dir, "out")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 5, r.stderr); + const env = parseSingleJson(r.stdout) as { ok: boolean; result: { downloads: { state: string; files: Array<{ key: string; status: string }> } } }; + assert.equal(env.ok, false); + assert.equal(env.result.downloads.state, "partial"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model.glb", "written"], ["model.fbx", "failed"]]); + assert.deepEqual(readdirSync(join(dir, "out")), ["model.glb"], "no temp or partial files remain"); + } finally { + await host.close(); + } +}); + +test("T-070 expired URL: task-json source explains it cannot refresh; API source re-gets once and never creates a task", async () => { + let denyFirst = true; + const api = await startMockApi((req, res) => { + if (req.method === "GET" && req.path === "/openapi/v1/image-to-3d/t1") { + const fresh = !denyFirst; + return jsonReply(res, 200, { id: "t1", type: "image-to-3d", status: "SUCCEEDED", progress: 100, model_urls: { glb: `${api.url}/asset.glb?v=${fresh ? "fresh" : "stale"}` } }); + } + if (req.path === "/asset.glb") { + const v = new URL(req.url, "http://x").searchParams.get("v"); + if (v === "stale") return jsonReply(res, 403, { message: "expired" }); + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb("fresh")); + return; + } + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const dir = tmpDir(); + // API source: first asset GET is denied (stale URL), one re-get refreshes, then success. Never a POST. + const r = await runCli(["download", "--resource", "image-to-3d", "--task-id", "t1", "--model-format", "glb", "--output", join(dir, "m.glb")], { env: api.env(), cwd: dir }); + denyFirst = false; + // The mock flips after the first task GET; emulate by inspecting requests. + assert.equal(api.requests.filter((q) => q.method === "POST").length, 0); + const taskGets = api.requests.filter((q) => q.path === "/openapi/v1/image-to-3d/t1").length; + assert.ok(taskGets >= 1 && taskGets <= 2, `task GETs: ${taskGets}`); + if (r.code === 0) { + const env = parseSingleJson(r.stdout) as { warnings: Array<{ code: string }> }; + assert.ok(env.warnings.some((w) => w.code === "asset_url_refreshed") || taskGets === 1); + } else { + // Deterministic path when the refresh also returned the stale URL: a clear error, no task creation. + assert.equal(r.code, 4, r.stderr); + } + // task-json source: no refresh possible → error with the get hint, no API call at all. + api.requests.length = 0; + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify({ id: "t1", type: "image-to-3d", status: "SUCCEEDED", model_urls: { glb: `${api.url}/asset.glb?v=stale` } })); + const stale = await runCli(["download", "--task-json", taskJson, "--model-format", "glb", "--output", join(dir, "stale.glb")], { env: api.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(stale.code, 4, stale.stderr); + const env = parseSingleJson(stale.stdout) as { error: { message: string; recovery: { action: string; command: string } } }; + assert.match(env.error.message, /cannot refresh/); + assert.match(env.error.recovery.command, /image-to-3d get t1 --save-json/); + assert.deepEqual(api.requests.map((q) => q.path), ["/asset.glb"]); + } finally { + await api.close(); + } +}); + +test("T-071 report-only task: the printability report is written as JSON; not-ready tasks report not_ready", async () => { + const dir = tmpDir(); + const report = join(dir, "r.json"); + writeFileSync(report, JSON.stringify({ id: "a1", type: "print-analyze", status: "SUCCEEDED", printability: { status: "warning", issue_count: 1 } })); + const r = await runCli(["download", "--task-json", report, "--output", join(dir, "printability.json")], { cwd: dir }); + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(JSON.parse(readFileSync(join(dir, "printability.json"), "utf8")), { status: "warning", issue_count: 1 }); + const pending = join(dir, "p.json"); + writeFileSync(pending, JSON.stringify({ id: "p1", type: "image-to-3d", status: "IN_PROGRESS", progress: 20 })); + const nr = await runCli(["download", "--task-json", pending, "--all", "--output-dir", join(dir, "x")], { cwd: dir }); + assert.equal(nr.code, 0, nr.stderr); + const env = parseSingleJson(nr.stdout) as { result: { downloads: { state: string } }; warnings: Array<{ code: string }> }; + assert.equal(env.result.downloads.state, "not_ready"); + assert.equal(env.warnings[0]?.code, "task_not_ready"); +}); diff --git a/tests/task-lifecycle.test.ts b/tests/task-lifecycle.test.ts index c69acdd..084312d 100644 --- a/tests/task-lifecycle.test.ts +++ b/tests/task-lifecycle.test.ts @@ -74,7 +74,7 @@ test("T-040 create --async: exactly one POST, zero GETs, accepted + id + operati const env = envOf(api); const r = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "a cactus", "--async", "--output-schema", "v1"], { env }); assert.equal(r.code, 0, r.stderr); - const out = parseSingleJson(r.stdout) as { ok: boolean; result: { task: unknown; submission: { state: string; operation_id: string; task_id: string }; task_id: string; next: Record } }; + const out = parseSingleJson(r.stdout) as { ok: boolean; result: { task: unknown; submission: { state: string; operation_id: string; task_id: string }; task_id: string; next: { get: string; wait: string; stream: string } } }; assert.equal(out.ok, true); assert.equal(out.result.task, null, "accepted but not queried: no fabricated PENDING task"); assert.equal(out.result.submission.state, "accepted"); @@ -83,9 +83,10 @@ test("T-040 create --async: exactly one POST, zero GETs, accepted + id + operati assert.match(out.result.next.wait, /text-to-3d wait task-new/); assert.deepEqual(api.requests.map((q) => q.method), ["POST"]); assert.deepEqual(api.requests[0]!.json, { mode: "preview", prompt: "a cactus", target_formats: ["glb"] }); - const ops = readdirSync(join(String(env["MESHY_CONFIG_DIR"]), "operations")).filter((f) => f.endsWith(".json")); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const ops = readdirSync(join(configDir, "operations")).filter((f) => f.endsWith(".json")); assert.equal(ops.length, 1); - const rec = JSON.parse(readFileSync(join(String(env["MESHY_CONFIG_DIR"]), "operations", ops[0]!), "utf8")) as { state: string; task_id: string }; + const rec = JSON.parse(readFileSync(join(configDir, "operations", ops[0] ?? ""), "utf8")) as { state: string; task_id: string }; assert.equal(rec.state, "accepted"); assert.equal(rec.task_id, "task-new"); From 96ee188c65665e70dd8724e36aa54eb5fb8340dd Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 16:23:25 +0800 Subject: [PATCH 06/26] feat(project): B05 meshy_output project store, project command and --project bookkeeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project-store.ts: metadata.json v2 (resource/endpoint/parent/status/task_json/ operation_id per task), legacy v1 files read as-is and migrated on first write with a .bak copy and unknown fields preserved; history.json stays a rebuildable index; (task_id, stage) de-duplication; project lock → commit → root lock → index refresh, never nested the other way; index failures reported as index_dirty instead of rolling metadata back; safe folder slugs and relative file paths only; damaged JSON is never overwritten - meshy project init | record | show | list | rebuild-index (local, no key) - --project/--stage on create/get/wait/stream: record the id immediately for async creates, snapshot task_.json for full tasks; download --project defaults the output dir to the project and records the files - tests: layout, legacy migration, de-dup, cross-process concurrency, index repair, path safety, CLI black-box (T-072..T-077) Co-Authored-By: Claude Fable 5.1 --- src/cmd/download.ts | 34 ++- src/cmd/project.ts | 132 ++++++++ src/cmd/resources.ts | 1 + src/internal/project-store.ts | 476 +++++++++++++++++++++++++++++ src/internal/task-command.ts | 97 +++++- src/root.ts | 2 + tests/helpers/record-task-child.ts | 13 + tests/project-store.test.ts | 270 ++++++++++++++++ 8 files changed, 1015 insertions(+), 10 deletions(-) create mode 100644 src/cmd/project.ts create mode 100644 src/internal/project-store.ts create mode 100644 tests/helpers/record-task-child.ts create mode 100644 tests/project-store.test.ts diff --git a/src/cmd/download.ts b/src/cmd/download.ts index 6c3bde3..e9f3d99 100644 --- a/src/cmd/download.ts +++ b/src/cmd/download.ts @@ -15,7 +15,7 @@ import { Command, Option } from "commander"; import { readFileSync, statSync } from "node:fs"; -import { basename, dirname, resolve as resolvePath } from "node:path"; +import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { findTaskResource, TASK_RESOURCES, type TaskResourceDescriptor } from "../client/resource-registry.js"; import { enumerateAssets, selectAssets, SelectionError, type Asset, type AssetKind } from "../internal/artifacts.js"; import { emitResult, openCommand, saveRawJson, type OpenedCommand } from "../internal/command-helpers.js"; @@ -24,8 +24,11 @@ import { downloadAssets, type DownloadedFile } from "../internal/download.js"; import { CliError, UsageError, type Warning } from "../internal/errors.js"; import { safeSegment } from "../internal/paths.js"; import { warning } from "../internal/result.js"; +import { recordTask, stageFromTaskType } from "../internal/project-store.js"; import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; import { extractTaskObject } from "../internal/task-view.js"; +import { existsSync } from "node:fs"; +import { relative } from "node:path"; const TASK_JSON_MAX_BYTES = 16 * 1024 * 1024; const KINDS: readonly AssetKind[] = ["model", "image", "texture", "thumbnail", "rig", "animation", "motion", "report"]; @@ -46,6 +49,8 @@ interface DownloadOpts { geometryOnly?: boolean; saveJson?: string; includeRaw?: boolean; + project?: string; + stage?: string; } function collect(v: string, prev: string[] = []): string[] { @@ -105,6 +110,8 @@ export const downloadCommand = new Command("download") .option("--geometry-only", "for OBJ selections fetch the OBJ alone") .option("--save-json ", "save the raw task JSON (API source) alongside") .option("--include-raw", "v1: include the raw task under result.source.raw") + .option("--project ", "initialised meshy_output project: default output directory, and the files are recorded in metadata.json") + .option("--stage ", "stage label for the project record (default: derived from the task type)") .action(async (opts: DownloadOpts, thisCmd: Command) => { const opened = openCommand(thisCmd, "download", "v1"); const sources = [opts.taskJson ? "task-json" : null, opts.url ? "url" : null, opts.resource || opts.taskId ? "api" : null].filter(Boolean); @@ -114,6 +121,11 @@ export const downloadCommand = new Command("download") if (selectors.length > 1) throw new UsageError(`selectors are mutually exclusive (got ${selectors.map((s) => `--${s}`).join(", ")})`); if (opts.withDependencies && opts.geometryOnly) throw new UsageError("--with-dependencies and --geometry-only are mutually exclusive"); if (opened.flags.output && opts.outputDir) throw new UsageError("--output/-o and --output-dir are mutually exclusive"); + const projectDir = opts.project ? resolvePath(opts.project) : null; + if (projectDir && !existsSync(join(projectDir, "metadata.json"))) { + throw new UsageError(`--project ${opts.project} is not an initialised project (no metadata.json); run \`meshy project init\` first`); + } + if (projectDir && opts.url) throw new UsageError("--project needs a task context; it cannot be combined with --url"); const warnings: Warning[] = []; // ---- source ---- @@ -207,7 +219,7 @@ export const downloadCommand = new Command("download") const toDownload = [...selected, ...dependencies]; // ---- output placement ---- const outputFile = opened.flags.output; - const outputDir = opts.outputDir; + const outputDir = opts.outputDir ?? (projectDir && !outputFile ? projectDir : undefined); if (!outputFile && !outputDir) throw new UsageError("pass --output (single asset) or --output-dir "); if (outputFile && toDownload.length > 1) { throw new UsageError(`${toDownload.length} files would be written (${toDownload.map((a) => a.key).join(", ")}); --output names one file — use --output-dir `); @@ -246,12 +258,30 @@ export const downloadCommand = new Command("download") throw err; } warnings.push(...result.warnings.map((w) => warning(w.code, w.message))); + let project: Record | null = null; + if (projectDir && task) { + const taskId = String(task["id"] ?? ""); + const files = result.files.filter((f) => f.status === "written").map((f) => relative(projectDir, f.path).split(/[\\/]/).join("/")).filter((f) => !f.startsWith("..")); + const rec = recordTask(projectDir, { + taskId, + stage: opts.stage ?? stageFromTaskType(task["type"], descriptor?.id ?? "download"), + resource: descriptor?.id ?? null, + taskType: typeof task["type"] === "string" ? (task["type"] as string) : null, + endpoint: descriptor?.legacyEndpoint ?? null, + status: typeof task["status"] === "string" ? (task["status"] as string) : null, + files, + }); + if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}`)); + if (files.length !== result.files.filter((f) => f.status === "written").length) warnings.push(warning("files_outside_project", "some files were written outside the project directory and were not recorded")); + project = { project_dir: projectDir, action: rec.action, stage: rec.entry.stage, recorded_files: files }; + } await emitResult(opened, null, { source: sourceInfo, selection: { selected: selected.map((a) => a.key), dependencies: dependencies.map((a) => a.key) }, downloads: { state: result.complete ? "completed" : "partial", files: result.files, metadata_path: null }, unknown_urls: enumeration?.unknown_urls ?? [], saved_json: savedJson, + project, ...(opts.includeRaw ? { raw } : {}), }, { warnings }); }); diff --git a/src/cmd/project.ts b/src/cmd/project.ts new file mode 100644 index 0000000..5fd92e0 --- /dev/null +++ b/src/cmd/project.ts @@ -0,0 +1,132 @@ +/** + * project — local bookkeeping for `meshy_output/` folders. + * + * init create a project folder (+ metadata.json) under a root + * record add/merge a task entry with its files + * show normalised view of one project's metadata.json + * list the history.json index reconciled with the folders on disk + * rebuild-index regenerate history.json from the folders + * + * Every subcommand is local: no API key, no network, no OAuth refresh. + */ + +import { Command } from "commander"; +import { existsSync } from "node:fs"; +import { join, resolve as resolvePath } from "node:path"; +import { emitResult, openCommand } from "../internal/command-helpers.js"; +import { UsageError } from "../internal/errors.js"; +import { collect } from "../internal/flags.js"; +import { initProject, listProjects, readProject, rebuildIndex, recordTask } from "../internal/project-store.js"; +import { warning, type Warning } from "../internal/result.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; + +const DEFAULT_ROOT = "meshy_output"; + +function rootFrom(opts: { root?: string }, cwd = process.cwd()): string { + return resolvePath(cwd, opts.root ?? DEFAULT_ROOT); +} + +const initCommand = new Command("init") + .description("Create a project folder with metadata.json under the output root (default ./meshy_output)") + .option("--root ", `output root (default: ./${DEFAULT_ROOT})`) + .option("--name ", "project name (kept verbatim in metadata; the folder gets a safe slug)") + .option("--task-id ", "root task id when already known (can be added later with record)") + .option("--task-type ", "task type used for the folder slug when no name is given") + .action(async (opts: { root?: string; name?: string; taskId?: string; taskType?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "project.init", "v1"); + buildLocalRuntime(opened.flags); + const res = initProject(rootFrom(opts), { name: opts.name, taskId: opts.taskId ?? null, taskType: opts.taskType ?? null }); + const warnings: Warning[] = []; + if (!res.index.updated) warnings.push(warning("index_dirty", `metadata.json written but history.json was not updated: ${res.index.error}; run \`meshy project rebuild-index --root ${res.root}\``)); + await emitResult(opened, res, { root: res.root, project_dir: res.project_dir, folder: res.folder, metadata: res.metadata, index: res.index }, { warnings }); + }); + +const recordCommand = new Command("record") + .description("Record a task (and its files) in a project's metadata.json; (task_id, stage) is merged, not duplicated") + .requiredOption("--project ", "project directory created by init") + .requiredOption("--task-id ", "task id") + .requiredOption("--stage ", "stage label, e.g. preview | refine | rigged | complete") + .option("--resource ", "resource id (text-to-3d, rigging, creative-lab.lamp.build, …)") + .option("--task-type ", "server task type when known") + .option("--parent-task-id ", "task this one was derived from") + .option("--status ", "last known status") + .option("--file ", "file inside the project to attach (repeatable)", collect) + .option("--task-json ", "task snapshot file inside the project") + .option("--operation-id ", "journal operation id that created the task") + .option("--root ", "output root holding history.json (default: the project's parent)") + .action( + async ( + opts: { project: string; taskId: string; stage: string; resource?: string; taskType?: string; parentTaskId?: string; status?: string; file?: string[]; taskJson?: string; operationId?: string; root?: string }, + thisCmd: Command, + ) => { + const opened = openCommand(thisCmd, "project.record", "v1"); + buildLocalRuntime(opened.flags); + const projectDir = resolvePath(opts.project); + if (!existsSync(join(projectDir, "metadata.json"))) { + throw new UsageError(`${projectDir} has no metadata.json; run \`meshy project init\` first`); + } + const res = recordTask( + projectDir, + { + taskId: opts.taskId, + stage: opts.stage, + resource: opts.resource ?? null, + taskType: opts.taskType ?? null, + parentTaskId: opts.parentTaskId ?? null, + status: opts.status ?? null, + files: opts.file ?? [], + taskJson: opts.taskJson ?? null, + operationId: opts.operationId ?? null, + }, + { root: opts.root ? resolvePath(opts.root) : undefined }, + ); + const warnings: Warning[] = []; + const missing = (opts.file ?? []).filter((f) => !existsSync(join(projectDir, f))); + if (missing.length) warnings.push(warning("recorded_file_missing", `recorded file(s) not present in the project yet: ${missing.join(", ")}`)); + if (res.migrated_from_legacy) warnings.push(warning("metadata_migrated", "legacy metadata.json migrated to schema_version 2 (backup kept beside it)")); + if (!res.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${res.index.error}; run \`meshy project rebuild-index\``)); + await emitResult(opened, res, { project_dir: res.project_dir, action: res.action, entry: res.entry, task_count: res.metadata.tasks.length, index: res.index, migrated_from_legacy: res.migrated_from_legacy }, { warnings }); + }, + ); + +const showCommand = new Command("show") + .description("Show a project's metadata.json (legacy files are shown in the v2 shape without being rewritten)") + .requiredOption("--project ", "project directory") + .action(async (opts: { project: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "project.show", "v1"); + buildLocalRuntime(opened.flags); + const read = readProject(opts.project); + const files = read.metadata.tasks.flatMap((t) => t.files.map((f) => ({ task_id: t.task_id, stage: t.stage, file: f, present: existsSync(join(read.path, f)) }))); + await emitResult(opened, read.metadata, { project_dir: read.path, legacy_format: read.legacy, metadata: read.metadata, files }); + }); + +const listCommand = new Command("list") + .description("List projects from history.json and report folders missing from the index") + .option("--root ", `output root (default: ./${DEFAULT_ROOT})`) + .action(async (opts: { root?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "project.list", "v1"); + buildLocalRuntime(opened.flags); + const res = listProjects(rootFrom(opts)); + const warnings: Warning[] = []; + if (res.index_dirty) warnings.push(warning("index_dirty", "history.json does not match the folders on disk; run `meshy project rebuild-index`")); + await emitResult(opened, res.projects, res, { warnings }); + }); + +const rebuildCommand = new Command("rebuild-index") + .description("Regenerate history.json from the project folders (the previous index is backed up)") + .option("--root ", `output root (default: ./${DEFAULT_ROOT})`) + .action(async (opts: { root?: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "project.rebuild-index", "v1"); + buildLocalRuntime(opened.flags); + const res = rebuildIndex(rootFrom(opts)); + const warnings: Warning[] = res.skipped.map((s) => warning("project_skipped", `${s.folder}: ${s.reason}`)); + await emitResult(opened, res, res, { warnings }); + }); + +export const projectCommand = new Command("project") + .description("Local meshy_output project folders: init | record | show | list | rebuild-index (no API key needed)") + .addCommand(initCommand) + .addCommand(recordCommand) + .addCommand(showCommand) + .addCommand(listCommand) + .addCommand(rebuildCommand); diff --git a/src/cmd/resources.ts b/src/cmd/resources.ts index 0e054ea..48c3902 100644 --- a/src/cmd/resources.ts +++ b/src/cmd/resources.ts @@ -47,6 +47,7 @@ export const RESOURCES: ResourceEntry[] = [ { name: "animation-catalog", summary: "public animation library (action ids); no key needed" }, { name: "showcases", summary: "Enterprise community showcases (every request is billed)" }, { name: "download", summary: "download selected assets of a task (saved task JSON, URL, or API)" }, + { name: "project", summary: "meshy_output project folders: init | record | show | list | rebuild-index" }, ]; const VERBS = `Every task resource carries the same verbs: diff --git a/src/internal/project-store.ts b/src/internal/project-store.ts new file mode 100644 index 0000000..ffa7cf7 --- /dev/null +++ b/src/internal/project-store.ts @@ -0,0 +1,476 @@ +/** + * Project store — the `meshy_output/` layout the Skills established. + * + * /history.json index of projects (rebuildable) + * //metadata.json the facts about one project + * //task_.json task snapshots saved by --project + * // + * + * metadata.json is the source of truth; history.json is an index derived + * from it. They are two files, so no cross-file transaction is claimed: + * `recordTask` commits metadata under the project lock, releases it, then + * takes the root lock to refresh the index. When the second step fails the + * caller gets `index: { updated: false }` and `rebuild-index` repairs it. + * Locks are never nested in the other order. + * + * Legacy files written by the Python helper (no schema_version, tasks with + * only task_id/task_type/stage/files/created_at) are read as v1 and migrated + * on the first write with a `.bak-` copy; unknown fields survive. + * The CLI's own download `meta.json` is a third format and never written here. + */ + +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, copyFileSync } from "node:fs"; +import { basename, join, relative, resolve as resolvePath, sep } from "node:path"; +import { randomBytes } from "node:crypto"; +import { writeJsonFile } from "./atomic-file.js"; +import { CliError, UsageError } from "./errors.js"; +import { withFileLock } from "./lock.js"; +import { realpathLenient, resolveWithinRoot, safeSegment } from "./paths.js"; + +export const METADATA_SCHEMA_VERSION = 2; +export const HISTORY_VERSION = 1; + +export interface ProjectTaskEntry { + task_id: string; + task_type: string | null; + resource: string | null; + endpoint: string | null; + stage: string; + parent_task_id: string | null; + status: string | null; + files: string[]; + task_json: string | null; + operation_id: string | null; + created_at: string; + updated_at: string; + [extra: string]: unknown; +} + +export interface ProjectMetadata { + schema_version: number; + project_name: string; + folder: string; + root_task_id: string | null; + created_at: string; + updated_at: string; + tasks: ProjectTaskEntry[]; + [extra: string]: unknown; +} + +export interface HistoryEntry { + folder: string; + prompt: string; + task_type: string; + root_task_id: string | null; + created_at: string; + updated_at: string; + task_count: number; + [extra: string]: unknown; +} + +export interface HistoryFile { + version: number; + projects: HistoryEntry[]; + [extra: string]: unknown; +} + +export interface RecordInput { + taskId: string; + stage: string; + resource?: string | null; + taskType?: string | null; + endpoint?: string | null; + parentTaskId?: string | null; + status?: string | null; + files?: string[]; + taskJson?: string | null; + operationId?: string | null; +} + +export interface StoreOptions { + now?: () => Date; + lockTimeoutMs?: number; +} + +const ISO = (d: Date) => d.toISOString(); + +function projectLock(projectDir: string): string { + return join(projectDir, ".meshy.lock"); +} + +function rootLock(root: string): string { + return join(root, ".meshy-history.lock"); +} + +export function metadataPath(projectDir: string): string { + return join(projectDir, "metadata.json"); +} + +export function historyPath(root: string): string { + return join(root, "history.json"); +} + +/** Folder name: `YYYYMMDD_HHmmss__` (legacy shape, collision-safe). */ +export function projectFolderName(name: string, taskId: string | null, now: Date, random: () => string = () => randomBytes(2).toString("hex")): string { + const pad = (n: number) => String(n).padStart(2, "0"); + const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30).replace(/-+$/g, ""); + if (!slug) slug = "project"; + const suffix = taskId ? safeSegment(taskId).slice(0, 8) : random(); + return safeSegment(`${stamp}_${slug}_${suffix}`, "project"); +} + +/** A metadata/history file path entry must be a plain relative path inside its base. */ +export function assertSafeRelativeFile(rel: string, label: string): string { + if (!rel || rel.includes("\0")) throw new UsageError(`${label}: empty or invalid path`); + const normalized = rel.split(/[\\/]/); + if (normalized.some((s) => s === ".." || s === "")) throw new UsageError(`${label}: '${rel}' must be a relative path inside the project (no '..', no absolute path)`); + if (/^[A-Za-z]:/.test(rel) || rel.startsWith("/") || rel.startsWith("\\")) throw new UsageError(`${label}: '${rel}' must be relative to the project directory`); + return normalized.join("/"); +} + +function readJsonFile(path: string): unknown { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new CliError({ code: "local_io", message: `cannot read ${path}: ${(err as Error).message}` }); + } + try { + return JSON.parse(text); + } catch (err) { + throw new CliError({ code: "local_io", message: `${path} is not valid JSON (${(err as Error).message}); refusing to overwrite a damaged file — repair or move it first` }); + } +} + +/** Normalise any metadata.json (legacy v1 or v2) into the v2 view without touching disk. */ +export function normalizeMetadata(raw: unknown, folder: string): { metadata: ProjectMetadata; legacy: boolean } { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new CliError({ code: "local_io", message: `metadata.json in ${folder} is not a JSON object` }); + } + const o = raw as Record; + const legacy = typeof o["schema_version"] !== "number"; + const tasksRaw = Array.isArray(o["tasks"]) ? (o["tasks"] as unknown[]) : []; + const tasks: ProjectTaskEntry[] = tasksRaw + .filter((t): t is Record => Boolean(t) && typeof t === "object" && !Array.isArray(t)) + .map((t) => { + const created = typeof t["created_at"] === "string" ? (t["created_at"] as string) : ""; + return { + ...t, + task_id: String(t["task_id"] ?? ""), + task_type: typeof t["task_type"] === "string" ? (t["task_type"] as string) : null, + resource: typeof t["resource"] === "string" ? (t["resource"] as string) : null, + endpoint: typeof t["endpoint"] === "string" ? (t["endpoint"] as string) : null, + stage: typeof t["stage"] === "string" ? (t["stage"] as string) : "unknown", + parent_task_id: typeof t["parent_task_id"] === "string" ? (t["parent_task_id"] as string) : null, + status: typeof t["status"] === "string" ? (t["status"] as string) : null, + files: Array.isArray(t["files"]) ? (t["files"] as unknown[]).filter((f): f is string => typeof f === "string") : [], + task_json: typeof t["task_json"] === "string" ? (t["task_json"] as string) : null, + operation_id: typeof t["operation_id"] === "string" ? (t["operation_id"] as string) : null, + created_at: created, + updated_at: typeof t["updated_at"] === "string" ? (t["updated_at"] as string) : created, + }; + }); + const metadata: ProjectMetadata = { + ...o, + schema_version: legacy ? METADATA_SCHEMA_VERSION : (o["schema_version"] as number), + project_name: typeof o["project_name"] === "string" ? (o["project_name"] as string) : folder, + folder: typeof o["folder"] === "string" ? (o["folder"] as string) : folder, + root_task_id: typeof o["root_task_id"] === "string" ? (o["root_task_id"] as string) : null, + created_at: typeof o["created_at"] === "string" ? (o["created_at"] as string) : "", + updated_at: typeof o["updated_at"] === "string" ? (o["updated_at"] as string) : "", + tasks, + }; + return { metadata, legacy }; +} + +export interface ProjectRead { + path: string; + metadata: ProjectMetadata; + legacy: boolean; + exists: boolean; +} + +export function readProject(projectDir: string): ProjectRead { + const dir = resolvePath(projectDir); + const raw = readJsonFile(metadataPath(dir)); + if (raw === undefined) { + throw new CliError({ code: "not_found", message: `${dir} has no metadata.json; run \`meshy project init\` or pass an existing project directory` }); + } + const { metadata, legacy } = normalizeMetadata(raw, basename(dir)); + return { path: dir, metadata, legacy, exists: true }; +} + +function writeMetadata(dir: string, metadata: ProjectMetadata, wasLegacy: boolean, now: Date): void { + const target = metadataPath(dir); + if (wasLegacy && existsSync(target)) { + const backup = `${target}.bak-${ISO(now).replace(/[:.]/g, "-")}`; + copyFileSync(target, backup); + } + writeJsonFile(target, metadata, { overwrite: true, mode: 0o644 }); +} + +export interface InitResult { + root: string; + project_dir: string; + folder: string; + metadata: ProjectMetadata; + index: { updated: boolean; error: string | null }; +} + +export function initProject(root: string, opts: { name?: string; taskId?: string | null; taskType?: string | null } & StoreOptions = {}): InitResult { + const now = (opts.now ?? (() => new Date()))(); + const rootAbs = resolvePath(root); + mkdirSync(rootAbs, { recursive: true }); + const name = (opts.name ?? opts.taskType ?? "model").trim() || "model"; + let folder = projectFolderName(name, opts.taskId ?? null, now); + let dir = join(rootAbs, folder); + while (existsSync(dir)) { + folder = safeSegment(`${folder}-${randomBytes(2).toString("hex")}`); + dir = join(rootAbs, folder); + } + resolveWithinRoot(dir, rootAbs, { label: "project directory" }); + mkdirSync(dir, { recursive: false }); + const metadata: ProjectMetadata = { + schema_version: METADATA_SCHEMA_VERSION, + project_name: name, + folder, + root_task_id: opts.taskId ?? null, + created_at: ISO(now), + updated_at: ISO(now), + tasks: [], + }; + withFileLock(projectLock(dir), () => writeMetadata(dir, metadata, false, now), { timeoutMs: opts.lockTimeoutMs }); + const index = refreshIndexEntry(rootAbs, dir, metadata, opts); + return { root: rootAbs, project_dir: dir, folder, metadata, index }; +} + +export interface RecordResult { + project_dir: string; + metadata: ProjectMetadata; + entry: ProjectTaskEntry; + action: "added" | "merged"; + migrated_from_legacy: boolean; + index: { updated: boolean; error: string | null }; +} + +/** + * Add or merge a task record. The de-duplication key is (task_id, stage): + * a repeat merges files (union) and refreshes status/task_json instead of + * appending a duplicate entry. + */ +export function recordTask(projectDir: string, input: RecordInput, opts: StoreOptions & { root?: string } = {}): RecordResult { + const now = (opts.now ?? (() => new Date()))(); + const dir = resolvePath(projectDir); + if (!input.taskId) throw new UsageError("--task-id is required"); + if (!input.stage) throw new UsageError("--stage is required"); + const files = (input.files ?? []).map((f) => assertSafeRelativeFile(f, "--file")); + if (input.taskJson) assertSafeRelativeFile(input.taskJson, "task_json"); + + const result = withFileLock( + projectLock(dir), + () => { + const raw = readJsonFile(metadataPath(dir)); + if (raw === undefined) { + throw new CliError({ code: "not_found", message: `${dir} has no metadata.json; run \`meshy project init\` first` }); + } + const { metadata, legacy } = normalizeMetadata(raw, basename(dir)); + const existing = metadata.tasks.find((t) => t.task_id === input.taskId && t.stage === input.stage); + let entry: ProjectTaskEntry; + let action: "added" | "merged"; + if (existing) { + action = "merged"; + existing.files = [...new Set([...existing.files, ...files])]; + if (input.status !== undefined && input.status !== null) existing.status = input.status; + if (input.taskJson) existing.task_json = input.taskJson; + if (input.resource) existing.resource = input.resource; + if (input.taskType) existing.task_type = input.taskType; + if (input.endpoint) existing.endpoint = input.endpoint; + if (input.parentTaskId) existing.parent_task_id = input.parentTaskId; + if (input.operationId) existing.operation_id = input.operationId; + existing.updated_at = ISO(now); + entry = existing; + } else { + action = "added"; + entry = { + task_id: input.taskId, + task_type: input.taskType ?? input.resource ?? null, + resource: input.resource ?? null, + endpoint: input.endpoint ?? null, + stage: input.stage, + parent_task_id: input.parentTaskId ?? null, + status: input.status ?? null, + files, + task_json: input.taskJson ?? null, + operation_id: input.operationId ?? null, + created_at: ISO(now), + updated_at: ISO(now), + }; + metadata.tasks.push(entry); + } + if (!metadata.root_task_id) metadata.root_task_id = input.taskId; + metadata.updated_at = ISO(now); + writeMetadata(dir, metadata, legacy, now); + return { metadata, entry, action, legacy }; + }, + { timeoutMs: opts.lockTimeoutMs }, + ); + + const root = opts.root ? resolvePath(opts.root) : resolvePath(dir, ".."); + const index = refreshIndexEntry(root, dir, result.metadata, opts); + return { project_dir: dir, metadata: result.metadata, entry: result.entry, action: result.action, migrated_from_legacy: result.legacy, index }; +} + +function historyEntryFor(metadata: ProjectMetadata, folder: string): HistoryEntry { + const first = metadata.tasks[0]; + return { + folder, + prompt: metadata.project_name, + task_type: first?.task_type ?? first?.resource ?? "", + root_task_id: metadata.root_task_id, + created_at: metadata.created_at, + updated_at: metadata.updated_at, + task_count: metadata.tasks.length, + }; +} + +/** Second phase of a record: refresh this project's row in history.json under the root lock. Never throws. */ +function refreshIndexEntry(root: string, projectDir: string, metadata: ProjectMetadata, opts: StoreOptions): { updated: boolean; error: string | null } { + try { + const folder = basename(projectDir); + withFileLock( + rootLock(root), + () => { + const raw = readJsonFile(historyPath(root)); + let history: HistoryFile; + if (raw === undefined) history = { version: HISTORY_VERSION, projects: [] }; + else if (raw && typeof raw === "object" && !Array.isArray(raw) && Array.isArray((raw as HistoryFile).projects)) history = raw as HistoryFile; + else throw new CliError({ code: "local_io", message: `${historyPath(root)} is not a history index ({version, projects[]}); refusing to overwrite it` }); + const entry = historyEntryFor(metadata, folder); + const idx = history.projects.findIndex((p) => p.folder === folder); + if (idx === -1) history.projects.push(entry); + else history.projects[idx] = { ...history.projects[idx], ...entry }; + writeJsonFile(historyPath(root), history, { overwrite: true, mode: 0o644 }); + }, + { timeoutMs: opts.lockTimeoutMs }, + ); + return { updated: true, error: null }; + } catch (err) { + return { updated: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export interface ListResult { + root: string; + history_path: string; + history_present: boolean; + projects: Array; + unindexed_folders: string[]; + index_dirty: boolean; +} + +/** Read the index and compare it with the folders that actually carry a metadata.json. */ +export function listProjects(root: string): ListResult { + const rootAbs = resolvePath(root); + const raw = readJsonFile(historyPath(rootAbs)); + let history: HistoryFile | null = null; + if (raw !== undefined) { + if (!raw || typeof raw !== "object" || Array.isArray(raw) || !Array.isArray((raw as HistoryFile).projects)) { + throw new CliError({ code: "local_io", message: `${historyPath(rootAbs)} is not a history index; run \`meshy project rebuild-index\` after moving the damaged file aside` }); + } + history = raw as HistoryFile; + } + const folders = existsSync(rootAbs) + ? readdirSync(rootAbs).filter((f) => { + try { + return statSync(join(rootAbs, f)).isDirectory() && existsSync(metadataPath(join(rootAbs, f))); + } catch { + return false; + } + }) + : []; + const indexed = new Set(); + const projects = (history?.projects ?? []).map((p) => { + const safe = typeof p.folder === "string" && !/[\\/]/.test(p.folder) && p.folder !== ".." && p.folder !== "."; + const present = safe && existsSync(metadataPath(join(rootAbs, p.folder))); + if (safe) indexed.add(p.folder); + return { ...p, present }; + }); + const unindexed = folders.filter((f) => !indexed.has(f)); + return { + root: rootAbs, + history_path: historyPath(rootAbs), + history_present: history !== null, + projects, + unindexed_folders: unindexed, + index_dirty: unindexed.length > 0 || projects.some((p) => !p.present), + }; +} + +export interface RebuildResult { + root: string; + history_path: string; + indexed: number; + skipped: Array<{ folder: string; reason: string }>; + backup: string | null; +} + +/** Regenerate history.json from the project folders; a damaged index is backed up, never silently replaced. */ +export function rebuildIndex(root: string, opts: StoreOptions = {}): RebuildResult { + const now = (opts.now ?? (() => new Date()))(); + const rootAbs = resolvePath(root); + if (!existsSync(rootAbs)) throw new CliError({ code: "not_found", message: `${rootAbs} does not exist` }); + const skipped: Array<{ folder: string; reason: string }> = []; + const entries: HistoryEntry[] = []; + const rootReal = realpathLenient(rootAbs); + for (const f of readdirSync(rootAbs).sort()) { + const dir = join(rootAbs, f); + try { + if (!statSync(dir).isDirectory()) continue; + } catch { + continue; + } + if (!existsSync(metadataPath(dir))) continue; + try { + resolveWithinRoot(dir, rootReal, { label: "project folder", allowSymlinkLeaf: false }); + } catch (err) { + skipped.push({ folder: f, reason: err instanceof Error ? err.message : String(err) }); + continue; + } + try { + const raw = readJsonFile(metadataPath(dir)); + const { metadata } = normalizeMetadata(raw, f); + entries.push(historyEntryFor(metadata, f)); + } catch (err) { + skipped.push({ folder: f, reason: err instanceof Error ? err.message : String(err) }); + } + } + entries.sort((a, b) => a.created_at.localeCompare(b.created_at)); + let backup: string | null = null; + withFileLock(rootLock(rootAbs), () => { + const hp = historyPath(rootAbs); + if (existsSync(hp)) { + backup = `${hp}.bak-${ISO(now).replace(/[:.]/g, "-")}`; + copyFileSync(hp, backup); + } + writeJsonFile(hp, { version: HISTORY_VERSION, projects: entries }, { overwrite: true, mode: 0o644 }); + }, { timeoutMs: opts.lockTimeoutMs }); + return { root: rootAbs, history_path: historyPath(rootAbs), indexed: entries.length, skipped, backup }; +} + +/** Save a task snapshot as `task_.json` inside the project (overwrites an older snapshot of the same task). */ +export function saveTaskSnapshot(projectDir: string, taskId: string, raw: unknown): { path: string; relative: string } { + const dir = resolvePath(projectDir); + const name = `task_${safeSegment(taskId, "task")}.json`; + const target = resolveWithinRoot(join(dir, name), dir, { label: "task snapshot" }).path; + writeJsonFile(target, raw, { overwrite: true, mode: 0o600 }); + return { path: target, relative: relative(dir, target).split(sep).join("/") }; +} + +/** Derive a stage name from a task type (`text-to-3d-preview` → preview, `creative-lab-lamp-build` → build). */ +export function stageFromTaskType(type: unknown, fallback: string): string { + if (typeof type !== "string" || !type) return fallback; + const m = /-(preview|refine|prototype|build)$/.exec(type); + return m ? m[1]! : fallback; +} diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index e800b57..9a5aa5f 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -49,6 +49,9 @@ import { type OperationRecord, } from "./operation-store.js"; import { originOf } from "./config.js"; +import { recordTask, saveTaskSnapshot, stageFromTaskType } from "./project-store.js"; +import { existsSync } from "node:fs"; +import { join, resolve as resolvePath } from "node:path"; export interface CreateSpec { description: string; @@ -88,7 +91,9 @@ export interface ResourceCommandSpec { const TASK_JSON_OPTIONS = (cmd: Command): Command => cmd .option("--save-json ", "save the full task JSON as received (never overwrites)") - .option("--include-raw", "v1: include the untouched task response under result.task.raw"); + .option("--include-raw", "v1: include the untouched task response under result.task.raw") + .option("--project ", "initialised meshy_output project: save task_.json there and record the task in metadata.json") + .option("--stage ", "stage label for the project record (default: derived from the task type, e.g. preview | refine | build)"); interface DownloadOutcome { state: "not_requested" | "not_ready" | "completed" | "failed"; @@ -126,6 +131,75 @@ function nextCommands(descriptor: TaskResourceDescriptor, taskId: string): Recor }; } +interface ProjectAttachment { + project_dir: string; + snapshot: string | null; + stage: string; + action: "added" | "merged"; + index: { updated: boolean; error: string | null }; +} + +function parentTaskIdFromPayload(payload: Record | null): string | null { + if (!payload) return null; + for (const k of ["preview_task_id", "input_task_id", "rig_task_id"]) { + if (typeof payload[k] === "string" && payload[k]) return payload[k] as string; + } + return null; +} + +/** + * --project: snapshot the task (when a full task is known) and record it. + * Failures keep the task id in the error result — a bookkeeping problem must + * never read as "no task was created". + */ +function attachToProject( + opts: Record, + descriptor: TaskResourceDescriptor, + taskId: string, + task: Task | null, + raw: unknown, + extra: { operationId?: string | null; payload?: Record | null; files?: string[] }, + warnings: Warning[], +): ProjectAttachment | null { + const projectFlag = opts.project as string | undefined; + if (!projectFlag) return null; + const projectDir = resolvePath(projectFlag); + if (!existsSync(join(projectDir, "metadata.json"))) { + throw new CliError({ + code: "local_io", + message: `--project ${projectFlag} is not an initialised project (no metadata.json); run \`meshy project init\` first`, + result: { task_id: taskId, task: task ? toTaskView(raw ?? task, { descriptor }) : null }, + }); + } + const stage = (opts.stage as string | undefined) ?? (typeof extra.payload?.["mode"] === "string" ? (extra.payload["mode"] as string) : descriptor.creativeLab?.stage ?? stageFromTaskType(task?.type, descriptor.id)); + try { + const snapshot = task && raw ? saveTaskSnapshot(projectDir, taskId, raw) : null; + const rec = recordTask(projectDir, { + taskId, + stage, + resource: descriptor.id, + taskType: task?.type ?? null, + endpoint: descriptor.legacyEndpoint, + parentTaskId: parentTaskIdFromPayload(extra.payload ?? null), + status: task?.status ?? null, + taskJson: snapshot?.relative ?? null, + operationId: extra.operationId ?? null, + files: extra.files ?? [], + }); + if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}; run \`meshy project rebuild-index\``)); + if (rec.migrated_from_legacy) warnings.push(warning("metadata_migrated", "legacy metadata.json migrated to schema_version 2 (backup kept beside it)")); + return { project_dir: projectDir, snapshot: snapshot?.path ?? null, stage, action: rec.action, index: rec.index }; + } catch (err) { + if (err instanceof CliError && err.result) throw err; + throw new CliError({ + code: "local_io", + message: `task ${taskId} exists but recording it in ${projectDir} failed: ${err instanceof Error ? err.message : String(err)}`, + result: { task_id: taskId, task: task ? toTaskView(raw ?? task, { descriptor }) : null, submission: { state: "accepted", operation_id: extra.operationId ?? null, task_id: taskId } }, + cause: err, + }); + } +} + export function buildResourceCommand(spec: ResourceCommandSpec): Command { const descriptor = requireTaskResource(spec.name); const endpointOf = spec.endpointOf ?? ((client: MeshyClient) => client.endpointFor(descriptor)); @@ -173,6 +247,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { if (runAsync) { const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, createRaw, { workspace: opened.flags.workspace }) : null; + const project = attachToProject(opts, descriptor, taskId, null, null, { operationId, payload }, warnings); if (opened.schema === "v1") { await emitEnvelope( okEnvelope( @@ -184,7 +259,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { includeRaw: false, submission: { state: "accepted", operation_id: operationId, task_id: taskId, request_id: submitted.requestId }, savedJson, - extra: { task_id: taskId, next: nextCommands(descriptor, taskId) }, + extra: { task_id: taskId, next: nextCommands(descriptor, taskId), project }, }), warnings, ), @@ -199,6 +274,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { status: "PENDING", hint: `meshy-cli ${descriptor.commandPath.join(" ")} wait ${taskId}`, operation_id: operationId, + ...(project ? { project_dir: project.project_dir } : {}), }, { format: opened.format, file: undefined }, ); @@ -206,7 +282,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { } // 3. Sync: poll the id we just recorded. - await waitAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, opts, { + await waitAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, { ...opts, __payload: payload, __operationId: operationId }, { state: "accepted", operation_id: operationId, task_id: taskId, @@ -222,10 +298,12 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { const runtime = await buildRuntime(opened.flags); const { task, raw } = await endpointOf(runtime.client).retrieveDetailed(taskId, { signal: abortSignal() }); const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspace }) : null; + const warnings: Warning[] = []; + const project = attachToProject(opts, descriptor, taskId, task, raw, {}, warnings); if (opened.schema === "v1") { const downloads = await maybeDownloadV1(opened, task, spec.name); await emitEnvelope( - okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw: Boolean(opts.includeRaw), submission: { state: "accepted", operation_id: null }, downloads, savedJson })), + okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw: Boolean(opts.includeRaw), submission: { state: "accepted", operation_id: null }, downloads, savedJson, extra: project ? { project } : {} }), warnings), opened.format, ); return; @@ -542,6 +620,8 @@ async function waitAndReport( const elapsed = (performance.now() - started) / 1000; const { task, raw, timedOut, aborted } = poll; const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspace }) : null; + const project = attachToProject(opts, descriptor, taskId, task, raw, { operationId: (opts.__operationId as string | undefined) ?? submission.operation_id ?? null, payload: (opts.__payload as Record | undefined) ?? null }, warnings); + const projectExtra = project ? { project } : {}; if (aborted) throw interruptedError(descriptor, taskId, { task, raw }, submission, opts, opened, savedJson); @@ -557,7 +637,7 @@ async function waitAndReport( code: "timed_out", message: `task ${taskId} did not reach a terminal status within ${timeoutSeconds}s (last status: ${task.status}); the server keeps running it`, recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, - result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { wait: waitInfo, next: nextCommands(descriptor, taskId) } }), + result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { wait: waitInfo, next: nextCommands(descriptor, taskId), ...projectExtra } }), warnings, }); } @@ -565,13 +645,13 @@ async function waitAndReport( throw new CliError({ code: "task_failed", message: task.task_error?.message ? `task ${taskId} ${task.status}: ${task.task_error.message}` : `task ${taskId} ended as ${task.status}`, - result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { wait: waitInfo } }), + result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { wait: waitInfo, ...projectExtra } }), warnings, }); } const downloads = await maybeDownloadV1(opened, task, descriptor.id); await emitEnvelope( - okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw, submission, downloads, savedJson, extra: { wait: waitInfo } }), warnings), + okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw, submission, downloads, savedJson, extra: { wait: waitInfo, ...projectExtra } }), warnings), opened.format, ); } @@ -640,8 +720,9 @@ async function streamAndReport( }); const savedJson = opts.saveJson && outcome.raw ? saveRawJson(opts.saveJson as string, outcome.raw, { workspace: opened.flags.workspace }) : null; + const project = outcome.task ? attachToProject(opts, descriptor, taskId, outcome.task, outcome.raw, {}, warnings) : null; const streamInfo = { events: outcome.events, ended: outcome.reason, elapsed_seconds: Number((outcome.elapsedMs / 1000).toFixed(2)) }; - const result = taskResult({ task: outcome.task, raw: outcome.raw, descriptor, includeRaw, submission, savedJson, extra: { stream: streamInfo, task_id: taskId } }); + const result = taskResult({ task: outcome.task, raw: outcome.raw, descriptor, includeRaw, submission, savedJson, extra: { stream: streamInfo, task_id: taskId, ...(project ? { project } : {}) } }); let finalError: CliError | null = null; switch (outcome.reason) { diff --git a/src/root.ts b/src/root.ts index 49c354c..40e9f55 100644 --- a/src/root.ts +++ b/src/root.ts @@ -32,6 +32,7 @@ import { imageToImageCommand } from "./cmd/image-to-image.js"; import { makeCommand } from "./cmd/make.js"; import { multiColorPrintCommand } from "./cmd/multi-color-print.js"; import { multiImageTo3dCommand } from "./cmd/multi-image-to-3d.js"; +import { projectCommand } from "./cmd/project.js"; import { remeshCommand } from "./cmd/remesh.js"; import { repairPrintabilityCommand } from "./cmd/repair-printability.js"; import { resizeCommand } from "./cmd/resize.js"; @@ -196,6 +197,7 @@ export function buildRootCommand(): Command { animationCatalogCommand, showcasesCommand, downloadCommand, + projectCommand, ]) { program.addCommand(cmd, { hidden: true }); } diff --git a/tests/helpers/record-task-child.ts b/tests/helpers/record-task-child.ts new file mode 100644 index 0000000..fdc6fee --- /dev/null +++ b/tests/helpers/record-task-child.ts @@ -0,0 +1,13 @@ +/** + * Child for the project-store concurrency test: records one task in the given + * project. argv: + */ +import { recordTask } from "../../src/internal/project-store.js"; + +const [dir, taskId, stage, root] = process.argv.slice(2); +if (!dir || !taskId || !stage || !root) { + process.stderr.write("usage: record-task-child \n"); + process.exit(2); +} +const res = recordTask(dir, { taskId, stage, resource: "text-to-3d", files: [`${taskId}.glb`] }, { root }); +process.stdout.write(`${JSON.stringify({ action: res.action, count: res.metadata.tasks.length, index: res.index.updated })}\n`); diff --git a/tests/project-store.test.ts b/tests/project-store.test.ts new file mode 100644 index 0000000..180bcdf --- /dev/null +++ b/tests/project-store.test.ts @@ -0,0 +1,270 @@ +/** + * Project store (T-072..T-077): layout, legacy compatibility, de-duplication, + * concurrency, index repair and path safety — plus the `--project` hook on + * task verbs and the `project` subcommands as black boxes. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, symlinkSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assertSafeRelativeFile, + initProject, + listProjects, + normalizeMetadata, + projectFolderName, + readProject, + rebuildIndex, + recordTask, +} from "../src/internal/project-store.js"; +import { CliError, UsageError } from "../src/internal/errors.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const fixedNow = () => new Date("2026-09-07T10:20:30.000Z"); + +test("T-072 init → record → show: exact layout, metadata v2, history index", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "Demo Fixture", taskId: "fixture-task-a", now: fixedNow }); + assert.match(init.folder, /^\d{8}_\d{6}_demo-fixture_fixture-$/); + assert.ok(existsSync(join(init.project_dir, "metadata.json"))); + assert.equal(init.index.updated, true); + const history = JSON.parse(readFileSync(join(root, "history.json"), "utf8")) as { version: number; projects: Array<{ folder: string; task_count: number }> }; + assert.equal(history.version, 1); + assert.equal(history.projects[0]!.folder, init.folder); + assert.equal(history.projects[0]!.task_count, 0); + + const rec = recordTask(init.project_dir, { taskId: "fixture-task-a", stage: "preview", resource: "text-to-3d", taskType: "text-to-3d-preview", endpoint: "/openapi/v2/text-to-3d", status: "SUCCEEDED", files: ["preview.glb"], taskJson: "task_fixture-task-a.json", operationId: "fixture-op-1" }, { root, now: fixedNow }); + assert.equal(rec.action, "added"); + const meta = readProject(init.project_dir).metadata; + assert.equal(meta.schema_version, 2); + assert.deepEqual(meta.tasks[0], { + task_id: "fixture-task-a", + task_type: "text-to-3d-preview", + resource: "text-to-3d", + endpoint: "/openapi/v2/text-to-3d", + stage: "preview", + parent_task_id: null, + status: "SUCCEEDED", + files: ["preview.glb"], + task_json: "task_fixture-task-a.json", + operation_id: "fixture-op-1", + created_at: "2026-09-07T10:20:30.000Z", + updated_at: "2026-09-07T10:20:30.000Z", + }); + const h2 = JSON.parse(readFileSync(join(root, "history.json"), "utf8")) as { projects: Array<{ task_count: number; root_task_id: string }> }; + assert.equal(h2.projects[0]!.task_count, 1); + assert.equal(h2.projects[0]!.root_task_id, "fixture-task-a"); +}); + +test("T-074 (task_id, stage) is merged, different stages and tasks are kept", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "dedupe", now: fixedNow }); + recordTask(init.project_dir, { taskId: "t1", stage: "preview", files: ["a.glb"], status: "IN_PROGRESS" }, { root }); + const merged = recordTask(init.project_dir, { taskId: "t1", stage: "preview", files: ["a.glb", "thumb.png"], status: "SUCCEEDED" }, { root }); + assert.equal(merged.action, "merged"); + recordTask(init.project_dir, { taskId: "t2", stage: "refine", files: ["b.glb"] }, { root }); + recordTask(init.project_dir, { taskId: "t1", stage: "rigged", files: ["rig.glb"] }, { root }); + const meta = readProject(init.project_dir).metadata; + assert.deepEqual(meta.tasks.map((t) => [t.task_id, t.stage, t.files, t.status]), [ + ["t1", "preview", ["a.glb", "thumb.png"], "SUCCEEDED"], + ["t2", "refine", ["b.glb"], null], + ["t1", "rigged", ["rig.glb"], null], + ]); + assert.equal(meta.root_task_id, "t1"); +}); + +test("T-073 legacy metadata.json (no schema_version) is read as v1, migrated on write with a backup, unknown fields kept; download meta.json is never accepted as metadata", () => { + const root = tmpDir("proj-root-"); + const folder = "20260101_120000_legacy_abcdef12"; + const dir = join(root, folder); + mkdirSync(dir); + const legacy = { + project_name: "legacy", + folder, + root_task_id: "abcdef12-legacy", + created_at: "2026-01-01T12:00:00", + updated_at: "2026-01-01T12:00:00", + tasks: [{ task_id: "abcdef12-legacy", task_type: "text-to-3d", stage: "preview", files: ["preview.glb"], created_at: "2026-01-01T12:00:00" }], + custom_note: "keep me", + }; + writeFileSync(join(dir, "metadata.json"), JSON.stringify(legacy, null, 2)); + writeFileSync(join(root, "history.json"), JSON.stringify({ version: 1, projects: [{ folder, prompt: "legacy", task_type: "text-to-3d", root_task_id: "abcdef12-legacy", created_at: legacy.created_at, updated_at: legacy.updated_at, task_count: 1 }] })); + + const read = readProject(dir); + assert.equal(read.legacy, true); + assert.equal(read.metadata.tasks[0]!.resource, null); + assert.equal(read.metadata.tasks[0]!.status, null); + assert.equal(read.metadata["custom_note"], "keep me"); + // show does not rewrite the file + assert.equal(JSON.parse(readFileSync(join(dir, "metadata.json"), "utf8")).schema_version, undefined); + + const rec = recordTask(dir, { taskId: "abcdef12-legacy", stage: "refined", files: ["refined.glb"] }, { root, now: fixedNow }); + assert.equal(rec.migrated_from_legacy, true); + const after = JSON.parse(readFileSync(join(dir, "metadata.json"), "utf8")) as Record; + assert.equal(after["schema_version"], 2); + assert.equal(after["custom_note"], "keep me"); + assert.equal((after["tasks"] as unknown[]).length, 2); + const backups = readdirSync(dir).filter((f) => f.startsWith("metadata.json.bak-")); + assert.equal(backups.length, 1); + assert.deepEqual(JSON.parse(readFileSync(join(dir, backups[0]!), "utf8")), legacy); + const hist = JSON.parse(readFileSync(join(root, "history.json"), "utf8")) as { version: number; projects: Array<{ task_count: number }> }; + assert.equal(hist.version, 1); + assert.equal(hist.projects[0]!.task_count, 2); + + // A CLI download meta.json is not project metadata. + const other = join(root, "20260101_120001_dl_deadbeef"); + mkdirSync(other); + writeFileSync(join(other, "metadata.json"), JSON.stringify({ resource: "image-to-3d", task: { id: "x" }, saved_files: [] })); + const view = readProject(other).metadata; + assert.deepEqual(view.tasks, [], "no tasks array → no tasks, nothing invented"); +}); + +test("T-077 unsafe names and paths are refused or skipped; damaged JSON is never overwritten", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "安全 名称 with, comma", now: fixedNow }); + assert.match(init.folder, /^\d{8}_\d{6}_with-comma_[0-9a-f]{4}$/, "only the ASCII part survives in the slug"); + assert.equal(init.metadata.project_name, "安全 名称 with, comma"); + const cjk = initProject(root, { name: "安全名称", now: fixedNow }); + assert.match(cjk.folder, /^\d{8}_\d{6}_project_[0-9a-f]{4}$/, "a name without ASCII falls back to a safe slug"); + assert.equal(cjk.metadata.project_name, "安全名称"); + for (const bad of ["../x.glb", "/abs/x.glb", "C:\\x.glb", "a//b", ""]) { + assert.throws(() => assertSafeRelativeFile(bad, "--file"), UsageError, bad); + } + assert.equal(assertSafeRelativeFile("sub\\dir\\model, with comma.glb", "--file"), "sub/dir/model, with comma.glb"); + // Same second, same name → distinct folders. + const a = projectFolderName("same", null, fixedNow(), () => "aaaa"); + const b = projectFolderName("same", null, fixedNow(), () => "bbbb"); + assert.notEqual(a, b); + const c = initProject(root, { name: "same", now: fixedNow }); + const d = initProject(root, { name: "same", now: fixedNow }); + assert.notEqual(c.folder, d.folder); + // Damaged metadata: readProject throws, file untouched; recordTask throws too. + const broken = join(root, "20260101_120000_broken_00000000"); + mkdirSync(broken); + writeFileSync(join(broken, "metadata.json"), "{ not json"); + assert.throws(() => readProject(broken), (e: unknown) => e instanceof CliError && e.code === "local_io"); + assert.throws(() => recordTask(broken, { taskId: "t", stage: "s" }, { root }), CliError); + assert.equal(readFileSync(join(broken, "metadata.json"), "utf8"), "{ not json"); + // history.json pointing outside the root is reported as absent, and rebuild skips a symlinked folder. + const outside = tmpDir("outside-"); + mkdirSync(join(outside, "victim")); + writeFileSync(join(outside, "victim", "metadata.json"), JSON.stringify({ schema_version: 2, project_name: "v", folder: "victim", root_task_id: null, created_at: "", updated_at: "", tasks: [] })); + symlinkSync(join(outside, "victim"), join(root, "linked")); + const rebuilt = rebuildIndex(root, { now: fixedNow }); + assert.ok(rebuilt.skipped.some((s) => s.folder === "linked")); + assert.ok(rebuilt.backup && existsSync(rebuilt.backup), "previous index backed up"); + writeFileSync(join(root, "history.json"), JSON.stringify({ version: 1, projects: [{ folder: "../../etc", prompt: "x", task_type: "", root_task_id: null, created_at: "", updated_at: "", task_count: 0 }] })); + const listed = listProjects(root); + assert.equal(listed.projects[0]!.present, false); + assert.equal(listed.index_dirty, true); + assert.throws(() => normalizeMetadata([1, 2], "f"), CliError); +}); + +test("T-075 concurrent records from separate processes lose nothing and keep valid JSON", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "race", now: fixedNow }); + const script = fileURLToPath(new URL("./helpers/record-task-child.ts", import.meta.url)); + const children = ["t1", "t2", "t3", "t4", "t5", "t6"].map((id) => + spawnSync(process.execPath, ["--import", "tsx", script, init.project_dir, id, "preview", root], { encoding: "utf8", env: { ...process.env, MESHY_CLI_NO_UPDATE_NOTIFIER: "1" } }), + ); + for (const c of children) assert.equal(c.status, 0, c.stderr); + const meta = readProject(init.project_dir).metadata; + assert.deepEqual(meta.tasks.map((t) => t.task_id).sort(), ["t1", "t2", "t3", "t4", "t5", "t6"]); + const hist = JSON.parse(readFileSync(join(root, "history.json"), "utf8")) as { projects: Array<{ task_count: number }> }; + assert.equal(hist.projects.length, 1); + assert.equal(hist.projects[0]!.task_count, 6); +}); + +test("T-076 metadata committed but index update failing is reported as index_dirty and repaired by rebuild-index", () => { + const root = tmpDir("proj-root-"); + const init = initProject(root, { name: "dirty", now: fixedNow }); + // Corrupt the history index so the second phase fails. + writeFileSync(join(root, "history.json"), "[]"); + const rec = recordTask(init.project_dir, { taskId: "t1", stage: "preview" }, { root }); + assert.equal(rec.action, "added"); + assert.equal(rec.index.updated, false); + assert.match(rec.index.error ?? "", /not a history index/); + assert.equal(readProject(init.project_dir).metadata.tasks.length, 1, "metadata is never rolled back"); + assert.equal(readFileSync(join(root, "history.json"), "utf8"), "[]", "damaged index untouched"); + assert.throws(() => listProjects(root), CliError); + const rebuilt = rebuildIndex(root, { now: fixedNow }); + assert.equal(rebuilt.indexed, 1); + assert.ok(rebuilt.backup); + const listed = listProjects(root); + assert.equal(listed.index_dirty, false); + assert.equal(listed.projects[0]!.task_count, 1); +}); + +test("T-072 CLI: project init/record/show/list/rebuild-index run without a key or network", async () => { + const cwd = tmpDir("proj-cli-"); + const env = { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CLI_NO_UPDATE_NOTIFIER: "1", MESHY_CONFIG_DIR: tmpDir("cfg-") }; + const init = await runCli(["project", "init", "--name", "demo", "--task-id", "fixture-task-a"], { cwd, env }); + assert.equal(init.code, 0, init.stderr); + const initEnv = parseSingleJson(init.stdout) as { command: string; result: { project_dir: string; folder: string } }; + assert.equal(initEnv.command, "project.init"); + assert.ok(existsSync(join(cwd, "meshy_output", initEnv.result.folder, "metadata.json"))); + writeFileSync(join(initEnv.result.project_dir, "preview.glb"), "glb"); + const rec = await runCli(["project", "record", "--project", initEnv.result.project_dir, "--task-id", "fixture-task-a", "--resource", "text-to-3d", "--stage", "preview", "--file", "preview.glb", "--file", "thumb, with comma.png"], { cwd, env }); + assert.equal(rec.code, 0, rec.stderr); + const recEnv = parseSingleJson(rec.stdout) as { result: { entry: { files: string[] }; action: string }; warnings: Array<{ code: string }> }; + assert.deepEqual(recEnv.result.entry.files, ["preview.glb", "thumb, with comma.png"]); + assert.ok(recEnv.warnings.some((w) => w.code === "recorded_file_missing")); + const show = await runCli(["project", "show", "--project", initEnv.result.project_dir], { cwd, env }); + assert.equal(show.code, 0, show.stderr); + const showEnv = parseSingleJson(show.stdout) as { result: { legacy_format: boolean; files: Array<{ file: string; present: boolean }> } }; + assert.equal(showEnv.result.legacy_format, false); + assert.deepEqual(showEnv.result.files.map((f) => [f.file, f.present]), [["preview.glb", true], ["thumb, with comma.png", false]]); + const list = await runCli(["project", "list"], { cwd, env }); + assert.equal(list.code, 0, list.stderr); + assert.equal((parseSingleJson(list.stdout) as { result: { projects: unknown[]; index_dirty: boolean } }).result.index_dirty, false); + const rebuild = await runCli(["project", "rebuild-index"], { cwd, env }); + assert.equal(rebuild.code, 0, rebuild.stderr); + const missing = await runCli(["project", "record", "--project", join(cwd, "nope"), "--task-id", "x", "--stage", "s"], { cwd, env }); + assert.equal(missing.code, 2); +}); + +test("--project on task verbs: async create records the id immediately; wait snapshots the final task", async () => { + let gets = 0; + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "task-p" }); + gets += 1; + return jsonReply(res, 200, { id: "task-p", type: "text-to-3d-preview", status: gets < 2 ? "IN_PROGRESS" : "SUCCEEDED", progress: 100 }); + }); + try { + const cwd = tmpDir("proj-cli-"); + const env = api.env(); + const init = await runCli(["project", "init", "--name", "chain"], { cwd, env }); + assert.equal(init.code, 0, init.stderr); + const dir = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const created = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "p", "--async", "--project", dir, "--output-schema", "v1"], { cwd, env }); + assert.equal(created.code, 0, created.stderr); + const cEnv = parseSingleJson(created.stdout) as { result: { project: { stage: string; action: string; snapshot: string | null } } }; + assert.equal(cEnv.result.project.stage, "preview"); + assert.equal(cEnv.result.project.snapshot, null); + let meta = JSON.parse(readFileSync(join(dir, "metadata.json"), "utf8")) as { tasks: Array<{ task_id: string; status: string | null; operation_id: string | null; task_json: string | null }> }; + assert.equal(meta.tasks[0]!.task_id, "task-p"); + assert.equal(meta.tasks[0]!.status, null); + assert.ok(meta.tasks[0]!.operation_id); + + const waited = await runCli(["text-to-3d", "wait", "task-p", "--project", dir, "--output-schema", "v1"], { cwd, env }); + assert.equal(waited.code, 0, waited.stderr); + const wEnv = parseSingleJson(waited.stdout) as { result: { project: { action: string; snapshot: string } } }; + assert.equal(wEnv.result.project.action, "merged"); + assert.ok(existsSync(join(dir, "task_task-p.json"))); + meta = JSON.parse(readFileSync(join(dir, "metadata.json"), "utf8")) as typeof meta; + assert.equal(meta.tasks.length, 1, "same task+stage merged, not duplicated"); + assert.equal(meta.tasks[0]!.status, "SUCCEEDED"); + assert.equal(meta.tasks[0]!.task_json, "task_task-p.json"); + + // Not an initialised project → local_io with the task id kept. + const bad = await runCli(["text-to-3d", "get", "task-p", "--project", join(cwd, "nope"), "--output-schema", "v1"], { cwd, env }); + assert.equal(bad.code, 11, bad.stderr); + const bEnv = parseSingleJson(bad.stdout) as { result: { task_id: string } }; + assert.equal(bEnv.result.task_id, "task-p"); + } finally { + await api.close(); + } +}); From da7e1dc5feacfba9db508a155bbc3a980e83afda Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 16:23:25 +0800 Subject: [PATCH 07/26] feat(local): B06-B08 inspect faces, OBJ prepare-print, slicers and doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B06 inspect faces: verdict pass|fail|unknown from the task's face_count only (missing/null/string → unknown, exit 13; over the limit → exit 12); --max-faces is required; sources task-json (no network) or one GET through the registry; a failing verdict describes a remesh and never runs one. B07 mesh prepare-print: streaming two-pass OBJ transform (Y-up → Z-up, scale to --height-mm, XY centred, grounded at Z=0), normals rotated only, extra vertex fields and face/UV indices preserved, LF/CRLF kept; default .print.obj, --in-place explicit; NaN/Infinity/degenerate/empty → validation with no output; mtllib/texture dependencies copied across directories or refused unless --geometry-only; validated against the independent fixture oracle. B07 slicers: seven registered slicers with multicolor metadata; detection rules for darwin/win32/linux through an injectable environment; open launches the detected path only (shell:false, single argv, no default-app fallback). B08 doctor: local by default (versions, credential sources as booleans, base URLs, workspace, .env candidates named not read); --check-api makes one free GET /balance (exit 3 / 7 on failure); --check-slicers runs detection. Also: decisions D-026..D-028, capability matrix 33/35 implemented, in-process command tests forward the test runner's frames to the real stdout. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/capability-matrix.json | 95 ++- docs/skill-parity/decisions.md | 31 + docs/skill-parity/migration-notes.md | 7 +- src/cmd/doctor.ts | 72 +++ src/cmd/inspect.ts | 220 +++++++ src/cmd/mesh.ts | 76 +++ src/cmd/resources.ts | 4 + src/cmd/slicer.ts | 54 ++ src/internal/doctor.ts | 345 +++++++++++ src/internal/inspect.ts | 139 +++++ src/internal/obj-transform.ts | 741 +++++++++++++++++++++++ src/internal/slicers.ts | 408 +++++++++++++ src/root.ts | 8 + tests/doctor.test.ts | 513 ++++++++++++++++ tests/inspect.test.ts | 449 ++++++++++++++ tests/obj-transform.test.ts | 251 ++++++++ tests/slicers.test.ts | 174 ++++++ 17 files changed, 3553 insertions(+), 34 deletions(-) create mode 100644 src/cmd/doctor.ts create mode 100644 src/cmd/inspect.ts create mode 100644 src/cmd/mesh.ts create mode 100644 src/cmd/slicer.ts create mode 100644 src/internal/doctor.ts create mode 100644 src/internal/inspect.ts create mode 100644 src/internal/obj-transform.ts create mode 100644 src/internal/slicers.ts create mode 100644 tests/doctor.test.ts create mode 100644 tests/inspect.test.ts create mode 100644 tests/obj-transform.test.ts create mode 100644 tests/slicers.test.ts diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index d14596b..c58779f 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -365,14 +365,16 @@ "source_hint": "pipelines.md:rig/basic_animations/thumbnail", "target": "download asset registry with role and dependencies", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-060", "T-062", "T-071" ], - "evidence": [], + "evidence": [ + "src/internal/artifacts.ts enumerateAssets: model formats (lamp parts STL/ZIP, keychain/fridge OBJ as ZIP bundle), images, textures, primary/multi-view/alpha thumbnails, rig + basic_animations, motion, report; unknown URLs listed; tests/artifacts.test.ts T-060/T-062/T-071" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -386,14 +388,18 @@ "source_hint": "meshy_task.py:_cmd_download/_cmd_thumbnail", "target": "meshy download --asset/--kind/--model-format", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-061", "T-063" ], - "evidence": [], - "intentional_differences": [], + "evidence": [ + "src/cmd/download.ts selectors --asset/--model-format/--kind/--all/--list; tests/download-command.test.ts T-061, tests/artifacts.test.ts T-063" + ], + "intentional_differences": [ + "download requires an explicit selector when a task exposes several assets (legacy defaulted to glb); legacy -o keeps downloading everything" + ], "external_verification": "offline_primary" }, { @@ -406,7 +412,7 @@ "source_hint": "meshy_task.py:download; CLI existing download.ts", "target": "safe download with actual final-path manifest", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-064", @@ -417,7 +423,9 @@ "T-069", "T-070" ], - "evidence": [], + "evidence": [ + "src/internal/download.ts fetchToTemp (no credential, redirect re-validation, private-network refusal, size cap, sha256), exclusive publish + realpath containment, partial manifests; tests T-064..T-070" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -431,13 +439,15 @@ "source_hint": "meshy_task.py:_cmd_get/_cmd_poll", "target": "--save-json and project task snapshots", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-010", "T-063" ], - "evidence": [], + "evidence": [ + "--save-json on task verbs/list/balance/api/catalog/showcases (raw JSON, never the envelope); project task_.json snapshots; tests T-010, T-063, project-store tests" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -451,13 +461,15 @@ "source_hint": "meshy_task.py:get_project_dir", "target": "meshy project init", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-072", "T-077" ], - "evidence": [], + "evidence": [ + "src/internal/project-store.ts initProject + src/cmd/project.ts init; tests/project-store.test.ts T-072/T-077" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -471,14 +483,16 @@ "source_hint": "meshy_task.py:record_task", "target": "meshy project record/show", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-073", "T-074", "T-075" ], - "evidence": [], + "evidence": [ + "project-store recordTask (task_id+stage merge, legacy v1 migration with backup, unknown fields kept) + project record/show; tests T-073/T-074/T-075" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -492,7 +506,7 @@ "source_hint": "meshy_task.py:record_task history.json", "target": "meshy project list/rebuild-index", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-073", @@ -500,7 +514,9 @@ "T-076", "T-077" ], - "evidence": [], + "evidence": [ + "project list (index vs folders, index_dirty) and rebuild-index (backup, symlink/escape skipped); tests T-073/T-075/T-076/T-077" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -514,7 +530,7 @@ "source_hint": "meshy_task.py:_cmd_check_faces", "target": "meshy inspect faces", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-080", @@ -522,9 +538,12 @@ "T-082" ], "evidence": [ - "meshyd httpapi/dto.go: public task DTO has no face_count → unknown semantics required" + "meshyd httpapi/dto.go: public task DTO has no face_count → unknown semantics required", + "src/internal/inspect.ts judgeFaceCount/judgeTask + src/cmd/inspect.ts (--max-faces required; unknown exit 13; fail exit 12; remesh only described); tests/inspect.test.ts T-080..T-082" + ], + "intentional_differences": [ + "missing face_count is unknown (exit 13), never 0 (legacy check-faces passed unmeasured models); --max-faces has no default" ], - "intentional_differences": [], "external_verification": "offline_primary" }, { @@ -537,7 +556,7 @@ "source_hint": "fix_obj.py:fix_obj_for_printing", "target": "meshy mesh prepare-print", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-083", @@ -546,8 +565,12 @@ "T-086", "T-087" ], - "evidence": [], - "intentional_differences": [], + "evidence": [ + "src/internal/obj-transform.ts two-pass streaming transform + src/cmd/mesh.ts prepare-print; fixture oracle box-height-80.expected.json; tests/obj-transform.test.ts T-083..T-087" + ], + "intentional_differences": [ + "default output is .print.obj, in-place only with --in-place; NaN/Infinity/degenerate input is a validation error instead of silent scale 1.0 output" + ], "external_verification": "offline_primary" }, { @@ -560,13 +583,15 @@ "source_hint": "slicers.py:detect_slicers/SLICER_MAP", "target": "meshy slicer detect", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-088", "T-091" ], - "evidence": [], + "evidence": [ + "src/internal/slicers.ts detectSlicers (darwin/win32/linux rules, seven registered, multicolor flag) + src/cmd/slicer.ts detect; tests/slicers.test.ts T-088" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -580,7 +605,7 @@ "source_hint": "slicers.py:open_in_slicer", "target": "meshy slicer open", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-089", @@ -588,8 +613,12 @@ "T-091", "T-112" ], - "evidence": [], - "intentional_differences": [], + "evidence": [ + "src/internal/slicers.ts openInSlicer (detected path only, shell:false, single argv, no default-app fallback) + slicer open; tests T-089..T-091" + ], + "intentional_differences": [ + "no default-application fallback (legacy used os.startfile / xdg-open); Windows launches the detected absolute exe, not a PATH lookup" + ], "external_verification": "required_when_applicable_not_run" }, { @@ -602,13 +631,15 @@ "source_hint": "meshy_task.py:_cmd_check_env", "target": "meshy doctor", "baseline_status": "missing", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-102", "T-103" ], - "evidence": [], + "evidence": [ + "src/internal/doctor.ts runDoctorDetailed + src/cmd/doctor.ts (local default; --check-api one GET /balance; --check-slicers); tests/doctor.test.ts T-102/T-103" + ], "intentional_differences": [], "external_verification": "offline_primary" }, @@ -646,13 +677,15 @@ "source_hint": "CLI auth/config/credentials/runtime", "target": "preserve existing auth commands and token protocol", "baseline_status": "existing_needs_regression", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-104", "T-105" ], - "evidence": [], + "evidence": [ + "auth commands untouched; runtime refresh path unchanged; tests/auth-headless.test.ts, oauth.test.ts, device*.test.ts, credentials.test.ts, runtime.test.ts still pass (regression)" + ], "intentional_differences": [], "external_verification": "required_when_applicable_not_run" }, @@ -837,6 +870,6 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-07T07:11:01.359Z" + "updated_at": "2026-09-07T08:21:03.006Z" } } diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index d78e67f..cd4e610 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -230,3 +230,34 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu - Empty or placeholder `--api-key` / `MESHY_API_KEY` values keep meaning "unset" (0.2.0 behaviour that CI and the runtime tests depend on); only the explicit key file is strict. + +## D-026 Asset URL policy + +- Asset downloads (`meshy download`, legacy `-o`) accept `https:` to any host and + `http:` only to loopback hosts (127.0.0.0/8, `localhost`, `::1`) so local test + servers work without a global `--insecure`. Private-network literals (10/8, + 172.16/12, 192.168/16, 169.254/16, fc00::/7, fe80::/10) are refused, also as + redirect targets; an https → http downgrade redirect is refused. Host names are + not resolved before the request, so a DNS name pointing at a private address is + not detected — documented limitation, not a claim. +- No Authorization or Cookie header is ever sent to an asset host; the API + credential belongs to the API origins only. + +## D-027 `--project` bookkeeping on task verbs and download + +- `create/get/wait/stream --project ` record the task in `metadata.json` + (stage from `--stage`, else the payload `mode`, else the Creative Lab stage, else + the task type suffix preview/refine/prototype/build, else the resource id) and + save `task_.json` whenever a full task is known. Async create records the + id immediately without a snapshot. `download --project ` defaults the + output directory to the project and records the written files. +- A bookkeeping failure is `local_io` (exit 11) with the task id kept in + `result` — it never reads as "no task was created". + +## D-028 Tests that drive commands in-process + +- The node test runner reports to its parent over the same stdout the CLI writes + to, so in-process command tests forward non-string chunks (the runner's binary + frames) to the real stdout and capture only the CLI's string writes. Black-box + behaviour is still asserted through `dist/index.js` subprocesses wherever exit + codes or stderr matter. diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index 12a2999..ff9026a 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -25,7 +25,7 @@ explicitly in its examples. | `meshy_task.py check-faces --endpoint E --task-id ID --max-faces N` | `meshy inspect faces --resource R --task-id ID --max-faces N` | `--max-faces` is required; missing face count = `unknown` (exit 13), never 0 | | `fix_obj.py model.obj --height-mm 75` | `meshy mesh prepare-print model.obj --height-mm 75 [--output F | --in-place]` | default writes `model.print.obj`, no in-place overwrite unless asked | | `slicers.py detect` | `meshy slicer detect --output-schema v1` | same seven slicers + `multicolor` | -| `slicers.py open --file F --slicer S` | `meshy slicer open --slicer S --file F --output-schema v1` | no default-app fallback; detected path only | +| `slicers.py open --file F --slicer S` | `meshy slicer open --slicer S --file F --output-schema v1` | no default-app fallback; detected path only; `launch_requested` is not proof of import | | curl `/web/public/animations/resources?category=C` + python filter | `meshy animation-catalog list --category C --search wave` | no key, no Authorization; search is local | | curl `/openapi/v1/showcases?...` | `meshy showcases list --search car --page-size 3 --model-format glb` | billable GET, single request, no retry | | curl `/openapi/v1/uv-unwrap` | `meshy uv-unwrap create --input-task-id ID` or `--model-url m.glb` | exactly one source | @@ -117,5 +117,6 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, -`--include-raw`, `--project`, `--operation-id`, `--stop-after-first` (make), -`--idle-timeout` (stream). +`--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), +`--idle-timeout` (stream). New commands: `uv-unwrap`, `creative-lab`, `animation-catalog`, +`showcases`, `download`, `project`, `inspect`, `mesh`, `slicer`, `doctor`. diff --git a/src/cmd/doctor.ts b/src/cmd/doctor.ts new file mode 100644 index 0000000..f2ee77e --- /dev/null +++ b/src/cmd/doctor.ts @@ -0,0 +1,72 @@ +/** + * doctor — environment diagnosis with a strictly local default. + * + * Exit policy: the default run always exits 0, whatever it finds — a warning + * about a missing credential *is* the diagnosis, not a failure of the command. + * `--check-api` is different: the caller asked whether the account works, so + * a credential that cannot be resolved or is rejected exits 3 and a transport + * failure exits 7, each with the full report kept in `result` (D-024). + */ + +import { Command } from "commander"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson } from "../internal/command-helpers.js"; +import { runDoctorDetailed, type DoctorApiFailure } from "../internal/doctor.js"; +import { classifyError, CliError, type CliErrorCode } from "../internal/errors.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; + +interface DoctorCommandOptions { + checkApi?: boolean; + checkSlicers?: boolean; + saveJson?: string; +} + +/** + * Map a --check-api failure onto the exit contract. Anything that stops a + * credential from being resolved (none found, unusable key file, corrupt + * profile store) is `auth`; a rejected credential stays `auth`; a transport + * failure stays `network`; other API answers keep their own classification. + */ +export function apiCheckFailure(failure: DoctorApiFailure, result: Record): CliError { + const classified = classifyError(failure.error); + const code: CliErrorCode = failure.stage === "credentials" ? "auth" : classified.code; + return new CliError({ + code, + message: `--check-api failed: ${classified.message}`, + httpStatus: classified.httpStatus, + retryable: classified.retryable, + recovery: classified.recovery, + hint: classified.hint, + result, + cause: failure.error, + }); +} + +/** Build a fresh `doctor` command (tests parse a new tree per run). */ +export function buildDoctorCommand(): Command { + return new Command("doctor") + .description( + "Diagnose the local environment: versions, credential sources (presence only, values never read), base URLs, workspace. " + + "No network by default; --check-api makes one free GET /balance, --check-slicers runs slicer detection", + ) + .option("--check-api", "resolve the credential like an API command and call GET /balance once (free); exit 3 if no credential works, 7 if unreachable") + .option("--check-slicers", "detect installed slicers (local only)") + .option("--save-json ", "save the report to this file (never overwrites)") + .action(async (opts: DoctorCommandOptions, thisCmd: Command) => { + const opened = openCommand(thisCmd, "doctor", "v1"); + rejectOutputFlagForV1(opened, opts.saveJson); + buildLocalRuntime(opened.flags); + const { report, apiFailure } = await runDoctorDetailed({ + flags: opened.flags, + checkApi: Boolean(opts.checkApi), + checkSlicers: Boolean(opts.checkSlicers), + }); + const saved = opts.saveJson ? saveRawJson(opts.saveJson, report, { workspace: opened.flags.workspace }) : null; + const result: Record = { ...report, saved_json: saved }; + if (opts.checkApi && report.api_ready !== true) { + throw apiCheckFailure(apiFailure ?? { stage: "balance", error: new Error("--check-api did not complete") }, result); + } + await emitResult(opened, result, result); + }); +} + +export const doctorCommand = buildDoctorCommand(); diff --git a/src/cmd/inspect.ts b/src/cmd/inspect.ts new file mode 100644 index 0000000..70b7958 --- /dev/null +++ b/src/cmd/inspect.ts @@ -0,0 +1,220 @@ +/** + * inspect faces — the face-count gate. + * + * Two mutually exclusive task sources: a saved task JSON (no network, no + * credential, no config directory) or exactly one GET of a task through the + * resource registry. The command never downloads a model and never creates a + * remesh task; a failing verdict only *describes* the remesh that would help. + * + * `--max-faces` is required on purpose: the rigging ceiling (300000) is one + * consumer's rule and uv-unwrap's is 40000 — there is no limit that belongs + * to every mesh, so none is assumed. Exit codes: pass 0, fail 12, unknown 13 + * (D-009). Nothing printed here claims more than the face count. + */ + +import { Command } from "commander"; +import { closeSync, fstatSync, openSync, readSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import { findTaskResource, TASK_RESOURCES } from "../client/resource-registry.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson, type SavedJson } from "../internal/command-helpers.js"; +import { abortSignal } from "../internal/context.js"; +import { CliError, UsageError } from "../internal/errors.js"; +import { judgeTask, remeshSuggestion, type RemeshSuggestion } from "../internal/inspect.js"; +import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; +import { extractTaskObject } from "../internal/task-view.js"; + +/** Engineering limit for a local task JSON (D-018); enforced while reading, never by truncation. */ +export const TASK_JSON_MAX_BYTES = 16 * 1024 * 1024; + +export type TaskJsonShape = "api" | "meta.json" | "v1-envelope" | "v1-result"; + +export interface LoadedTaskJson { + path: string; + task: Record; + shape: TaskJsonShape; +} + +interface FacesOptions { + taskJson?: string; + resource?: string; + taskId?: string; + maxFaces?: number; + saveJson?: string; +} + +type TaskSource = { kind: "task-json"; path: string } | { kind: "api"; resource: string; taskId: string }; + +function errnoMessage(err: unknown): string { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return code ? code : err instanceof Error ? err.message : String(err); +} + +/** `--max-faces` accepts a plain positive integer; "3.5", "0", "-1" and "abc" are usage errors. */ +export function parseMaxFaces(raw: string): number { + const text = raw.trim(); + if (!/^\d+$/.test(text)) throw new UsageError(`--max-faces must be a positive integer (got '${raw}')`); + const n = Number(text); + if (!Number.isSafeInteger(n) || n < 1) throw new UsageError(`--max-faces must be a positive integer (got '${raw}')`); + return n; +} + +function readBounded(fd: number, maxBytes: number, label: string): string { + const chunks: Buffer[] = []; + const chunk = Buffer.allocUnsafe(64 * 1024); + let total = 0; + for (;;) { + const n = readSync(fd, chunk, 0, chunk.length, null); + if (n === 0) break; + total += n; + if (total > maxBytes) { + throw new UsageError(`--task-json: ${label} exceeds ${maxBytes} bytes (task JSON limit); refusing to read further`); + } + chunks.push(Buffer.from(chunk.subarray(0, n))); + } + return Buffer.concat(chunks, total).toString("utf8"); +} + +/** + * Read a task from a local JSON file in any shape the CLI writes or the API + * returns (API task, download meta.json, v1 envelope / result). Every problem + * with the file is a usage error: the caller named it, so a bad file is a + * mistake to report, not a fact about the model. + */ +export function readTaskJsonFile(path: string, opts: { cwd?: string; maxBytes?: number } = {}): LoadedTaskJson { + const cwd = opts.cwd ?? process.cwd(); + const maxBytes = opts.maxBytes ?? TASK_JSON_MAX_BYTES; + const abs = resolvePath(cwd, path); + let fd: number; + try { + fd = openSync(abs, "r"); + } catch (err) { + throw new UsageError(`--task-json: cannot open ${path} (${errnoMessage(err)})`); + } + let text: string; + try { + if (!fstatSync(fd).isFile()) throw new UsageError(`--task-json: not a regular file: ${path}`); + text = readBounded(fd, maxBytes, path); + } finally { + closeSync(fd); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new UsageError(`--task-json: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)})`); + } + const extracted = extractTaskObject(parsed); + if (!extracted) { + throw new UsageError( + `--task-json: ${path} does not contain a task (expected an API task object with id+status, a download meta.json with "task", or a v1 envelope with result.task)`, + ); + } + return { path: abs, task: extracted.task, shape: extracted.source }; +} + +function resolveSource(opts: FacesOptions): TaskSource { + const hasFile = opts.taskJson !== undefined; + const hasApi = opts.resource !== undefined || opts.taskId !== undefined; + if (hasFile && hasApi) { + throw new UsageError("choose one task source: --task-json , or --resource with --task-id — not both"); + } + if (hasFile) return { kind: "task-json", path: opts.taskJson! }; + if (opts.resource === undefined || opts.taskId === undefined) { + throw new UsageError("a task source is required: --task-json , or --resource together with --task-id "); + } + return { kind: "api", resource: opts.resource, taskId: opts.taskId }; +} + +function taskIdOf(task: Record, fallback: string | null): string | null { + if (typeof task["id"] === "string" && task["id"]) return task["id"] as string; + if (typeof task["task_id"] === "string" && task["task_id"]) return task["task_id"] as string; + return fallback; +} + +export interface FacesResult { + face_count: number | null; + limit: number; + comparison: "lte"; + verdict: "pass" | "fail" | "unknown"; + reason: string | null; + source: + | { kind: "task-json"; path: string; shape: TaskJsonShape } + | { kind: "api"; resource: string; task_id: string; endpoint: string; requests_made: 1 }; + task_id: string | null; + status: string | null; + /** Only on `fail`: an unexecuted remesh the caller may run. */ + suggestion: RemeshSuggestion | null; + saved_json: SavedJson | null; +} + +/** Build a fresh `inspect` command tree (tests parse a new tree per run). */ +export function buildInspectCommand(): Command { + const faces = new Command("faces") + .description( + "Face-count gate: pass (exit 0) | fail (exit 12) | unknown (exit 13). Answers only whether face_count <= --max-faces; " + + "a missing or malformed count is unknown, never 0. Never downloads a model or submits a remesh", + ) + .option("--task-json ", "saved task JSON (API task, download meta.json or v1 envelope); no network, no credential") + .option("--resource ", "task resource id (see `meshy resources`), fetched once with --task-id") + .option("--task-id ", "task id to fetch once from the API (with --resource)") + .option("--max-faces ", "maximum accepted face count (required; e.g. 300000 for rigging, 40000 for uv-unwrap — no default)", parseMaxFaces) + .option("--save-json ", "API source only: save the task JSON as received (never overwrites)") + .action(async (opts: FacesOptions, thisCmd: Command) => { + const opened = openCommand(thisCmd, "inspect.faces", "v1"); + rejectOutputFlagForV1(opened, opts.saveJson); + if (opts.maxFaces === undefined) { + throw new UsageError( + "--max-faces is required: the limit belongs to the caller (rigging accepts up to 300000 faces, uv-unwrap up to 40000); no default is assumed", + ); + } + const limit = opts.maxFaces; + const source = resolveSource(opts); + + let task: Record; + let sourceInfo: FacesResult["source"]; + let savedJson: SavedJson | null = null; + if (source.kind === "task-json") { + if (opts.saveJson) { + throw new UsageError("--save-json only applies to the API source (--resource/--task-id); the task JSON is already a file"); + } + buildLocalRuntime(opened.flags); + const loaded = readTaskJsonFile(source.path); + task = loaded.task; + sourceInfo = { kind: "task-json", path: loaded.path, shape: loaded.shape }; + } else { + const descriptor = findTaskResource(source.resource); + if (!descriptor) { + throw new UsageError(`unknown --resource '${source.resource}'. Valid ids: ${TASK_RESOURCES.map((d) => d.id).join(", ")}`); + } + const runtime = await buildRuntime(opened.flags); + // Exactly one GET; the verdict is read from the raw JSON, not the Zod-defaulted object. + const { raw } = await runtime.client.endpointFor(descriptor).retrieveDetailed(source.taskId, { signal: abortSignal() }); + const extracted = extractTaskObject(raw); + task = extracted?.task ?? (raw as Record); + savedJson = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + sourceInfo = { kind: "api", resource: descriptor.id, task_id: source.taskId, endpoint: descriptor.legacyEndpoint, requests_made: 1 }; + } + + const verdict = judgeTask(task, limit); + const taskId = taskIdOf(task, source.kind === "api" ? source.taskId : null); + const result: FacesResult = { + ...verdict, + source: sourceInfo, + task_id: taskId, + status: typeof task["status"] === "string" ? (task["status"] as string) : null, + suggestion: verdict.verdict === "fail" ? remeshSuggestion(taskId, limit) : null, + saved_json: savedJson, + }; + if (verdict.verdict === "fail") { + throw new CliError({ code: "check_failed", message: `face count check failed: ${verdict.reason}`, result: { ...result } }); + } + if (verdict.verdict === "unknown") { + throw new CliError({ code: "check_unknown", message: `face count unknown: ${verdict.reason}`, result: { ...result } }); + } + await emitResult(opened, result, result); + }); + + return new Command("inspect").description("Local checks on task results (face-count gate)").addCommand(faces); +} + +export const inspectCommand = buildInspectCommand(); diff --git a/src/cmd/mesh.ts b/src/cmd/mesh.ts new file mode 100644 index 0000000..21b954d --- /dev/null +++ b/src/cmd/mesh.ts @@ -0,0 +1,76 @@ +/** + * mesh — local geometry helpers. No API, no credential, no network. + * + * `prepare-print` is the legacy `fix_obj.py` transform (Y-up → Z-up, scaled to a + * target height, XY centred, grounded at Z=0) as a streaming, never-overwriting + * command. The output file is chosen by the global `-o/--output ` flag: + * Commander parses root options wherever they appear, so a second `--output` + * declared here would never receive the value. Without `-o` the result is + * `.print.obj` next to the input; `--in-place` replaces the input itself. + */ + +import { Command } from "commander"; +import { dirname, resolve as resolvePath } from "node:path"; +import { emitResult, openCommand } from "../internal/command-helpers.js"; +import { UsageError } from "../internal/errors.js"; +import { parseNumber } from "../internal/flags.js"; +import { DEFAULT_HEIGHT_MM, defaultOutputPath, prepareObjForPrint } from "../internal/obj-transform.js"; +import { resolveWithinRoot } from "../internal/paths.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; + +const prepareCommand = new Command("prepare-print") + .description( + "Rotate a Y-up OBJ to Z-up, scale it to --height-mm, centre XY and rest it on Z=0 for slicing. " + + "Writes .print.obj beside the input unless -o, --output names the target (a new file, " + + "or an existing directory) or --in-place replaces the input. mtllib/texture dependencies are copied " + + "when the output moves to another directory; a missing dependency is refused unless --geometry-only", + ) + .argument("", "OBJ file to prepare") + .option("--height-mm ", "target height in millimetres, finite and > 0", parseNumber, DEFAULT_HEIGHT_MM) + .option("--in-place", "replace the input file itself (temp file + atomic rename); cannot be combined with -o/--output") + .option("--geometry-only", "never copy MTL/texture dependencies; a missing dependency becomes a warning") + .action( + async ( + input: string, + opts: { heightMm: number; inPlace?: boolean; geometryOnly?: boolean }, + thisCmd: Command, + ) => { + const opened = openCommand(thisCmd, "mesh.prepare-print", "v1"); + buildLocalRuntime(opened.flags); + const outputFlag = opened.flags.output; + if (outputFlag !== undefined && opts.inPlace) { + throw new UsageError("--output/-o and --in-place are mutually exclusive: pick a new file or replace the input, not both"); + } + const cwd = process.cwd(); + const workspace = opened.flags.workspace; + + // Reads may follow a symlinked input; an in-place replacement may not + // (renaming over the link would drop the bytes somewhere else). + const inputAbs = resolvePath(cwd, input); + const resolvedInput = resolveWithinRoot(inputAbs, workspace ?? dirname(inputAbs), { + cwd, + allowSymlinkLeaf: !opts.inPlace, + label: "input", + }); + + let outputPath: string | undefined; + if (outputFlag !== undefined) { + const outAbs = resolvePath(cwd, outputFlag); + outputPath = resolveWithinRoot(outAbs, workspace ?? dirname(outAbs), { cwd, label: "--output" }).path; + } else if (!opts.inPlace && workspace) { + outputPath = resolveWithinRoot(defaultOutputPath(resolvedInput.path), workspace, { cwd, label: "output" }).path; + } + + const report = await prepareObjForPrint(resolvedInput.path, { + heightMm: opts.heightMm, + outputPath, + inPlace: opts.inPlace, + geometryOnly: opts.geometryOnly, + }); + await emitResult(opened, report, report, { warnings: report.warnings }); + }, + ); + +export const meshCommand = new Command("mesh") + .description("Local mesh helpers (no API key required)") + .addCommand(prepareCommand); diff --git a/src/cmd/resources.ts b/src/cmd/resources.ts index 48c3902..3c53e80 100644 --- a/src/cmd/resources.ts +++ b/src/cmd/resources.ts @@ -48,6 +48,10 @@ export const RESOURCES: ResourceEntry[] = [ { name: "showcases", summary: "Enterprise community showcases (every request is billed)" }, { name: "download", summary: "download selected assets of a task (saved task JSON, URL, or API)" }, { name: "project", summary: "meshy_output project folders: init | record | show | list | rebuild-index" }, + { name: "inspect", summary: "local checks: faces (face-count gate pass | fail | unknown)" }, + { name: "mesh", summary: "prepare-print: OBJ Y-up → Z-up, scale to height, centre, ground at Z=0" }, + { name: "slicer", summary: "detect installed slicers | open a model in one" }, + { name: "doctor", summary: "local environment diagnosis (no network by default)" }, ]; const VERBS = `Every task resource carries the same verbs: diff --git a/src/cmd/slicer.ts b/src/cmd/slicer.ts new file mode 100644 index 0000000..748da78 --- /dev/null +++ b/src/cmd/slicer.ts @@ -0,0 +1,54 @@ +/** + * slicer — detect installed slicers and hand a model file to one of them. + * + * Purely local: no API, no credential, no network. `detect` reports the seven + * registered slicers found on this machine (an empty list is a valid answer); + * `open` launches only a registered, detected slicer at its detected path with + * the file as one argument — no shell, no default-application fallback — and + * reports `launch_requested`, which is not a claim that the import succeeded. + */ + +import { Command } from "commander"; +import { emitResult, openCommand, type OpenedCommand } from "../internal/command-helpers.js"; +import { UsageError } from "../internal/errors.js"; +import { buildLocalRuntime } from "../internal/runtime.js"; +import { detectSlicers, LAUNCHABLE_EXTENSIONS, openInSlicer, SLICERS } from "../internal/slicers.js"; + +/** `-o` downloads assets elsewhere; here it would be silently ignored, so refuse it. */ +function rejectOutputFlag(opened: OpenedCommand): void { + if (opened.flags.output !== undefined) { + throw new UsageError(`--output/-o has no meaning for ${opened.command}; this command writes no files`); + } +} + +const detectCommand = new Command("detect") + .description( + `Detect installed slicers (${SLICERS.map((s) => s.name).join(", ")}). Local only; an empty list is a successful result`, + ) + .action(async (_opts: Record, thisCmd: Command) => { + const opened = openCommand(thisCmd, "slicer.detect", "v1"); + rejectOutputFlag(opened); + buildLocalRuntime(opened.flags); + const detection = detectSlicers(); + await emitResult(opened, detection, detection); + }); + +const openCmd = new Command("open") + .description( + "Open a model file in a detected slicer. Launches the detected executable (macOS: open -a ) with the " + + "file as a single argument and returns once the process started; it never waits for the GUI to close", + ) + .requiredOption("--slicer ", "registered slicer name or id (see: meshy slicer detect)") + .requiredOption("--file ", `model file to open (${[...LAUNCHABLE_EXTENSIONS].join(", ")})`) + .action(async (opts: { slicer: string; file: string }, thisCmd: Command) => { + const opened = openCommand(thisCmd, "slicer.open", "v1"); + rejectOutputFlag(opened); + buildLocalRuntime(opened.flags); + const launch = await openInSlicer(opts.file, opts.slicer); + await emitResult(opened, launch, launch); + }); + +export const slicerCommand = new Command("slicer") + .description("Detect installed slicers and open models in them (no API key required)") + .addCommand(detectCommand) + .addCommand(openCmd); diff --git a/src/internal/doctor.ts b/src/internal/doctor.ts new file mode 100644 index 0000000..efd9e43 --- /dev/null +++ b/src/internal/doctor.ts @@ -0,0 +1,345 @@ +/** + * doctor — read-only environment diagnosis. + * + * The default run is fully local and must complete with no credential, no + * network and an empty config directory. It reports versions, which credential + * sources are *present* — never their values: the stored profile is stat'ed + * and not parsed, the flag and env var become booleans, and an explicit + * `--api-key-file` is run through the same parser API commands use with only + * its verdict kept — the effective base URLs, workspace writability and + * whether the cwd holds a `.env` candidate (named, never read: there is no + * auto-discovery, D-016). Nothing here refreshes an OAuth token or writes. + * + * `--check-api` resolves the credential exactly like an API command (flags → + * env → key file → stored profile) and makes one GET /balance — the only free + * authenticated endpoint. `--check-slicers` runs the local slicer detection. + * Neither is implied by the other (D-024). Everything else stays offline. + */ + +import { accessSync, constants as fsConstants, statSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import { MeshyClient } from "../client/index.js"; +import { + DEFAULT_BASE_URL_V1, + DEFAULT_BASE_URL_V2, + deriveCreativeLabBase, + derivePublicWebBase, + loadConfig, + type MeshyConfig, +} from "./config.js"; +import { credentialsPath } from "./credentials.js"; +import { loadEnvFile } from "./env-file.js"; +import { configOverridesFrom, type GlobalFlags } from "./runtime.js"; +import { detectSlicers as detectSlicersImpl, type DetectionEnv, type SlicerDetection } from "./slicers.js"; +import { VERSION } from "./version.js"; + +export type DoctorCheckStatus = "ok" | "warn" | "fail" | "skipped"; + +export interface DoctorCheck { + id: string; + status: DoctorCheckStatus; + detail: string; +} + +export interface DoctorReport { + cli: { version: string; node: string; platform: string; arch: string }; + /** Node satisfies the engine range and the CLI loaded: local commands can run. */ + local_ready: boolean; + /** null unless --check-api was requested. */ + api_ready: boolean | null; + checks: DoctorCheck[]; + credential_sources: { + flag: boolean; + env: boolean; + /** Absolute path of --api-key-file when given (its verdict is a check), else null. */ + api_key_file: string | null; + stored_profile: { path: string; exists: boolean }; + }; + base_urls: { v1: string; v2: string; creative_lab: string | null; public_web: string }; + workspace: { path: string | null; writable: boolean | null }; + /** Names among .env / .env.local present in cwd — never read. */ + cwd_env_candidates: string[]; + slicers?: unknown; + api?: { balance: number } | { error: string } | null; +} + +export type { SlicerDetection } from "./slicers.js"; + +/** Injectable slicer detection (tests pass a fake); the default is internal/slicers.ts. */ +export type SlicerDetector = (env?: Partial) => SlicerDetection | unknown; + +export interface DoctorOptions { + flags: GlobalFlags; + checkApi: boolean; + checkSlicers: boolean; + /** Drives the local checks (default process.env). `--check-api` resolves through loadConfig, which reads process.env like every API command. */ + env?: NodeJS.ProcessEnv; + cwd?: string; + detectSlicers?: SlicerDetector; + probeBalance?: () => Promise; +} + +export interface DoctorApiFailure { + /** `credentials`: no usable credential could be resolved; `balance`: the single GET failed. */ + stage: "credentials" | "balance"; + error: unknown; +} + +export interface DoctorOutcome { + report: DoctorReport; + /** Set when --check-api did not end with api_ready true; the command maps it to an exit code. */ + apiFailure: DoctorApiFailure | null; +} + +const REQUIRED_NODE_MAJOR = 24; +const CWD_ENV_CANDIDATES = [".env", ".env.local"] as const; +/** Mirrors config.ts: an empty or placeholder key means "unset". */ +const PLACEHOLDER_KEYS = new Set(["", "YOUR_MESHY_API_KEY_HERE"]); + +function keyPresent(value: string | null | undefined): boolean { + return typeof value === "string" && !PLACEHOLDER_KEYS.has(value.trim()); +} + +function stripTrail(s: string): string { + return s.replace(/\/+$/, ""); +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** Replace every known secret in free text; paths and verdicts are all a report needs. */ +function scrub(text: string, secrets: readonly string[]): string { + let out = text; + for (const secret of secrets) { + if (secret.length >= 4) out = out.split(secret).join("[redacted]"); + } + return out; +} + +function summarizeSlicers(detection: unknown): string { + if (detection && typeof detection === "object") { + const d = detection as Partial; + const found = Array.isArray(d.slicers) ? d.slicers : null; + if (found) { + const names = found.map((s) => (s && typeof s === "object" && typeof s.name === "string" ? s.name : "?")); + const platform = typeof d.platform === "string" ? d.platform : process.platform; + return found.length === 0 + ? `no registered slicer detected on ${platform}` + : `${found.length} slicer(s) detected on ${platform}: ${names.join(", ")}`; + } + } + return "slicer detection completed"; +} + +/** Run the diagnosis and also return the raw API failure for exit-code mapping. */ +export async function runDoctorDetailed(opts: DoctorOptions): Promise { + const { flags } = opts; + const env = opts.env ?? process.env; + const cwd = opts.cwd ?? process.cwd(); + const checks: DoctorCheck[] = []; + const secrets: string[] = []; + if (typeof flags.apiKey === "string") secrets.push(flags.apiKey.trim()); + if (typeof env["MESHY_API_KEY"] === "string") secrets.push(env["MESHY_API_KEY"].trim()); + + // --- CLI and runtime ----------------------------------------------------- + const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10); + const nodeOk = Number.isFinite(nodeMajor) && nodeMajor >= REQUIRED_NODE_MAJOR; + checks.push({ id: "cli", status: "ok", detail: `meshy-cli ${VERSION} on node ${process.version} (${process.platform} ${process.arch})` }); + checks.push({ + id: "node", + status: nodeOk ? "ok" : "fail", + detail: nodeOk + ? `node ${process.version} satisfies the required >=${REQUIRED_NODE_MAJOR}` + : `node ${process.version} is below the required >=${REQUIRED_NODE_MAJOR}; install Node ${REQUIRED_NODE_MAJOR} or newer`, + }); + + // --- Base URLs (same precedence as config.ts, resolved without a credential) --- + const v1 = stripTrail(flags.baseUrlV1 ?? env["MESHY_BASE_URL_V1"] ?? DEFAULT_BASE_URL_V1); + const v2 = stripTrail(flags.baseUrlV2 ?? env["MESHY_BASE_URL_V2"] ?? DEFAULT_BASE_URL_V2); + const explicitCreativeLab = flags.baseUrlCreativeLab ?? env["MESHY_BASE_URL_CREATIVE_LAB"]; + const creativeLab = explicitCreativeLab ? stripTrail(explicitCreativeLab) : deriveCreativeLabBase(v1); + const publicWeb = derivePublicWebBase(v1); + let v1Parses = true; + try { + new URL(v1); + } catch { + v1Parses = false; + } + checks.push({ + id: "base_urls", + status: v1Parses && creativeLab !== null ? "ok" : "warn", + detail: !v1Parses + ? `v1 base ${JSON.stringify(v1)} is not a valid URL (check --base-url-v1 / MESHY_BASE_URL_V1)` + : creativeLab === null + ? `v1 ${v1}; v2 ${v2}; Creative Lab base cannot be derived from a non-standard v1 path — pass --base-url-creative-lab when using creative-lab commands` + : `v1 ${v1}; v2 ${v2}; creative-lab ${creativeLab}; public-web ${publicWeb}`, + }); + + // --- Credential sources: presence only ---------------------------------- + const flagPresent = keyPresent(flags.apiKey); + const envPresent = keyPresent(env["MESHY_API_KEY"]); + let apiKeyFilePath: string | null = null; + let apiKeyFileUsable = false; + if (flags.envFile) { + apiKeyFilePath = resolvePath(cwd, flags.envFile); + try { + const loaded = loadEnvFile(flags.envFile, cwd); + apiKeyFilePath = loaded.path; + if (loaded.apiKey) secrets.push(loaded.apiKey.trim()); + const ignored = loaded.otherKeys.length > 0 ? `; ${loaded.otherKeys.length} other key(s) ignored: ${loaded.otherKeys.join(", ")}` : ""; + if (keyPresent(loaded.apiKey)) { + apiKeyFileUsable = true; + checks.push({ id: "api_key_file", status: "ok", detail: `${loaded.path} defines MESHY_API_KEY (value not shown${ignored})` }); + } else { + checks.push({ id: "api_key_file", status: "fail", detail: `${loaded.path} defines no usable MESHY_API_KEY (missing, empty or placeholder${ignored})` }); + } + } catch (err) { + checks.push({ id: "api_key_file", status: "fail", detail: messageOf(err) }); + } + } else { + checks.push({ id: "api_key_file", status: "skipped", detail: "--api-key-file not given" }); + } + const storedPath = credentialsPath(v1, env); + const storedExists = isFile(storedPath); + const anySource = flagPresent || envPresent || apiKeyFileUsable || storedExists; + checks.push({ + id: "credentials", + status: anySource ? "ok" : "warn", + detail: + `sources present: --api-key=${flagPresent ? "yes" : "no"}, MESHY_API_KEY=${envPresent ? "yes" : "no"}, ` + + `--api-key-file=${flags.envFile ? (apiKeyFileUsable ? "usable" : "unusable") : "none"}, ` + + `stored profile=${storedExists ? "present" : "absent"} (${storedPath}); values are never read by doctor` + + (anySource ? "" : ". API commands need --api-key, MESHY_API_KEY, --api-key-file or `meshy auth login`"), + }); + + // --- Workspace ----------------------------------------------------------- + let workspace: DoctorReport["workspace"] = { path: null, writable: null }; + if (flags.workspace) { + const abs = resolvePath(cwd, flags.workspace); + workspace = { path: abs, writable: null }; + let st: ReturnType | null = null; + try { + st = statSync(abs); + } catch { + st = null; + } + if (!st) { + checks.push({ id: "workspace", status: "warn", detail: `${abs} does not exist yet; local tools will create it on first write` }); + } else if (!st.isDirectory()) { + workspace.writable = false; + checks.push({ id: "workspace", status: "fail", detail: `${abs} is not a directory` }); + } else { + try { + accessSync(abs, fsConstants.W_OK); + workspace.writable = true; + checks.push({ id: "workspace", status: "ok", detail: `${abs} is a writable directory` }); + } catch { + workspace.writable = false; + checks.push({ id: "workspace", status: "fail", detail: `${abs} is not writable by this user` }); + } + } + } else { + checks.push({ id: "workspace", status: "skipped", detail: "--workspace not given (files land next to their targets)" }); + } + + // --- cwd .env candidates: names only -------------------------------------- + const cwdEnvCandidates = CWD_ENV_CANDIDATES.filter((name) => isFile(resolvePath(cwd, name))); + checks.push({ + id: "cwd_env_files", + status: "ok", + detail: + cwdEnvCandidates.length === 0 + ? `no .env or .env.local in ${cwd} (none is ever read automatically)` + : `${cwdEnvCandidates.join(", ")} found in ${cwd}; not read — pass --api-key-file to use one`, + }); + + const report: DoctorReport = { + cli: { version: VERSION, node: process.version, platform: process.platform, arch: process.arch }, + local_ready: nodeOk && VERSION.length > 0, + api_ready: null, + checks, + credential_sources: { + flag: flagPresent, + env: envPresent, + api_key_file: apiKeyFilePath, + stored_profile: { path: storedPath, exists: storedExists }, + }, + base_urls: { v1, v2, creative_lab: creativeLab, public_web: publicWeb }, + workspace, + cwd_env_candidates: cwdEnvCandidates, + slicers: null, + api: null, + }; + + // --- --check-api: one free GET /balance ------------------------------------ + let apiFailure: DoctorApiFailure | null = null; + if (opts.checkApi) { + let config: MeshyConfig | null = null; + try { + config = loadConfig(configOverridesFrom(flags)); + secrets.push(config.apiKey); + } catch (err) { + apiFailure = { stage: "credentials", error: err }; + report.api_ready = false; + report.api = { error: messageOf(err) }; + checks.push({ id: "api", status: "fail", detail: `no usable credential: ${messageOf(err)}` }); + } + if (config) { + const resolved = config; + const probe = opts.probeBalance ?? (async (): Promise => (await new MeshyClient(resolved).balance.get()).balance); + try { + const balance = await probe(); + report.api_ready = true; + report.api = { balance }; + checks.push({ + id: "api", + status: "ok", + detail: `GET ${resolved.baseUrlV1}/balance succeeded with the ${resolved.credentialSource} credential; balance ${balance}`, + }); + } catch (err) { + apiFailure = { stage: "balance", error: err }; + report.api_ready = false; + report.api = { error: messageOf(err) }; + checks.push({ id: "api", status: "fail", detail: `GET /balance failed: ${messageOf(err)}` }); + } + } + } else { + checks.push({ id: "api", status: "skipped", detail: "not requested (pass --check-api for one free GET /balance)" }); + } + + // --- --check-slicers: local detection only --------------------------------- + if (opts.checkSlicers) { + try { + const detect: SlicerDetector = opts.detectSlicers ?? ((overrides) => detectSlicersImpl(overrides)); + // Detection reads the real platform/filesystem; only the environment block is injectable. + const detection = detect(opts.env ? { env: opts.env as Record } : undefined); + report.slicers = detection; + checks.push({ id: "slicers", status: "ok", detail: summarizeSlicers(detection) }); + } catch (err) { + report.slicers = { error: messageOf(err) }; + checks.push({ id: "slicers", status: "fail", detail: `slicer detection failed: ${messageOf(err)}` }); + } + } else { + checks.push({ id: "slicers", status: "skipped", detail: "not requested (pass --check-slicers)" }); + } + + // Free text is the only place a secret could slip through; strip every known value. + for (const check of checks) check.detail = scrub(check.detail, secrets); + if (report.api && "error" in report.api) report.api = { error: scrub(report.api.error, secrets) }; + + return { report, apiFailure }; +} + +/** Run the diagnosis; never throws for a missing credential or a failed check. */ +export async function runDoctor(opts: DoctorOptions): Promise { + return (await runDoctorDetailed(opts)).report; +} diff --git a/src/internal/inspect.ts b/src/internal/inspect.ts new file mode 100644 index 0000000..26fc824 --- /dev/null +++ b/src/internal/inspect.ts @@ -0,0 +1,139 @@ +/** + * Face-count gate — the verdict logic behind `meshy inspect faces`. + * + * The legacy Skill's check-faces script defaulted a missing face_count to 0 and + * printed a passing line for a model it never measured. Nothing here fabricates + * a number: only a finite, non-negative integer that the task actually carries + * is "known"; everything else is `unknown`, which the command turns into exit + * 13 rather than a pass (D-009). Strings are never parsed into numbers — a + * `"1234"` that the server never meant as a count must not become one here. + * + * The gate answers exactly one question — is face_count <= the caller's limit? + * It knows nothing about rigging eligibility (mesh shape, textures, humanoid + * proportions), so nothing in this module says "rig-ready". When it fails it + * can describe a remesh that would help; it never runs one. + */ + +import { isTerminalStatus } from "../client/types.js"; + +export type FaceVerdictKind = "pass" | "fail" | "unknown"; + +export interface FaceVerdict { + /** The measured value when known, null for `unknown` (never a substituted 0). */ + face_count: number | null; + limit: number; + comparison: "lte"; + verdict: FaceVerdictKind; + /** Why the verdict is `fail` or `unknown`; null for `pass`. */ + reason: string | null; +} + +export interface FaceCountSource { + /** The raw value exactly as the task carries it (undefined when absent). */ + value: unknown; + /** Where the value came from; `none` when the task has no top-level face_count key. */ + source: "face_count" | "none"; + status: string | null; +} + +export interface RemeshSuggestion { + description: string; + /** A command the caller may run; null when it cannot be built safely. */ + command: string | null; + /** Always false: inspect never submits a billable task. */ + executed: false; +} + +/** The remesh endpoint's accepted target_polycount range (see cmd/remesh.ts). */ +export const REMESH_POLYCOUNT_RANGE = { min: 100, max: 300_000 } as const; + +/** Task ids are echoed into a shell command only when they are plain tokens. */ +const SAFE_TASK_ID = /^[A-Za-z0-9._:-]+$/; + +function assertLimit(limit: number): void { + if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) { + throw new RangeError(`face-count limit must be a positive integer (got ${String(limit)})`); + } +} + +function describe(value: unknown): string { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; +} + +/** + * Judge a raw face_count against `limit`. Only a finite, non-negative integer is + * a known count; `<= limit` passes, anything larger fails, everything else is + * unknown with a reason that names what was wrong. + */ +export function judgeFaceCount(raw: unknown, limit: number): FaceVerdict { + assertLimit(limit); + const unknown = (reason: string): FaceVerdict => ({ face_count: null, limit, comparison: "lte", verdict: "unknown", reason }); + if (raw === undefined) return unknown("face_count missing"); + if (raw === null) return unknown("face_count is null"); + if (typeof raw === "string") { + const shown = raw.length > 40 ? `${raw.slice(0, 40)}…` : raw; + return unknown(`face_count is a string, not a number (${JSON.stringify(shown)} is not parsed)`); + } + if (typeof raw !== "number") return unknown(`face_count is not a number (got ${describe(raw)})`); + if (!Number.isFinite(raw)) return unknown(`face_count is not a finite number (got ${String(raw)})`); + if (!Number.isInteger(raw) || raw < 0) return unknown(`face_count is not a non-negative integer (got ${raw})`); + if (raw <= limit) return { face_count: raw, limit, comparison: "lte", verdict: "pass", reason: null }; + return { + face_count: raw, + limit, + comparison: "lte", + verdict: "fail", + reason: `face_count ${raw} exceeds the limit ${limit} by ${raw - limit}`, + }; +} + +/** + * Read the top-level `face_count` of a task object. Nothing is derived from + * other fields and nothing is defaulted: an absent key is reported as absent. + */ +export function faceCountFromTask(task: Record): FaceCountSource { + const status = typeof task["status"] === "string" ? (task["status"] as string) : null; + if (Object.prototype.hasOwnProperty.call(task, "face_count")) { + return { value: task["face_count"], source: "face_count", status }; + } + return { value: undefined, source: "none", status }; +} + +/** + * Judge a whole task: the top-level face_count against `limit`. A task that + * has not finished and carries no count is unknown *because it is still + * running* — the reason says so instead of implying the field will never come. + */ +export function judgeTask(task: Record, limit: number): FaceVerdict { + const { value, status } = faceCountFromTask(task); + const verdict = judgeFaceCount(value, limit); + if (verdict.verdict === "unknown" && (value === undefined || value === null) && status !== null && !isTerminalStatus(status)) { + return { ...verdict, reason: `task is ${status}; no face count yet` }; + } + return verdict; +} + +/** + * Describe — never execute — the remesh that would bring a model under `limit`. + * The target is clamped to the range the remesh endpoint accepts. + */ +export function remeshSuggestion(taskId: string | null, limit: number): RemeshSuggestion { + const target = Math.min(Math.max(Math.trunc(limit), REMESH_POLYCOUNT_RANGE.min), REMESH_POLYCOUNT_RANGE.max); + const flags = `--target-polycount ${target} --output-schema v1`; + if (taskId && SAFE_TASK_ID.test(taskId)) { + const command = `meshy remesh create --input-task-id ${taskId} ${flags}`; + return { + description: `Reduce the polycount with a remesh task targeting ${target} faces (billable; not executed by inspect — run it yourself): ${command}`, + command, + executed: false, + }; + } + const why = taskId ? "the task id contains characters that are not safe to echo into a command" : "the source task id is unknown"; + return { + description: `Reduce the polycount with a remesh task targeting ${target} faces (billable; not executed by inspect). ${why}; run: meshy remesh create --model-url ${flags}`, + command: null, + executed: false, + }; +} diff --git a/src/internal/obj-transform.ts b/src/internal/obj-transform.ts new file mode 100644 index 0000000..d90a131 --- /dev/null +++ b/src/internal/obj-transform.ts @@ -0,0 +1,741 @@ +/** + * OBJ print preparation — the legacy `fix_obj.py` transform as a streaming module. + * + * Meshy exports Y-up OBJ files in arbitrary units; slicers want Z-up + * millimetres with the model standing on the build plate. The transform is + * + * R(x, y, z) = (x, -z, y) Y-up → Z-up; determinant +1, so face winding is kept + * v' = s·R(v) + (tx, ty, tz) s = height_mm / (zmax − zmin) of the rotated box, + * XY centred on the origin, minZ on the plate (0) + * n' = R(n) normals only rotate — no scale, no translation + * + * Every other line (vt, f, o, g, s, usemtl, mtllib, comments, blanks) is copied + * verbatim, so topology, UVs and material bindings cannot drift. Extra fields on + * a `v` line (vertex colours, w) are kept verbatim after the three coordinates. + * + * Two streaming passes keep memory constant regardless of file size: pass 1 folds + * every vertex into a bounding box, pass 2 rewrites lines through a write stream + * into a temp file in the target directory. The temp file is published only after + * the whole rewrite succeeded — nothing is written when validation fails, an + * existing output is never overwritten, and the input is replaced only with + * `inPlace` (temp file + rename on the same filesystem). + * + * Material dependencies (`mtllib`, and `map_*` textures inside those MTLs) are + * resolved relative to the input and only inside the input's directory tree. + * When the output lands in another directory they are copied alongside it; a + * missing or escaping dependency is a validation error there unless the caller + * asked for `geometryOnly`, because a "success" with a silently broken material + * is worse than a refusal. No env, no credentials, no network. + */ + +import { + chmodSync, + closeSync, + createReadStream, + createWriteStream, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + type Stats, +} from "node:fs"; +import { once } from "node:events"; +import { basename, dirname, extname, join, posix, relative, resolve as resolvePath, win32 } from "node:path"; +import { finished } from "node:stream/promises"; +import { StringDecoder } from "node:string_decoder"; +import { copyFilePublished, publishTempFile, tempPathFor } from "./atomic-file.js"; +import { CliError, UsageError, type Warning } from "./errors.js"; +import { isInside, realpathLenient } from "./paths.js"; +import { warning } from "./result.js"; + +export type Vec3 = [number, number, number]; + +export interface Bbox { + min: Vec3; + max: Vec3; +} + +export const OBJ_ROTATION = "(x,y,z)->(x,-z,y)" as const; +export const DEFAULT_HEIGHT_MM = 75; +/** Same engineering ceiling as endpoint-contracts.json `limits.obj_bytes`. */ +export const DEFAULT_MAX_OBJ_BYTES = 2 * 1024 * 1024 * 1024; +/** A rotated height at or below this is treated as degenerate (flat model). */ +export const MIN_MODEL_HEIGHT = 1e-6; + +const READ_CHUNK_BYTES = 256 * 1024; +/** A "line" longer than this is not a text OBJ; refusing keeps the carry buffer bounded. */ +const MAX_LINE_CHARS = 16 * 1024 * 1024; +const WS = /\s+/; +const NUMBER_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; +const TEXTURE_KEY_RE = /^(?:map_[A-Za-z0-9_]+|bump|disp|decal|refl|norm)$/i; +const URL_RE = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//; + +export interface ObjTransformReport { + /** Absolute input path. */ + input: string; + /** Absolute path of the written file (equals `input` when in place). */ + output: string; + height_mm: number; + scale: number; + rotation: typeof OBJ_ROTATION; + translation: Vec3; + /** Bounding box of the input as written (its own axes and units). */ + before_bbox: Bbox; + /** Bounding box of the output (Z-up, millimetres). */ + after_bbox: Bbox; + counts: { + vertices: number; + normals: number; + uvs: number; + faces: number; + lines_total: number; + }; + material: { + /** `mtllib` references as written, in order, deduplicated. */ + mtllib: string[]; + /** Absolute paths of dependencies copied next to the output by this run. */ + copied: string[]; + /** References (as written) that could not be resolved inside the input's directory. */ + missing: string[]; + }; + in_place: boolean; + warnings: Warning[]; +} + +export interface PrepareObjOptions { + /** Target height in millimetres (default 75). Must be finite and > 0. */ + heightMm?: number; + /** Explicit output file (or existing directory). Mutually exclusive with `inPlace`. */ + outputPath?: string; + /** Replace the input itself via temp file + rename. */ + inPlace?: boolean; + /** Never copy MTL/texture dependencies; missing ones become warnings only. */ + geometryOnly?: boolean; + /** Refuse inputs larger than this many bytes (default 2 GiB). */ + maxBytes?: number; +} + +export function rotateYUpToZUp(v: Vec3): Vec3 { + return [v[0], -v[2], v[1]]; +} + +/** Default output beside the input: `.print.obj`. */ +export function defaultOutputPath(input: string): string { + const ext = extname(input); + const stem = ext ? basename(input, ext) : basename(input); + return join(dirname(input), `${stem}.print.obj`); +} + +/** + * Six fixed decimals (error ≤ 5e-7, inside the 1e-5 mm oracle tolerance) with + * trailing zeros trimmed so a unit box does not become a wall of `.000000`. + * `-0` never appears: a slicer gains nothing from a signed zero. + */ +export function formatObjNumber(n: number): string { + if (n === 0) return "0"; + let s = n.toFixed(6); + if (s.includes(".")) s = s.replace(/0+$/, "").replace(/\.$/, ""); + return s === "-0" ? "0" : s; +} + +function validation(message: string, extra: { hint?: string; details?: unknown } = {}): CliError { + return new CliError({ code: "validation", message, hint: extra.hint, details: extra.details }); +} + +function localIo(message: string, cause?: unknown): CliError { + return new CliError({ code: "local_io", message, cause }); +} + +function errno(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code; +} + +function removeQuietly(path: string): void { + try { + unlinkSync(path); + } catch { + /* already gone */ + } +} + +function noNegZero(n: number): number { + return n === 0 ? 0 : n; +} + +function parseCoord(token: string | undefined): number | null { + if (token === undefined || !NUMBER_RE.test(token)) return null; + const n = Number(token); + return Number.isFinite(n) ? n : null; +} + +/** First whitespace-delimited token (the OBJ keyword) of an already-trimmed line. */ +function keywordOf(t: string): string { + const m = WS.exec(t); + return m ? t.slice(0, m.index) : t; +} + +function parseVec3(tokens: string[], lineNo: number, kind: string): Vec3 { + if (tokens.length < 4) { + throw validation(`${kind} line ${lineNo} has ${tokens.length - 1} coordinate(s); expected 3`); + } + const out: number[] = []; + for (let i = 1; i <= 3; i++) { + const n = parseCoord(tokens[i]); + if (n === null) { + throw validation(`non-finite or unparseable coordinate '${tokens[i] ?? ""}' in ${kind} line ${lineNo}`); + } + out.push(n); + } + return [out[0]!, out[1]!, out[2]!]; +} + +/** Negate a numeric token textually so the input's precision survives the rotation. */ +function negateToken(token: string): string { + const n = Number(token); + if (n === 0) return "0"; + if (token.startsWith("-")) return token.slice(1); + if (token.startsWith("+")) return `-${token.slice(1)}`; + return `-${token}`; +} + +interface MutableBbox { + min: Vec3; + max: Vec3; + seen: boolean; +} + +function emptyBbox(): MutableBbox { + return { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity], seen: false }; +} + +function fold(b: MutableBbox, v: Vec3): void { + for (let i = 0; i < 3; i++) { + const c = v[i]!; + if (c < b.min[i]!) b.min[i] = c; + if (c > b.max[i]!) b.max[i] = c; + } + b.seen = true; +} + +type LineSink = (text: string, eol: string, lineNo: number) => void; + +/** + * Stream `path` line by line. Each line's own terminator ("\n", "\r\n", or "" + * for a final unterminated line) is handed to the sink so pass 2 can preserve + * the input's style. UTF-8 is decoded across chunk boundaries. `afterChunk` + * runs after every read chunk so a writer can apply backpressure. + */ +async function forEachLine(path: string, onLine: LineSink, afterChunk: (() => Promise) | null): Promise { + const stream = createReadStream(path, { highWaterMark: READ_CHUNK_BYTES }); + const decoder = new StringDecoder("utf8"); + let carry = ""; + let lineNo = 0; + try { + for await (const chunk of stream) { + carry += decoder.write(chunk as Buffer); + let start = 0; + let nl: number; + while ((nl = carry.indexOf("\n", start)) !== -1) { + let end = nl; + let eol = "\n"; + if (end > start && carry.charCodeAt(end - 1) === 13) { + end -= 1; + eol = "\r\n"; + } + lineNo += 1; + onLine(carry.slice(start, end), eol, lineNo); + start = nl + 1; + } + carry = start > 0 ? carry.slice(start) : carry; + if (carry.length > MAX_LINE_CHARS) { + throw validation(`line ${lineNo + 1} exceeds ${MAX_LINE_CHARS} characters; not a text OBJ file`); + } + if (afterChunk) await afterChunk(); + } + carry += decoder.end(); + if (carry.length > 0) { + lineNo += 1; + onLine(carry, "", lineNo); + if (afterChunk) await afterChunk(); + } + return lineNo; + } catch (err) { + stream.destroy(); + if (err instanceof CliError || err instanceof UsageError) throw err; + throw localIo(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`, err); + } +} + +interface Scan { + raw: MutableBbox; + rotated: MutableBbox; + vertices: number; + normals: number; + uvs: number; + faces: number; + lines: number; + mtllib: string[]; +} + +/** Pass 1: bounding boxes, counts and material references. Constant memory. */ +async function scanObj(input: string): Promise { + const scan: Scan = { raw: emptyBbox(), rotated: emptyBbox(), vertices: 0, normals: 0, uvs: 0, faces: 0, lines: 0, mtllib: [] }; + const seenMtl = new Set(); + scan.lines = await forEachLine( + input, + (text, _eol, lineNo) => { + const t = text.trim(); + if (t.length === 0) return; + switch (keywordOf(t)) { + case "v": { + const v = parseVec3(t.split(WS), lineNo, "v"); + fold(scan.raw, v); + fold(scan.rotated, rotateYUpToZUp(v)); + scan.vertices += 1; + break; + } + case "vn": + parseVec3(t.split(WS), lineNo, "vn"); + scan.normals += 1; + break; + case "vt": + scan.uvs += 1; + break; + case "f": + scan.faces += 1; + break; + case "mtllib": { + const ref = t.slice("mtllib".length).trim(); + if (ref && !seenMtl.has(ref)) { + seenMtl.add(ref); + scan.mtllib.push(ref); + } + break; + } + default: + break; + } + }, + null, + ); + return scan; +} + +interface Transform { + scale: number; + translation: Vec3; +} + +function rewriteLine(text: string, lineNo: number, tf: Transform): string { + const t = text.trim(); + if (t.length === 0) return text; + const key = keywordOf(t); + if (key === "v") { + const tokens = t.split(WS); + const [rx, ry, rz] = rotateYUpToZUp(parseVec3(tokens, lineNo, "v")); + const parts = [ + "v", + formatObjNumber(rx * tf.scale + tf.translation[0]), + formatObjNumber(ry * tf.scale + tf.translation[1]), + formatObjNumber(rz * tf.scale + tf.translation[2]), + ]; + for (let i = 4; i < tokens.length; i++) parts.push(tokens[i]!); + return parts.join(" "); + } + if (key === "vn") { + const tokens = t.split(WS); + parseVec3(tokens, lineNo, "vn"); + // (nx, ny, nz) → (nx, -nz, ny), reordering the original tokens so no precision is lost. + const parts = ["vn", tokens[1]!, negateToken(tokens[3]!), tokens[2]!]; + for (let i = 4; i < tokens.length; i++) parts.push(tokens[i]!); + return parts.join(" "); + } + return text; +} + +/** Pass 2: rewrite into `tmp`, streaming with backpressure. */ +async function rewriteObj(input: string, tmp: string, tf: Transform): Promise { + const out = createWriteStream(tmp, { flags: "wx" }); + let writeError: Error | null = null; + out.on("error", (err) => { + writeError = err; + }); + let buf = ""; + const flush = async (): Promise => { + if (writeError) throw writeError; + if (buf.length === 0) return; + const chunk = buf; + buf = ""; + if (!out.write(chunk)) await once(out, "drain"); + if (writeError) throw writeError; + }; + try { + await forEachLine( + input, + (text, eol, lineNo) => { + buf += rewriteLine(text, lineNo, tf) + eol; + }, + flush, + ); + await flush(); + out.end(); + await finished(out); + } catch (err) { + out.destroy(); + if (err instanceof CliError || err instanceof UsageError) throw err; + throw localIo(`failed to write ${tmp}: ${err instanceof Error ? err.message : String(err)}`, err); + } +} + +type Located = { kind: "found"; path: string; real: string } | { kind: "outside" } | { kind: "missing" }; + +/** + * Resolve a material reference relative to `baseDir`, admitting only regular + * files whose real path stays inside `rootDir`. URLs, absolute paths (POSIX or + * Windows) and anything escaping the tree are never read. + */ +function locateDependency(ref: string, baseDir: string, rootReal: string): Located { + if (!ref || URL_RE.test(ref) || /^data:/i.test(ref)) return { kind: "outside" }; + const candidates = [ref]; + if (ref.includes("\\")) candidates.push(ref.replaceAll("\\", "/")); + let sawMissing = false; + for (const candidate of candidates) { + if (posix.isAbsolute(candidate) || win32.isAbsolute(candidate)) return { kind: "outside" }; + const abs = resolvePath(baseDir, candidate); + let real: string; + try { + real = realpathLenient(abs); + } catch { + sawMissing = true; + continue; + } + if (!isInside(rootReal, real)) return { kind: "outside" }; + let st: Stats | null = null; + try { + st = statSync(real); + } catch (err) { + if (errno(err) !== "ENOENT" && errno(err) !== "ENOTDIR") throw localIo(`cannot access ${abs}: ${(err as Error).message}`, err); + } + if (st?.isFile()) return { kind: "found", path: abs, real }; + sawMissing = true; + } + return sawMissing ? { kind: "missing" } : { kind: "outside" }; +} + +/** Texture references (`map_Kd [-options] file`) inside an MTL. */ +export function textureReferencesInMtl(text: string): string[] { + const refs: string[] = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const key = keywordOf(line); + if (!TEXTURE_KEY_RE.test(key)) continue; + const rest = line.slice(key.length).trim(); + if (!rest) continue; + // Options (`-s 1 1 1`, `-bm 0.5`) precede the filename; without options the + // whole remainder is the name so spaces in filenames survive. + const ref = rest.startsWith("-") ? rest.split(WS).at(-1)! : rest; + if (!refs.includes(ref)) refs.push(ref); + } + return refs; +} + +interface MaterialPlan { + copies: Array<{ ref: string; source: string; target: string }>; + missing: string[]; + warnings: Warning[]; +} + +function planMaterials( + refs: string[], + inputDir: string, + outputDir: string, + wantCopies: boolean, +): MaterialPlan { + const plan: MaterialPlan = { copies: [], missing: [], warnings: [] }; + const inputReal = realpathLenient(inputDir); + const targets = new Set(); + + const note = (ref: string, located: Located, origin: string): void => { + plan.missing.push(ref); + if (located.kind === "outside") { + plan.warnings.push( + warning( + "material_dependency_outside_input_dir", + `${origin} references '${ref}', which is a URL, an absolute path or escapes the input's directory; it was not read or copied`, + ), + ); + } else { + plan.warnings.push(warning("material_dependency_missing", `${origin} references '${ref}', which does not exist next to the input`)); + } + }; + + const schedule = (ref: string, real: string): void => { + if (!wantCopies) return; + const target = join(outputDir, relative(inputReal, real)); + if (targets.has(target)) return; + targets.add(target); + plan.copies.push({ ref, source: real, target }); + }; + + for (const written of refs) { + // `mtllib a.mtl b.mtl` lists several files, but names with spaces exist in + // the wild: try the whole remainder first, split only when it is not a file. + let names = [written]; + if (WS.test(written) && locateDependency(written, inputDir, inputReal).kind !== "found") { + names = written.split(WS); + } + for (const name of names) { + const located = locateDependency(name, inputDir, inputReal); + if (located.kind !== "found") { + note(name, located, "mtllib"); + continue; + } + schedule(name, located.real); + let mtlText: string; + try { + mtlText = readFileSync(located.real, "utf8"); + } catch (err) { + throw localIo(`failed to read material file ${located.path}: ${(err as Error).message}`, err); + } + const mtlDir = dirname(located.real); + for (const texRef of textureReferencesInMtl(mtlText)) { + const tex = locateDependency(texRef, mtlDir, inputReal); + if (tex.kind !== "found") { + note(texRef, tex, `${name}`); + continue; + } + schedule(texRef, tex.real); + } + } + } + return plan; +} + +function sameContent(a: string, b: string): boolean { + const sa = statSync(a); + const sb = statSync(b); + if (sa.size !== sb.size) return false; + return readFileSync(a).equals(readFileSync(b)); +} + +/** Copy a dependency without overwriting; an identical file already there counts as present. */ +function copyDependency(source: string, target: string): "copied" | "present" { + let existing: Stats | null = null; + try { + existing = lstatSync(target); + } catch (err) { + if (errno(err) !== "ENOENT" && errno(err) !== "ENOTDIR") throw localIo(`cannot access ${target}: ${(err as Error).message}`, err); + } + if (existing) { + if (existing.isFile() && sameContent(source, target)) return "present"; + throw localIo(`refusing to overwrite ${target}: a different file already exists there (needed for material dependency ${basename(source)})`); + } + copyFilePublished(source, target); + return "copied"; +} + +function statInput(input: string): { stat: Stats; lstat: Stats } { + let lst: Stats; + try { + lst = lstatSync(input); + } catch (err) { + if (errno(err) === "ENOENT" || errno(err) === "ENOTDIR") { + throw new CliError({ code: "not_found", message: `input file not found: ${input}` }); + } + throw localIo(`cannot access ${input}: ${(err as Error).message}`, err); + } + let st: Stats; + try { + st = statSync(input); + } catch (err) { + if (errno(err) === "ENOENT" || errno(err) === "ENOTDIR") { + throw new CliError({ code: "not_found", message: `input file not found: ${input} (dangling symbolic link)` }); + } + throw localIo(`cannot access ${input}: ${(err as Error).message}`, err); + } + if (!st.isFile()) throw validation(`input is not a regular file: ${input}`); + return { stat: st, lstat: lst }; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function exists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch { + return false; + } +} + +/** + * Rotate, scale and ground an OBJ for printing. See the module comment for the + * maths and the file contract. Throws CliError (`validation` / `local_io` / + * `not_found`) or UsageError; on any failure no output file exists. + */ +export async function prepareObjForPrint(inputPath: string, opts: PrepareObjOptions = {}): Promise { + const heightMm = opts.heightMm ?? DEFAULT_HEIGHT_MM; + if (typeof heightMm !== "number" || !Number.isFinite(heightMm) || heightMm <= 0) { + throw validation(`--height-mm must be a finite number greater than 0 (got ${String(heightMm)})`); + } + const inPlace = opts.inPlace === true; + const geometryOnly = opts.geometryOnly === true; + if (opts.outputPath !== undefined && inPlace) { + throw new UsageError("--output/-o and --in-place are mutually exclusive: pick a new file or replace the input, not both"); + } + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_OBJ_BYTES; + + const input = resolvePath(inputPath); + const { stat, lstat } = statInput(input); + if (stat.size === 0) throw validation(`input is empty: ${input}`); + if (stat.size > maxBytes) { + throw localIo(`input is ${stat.size} bytes, above the ${maxBytes}-byte limit for local OBJ processing: ${input}`); + } + if (inPlace && lstat.isSymbolicLink()) { + throw localIo(`refusing to replace ${input} in place: it is a symbolic link`); + } + const inputDir = dirname(input); + + let output: string; + if (inPlace) { + output = input; + } else { + output = opts.outputPath !== undefined ? resolvePath(opts.outputPath) : defaultOutputPath(input); + if (isDirectory(output)) output = join(output, basename(defaultOutputPath(input))); + if (exists(output)) { + throw new CliError({ + code: "local_io", + message: `refusing to overwrite existing file: ${output} (choose another --output path, or pass --in-place to replace the input itself)`, + recovery: { action: "choose_path", automatic: false }, + }); + } + } + const outputDir = dirname(output); + + // Pass 1 — validate and measure. Nothing has been written yet. + const scan = await scanObj(input); + if (scan.vertices === 0) throw validation(`no vertex (v) lines found in ${input}`); + const rot = scan.rotated; + const height = rot.max[2] - rot.min[2]; + if (!(height > MIN_MODEL_HEIGHT)) { + throw validation( + `degenerate model: rotated height is ${height} (≤ ${MIN_MODEL_HEIGHT}); every vertex shares the same up-axis value, so no scale can be derived`, + ); + } + const scale = heightMm / height; + const translation: Vec3 = [ + noNegZero((-(rot.min[0] + rot.max[0]) / 2) * scale), + noNegZero((-(rot.min[1] + rot.max[1]) / 2) * scale), + noNegZero(-rot.min[2] * scale), + ]; + const tf: Transform = { scale, translation }; + + // Materials — decided before any write so a broken binding is refused up front. + const crossDir = !inPlace && realpathLenient(outputDir) !== realpathLenient(inputDir); + const wantCopies = crossDir && !geometryOnly; + const plan = planMaterials(scan.mtllib, inputDir, outputDir, wantCopies); + const warnings: Warning[] = [...plan.warnings]; + if (wantCopies && plan.missing.length > 0) { + throw validation( + `material dependencies of ${input} cannot be carried to ${outputDir}: ${plan.missing.join(", ")} — the output would reference files that are not there. ` + + "Pass --geometry-only to write only the geometry, or keep the output in the input's directory", + { hint: "pass --geometry-only, or write the output next to the input" }, + ); + } + if (crossDir && geometryOnly && scan.mtllib.length > 0) { + warnings.push( + warning( + "material_dependencies_not_copied", + `--geometry-only: ${scan.mtllib.join(", ")} referenced by the output were not copied to ${outputDir}`, + ), + ); + } + + // Pass 2 — rewrite into a temp file beside the target, then publish. + if (!inPlace) { + try { + mkdirSync(outputDir, { recursive: true }); + } catch (err) { + throw localIo(`cannot create output directory ${outputDir}: ${(err as Error).message}`, err); + } + } + const tmp = tempPathFor(output); + const copied: string[] = []; + try { + await rewriteObj(input, tmp, tf); + if (inPlace) { + const fd = openSync(tmp, "r"); + try { + fsyncSync(fd); + } finally { + closeSync(fd); + } + try { + chmodSync(tmp, stat.mode & 0o777); + } catch { + /* keep default mode */ + } + renameSync(tmp, output); + } else { + for (const copy of plan.copies) { + if (copyDependency(copy.source, copy.target) === "copied") { + copied.push(copy.target); + } else { + warnings.push( + warning("material_dependency_already_present", `${copy.target} already exists with identical content; not copied again`), + ); + } + } + publishTempFile(tmp, output); + } + } catch (err) { + removeQuietly(tmp); + if (err instanceof CliError || err instanceof UsageError) throw err; + throw localIo(`failed to write ${output}: ${err instanceof Error ? err.message : String(err)}`, err); + } + + const afterBbox: Bbox = { + min: [ + noNegZero(rot.min[0] * scale + translation[0]), + noNegZero(rot.min[1] * scale + translation[1]), + noNegZero(rot.min[2] * scale + translation[2]), + ], + max: [ + noNegZero(rot.max[0] * scale + translation[0]), + noNegZero(rot.max[1] * scale + translation[1]), + noNegZero(rot.max[2] * scale + translation[2]), + ], + }; + + return { + input, + output, + height_mm: heightMm, + scale, + rotation: OBJ_ROTATION, + translation, + before_bbox: { min: [...scan.raw.min], max: [...scan.raw.max] }, + after_bbox: afterBbox, + counts: { + vertices: scan.vertices, + normals: scan.normals, + uvs: scan.uvs, + faces: scan.faces, + lines_total: scan.lines, + }, + material: { mtllib: scan.mtllib, copied, missing: plan.missing }, + in_place: inPlace, + warnings, + }; +} diff --git a/src/internal/slicers.ts b/src/internal/slicers.ts new file mode 100644 index 0000000..95d4927 --- /dev/null +++ b/src/internal/slicers.ts @@ -0,0 +1,408 @@ +/** + * Slicer registry, detection and launch — the legacy `slicers.py` helper. + * + * Seven slicers are registered with the install locations the skill used; the + * `multicolor` flag is capability metadata only (the slicer can drive + * multi-material prints — it says nothing about a particular printer). + * + * Detection is a pure function of an injectable DetectionEnv so every + * platform's rules are testable on any host: + * macOS — `/Applications/.app` and `~/Applications/.app` + * Windows — `\\`, where a `*` + * suffix on the registered directory matches versioned installs + * ("Creality Print 5.1", "UltiMaker Cura 5.7") + * Linux — the three executables the skill knew about, on PATH; the other + * four are reported as unsupported instead of guessed + * + * Launching only ever runs the detected path (or `open -a ` on macOS) + * with the model file as a single argv element: no shell, no string commands, + * no default-application fallback. The legacy Windows bug — detecting an exe + * under Program Files, then running a same-named exe from PATH — is exactly + * what the detected absolute path avoids. A spawn that fails is an error; a + * spawn that succeeds is `launch_requested`, never proof that the slicer + * imported the file. On macOS the short-lived `open` helper is awaited (bounded) + * for its exit status; GUI processes are never awaited. + */ + +import { spawn as nodeSpawn } from "node:child_process"; +import type { EventEmitter } from "node:events"; +import { accessSync, constants as fsConstants, existsSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { extname, posix, resolve as resolvePath, win32 } from "node:path"; +import { CliError, UsageError } from "./errors.js"; + +export interface SlicerDescriptor { + /** Stable slug for scripts (`orca-slicer`). */ + id: string; + /** Display name, as the legacy skill spelled it (`OrcaSlicer`). */ + name: string; + /** macOS bundle name without `.app`. */ + macApp: string; + /** Windows executable file name. */ + winExe: string; + /** Windows directory under Program Files; a trailing `*` matches a version suffix. */ + winDir: string; + /** Linux executable expected on PATH, or null when the skill registered none. */ + linuxExe: string | null; + /** Capability metadata: the slicer supports multi-material / multi-colour prints. */ + multicolor: boolean; +} + +export const SLICERS: readonly SlicerDescriptor[] = [ + { id: "orca-slicer", name: "OrcaSlicer", macApp: "OrcaSlicer", winExe: "orca-slicer.exe", winDir: "OrcaSlicer", linuxExe: "orca-slicer", multicolor: true }, + { id: "bambu-studio", name: "Bambu Studio", macApp: "BambuStudio", winExe: "bambu-studio.exe", winDir: "BambuStudio", linuxExe: "bambu-studio", multicolor: true }, + { id: "creality-print", name: "Creality Print", macApp: "Creality Print", winExe: "CrealityPrint.exe", winDir: "Creality Print*", linuxExe: null, multicolor: true }, + { id: "elegoo-slicer", name: "Elegoo Slicer", macApp: "ElegooSlicer", winExe: "elegoo-slicer.exe", winDir: "ElegooSlicer", linuxExe: null, multicolor: true }, + { id: "anycubic-slicer-next", name: "Anycubic Slicer Next", macApp: "AnycubicSlicerNext", winExe: "AnycubicSlicerNext.exe", winDir: "AnycubicSlicerNext", linuxExe: null, multicolor: true }, + { id: "prusa-slicer", name: "PrusaSlicer", macApp: "PrusaSlicer", winExe: "prusa-slicer.exe", winDir: "PrusaSlicer", linuxExe: "prusa-slicer", multicolor: false }, + { id: "ultimaker-cura", name: "UltiMaker Cura", macApp: "UltiMaker Cura", winExe: "UltiMaker-Cura.exe", winDir: "UltiMaker Cura*", linuxExe: null, multicolor: false }, +]; + +/** File types a slicer can be asked to open. Anything else is a usage error. */ +export const LAUNCHABLE_EXTENSIONS: ReadonlySet = new Set(["obj", "stl", "3mf", "glb", "gltf", "step", "stp", "ply", "amf"]); + +export const NO_LINUX_EXECUTABLE = "no registered Linux executable"; + +export interface DetectionEnv { + platform: "darwin" | "win32" | "linux" | string; + env: Record; + home: string; + exists(path: string): boolean; + readdir(dir: string): string[]; + which(name: string): string | null; +} + +export interface DetectedSlicer { + id: string; + name: string; + path: string; + multicolor: boolean; + platform: string; +} + +export interface UnsupportedSlicer { + id: string; + name: string; + reason: string; +} + +export interface SlicerDetection { + platform: string; + slicers: DetectedSlicer[]; + unsupported: UnsupportedSlicer[]; +} + +/** Environment variables on Windows are case-insensitive; plain records in tests are not. */ +function envValue(env: Record, name: string): string | undefined { + if (env[name] !== undefined) return env[name]; + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(env)) { + if (key.toLowerCase() === lower) return value; + } + return undefined; +} + +function isExecutableFile(path: string, platform: string): boolean { + try { + if (!statSync(path).isFile()) return false; + if (platform !== "win32") accessSync(path, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +/** PATH lookup using the simulated platform's delimiter and PATHEXT rules. */ +function whichFromPath(env: Record, platform: string): (name: string) => string | null { + return (name) => { + const isWin = platform === "win32"; + const p = isWin ? win32 : posix; + const dirs = (envValue(env, "PATH") ?? "").split(isWin ? ";" : ":").filter(Boolean); + const exts = isWin ? (envValue(env, "PATHEXT") ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) : [""]; + for (const dir of dirs) { + for (const ext of exts) { + const candidate = p.join(dir, ext && !name.toLowerCase().endsWith(ext.toLowerCase()) ? `${name}${ext}` : name); + if (isExecutableFile(candidate, platform)) return candidate; + } + } + return null; + }; +} + +/** The real environment, with any field replaced by the caller (tests simulate other platforms). */ +export function defaultDetectionEnv(overrides: Partial = {}): DetectionEnv { + const platform = overrides.platform ?? process.platform; + const env = overrides.env ?? (process.env as Record); + return { + platform, + env, + home: overrides.home ?? homedir(), + exists: overrides.exists ?? ((path) => existsSync(path)), + readdir: + overrides.readdir ?? + ((dir) => { + try { + return readdirSync(dir); + } catch { + return []; + } + }), + which: overrides.which ?? whichFromPath(env, platform), + }; +} + +interface Probe { + path: string | null; + checked: string[]; + unsupported: string | null; +} + +function probeDarwin(s: SlicerDescriptor, env: DetectionEnv): Probe { + const checked = [posix.join("/Applications", `${s.macApp}.app`), posix.join(env.home, "Applications", `${s.macApp}.app`)]; + for (const candidate of checked) { + if (env.exists(candidate)) return { path: candidate, checked, unsupported: null }; + } + return { path: null, checked, unsupported: null }; +} + +function probeWindows(s: SlicerDescriptor, env: DetectionEnv): Probe { + const bases = [ + envValue(env.env, "ProgramFiles") ?? "C:\\Program Files", + envValue(env.env, "ProgramFiles(x86)") ?? "C:\\Program Files (x86)", + ]; + const checked: string[] = []; + for (const base of bases) { + let dirs: string[]; + if (s.winDir.endsWith("*")) { + const prefix = s.winDir.slice(0, -1).toLowerCase(); + let entries: string[]; + try { + entries = env.readdir(base); + } catch { + entries = []; + } + // Highest version last; the newest install that actually holds the exe wins. + dirs = entries + .filter((entry) => entry.toLowerCase().startsWith(prefix)) + .sort((a, b) => a.localeCompare(b, "en", { numeric: true })) + .reverse(); + if (dirs.length === 0) checked.push(win32.join(base, s.winDir, s.winExe)); + } else { + dirs = [s.winDir]; + } + for (const dir of dirs) { + const candidate = win32.join(base, dir, s.winExe); + checked.push(candidate); + if (env.exists(candidate)) return { path: candidate, checked, unsupported: null }; + } + } + return { path: null, checked, unsupported: null }; +} + +function probeLinux(s: SlicerDescriptor, env: DetectionEnv): Probe { + if (s.linuxExe === null) return { path: null, checked: [], unsupported: NO_LINUX_EXECUTABLE }; + const found = env.which(s.linuxExe); + return { path: found, checked: [`${s.linuxExe} on PATH`], unsupported: null }; +} + +function probe(s: SlicerDescriptor, env: DetectionEnv): Probe { + switch (env.platform) { + case "darwin": + return probeDarwin(s, env); + case "win32": + return probeWindows(s, env); + case "linux": + return probeLinux(s, env); + default: + return { path: null, checked: [], unsupported: `no detection rule for platform '${env.platform}'` }; + } +} + +/** Detect installed slicers. An empty `slicers` list is a successful answer. */ +export function detectSlicers(env: Partial = {}): SlicerDetection { + const resolved = defaultDetectionEnv(env); + const detection: SlicerDetection = { platform: resolved.platform, slicers: [], unsupported: [] }; + for (const s of SLICERS) { + const result = probe(s, resolved); + if (result.path !== null) { + detection.slicers.push({ id: s.id, name: s.name, path: result.path, multicolor: s.multicolor, platform: resolved.platform }); + } else if (result.unsupported !== null) { + detection.unsupported.push({ id: s.id, name: s.name, reason: result.unsupported }); + } + } + return detection; +} + +/** Registered descriptor by display name or id, case-insensitive. */ +export function findSlicer(nameOrId: string): SlicerDescriptor | undefined { + const q = nameOrId.trim().toLowerCase(); + return SLICERS.find((s) => s.id === q || s.name.toLowerCase() === q); +} + +/** The subset of a child process the launcher relies on; node's ChildProcess satisfies it. */ +export type SpawnedChild = EventEmitter & { pid?: number | undefined; unref(): void }; + +export interface LaunchSpawnOptions { + detached: true; + stdio: "ignore"; + shell: false; +} + +export type SpawnFn = (command: string, args: readonly string[], options: LaunchSpawnOptions) => SpawnedChild; + +export interface SlicerLaunch { + launch_requested: true; + slicer: DetectedSlicer; + /** Absolute path handed to the slicer. */ + file: string; + pid: number | null; + argv: string[]; + platform: string; + /** macOS only: the `open` helper's exit status (null when it was still running at the deadline). */ + launcher: { command: string; exit_code: number | null } | null; +} + +export interface OpenInSlicerOptions { + detection?: SlicerDetection; + spawn?: SpawnFn; + env?: Partial; + cwd?: string; + /** How long to wait for macOS `open` to exit before reporting `exit_code: null` (default 5 s). */ + launcherTimeoutMs?: number; +} + +const DEFAULT_LAUNCHER_TIMEOUT_MS = 5_000; + +function awaitSpawned(child: SpawnedChild): Promise { + return new Promise((resolve, reject) => { + const onSpawn = (): void => { + cleanup(); + resolve(); + }; + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + const cleanup = (): void => { + child.removeListener("spawn", onSpawn); + child.removeListener("error", onError); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); +} + +function awaitExitBounded(child: SpawnedChild, timeoutMs: number): Promise { + return new Promise((resolve) => { + const onExit = (code: number | null): void => { + clearTimeout(timer); + resolve(code); + }; + const timer = setTimeout(() => { + child.removeListener("exit", onExit); + resolve(null); + }, timeoutMs); + child.once("exit", onExit); + }); +} + +/** + * Open `file` in a registered, detected slicer. Resolves as soon as the OS + * confirms the process started (plus, on macOS, when `open` reports back); + * it never waits for a GUI to close. + */ +export async function openInSlicer(file: string, slicerNameOrId: string, opts: OpenInSlicerOptions = {}): Promise { + const descriptor = findSlicer(slicerNameOrId); + if (!descriptor) { + const registered = SLICERS.map((s) => `${s.name} (${s.id})`).join(", "); + throw new UsageError(`unknown slicer '${slicerNameOrId}'. Registered slicers: ${registered}`); + } + + const detection = opts.detection ?? detectSlicers(opts.env); + const platform = detection.platform; + const detected = detection.slicers.find((s) => s.id === descriptor.id); + if (!detected) { + const unsupported = detection.unsupported.find((s) => s.id === descriptor.id); + const checked = probe(descriptor, defaultDetectionEnv({ ...opts.env, platform })).checked; + throw new CliError({ + code: "not_found", + message: unsupported + ? `${descriptor.name} cannot be detected on ${platform}: ${unsupported.reason}` + : `${descriptor.name} is not installed in a known location on ${platform}`, + hint: "meshy slicer detect --output-schema v1", + recovery: { action: "run_hint", automatic: false, command: "meshy slicer detect --output-schema v1" }, + details: { checked }, + }); + } + + const absFile = resolvePath(opts.cwd ?? process.cwd(), file); + let fileStat; + try { + fileStat = statSync(absFile); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new CliError({ code: "not_found", message: `model file not found: ${absFile}` }); + } + throw new CliError({ code: "local_io", message: `cannot access ${absFile}: ${(err as Error).message}`, cause: err }); + } + if (!fileStat.isFile()) { + throw new CliError({ code: "not_found", message: `model path is not a regular file: ${absFile}` }); + } + const ext = extname(absFile).slice(1).toLowerCase(); + if (!LAUNCHABLE_EXTENSIONS.has(ext)) { + throw new UsageError( + `unsupported model file extension '${ext ? `.${ext}` : "(none)"}' for ${absFile}; expected one of ${[...LAUNCHABLE_EXTENSIONS].join(", ")}`, + ); + } + + let command: string; + let args: string[]; + if (platform === "darwin") { + command = "open"; + args = ["-a", detected.path, absFile]; + } else { + command = detected.path; + args = [absFile]; + } + const spawnOptions: LaunchSpawnOptions = { detached: true, stdio: "ignore", shell: false }; + const spawnImpl: SpawnFn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn); + + let child: SpawnedChild; + try { + child = spawnImpl(command, args, spawnOptions); + await awaitSpawned(child); + } catch (err) { + throw new CliError({ + code: "local_io", + message: `failed to launch ${descriptor.name} (${command}): ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } + // Late errors on an already-running child must not crash the CLI. + child.on("error", () => {}); + child.unref(); + + let launcher: SlicerLaunch["launcher"] = null; + if (platform === "darwin") { + const exitCode = await awaitExitBounded(child, opts.launcherTimeoutMs ?? DEFAULT_LAUNCHER_TIMEOUT_MS); + if (exitCode !== null && exitCode !== 0) { + throw new CliError({ + code: "local_io", + message: `open exited with code ${exitCode}: ${descriptor.name} at ${detected.path} did not accept ${absFile}`, + details: { argv: [command, ...args], exit_code: exitCode }, + }); + } + launcher = { command, exit_code: exitCode }; + } + + return { + launch_requested: true, + slicer: detected, + file: absFile, + pid: child.pid ?? null, + argv: [command, ...args], + platform, + launcher, + }; +} diff --git a/src/root.ts b/src/root.ts index 40e9f55..7895c74 100644 --- a/src/root.ts +++ b/src/root.ts @@ -26,10 +26,13 @@ import { balanceCommand } from "./cmd/balance.js"; import { convertCommand } from "./cmd/convert.js"; import { creativeLabCommand } from "./cmd/creative-lab.js"; import { deleteCommand } from "./cmd/delete.js"; +import { doctorCommand } from "./cmd/doctor.js"; import { downloadCommand } from "./cmd/download.js"; import { imageTo3dCommand } from "./cmd/image-to-3d.js"; import { imageToImageCommand } from "./cmd/image-to-image.js"; +import { inspectCommand } from "./cmd/inspect.js"; import { makeCommand } from "./cmd/make.js"; +import { meshCommand } from "./cmd/mesh.js"; import { multiColorPrintCommand } from "./cmd/multi-color-print.js"; import { multiImageTo3dCommand } from "./cmd/multi-image-to-3d.js"; import { projectCommand } from "./cmd/project.js"; @@ -40,6 +43,7 @@ import { resourcesCommand } from "./cmd/resources.js"; import { retextureCommand } from "./cmd/retexture.js"; import { riggingCommand } from "./cmd/rigging.js"; import { showcasesCommand } from "./cmd/showcases.js"; +import { slicerCommand } from "./cmd/slicer.js"; import { textTo3dCommand } from "./cmd/text-to-3d.js"; import { textToImageCommand } from "./cmd/text-to-image.js"; import { textToMotionCommand } from "./cmd/text-to-motion.js"; @@ -198,6 +202,10 @@ export function buildRootCommand(): Command { showcasesCommand, downloadCommand, projectCommand, + inspectCommand, + meshCommand, + slicerCommand, + doctorCommand, ]) { program.addCommand(cmd, { hidden: true }); } diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 0000000..1034102 --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,513 @@ +/** + * doctor — read-only diagnosis (T-102) and the two opt-in probes (T-103). + * runDoctor is exercised with injected env / cwd / probe / detector; the + * command is driven in-process with a loopback mock for --check-api. The + * default run must never call fetch, the probe or the detector, and no secret + * value may appear anywhere in the report. + */ + +process.env["MESHY_CLI_NO_UPDATE_NOTIFIER"] = "1"; + +import test from "node:test"; +import assert from "node:assert/strict"; +import { Command } from "commander"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MeshyApiError } from "../src/client/errors.js"; +import { TransportError } from "../src/client/transport.js"; +import { apiCheckFailure, buildDoctorCommand } from "../src/cmd/doctor.js"; +import { resetCommandContextForTests } from "../src/internal/context.js"; +import { runDoctor, runDoctorDetailed, type DoctorReport } from "../src/internal/doctor.js"; +import { authRequiredError, CliError, UsageError } from "../src/internal/errors.js"; +import { mirrorGlobalOptionsToDescendants, registerRootGlobalOptions, walkCommands } from "../src/internal/global-options.js"; +import type { V1Envelope } from "../src/internal/result.js"; +import type { GlobalFlags } from "../src/internal/runtime.js"; +import { VERSION } from "../src/internal/version.js"; +import { jsonReply, parseSingleJson, startMockApi } from "./helpers/cli.js"; + +const FLAG_SECRET = "msy_flag_secret_value_789"; +const ENV_SECRET = "msy_env_secret_value_456"; +const DOTENV_SECRET = "msy_dotenv_secret_value_123"; +const FILE_SECRET = "msy_keyfile_secret_value_321"; + +function flagsOf(extra: Partial = {}): GlobalFlags { + return { format: "json", updateCheck: false, verbose: false, ...extra }; +} + +function tmp(prefix = "meshy-doctor-"): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +/** A private env for the local checks: nothing from the developer's shell leaks in. */ +function isolatedEnv(extra: Record = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CONFIG_DIR: tmp("meshy-config-"), ...extra }; + for (const k of Object.keys(env)) if (env[k] === undefined) delete env[k]; + return env; +} + +function check(report: DoctorReport, id: string): { id: string; status: string; detail: string } { + const c = report.checks.find((x) => x.id === id); + assert.ok(c, `check '${id}' missing; have ${report.checks.map((x) => x.id).join(", ")}`); + return c; +} + +function spy(impl: () => T): (() => T) & { calls: number } { + const fn = (() => { + fn.calls += 1; + return impl(); + }) as (() => T) & { calls: number }; + fn.calls = 0; + return fn; +} + +/** Fail loudly if anything reaches the network while `fn` runs. */ +async function withNoNetwork(fn: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = (() => { + throw new Error("network touched during a local doctor run"); + }) as typeof fetch; + try { + return await fn(); + } finally { + globalThis.fetch = original; + } +} + +/** loadConfig reads process.env like every API command; scope the overrides to one test. */ +async function withProcessEnv(overrides: Record, fn: () => Promise): Promise { + const saved: Record = {}; + for (const [k, v] of Object.entries(overrides)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { + return await fn(); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +} + +// --------------------------------------------------------------------------- +// runDoctor +// --------------------------------------------------------------------------- + +test("T-102 default run: local only — no probe, no detector, no fetch; booleans instead of values; .env named, never read", async () => { + const cwd = tmp("meshy-cwd-"); + writeFileSync(join(cwd, ".env"), `MESHY_API_KEY=${DOTENV_SECRET}\nNODE_OPTIONS=--require ./evil.js\n`); + const env = isolatedEnv({ MESHY_API_KEY: ENV_SECRET }); + const probeBalance = spy(() => Promise.resolve(42)); + const detectSlicers = spy(() => ({ platform: "test", slicers: [], unsupported: [] })); + + const report = await withNoNetwork(() => + runDoctor({ flags: flagsOf({ apiKey: FLAG_SECRET }), checkApi: false, checkSlicers: false, env, cwd, probeBalance, detectSlicers }), + ); + + assert.equal(probeBalance.calls, 0); + assert.equal(detectSlicers.calls, 0); + assert.deepEqual(report.cli, { version: VERSION, node: process.version, platform: process.platform, arch: process.arch }); + assert.equal(report.local_ready, true); + assert.equal(report.api_ready, null); + assert.equal(report.api, null); + assert.equal(report.slicers, null); + assert.deepEqual(report.credential_sources, { + flag: true, + env: true, + api_key_file: null, + stored_profile: { path: join(env["MESHY_CONFIG_DIR"]!, "credentials.json"), exists: false }, + }); + assert.deepEqual(report.cwd_env_candidates, [".env"]); + assert.match(check(report, "cwd_env_files").detail, /\.env found .* not read/); + assert.deepEqual(report.base_urls, { + v1: "https://api.meshy.ai/openapi/v1", + v2: "https://api.meshy.ai/openapi/v2", + creative_lab: "https://api.meshy.ai/openapi/creative-lab", + public_web: "https://api.meshy.ai/web/public", + }); + assert.deepEqual(report.workspace, { path: null, writable: null }); + for (const [id, status] of [["cli", "ok"], ["node", "ok"], ["credentials", "ok"], ["api_key_file", "skipped"], ["workspace", "skipped"], ["api", "skipped"], ["slicers", "skipped"]]) { + assert.equal(check(report, id!).status, status, id); + } + const text = JSON.stringify(report); + for (const secret of [FLAG_SECRET, ENV_SECRET, DOTENV_SECRET]) { + assert.ok(!text.includes(secret), `secret ${secret} leaked into the report`); + } + assert.ok(!text.includes("evil.js"), "the .env content was read"); +}); + +test("T-102 no credential anywhere: local_ready stays true, credentials check warns, nothing throws", async () => { + const cwd = tmp("meshy-cwd-"); + const env = isolatedEnv({ MESHY_API_KEY: "" }); + const report = await withNoNetwork(() => runDoctor({ flags: flagsOf(), checkApi: false, checkSlicers: false, env, cwd })); + assert.equal(report.local_ready, true); + assert.equal(report.api_ready, null); + assert.equal(report.credential_sources.env, false, "an empty MESHY_API_KEY means unset"); + assert.equal(report.credential_sources.flag, false); + assert.equal(check(report, "credentials").status, "warn"); + assert.match(check(report, "credentials").detail, /meshy auth login/); + assert.deepEqual(report.cwd_env_candidates, []); + // A placeholder key is also "unset". + const placeholder = await runDoctor({ flags: flagsOf({ apiKey: "YOUR_MESHY_API_KEY_HERE" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.equal(placeholder.credential_sources.flag, false); +}); + +test("stored profile: the credentials file is stat'ed, never parsed", async () => { + const dir = tmp(); + const credFile = join(dir, "credentials.json"); + // Deliberately not JSON: parsing it would throw; doctor must only report existence. + writeFileSync(credFile, `{ this is not json; token=msy_stored_secret_value_000 `); + const env = isolatedEnv({ MESHY_CREDENTIALS_PATH: credFile }); + const report = await runDoctor({ flags: flagsOf(), checkApi: false, checkSlicers: false, env, cwd: dir }); + assert.deepEqual(report.credential_sources.stored_profile, { path: credFile, exists: true }); + assert.equal(check(report, "credentials").status, "ok"); + assert.ok(!JSON.stringify(report).includes("msy_stored_secret_value_000")); + // Non-production v1 base → the dev file is the one that applies. + const staging = await runDoctor({ flags: flagsOf({ baseUrlV1: "https://staging.example.invalid/openapi/v1/" }), checkApi: false, checkSlicers: false, env: isolatedEnv(), cwd: dir }); + assert.match(staging.credential_sources.stored_profile.path, /credentials\.dev\.json$/); + assert.equal(staging.credential_sources.stored_profile.exists, false); +}); + +test("--api-key-file: valid → ok without the value; malformed, missing, keyless → fail checks, never a throw", async () => { + const cwd = tmp("meshy-cwd-"); + writeFileSync(join(cwd, "good.env"), `# ci key\nexport MESHY_API_KEY="${FILE_SECRET}"\nOTHER=1\n`); + writeFileSync(join(cwd, "bad.env"), "this line is not an assignment\n"); + writeFileSync(join(cwd, "nokey.env"), "OTHER=1\n"); + writeFileSync(join(cwd, "dup.env"), "MESHY_API_KEY=a1234\nMESHY_API_KEY=b1234\n"); + const env = isolatedEnv(); + + const good = await runDoctor({ flags: flagsOf({ envFile: "good.env" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.equal(check(good, "api_key_file").status, "ok"); + assert.match(check(good, "api_key_file").detail, /value not shown/); + assert.match(check(good, "api_key_file").detail, /1 other key\(s\) ignored: OTHER/); + assert.equal(good.credential_sources.api_key_file, join(cwd, "good.env")); + assert.equal(check(good, "credentials").status, "ok"); + assert.ok(!JSON.stringify(good).includes(FILE_SECRET), "the key file value leaked"); + + for (const [name, pattern] of [["bad.env", /not a KEY=value assignment/], ["nokey.env", /no usable MESHY_API_KEY/], ["dup.env", /more than once/], ["missing.env", /file not found/]] as const) { + const report = await runDoctor({ flags: flagsOf({ envFile: name }), checkApi: false, checkSlicers: false, env, cwd }); + assert.equal(check(report, "api_key_file").status, "fail", name); + assert.match(check(report, "api_key_file").detail, pattern, name); + assert.equal(report.credential_sources.api_key_file, join(cwd, name), name); + assert.equal(check(report, "credentials").status, "warn", `${name}: an unusable key file is not a credential source`); + assert.equal(report.local_ready, true, name); + } +}); + +test("workspace: writable directory ok, missing directory warns, a file fails", async () => { + const cwd = tmp("meshy-cwd-"); + const env = isolatedEnv(); + mkdirSync(join(cwd, "ws")); + writeFileSync(join(cwd, "afile"), "x"); + const ok = await runDoctor({ flags: flagsOf({ workspace: "ws" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.deepEqual(ok.workspace, { path: join(cwd, "ws"), writable: true }); + assert.equal(check(ok, "workspace").status, "ok"); + const missing = await runDoctor({ flags: flagsOf({ workspace: join(cwd, "later") }), checkApi: false, checkSlicers: false, env, cwd }); + assert.deepEqual(missing.workspace, { path: join(cwd, "later"), writable: null }); + assert.equal(check(missing, "workspace").status, "warn"); + const file = await runDoctor({ flags: flagsOf({ workspace: "afile" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.deepEqual(file.workspace, { path: join(cwd, "afile"), writable: false }); + assert.equal(check(file, "workspace").status, "fail"); + if (process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() !== 0) { + mkdirSync(join(cwd, "ro")); + chmodSync(join(cwd, "ro"), 0o500); + const ro = await runDoctor({ flags: flagsOf({ workspace: "ro" }), checkApi: false, checkSlicers: false, env, cwd }); + assert.equal(ro.workspace.writable, false); + assert.equal(check(ro, "workspace").status, "fail"); + } +}); + +test("base URLs follow flag → env → default, strip trailing slashes and derive Creative Lab only from the standard path", async () => { + const cwd = tmp("meshy-cwd-"); + const fromEnv = await runDoctor({ + flags: flagsOf(), + checkApi: false, + checkSlicers: false, + env: isolatedEnv({ MESHY_BASE_URL_V1: "https://staging.example.invalid/openapi/v1/", MESHY_BASE_URL_V2: "https://staging.example.invalid/openapi/v2" }), + cwd, + }); + assert.deepEqual(fromEnv.base_urls, { + v1: "https://staging.example.invalid/openapi/v1", + v2: "https://staging.example.invalid/openapi/v2", + creative_lab: "https://staging.example.invalid/openapi/creative-lab", + public_web: "https://staging.example.invalid/web/public", + }); + assert.equal(check(fromEnv, "base_urls").status, "ok"); + const custom = await runDoctor({ + flags: flagsOf({ baseUrlV1: "https://proxy.example.invalid/meshy" }), + checkApi: false, + checkSlicers: false, + env: isolatedEnv({ MESHY_BASE_URL_V1: "https://ignored.example.invalid/openapi/v1" }), + cwd, + }); + assert.equal(custom.base_urls.v1, "https://proxy.example.invalid/meshy", "the flag wins over the env"); + assert.equal(custom.base_urls.creative_lab, null); + assert.equal(check(custom, "base_urls").status, "warn"); + assert.match(check(custom, "base_urls").detail, /--base-url-creative-lab/); + const explicit = await runDoctor({ + flags: flagsOf({ baseUrlV1: "https://proxy.example.invalid/meshy", baseUrlCreativeLab: "https://lab.example.invalid/cl/" }), + checkApi: false, + checkSlicers: false, + env: isolatedEnv(), + cwd, + }); + assert.equal(explicit.base_urls.creative_lab, "https://lab.example.invalid/cl"); + assert.equal(check(explicit, "base_urls").status, "ok"); +}); + +test("T-103 --check-api: the probe runs exactly once, api_ready true, balance recorded, detector untouched", async () => { + const cwd = tmp("meshy-cwd-"); + const probeBalance = spy(() => Promise.resolve(42)); + const detectSlicers = spy(() => ({ platform: "test", slicers: [], unsupported: [] })); + // The flag key resolves through loadConfig without touching process.env or any file. + const report = await runDoctor({ flags: flagsOf({ apiKey: "msy_fixture_probe_key_555" }), checkApi: true, checkSlicers: false, env: isolatedEnv(), cwd, probeBalance, detectSlicers }); + assert.equal(probeBalance.calls, 1); + assert.equal(detectSlicers.calls, 0); + assert.equal(report.api_ready, true); + assert.deepEqual(report.api, { balance: 42 }); + assert.equal(report.slicers, null); + assert.equal(check(report, "api").status, "ok"); + assert.match(check(report, "api").detail, /GET .*\/balance succeeded with the flag credential; balance 42/); + assert.equal(check(report, "slicers").status, "skipped"); + assert.ok(!JSON.stringify(report).includes("msy_fixture_probe_key_555")); +}); + +test("T-103 --check-api without any credential: api_ready false, fail check, probe never called, report still returned", async () => { + const dir = tmp(); + const probeBalance = spy(() => Promise.resolve(1)); + await withProcessEnv({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: join(dir, "absent.json"), MESHY_BASE_URL_V1: undefined }, async () => { + const { report, apiFailure } = await runDoctorDetailed({ flags: flagsOf(), checkApi: true, checkSlicers: false, env: isolatedEnv(), cwd: dir, probeBalance }); + assert.equal(probeBalance.calls, 0); + assert.equal(report.api_ready, false); + assert.equal(report.local_ready, true); + assert.deepEqual(Object.keys(report.api ?? {}), ["error"]); + assert.equal(check(report, "api").status, "fail"); + assert.match(check(report, "api").detail, /no usable credential/); + assert.equal(apiFailure?.stage, "credentials"); + // An unusable explicit key file is also "no credential", not a thrown error. + writeFileSync(join(dir, "bad.env"), "nonsense\n"); + const bad = await runDoctorDetailed({ flags: flagsOf({ envFile: join(dir, "bad.env") }), checkApi: true, checkSlicers: false, env: isolatedEnv(), cwd: dir, probeBalance }); + assert.equal(bad.report.api_ready, false); + assert.equal(bad.apiFailure?.stage, "credentials"); + assert.equal(check(bad.report, "api_key_file").status, "fail"); + assert.equal(probeBalance.calls, 0); + }); +}); + +test("T-103 --check-api: a rejected or unreachable balance call is recorded, and the command maps it to exit 3 / 7", async () => { + const cwd = tmp("meshy-cwd-"); + const rejected = new MeshyApiError({ message: "meshy api 401 on /balance: invalid api key", status: 401, code: "auth", path: "/balance", credentialKind: "api_key" }); + const failing = spy(() => Promise.reject(rejected)); + const { report, apiFailure } = await runDoctorDetailed({ flags: flagsOf({ apiKey: "msy_fixture_probe_key_777" }), checkApi: true, checkSlicers: false, env: isolatedEnv(), cwd, probeBalance: failing }); + assert.equal(failing.calls, 1, "exactly one attempt, no retry"); + assert.equal(report.api_ready, false); + assert.deepEqual(report.api, { error: "meshy api 401 on /balance: invalid api key" }); + assert.equal(check(report, "api").status, "fail"); + assert.equal(apiFailure?.stage, "balance"); + + const auth = apiCheckFailure(apiFailure!, { ...report }); + assert.equal(auth.code, "auth"); + assert.equal(auth.exitCode, 3); + assert.equal(auth.httpStatus, 401); + assert.equal((auth.result as unknown as DoctorReport).api_ready, false); + assert.match(auth.hint ?? "", /Credential rejected/); + + const network = apiCheckFailure( + { stage: "balance", error: new TransportError({ message: "request to /balance failed: connect ECONNREFUSED", phase: "connect", path: "/balance" }) }, + { ...report }, + ); + assert.equal(network.code, "network"); + assert.equal(network.exitCode, 7); + + const missing = apiCheckFailure({ stage: "credentials", error: authRequiredError() }, { ...report }); + assert.equal(missing.code, "auth"); + assert.equal(missing.exitCode, 3); + assert.match(missing.hint ?? "", /meshy auth login/); + const badFile = apiCheckFailure({ stage: "credentials", error: new CliError({ code: "usage", message: "--api-key-file: file not found: x.env" }) }, { ...report }); + assert.equal(badFile.code, "auth", "a credential that cannot be resolved is an auth failure for --check-api"); + assert.equal(badFile.exitCode, 3); + + const server = apiCheckFailure({ stage: "balance", error: new MeshyApiError({ message: "meshy api 500 on /balance: boom", status: 500, code: "server", path: "/balance" }) }, { ...report }); + assert.equal(server.code, "server"); + assert.equal(server.exitCode, 1); +}); + +test("T-103 --check-slicers: the injected detector runs once with the env; no probe, no network", async () => { + const cwd = tmp("meshy-cwd-"); + const env = isolatedEnv(); + const detection = { + platform: "darwin", + slicers: [{ id: "orca", name: "OrcaSlicer", path: "/Applications/OrcaSlicer.app", multicolor: true, platform: "darwin" }], + unsupported: [{ id: "ideamaker", name: "ideaMaker", reason: "unsupported_on_platform" }], + }; + let seenEnv: NodeJS.ProcessEnv | undefined; + let calls = 0; + const detectSlicers = (e?: { env?: Record }): unknown => { + calls += 1; + seenEnv = e?.env; + return detection; + }; + const probeBalance = spy(() => Promise.resolve(1)); + const report = await withNoNetwork(() => runDoctor({ flags: flagsOf(), checkApi: false, checkSlicers: true, env, cwd, detectSlicers, probeBalance })); + assert.equal(calls, 1); + assert.equal(seenEnv, env); + assert.equal(probeBalance.calls, 0); + assert.equal(report.api_ready, null); + assert.equal(report.api, null); + assert.deepEqual(report.slicers, detection); + assert.equal(check(report, "slicers").status, "ok"); + assert.match(check(report, "slicers").detail, /1 slicer\(s\) detected on darwin: OrcaSlicer/); + assert.equal(check(report, "api").status, "skipped"); + + const broken = await runDoctor({ + flags: flagsOf(), + checkApi: false, + checkSlicers: true, + env, + cwd, + detectSlicers: () => { + throw new Error("registry unreadable"); + }, + }); + assert.equal(check(broken, "slicers").status, "fail"); + assert.deepEqual(broken.slicers, { error: "registry unreadable" }); +}); + +// --------------------------------------------------------------------------- +// The command, in-process +// --------------------------------------------------------------------------- + +interface InProcessRun { + stdout: string; + error: unknown; +} + +function buildTree(cmd: Command): Command { + const root = new Command("meshy"); + registerRootGlobalOptions(root); + root.addCommand(cmd); + mirrorGlobalOptionsToDescendants(root); + walkCommands(root, (c) => { + c.exitOverride(); + c.configureOutput({ writeErr: () => undefined, writeOut: () => undefined }); + }); + return root; +} + +async function runDoctorCommand(args: string[]): Promise { + const chunks: string[] = []; + const original = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown): boolean => { + // The node test runner reports to its parent over this same stdout with + // binary frames; only the CLI's string writes belong to the capture. + if (typeof chunk !== "string") { + return (original as (c: string | Uint8Array, e?: unknown, cb?: unknown) => boolean).call(process.stdout, chunk, encodingOrCb, cb); + } + chunks.push(chunk); + const callback = typeof encodingOrCb === "function" ? encodingOrCb : typeof cb === "function" ? cb : undefined; + if (callback) (callback as () => void)(); + return true; + }) as typeof process.stdout.write; + let error: unknown = null; + try { + await buildTree(buildDoctorCommand()).parseAsync(["node", "meshy", "doctor", ...args]); + } catch (err) { + error = err; + } finally { + process.stdout.write = original; + resetCommandContextForTests(); + } + return { stdout: chunks.join(""), error }; +} + +type DoctorResult = DoctorReport & { saved_json: { path: string; bytes: number } | null }; + +test("T-102 command: the default run emits one ok envelope, exit 0 semantics, and touches no network", async () => { + const run = await withNoNetwork(() => runDoctorCommand(["--output-schema", "v1"])); + assert.equal(run.error, null, String(run.error)); + const env = parseSingleJson(run.stdout) as V1Envelope; + assert.deepEqual(Object.keys(env), ["schema_version", "command", "ok", "result", "error", "warnings"]); + assert.equal(env.command, "doctor"); + assert.equal(env.ok, true); + assert.equal(env.result!.api_ready, null); + assert.equal(env.result!.local_ready, true); + assert.equal(env.result!.saved_json, null); + assert.equal(env.result!.cli.version, VERSION); + // Warnings and failed local checks never change the exit: the run still succeeds. + const missingWs = await withNoNetwork(() => runDoctorCommand(["--workspace", join(tmp(), "nope")])); + assert.equal(missingWs.error, null); + assert.equal((parseSingleJson(missingWs.stdout) as V1Envelope).ok, true); +}); + +test("command: --save-json stores the report (not the envelope); legacy schema and -o are usage errors", async () => { + const dir = tmp(); + const target = join(dir, "doctor.json"); + const run = await runDoctorCommand(["--save-json", target]); + assert.equal(run.error, null, String(run.error)); + const env = parseSingleJson(run.stdout) as V1Envelope; + assert.equal(env.result!.saved_json?.path, realpathSync(target)); + const saved = JSON.parse(readFileSync(target, "utf8")) as Record; + assert.equal(saved["local_ready"], true); + assert.ok(!("schema_version" in saved)); + assert.ok(!("saved_json" in saved)); + const legacy = await runDoctorCommand(["--output-schema", "legacy"]); + assert.ok(legacy.error instanceof UsageError); + const output = await runDoctorCommand(["-o", "x.json"]); + assert.ok(output.error instanceof UsageError); + assert.match((output.error as Error).message, /--save-json/); +}); + +test("T-103 command --check-api: one GET /balance with the flag key; 401 → exit 3; unreachable → exit 7; no credential → exit 3 and no request", async () => { + let status = 200; + const api = await startMockApi((req, res) => { + if (req.path === "/openapi/v1/balance") return jsonReply(res, status, status === 200 ? { balance: 42 } : { message: "invalid api key" }); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + const common = ["--check-api", "--base-url-v1", `${api.url}/openapi/v1`, "--api-key", "msy_fixture_key_loopback_only"]; + const ok = await runDoctorCommand(common); + assert.equal(ok.error, null, String(ok.error)); + const env = parseSingleJson(ok.stdout) as V1Envelope; + assert.equal(env.ok, true); + assert.equal(env.result!.api_ready, true); + assert.deepEqual(env.result!.api, { balance: 42 }); + assert.equal(api.requests.length, 1); + assert.equal(api.requests[0]!.method, "GET"); + assert.equal(api.requests[0]!.path, "/openapi/v1/balance"); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_fixture_key_loopback_only"); + assert.ok(!ok.stdout.includes("msy_fixture_key_loopback_only")); + + status = 401; + const rejected = await runDoctorCommand(common); + assert.ok(rejected.error instanceof CliError, String(rejected.error)); + assert.equal(rejected.error.code, "auth"); + assert.equal(rejected.error.exitCode, 3); + assert.equal((rejected.error.result as unknown as DoctorResult).api_ready, false); + assert.equal(api.requests.length, 2, "no retry"); + assert.equal(rejected.stdout, ""); + + await withProcessEnv({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: join(tmp(), "absent.json"), MESHY_BASE_URL_V1: undefined }, async () => { + const none = await runDoctorCommand(["--check-api"]); + assert.ok(none.error instanceof CliError, String(none.error)); + assert.equal(none.error.code, "auth"); + assert.equal(none.error.exitCode, 3); + const result = none.error.result as unknown as DoctorResult; + assert.equal(result.api_ready, false); + assert.equal(result.local_ready, true); + assert.equal(result.checks.find((c) => c.id === "api")?.status, "fail"); + }); + assert.equal(api.requests.length, 2, "no credential → no request"); + } finally { + await api.close(); + } + const dead = await startMockApi(() => undefined); + await dead.close(); + const unreachable = await runDoctorCommand(["--check-api", "--base-url-v1", `${dead.url}/openapi/v1`, "--api-key", "msy_fixture_key_loopback_only"]); + assert.ok(unreachable.error instanceof CliError, String(unreachable.error)); + assert.equal(unreachable.error.code, "network"); + assert.equal(unreachable.error.exitCode, 7); + assert.equal((unreachable.error.result as unknown as DoctorResult).api_ready, false); +}); diff --git a/tests/inspect.test.ts b/tests/inspect.test.ts new file mode 100644 index 0000000..1962c3a --- /dev/null +++ b/tests/inspect.test.ts @@ -0,0 +1,449 @@ +/** + * inspect faces — verdict logic (T-080, T-081) and the command driven + * in-process: the file source needs no credential or network, the API source + * makes exactly one GET against a loopback mock (T-082). The dist/ subprocess + * variant is present but skipped until root.ts registers the command. + */ + +// Keep envelope rendering hermetic whatever the dev machine's update cache holds. +process.env["MESHY_CLI_NO_UPDATE_NOTIFIER"] = "1"; + +import test from "node:test"; +import assert from "node:assert/strict"; +import { Command } from "commander"; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildInspectCommand, parseMaxFaces, readTaskJsonFile, type FacesResult } from "../src/cmd/inspect.js"; +import { resetCommandContextForTests } from "../src/internal/context.js"; +import { CliError, UsageError } from "../src/internal/errors.js"; +import { mirrorGlobalOptionsToDescendants, registerRootGlobalOptions, walkCommands } from "../src/internal/global-options.js"; +import { faceCountFromTask, judgeFaceCount, judgeTask, remeshSuggestion, type FaceVerdict } from "../src/internal/inspect.js"; +import type { V1Envelope } from "../src/internal/result.js"; +import { toTaskView } from "../src/internal/task-view.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi } from "./helpers/cli.js"; + +const FIXTURE_URL = new URL("./fixtures/skill-parity/task-rigging.synthetic.json", import.meta.url); +const FIXTURE_PATH = fileURLToPath(FIXTURE_URL); +const fixture = JSON.parse(readFileSync(FIXTURE_URL, "utf8")) as Record; +const FIXTURE_FACES = 250_000; + +// --------------------------------------------------------------------------- +// In-process harness: a fresh command tree per run (Commander keeps option +// state on the instance), stdout captured (writeStdout needs its callback +// invoked), and the module-level command context reset afterwards. +// --------------------------------------------------------------------------- + +interface InProcessRun { + stdout: string; + error: unknown; +} + +function buildTree(cmd: Command): Command { + const root = new Command("meshy"); + registerRootGlobalOptions(root); + root.addCommand(cmd); + mirrorGlobalOptionsToDescendants(root); + walkCommands(root, (c) => { + c.exitOverride(); + c.configureOutput({ writeErr: () => undefined, writeOut: () => undefined }); + }); + return root; +} + +async function runInspect(args: string[]): Promise { + const chunks: string[] = []; + const original = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown): boolean => { + // The node test runner reports to its parent over this same stdout with + // binary frames; only the CLI's string writes belong to the capture. + if (typeof chunk !== "string") { + return (original as (c: string | Uint8Array, e?: unknown, cb?: unknown) => boolean).call(process.stdout, chunk, encodingOrCb, cb); + } + chunks.push(chunk); + const callback = typeof encodingOrCb === "function" ? encodingOrCb : typeof cb === "function" ? cb : undefined; + if (callback) (callback as () => void)(); + return true; + }) as typeof process.stdout.write; + let error: unknown = null; + try { + await buildTree(buildInspectCommand()).parseAsync(["node", "meshy", "inspect", "faces", ...args]); + } catch (err) { + error = err; + } finally { + process.stdout.write = original; + resetCommandContextForTests(); + } + return { stdout: chunks.join(""), error }; +} + +function envelopeOf(run: InProcessRun): V1Envelope { + assert.equal(run.error, null, run.error instanceof Error ? run.error.stack : String(run.error)); + return parseSingleJson(run.stdout) as V1Envelope; +} + +function cliErrorOf(run: InProcessRun): CliError { + assert.ok(run.error instanceof CliError, `expected a CliError, got ${String(run.error)}`); + assert.equal(run.stdout, "", "a failed check prints nothing in-process; the entry point renders the envelope"); + return run.error; +} + +function tmp(): string { + return mkdtempSync(join(tmpdir(), "meshy-inspect-")); +} + +function writeJson(dir: string, name: string, value: unknown): string { + const path = join(dir, name); + writeFileSync(path, JSON.stringify(value, null, 2)); + return path; +} + +// --------------------------------------------------------------------------- +// Pure verdict logic +// --------------------------------------------------------------------------- + +test("T-080 judgeFaceCount: limit-1 / limit / limit+1 → pass / pass / fail", () => { + for (const limit of [300_000, 40_000, 1]) { + assert.equal(judgeFaceCount(limit - 1, limit).verdict, limit - 1 >= 0 ? "pass" : "unknown", `${limit}-1`); + assert.equal(judgeFaceCount(limit, limit).verdict, "pass", `${limit} == limit`); + const over = judgeFaceCount(limit + 1, limit); + assert.equal(over.verdict, "fail", `${limit}+1`); + assert.equal(over.face_count, limit + 1); + assert.match(over.reason ?? "", /exceeds the limit/); + } + // The fixture (250000 faces) against the three limits the black-box tests use. + const at = (limit: number): FaceVerdict => judgeFaceCount(fixture["face_count"], limit); + assert.deepEqual(at(300_000), { face_count: FIXTURE_FACES, limit: 300_000, comparison: "lte", verdict: "pass", reason: null }); + assert.equal(at(FIXTURE_FACES).verdict, "pass"); + assert.equal(at(FIXTURE_FACES - 1).verdict, "fail"); + // A real 0 from the server is a known count, not "unknown". + assert.deepEqual(judgeFaceCount(0, 10), { face_count: 0, limit: 10, comparison: "lte", verdict: "pass", reason: null }); +}); + +test("T-081 judgeFaceCount: missing/null/negative/string/NaN/float/… → unknown, never a fabricated 0", () => { + const cases: Array<[unknown, RegExp]> = [ + [undefined, /face_count missing/], + [null, /face_count is null/], + [-1, /not a non-negative integer/], + ["1234", /string, not a number/], + ["", /string, not a number/], + [Number.NaN, /not a finite number/], + [Number.POSITIVE_INFINITY, /not a finite number/], + [3.5, /not a non-negative integer/], + [true, /not a number \(got boolean\)/], + [{ count: 5 }, /not a number \(got object\)/], + [[5], /not a number \(got array\)/], + ]; + for (const [raw, reason] of cases) { + const v = judgeFaceCount(raw, 300_000); + assert.equal(v.verdict, "unknown", `raw=${String(raw)}`); + assert.equal(v.face_count, null, `raw=${String(raw)} must not become a number`); + assert.equal(v.comparison, "lte"); + assert.equal(v.limit, 300_000); + assert.match(v.reason ?? "", reason, `raw=${String(raw)}`); + } + // "1234" is never parsed even when it would pass or fail numerically. + assert.equal(judgeFaceCount("1234", 1000).verdict, "unknown"); + assert.equal(judgeFaceCount("1234", 2000).verdict, "unknown"); +}); + +test("judgeFaceCount rejects an invalid limit instead of guessing", () => { + for (const bad of [0, -1, 2.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => judgeFaceCount(10, bad), RangeError, String(bad)); + } +}); + +test("faceCountFromTask reads only the top-level face_count and reports absence as absence", () => { + const nested = { id: "t", status: "SUCCEEDED", result: { face_count: 5 }, printability: { metrics: { face_count: 7 } } }; + assert.deepEqual(faceCountFromTask(nested), { value: undefined, source: "none", status: "SUCCEEDED" }); + assert.deepEqual(faceCountFromTask({ id: "t", status: "SUCCEEDED", face_count: null }), { value: null, source: "face_count", status: "SUCCEEDED" }); + assert.deepEqual(faceCountFromTask({ id: "t", face_count: 12 }), { value: 12, source: "face_count", status: null }); + assert.deepEqual(faceCountFromTask({ id: "t", status: 3, face_count: 0 }), { value: 0, source: "face_count", status: null }); +}); + +test("T-081 judgeTask: a non-terminal task without face_count is unknown because it is still running", () => { + for (const status of ["PENDING", "IN_PROGRESS", "QUEUED"]) { + const v = judgeTask({ id: "t", status }, 300_000); + assert.equal(v.verdict, "unknown", status); + assert.equal(v.face_count, null); + assert.equal(v.reason, `task is ${status}; no face count yet`); + assert.equal(judgeTask({ id: "t", status, face_count: null }, 300_000).reason, `task is ${status}; no face count yet`); + } + // Terminal tasks without the field: the field is simply missing. + assert.equal(judgeTask({ id: "t", status: "SUCCEEDED" }, 300_000).reason, "face_count missing"); + assert.equal(judgeTask({ id: "t", status: "FAILED" }, 300_000).reason, "face_count missing"); + assert.equal(judgeTask({ id: "t" }, 300_000).reason, "face_count missing"); + // A malformed value on a running task is reported as malformed, not as "not yet". + assert.match(judgeTask({ id: "t", status: "IN_PROGRESS", face_count: "12" }, 300_000).reason ?? "", /string/); + // A count the server did send is judged whatever the status. + assert.equal(judgeTask({ id: "t", status: "IN_PROGRESS", face_count: 10 }, 300_000).verdict, "pass"); + assert.equal(judgeTask(fixture, FIXTURE_FACES - 1).verdict, "fail"); +}); + +test("remeshSuggestion describes an unexecuted remesh and clamps the target to the endpoint range", () => { + const s = remeshSuggestion("fixture-rig-1", 249_999); + assert.equal(s.executed, false); + assert.equal(s.command, "meshy remesh create --input-task-id fixture-rig-1 --target-polycount 249999 --output-schema v1"); + assert.match(s.description, /not executed/); + assert.ok(s.description.includes(s.command)); + assert.match(remeshSuggestion("t", 50).command ?? "", /--target-polycount 100 /); + assert.match(remeshSuggestion("t", 10_000_000).command ?? "", /--target-polycount 300000 /); + // No id, or an id that is not a plain token: no command is fabricated. + const none = remeshSuggestion(null, 1000); + assert.equal(none.command, null); + assert.equal(none.executed, false); + assert.match(none.description, /task id is unknown/); + const unsafe = remeshSuggestion("a b; rm -rf /", 1000); + assert.equal(unsafe.command, null); + assert.ok(!unsafe.description.includes("rm -rf")); +}); + +test("parseMaxFaces accepts plain positive integers only", () => { + assert.equal(parseMaxFaces("300000"), 300_000); + assert.equal(parseMaxFaces(" 40000 "), 40_000); + for (const bad of ["0", "-1", "3.5", "abc", "1e5", "", "0x10"]) { + assert.throws(() => parseMaxFaces(bad), UsageError, bad); + } +}); + +test("readTaskJsonFile: bounded read, invalid JSON, non-task JSON and missing files are usage errors", () => { + const dir = tmp(); + const ok = readTaskJsonFile(FIXTURE_PATH); + assert.equal(ok.shape, "api"); + assert.equal(ok.task["id"], "fixture-rig-1"); + assert.throws(() => readTaskJsonFile(FIXTURE_PATH, { maxBytes: 64 }), (e: unknown) => e instanceof UsageError && /exceeds 64 bytes/.test(e.message)); + assert.throws(() => readTaskJsonFile(join(dir, "missing.json")), (e: unknown) => e instanceof UsageError && /cannot open/.test(e.message)); + assert.throws(() => readTaskJsonFile(dir), (e: unknown) => e instanceof UsageError && /not a regular file/.test(e.message)); + writeFileSync(join(dir, "bad.json"), "{ not json"); + assert.throws(() => readTaskJsonFile(join(dir, "bad.json")), (e: unknown) => e instanceof UsageError && /not valid JSON/.test(e.message)); + writeJson(dir, "notask.json", { hello: "world" }); + assert.throws(() => readTaskJsonFile(join(dir, "notask.json")), (e: unknown) => e instanceof UsageError && /does not contain a task/.test(e.message)); + // Relative paths resolve against the given cwd. + writeJson(dir, "rel.json", fixture); + assert.equal(readTaskJsonFile("rel.json", { cwd: dir }).path, join(dir, "rel.json")); +}); + +// --------------------------------------------------------------------------- +// The command, in-process: file source (T-080/T-081/T-082 local part) +// --------------------------------------------------------------------------- + +test("T-080 command: fixture 250000 vs 300000 / 250000 / 249999 → pass 0 / pass 0 / fail 12", async () => { + const pass = envelopeOf(await runInspect(["--task-json", FIXTURE_PATH, "--max-faces", "300000", "--output-schema", "v1"])); + assert.deepEqual(Object.keys(pass), ["schema_version", "command", "ok", "result", "error", "warnings"]); + assert.equal(pass.schema_version, "meshy.cli/v1"); + assert.equal(pass.command, "inspect.faces"); + assert.equal(pass.ok, true); + assert.equal(pass.error, null); + const r = pass.result!; + assert.equal(r.verdict, "pass"); + assert.equal(r.face_count, FIXTURE_FACES); + assert.equal(r.limit, 300_000); + assert.equal(r.comparison, "lte"); + assert.equal(r.reason, null); + assert.equal(r.task_id, "fixture-rig-1"); + assert.equal(r.status, "SUCCEEDED"); + assert.equal(r.suggestion, null); + assert.equal(r.saved_json, null); + assert.equal(r.source.kind, "task-json"); + assert.equal((r.source as { path: string }).path, FIXTURE_PATH); + // The gate answers the face count and nothing more. + assert.doesNotMatch(JSON.stringify(pass), /rig[- ]?ready|riggable/i); + + const equal = envelopeOf(await runInspect(["--task-json", FIXTURE_PATH, "--max-faces", String(FIXTURE_FACES)])); + assert.equal(equal.result!.verdict, "pass"); + + const fail = cliErrorOf(await runInspect(["--task-json", FIXTURE_PATH, "--max-faces", String(FIXTURE_FACES - 1), "--output-schema", "v1"])); + assert.equal(fail.code, "check_failed"); + assert.equal(fail.exitCode, 12); + const failResult = fail.result as unknown as FacesResult; + assert.equal(failResult.verdict, "fail"); + assert.equal(failResult.face_count, FIXTURE_FACES); + assert.match(failResult.reason ?? "", /exceeds the limit 249999 by 1/); + assert.equal(failResult.suggestion?.executed, false); + assert.equal(failResult.suggestion?.command, "meshy remesh create --input-task-id fixture-rig-1 --target-polycount 249999 --output-schema v1"); +}); + +test("T-081 command: missing/null/string face_count and a running task → unknown, exit 13, no remesh suggestion", async () => { + const dir = tmp(); + const cases: Array<[string, Record, RegExp]> = [ + ["running.json", { id: "t-run", status: "IN_PROGRESS", progress: 40 }, /task is IN_PROGRESS; no face count yet/], + ["pending-null.json", { id: "t-pend", status: "PENDING", face_count: null }, /task is PENDING; no face count yet/], + ["missing.json", { id: "t-done", status: "SUCCEEDED", result: { face_count: 5 } }, /face_count missing/], + ["null.json", { id: "t-null", status: "SUCCEEDED", face_count: null }, /face_count is null/], + ["string.json", { id: "t-str", status: "SUCCEEDED", face_count: "1234" }, /string, not a number/], + ["negative.json", { id: "t-neg", status: "SUCCEEDED", face_count: -5 }, /not a non-negative integer/], + ["float.json", { id: "t-flt", status: "SUCCEEDED", face_count: 3.5 }, /not a non-negative integer/], + ]; + for (const [name, task, reason] of cases) { + const path = writeJson(dir, name, task); + const err = cliErrorOf(await runInspect(["--task-json", path, "--max-faces", "300000"])); + assert.equal(err.code, "check_unknown", name); + assert.equal(err.exitCode, 13, name); + const result = err.result as unknown as FacesResult; + assert.equal(result.verdict, "unknown", name); + assert.equal(result.face_count, null, `${name}: nothing is fabricated`); + assert.match(result.reason ?? "", reason, name); + assert.equal(result.suggestion, null, `${name}: unknown never suggests a remesh`); + assert.equal(result.task_id, task["id"]); + } +}); + +test("T-082 local: meta.json and v1 envelope shapes yield the same verdict as the API task shape", async () => { + const dir = tmp(); + const meta = writeJson(dir, "meta.json", { resource: "rigging", endpoint: "/openapi/v1/rigging", task: fixture, saved_files: [] }); + const envelopeWithRaw = writeJson(dir, "envelope-raw.json", { + schema_version: "meshy.cli/v1", + command: "rigging.get", + ok: true, + result: { task: toTaskView(fixture, { includeRaw: true }), submission: null, downloads: null, saved_json: null }, + error: null, + warnings: [], + }); + const envelopePlain = writeJson(dir, "envelope-plain.json", { + schema_version: "meshy.cli/v1", + command: "rigging.get", + ok: true, + result: { task: toTaskView(fixture) }, + error: null, + warnings: [], + }); + const shapes: Array<[string, string]> = [ + [FIXTURE_PATH, "api"], + [meta, "meta.json"], + [envelopeWithRaw, "v1-envelope"], + [envelopePlain, "v1-result"], + ]; + for (const [path, shape] of shapes) { + const pass = envelopeOf(await runInspect(["--task-json", path, "--max-faces", "300000"])); + assert.equal(pass.result!.verdict, "pass", shape); + assert.equal(pass.result!.face_count, FIXTURE_FACES, shape); + assert.equal(pass.result!.task_id, "fixture-rig-1", shape); + assert.equal((pass.result!.source as { shape: string }).shape, shape); + const fail = cliErrorOf(await runInspect(["--task-json", path, "--max-faces", String(FIXTURE_FACES - 1)])); + assert.equal(fail.code, "check_failed", shape); + assert.equal((fail.result as unknown as FacesResult).face_count, FIXTURE_FACES, shape); + } +}); + +test("command: usage errors before any work — sources, --max-faces, schema, -o and --save-json", async () => { + const usage = async (args: string[], pattern: RegExp): Promise => { + const run = await runInspect(args); + assert.ok(run.error instanceof UsageError, `${args.join(" ")}: expected UsageError, got ${String(run.error)}`); + assert.match(run.error.message, pattern, args.join(" ")); + assert.equal(run.stdout, ""); + }; + await usage(["--task-json", FIXTURE_PATH], /--max-faces is required/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "0"], /positive integer/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "3.5"], /positive integer/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "abc"], /positive integer/); + await usage(["--max-faces", "300000"], /task source is required/); + await usage(["--task-json", FIXTURE_PATH, "--resource", "rigging", "--task-id", "x", "--max-faces", "300000"], /not both/); + await usage(["--task-json", FIXTURE_PATH, "--task-id", "x", "--max-faces", "300000"], /not both/); + await usage(["--resource", "rigging", "--max-faces", "300000"], /together with --task-id/); + await usage(["--task-id", "x", "--max-faces", "300000"], /together with --task-id/); + await usage(["--resource", "no-such-resource", "--task-id", "x", "--max-faces", "300000"], /unknown --resource 'no-such-resource'.*rigging.*uv-unwrap/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "300000", "--output-schema", "legacy"], /only emits the v1 envelope/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "300000", "-o", "out.glb"], /--save-json/); + await usage(["--task-json", FIXTURE_PATH, "--max-faces", "300000", "--save-json", "x.json"], /only applies to the API source/); + await usage(["--task-json", join(tmp(), "missing.json"), "--max-faces", "300000"], /cannot open/); +}); + +// --------------------------------------------------------------------------- +// The command, in-process: API source (T-082) against a loopback mock +// --------------------------------------------------------------------------- + +test("T-082 api: exactly one GET through the registry, same verdict as the file, no download and no remesh", async () => { + const noFace = { id: "no-face", type: "rigging", status: "SUCCEEDED", progress: 100, result: {} }; + const api = await startMockApi((req, res) => { + if (req.method === "GET" && req.path === "/openapi/v1/rigging/fixture-rig-1") return jsonReply(res, 200, fixture); + if (req.method === "GET" && req.path === "/openapi/v1/rigging/no-face") return jsonReply(res, 200, noFace); + return jsonReply(res, 404, { message: "nope" }); + }); + const dir = tmp(); + try { + const common = ["--base-url-v1", `${api.url}/openapi/v1`, "--api-key", "msy_fixture_key_loopback_only", "--output-schema", "v1"]; + const pass = envelopeOf(await runInspect(["--resource", "rigging", "--task-id", "fixture-rig-1", "--max-faces", "300000", ...common])); + assert.equal(pass.result!.verdict, "pass"); + assert.equal(pass.result!.face_count, FIXTURE_FACES); + assert.deepEqual(pass.result!.source, { kind: "api", resource: "rigging", task_id: "fixture-rig-1", endpoint: "/openapi/v1/rigging", requests_made: 1 }); + assert.equal(api.requests.length, 1); + assert.equal(api.requests[0]!.method, "GET"); + assert.equal(api.requests[0]!.path, "/openapi/v1/rigging/fixture-rig-1"); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_fixture_key_loopback_only"); + + // Same fact, other source: identical verdict. + const local = envelopeOf(await runInspect(["--task-json", FIXTURE_PATH, "--max-faces", "300000"])); + assert.equal(local.result!.verdict, pass.result!.verdict); + assert.equal(local.result!.face_count, pass.result!.face_count); + assert.equal(api.requests.length, 1, "the file source made no request"); + + // A failing verdict describes a remesh; the mock sees no POST. + const saveTo = join(dir, "task.json"); + const fail = cliErrorOf( + await runInspect(["--resource", "rigging", "--task-id", "fixture-rig-1", "--max-faces", String(FIXTURE_FACES - 1), "--save-json", saveTo, ...common]), + ); + assert.equal(fail.code, "check_failed"); + assert.equal(fail.exitCode, 12); + const failResult = fail.result as unknown as FacesResult; + assert.equal(failResult.suggestion?.executed, false); + assert.match(failResult.suggestion?.command ?? "", /^meshy remesh create --input-task-id fixture-rig-1 --target-polycount 249999/); + assert.equal(failResult.saved_json?.path, realpathSync(saveTo)); + assert.deepEqual(JSON.parse(readFileSync(saveTo, "utf8")), fixture, "--save-json keeps the raw task, not the envelope"); + assert.equal(api.requests.length, 2); + assert.ok(api.requests.every((r) => r.method === "GET"), "never a POST (no remesh, no download)"); + + // D-009: the public task DTO usually carries no face_count → unknown, exit 13, still one GET. + const unknown = cliErrorOf(await runInspect(["--resource", "rigging", "--task-id", "no-face", "--max-faces", "300000", ...common])); + assert.equal(unknown.code, "check_unknown"); + assert.equal(unknown.exitCode, 13); + assert.equal((unknown.result as unknown as FacesResult).reason, "face_count missing"); + assert.equal(api.requests.length, 3); + + // An unknown resource is refused before any request or credential resolution. + const bad = await runInspect(["--resource", "nope", "--task-id", "x", "--max-faces", "1", ...common]); + assert.ok(bad.error instanceof UsageError); + assert.equal(api.requests.length, 3); + } finally { + await api.close(); + } +}); + +test("T-082 subprocess: dist/ inspect faces — file source needs no credential, API source makes one GET", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "GET" && req.path === "/openapi/v1/rigging/fixture-rig-1") return jsonReply(res, 200, fixture); + return jsonReply(res, 404, { message: "nope" }); + }); + try { + // File source: no MESHY_API_KEY, no profile, no base URL — still exit 0. + const local = await runCli(["inspect", "faces", "--task-json", FIXTURE_PATH, "--max-faces", "300000", "--output-schema", "v1"]); + assert.equal(local.code, 0, local.stderr); + const localOut = parseSingleJson(local.stdout) as V1Envelope; + assert.equal(localOut.command, "inspect.faces"); + assert.equal(localOut.result!.verdict, "pass"); + + const remote = await runCli(["inspect", "faces", "--resource", "rigging", "--task-id", "fixture-rig-1", "--max-faces", "300000"], { env: api.env() }); + assert.equal(remote.code, 0, remote.stderr); + const remoteOut = parseSingleJson(remote.stdout) as V1Envelope; + assert.equal(remoteOut.result!.verdict, localOut.result!.verdict); + assert.equal(remoteOut.result!.face_count, localOut.result!.face_count); + assert.equal(api.requests.length, 1); + assert.equal(api.requests[0]!.method, "GET"); + + const fail = await runCli(["inspect", "faces", "--task-json", FIXTURE_PATH, "--max-faces", String(FIXTURE_FACES - 1)]); + assert.equal(fail.code, 12, fail.stderr); + const failOut = parseSingleJson(fail.stdout) as V1Envelope; + assert.equal(failOut.ok, false); + assert.equal(failOut.error?.code, "check_failed"); + assert.equal(failOut.result?.verdict, "fail"); + assert.equal(failOut.result?.suggestion?.executed, false); + + const unknown = await runCli(["inspect", "faces", "--resource", "rigging", "--task-id", "fixture-rig-1", "--max-faces", "300000"], { + env: api.env({ MESHY_API_KEY: undefined }), + }); + assert.equal(unknown.code, 3, "API source without a credential is an auth failure, not unknown"); + assert.equal(api.requests.length, 1); + } finally { + await api.close(); + } +}); diff --git a/tests/obj-transform.test.ts b/tests/obj-transform.test.ts new file mode 100644 index 0000000..3460119 --- /dev/null +++ b/tests/obj-transform.test.ts @@ -0,0 +1,251 @@ +/** + * OBJ print preparation (T-083..T-087) against the independent fixture oracle + * tests/fixtures/skill-parity/box-height-80.expected.json. Nothing here derives + * an expected value from the implementation under test. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { prepareObjForPrint, defaultOutputPath, formatObjNumber, textureReferencesInMtl } from "../src/internal/obj-transform.js"; +import { CliError, UsageError } from "../src/internal/errors.js"; +import { tmpDir } from "./helpers/cli.js"; + +const FIXTURES = fileURLToPath(new URL("./fixtures/skill-parity/", import.meta.url)); +const ORACLE = JSON.parse(readFileSync(join(FIXTURES, "box-height-80.expected.json"), "utf8")) as { + height_mm: number; + scale: number; + translation: [number, number, number]; + vertices: number[][]; + normals: number[][]; + bbox_min: number[]; + bbox_max: number[]; + face_count: number; + vertex_count: number; + uv_count: number; + material_dependency: string; + tolerance: { absolute_mm: number; relative_to_height: number }; +}; + +function tol(height: number): number { + return ORACLE.tolerance.absolute_mm + ORACLE.tolerance.relative_to_height * height; +} + +function near(actual: number, expected: number, eps: number, label: string): void { + assert.ok(Math.abs(actual - expected) <= eps, `${label}: ${actual} vs ${expected} (eps ${eps})`); +} + +interface ParsedObj { + v: number[][]; + vExtra: string[][]; + vn: number[][]; + vt: string[]; + f: string[]; + other: string[]; + eol: "\n" | "\r\n"; +} + +function parseObj(text: string): ParsedObj { + const out: ParsedObj = { v: [], vExtra: [], vn: [], vt: [], f: [], other: [], eol: text.includes("\r\n") ? "\r\n" : "\n" }; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line) continue; + const t = line.split(/\s+/); + if (t[0] === "v") { + out.v.push([Number(t[1]), Number(t[2]), Number(t[3])]); + out.vExtra.push(t.slice(4)); + } else if (t[0] === "vn") out.vn.push([Number(t[1]), Number(t[2]), Number(t[3])]); + else if (t[0] === "vt") out.vt.push(line); + else if (t[0] === "f") out.f.push(line); + else out.other.push(line); + } + return out; +} + +/** Copy the fixture box (OBJ + MTL) into a fresh temp dir; returns the OBJ path. */ +function fixtureCopy(dir = tmpDir("obj-")): { dir: string; obj: string; mtl: string } { + const obj = join(dir, "box-y-up.obj"); + const mtl = join(dir, "box.mtl"); + copyFileSync(join(FIXTURES, "box-y-up.obj"), obj); + copyFileSync(join(FIXTURES, "box.mtl"), mtl); + return { dir, obj, mtl }; +} + +const ORIGINAL = readFileSync(join(FIXTURES, "box-y-up.obj"), "utf8"); +const ORIGINAL_PARSED = parseObj(ORIGINAL); + +test("T-083 oracle: every vertex, normal, bbox, scale, translation and count match box-height-80.expected.json", async () => { + const { dir, obj } = fixtureCopy(); + const report = await prepareObjForPrint(obj, { heightMm: ORACLE.height_mm }); + const eps = tol(ORACLE.height_mm); + assert.equal(report.output, join(dir, "box-y-up.print.obj")); + near(report.scale, ORACLE.scale, 1e-9, "scale"); + ORACLE.translation.forEach((t, i) => near(report.translation[i]!, t, eps, `translation[${i}]`)); + ORACLE.bbox_min.forEach((b, i) => near(report.after_bbox.min[i]!, b, eps, `bbox_min[${i}]`)); + ORACLE.bbox_max.forEach((b, i) => near(report.after_bbox.max[i]!, b, eps, `bbox_max[${i}]`)); + assert.equal(report.counts.vertices, ORACLE.vertex_count); + assert.equal(report.counts.normals, ORACLE.normals.length); + assert.equal(report.counts.uvs, ORACLE.uv_count); + assert.equal(report.counts.faces, ORACLE.face_count); + assert.deepEqual(report.material.mtllib, [ORACLE.material_dependency]); + assert.deepEqual(report.material.missing, []); + + const written = parseObj(readFileSync(report.output, "utf8")); + assert.equal(written.v.length, ORACLE.vertices.length); + ORACLE.vertices.forEach((exp, i) => exp.forEach((c, j) => near(written.v[i]![j]!, c, eps, `v[${i}][${j}]`))); + ORACLE.normals.forEach((exp, i) => exp.forEach((c, j) => near(written.vn[i]![j]!, c, 1e-9, `vn[${i}][${j}]`))); + // Extra vertex fields (the fixture carries rgb after xyz) and face/UV/normal index triplets are untouched. + written.vExtra.forEach((extra, i) => assert.deepEqual(extra, ORIGINAL_PARSED.vExtra[i], `v[${i}] extra fields`)); + assert.deepEqual(written.f, ORIGINAL_PARSED.f); + assert.deepEqual(written.vt, ORIGINAL_PARSED.vt); + assert.deepEqual(written.other, ORIGINAL_PARSED.other, "mtllib/o/usemtl/comments preserved verbatim"); + // Input untouched. + assert.equal(readFileSync(obj, "utf8"), ORIGINAL); +}); + +test("T-084 default height is 75 mm; CRLF, scientific notation and stray whitespace yield the same geometry", async () => { + const base = fixtureCopy(); + const def = await prepareObjForPrint(base.obj); + assert.equal(def.height_mm, 75); + near(def.after_bbox.max[2]! - def.after_bbox.min[2]!, 75, tol(75), "height"); + near(def.after_bbox.min[2]!, 0, tol(75), "minZ"); + near((def.after_bbox.min[0]! + def.after_bbox.max[0]!) / 2, 0, tol(75), "x centre"); + near((def.after_bbox.min[1]! + def.after_bbox.max[1]!) / 2, 0, tol(75), "y centre"); + const reference = parseObj(readFileSync(def.output, "utf8")); + + const variants: Array<[string, string]> = [ + ["crlf", ORIGINAL.replace(/\n/g, "\r\n")], + ["scientific", ORIGINAL.replace(/^v (\S+) (\S+) (\S+)/gm, (_m, x, y, z) => `v ${Number(x).toExponential(3)} ${Number(y).toExponential(3)} ${Number(z).toExponential(3)}`)], + ["whitespace", ORIGINAL.replace(/^v /gm, " v ").replace(/\n/g, " \n")], + ]; + for (const [name, text] of variants) { + const dir = tmpDir("obj-var-"); + const path = join(dir, `${name}.obj`); + writeFileSync(path, text); + copyFileSync(join(FIXTURES, "box.mtl"), join(dir, "box.mtl")); + const rep = await prepareObjForPrint(path); + const parsed = parseObj(readFileSync(rep.output, "utf8")); + assert.equal(parsed.v.length, reference.v.length, name); + reference.v.forEach((v, i) => v.forEach((c, j) => near(parsed.v[i]![j]!, c, 2e-3, `${name} v[${i}][${j}]`))); + assert.deepEqual(parsed.f, reference.f, `${name} faces`); + if (name === "crlf") assert.equal(parsed.eol, "\r\n", "line-ending style preserved"); + } +}); + +test("T-085 invalid inputs fail with validation and write nothing", async () => { + const cases: Array<[string, string, RegExp]> = [ + ["empty", "", /empty/], + ["no-vertices", "# comment\nvt 0 0\nf 1 2 3\n", /no vertex/], + ["nan", "v 0 NaN 0\nv 1 1 1\n", /unparseable|non-finite/], + ["infinity", "v 0 0 0\nv 1 Infinity 1\n", /unparseable|non-finite/], + ["bad-token", "v 0 0 abc\nv 1 1 1\n", /unparseable|non-finite/], + ["degenerate", "v 0 1 0\nv 1 1 0\nv 0 1 1\n", /degenerate/], + ["short", "v 0 1\nv 1 1 1\n", /expected 3/], + ]; + for (const [name, text, re] of cases) { + const dir = tmpDir("obj-bad-"); + const path = join(dir, `${name}.obj`); + writeFileSync(path, text); + await assert.rejects(prepareObjForPrint(path), (e: unknown) => e instanceof CliError && e.code === "validation" && re.test(e.message), name); + assert.ok(!existsSync(defaultOutputPath(path)), `${name}: no output written`); + assert.equal(readFileSync(path, "utf8"), text, `${name}: input untouched`); + } + const { obj } = fixtureCopy(); + for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) { + await assert.rejects(prepareObjForPrint(obj, { heightMm: bad }), (e: unknown) => e instanceof CliError && e.code === "validation", String(bad)); + } + await assert.rejects(prepareObjForPrint(obj, { outputPath: join(tmpDir(), "x.obj"), inPlace: true }), UsageError); + await assert.rejects(prepareObjForPrint(join(tmpDir(), "missing.obj")), (e: unknown) => e instanceof CliError && e.code === "not_found"); +}); + +test("T-086 in-place replaces the input only on success; existing outputs are refused; cross-directory copies materials", async () => { + // In place. + const a = fixtureCopy(); + const before = statSync(a.obj).mode & 0o777; + const rep = await prepareObjForPrint(a.obj, { heightMm: 80, inPlace: true }); + assert.equal(rep.output, a.obj); + assert.equal(rep.in_place, true); + const parsed = parseObj(readFileSync(a.obj, "utf8")); + ORACLE.vertices.forEach((exp, i) => exp.forEach((c, j) => near(parsed.v[i]![j]!, c, tol(80), `in-place v[${i}][${j}]`))); + assert.equal(statSync(a.obj).mode & 0o777, before, "mode preserved"); + assert.ok(!existsSync(join(a.dir, "box-y-up.print.obj"))); + // Failed transform in place leaves the original intact. + const b = tmpDir("obj-"); + const badPath = join(b, "bad.obj"); + writeFileSync(badPath, "v 0 0 0\nv 1 1 NaN\n"); + await assert.rejects(prepareObjForPrint(badPath, { inPlace: true }), CliError); + assert.equal(readFileSync(badPath, "utf8"), "v 0 0 0\nv 1 1 NaN\n"); + + // Existing output refused, untouched. + const c = fixtureCopy(); + const target = join(c.dir, "box-y-up.print.obj"); + writeFileSync(target, "keep"); + await assert.rejects(prepareObjForPrint(c.obj), (e: unknown) => e instanceof CliError && e.code === "local_io" && /refusing to overwrite/.test(e.message)); + assert.equal(readFileSync(target, "utf8"), "keep"); + + // Cross-directory: MTL copied next to the output and recorded. + const d = fixtureCopy(); + const outDir = join(tmpDir("obj-out-"), "nested"); + const rep2 = await prepareObjForPrint(d.obj, { outputPath: join(outDir, "printable.obj") }); + assert.equal(rep2.output, join(outDir, "printable.obj")); + assert.ok(existsSync(join(outDir, "box.mtl"))); + assert.deepEqual(rep2.material.copied, [join(outDir, "box.mtl")]); + assert.equal(readFileSync(join(outDir, "box.mtl"), "utf8"), readFileSync(d.mtl, "utf8")); + // Output directory as target: default name inside it. + const e = fixtureCopy(); + const dirTarget = tmpDir("obj-dirtarget-"); + const rep3 = await prepareObjForPrint(e.obj, { outputPath: dirTarget }); + assert.equal(rep3.output, join(dirTarget, "box-y-up.print.obj")); + + // Missing mtllib + different directory → validation unless geometryOnly. + const f = tmpDir("obj-"); + const missingMtl = join(f, "m.obj"); + writeFileSync(missingMtl, "mtllib nowhere.mtl\nv 0 0 0\nv 1 2 3\nf 1 2\n"); + await assert.rejects(prepareObjForPrint(missingMtl, { outputPath: join(tmpDir("obj-out-"), "m.obj") }), (e: unknown) => e instanceof CliError && e.code === "validation" && /geometry-only/.test(e.message)); + const geo = await prepareObjForPrint(missingMtl, { outputPath: join(tmpDir("obj-out-"), "m.obj"), geometryOnly: true }); + assert.deepEqual(geo.material.missing, ["nowhere.mtl"]); + assert.ok(geo.warnings.some((w) => w.code === "material_dependency_missing")); + // Same directory with a missing mtllib is only a warning (nothing to carry). + const same = await prepareObjForPrint(missingMtl); + assert.ok(same.warnings.some((w) => w.code === "material_dependency_missing")); + + // A mtllib escaping the input directory is never read or copied. + const g = tmpDir("obj-"); + const outside = tmpDir("obj-outside-"); + writeFileSync(join(outside, "secret.mtl"), "newmtl s\nmap_Kd ../../etc/passwd\n"); + const escaping = join(g, "e.obj"); + writeFileSync(escaping, `mtllib ${join(outside, "secret.mtl")}\nmtllib ../${outside.split("/").pop()}/secret.mtl\nv 0 0 0\nv 1 2 3\n`); + const rep4 = await prepareObjForPrint(escaping, { outputPath: join(tmpDir("obj-out-"), "e.obj"), geometryOnly: true }); + assert.equal(rep4.material.copied.length, 0); + assert.ok(rep4.warnings.every((w) => w.code === "material_dependency_outside_input_dir" || w.code === "material_dependencies_not_copied")); +}); + +test("T-087 a large OBJ streams with bounded memory", async () => { + const dir = tmpDir("obj-big-"); + const path = join(dir, "big.obj"); + const n = 200_000; + const chunks: string[] = ["# big\n"]; + for (let i = 0; i < n; i++) chunks.push(`v ${(i % 100) / 10} ${((i * 7) % 1000) / 10} ${(i % 37) / 3}\n`); + for (let i = 1; i + 2 <= n; i += 3) chunks.push(`f ${i} ${i + 1} ${i + 2}\n`); + writeFileSync(path, chunks.join("")); + const size = statSync(path).size; + if (global.gc) global.gc(); + const before = process.memoryUsage().heapUsed; + const rep = await prepareObjForPrint(path, { heightMm: 100 }); + const after = process.memoryUsage().heapUsed; + assert.equal(rep.counts.vertices, n); + assert.ok(existsSync(rep.output)); + assert.ok(statSync(rep.output).size > size * 0.5); + assert.ok(after - before < 200 * 1024 * 1024, `heap grew by ${((after - before) / 1048576).toFixed(1)} MB`); +}); + +test("helpers: number formatting stays inside the oracle tolerance; MTL texture references are parsed", () => { + assert.equal(formatObjNumber(0), "0"); + assert.equal(formatObjNumber(-0), "0"); + assert.equal(formatObjNumber(20), "20"); + assert.equal(formatObjNumber(-60.0000004), "-60"); + assert.equal(formatObjNumber(1.23456789), "1.234568"); + assert.deepEqual(textureReferencesInMtl("newmtl a\nmap_Kd tex.png\nmap_Bump -bm 0.5 bump.png\n# map_Ks ignored.png\nbump ../up.png\n"), ["tex.png", "bump.png", "../up.png"]); +}); diff --git a/tests/slicers.test.ts b/tests/slicers.test.ts new file mode 100644 index 0000000..8327f2b --- /dev/null +++ b/tests/slicers.test.ts @@ -0,0 +1,174 @@ +/** + * Slicer registry, detection and launch (T-088..T-091) with simulated + * platforms and a fake spawn. No real application is started. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { detectSlicers, findSlicer, openInSlicer, SLICERS, type DetectionEnv, type SpawnedChild, type SpawnFn } from "../src/internal/slicers.js"; +import { CliError, UsageError } from "../src/internal/errors.js"; +import { tmpDir } from "./helpers/cli.js"; + +const LEGACY_NAMES = ["OrcaSlicer", "Bambu Studio", "Creality Print", "Elegoo Slicer", "Anycubic Slicer Next", "PrusaSlicer", "UltiMaker Cura"]; +const MULTICOLOR = new Set(["OrcaSlicer", "Bambu Studio", "Creality Print", "Elegoo Slicer", "Anycubic Slicer Next"]); + +function fakeEnv(platform: string, existing: Set, extra: Partial = {}): Partial { + return { + platform, + env: {}, + home: platform === "win32" ? "C:\\Users\\me" : "/Users/me", + exists: (p) => existing.has(p), + readdir: (dir) => [...existing].filter((p) => p.startsWith(dir + (platform === "win32" ? "\\" : "/"))).map((p) => p.slice(dir.length + 1).split(/[\\/]/)[0]!), + which: () => null, + ...extra, + }; +} + +test("T-088 registry: the seven legacy names, ids and multicolor flags", () => { + assert.deepEqual(SLICERS.map((s) => s.name), LEGACY_NAMES); + for (const s of SLICERS) assert.equal(s.multicolor, MULTICOLOR.has(s.name), s.name); + assert.equal(findSlicer("orcaslicer")?.id, "orca-slicer"); + assert.equal(findSlicer("BAMBU-STUDIO")?.name, "Bambu Studio"); + assert.equal(findSlicer("Cura"), undefined); +}); + +test("T-088 macOS: /Applications and ~/Applications bundles", () => { + const env = fakeEnv("darwin", new Set(["/Applications/OrcaSlicer.app", "/Users/me/Applications/PrusaSlicer.app"])); + const d = detectSlicers(env); + assert.equal(d.platform, "darwin"); + assert.deepEqual(d.slicers.map((s) => [s.name, s.path, s.multicolor]), [ + ["OrcaSlicer", "/Applications/OrcaSlicer.app", true], + ["PrusaSlicer", "/Users/me/Applications/PrusaSlicer.app", false], + ]); + assert.deepEqual(d.unsupported, []); + assert.deepEqual(detectSlicers(fakeEnv("darwin", new Set())).slicers, [], "empty is a valid answer"); +}); + +test("T-088 Windows: Program Files bases, versioned glob directories and spaces", () => { + const existing = new Set([ + "C:\\Program Files\\Creality Print 5.1\\CrealityPrint.exe", + "C:\\Program Files\\UltiMaker Cura 5.7\\UltiMaker-Cura.exe", + "C:\\Program Files (x86)\\BambuStudio\\bambu-studio.exe", + "C:\\Program Files\\OrcaSlicer\\orca-slicer.exe", + ]); + const env = fakeEnv("win32", existing, { + env: { ProgramFiles: "C:\\Program Files", "ProgramFiles(x86)": "C:\\Program Files (x86)" }, + readdir: (dir) => (dir === "C:\\Program Files" ? ["Creality Print 5.1", "Creality Print 4.0", "UltiMaker Cura 5.7", "OrcaSlicer"] : dir === "C:\\Program Files (x86)" ? ["BambuStudio"] : []), + exists: (p) => existing.has(p), + }); + const d = detectSlicers(env); + assert.deepEqual(d.slicers.map((s) => [s.name, s.path]), [ + ["OrcaSlicer", "C:\\Program Files\\OrcaSlicer\\orca-slicer.exe"], + ["Bambu Studio", "C:\\Program Files (x86)\\BambuStudio\\bambu-studio.exe"], + ["Creality Print", "C:\\Program Files\\Creality Print 5.1\\CrealityPrint.exe"], + ["UltiMaker Cura", "C:\\Program Files\\UltiMaker Cura 5.7\\UltiMaker-Cura.exe"], + ]); + assert.equal(d.slicers.find((s) => s.name === "Creality Print")?.multicolor, true); +}); + +test("T-088 Linux: PATH lookup for the three registered executables; the rest are explicitly unsupported", () => { + const env = fakeEnv("linux", new Set(), { which: (name) => (name === "prusa-slicer" ? "/usr/bin/prusa-slicer" : name === "orca-slicer" ? "/opt/orca/orca-slicer" : null) }); + const d = detectSlicers(env); + assert.deepEqual(d.slicers.map((s) => [s.name, s.path]), [ + ["OrcaSlicer", "/opt/orca/orca-slicer"], + ["PrusaSlicer", "/usr/bin/prusa-slicer"], + ]); + assert.deepEqual(d.unsupported.map((u) => u.name).sort(), ["Anycubic Slicer Next", "Creality Print", "Elegoo Slicer", "UltiMaker Cura"]); + for (const u of d.unsupported) assert.match(u.reason, /no registered Linux executable/); +}); + +interface FakeSpawn { + spawn: SpawnFn; + calls: Array<{ command: string; args: string[]; options: unknown }>; +} + +function fakeSpawn(behaviour: { fail?: boolean; pid?: number; exitCode?: number | null } = {}): FakeSpawn { + const calls: FakeSpawn["calls"] = []; + const spawn: SpawnFn = (command, args, options) => { + calls.push({ command, args: [...args], options }); + const child = new EventEmitter() as SpawnedChild & { unrefCalled?: boolean }; + child.pid = behaviour.pid ?? 4242; + child.unref = () => { + child.unrefCalled = true; + }; + setImmediate(() => { + if (behaviour.fail) child.emit("error", new Error("spawn ENOENT")); + else { + child.emit("spawn"); + if (behaviour.exitCode !== undefined) setImmediate(() => child.emit("exit", behaviour.exitCode)); + } + }); + return child; + }; + return { spawn, calls }; +} + +test("T-089 Windows launches the detected absolute exe even when PATH has none", async () => { + const dir = tmpDir("slicer-"); + const file = join(dir, "model.obj"); + writeFileSync(file, "v 0 0 0\n"); + const exe = "C:\\Program Files\\Creality Print 5.1\\CrealityPrint.exe"; + const env = fakeEnv("win32", new Set([exe]), { + env: { ProgramFiles: "C:\\Program Files" }, + readdir: (d) => (d === "C:\\Program Files" ? ["Creality Print 5.1"] : []), + exists: (p) => p === exe, + which: () => null, + }); + const fs = fakeSpawn(); + const launch = await openInSlicer(file, "Creality Print", { env, spawn: fs.spawn }); + assert.equal(launch.launch_requested, true); + assert.equal(fs.calls.length, 1); + assert.equal(fs.calls[0]!.command, exe); + assert.deepEqual(fs.calls[0]!.args, [file]); + assert.equal(launch.pid, 4242); + assert.equal(launch.launcher, null); +}); + +test("T-090 the file path is one argv element; shell is false; macOS uses open -a ", async () => { + const dir = tmpDir("slicer-"); + const odd = join(dir, `$(rm -rf x); it's "odd" & weird.obj`); + writeFileSync(odd, "v 0 0 0\n"); + const env = fakeEnv("darwin", new Set(["/Applications/OrcaSlicer.app"])); + const fs = fakeSpawn({ exitCode: 0 }); + const launch = await openInSlicer(odd, "orca-slicer", { env, spawn: fs.spawn, launcherTimeoutMs: 500 }); + assert.equal(fs.calls.length, 1); + assert.equal(fs.calls[0]!.command, "open"); + assert.deepEqual(fs.calls[0]!.args, ["-a", "/Applications/OrcaSlicer.app", odd]); + assert.deepEqual(fs.calls[0]!.options, { detached: true, stdio: "ignore", shell: false }); + assert.deepEqual(launch.launcher, { command: "open", exit_code: 0 }); + assert.equal(launch.slicer.name, "OrcaSlicer"); + // Linux: detected path + file, no launcher wait. + const lin = fakeEnv("linux", new Set(), { which: (n) => (n === "prusa-slicer" ? "/usr/bin/prusa-slicer" : null) }); + const fs2 = fakeSpawn(); + const l2 = await openInSlicer(odd, "PrusaSlicer", { env: lin, spawn: fs2.spawn }); + assert.deepEqual(fs2.calls[0]!.args, [odd]); + assert.equal(fs2.calls[0]!.command, "/usr/bin/prusa-slicer"); + assert.equal(l2.launcher, null); +}); + +test("T-091 unknown / not detected / missing file / spawn failure / bad extension / open failure", async () => { + const dir = tmpDir("slicer-"); + const file = join(dir, "m.stl"); + writeFileSync(file, "solid\nendsolid\n"); + const env = fakeEnv("darwin", new Set(["/Applications/OrcaSlicer.app"])); + await assert.rejects(openInSlicer(file, "SuperSlicer", { env, spawn: fakeSpawn().spawn }), UsageError); + await assert.rejects(openInSlicer(file, "PrusaSlicer", { env, spawn: fakeSpawn().spawn }), (e: unknown) => e instanceof CliError && e.code === "not_found" && /not installed/.test(e.message)); + const linux = fakeEnv("linux", new Set()); + await assert.rejects(openInSlicer(file, "Elegoo Slicer", { env: linux, spawn: fakeSpawn().spawn }), (e: unknown) => e instanceof CliError && e.code === "not_found" && /no registered Linux executable/.test(e.message)); + await assert.rejects(openInSlicer(join(dir, "missing.stl"), "OrcaSlicer", { env, spawn: fakeSpawn().spawn }), (e: unknown) => e instanceof CliError && e.code === "not_found"); + writeFileSync(join(dir, "notes.txt"), "x"); + await assert.rejects(openInSlicer(join(dir, "notes.txt"), "OrcaSlicer", { env, spawn: fakeSpawn().spawn }), UsageError); + await assert.rejects(openInSlicer(file, "OrcaSlicer", { env, spawn: fakeSpawn({ fail: true }).spawn }), (e: unknown) => e instanceof CliError && e.code === "local_io"); + await assert.rejects(openInSlicer(file, "OrcaSlicer", { env, spawn: fakeSpawn({ exitCode: 1 }).spawn, launcherTimeoutMs: 500 }), (e: unknown) => e instanceof CliError && e.code === "local_io" && /exited with code 1/.test(e.message)); + // A GUI that never exits: resolves as soon as the launcher deadline passes with exit_code null (macOS) and never waits on other platforms. + const started = Date.now(); + const l = await openInSlicer(file, "OrcaSlicer", { env, spawn: fakeSpawn().spawn, launcherTimeoutMs: 100 }); + assert.deepEqual(l.launcher, { command: "open", exit_code: null }); + assert.ok(Date.now() - started < 2000); + const nothingRan = fakeSpawn(); + await assert.rejects(openInSlicer(file, "PrusaSlicer", { env, spawn: nothingRan.spawn }), CliError); + assert.equal(nothingRan.calls.length, 0, "no process is started for an undetected slicer"); +}); From e7c26fc053cea4e1bf7dee08953e25a3ec858137 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 16:27:30 +0800 Subject: [PATCH 08/26] =?UTF-8?q?chore(release):=200.3.0=20candidate=20?= =?UTF-8?q?=E2=80=94=20README,=20bundled=20skill,=20env=20example,=20origi?= =?UTF-8?q?n-policy=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Candidate version for the S1 skill-parity release (not published). README and skills/meshy-cli/SKILL.md describe the new commands, the v1 envelope, the exit codes 10-13/130, --api-key-file and the money rules; .env.example lists the Creative Lab base and the config dir. Adds T-105: a stored profile is refused for a Creative Lab base on another origin while an explicit key is allowed. Co-Authored-By: Claude Fable 5.1 --- .env.example | 10 +- README.md | 386 ++++++++++++++++++++++++---------- package.json | 4 +- skills/meshy-cli/SKILL.md | 169 ++++++++++----- tests/uv-creative-lab.test.ts | 31 +++ 5 files changed, 430 insertions(+), 170 deletions(-) diff --git a/.env.example b/.env.example index 16de923..8b8647a 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,20 @@ -# Required: your Meshy API key (https://www.meshy.ai/api) +# Your Meshy API key (https://www.meshy.ai/settings/api). Read from the +# environment, or from an explicit file via `meshy ... --api-key-file ` +# (only this key is read from the file; nothing is executed). MESHY_API_KEY=YOUR_MESHY_API_KEY_HERE # Optional base URL overrides (defaults shown) # MESHY_BASE_URL_V1=https://api.meshy.ai/openapi/v1 # MESHY_BASE_URL_V2=https://api.meshy.ai/openapi/v2 +# MESHY_BASE_URL_CREATIVE_LAB=https://api.meshy.ai/openapi/creative-lab # default: derived from the v1 origin # Optional timeouts / polling # MESHY_CONNECT_TIMEOUT_MS=10000 -# MESHY_READ_TIMEOUT_MS=120000 +# MESHY_READ_TIMEOUT_MS=120000 # covers headers and body # MESHY_POLL_INTERVAL_MS=3000 # Optional log level: debug | info | warn | error | silent # MESHY_LOG_LEVEL=warn + +# Optional: where credentials, the operation journal and the update cache live +# MESHY_CONFIG_DIR=~/.config/meshy diff --git a/README.md b/README.md index 5d54d6e..241cc68 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # meshy-cli -A small, well-structured command-line interface for the [Meshy AI API](https://docs.meshy.ai/) — text-to-3D, image-to-3D (standard and smart-topology), text-to-motion, remesh, convert, resize, rigging, animation, retexture, 2D image generation, multi-color print output, and the `balance` endpoint. Built for humans and AI agents. +A small, well-structured command-line interface for the [Meshy AI API](https://docs.meshy.ai/) — text-to-3D, image-to-3D (standard and smart-topology), text-to-motion, remesh, convert, resize, UV unwrap, rigging, animation, retexture, 2D image generation, multi-color print output, Creative Lab products, the public animation catalog, Enterprise showcases, and the `balance` endpoint — plus the local helpers a 3D-printing or agent workflow needs (selective downloads, project folders, face-count checks, OBJ print preparation, slicer launch, environment diagnosis). Built for humans and AI agents. Two layers. `meshy make` chains the documented flows so that one command produces one model. Underneath, a per-endpoint command for every resource -shares a uniform `create / get / list / wait / delete` verb surface, with a raw +shares a uniform `create / get / list / wait / stream / delete` verb surface, with a raw `api` passthrough for endpoints the CLI doesn't model yet and a `skills/` directory of agent-facing documentation. @@ -12,11 +12,12 @@ The flag surface is deliberately curated rather than a 1:1 mirror of the API: de ## Install -Requires Node 24+. +Requires Node 24+. No Python, no other runtime. ```bash npm i -g meshy-cli # installs `meshy-cli` and `meshy` meshy --help +meshy doctor # local diagnosis: versions, credential sources, base URLs (no network) ``` The same build is also published under the scoped alias @@ -35,6 +36,7 @@ pnpm install pnpm build node dist/index.js --help pnpm link --global # exposes `meshy` / `meshy-cli` on $PATH +pnpm typecheck && pnpm test # `pnpm test` rebuilds dist first (subprocess tests run it) ``` ## Auth @@ -61,22 +63,32 @@ Or keep it in the environment — unchanged, and still the right choice for CI: ```bash export MESHY_API_KEY=msy_your_key_here meshy-cli --api-key msy_... balance # or per-call +meshy balance --api-key-file ./keys.env # dotenv-style file; only MESHY_API_KEY is read ``` Get a key at . -**Resolution order:** `--api-key` › `MESHY_API_KEY` › the active stored profile. +**Resolution order:** `--api-key` › `MESHY_API_KEY` › `--api-key-file` › the active stored profile. The environment variable stays ahead of the stored credential on purpose, so a CI runner is never overridden by whatever a developer once logged into on that -machine. With none of the three, commands exit `3` and print the command that -fixes it. +machine. With none of the four, commands exit `3` and print the command that +fixes it. An empty or placeholder `--api-key` / `MESHY_API_KEY` counts as unset. + +**`--api-key-file`** reads exactly one variable, `MESHY_API_KEY`, from a +dotenv-style file (`export` prefix, quotes and `#` comments accepted; nothing is +expanded or executed, other keys are ignored). A file you name but that is +missing, malformed or key-less is an error — never a silent fall-through to +another account. There is no automatic `.env` discovery. The flag is not called +`--env-file` because Node.js itself intercepts that name anywhere in argv and +loads the whole file into the environment before the CLI starts. **Where it lives:** `~/.config/meshy/credentials.json`, mode `0600`, on every platform (`MESHY_CONFIG_DIR` or `MESHY_CREDENTIALS_PATH` move it). Writes go through a cross-process lock and a temp-file rename, because several agents driving this CLI at once is the normal case. A non-production `--base-url-v1` reads and writes `credentials.dev.json` instead, so staging cannot clobber a -production login. +production login. Stored profiles are only ever sent to the v1/v2 origins they +were resolved for (and to a Creative Lab base on the same origin). **OAuth token refresh:** when the stored OAuth access token is within 60 seconds of expiry (or already expired), the CLI silently refreshes it using the stored @@ -105,13 +117,16 @@ opinions spends someone else's credits. Compose anything else from the resource commands below. ```bash -meshy make "a red sports car" --dry-run # the steps and the estimate, no spend -meshy make "a red sports car" --max-credits 25 # refuse to start when over budget +meshy make "a red sports car" --dry-run # the steps and the estimate, no spend, no network +meshy make "a red sports car" --max-credits 25 # refuse to start when over budget +meshy make "a red sports car" --async # submit step 1 (one POST), return its id + pending_steps +meshy make "a red sports car" --stop-after-first # wait for step 1, then return the resume command ``` -Both guards run before the first task is created. If a later step fails, the -error's `hint` is the command that resumes from the finished step — running it -beats starting over, which would pay for that step twice. +All guards run before the first task is created. If a later step fails, the +error carries the finished step's task id and the command that resumes from it — +running that beats starting over, which would pay for the finished step twice. +`--async` and `--stop-after-first` are mutually exclusive. ## At a glance @@ -128,34 +143,106 @@ meshy-cli image-to-3d create --image-url https://example.com/cat.png # text → standalone motion clip (Prime/FBX by default; Swift produces BVH) meshy-cli text-to-motion create --prompt "a character waving" --duration 3 -meshy-cli text-to-motion create --prompt "a quick dodge to the left" --duration 2.5 --mode swift # smart topology: component-aware low-poly with a native polycount meshy-cli image-to-3d create --image-url cat.png --model-type smart-topology --target-polycount 10000 -# ultra: an extra Meshy 7 geometry pass for finer surface detail (standard mode, single image) -meshy-cli image-to-3d create --image-url cat.png --ultra-mode true - -# retexture from several views of the same object instead of one style reference -meshy-cli retexture create --input-task-id --multiview-image-urls front.png,side.png,back.png - -# fire-and-forget (--async): returns the task_id immediately, query later -TASK=$(meshy-cli text-to-image create --prompt "mountain landscape" --async | jq -r .task_id) -meshy-cli text-to-image get "$TASK" -meshy-cli text-to-image wait "$TASK" -o /tmp/result.json +# fire-and-forget (--async): exactly one POST, returns the task_id, query later +TASK=$(meshy-cli text-to-image create --prompt "mountain landscape" --async --output-schema v1 | jq -r .result.submission.task_id) +meshy-cli text-to-image get "$TASK" --output-schema v1 +meshy-cli text-to-image wait "$TASK" --output-schema v1 --save-json ./task.json +meshy-cli text-to-image stream "$TASK" --format ndjson --output-schema v1 # Server-Sent Events + +# UV unwrap and Creative Lab (photo → printable product, two stages) +meshy uv-unwrap create --input-task-id --async +meshy creative-lab figure prototype create --image-url ./photo.png --name demo --async +meshy creative-lab figure build create --input-task-id --async +meshy creative-lab lamp build create --input-task-id --model-format zip --options '{"diameter_mm":180}' + +# public animation catalog (no key) and Enterprise showcases (billed per request) +meshy animation-catalog list --category DailyActions --search wave +meshy showcases list --search car --page-size 3 --model-format glb + +# local helpers (no key, no network) +meshy download --task-json ./task.json --asset result.basic_animations.walking_glb_url --output walking.glb +meshy project init --root ./meshy_output --name demo +meshy inspect faces --task-json ./task.json --max-faces 300000 +meshy mesh prepare-print ./model.obj --height-mm 75 +meshy slicer detect +meshy doctor # raw passthrough for any endpoint meshy-cli api GET /balance meshy-cli api POST /text-to-3d --data '{"mode":"preview","prompt":"a cactus"}' ``` +## Stable machine output: `--output-schema v1` + +Existing commands keep their 0.2.0 output (`legacy`) unless told otherwise; the +commands added in this release always speak `v1`. Pass `--output-schema v1` to +get one envelope with six fixed keys on stdout, in `json` (default), `pretty` +or `ndjson`: + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "image-to-3d.get", + "ok": true, + "result": { + "task": { "task_id": "…", "resource": "image-to-3d", "status": "IN_PROGRESS", "progress": 52, + "face_count": null, "consumed_credits": null, "model_urls": {}, "task_error": null, "…": "…" }, + "submission": { "state": "accepted", "operation_id": null }, + "downloads": { "state": "not_requested", "files": [], "metadata_path": null }, + "saved_json": null + }, + "error": null, + "warnings": [] +} +``` + +`ok` says whether the CLI operation completed; `result.task.status` is the +server's task state. A `get` of a FAILED task is `ok:true` (the query worked); +a `wait` that ends on FAILED is `ok:false` with the whole task kept in `result`. +Fields the server did not send are `null` — a missing `face_count` is never `0`. +`--include-raw` adds the untouched response under `result.task.raw`; +`--save-json ` writes the raw API JSON (never the envelope) and refuses to +overwrite. Progress and update notices go to stderr, so stdout is always exactly +one JSON document (`ndjson` streams emit one line per event plus a final +`outcome` line). Errors are envelopes too: + +```json +{ "schema_version": "meshy.cli/v1", "command": "text-to-3d.create", "ok": false, + "result": { "submission": { "state": "unknown", "operation_id": "…" }, "task": null }, + "error": { "code": "submission_unknown", "message": "…", "http_status": null, "retryable": false, + "recovery": { "action": "reconcile", "automatic": false, "command": "meshy text-to-3d list …" } }, + "warnings": [] } +``` + +### Create, wait, stream and money + +- `create` sends **exactly one POST**, journaled locally before it leaves + (`~/.config/meshy/operations/.json`, no key material). A lost + response, a 5xx or a malformed success is `submission_unknown` (exit 10): the + server may have created the task, so the CLI never retries and never suggests + re-running the create. Reconcile with `list`, then decide. +- `--operation-id ` replays the recorded outcome of an identical earlier + request instead of submitting again; a different request under the same id is + refused. This is a local record, not a server-side idempotency key. +- `--async` returns after the POST (no polling). `get` is a query: any status + exits 0. `wait --timeout N` polls with a monotonic deadline (`0` = one query); + timeouts exit 8 with the last task kept. `stream` follows Server-Sent Events + with `--timeout` (total) and `--idle-timeout` (silence, keep-alives reset it). +- Ctrl-C stops waiting or downloading (exit 130) and sends no DELETE; the + envelope carries the task id and the command that resumes. + ## Resources One command per endpoint. They are all registered and all supported, but they are indexed by `meshy resources` rather than listed in `meshy --help` — that help text is read on every invocation (an agent pays for the whole surface each time), so it should not grow with the API. `meshy --help` documents -each one in full. +each one in full; `meshy resources --output-schema v1` also lists the query and +local commands with their kind. | Command | Meshy endpoint | Docs | |---|---|---| @@ -166,6 +253,7 @@ each one in full. | `remesh` | `/remesh` | [docs](https://docs.meshy.ai/en/api/remesh) | | `convert` | `/convert` | [docs](https://docs.meshy.ai/en/api/convert) | | `resize` | `/resize` | [docs](https://docs.meshy.ai/en/api/resize) | +| `uv-unwrap` | `/uv-unwrap` | [docs](https://docs.meshy.ai/en/api/uv-unwrap) | | `rigging` | `/rigging` | [docs](https://docs.meshy.ai/en/api/rigging) | | `animate` | `/animations` | [docs](https://docs.meshy.ai/en/api/animations) | | `text-to-motion` | `/text-to-motion` | [docs](https://docs.meshy.ai/en/api/text-to-motion) | @@ -175,14 +263,18 @@ each one in full. | `multi-color-print` | `/print/multi-color` | [docs](https://docs.meshy.ai/en/api/multi-color-print) | | `analyze-printability` | `/print/analyze` | [docs](https://docs.meshy.ai/en/api/analyze-printability) | | `repair-printability` | `/print/repair` | [docs](https://docs.meshy.ai/en/api/repair-printability) | +| `creative-lab prototype\|build` | `/openapi/creative-lab//v1/` | [figure](https://docs.meshy.ai/en/api/creative-lab-figure) · [lamp](https://docs.meshy.ai/en/api/creative-lab-lamp) · [keychain](https://docs.meshy.ai/en/api/creative-lab-keychain) · [fridge-magnet](https://docs.meshy.ai/en/api/creative-lab-fridge-magnet) | +| `animation-catalog list` | `GET /web/public/animations/resources` (no key) | [docs](https://docs.meshy.ai/en/api/animations) | +| `showcases list` | `GET /showcases` (Enterprise; every request is billed) | [docs](https://docs.meshy.ai/en/api/enterprise-api) | -Per-resource actions (all single-HTTP-call): +Per-resource actions (all single-HTTP-call except `wait`/`stream`): ``` -meshy-cli create [flags] [--data ] [--async] [--timeout ] -meshy-cli get +meshy-cli create [flags] [--data ] [--async] [--timeout ] [--operation-id ] +meshy-cli get [--save-json ] [--include-raw] [--project ] meshy-cli list [--page ] [--page-size ] [--sort-by ] meshy-cli wait [--timeout ] +meshy-cli stream [--timeout ] [--idle-timeout ] meshy-cli delete ``` @@ -191,61 +283,128 @@ Top-level shortcut: ``` meshy-cli delete # Meshy's DELETE is unified across resources, but GET is not, so - # `get`/`wait` live only on their resource. + # `get`/`wait`/`stream` live only on their resource. ``` `create` is **synchronous by default** — it polls until the task reaches a terminal status (`SUCCEEDED` / `FAILED` / `CANCELED`) or `--timeout` hits. Pass `--async` to return the `task_id` immediately; then call -` get ` or ` wait ` when you need the result. +` get `, `wait ` or `stream ` when you need the result. + +### Creative Lab -## Saving artifacts with `-o` +Four products (`figure`, `lamp`, `keychain`, `fridge-magnet`), each with a +`prototype` stage (photo → styled concept image; the lamp prototype also yields +a lampshade GLB) and a `build` stage that consumes a SUCCEEDED prototype created +through this API with the same key. Build options are validated per product +before anything is sent: lamp `--options` (diameter, thickness, light-source +preset, rotations …) with `--model-format stl|zip`; keychain and fridge-magnet +relief options with `--model-format glb|obj|zip` — their `obj` output is a ZIP +bundle and is saved as `.zip`; figure has no options. Prototypes made in the +web app are rejected by the server (404). + +## Saving artifacts with `-o` (legacy) and `meshy download` (selective) When `-o` is set on a `create`/`wait`/`get`, the CLI downloads every artifact -the task produced, writes a sidecar metadata file, and prints a status report -instead of JSON. Single-file outputs get a per-file `_meta.json` -(e.g. `front.jpeg` → `front_meta.json`) so two outputs can share one -directory; directory-mode outputs share a single `meta.json`. +the task produced, writes a sidecar metadata file, and (legacy schema) prints a +status report instead of JSON. Single-file outputs get a per-file +`_meta.json`; directory-mode outputs share a single `meta.json`. Under +`--output-schema v1` the same download is reported in `result.downloads`, and a +task that is not yet SUCCEEDED yields `downloads.state: "not_ready"` with exit 0. ```bash -# Single-artifact task — pass a file path. Extension is autocorrected -# against the response Content-Type; sharp transcodes between -# jpg/png/webp/gif/tiff/avif so the file truly has the requested format. -meshy-cli text-to-image create --ai-model nano-banana --prompt "a leaf" \ - -o assets/leaf.jpeg - -# Multi-artifact task (3D, multi-view 2D, animation) — pass a directory. -# Files land with role-based names: model.glb, thumbnail.png, -# texture_0_base_color.png, animation_glb.glb, … +meshy-cli text-to-image create --ai-model nano-banana --prompt "a leaf" -o assets/leaf.jpeg meshy-cli image-to-3d wait -o out/robot/ ``` -Existing files at the target abort with a clear `UsageError` — no silent -overwrite. Without `-o`, the CLI keeps its pre-download JSON behavior -(machine-readable summary on stdout). +`meshy download` is the selective, scriptable counterpart: + +```bash +meshy download --task-json ./task.json --list # what is there? +meshy download --task-json ./task.json --model-format glb --output ./model.glb +meshy download --task-json ./rig.json --asset result.basic_animations.walking_glb_url --output ./walking.glb +meshy download --task-json ./task.json --kind thumbnail --output-dir ./previews/ +meshy download --resource image-to-3d --task-id --all --output-dir ./out/ # one GET, then the assets +meshy download --url https://assets.meshy.ai/... --output ./file.glb +``` + +Sources are `--task-json` (an API task, a legacy `meta.json`, or a v1 envelope), +`--url`, or `--resource` + `--task-id`; selectors are `--asset ` (repeatable), +`--model-format`, `--kind`, `--all`. With several assets and no selector the +command lists the candidates and exits 2 instead of guessing. Selecting an OBJ +pulls its MTL and textures (`--geometry-only` to skip). Files are published +exclusively (never overwritten without `--overwrite`), checked against the +content type and magic bytes, kept inside the output directory (or `--workspace`), +and listed with size and sha256 in `result.downloads.files`. Asset hosts never +receive the API credential; an expired signed URL is refreshed once when the +task came from the API and reported as unrefreshable when it came from a file. + +## Projects (`meshy_output/`) + +The Skills' project layout, without Python: + +```bash +meshy project init --root ./meshy_output --name "demo" --task-id +meshy text-to-3d wait --project ./meshy_output/ # saves task_.json, records the stage +meshy project record --project ./meshy_output/ --task-id --resource text-to-3d --stage preview --file preview.glb +meshy project show --project ./meshy_output/ +meshy project list --root ./meshy_output +meshy project rebuild-index --root ./meshy_output +``` + +`metadata.json` (schema 2; legacy files are read as-is and migrated on the first +write with a backup) is the source of truth; `history.json` is a rebuildable +index. A repeat `(task_id, stage)` merges instead of duplicating. + +## Printing helpers + +```bash +meshy inspect faces --task-json ./task.json --max-faces 300000 # pass (0) | fail (12) | unknown (13) +meshy mesh prepare-print ./model.obj --height-mm 75 # writes ./model.print.obj +meshy mesh prepare-print ./model.obj --height-mm 80 --in-place +meshy slicer detect +meshy slicer open --slicer OrcaSlicer --file ./model.print.obj +``` + +`inspect faces` answers only whether the task's `face_count` is within the +limit you pass (`--max-faces` is required); a missing count is `unknown`, never +0, and a failing verdict only *describes* a remesh. `prepare-print` rotates a +Y-up OBJ to Z-up, scales it to the target height, centres it on XY and rests it +on Z=0, preserving faces, UVs, normals (rotated only) and material references; +it never overwrites without `--in-place`. `slicer open` launches only a +registered slicer at its detected path with the file as a single argument — no +shell, no default-application fallback; `launch_requested` is not proof that +the import succeeded. ## Image and 3D-model inputs Flags that take a media source (`--image-url`, `--image-urls`, `--reference-image-urls`, `--texture-image-url`, `--image-style-url`, -`--multiview-image-urls`, `--model-url`) accept: +`--multiview-image-urls`, `--model-url`) — and the same fields inside `--data` — +accept: -- **http(s) URLs** — preflighted with HEAD so unreachable sources fail fast. +- **http(s) URLs** — preflighted with an unauthenticated HEAD so unreachable sources fail fast. - **Local file paths** — absolute or relative to cwd. MIME-sniffed via magic - bytes (with extension fallback) and inlined as `data:` URIs on the wire. + bytes (with extension fallback), size-capped, and inlined as `data:` URIs on the wire. +- **`data:` URIs** — validated (base64, MIME kind, size) and passed through. -You do not need to host files. `data:` URIs on the command line are rejected -explicitly — pass a local path instead. Missing files and 4xx/5xx responses -exit with code `2` and a flag-prefixed message before any task is created. +Missing files and 4xx/5xx preflights exit with code `2` and a flag-prefixed +message before any task is created. GLB-only fields (`uv-unwrap`, `rigging` +`--model-url`) reject other formats locally. ## Global flags | Flag | Purpose | |------|---------| | `--api-key ` | Override `MESHY_API_KEY` | +| `--api-key-file ` | Read `MESHY_API_KEY` from an explicit dotenv-style file (only that key) | | `--base-url-v1 ` / `--base-url-v2 ` | Override endpoints (staging/proxy) | -| `--format json\|pretty\|ndjson` | Stdout format when `-o` is not set (default `json`) | -| `-o, --output ` | Download artifacts to a file/directory; write `meta.json`; switch stdout to a status report | +| `--base-url-creative-lab ` | Override the Creative Lab base (default: `/openapi/creative-lab`) | +| `--output-schema legacy\|v1` | Stdout data model (existing commands default to `legacy`; new commands are `v1`) | +| `--format json\|pretty\|ndjson` | Stdout rendering (default `json`) | +| `-o, --output ` | Download artifacts to a file/directory (task commands); output file for `mesh prepare-print` | +| `--workspace ` | Confine every written file to this directory | +| `--no-update-check` | Skip the background npm version check in this process | | `-v, --verbose` | Debug logging to stderr | | `--log-level ` | `debug \| info \| warn \| error \| silent` | @@ -253,44 +412,50 @@ exit with code `2` and a flag-prefixed message before any task is created. | Code | Meaning | |------|---------| -| 0 | success | -| 1 | generic / server | -| 2 | usage (flag parse error) | -| 3 | auth (`401`) | -| 4 | validation (`400`, `422`) | -| 5 | not found (`404`) | +| 0 | success (including `get` of any task status, empty lists, local checks that pass) | +| 1 | task ended FAILED/CANCELED while waiting, or an unclassified error | +| 2 | usage (flag parse error, conflicting or missing arguments) | +| 3 | auth (`401`, no usable credential) | +| 4 | validation (`400`, `422`, locally rejected payload) | +| 5 | not found (`404`; the cause is not guessed) | | 6 | rate limit (`429`) | -| 7 | network | -| 8 | timed out waiting for a task | +| 7 | network (read failures, stream disconnects) | +| 8 | timed out waiting for or streaming a task (the task keeps running) | | 9 | credit exhausted (`402`) | +| 10 | submission unknown — a create was sent but its outcome could not be confirmed | +| 11 | local I/O — refused overwrite, path outside the authorised root, journal/project write failure | +| 12 | check failed (`inspect faces` over the limit) | +| 13 | check unknown (`inspect faces` without a usable face count) | +| 130 | interrupted (Ctrl-C); nothing was deleted server-side | ## Environment variables | Variable | Default | |---|---| -| `MESHY_API_KEY` | — (required unless a profile is stored) | +| `MESHY_API_KEY` | — (required unless a profile is stored or `--api-key-file` is given) | | `MESHY_BASE_URL_V1` | `https://api.meshy.ai/openapi/v1` | | `MESHY_BASE_URL_V2` | `https://api.meshy.ai/openapi/v2` | +| `MESHY_BASE_URL_CREATIVE_LAB` | derived from the v1 origin (`/openapi/creative-lab`) | | `MESHY_OAUTH_AUTHORIZE_URL` | `https://www.meshy.ai/oauth/authorize` — override for staging/testing | | `MESHY_CLI_NO_BROWSER` | unset — set to `1` to suppress browser open (URL still printed to stderr) | -| `MESHY_CONNECT_TIMEOUT_MS` | `10000` | -| `MESHY_READ_TIMEOUT_MS` | `120000` | +| `MESHY_CONFIG_DIR` | `~/.config/meshy` (credentials, operation journal, update cache) | +| `MESHY_CONNECT_TIMEOUT_MS` | `10000` (read for compatibility; fetch has no separate connect timeout) | +| `MESHY_READ_TIMEOUT_MS` | `120000` — covers headers *and* body | | `MESHY_POLL_INTERVAL_MS` | `3000` | | `MESHY_LOG_LEVEL` | `warn` | | `MESHY_CLI_NO_UPDATE_NOTIFIER` | unset — any non-empty value disables update checks; CI envs (`CI`, `GITHUB_ACTIONS`, `BUILD_NUMBER`, `RUN_ID`) auto-skip | ## Update notifications -meshy-cli checks the npm registry for a newer version at most once per 24 hours. The result is cached at `~/.config/meshy/update-state.json`. The refresh runs in a detached background process so it can never slow down or fail a command. +meshy-cli checks the npm registry for a newer version at most once per 24 hours. The result is cached at `/update-state.json`. The refresh runs in a detached background process so it can never slow down or fail a command, and it never runs for local commands (`resources`, `project`, `inspect`, `mesh`, `slicer`, `doctor`, `download`, `animation-catalog`), for `make --dry-run`, or with `--no-update-check`. When a newer version is available: -- **JSON object outputs** (`--format json` when the result is an object) carry a top-level `_notice.update = { current, latest, message, command }` so agents can relay it to the user. -- **ndjson arrays** carry `_notice.update` on the **first line only** (the first element, if it is a plain object). -- **Plain JSON arrays** (`--format json` with an array result) are intentionally left untouched — there is no clean metadata slot in a JSON array without breaking the schema. +- **Legacy JSON object outputs** carry a top-level `_notice.update = { current, latest, message, command }` so agents can relay it to the user. +- **v1 envelopes are never decorated** — the six keys are the contract; humans get the hint on stderr. +- **ndjson arrays** (legacy) carry `_notice.update` on the **first line only**. - **Humans on an interactive terminal** get a single line on stderr after the command output. - **stdout is never polluted** — the notice never appears on stdout. -- **Skipped automatically** in CI environments and for development builds. ## Agent skills @@ -298,43 +463,50 @@ The `skills/` directory contains markdown-based skills for AI coding agents (Claude Code, etc.): - [`skills/meshy-cli`](skills/meshy-cli/SKILL.md) — a single skill covering - setup, `make`, the shared verb contract, the API constraints that produce - failed tasks when ignored, and the exit-code table. + setup, `make`, the shared verb contract, the v1 envelope, the API constraints + that produce failed tasks when ignored, the local helpers, and the exit-code table. It is deliberately short. A skill is loaded into an agent's context on every invocation, so length is a running cost; anything an agent can look up on demand (`meshy resources`, `meshy --help`, ) is linked rather than copied. +## Skill-parity documentation + +`docs/skill-parity/` records how this release covers the execution capabilities +of the Meshy Skills without Python: the frozen baseline, the endpoint contracts, +the capability matrix, design decisions, migration notes with every intentional +difference, and the verification record. + ## Project layout ``` meshy-cli/ ├── src/ -│ ├── index.ts # CLI entry +│ ├── index.ts # CLI entry: update-check policy, SIGINT, unified error exit │ ├── root.ts # root command + global flag wiring -│ ├── cmd/ # make + resources + api/balance/delete + one file per endpoint -│ ├── client/ # Meshy HTTP client (v1 + v2 fetchers, typed endpoints) +│ ├── cmd/ # make, auth, balance, resources, api, one file per endpoint, +│ │ # uv-unwrap, creative-lab, animation-catalog, showcases, +│ │ # download, project, inspect, mesh, slicer, doctor +│ ├── client/ +│ │ ├── resource-registry.ts # every resource: path, family, verbs, billing, media fields +│ │ ├── transport.ts # per-family HTTP transport (auth scope, deadlines, redirects) +│ │ ├── endpoints/ # TaskEndpoint + balance / catalog / showcases +│ │ └── types.ts # Zod task schemas │ └── internal/ -│ ├── config.ts # env + flag → runtime config -│ ├── runtime.ts # lazy client/config construction -│ ├── make-plan.ts # make's route choice + step list (pure, offline) -│ ├── pricing.ts # credit estimates for the chains make runs -│ ├── task-command.ts # create/get/list/wait/delete factory -│ ├── poll.ts # backoff polling until terminal -│ ├── file-input.ts # image + 3D-model flag resolution (URL or local) -│ ├── download.ts # -o artifact download, meta.json, extension fix -│ ├── report.ts # Status: SUCCESS / FAIL stdout formatter -│ ├── output.ts # json / pretty / ndjson writers -│ ├── payload.ts # --data parsing, mergePayload, dropNullish -│ ├── flags.ts # parseBool / parseInt10 / parseCsv helpers -│ ├── errors.ts # UsageError + exit-code mapping -│ ├── global-options.ts # mirror --format etc. onto subcommands -│ └── logger.ts # leveled stderr logger -├── tests/ # node:test unit + integration tests -├── skills/ # agent-facing skill: SKILL.md + bundled animation catalog (published) +│ ├── result.ts / errors.ts # v1 envelope, error codes, exit codes +│ ├── task-command.ts # create/get/list/wait/stream/delete factory +│ ├── operation-store.ts # submission journal (single POST, unknown outcomes) +│ ├── stream.ts / poll.ts # SSE parser + cancellable polling +│ ├── artifacts.ts / download.ts # asset keys, safe downloads, legacy -o +│ ├── project-store.ts # meshy_output metadata + history +│ ├── inspect.ts / obj-transform.ts / slicers.ts / doctor.ts +│ ├── config.ts / runtime.ts / env-file.ts / credentials.ts / oauth.ts +│ └── atomic-file.ts / paths.ts / lock.ts +├── tests/ # node:test unit, contract and black-box tests +├── skills/ # agent-facing skill (published) +├── docs/skill-parity/ # baseline, contracts, matrix, decisions, verification ├── package.json -├── tsconfig.json └── README.md ``` @@ -342,24 +514,22 @@ meshy-cli/ - **Two layers, one of them opinion-free.** `make` chains endpoints; the resource commands expose them one at a time. `make` picks its chain from the - input type alone and stops there. Every richer decision — going through an - image first, confirming a shape before texturing, a polycount that rigs well - — is a judgement about someone else's asset and someone else's credits, so it - stays with the caller rather than being baked into a default. + input type alone and stops there. - **Plan first, then spend.** `make` computes the whole chain before creating anything, so `--dry-run` and a real run share one code path and one estimate. - `--max-credits` refuses on that estimate; a budget enforced after step one has - billed is not a budget. -- **Two fetchers.** `text-to-3d` lives under `/openapi/v2`, everything else on - `/openapi/v1`. The client holds both and routes per endpoint. -- **Uniform verbs.** A single `buildResourceCommand` factory generates - `get`/`list`/`wait`/`delete` for every resource. Resource modules only - contribute their unique `create` flag shape. +- **One POST, journaled.** Every billable create is recorded before it is sent; + a lost answer is reported as unknown, never re-sent. +- **One registry.** `src/client/resource-registry.ts` is the only place a path, + API family, verb set or media field is declared; commands, the `resources` + index and the transport all read from it. +- **Credentials have a scope.** The API credential goes to the API origins it + was resolved for and nowhere else — not to asset hosts, not to the public + catalog, not across redirects. - **`--data` escape hatch.** Every `create` accepts a raw JSON object (or - `@file.json`) that merges with structured flags. Use this when Meshy ships a - new field before the CLI models it. -- **Stdout is reserved for command output.** Logs and errors go to stderr so - pipes (`| jq`, `-o file`) stay clean. + `@file.json`) that merges with structured flags (flags win, explicit `false` + and `0` survive). +- **Stdout is reserved for command output.** Logs, progress and errors' prose go + to stderr so pipes (`| jq`, `-o file`) stay clean. ## License diff --git a/package.json b/package.json index 30b662a..85e4e92 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "meshy-cli", - "version": "0.2.0", - "description": "Official command-line interface for the Meshy AI API \u2014 text-to-3D, image-to-3D, text-to-motion, remesh, rigging, animate, retexture, 2D images, multi-color print, balance.", + "version": "0.3.0", + "description": "Official command-line interface for the Meshy AI API \u2014 text-to-3D, image-to-3D, text-to-motion, remesh, UV unwrap, rigging, animate, retexture, 2D images, multi-color print, Creative Lab, balance \u2014 plus selective downloads, project folders, face checks, OBJ print preparation and slicer launch.", "license": "MIT", "type": "module", "packageManager": "pnpm@11.24.0", diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 41b601e..1be178a 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -1,10 +1,10 @@ --- name: meshy-cli -description: "Generate 3D models, motion clips, and 2D images with the Meshy API through the meshy-cli command — text-to-3D, image-to-3D, text-to-motion, remesh, rigging, animation, retexture, printability. Use for any Meshy asset request." +description: "Generate 3D models, motion clips, 2D images and Creative Lab print products with the Meshy API through the meshy-cli command — text-to-3D, image-to-3D, text-to-motion, remesh, UV unwrap, rigging, animation, retexture, printability, selective downloads, project folders, face checks, OBJ print preparation and slicer launch. Use for any Meshy asset or 3D-printing request." license: MIT -compatibility: Requires meshy-cli on PATH and a stored credential or MESHY_API_KEY; network access to api.meshy.ai +compatibility: Requires meshy-cli on PATH (Node 24+, no Python) and a stored credential, MESHY_API_KEY or --api-key-file for API commands; network access to api.meshy.ai. Local helpers work offline. metadata: - version: "1.0.0" + version: "1.1.0" cli-help: "meshy --help" --- @@ -14,38 +14,102 @@ metadata: `npm i -g meshy-cli`, then `meshy auth login` (browser) or `meshy auth login --with-key msy_...`. `MESHY_API_KEY` also works and wins over -a stored profile, which is what CI wants. Never echo the key back or write it -into a shell profile. +a stored profile, which is what CI wants; `--api-key-file ./keys.env` reads only +`MESHY_API_KEY` from an explicit dotenv-style file (nothing is auto-discovered or +executed; do not use Node's `--env-file`). Never echo the key back or write it +into a shell profile. `meshy doctor` checks the environment without touching the +network; `meshy doctor --check-api` makes one free balance call. + +## Always ask for the stable envelope + +Add `--output-schema v1 --format json` to every command you parse. stdout is then +exactly one JSON object with six keys — `schema_version, command, ok, result, +error, warnings` — and nothing else; progress goes to stderr. `ok` is whether the +CLI operation completed; `result.task.status` is the server's task state (a `get` +of a FAILED task is `ok:true`). Fields the server did not send are `null` — +never treat a `null` `face_count` or `consumed_credits` as 0. `error.code` and +the exit code (below) are stable; `error.recovery.command`, when present, is a +command you can run verbatim. ## One model, one command ```bash -meshy make "a red sports car" -o car.glb # prompt → text-to-3d preview → refine -meshy make ./cat.png -o out/cat/ # image → image-to-3d, textured -meshy make "a red sports car" --dry-run # planned steps + estimate, no spend -meshy make "..." --max-credits 25 # refuse to start over budget +meshy make "a red sports car" -o car.glb --output-schema v1 # prompt → text-to-3d preview → refine +meshy make ./cat.png -o out/cat/ --output-schema v1 # image → image-to-3d, textured +meshy make "a red sports car" --dry-run --output-schema v1 # planned steps + estimate, no spend, no network +meshy make "..." --max-credits 25 # refuse to start over budget +meshy make "..." --async --output-schema v1 # one POST, returns step 1's task_id + pending_steps ``` -The input decides the chain and nothing else does. If step 2 fails, the error's -`hint` is the command that resumes from step 1's task — run it verbatim rather -than starting over, or the finished step is paid for twice. +The input decides the chain and nothing else does. If a later step fails, the +result carries the finished step's task id and the `resume` command — run it +verbatim rather than starting over, or the finished step is paid for twice. ## Everything else -`meshy resources` indexes the 17 endpoint commands; each carries the same verbs: +`meshy resources --output-schema v1` indexes every command (kind `task`, `query` +or `local`); each task resource carries the same verbs: ```bash -meshy create [flags] [--data ''] [--async] [--timeout ] -meshy get|wait|delete -meshy list [--page-size ] +meshy create [flags] [--data ''] [--async] [--timeout ] [--operation-id ] --output-schema v1 +meshy get|wait|stream|delete --output-schema v1 +meshy list [--page-size ] --output-schema v1 ``` -`create` blocks until the task is terminal; `--async` returns the id instead. -`-o ` downloads artifacts (a directory for multi-file results) and writes -a `meta.json` sidecar; without `-o`, stdout is the task JSON — parse ids from -there, never from text shown in chat. `--data ''` reaches any field the -CLI has no flag for, and `meshy api ` reaches any endpoint it has -no command for. +- `create` blocks until the task is terminal; `--async` submits **exactly one + POST** and returns `result.submission.task_id` — parse ids from stdout, never + from text shown in chat. `wait ` polls (`--timeout 0` = one query, exit 8 on + timeout with the task kept); `stream --format ndjson` follows Server-Sent + Events (one line per event, last line `event:"outcome"`). +- **Never re-run a create after exit 10** (`submission_unknown`): the request was + sent and the server may have created the task. Run the `error.recovery.command` + (`… list`) and reconcile first. Pass `--operation-id ` to a create so a + repeat of the same request replays the recorded outcome instead of billing again. +- `--save-json ` stores the raw API task (use it as the input of `download`, + `inspect faces` and `project record`); `--include-raw` puts the raw response under + `result.task.raw`; `--project ` records the task in a project folder. +- `--data ''` reaches any field the CLI has no flag for (flags win, explicit + `false`/`0` survive); `meshy api ` reaches any endpoint it has no + command for. Media flags and the same fields inside `--data` accept URLs, local + files and `data:` URIs. + +## Creative Lab, UV, catalog, showcases + +```bash +meshy uv-unwrap create --input-task-id --async --output-schema v1 # GLB only, ≤40k faces (server enforces) +meshy creative-lab prototype create --image-url ./photo.png --name demo --async --output-schema v1 +meshy creative-lab build create --input-task-id [--model-format …] [--options ''] --async --output-schema v1 +meshy animation-catalog list --category DailyActions --search wave --output-schema v1 # public, no key; search is local +meshy showcases list --search car --page-size 3 --output-schema v1 # Enterprise only; EVERY request is billed +``` + +Prototype meanings differ: figure/keychain/fridge-magnet yield a concept image, +lamp also yields a lampshade GLB. Build consumes a SUCCEEDED prototype made +through this API with the same key (web-app prototypes → 404). Lamp builds output +`lamp_stl`/`base_stl` or `bundle_zip`; keychain/fridge-magnet `obj` output is a +ZIP bundle saved as `.zip`. Never call `showcases` in a health check. + +## Downloads, projects, printing (local, no key) + +```bash +meshy download --task-json ./task.json --list --output-schema v1 +meshy download --task-json ./task.json --model-format glb --output ./model.glb --output-schema v1 +meshy download --task-json ./rig.json --asset result.basic_animations.walking_glb_url --output ./walking.glb --output-schema v1 +meshy download --resource image-to-3d --task-id --all --output-dir ./out/ --output-schema v1 +meshy project init --root ./meshy_output --name "" --task-id --output-schema v1 +meshy project record --project --task-id --resource text-to-3d --stage preview --file preview.glb --output-schema v1 +meshy inspect faces --task-json ./task.json --max-faces 300000 --output-schema v1 # exit 0 pass | 12 fail | 13 unknown +meshy mesh prepare-print ./model.obj --height-mm 75 --output-schema v1 # writes ./model.print.obj (Y-up → Z-up, grounded) +meshy slicer detect --output-schema v1 +meshy slicer open --slicer OrcaSlicer --file ./model.print.obj --output-schema v1 # launch only; not proof of import +``` + +`download` needs an explicit selector when a task has several assets (it lists +them and exits 2 otherwise); it never overwrites without `--overwrite`. `inspect +faces` answers only the face-count question — `unknown` (exit 13) means the task +carries no usable `face_count`; do not treat it as a pass and do not start a +remesh unless the user agrees. `prepare-print` never edits the input unless +`--in-place`. ## Constraints that will bite @@ -56,64 +120,53 @@ These are API rules, not preferences — ignoring them produces failed tasks: textured with `retexture` instead. - **`rigging` needs a textured biped GLB** under 300k faces, with clear limbs — not props, quadrupeds or untextured drafts. Too dense? `remesh` first. A - successful rigging task already bundles walking and running clips, so check - its result before calling `animate`. + successful rigging task already bundles walking and running clips + (`result.basic_animations.*`), so check its result before calling `animate`. - **`animate` takes a rigging task id**, not a model task id, plus an integer - `--action-id`. + `--action-id` from the catalog. - **`text-to-motion` produces a standalone skeletal clip**, not an animated - character. Pass a 2–10 second duration in 0.5-second increments. Prime is the - default (FBX, 10 credits); Swift returns BVH (3 credits). + character. Pass a 2–10 second duration in 0.5-second increments. - **`repair-printability` drops textures and invalidates UVs.** Run it before texturing, or re-`retexture` afterwards. +- **`uv-unwrap` takes one source** (`--input-task-id` or `--model-url`, GLB only). - **`multi-image-to-3d` is beta**; use `image-to-3d` unless multi-view input was explicitly asked for. - `image-to-3d` defaults to an untextured draft mesh; `--should-texture true` (what `make` uses) produces a textured model in one task. - **The model set is not the same on every endpoint.** The image-driven endpoints run Meshy 7 by default; `text-to-3d` has no Meshy 7 at all and its - default is still Meshy 6. So `--ultra-mode` (an extra Meshy 7 geometry pass, - billed on top) exists on `image-to-3d` alone, and `retexture`'s - `--multiview-image-urls` needs Meshy 7 — it takes 1-4 views of **the same - object**, not 1-4 style references, and cannot be combined with - `--text-style-prompt` or `--image-style-url`. + default is still Meshy 6. `--ultra-mode` exists on `image-to-3d` alone, and + `retexture`'s `--multiview-image-urls` needs Meshy 7 — it takes 1-4 views of + **the same object**, not style references. ## Finding an animation id -`--action-id` is an integer from Meshy's animation library. This skill bundles -the full catalog as `animation-library.json` next to this file — 678 actions, -each with `action_id`, `name`, `category`, `sub_category`, `is_free`, -`preview_url`, and a hand-written `description` the API does not return. Pick an -action by what it depicts, offline and with no key: - -```bash -# from this skill's directory -jq -r '.[] | select(.category=="Fighting") | "\(.action_id)\t\(.name)\t\(.description)"' \ - animation-library.json -``` +`--action-id` is an integer from Meshy's animation library. Ask the live public +catalog (no key): `meshy animation-catalog list --category Fighting --search kick +--output-schema v1` → `result.items[].action_id`. Ids are not `1..N` (the catalog +contains `-2`, `-1` and `0`) — never guess one. This skill also bundles a +snapshot as `animation-library.json` (with hand-written descriptions the API does +not return; it can lag behind the live catalog). Categories: WalkAndRun, BodyMovements, DailyActions, Fighting, Dancing. -The bundled catalog can lag as Meshy adds actions. For the authoritative live -list (ids + names, no descriptions) hit the public endpoint — no key required: - -```bash -curl -s "https://api.meshy.ai/web/public/animations/resources" \ - | jq -r '.result.list[] | select(.category=="Fighting") | "\(.id)\t\(.name)"' -``` - ## When something fails -Exit codes: `0` ok · `2` usage · `3` auth · `4` validation · `5` not found · -`6` rate limit · `7` network · `8` timed out · `9` out of credits. +Exit codes: `0` ok · `1` task FAILED/CANCELED while waiting, or unclassified · +`2` usage · `3` auth · `4` validation · `5` not found · `6` rate limit · +`7` network · `8` timed out (task keeps running) · `9` out of credits · +`10` submission unknown (do not re-create) · `11` local I/O · `12` check failed · +`13` check unknown · `130` interrupted. - Exit 9 → run `meshy balance`, relay the number, do not retry. - Exit 6 → back off; the CLI does not retry for you. -- A `FAILED` task → relay `task_error.message` verbatim; do not guess a cause. -- Any error payload may carry `hint` — a command to run. Prefer it over - improvising. +- Exit 8 → `wait`/`stream` the same task id again; it was not cancelled. +- Exit 10 → reconcile with ` list`; never submit the same create again blindly. +- A `FAILED` task → relay `result.task.task_error.message` verbatim; do not guess a cause. +- Any error may carry `error.recovery.command` — prefer it over improvising. -JSON object output may also carry `_notice.update` when a newer meshy-cli -exists; pass its `command` on to the user. +Legacy JSON output (without `--output-schema v1`) may carry `_notice.update` when +a newer meshy-cli exists; pass its `command` on to the user. ## Docs diff --git a/tests/uv-creative-lab.test.ts b/tests/uv-creative-lab.test.ts index e9f3917..7850f29 100644 --- a/tests/uv-creative-lab.test.ts +++ b/tests/uv-creative-lab.test.ts @@ -254,3 +254,34 @@ test("T-041/T-042 make: --async is one POST and zero polls with pending_steps; - await api.close(); } }); + +test("T-105 a stored profile is never sent to a Creative Lab base on another origin; an explicit key may be", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "cl-x" }) : jsonReply(res, 404, {}))); + const other = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "cl-y" }) : jsonReply(res, 404, {}))); + try { + const dir = tmpDir(); + const credFile = join(dir, "credentials.json"); + writeFileSync(credFile, JSON.stringify({ auth_version: 1, active_profile: "default", profiles: { default: { kind: "api_key", api_key: "msy_stored_profile_key", created_at: 1 } } })); + const env = api.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: credFile, MESHY_BASE_URL_CREATIVE_LAB: `${other.url}/openapi/creative-lab` }); + const refused = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "p1", "--async"], { env, cwd: dir }); + assert.equal(refused.code, 3, refused.stderr); + const out = parseSingleJson(refused.stdout) as { error: { code: string; message: string } }; + assert.equal(out.error.code, "auth"); + assert.match(out.error.message, /different origin/); + assert.equal(other.requests.length, 0, "the stored profile never left for the other origin"); + assert.equal(api.requests.length, 0); + // Same-origin derived base with the stored profile works. + const same = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "p1", "--async"], { env: api.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: credFile }), cwd: dir }); + assert.equal(same.code, 0, same.stderr); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer msy_stored_profile_key"); + // An explicit key is the user's choice and may go to the explicit origin. + const explicit = await runCli(["creative-lab", "figure", "build", "create", "--input-task-id", "p1", "--async", "--api-key", "msy_explicit"], { env, cwd: dir }); + assert.equal(explicit.code, 0, explicit.stderr); + assert.equal(other.requests.length, 1); + assert.equal(other.requests[0]!.headers["authorization"], "Bearer msy_explicit"); + assert.equal(other.requests[0]!.path, "/openapi/creative-lab/figure/v1/build"); + } finally { + await api.close(); + await other.close(); + } +}); From 6273d9aa6ef396cf1cc26838e2c0d09ab176f595 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 16:33:26 +0800 Subject: [PATCH 09/26] docs(skill-parity): verification record and review handoff for the 0.3.0 candidate Binds every check to code HEAD e7c26fc: install/typecheck/test (502 pass)/ build, npm pack (meshy-cli-0.3.0.tgz, sha256 recorded), tarball install into a temporary prefix with 29 smoke checks, one real unauthenticated public catalog query; live account/OS/GUI checks recorded as not_run. Capability matrix: 35/35 implemented, review not_run. REVIEW_HANDOFF.md follows the package template with sanitised sample outputs and the Codex review prompt. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/REVIEW_HANDOFF.md | 776 ++++++++++++++++++ docs/skill-parity/capability-matrix.json | 17 +- docs/skill-parity/verification.json | 949 ++++++++++++++++++++++- 3 files changed, 1723 insertions(+), 19 deletions(-) create mode 100644 docs/skill-parity/REVIEW_HANDOFF.md diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md new file mode 100644 index 0000000..77caf37 --- /dev/null +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -0,0 +1,776 @@ +# Meshy CLI S1 Review Handoff + +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. + +## 1. 代码定位 + +- 仓库路径 / remote:`/Users/ark/Dev/meshy-cli`(fresh clone) / `https://github.com/meshy-dev/meshy-cli.git` +- 分支:`feat/skill-parity-s1`(本地分支,未推送) +- base SHA:`fd94490916376e691efcea51324ac4326b459e1f`(0.2.0,= 计划基线 = 开工时的 remote main) +- head SHA(代码):`e7c26fc053cea4e1bf7dee08953e25a3ec858137`;本文件与 verification.json/capability-matrix.json 在其后的 docs-only commit 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`) +- 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) +- 实施包版本:2026-09-07 / v1 +- 实际源码与计划基线的差异:无(remote main 与 Skills HEAD 均等于计划 SHA,见 `docs/skill-parity/baseline-delta.md`) +- PR URL:未创建(未获创建 PR / 推送授权) +- Node / pnpm / OS / arch:Node v24.20.0(fnm)/ pnpm 11.24.0(corepack,`packageManager`)/ macOS 26.6 (Darwin 25.6.0) / arm64 + +## 2. 完成状态 + +- G1-code / review-ready:**passed**(自评;等待 Codex review) +- G1-release / ready-for-S2:**not_run**(无 review 结论、无真实账号/多 OS/切片器验证、未发布) +- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`,全部 `implementation_status: implemented`,`review_status: not_run`) +- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **502 通过 / 0 失败 / 0 跳过**(502 项,含基线原有 363 项;覆盖 73 个 T-id 的离线部分,见 verification.json `behavior_tests`) +- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open +- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(meshyd 仅只读核对 6 个文件,见 baseline.json) + +## 3. 本次具体改动 + +`git log --oneline fd94490..HEAD`(最早在下): + +``` +e7c26fc chore(release): 0.3.0 candidate — README, bundled skill, env example, origin-policy test +da7e1dc feat(local): B06-B08 inspect faces, OBJ prepare-print, slicers and doctor +96ee188 feat(project): B05 meshy_output project store, project command and --project bookkeeping +3935f35 feat(download): B04 asset enumeration, selective download and safe file placement +6784309 feat(tasks): B03 task lifecycle — v1 verbs, journaled single POST, SSE stream, make async, uv-unwrap, creative-lab +f05ff87 feat(client): B02 transport, resource registry, catalog and showcases +e7ea577 feat(cli): B01 v1 envelope, exit codes, local runtime, --api-key-file and unified error exit +c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixtures +``` + +` 108 files changed, 14888 insertions(+), 881 deletions(-)` + +| 批次 | commit | 改动与用户行为 | 关键验证 | +| --- | --- | --- | --- | +| B00 | `docs(skill-parity)` | baseline/endpoint-contracts/decisions/migration-notes/fixtures;无代码 | `validate` 由 tests/resource-registry.test.ts 绑定 | +| B01 | `feat(cli): B01` | v1 envelope、退出码 10–13/130、`CliError` 分类;全局 `--output-schema/--api-key-file/--workspace/--no-update-check/--base-url-creative-lab`;Commander 参数错误 → exit 2;本地 runtime;SIGINT→130;stdout flush;update-check 策略;balance/api/delete 的 v1 + `--save-json` | tests/cli-contract, env-file, result | +| B02 | `feat(client): B02` | transport(路径校验、拒绝 redirect、body 级超时、body 上限、connect/unknown phase);resource registry(16 task + 8 Creative Lab stage + 3 query);TaskView(缺失字段为 null);媒体规范化移到合并后的 payload、支持 data URI;animation-catalog(无鉴权)、showcases(单次计费 GET);registry 驱动的 `resources` | tests/transport, resource-registry, task-view, catalog-showcases, file-input | +| B03 | `feat(tasks): B03` | create/get/wait/stream/delete/list 双 schema;get 任意状态 exit 0;`--timeout 0` 单次查询;operation journal(POST 前落盘、5xx/畸形 2xx/断线 → exit 10、`--operation-id` 重放/冲突);SSE 解析与 stream 语义(ndjson 事件 + outcome);`make --async` 单次 POST + `--stop-after-first`;uv-unwrap;creative-lab 4×2 命令树与逐产品校验 | tests/task-lifecycle, sse, operation-store, uv-creative-lab | +| B04 | `feat(download): B04` | 资产枚举(lamp 部件 STL/ZIP、keychain/fridge OBJ=ZIP bundle、嵌套 basic_animations、多视角/alpha 缩略图、报告);安全下载(无凭据、逐跳重定向校验、私网拒绝、大小上限、sha256、MIME 校正、magic 校验、link 独占发布、realpath 作用根);`meshy download` | tests/artifacts, download-command, download(legacy) | +| B05 | `feat(project): B05` | metadata.json v2 + legacy 迁移备份、history 索引、(task_id,stage) 合并、锁顺序、index_dirty;`project init/record/show/list/rebuild-index`;任务动词与 download 的 `--project/--stage` | tests/project-store(含多进程) | +| B06–B08 | `feat(local): B06-B08` | `inspect faces`(unknown≠0,exit 12/13,仅描述 remesh);`mesh prepare-print`(两遍流式、oracle 校验、材质依赖、`--in-place`);`slicer detect/open`(七注册、三 OS 规则、无默认应用 fallback);`doctor`(默认本地,`--check-api` 单次 balance) | tests/inspect, obj-transform, slicers, doctor | +| B09 | `chore(release)` + docs commit | 0.3.0 候选版本、README、随包 SKILL.md、.env.example、T-105;verification.json、REVIEW_HANDOFF | 本节 5、7 | + +## 4. 能力与接口证据 + +- 更新后的 capability-matrix 路径:`docs/skill-parity/capability-matrix.json` +- endpoint-contracts 与模型/媒体参数映射:`docs/skill-parity/endpoint-contracts.json`(与 `src/client/resource-registry.ts` 由 `tests/resource-registry.test.ts` 逐字段对账) +- v1 JSON schema / 错误码定义:`src/internal/result.ts`(六固定键)、`src/internal/errors.ts`(`CliErrorCode` → 退出码表);`README.md` "Stable machine output" +- legacy compatibility / migration notes:`docs/skill-parity/migration-notes.md`(§3 兼容表:get 非终态 exit 0、`make --async` 单次 POST、Commander 错误 exit 2、`rigging list` 启用;其余 legacy 输出不变) +- intentional differences:`docs/skill-parity/migration-notes.md` §2(2.1–2.12),关键项: + - `--api-key-file` 而非契约中的 `--env-file`:Node 22/24 会在整个 argv 预扫描 `--env-file` 并自行加载整个文件(含 NODE_OPTIONS)、缺失时 exit 9(决策 D-025,实验记录在 decisions.md) + - `showcase_type` 服务端枚举为 `animate`,文档写 `animated`;CLI 两者都接受,发送 `animate` 并给 warning(D-007) + - 公共任务 DTO 不含 `face_count`(meshyd `httpapi/dto.go` 只读核对)→ API 来源的 `inspect faces` 通常为 unknown/exit 13,绝不当 0(D-009) + - `rigging list` 启用(官方文档 + 服务端路由均存在;0.2.0 标为不支持) + - lamp prototype 已废弃 `text` 输入在提交前拒绝 +- 限权 API / 未支持的 OS / deferred 新功能:UV/Creative Lab/showcases 真实调用 not_run;Windows/Linux 仅 fixture 覆盖;keycap/vinyl-figure/brick-figure/clover-fidget 等新 Creative Lab 产品记录为 deferred(矩阵 DF-001) + +### 脱敏实际 CLI 输出(loopback mock 或本地;``/`` 为替换后的路径) + +`text-to-3d create --mode preview --prompt "a cactus" --async --output-schema v1`(exit=0): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "text-to-3d.create", + "ok": true, + "result": { + "task": null, + "submission": { + "state": "accepted", + "operation_id": "2eb38a76-8e3f-48e1-9b0f-f8a3fff9d877", + "task_id": "fixture-task-a", + "request_id": null + }, + "downloads": { + "state": "not_requested", + "files": [], + "metadata_path": null + }, + "saved_json": null, + "task_id": "fixture-task-a", + "next": { + "get": "meshy text-to-3d get fixture-task-a --output-schema v1", + "wait": "meshy text-to-3d wait fixture-task-a --output-schema v1", + "stream": "meshy text-to-3d stream fixture-task-a --format ndjson --output-schema v1" + }, + "project": null + }, + "error": null, + "warnings": [] +} +``` + +`text-to-3d get fixture-task-a --output-schema v1`(exit=0,IN_PROGRESS): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "text-to-3d.get", + "ok": true, + "result": { + "task": { + "task_id": "fixture-task-a", + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", + "name": null, + "status": "IN_PROGRESS", + "progress": 52, + "preceding_tasks": null, + "created_at": 1757200000000, + "started_at": 1757200010000, + "finished_at": 0, + "expires_at": 0, + "face_count": null, + "consumed_credits": null, + "model_urls": {}, + "image_urls": [], + "texture_urls": [], + "thumbnail_url": null, + "thumbnail_urls": null, + "alpha_thumbnail_url": null, + "result": null, + "printability": null, + "task_error": null + }, + "submission": { + "state": "accepted", + "operation_id": null + }, + "downloads": { + "state": "not_requested", + "files": [], + "metadata_path": null + }, + "saved_json": null + }, + "error": null, + "warnings": [] +} +``` + +`text-to-3d get fixture-task-failed --output-schema v1`(exit=0,FAILED 仍是成功的查询): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "text-to-3d.get", + "ok": true, + "result": { + "task": { + "task_id": "fixture-task-failed", + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", + "name": null, + "status": "FAILED", + "progress": 0, + "preceding_tasks": null, + "created_at": 1757200000000, + "started_at": 1757200010000, + "finished_at": 1757200020000, + "expires_at": 0, + "face_count": null, + "consumed_credits": 0, + "model_urls": {}, + "image_urls": [], + "texture_urls": [], + "thumbnail_url": null, + "thumbnail_urls": null, + "alpha_thumbnail_url": null, + "result": null, + "printability": null, + "task_error": { + "message": "The server is busy. Please try again later." + } + }, + "submission": { + "state": "accepted", + "operation_id": null + }, + "downloads": { + "state": "not_requested", + "files": [], + "metadata_path": null + }, + "saved_json": null + }, + "error": null, + "warnings": [] +} +``` + +`text-to-3d wait fixture-task-a --timeout 0 --output-schema v1`(exit=8): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "text-to-3d.wait", + "ok": false, + "result": { + "task": { + "task_id": "fixture-task-a", + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", + "name": null, + "status": "IN_PROGRESS", + "progress": 52, + "preceding_tasks": null, + "created_at": 1757200000000, + "started_at": 1757200010000, + "finished_at": 0, + "expires_at": 0, + "face_count": null, + "consumed_credits": null, + "model_urls": {}, + "image_urls": [], + "texture_urls": [], + "thumbnail_url": null, + "thumbnail_urls": null, + "alpha_thumbnail_url": null, + "result": null, + "printability": null, + "task_error": null + }, + "submission": { + "state": "accepted", + "operation_id": null + }, + "downloads": { + "state": "not_requested", + "files": [], + "metadata_path": null + }, + "saved_json": null, + "wait": { + "timed_out": true, + "elapsed_seconds": 0.01, + "polls": 1 + }, + "next": { + "get": "meshy text-to-3d get fixture-task-a --output-schema v1", + "wait": "meshy text-to-3d wait fixture-task-a --output-schema v1", + "stream": "meshy text-to-3d stream fixture-task-a --format ndjson --output-schema v1" + } + }, + "error": { + "code": "timed_out", + "message": "task fixture-task-a did not reach a terminal status within 0s (last status: IN_PROGRESS); the server keeps running it", + "http_status": null, + "retryable": false, + "recovery": { + "action": "wait", + "automatic": false, + "command": "meshy text-to-3d wait fixture-task-a --output-schema v1" + } + }, + "warnings": [] +} +``` + +`text-to-3d create ... --async`,服务端返回 500(exit=10,仅一次 POST,日志记录 unknown): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "text-to-3d.create", + "ok": false, + "result": { + "submission": { + "state": "unknown", + "operation_id": "b942c376-1d0d-430d-8419-d031041ae7a0", + "task_id": null + }, + "task": null, + "downloads": { + "state": "not_requested", + "files": [], + "metadata_path": null + } + }, + "error": { + "code": "submission_unknown", + "message": "the create request was sent but its outcome is unknown (meshy api 500 on /text-to-3d: internal error); the server may or may not have created a task", + "http_status": 500, + "retryable": false, + "recovery": { + "action": "reconcile", + "automatic": false, + "command": "meshy text-to-3d list --output-schema v1 # then match operation b942c376-1d0d-430d-8419-d031041ae7a0 by time/prompt before creating again" + } + }, + "warnings": [] +} +``` + +`download --resource rigging --task-id fixture-rig-1 --asset result.basic_animations.walking_glb_url --output walking.glb`(exit=0): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "download", + "ok": true, + "result": { + "source": { + "kind": "api", + "resource": "rigging", + "task_id": "fixture-rig-1", + "status": "SUCCEEDED" + }, + "selection": { + "selected": [ + "result.basic_animations.walking_glb_url" + ], + "dependencies": [] + }, + "downloads": { + "state": "completed", + "files": [ + { + "key": "result.basic_animations.walking_glb_url", + "path": "/walking.glb", + "relative_path": "walking.glb", + "bytes": 28, + "sha256": "617b6574607e137a4eeb3821a0e3ce60abd56e34d8c4d256e159f61764f156a8", + "content_type": "model/gltf-binary", + "format": "glb", + "container_format": null, + "extracted": null, + "status": "written", + "error": null, + "publish_method": "link" + } + ], + "metadata_path": null + }, + "unknown_urls": [], + "saved_json": null, + "project": null + }, + "error": null, + "warnings": [] +} +``` + +`project record --project --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb`(tarball 安装 smoke,exit 0): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "project.record", + "ok": true, + "result": { + "project_dir": "/meshy_output/20260907_162908_smoke-demo_fixture-", + "action": "added", + "entry": { + "task_id": "fixture-rig-1", + "task_type": "rigging", + "resource": "rigging", + "endpoint": null, + "stage": "rigged", + "parent_task_id": null, + "status": null, + "files": [ + "rigged.glb" + ], + "task_json": null, + "operation_id": null, + "created_at": "2026-09-07T08:29:09.067Z", + "updated_at": "2026-09-07T08:29:09.067Z" + }, + "task_count": 1, + "index": { + "updated": true, + "error": null + }, + "migrated_from_legacy": false + }, + "error": null, + "warnings": [ + { + "code": "recorded_file_missing", + "message": "recorded file(s) not present in the project yet: rigged.glb" + } + ] +} +``` + +`project show --project `(exit 0): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "project.show", + "ok": true, + "result": { + "project_dir": "/meshy_output/20260907_162908_smoke-demo_fixture-", + "legacy_format": false, + "metadata": { + "schema_version": 2, + "project_name": "smoke demo", + "folder": "20260907_162908_smoke-demo_fixture-", + "root_task_id": "fixture-rig-1", + "created_at": "2026-09-07T08:29:08.930Z", + "updated_at": "2026-09-07T08:29:09.067Z", + "tasks": [ + { + "task_id": "fixture-rig-1", + "task_type": "rigging", + "resource": "rigging", + "endpoint": null, + "stage": "rigged", + "parent_task_id": null, + "status": null, + "files": [ + "rigged.glb" + ], + "task_json": null, + "operation_id": null, + "created_at": "2026-09-07T08:29:09.067Z", + "updated_at": "2026-09-07T08:29:09.067Z" + } + ] + }, + "files": [ + { + "task_id": "fixture-rig-1", + "stage": "rigged", + "file": "rigged.glb", + "present": false + } + ] + }, + "error": null, + "warnings": [] +} +``` + +`inspect faces --task-json - --max-faces 300000`,任务无 face_count(exit 13): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "inspect.faces", + "ok": false, + "result": { + "face_count": null, + "limit": 300000, + "comparison": "lte", + "verdict": "unknown", + "reason": "face_count missing", + "source": { + "kind": "task-json", + "path": "/dev/stdin", + "shape": "api" + }, + "task_id": "x", + "status": "SUCCEEDED", + "suggestion": null, + "saved_json": null + }, + "error": { + "code": "check_unknown", + "message": "face count unknown: face_count missing", + "http_status": null, + "retryable": false, + "recovery": null + }, + "warnings": [] +} +``` + +`mesh prepare-print box-y-up.obj --height-mm 80`(exit 0;与 fixture oracle 一致:scale 20,bbox [-20,-60,0]..[20,60,80]): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "mesh.prepare-print", + "ok": true, + "result": { + "input": "/box-y-up.obj", + "output": "/box-y-up.print.obj", + "height_mm": 80, + "scale": 20, + "rotation": "(x,y,z)->(x,-z,y)", + "translation": [ + 0, + 0, + -40 + ], + "before_bbox": { + "min": [ + -1, + 2, + -3 + ], + "max": [ + 1, + 6, + 3 + ] + }, + "after_bbox": { + "min": [ + -20, + -60, + 0 + ], + "max": [ + 20, + 60, + 80 + ] + }, + "counts": { + "vertices": 8, + "normals": 6, + "uvs": 4, + "faces": 6, + "lines_total": 28 + }, + "material": { + "mtllib": [ + "box.mtl" + ], + "copied": [], + "missing": [] + }, + "in_place": false, + "warnings": [] + }, + "error": null, + "warnings": [] +} +``` + +`slicer detect`(exit 0;本机未安装任何注册切片器,空列表即成功): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "slicer.detect", + "ok": true, + "result": { + "platform": "darwin", + "slicers": [ + { + "id": "bambu-studio", + "name": "Bambu Studio", + "path": "/Applications/BambuStudio.app", + "multicolor": true, + "platform": "darwin" + } + ], + "unsupported": [] + }, + "error": null, + "warnings": [] +} +``` + +`doctor`(exit 0,无网络): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "doctor", + "ok": true, + "result": { + "cli": { + "version": "0.3.0", + "node": "v24.20.0", + "platform": "darwin", + "arch": "arm64" + }, + "local_ready": true, + "api_ready": null, + "checks": [ + { + "id": "cli", + "status": "ok", + "detail": "meshy-cli 0.3.0 on node v24.20.0 (darwin arm64)" + }, + { + "id": "node", + "status": "ok", + "detail": "node v24.20.0 satisfies the required >=24" + }, + { + "id": "base_urls", + "status": "ok", + "detail": "v1 https://api.meshy.ai/openapi/v1; v2 https://api.meshy.ai/openapi/v2; creative-lab https://api.meshy.ai/openapi/creative-lab; public-web https://api.meshy.ai/web/public" + }, + { + "id": "api_key_file", + "status": "skipped", + "detail": "--api-key-file not given" + }, + { + "id": "credentials", + "status": "warn", + "detail": "sources present: --api-key=no, MESHY_API_KEY=no, --api-key-file=none, stored profile=absent (/credentials.json); values are never read by doctor. API commands need --api-key, MESHY_API_KEY, --api-key-file or `meshy auth login`" + }, + { + "id": "workspace", + "status": "skipped", + "detail": "--workspace not given (files land next to their targets)" + }, + { + "id": "cwd_env_files", + "status": "ok", + "detail": "no .env or .env.local in (none is ever read automatically)" + }, + { + "id": "api", + "status": "skipped", + "detail": "not requested (pass --check-api for one free GET /balance)" + }, + { + "id": "slicers", + "status": "skipped", + "detail": "not requested (pass --check-slicers)" + } + ], + "credential_sources": { + "flag": false, + "env": false, + "api_key_file": null, + "stored_profile": { + "path": "/credentials.json", + "exists": false + } + }, + "base_urls": { + "v1": "https://api.meshy.ai/openapi/v1", + "v2": "https://api.meshy.ai/openapi/v2", + "creative_lab": "https://api.meshy.ai/openapi/creative-lab", + "public_web": "https://api.meshy.ai/web/public" + }, + "workspace": { + "path": null, + "writable": null + }, + "cwd_env_candidates": [], + "slicers": null, + "api": null, + "saved_json": null + }, + "error": null, + "warnings": [] +} +``` + +## 5. 实际验证记录 + +全部绑定代码 HEAD `e7c26fc053cea4e1bf7dee08953e25a3ec858137`,环境 macOS 26.6 arm64 / Node v24.20.0 / pnpm 11.24.0;日志见 `docs/skill-parity/verification.json`(`final_runs`、`package_smoke`)。 + +| 命令 / 测试 ID | 代码 SHA | 环境 | exit code | 结果 | 日志/fixture/报告 | +| --- | --- | --- | --- | --- | --- | +| `node --version` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm --version` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm install --frozen-lockfile` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm typecheck` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm test` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed (502 tests, 502 pass, 0 fail) | verification.json final_runs | +| `pnpm build` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `bash -c test "$(node dist/index.js --version)" = "$(node -p "require(\"./package.json\").version")"` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/meshy-cli-0.3.0.tgz` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| npm pack 文件清单 + 必需文件 + 禁止内容 | e7c26fc053ce | 同上 | 0 | passed(394 files;dist/skills/README/LICENSE/.env.example 存在;无 .env/credentials/tests/docs/src) | verification.json package_smoke | +| smoke:version | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:version_cli | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:help_cli | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:balance_help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:balance_nokey_json | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:balance_nokey_v1 | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:resources_v1 | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor_check_slicers | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor_check_api_nokey | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:slicer_detect | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:inspect_pass | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:inspect_fail | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 12 (expected 12) | passed | verification.json package_smoke | +| smoke:inspect_unknown | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 13 (expected 13) | passed | verification.json package_smoke | +| smoke:mesh_prepare | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:mesh_refuse_overwrite | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 11 (expected 11) | passed | verification.json package_smoke | +| smoke:project_init | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_record | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_show | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_list | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:download_list | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:download_no_selector | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | +| smoke:make_dry_run | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:usage_unknown_flag | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | +| smoke:uv_help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:creative_lab_help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:stream_help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:catalog_live | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| 新增行为用例 T-001…T-108(离线部分) | e7c26fc053ce | node:test + tsx,loopback mock,临时目录 | 0 | passed(`pnpm test` 502/502) | tests/*.test.ts;verification.json behavior_tests | + +基线(fd94490)上 `pnpm test` 363/363 通过,无基线失败;本次没有删除或跳过任何既有测试(`tests/surface.test.ts` 的模型/废弃参数约束原样保留并通过)。 + +## 6. 真实环境验证 + +| 环境/能力 | 身份/资源(脱敏) | 实际结果 | 未完成原因 | 补验方式 | +| --- | --- | --- | --- | --- | +| API Key 与 OAuth profile 回归 | 无 | not_run | 本会话无测试账户/Key | 用测试 Key:`meshy doctor --check-api`、`meshy balance --output-schema v1`、`meshy auth status`;OAuth:`meshy auth login` 后 `meshy text-to-3d list --output-schema v1` | +| UV / Creative Lab | 无 | not_run | 计费/账号 gate,未获预算授权 | `meshy uv-unwrap create --input-task-id <已有 SUCCEEDED 任务> --async --output-schema v1` → `wait`;`meshy creative-lab figure prototype create --image-url --async` → `wait` → `build create --input-task-id`;记录 task id、请求次数、消耗额度 | +| Enterprise showcases(计费查询) | 无 | not_run | 每次请求计费且需 Enterprise | `meshy showcases list --page-size 1 --output-schema v1`(一次);核对 `showcase_type=animated` 别名是否被服务端接受(D-007) | +| 公开动画目录(免费、无鉴权) | 无需身份 | passed(1 次 GET,157 条,8 条匹配 "wave") | — | verification.json live_verification T-110 partial | +| macOS arm64 | 本机 | passed(tarball 安装 + 29 项 smoke) | — | verification.json package_smoke | +| Windows x64 | 无 | not_run | 无主机 | 安装 tarball,运行 `meshy doctor --check-slicers`、`meshy slicer detect`、`meshy mesh prepare-print`;检测规则已用 fixture 覆盖(T-088/T-089) | +| Linux x64 | 无 | not_run | 无主机 | 同上;CI(ubuntu, Node 24/26)会在推送后覆盖安装 smoke | +| 真实 slicer open | 无 | not_run | 本机未安装任何注册切片器 | 安装 OrcaSlicer 后 `meshy slicer open --slicer OrcaSlicer --file `,仅验证启动 | + +## 7. 打包证据 + +- 候选版本:`0.3.0`(package.json,**未发布**;版本号可由 reviewer 调整) +- tarball 路径:`/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/meshy-cli-0.3.0.tgz`(`npm pack --json --pack-destination`;复现见 §9) +- SHA256:`8e4e4d86faf18d5b5539264fdac5377059d033e07e4defc902e872f470e66367` +- npm pack 文件清单:394 个文件;必需项 dist/index.js、skills/meshy-cli/SKILL.md、skills/meshy-cli/animation-library.json、README.md、LICENSE、.env.example、package.json 均在;无 .env / credentials / tests / docs / src / node_modules +- 临时安装 prefix:`npm install -g --prefix ` → `/bin/meshy` 与 `/bin/meshy-cli` 均指向 `dist/index.js` +- 两个 bin、sharp、本地无 Key/无 Python 验证:29 项 smoke 全部符合预期(含 `MESHY_API_KEY=""` 下 balance 的 stdout 为 JSON、exit 3;无 Key 的本地命令 exit 0;dist 中不含 python 引用) +- 是否发布:**未发布** + +## 8. 需要 Codex 优先 review 的事项 + +1. 请求/路由/模型参数与旧 Skill 的等价性:`src/client/resource-registry.ts` vs `docs/skill-parity/endpoint-contracts.json`;Creative Lab 逐产品 Zod 范围(`src/cmd/creative-lab.ts`)取自官方页面与 meshyd 结构体,未做真实调用;uv-unwrap 双源互斥是 CLI 选择。 +2. async、unknown submission、SIGINT/SSE 的恢复语义:`src/internal/task-command.ts`(`submitOnce`/`classifySubmissionFailure`:TransportError.phase connect/validate → not_submitted,其余 → unknown;4xx → rejected);`src/internal/stream.ts`(idle/total deadline、terminal 后 close、error event 映射);`src/internal/operation-store.ts`(重放/冲突仅限本地记录)。 +3. JSON、stderr、exit 和 legacy 兼容:legacy 变化仅 get 非终态 exit 0、`make --async` 形状、Commander 错误 exit 2、`rigging list`;v1-only 命令拒绝 `--output-schema legacy`(D-002)。 +4. credential origin / public fetch / api-key-file / 日志边界:`src/client/transport.ts`(拒绝 scheme-relative/userinfo/异源/`..`/redirect);`src/internal/config.ts`(stored profile 不发往异源 creative-lab;v2 异源 override 的既有风险仅记录,见 D-013);`--api-key-file` 命名原因 D-025;日志对 data URI 脱敏、签名 query 不入日志。 +5. 嵌套资产、最终文件名、并发覆盖和本地路径:`src/internal/artifacts.ts`(keychain/fridge OBJ 为 ZIP 容器)、`src/internal/download.ts`(link 独占发布 + `copy-exclusive` 回退、MIME 改名后再次校验、私网/降级 redirect 拒绝;DNS 名指向私网不检测)。 +6. metadata 与 history 的锁/部分提交/恢复:`src/internal/project-store.ts`(项目锁 → 提交 → 根锁 → 索引;index_dirty 语义;legacy 迁移备份)。 +7. OBJ 数值/材质与 slicer OS 行为:`src/internal/obj-transform.ts`(旋转/缩放/平移公式、法线仅旋转、两遍流式、`formatObjNumber` 6 位小数);`src/internal/slicers.ts`(win32 用检测到的绝对 exe、macOS `open -a `、无默认应用);Windows/Linux 仅 fixture 验证。 +8. 声称通过但实际缺环境的事项:无 `passed` 依赖未运行的环境;§6 列出的全部 not_run/partial 项均在 verification.json 标注。 + +## 9. 最短复现步骤 + +```sh +git clone https://github.com/meshy-dev/meshy-cli.git && cd meshy-cli +git fetch feat/skill-parity-s1 && git checkout e7c26fc053cea4e1bf7dee08953e25a3ec858137 # 或使用本地仓库 /Users/ark/Dev/meshy-cli +node --version # v24.x +corepack enable && pnpm --version # 11.24.0 +pnpm install --frozen-lockfile +pnpm typecheck +pnpm test # 先 tsc 生成 dist,再运行 node:test(约 40 s;loopback mock,无外网、无凭据) +pnpm build +npm pack --json --pack-destination /tmp/meshy-pack +shasum -a 256 /tmp/meshy-pack/meshy-cli-0.3.0.tgz +npm install -g --prefix /tmp/meshy-prefix /tmp/meshy-pack/meshy-cli-0.3.0.tgz +export MESHY_CONFIG_DIR=$(mktemp -d) +/tmp/meshy-prefix/bin/meshy --version +/tmp/meshy-prefix/bin/meshy doctor --output-schema v1 +cp tests/fixtures/skill-parity/box-y-up.obj tests/fixtures/skill-parity/box.mtl /tmp/ && /tmp/meshy-prefix/bin/meshy mesh prepare-print /tmp/box-y-up.obj --height-mm 80 --output-schema v1 +/tmp/meshy-prefix/bin/meshy inspect faces --task-json tests/fixtures/skill-parity/task-rigging.synthetic.json --max-faces 300000 --output-schema v1 +MESHY_API_KEY= /tmp/meshy-prefix/bin/meshy balance --output-schema v1 ; echo "exit=$?" # 3, stdout 为 v1 envelope +``` + +不依赖作者机器上的凭据、Python 或全局包;`tests/helpers/cli.ts` 为每次子进程创建隔离的 `MESHY_CONFIG_DIR` 与 cwd,并显式指向 loopback mock。 + +## 10. 交给 Codex 的 Review 提示词 + +请 review Meshy CLI S1 实现。仓库和 base/head SHA 见本文件,规范是 2026-09-07 v1 实施包。先核实 diff 与 verification 绑定同一 HEAD(代码 HEAD `e7c26fc053cea4e1bf7dee08953e25a3ec858137`;本文件所在 docs commit 不改代码),再对照 capability matrix 和 COMMAND_CONTRACTS.md 检查功能、正确性、兼容、安全及测试缺口。 + +请优先找可复现的 bug、重复付费风险、凭据/路径问题、丢失资产/项目记录、OBJ 错误和伪造完成状态。必要时运行有意义的离线测试;不要做未授权付费调用、发布或 Skill/MCP 迁移。每项 finding 给出优先级、文件/行号、触发条件、影响与修复建议;区分代码缺陷、测试缺口与尚未完成的外部验证。最后分别判断 G1-code 是否可接受、G1-release 是否满足以及是否允许进入 S2。没有发现也要说明验证范围和剩余限制。 diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index c58779f..30f23fe 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -2,7 +2,7 @@ "schema_version": 1, "title": "Meshy CLI S1 capability matrix — implementation tracking", "date": "2026-09-07", - "status": "in_progress", + "status": "review-ready (G1-code); G1-release not_run", "baseline": { "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" @@ -819,14 +819,16 @@ "source_hint": "CLI package.json/bin and Skill install metadata", "target": "candidate tarball; both bins; Node24; no Python", "baseline_status": "existing_needs_regression", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-107", "T-108", "T-109" ], - "evidence": [], + "evidence": [ + "npm pack → meshy-cli-0.3.0.tgz sha256 8e4e4d86faf18d5b5539264fdac5377059d033e07e4defc902e872f470e66367; installed into a temp prefix; both bins run; no Python; see verification.json package_smoke" + ], "intentional_differences": [], "external_verification": "required_when_applicable_not_run" }, @@ -840,13 +842,15 @@ "source_hint": "CLI root/resources; CLI bundled skill", "target": "registry-driven help and capability/version metadata", "baseline_status": "partial", - "implementation_status": "not_started", + "implementation_status": "implemented", "review_status": "not_run", "test_ids": [ "T-020", "T-108" ], - "evidence": [], + "evidence": [ + "resources index generated from resource-registry.ts (kind task|query|local, verbs, endpoints); root/verb help updated; skills/meshy-cli/SKILL.md rewritten; tests/resource-registry.test.ts, smoke resources_v1" + ], "intentional_differences": [], "external_verification": "offline_primary" } @@ -870,6 +874,7 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-07T08:21:03.006Z" + "updated_at": "2026-09-07T08:30:28.993Z", + "code_head": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" } } diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json index 8747277..d06484e 100644 --- a/docs/skill-parity/verification.json +++ b/docs/skill-parity/verification.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "status": "in_progress", + "status": "review-ready (G1-code); live checks not_run", "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", "environment": { "os": "macOS 26.6 (Darwin 25.6.0) arm64", @@ -8,18 +8,941 @@ "pnpm": "11.24.0" }, "baseline_runs": [ - { "command": "pnpm install --frozen-lockfile", "sha": "fd94490916376e691efcea51324ac4326b459e1f", "exit_code": 0, "duration_s": 10, "result": "passed" }, - { "command": "pnpm typecheck", "sha": "fd94490916376e691efcea51324ac4326b459e1f", "exit_code": 0, "duration_s": 0, "result": "passed" }, - { "command": "pnpm test", "sha": "fd94490916376e691efcea51324ac4326b459e1f", "exit_code": 0, "duration_s": 41, "result": "passed", "tests": 363, "pass": 363, "fail": 0 }, - { "command": "pnpm build", "sha": "fd94490916376e691efcea51324ac4326b459e1f", "exit_code": 0, "duration_s": 1, "result": "passed" } + { + "command": "pnpm install --frozen-lockfile", + "sha": "fd94490916376e691efcea51324ac4326b459e1f", + "exit_code": 0, + "duration_s": 10, + "result": "passed" + }, + { + "command": "pnpm typecheck", + "sha": "fd94490916376e691efcea51324ac4326b459e1f", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "pnpm test", + "sha": "fd94490916376e691efcea51324ac4326b459e1f", + "exit_code": 0, + "duration_s": 41, + "result": "passed", + "tests": 363, + "pass": 363, + "fail": 0 + }, + { + "command": "pnpm build", + "sha": "fd94490916376e691efcea51324ac4326b459e1f", + "exit_code": 0, + "duration_s": 1, + "result": "passed" + } ], - "final_runs": [], - "behavior_tests": {}, + "final_runs": [ + { + "command": "node --version", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "pnpm --version", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "duration_s": 1, + "result": "passed" + }, + { + "command": "pnpm install --frozen-lockfile", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "pnpm typecheck", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "duration_s": 1, + "result": "passed" + }, + { + "command": "pnpm test", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "duration_s": 41, + "result": "passed", + "tests": 502, + "pass": 502, + "fail": 0, + "skipped": 0 + }, + { + "command": "pnpm build", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "duration_s": 1, + "result": "passed" + }, + { + "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/meshy-cli-0.3.0.tgz", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "duration_s": 2, + "result": "passed" + } + ], + "behavior_tests": { + "T-001": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-002": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts", + "tests/result.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-003": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-004": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-005": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-006": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-007": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-008": { + "result": "passed", + "files": [ + "tests/task-view.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-009": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-010": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-011": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts", + "tests/result.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-012": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-020": { + "result": "passed", + "files": [ + "tests/resource-registry.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-021": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-022": { + "result": "passed", + "files": [ + "tests/resource-registry.test.ts", + "tests/uv-creative-lab.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-023": { + "result": "passed", + "files": [ + "tests/resource-registry.test.ts", + "tests/uv-creative-lab.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-024": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-025": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-026": { + "result": "passed", + "files": [ + "tests/catalog-showcases.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-027": { + "result": "passed", + "files": [ + "tests/catalog-showcases.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-028": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-029": { + "result": "passed", + "files": [ + "tests/file-input.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-030": { + "result": "passed", + "files": [ + "tests/file-input.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-031": { + "result": "passed", + "files": [ + "tests/transport.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-032": { + "result": "passed", + "files": [ + "tests/transport.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-040": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-041": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-042": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-043": { + "result": "passed", + "files": [ + "tests/operation-store.test.ts", + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-044": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-045": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-046": { + "result": "passed", + "files": [ + "tests/operation-store.test.ts", + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-047": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-048": { + "result": "passed", + "files": [ + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-049": { + "result": "passed", + "files": [ + "tests/sse.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-050": { + "result": "passed", + "files": [ + "tests/sse.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-051": { + "result": "passed", + "files": [ + "tests/sse.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-052": { + "result": "passed", + "files": [ + "tests/sse.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-053": { + "result": "passed", + "files": [ + "tests/sse.test.ts", + "tests/task-lifecycle.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-060": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-061": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts", + "tests/download-command.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-062": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts", + "tests/download-command.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-063": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-064": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-066": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-067": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-068": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-069": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-070": { + "result": "passed", + "files": [ + "tests/download-command.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-071": { + "result": "passed", + "files": [ + "tests/artifacts.test.ts", + "tests/download-command.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-072": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-073": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-074": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-075": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-076": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-077": { + "result": "passed", + "files": [ + "tests/project-store.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-080": { + "result": "passed", + "files": [ + "tests/inspect.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-081": { + "result": "passed", + "files": [ + "tests/inspect.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-082": { + "result": "passed", + "files": [ + "tests/inspect.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-083": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-084": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-085": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-086": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-087": { + "result": "passed", + "files": [ + "tests/obj-transform.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-088": { + "result": "passed", + "files": [ + "tests/slicers.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-089": { + "result": "passed", + "files": [ + "tests/slicers.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-090": { + "result": "passed", + "files": [ + "tests/slicers.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-091": { + "result": "passed", + "files": [ + "tests/slicers.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-100": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts", + "tests/env-file.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-101": { + "result": "passed", + "files": [ + "tests/env-file.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-102": { + "result": "passed", + "files": [ + "tests/cli-contract.test.ts", + "tests/doctor.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-103": { + "result": "passed", + "files": [ + "tests/doctor.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + }, + "T-105": { + "result": "passed", + "files": [ + "tests/uv-creative-lab.test.ts" + ], + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + } + }, "live_verification": [ - { "id": "T-104", "scope": "API Key and OAuth profile regression against a real account", "result": "not_run", "reason": "no credential configured in this session" }, - { "id": "T-109", "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "not_run", "reason": "only macOS arm64 available; recorded per OS when run" }, - { "id": "T-110", "scope": "real free queries, existing task get, asset download", "result": "not_run", "reason": "no credential configured" }, - { "id": "T-111", "scope": "UV Unwrap / Creative Lab / showcases smoke", "result": "not_run", "reason": "billable or account-gated; no budget authorised" }, - { "id": "T-112", "scope": "real slicer open", "result": "not_run", "reason": "no slicer installed on this host" } - ] + { + "id": "T-104", + "scope": "API Key and OAuth profile regression against a real account", + "result": "not_run", + "reason": "no credential configured in this session; offline regression suites (auth-headless, oauth, device, credentials, runtime) pass" + }, + { + "id": "T-109", + "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", + "result": "partial", + "detail": "macOS 26.6 arm64, Node 24.20.0: tarball install + 31 smoke checks passed (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" + }, + { + "id": "T-110", + "scope": "real free queries, existing task get, asset download", + "result": "partial", + "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions) returned 157 entries, 8 matched \"wave\". Authenticated get/download: not_run (no credential)" + }, + { + "id": "T-111", + "scope": "UV Unwrap / Creative Lab / showcases smoke", + "result": "not_run", + "reason": "billable or account-gated; no budget or account authorised" + }, + { + "id": "T-112", + "scope": "real slicer open", + "result": "not_run", + "reason": "no slicer application installed on this host (slicer detect returned an empty list)" + } + ], + "code_head": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "recorded_at": "2026-09-07T08:30:28.989Z", + "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run.", + "package_smoke": { + "tarball": "meshy-cli-0.3.0.tgz", + "sha256": "8e4e4d86faf18d5b5539264fdac5377059d033e07e4defc902e872f470e66367", + "file_count": 394, + "required_present": true, + "forbidden_absent": true, + "install_prefix": "temporary npm prefix (npm install -g --prefix )", + "bins": [ + "meshy", + "meshy-cli" + ], + "python_required": false, + "checks": [ + { + "id": "version", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy --version", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "version_cli", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy-cli --version", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy --help", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "help_cli", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy-cli --help", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "balance_help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy balance --help", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "balance_nokey_json", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy balance", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 3, + "expected_exit": 3, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "balance_nokey_v1", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy balance --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 3, + "expected_exit": 3, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "resources_v1", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy resources --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "doctor", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy doctor --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "doctor_check_slicers", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy doctor --check-slicers --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "doctor_check_api_nokey", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy doctor --check-api --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 3, + "expected_exit": 3, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "slicer_detect", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy slicer detect --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "inspect_pass", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "inspect_fail", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 12, + "expected_exit": 12, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "inspect_unknown", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy inspect faces --task-json /dev/stdin --max-faces 300000 --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 13, + "expected_exit": 13, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "mesh_prepare", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "mesh_refuse_overwrite", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 11, + "expected_exit": 11, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "project_init", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "project_record", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/work/meshy_output/20260907_162908_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "project_show", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/work/meshy_output/20260907_162908_smoke-demo_fixture- --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "project_list", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "download_list", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "download_no_selector", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 2, + "expected_exit": 2, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "make_dry_run", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "usage_unknown_flag", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy balance --bogus --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 2, + "expected_exit": 2, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "uv_help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy uv-unwrap create --help", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "creative_lab_help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy creative-lab lamp build create --help", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "stream_help", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy text-to-3d stream --help", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + }, + { + "id": "catalog_live", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", + "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "exit_code": 0, + "expected_exit": 0, + "result": "passed", + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + } + ] + } } From 0388fe804b456a931f871d2f227e2acf22359d77 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 17:29:08 +0800 Subject: [PATCH 10/26] =?UTF-8?q?fix(review):=20address=20Codex=20review?= =?UTF-8?q?=20round=201=20findings=20F01=E2=80=93F10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F01 every failure after the server accepted a task (save-json, polling 5xx, download, project record) keeps result.task_id/submission/next; -o, --save-json and --project are checked before the POST (zero requests on a detectable conflict) F02 make submits through the shared submitCreate primitive: a journal write failure after acceptance is local_io (11) with the known id F03 --workspace is the root of every write: task -o (get/wait/stream/create/ make), project init/record/rebuild-index, --project on task verbs and download F04 OBJ dependency copies are proven inside the write root on real paths before mkdir and again before publish (symlinked materials/ refused) F05 credential fingerprint binds to a one-way digest of the API key or the OAuth subject (token rotation keeps the identity) F06 payload fingerprint hashes decoded data-URI bytes (equal-length images no longer collide; wrong test assertion replaced) F07 wait deadline bounds every in-flight GET and the sleep; late replies are timeouts, PollResult.task may be null before the first response F08 Creative Lab options/output merge field by field across --data, --options and typed flags (nestedObjectKeys) F09 downloaded OBJ/MTL references are rewritten to the saved names and reported (material_links, relinked manifest entries, unresolved warning) F10 stream -o downloads in ndjson/json/pretty; one outcome line on failure Also: operation-store race test released through a barrier (real contention), SSE fixture trailing blank line + .gitattributes, mkdir failures are local_io, tests/codex-review-round1.test.ts mirrors probes R01–R12 as positive regressions, decisions D-029..D-037, migration notes, README and bundled SKILL.md updated. Co-Authored-By: Claude Fable 5.1 --- .gitattributes | 3 +- README.md | 39 +- docs/skill-parity/decisions.md | 108 +++ docs/skill-parity/migration-notes.md | 14 + skills/meshy-cli/SKILL.md | 13 +- src/cmd/creative-lab.ts | 18 +- src/cmd/download.ts | 10 +- src/cmd/make.ts | 174 ++-- src/cmd/mesh.ts | 3 + src/cmd/project.ts | 20 +- src/internal/atomic-file.ts | 17 +- src/internal/config.ts | 9 + src/internal/credentials.ts | 3 + src/internal/download.ts | 61 +- src/internal/material-links.ts | 381 +++++++++ src/internal/obj-transform.ts | 33 +- src/internal/operation-store.ts | 98 ++- src/internal/payload.ts | 38 + src/internal/poll.ts | 78 +- src/internal/task-command.ts | 514 +++++++++--- tests/codex-review-round1.test.ts | 757 ++++++++++++++++++ .../skill-parity/task-error.synthetic.sse | 1 - tests/helpers/begin-operation-child.ts | 25 +- tests/operation-store.test.ts | 100 ++- tests/poll.test.ts | 44 +- 25 files changed, 2266 insertions(+), 295 deletions(-) create mode 100644 src/internal/material-links.ts create mode 100644 tests/codex-review-round1.test.ts diff --git a/.gitattributes b/.gitattributes index e62788b..4f8d623 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ -dist/** linguist-generated=true +# SSE fixtures end with the blank line that terminates their last event; that is protocol, not stray whitespace. +tests/fixtures/skill-parity/*.sse -whitespace diff --git a/README.md b/README.md index 241cc68..dbe7eb7 100644 --- a/README.md +++ b/README.md @@ -227,11 +227,27 @@ one JSON document (`ndjson` streams emit one line per event plus a final re-running the create. Reconcile with `list`, then decide. - `--operation-id ` replays the recorded outcome of an identical earlier request instead of submitting again; a different request under the same id is - refused. This is a local record, not a server-side idempotency key. + refused (`operation_conflict`, exit 2, naming what differs). "Identical" means + the same resource, API origin, credential — a one-way digest of the API key, + or the OAuth account — and payload, with every inline image or model hashed by + content. This is a local record, not a server-side idempotency key. +- Local targets that would fail after the POST are checked before it: an + existing `--save-json` file, an `-o` path outside `--workspace`, a missing + `--project` all exit 11 with "nothing was submitted" and cost no request. +- Once the server has accepted a task, every later failure — saving JSON, + polling (a 503), downloading, recording, Ctrl-C — still reports + `result.task_id`, `result.submission` and `result.next` (the `get`/`wait`/ + `stream` commands that pick the task up). A bookkeeping problem never reads + as "no task was created". - `--async` returns after the POST (no polling). `get` is a query: any status - exits 0. `wait --timeout N` polls with a monotonic deadline (`0` = one query); - timeouts exit 8 with the last task kept. `stream` follows Server-Sent Events - with `--timeout` (total) and `--idle-timeout` (silence, keep-alives reset it). + exits 0. `wait --timeout N` polls with a monotonic deadline (`0` = one query) + that also bounds every in-flight GET: a response arriving after the deadline + is a timeout (exit 8, last status kept, `result.task` null when none arrived + in time), never a late success, and no request starts once the budget is + spent. `stream` follows Server-Sent Events with `--timeout` (total) and + `--idle-timeout` (silence, keep-alives reset it); `-o` downloads the assets + in every output format, and in `ndjson` the final `outcome` line carries the + download manifest. - Ctrl-C stops waiting or downloading (exit 130) and sends no DELETE; the envelope carries the task id and the command that resumes. @@ -332,7 +348,13 @@ Sources are `--task-json` (an API task, a legacy `meta.json`, or a v1 envelope), `--url`, or `--resource` + `--task-id`; selectors are `--asset ` (repeatable), `--model-format`, `--kind`, `--all`. With several assets and no selector the command lists the candidates and exits 2 instead of guessing. Selecting an OBJ -pulls its MTL and textures (`--geometry-only` to skip). Files are published +pulls its MTL and textures (`--geometry-only` to skip); once the set has landed +the OBJ's `mtllib` and the MTL's `map_*` references are rewritten to the names +actually saved (`model.mtl`, `texture_0_base_color.png`, …) so the model loads +from that directory — every link is listed under +`result.downloads.material_links`, rewritten files carry `relinked: true` with +their final sha256, and a reference that matches no downloaded file stays as +written and is warned (`material_reference_unresolved`). Files are published exclusively (never overwritten without `--overwrite`), checked against the content type and magic bytes, kept inside the output directory (or `--workspace`), and listed with size and sha256 in `result.downloads.files`. Asset hosts never @@ -371,7 +393,10 @@ limit you pass (`--max-faces` is required); a missing count is `unknown`, never 0, and a failing verdict only *describes* a remesh. `prepare-print` rotates a Y-up OBJ to Z-up, scales it to the target height, centres it on XY and rests it on Z=0, preserving faces, UVs, normals (rotated only) and material references; -it never overwrites without `--in-place`. `slicer open` launches only a +it never overwrites without `--in-place`, and the MTL/texture copies it makes +beside the output are proven — on real paths, before any directory is created — +to lie inside the output directory (or `--workspace`), so a symlinked +`materials/` cannot redirect them. `slicer open` launches only a registered slicer at its detected path with the file as a single argument — no shell, no default-application fallback; `launch_requested` is not proof that the import succeeded. @@ -403,7 +428,7 @@ message before any task is created. GLB-only fields (`uv-unwrap`, `rigging` | `--output-schema legacy\|v1` | Stdout data model (existing commands default to `legacy`; new commands are `v1`) | | `--format json\|pretty\|ndjson` | Stdout rendering (default `json`) | | `-o, --output ` | Download artifacts to a file/directory (task commands); output file for `mesh prepare-print` | -| `--workspace ` | Confine every written file to this directory | +| `--workspace ` | Confine every written file to this directory: `download`, `-o` on task verbs and `make`, `--save-json`, `--project`/project folders, `mesh prepare-print` outputs and their copied materials — checked on real paths before anything is created | | `--no-update-check` | Skip the background npm version check in this process | | `-v, --verbose` | Debug logging to stderr | | `--log-level ` | `debug \| info \| warn \| error \| silent` | diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index cd4e610..51f71dd 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -261,3 +261,111 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu frames) to the real stdout and capture only the CLI's string writes. Black-box behaviour is still asserted through `dist/index.js` subprocesses wherever exit codes or stderr matter. + +## D-029 Credential identity in the operation journal binds to the account + +- Codex review round 1 (F05): `credential_fingerprint` hashed only + source/profile/kind/origin, so two API keys exported through the same + `MESHY_API_KEY` were one identity and a repeated `--operation-id` replayed the + other account's task. It now includes a one-way digest of the API key + (`sha256("meshy-cli/credential-binding/v1|")`) for static keys, and the + stable OAuth subject (`user_id` of the stored profile) for browser logins — never + the access token, so a routine refresh keeps the identity while a different user + under the same profile name does not. A profile without `user_id` binds to the + profile name only (documented limitation). No key material is stored; the + conflict message names what differs (`result.conflict`). + +## D-030 Media content is part of the payload fingerprint + +- Review F06: data URIs were reduced to `;len=`, so two images of equal + encoded length collided and a changed picture reused the old task. The + fingerprint now hashes the *decoded bytes* of every data URI + (`data:;sha256=`): the same file re-inlined (even with different + base64 line wrapping) matches, different content of any length does not, and the + journal still never holds the content. The previous test asserting the collision + was wrong and was replaced. + +## D-031 The wait deadline bounds every request + +- Review F07: `pollUntilTerminal` passed only the abort signal to each GET and + judged the deadline after the response, so a reply arriving late could be + reported as an in-time SUCCEEDED and one more GET could start after the budget. + Each request now carries `timeoutMs = min(remaining budget, read timeout)`; a + deadline-bound request that times out *is* the timeout (exit 8), the sleep never + overshoots, and no request starts once the budget is spent. `PollResult.task` is + `null` only when no response arrived in time — the caller still knows the task id + and reports it (`result.task_id`, `result.next`, legacy `{id, timed_out:true}`). + `--timeout 0` keeps its single-query semantics bounded by the transport read + timeout, not by a zero budget. + +## D-032 `--workspace` is the root of every write, checked before the POST + +- Review F03: the v1 `-o` path handed the legacy downloader the output directory + as its own root, `project` ignored the flag, and `make` never looked at it. + `downloadArtifacts` now takes the workspace as root (directory, every planned + file and the sidecar are proven inside it before `mkdir`), `project + init/record/rebuild-index` confine `--root`/`--project`, task verbs confine + `--project`, `mesh prepare-print` confines its output and every copied + dependency (D-033), `download --project` confines the project. Without a + workspace the command's own root (output directory / project directory) applies + as before. `create` and `make` check `-o`, `--save-json` and `--project` *before* + the billable POST (containment, symlink leaf, existing file, initialised + project): a detectable conflict is exit 11 with "nothing was submitted" and zero + requests. + +## D-033 Dependency copies are proven inside the write root on real paths + +- Review F04: `copyDependency` only lstat'ed the leaf, so `/materials` + being a symlink to another directory let `materials/a.mtl` land outside the + workspace. Every planned copy target is now resolved with `resolveWithinRoot` + against the write root (workspace, else the output directory) — deepest existing + ancestor realpath'd, no symlink leaf — before any directory is created, and again + immediately before the copy is published. The report keeps the planned path + (beside the output) so `copied` stays consistent with `output`. + +## D-034 An accepted task survives every later failure; one submission state machine + +- Review F01/F02: after the server returned a task id, `--save-json` on an existing + file, a 503 while polling, and (in `make`) a journal write failure all produced + `result: null` or `submission_unknown`. Every post-acceptance step now runs in + the task's context (`withTaskContext`): the thrown error keeps its own + classification (code, HTTP status, hint, recovery, exit code) and its partial + result (files written, a failed download manifest), and always carries + `result.task_id`, `result.submission` and `result.next`. `make` uses the same + `submitCreate` primitive as the resource commands, so accepted / rejected / + unknown / journal-failure-after-acceptance (`local_io`, exit 11, id kept) are + decided in exactly one place. + +## D-035 Nested option objects merge field by field + +- Review F08: `mergePayload` is a shallow, later-wins merge (arrays and scalars + replace wholesale, by design), so `--options` replaced the whole + `--data.options` object and silently dropped the user's other settings before a + billable build. A resource may declare `nestedObjectKeys` (Creative Lab build: + `options`, `output`); those keys merge field by field across + defaults < `--data` < flags, typed flags win, explicit `false`/`0` survive, and + validation sees the combined object. Nothing else became a recursive merge. + +## D-036 Downloaded OBJ sets are relinked, not renamed + +- Review F09: the downloader saved `model.obj`/`model.mtl`/`texture__.png` + while the OBJ still said `mtllib box.mtl` and the MTL named the server's texture + files, so a complete download did not load. After a set has landed the CLI + rewrites `mtllib` to the saved MTL and each `map_*` reference to the saved + texture (exact name → channel word in the name → channel of the MTL key + (`map_Kd` → base color) → the only texture when there is exactly one reference); + what it cannot resolve stays as written and is reported + (`material_reference_unresolved`). Only the two text files the CLI just wrote are + touched, rewritten manifest entries carry `relinked: true` with their final + digest, and `result.downloads.material_links` lists every link. ZIP bundles + (keychain / fridge-magnet OBJ) and `--geometry-only` downloads are never + rewritten. Stable file names were kept over "preserve the server's names" so + scripts and the download manifest stay predictable. + +## D-037 `stream -o` downloads in every output format + +- Review F10: the ndjson branch printed the outcome and returned before the + download ran, so changing `--format` changed the command's side effects. The + download (and the project record) now happen before the format branch; the + ndjson `outcome` line carries the manifest, a download failure is one `ok:false` + outcome (exit code of the failure, task kept), and json/pretty behave the same. diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index ff9026a..b9bd735 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -115,6 +115,20 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | `rigging list` | usage error | works | see 2.9 | | everything else (flags, payload defaults, `-o` download layout, `meta.json`, auth) | unchanged | unchanged | legacy output preserved; v1 is opt-in | +### 3.1 Corrections from Codex review round 1 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| `create`/`make -o ` or `--save-json ` | refused after the task ran (exit 2 / 11) | refused **before** the POST, exit 11, "nothing was submitted" | F01 | +| failure after the server accepted a task (save, poll 5xx, download, record) | `result: null` / bare API error | same exit code, `result.task_id` + `submission` + `next` kept | F01, F02 | +| `wait`: reply arriving after `--timeout` | reported as in-time success; one extra GET possible | exit 8 (`timed_out`), no GET after the deadline | F07 | +| `--workspace` on `-o` (task verbs, `make`), `project`, `--project` | not enforced | exit 11 before any write | F03 | +| `mesh prepare-print` material copy through a symlinked `materials/` | written outside | exit 11, nothing written | F04 | +| `--operation-id` with another API key / account or another image of equal size | replayed the old task | `operation_conflict` (exit 2), no request | F05, F06 | +| Creative Lab `--data.options` + `--options` | `--options` replaced the object | merged field by field | F08 | +| downloaded OBJ/MTL references | pointed at server-side names | rewritten to the saved names, reported | F09 | +| `stream --format ndjson -o` | assets not downloaded | downloaded; `outcome` carries the manifest | F10 | + New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, `--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 1be178a..35a2a82 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -59,12 +59,19 @@ meshy list [--page-size ] --output-schema v1 - `create` blocks until the task is terminal; `--async` submits **exactly one POST** and returns `result.submission.task_id` — parse ids from stdout, never from text shown in chat. `wait ` polls (`--timeout 0` = one query, exit 8 on - timeout with the task kept); `stream --format ndjson` follows Server-Sent - Events (one line per event, last line `event:"outcome"`). + timeout with the last status kept; a reply that lands after the deadline is a + timeout, not a success); `stream --format ndjson` follows Server-Sent + Events (one line per event, last line `event:"outcome"`, which carries the `-o` + download manifest). - **Never re-run a create after exit 10** (`submission_unknown`): the request was sent and the server may have created the task. Run the `error.recovery.command` (`… list`) and reconcile first. Pass `--operation-id ` to a create so a - repeat of the same request replays the recorded outcome instead of billing again. + repeat of the same request replays the recorded outcome instead of billing again; + a different key/account, payload or image under the same id is refused (exit 2). +- **An error after `create` was accepted still names the task**: read + `result.task_id` / `result.submission.task_id` and `result.next` from any + non-zero exit (11 local I/O, 1 polling failure, 130 interrupt) before deciding + anything; never create again because a later step failed. - `--save-json ` stores the raw API task (use it as the input of `download`, `inspect faces` and `project record`); `--include-raw` puts the raw response under `result.task.raw`; `--project ` records the task in a project folder. diff --git a/src/cmd/creative-lab.ts b/src/cmd/creative-lab.ts index ffa7bb6..b355e6d 100644 --- a/src/cmd/creative-lab.ts +++ b/src/cmd/creative-lab.ts @@ -14,7 +14,10 @@ * * Build options are validated per product with the ranges the API documents * so a bad value fails before a billable POST; unknown option keys are passed - * through (the server is the final validator). + * through (the server is the final validator). `options` and `output` are + * declared as nested-merge keys, so `--data '{"options":{…}}'`, `--options` + * and the typed flags compose field by field (typed flags win) instead of the + * later layer deleting the earlier one's settings. */ import { Command, Option } from "commander"; @@ -183,6 +186,7 @@ function buildSpec(p: ProductDescriptor): ResourceCommandSpec { description: `Creative Lab ${p.label} — build stage: SUCCEEDED prototype → ${p.buildMeaning}`, create: { description: `Create a ${p.label} build from a prototype task created through this API`, + nestedObjectKeys: ["options", "output"], configure(cmd) { cmd .option("--input-task-id ", "SUCCEEDED prototype task of the same product created with the same API key (required)") @@ -247,18 +251,6 @@ function buildSpec(p: ProductDescriptor): ResourceCommandSpec { }; } -/** - * Deep-merge `--options`/`--data.options` objects: typed values win, unspecified - * keys survive, explicit false/0 are kept. mergePayload works on top-level - * keys only, so the nested object is merged here before validation. - */ -export function mergeOptionObjects(fromData: unknown, fromFlags: unknown): Record | undefined { - const a = fromData && typeof fromData === "object" && !Array.isArray(fromData) ? (fromData as Record) : undefined; - const b = fromFlags && typeof fromFlags === "object" && !Array.isArray(fromFlags) ? (fromFlags as Record) : undefined; - if (!a && !b) return undefined; - return { ...(a ?? {}), ...(b ?? {}) }; -} - function productCommand(product: CreativeLabProduct): Command { const p = PRODUCTS[product]; const cmd = new Command(product).description(`Creative Lab ${p.label}: prototype (${p.prototypeMeaning}) then build (${p.buildMeaning})`); diff --git a/src/cmd/download.ts b/src/cmd/download.ts index e9f3d99..3429741 100644 --- a/src/cmd/download.ts +++ b/src/cmd/download.ts @@ -22,7 +22,7 @@ import { emitResult, openCommand, saveRawJson, type OpenedCommand } from "../int import { abortSignal } from "../internal/context.js"; import { downloadAssets, type DownloadedFile } from "../internal/download.js"; import { CliError, UsageError, type Warning } from "../internal/errors.js"; -import { safeSegment } from "../internal/paths.js"; +import { resolveWithinRoot, safeSegment } from "../internal/paths.js"; import { warning } from "../internal/result.js"; import { recordTask, stageFromTaskType } from "../internal/project-store.js"; import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; @@ -121,7 +121,11 @@ export const downloadCommand = new Command("download") if (selectors.length > 1) throw new UsageError(`selectors are mutually exclusive (got ${selectors.map((s) => `--${s}`).join(", ")})`); if (opts.withDependencies && opts.geometryOnly) throw new UsageError("--with-dependencies and --geometry-only are mutually exclusive"); if (opened.flags.output && opts.outputDir) throw new UsageError("--output/-o and --output-dir are mutually exclusive"); - const projectDir = opts.project ? resolvePath(opts.project) : null; + const projectDir = opts.project + ? opened.flags.workspace + ? resolveWithinRoot(resolvePath(opts.project), opened.flags.workspace, { label: "--project" }).path + : resolvePath(opts.project) + : null; if (projectDir && !existsSync(join(projectDir, "metadata.json"))) { throw new UsageError(`--project ${opts.project} is not an initialised project (no metadata.json); run \`meshy project init\` first`); } @@ -278,7 +282,7 @@ export const downloadCommand = new Command("download") await emitResult(opened, null, { source: sourceInfo, selection: { selected: selected.map((a) => a.key), dependencies: dependencies.map((a) => a.key) }, - downloads: { state: result.complete ? "completed" : "partial", files: result.files, metadata_path: null }, + downloads: { state: result.complete ? "completed" : "partial", files: result.files, metadata_path: null, material_links: result.materialLinks }, unknown_urls: enumeration?.unknown_urls ?? [], saved_json: savedJson, project, diff --git a/src/cmd/make.ts b/src/cmd/make.ts index ee19bf5..88402b2 100644 --- a/src/cmd/make.ts +++ b/src/cmd/make.ts @@ -14,6 +14,11 @@ * someone else's credits on its own opinion. The resource commands remain the * way to compose anything else. * + * Submission goes through the same journal → single POST → journal primitive + * as the resource commands (`submitCreate`), so there is one state machine for + * accepted / rejected / unknown and a local journal failure after acceptance is + * local_io with the known task id — never "unknown". + * * Three ways to stop early, none of them a re-run: * --async submit step 1 and return immediately (no polling); * `pending_steps` lists what was not executed. @@ -23,7 +28,7 @@ */ import { Command, Option } from "commander"; -import { abortSignal } from "../internal/context.js"; +import { abortSignal, wasInterrupted } from "../internal/context.js"; import { CliError, HintedError, UsageError } from "../internal/errors.js"; import { parseInt10 } from "../internal/flags.js"; import { normalizeMediaPayload } from "../internal/file-input.js"; @@ -31,25 +36,22 @@ import { logger } from "../internal/logger.js"; import { planMake, type MakePlan, type MakeStep } from "../internal/make-plan.js"; import { emit, emitEnvelope } from "../internal/output.js"; import { emitResult, openCommand, type OpenedCommand } from "../internal/command-helpers.js"; -import { parseTimeoutSeconds, pollUntilTerminal } from "../internal/poll.js"; +import { downloadArtifacts } from "../internal/download.js"; +import { parseTimeoutSeconds, pollUntilTerminal, type PollResult } from "../internal/poll.js"; import { PRICING_DOCS } from "../internal/pricing.js"; import { okEnvelope, warning, type Warning } from "../internal/result.js"; import { buildRuntime, type Runtime } from "../internal/runtime.js"; -import { emitTerminalOutcome, buildTaskResult, taskNextCommands } from "../internal/task-command.js"; +import { + buildTaskResult, + emitTerminalOutcome, + preflightOutputPath, + submitCreate, + taskNextCommands, + wrapWithResult, +} from "../internal/task-command.js"; import { requireTaskResource } from "../client/resource-registry.js"; import type { Task } from "../client/types.js"; import type { TaskEndpoint } from "../client/endpoints/base.js"; -import { MeshyApiError } from "../client/errors.js"; -import { TransportError } from "../client/transport.js"; -import { - beginOperation, - credentialFingerprint, - newOperationId, - operationsRoot, - payloadFingerprint, - updateOperation, -} from "../internal/operation-store.js"; -import { originOf } from "../internal/config.js"; const DESCRIPTION = "Generate a 3D model in one command. A prompt runs the two-stage text flow " + @@ -140,6 +142,9 @@ function pendingSteps(plan: MakePlan, from: number, parentTaskId: string | null, async function runChain(plan: MakePlan, opts: MakeOptions, timeoutSeconds: number, runtime: Runtime, opened: OpenedCommand): Promise { const payloadFor = await buildPayloads(plan); + // -o is checked before the first billable request: an unwritable or + // out-of-workspace target must refuse while the run is still free. + if (runtime.flags.output) preflightOutputPath(runtime.flags.output, runtime.flags.workspace); const executed: ExecutedStep[] = []; const warnings: Warning[] = []; @@ -148,15 +153,22 @@ async function runChain(plan: MakePlan, opts: MakeOptions, timeoutSeconds: numbe for (const step of plan.steps) { const endpoint = endpointFor(step, runtime); + const descriptor = requireTaskResource(step.resource); const payload = payloadFor[step.index - 1]?.(completedTaskId) ?? {}; logger.debug(`make step ${step.index} payload`, payload); // The id is announced before polling starts: an interrupted run leaves a // task progressing server-side, and the caller needs its id to exist // somewhere other than this process's memory. - const { taskId, operationId } = await submitStep(runtime, step, endpoint, payload); + const submitted = await submitCreate(runtime, descriptor, endpoint, payload, null, { + label: `make: the ${step.label} request`, + extraResult: { step: step.index, route: plan.route, executed: [...executed] }, + }); + const { taskId, operationId } = submitted; + warnings.push(...submitted.warnings); announceStart(plan, step, taskId); executed.push({ step: step.index, resource: step.resource, action: step.action, task_id: taskId, status: null, operation_id: operationId }); + const submission = { state: "accepted", operation_id: operationId, task_id: taskId }; if (opts.async) { // Exactly one POST, zero polls: the caller owns the rest of the plan. @@ -166,13 +178,13 @@ async function runChain(plan: MakePlan, opts: MakeOptions, timeoutSeconds: numbe okEnvelope("make", { route: plan.route, submitted: { step: step.index, resource: step.resource, action: step.action, task_id: taskId, operation_id: operationId }, - submission: { state: "accepted", operation_id: operationId, task_id: taskId }, + submission, task: null, executed, pending_steps: pending, estimated_credits: plan.estimatedCredits, - next: taskNextCommands(requireTaskResource(step.resource), taskId), - }), + next: taskNextCommands(descriptor, taskId), + }, warnings), opened.format, ); return; @@ -194,14 +206,38 @@ async function runChain(plan: MakePlan, opts: MakeOptions, timeoutSeconds: numbe } const started = performance.now(); - const poll = await pollUntilTerminal(endpoint, taskId, { - timeoutSeconds, - intervalMs: runtime.config.pollIntervalMs, - signal: abortSignal(), - }); + let poll: PollResult; + try { + poll = await pollUntilTerminal(endpoint, taskId, { + timeoutSeconds, + intervalMs: runtime.config.pollIntervalMs, + requestTimeoutMs: runtime.config.readTimeoutMs, + signal: abortSignal(), + }); + } catch (err) { + const context = { + route: plan.route, + executed, + task_id: taskId, + submission, + task: null, + pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output), + next: taskNextCommands(descriptor, taskId), + }; + if (wasInterrupted() || abortSignal().aborted) { + throw new CliError({ + code: "interrupted", + message: `make: interrupted while waiting for ${step.label} (task ${taskId}); the server keeps running it`, + recovery: { action: "wait", automatic: false, command: `meshy ${step.resource} wait ${taskId}` }, + result: context, + }); + } + // A polling failure is not "no task": the id and the resume command travel with the error. + throw wrapWithResult(err, context); + } const { task, raw, timedOut, aborted } = poll; const elapsed = (performance.now() - started) / 1000; - executed[executed.length - 1]!.status = task.status; + executed[executed.length - 1]!.status = task?.status ?? null; announceOutcome(task, timedOut, elapsed); if (aborted) { @@ -209,12 +245,20 @@ async function runChain(plan: MakePlan, opts: MakeOptions, timeoutSeconds: numbe code: "interrupted", message: `make: interrupted while waiting for ${step.label} (task ${taskId}); the server keeps running it`, recovery: { action: "wait", automatic: false, command: `meshy ${step.resource} wait ${taskId}` }, - result: { route: plan.route, executed, task: buildTaskResult({ task, raw, descriptor: requireTaskResource(step.resource), includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: taskId } }).task, pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output) }, + result: { + route: plan.route, + executed, + task_id: taskId, + submission, + task: task ? buildTaskResult({ task, raw, descriptor, includeRaw: false, submission }).task : null, + pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output), + next: taskNextCommands(descriptor, taskId), + }, }); } - if (timedOut || task.status !== "SUCCEEDED") { - throw stepFailure(plan, step, task, raw, timedOut, completedTaskId, runtime, executed, opened); + if (timedOut || !task || task.status !== "SUCCEEDED") { + throw stepFailure(plan, step, task, taskId, raw, timedOut, completedTaskId, runtime, executed, opened); } if (step.index === plan.steps.length) { @@ -231,7 +275,7 @@ async function runChain(plan: MakePlan, opts: MakeOptions, timeoutSeconds: numbe okEnvelope("make", { route: plan.route, stopped_after: { step: step.index, resource: step.resource, action: step.action, task_id: task.id, status: task.status }, - task: buildTaskResult({ task, raw, descriptor: requireTaskResource(step.resource), includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: task.id } }).task, + task: buildTaskResult({ task, raw, descriptor, includeRaw: false, submission }).task, executed, pending_steps: pending, resume: resumeCommand(plan, task.id, runtime) ?? null, @@ -275,15 +319,15 @@ async function finalOutcome( const base = buildTaskResult({ task, raw, descriptor, includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: task.id } }); let downloads = base.downloads as Record; if (runtime.flags.output) { - const { downloadArtifacts } = await import("../internal/download.js"); try { - const { savedFiles, metadataPath } = await downloadArtifacts(task, runtime.flags.output, step.resource); - downloads = { state: "completed", files: savedFiles.map((p) => ({ path: p, status: "written" })), metadata_path: metadataPath }; + const { savedFiles, metadataPath, materialLinks } = await downloadArtifacts(task, runtime.flags.output, step.resource, { root: runtime.flags.workspace }); + if (materialLinks) warnings.push(...materialLinks.warnings); + downloads = { state: "completed", files: savedFiles.map((p) => ({ path: p, status: "written" })), metadata_path: metadataPath, material_links: materialLinks }; } catch (err) { throw new CliError({ - code: "local_io", + code: err instanceof CliError ? err.code : "local_io", message: `make finished (task ${task.id}) but downloading its assets failed: ${err instanceof Error ? err.message : String(err)}`, - result: { route: plan.route, executed, task: base.task, downloads: { state: "failed", files: [], metadata_path: null } }, + result: { route: plan.route, executed, task_id: task.id, task: base.task, submission: base.submission, downloads: { state: "failed", files: [], metadata_path: null }, next: taskNextCommands(descriptor, task.id) }, cause: err, }); } @@ -291,42 +335,6 @@ async function finalOutcome( await emitEnvelope(okEnvelope("make", { route: plan.route, executed, task: base.task, submission: base.submission, downloads, pending_steps: [] }, warnings), opened.format); } -/** Journal + single POST for one chain step; shares the journal contract with the resource commands. */ -async function submitStep(runtime: Runtime, step: MakeStep, endpoint: TaskEndpoint, payload: Record): Promise<{ taskId: string; operationId: string }> { - const descriptor = requireTaskResource(step.resource); - const root = operationsRoot(); - const operationId = newOperationId(); - const apiOrigin = originOf(endpoint.transportBaseUrl) ?? endpoint.transportBaseUrl; - beginOperation(root, operationId, { - resource: descriptor.id, - endpoint: descriptor.legacyEndpoint, - apiOrigin, - credentialFingerprint: credentialFingerprint({ source: runtime.config.credentialSource, profile: runtime.config.credentialProfile ?? null, origin: apiOrigin, kind: runtime.config.credentialKind }), - payloadFingerprint: payloadFingerprint(payload), - }); - try { - const created = await endpoint.createDetailed(payload, { signal: abortSignal() }); - updateOperation(root, operationId, { state: "accepted", task_id: created.taskId, request_id: created.requestId, http_status: 200 }); - return { taskId: created.taskId, operationId }; - } catch (err) { - const definite = err instanceof TransportError ? err.neverSent : err instanceof MeshyApiError && err.status >= 400 && err.status < 500; - const state = definite ? (err instanceof TransportError ? "not_submitted" : "rejected") : "unknown"; - try { - updateOperation(root, operationId, { state, error: err instanceof Error ? err.message : String(err), http_status: err instanceof MeshyApiError && err.status ? err.status : null }); - } catch { - /* journal failure is reported through the primary error */ - } - if (definite) throw err; - throw new CliError({ - code: "submission_unknown", - message: `make: the ${step.label} request was sent but its outcome is unknown (${err instanceof Error ? err.message : String(err)}); the server may have created a task`, - recovery: { action: "reconcile", automatic: false, command: `meshy ${step.resource} list --output-schema v1 # match operation ${operationId} before creating again` }, - result: { submission: { state: "unknown", operation_id: operationId, task_id: null }, task: null, step: step.index }, - cause: err, - }); - } -} - /** * One payload builder per step, taking the previous step's task id so the * chain stays a function of the plan plus what actually ran. @@ -389,7 +397,8 @@ export function resumeCommand( function stepFailure( plan: MakePlan, step: MakeStep, - task: Task, + task: Task | null, + taskId: string, raw: unknown, timedOut: boolean, completedTaskId: string, @@ -398,29 +407,30 @@ function stepFailure( opened: OpenedCommand, ): Error { const descriptor = requireTaskResource(step.resource); - const taskView = buildTaskResult({ task, raw, descriptor, includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: task.id } }).task; + const taskView = task ? buildTaskResult({ task, raw, descriptor, includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: taskId } }).task : null; const resume = resumeCommand(plan, completedTaskId, runtime); + const status = task?.status ?? "unknown"; if (opened.schema === "v1") { if (timedOut) { return new CliError({ code: "timed_out", - message: `make: ${step.label} did not finish within the timeout — task ${task.id} is still running`, - recovery: { action: "wait", automatic: false, command: `meshy ${step.resource} wait ${task.id}` }, - result: { route: plan.route, executed, task: taskView, pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output), resume: resume ?? null }, + message: `make: ${step.label} did not finish within the timeout — task ${taskId} is still running`, + recovery: { action: "wait", automatic: false, command: `meshy ${step.resource} wait ${taskId}` }, + result: { route: plan.route, executed, task_id: taskId, task: taskView, pending_steps: pendingSteps(plan, step.index, null, runtime.flags.output), resume: resume ?? null, next: taskNextCommands(descriptor, taskId) }, }); } return new CliError({ code: "task_failed", - message: task.task_error?.message || `make: ${step.label} ended as ${task.status} — task ${task.id}`, + message: task?.task_error?.message || `make: ${step.label} ended as ${status} — task ${taskId}`, recovery: resume ? { action: "resume", automatic: false, command: resume } : null, - result: { route: plan.route, executed, task: taskView, pending_steps: pendingSteps(plan, step.index, completedTaskId || null, runtime.flags.output), resume: resume ?? null }, + result: { route: plan.route, executed, task_id: taskId, task: taskView, pending_steps: pendingSteps(plan, step.index, completedTaskId || null, runtime.flags.output), resume: resume ?? null }, }); } if (timedOut) { return new HintedError({ - message: `make: ${step.label} did not finish within the timeout — task ${task.id} is still running`, + message: `make: ${step.label} did not finish within the timeout — task ${taskId} is still running`, code: "step_timeout", - hint: `meshy ${step.resource} wait ${task.id}`, + hint: `meshy ${step.resource} wait ${taskId}`, exitCode: 8, }); } @@ -429,8 +439,8 @@ function stepFailure( // than none. return new HintedError({ message: - task.task_error?.message || - `make: ${step.label} ended as ${task.status} — task ${task.id}`, + task?.task_error?.message || + `make: ${step.label} ended as ${status} — task ${taskId}`, code: "step_failed", ...(resume ? { hint: `${resume} # step ${step.index} failed; step ${step.index - 1} is kept` } : {}), }); @@ -460,8 +470,8 @@ function announceStart(plan: MakePlan, step: MakeStep, taskId: string): void { process.stderr.write(`[${step.index}/${plan.steps.length}] ${step.label} ${taskId}\n`); } -function announceOutcome(task: Task, timedOut: boolean, elapsed: number): void { - const mark = timedOut ? "timed out" : task.status === "SUCCEEDED" ? "ok" : task.status; +function announceOutcome(task: Task | null, timedOut: boolean, elapsed: number): void { + const mark = timedOut ? "timed out" : task ? (task.status === "SUCCEEDED" ? "ok" : task.status) : "no status received"; process.stderr.write(` ${mark} in ${elapsed.toFixed(0)}s\n`); } diff --git a/src/cmd/mesh.ts b/src/cmd/mesh.ts index 21b954d..ca3c8a8 100644 --- a/src/cmd/mesh.ts +++ b/src/cmd/mesh.ts @@ -61,11 +61,14 @@ const prepareCommand = new Command("prepare-print") outputPath = resolveWithinRoot(defaultOutputPath(resolvedInput.path), workspace, { cwd, label: "output" }).path; } + // The workspace (when given) is the root for the output *and* every + // copied material dependency; without one the output directory is. const report = await prepareObjForPrint(resolvedInput.path, { heightMm: opts.heightMm, outputPath, inPlace: opts.inPlace, geometryOnly: opts.geometryOnly, + root: workspace, }); await emitResult(opened, report, report, { warnings: report.warnings }); }, diff --git a/src/cmd/project.ts b/src/cmd/project.ts index 5fd92e0..934dea4 100644 --- a/src/cmd/project.ts +++ b/src/cmd/project.ts @@ -7,7 +7,9 @@ * list the history.json index reconciled with the folders on disk * rebuild-index regenerate history.json from the folders * - * Every subcommand is local: no API key, no network, no OAuth refresh. + * Every subcommand is local: no API key, no network, no OAuth refresh. With + * an explicit --workspace, every directory written (the root, the project) must + * resolve inside it — checked on real paths before anything is created. */ import { Command } from "commander"; @@ -16,6 +18,7 @@ import { join, resolve as resolvePath } from "node:path"; import { emitResult, openCommand } from "../internal/command-helpers.js"; import { UsageError } from "../internal/errors.js"; import { collect } from "../internal/flags.js"; +import { resolveWithinRoot } from "../internal/paths.js"; import { initProject, listProjects, readProject, rebuildIndex, recordTask } from "../internal/project-store.js"; import { warning, type Warning } from "../internal/result.js"; import { buildLocalRuntime } from "../internal/runtime.js"; @@ -26,6 +29,12 @@ function rootFrom(opts: { root?: string }, cwd = process.cwd()): string { return resolvePath(cwd, opts.root ?? DEFAULT_ROOT); } +/** With --workspace, a directory this command writes must resolve inside it (no symlink leaf, real paths). */ +function confine(path: string, workspace: string | undefined, label: string): string { + if (!workspace) return path; + return resolveWithinRoot(path, workspace, { label }).path; +} + const initCommand = new Command("init") .description("Create a project folder with metadata.json under the output root (default ./meshy_output)") .option("--root ", `output root (default: ./${DEFAULT_ROOT})`) @@ -35,7 +44,8 @@ const initCommand = new Command("init") .action(async (opts: { root?: string; name?: string; taskId?: string; taskType?: string }, thisCmd: Command) => { const opened = openCommand(thisCmd, "project.init", "v1"); buildLocalRuntime(opened.flags); - const res = initProject(rootFrom(opts), { name: opts.name, taskId: opts.taskId ?? null, taskType: opts.taskType ?? null }); + const root = confine(rootFrom(opts), opened.flags.workspace, "--root"); + const res = initProject(root, { name: opts.name, taskId: opts.taskId ?? null, taskType: opts.taskType ?? null }); const warnings: Warning[] = []; if (!res.index.updated) warnings.push(warning("index_dirty", `metadata.json written but history.json was not updated: ${res.index.error}; run \`meshy project rebuild-index --root ${res.root}\``)); await emitResult(opened, res, { root: res.root, project_dir: res.project_dir, folder: res.folder, metadata: res.metadata, index: res.index }, { warnings }); @@ -61,7 +71,7 @@ const recordCommand = new Command("record") ) => { const opened = openCommand(thisCmd, "project.record", "v1"); buildLocalRuntime(opened.flags); - const projectDir = resolvePath(opts.project); + const projectDir = confine(resolvePath(opts.project), opened.flags.workspace, "--project"); if (!existsSync(join(projectDir, "metadata.json"))) { throw new UsageError(`${projectDir} has no metadata.json; run \`meshy project init\` first`); } @@ -78,7 +88,7 @@ const recordCommand = new Command("record") taskJson: opts.taskJson ?? null, operationId: opts.operationId ?? null, }, - { root: opts.root ? resolvePath(opts.root) : undefined }, + { root: opts.root ? confine(resolvePath(opts.root), opened.flags.workspace, "--root") : undefined }, ); const warnings: Warning[] = []; const missing = (opts.file ?? []).filter((f) => !existsSync(join(projectDir, f))); @@ -118,7 +128,7 @@ const rebuildCommand = new Command("rebuild-index") .action(async (opts: { root?: string }, thisCmd: Command) => { const opened = openCommand(thisCmd, "project.rebuild-index", "v1"); buildLocalRuntime(opened.flags); - const res = rebuildIndex(rootFrom(opts)); + const res = rebuildIndex(confine(rootFrom(opts), opened.flags.workspace, "--root")); const warnings: Warning[] = res.skipped.map((s) => warning("project_skipped", `${s.folder}: ${s.reason}`)); await emitResult(opened, res, res, { warnings }); }); diff --git a/src/internal/atomic-file.ts b/src/internal/atomic-file.ts index 62b40fd..ffa99b3 100644 --- a/src/internal/atomic-file.ts +++ b/src/internal/atomic-file.ts @@ -60,6 +60,19 @@ function removeQuietly(path: string): void { } } +/** Create the target's directory; a parent that is a file (or unwritable) is a local_io error, not an internal one. */ +function ensureParentDir(target: string): void { + try { + mkdirSync(dirname(target), { recursive: true }); + } catch (err) { + throw new CliError({ + code: "local_io", + message: `cannot create directory ${dirname(target)}: ${err instanceof Error ? err.message : String(err)}`, + cause: err, + }); + } +} + export function refuseOverwriteError(target: string): CliError { return new CliError({ code: "local_io", @@ -137,7 +150,7 @@ export function writeJsonFile( value: unknown, opts: PublishOptions & { mode?: number } = {}, ): PublishResult { - mkdirSync(dirname(target), { recursive: true }); + ensureParentDir(target); const tmp = tempPathFor(target); try { writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: opts.mode ?? 0o600 }); @@ -154,7 +167,7 @@ export function writeJsonFile( /** Copy an existing file to `target` under the same publish rules. */ export function copyFilePublished(source: string, target: string, opts: PublishOptions = {}): PublishResult { - mkdirSync(dirname(target), { recursive: true }); + ensureParentDir(target); const tmp = tempPathFor(target); try { copyFileSync(source, tmp); diff --git a/src/internal/config.ts b/src/internal/config.ts index 70584fd..627d54c 100644 --- a/src/internal/config.ts +++ b/src/internal/config.ts @@ -62,6 +62,12 @@ export interface MeshyConfig { * Derived from the stored profile kind when credentialSource is "file". */ credentialKind: CredentialKind; + /** + * Stable account subject for an OAuth profile (user id), used to bind the + * operation journal to the account rather than to a rotating token. Unset for + * API keys (the key itself is digested) and for profiles without a user id. + */ + credentialSubject?: string; } export interface ConfigOverrides { @@ -157,6 +163,7 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { let credentialSource: CredentialSource = "flag"; let credentialProfile: string | undefined; let credentialKind: CredentialKind = "api_key"; + let credentialSubject: string | undefined; if (!PLACEHOLDER_KEYS.has(flagKey)) { apiKey = flagKey; @@ -186,6 +193,7 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { credentialSource = "file"; credentialProfile = stored.profile; credentialKind = stored.kind; + credentialSubject = stored.userId; } else { throw authRequiredError( "No credentials found. Pass --api-key, export MESHY_API_KEY, or log in.", @@ -208,6 +216,7 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { envFilePath, credentialsFile: credFile, credentialKind, + credentialSubject, }; setLogLevel(cfg.logLevel); diff --git a/src/internal/credentials.ts b/src/internal/credentials.ts index b83a182..c81f5c3 100644 --- a/src/internal/credentials.ts +++ b/src/internal/credentials.ts @@ -319,6 +319,8 @@ export interface ResolvedStoredCredential { apiKey?: string; accessToken?: string; expiresAt?: number; + /** Stable account subject of an OAuth profile, when the token endpoint reported one. */ + userId?: string; } /** @@ -341,6 +343,7 @@ export function resolveStoredCredential(file: string): ResolvedStoredCredential kind: "oauth", accessToken: profile.access_token, expiresAt: profile.expires_at, + ...(profile.user_id ? { userId: profile.user_id } : {}), }; } diff --git a/src/internal/download.ts b/src/internal/download.ts index 8ad0b28..e221ee0 100644 --- a/src/internal/download.ts +++ b/src/internal/download.ts @@ -12,9 +12,12 @@ * * Two entry points share the core: * downloadArtifacts — the 0.2.0 `-o` behaviour (all artifacts, role-based - * names, meta.json sidecar); output shape unchanged. + * names, meta.json sidecar); output shape unchanged. An + * explicit `root` (the --workspace) confines every path. * downloadAssets — the selective downloader behind `meshy download` and * the v1 manifest. + * Both relink OBJ → MTL → texture references after the set has landed (see + * material-links.ts), so a saved OBJ loads with the files beside it. */ import { createHash } from "node:crypto"; @@ -31,6 +34,7 @@ import { logger } from "./logger.js"; import { isInside, realpathLenient, resolveWithinRoot, safeExtension, safeSegment } from "./paths.js"; import { USER_AGENT } from "./user-agent.js"; import type { Asset } from "./artifacts.js"; +import { fileDigest, relinkMaterials, type MaterialLinkReport } from "./material-links.js"; /** Extensions sharp can transcode between. */ const CONVERTIBLE_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "gif", "tiff", "tif", "avif"]); @@ -301,6 +305,8 @@ export interface DownloadedFile { status: "written" | "failed" | "skipped"; error: string | null; publish_method: string | null; + /** True when the file's material references were rewritten to the saved names (OBJ/MTL only). */ + relinked: boolean; } export interface DownloadAssetsOptions extends FetchOptions { @@ -323,6 +329,8 @@ export interface DownloadAssetsResult { /** True when every requested asset was written. */ complete: boolean; warnings: Array<{ code: string; message: string }>; + /** OBJ/MTL/texture reference report when the set contained a text OBJ. */ + materialLinks: MaterialLinkReport | null; } function plannedName(asset: Asset, targetFile: string | undefined): string { @@ -362,7 +370,7 @@ export async function downloadAssets(assets: readonly Asset[], opts: DownloadAss const target = resolveWithinRoot(planned.endsWith(".json") ? planned : `${planned}.json`, rootReal, { label: "report path" }).path; const res = writeJsonFile(target, asset.report, { overwrite: opts.overwrite ?? false }); const bytes = statSync(target).size; - entry = { key: asset.key, path: target, relative_path: rel(rootReal, target), bytes, sha256: createHash("sha256").update(readFileSync(target)).digest("hex"), content_type: "application/json", format: "json", container_format: null, extracted: null, status: "written", error: null, publish_method: res.method }; + entry = { key: asset.key, path: target, relative_path: rel(rootReal, target), bytes, sha256: createHash("sha256").update(readFileSync(target)).digest("hex"), content_type: "application/json", format: "json", container_format: null, extracted: null, status: "written", error: null, publish_method: res.method, relinked: false }; } else { if (!asset.url) throw new CliError({ code: "validation", message: `asset ${asset.key} has no URL` }); let url = asset.url; @@ -385,7 +393,7 @@ export async function downloadAssets(assets: readonly Asset[], opts: DownloadAss } } catch (err) { const message = err instanceof Error ? err.message : String(err); - const failed: DownloadedFile = { key: asset.key, path: planned, relative_path: rel(rootReal, planned), bytes: 0, sha256: "", content_type: null, format: asset.format, container_format: asset.containerFormat, extracted: null, status: "failed", error: message, publish_method: null }; + const failed: DownloadedFile = { key: asset.key, path: planned, relative_path: rel(rootReal, planned), bytes: 0, sha256: "", content_type: null, format: asset.format, container_format: asset.containerFormat, extracted: null, status: "failed", error: message, publish_method: null, relinked: false }; files.push(failed); opts.onFile?.(failed); const code = err instanceof CliError ? err.code : "local_io"; @@ -401,7 +409,25 @@ export async function downloadAssets(assets: readonly Asset[], opts: DownloadAss files.push(entry); opts.onFile?.(entry); } - return { files, complete: files.every((f) => f.status === "written"), warnings }; + const materialLinks = await relinkWritten(files, warnings); + return { files, complete: files.every((f) => f.status === "written"), warnings, materialLinks }; +} + +/** After the set landed: point OBJ → MTL → textures at the saved names and re-take the digests of rewritten files. */ +async function relinkWritten(files: DownloadedFile[], warnings: Array<{ code: string; message: string }>): Promise { + const written = files.filter((f) => f.status === "written" && f.container_format === null); + const links = await relinkMaterials(written.map((f) => ({ key: f.key, path: f.path }))); + if (!links) return null; + for (const path of links.rewritten) { + const entry = files.find((f) => f.path === path); + if (!entry) continue; + const digest = fileDigest(path); + entry.bytes = digest.bytes; + entry.sha256 = digest.sha256; + entry.relinked = true; + } + warnings.push(...links.warnings); + return links; } function rel(root: string, path: string): string | null { @@ -478,6 +504,7 @@ async function placeFetched( status: "written", error: null, publish_method: res.method, + relinked: false, }; } @@ -594,12 +621,20 @@ export function looksLikeFile(path: string): boolean { export interface DownloadResult { savedFiles: string[]; metadataPath: string; + /** OBJ/MTL/texture reference report when the artifacts contained a text OBJ. */ + materialLinks: MaterialLinkReport | null; +} + +export interface DownloadArtifactsOptions { + /** Authorised root (the --workspace); every directory and file must resolve inside it. Default: the output directory itself. */ + root?: string; } export async function downloadArtifacts( task: Task, outputPath: string, resource: string, + opts: DownloadArtifactsOptions = {}, ): Promise { const artifacts = enumerateArtifacts(task); if (artifacts.length === 0) { @@ -649,8 +684,16 @@ export async function downloadArtifacts( ); } + // An explicit workspace is the root for everything written here — the + // directory, every planned file and the sidecar — checked before mkdir. + const workspaceReal = opts.root !== undefined ? realpathLenient(resolvePath(opts.root)) : null; + if (workspaceReal) { + resolveWithinRoot(targetDir, workspaceReal, { label: "output directory" }); + for (const p of [...plannedArtifactPaths, metadataPath]) resolveWithinRoot(p, workspaceReal, { label: "planned download path" }); + } + mkdirSync(targetDir, { recursive: true }); - const root = realpathLenient(targetDir); + const root = workspaceReal ?? realpathLenient(targetDir); const saved: string[] = []; if (singleFileMode) { saved.push(await downloadArtifact(artifacts[0]!, outputPath, root)); @@ -660,8 +703,10 @@ export async function downloadArtifacts( saved.push(await downloadArtifact(artifact, targetPath, root)); } } + const materialLinks = await relinkMaterials(artifacts.map((a, i) => ({ key: a.key, path: resolvePath(saved[i]!) }))); + for (const w of materialLinks?.warnings ?? []) logger.warn(w.message); writeMeta(task, resource, metadataPath, saved); - return { savedFiles: saved, metadataPath }; + return { savedFiles: saved, metadataPath, materialLinks }; } function deriveFilename(artifact: Artifact): string { @@ -752,7 +797,7 @@ function saveReportOnly(task: Task, outputPath: string, resource: string): Downl `${JSON.stringify({ resource, task, downloaded_at: new Date().toISOString() }, null, 2)}\n`, "utf8", ); - return { savedFiles: [], metadataPath: outputPath }; + return { savedFiles: [], metadataPath: outputPath, materialLinks: null }; } const metadataPath = join(outputPath, "meta.json"); if (existsSync(metadataPath)) { @@ -763,7 +808,7 @@ function saveReportOnly(task: Task, outputPath: string, resource: string): Downl } mkdirSync(outputPath, { recursive: true }); writeMeta(task, resource, metadataPath, []); - return { savedFiles: [], metadataPath }; + return { savedFiles: [], metadataPath, materialLinks: null }; } function writeMeta(task: Task, resource: string, path: string, savedFiles: string[]): void { diff --git a/src/internal/material-links.ts b/src/internal/material-links.ts new file mode 100644 index 0000000..683a478 --- /dev/null +++ b/src/internal/material-links.ts @@ -0,0 +1,381 @@ +/** + * Material relinking for downloaded OBJ sets. + * + * Meshy serves an OBJ, its MTL and the textures as separate URLs and the CLI + * saves them under stable names (model.obj, model.mtl, texture_0_base_color.png + * …). The OBJ, however, names its MTL the way the server knew it (`mtllib + * box.mtl`) and the MTL names its textures the same way, so a faithfully + * downloaded set can still be unloadable. Once every file of a set has landed, + * this module rewrites those references to the names actually on disk and + * reports every link it resolved — and every one it could not. Only the two + * text files the CLI itself just wrote are touched; nothing is renamed, the + * rewritten paths are listed, and their digests are re-taken by the caller. + * + * Texture references are matched in this order: the exact saved file name, a + * channel word in the referenced name (…_normal.png), the MTL key's channel + * (map_Kd → base color), and finally "the only texture there is" when the MTL + * has exactly one distinct reference. Anything else stays as written and is + * reported as `material_reference_unresolved`. + */ + +import { createHash } from "node:crypto"; +import { closeSync, createReadStream, createWriteStream, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs"; +import { once } from "node:events"; +import { basename } from "node:path"; +import { finished } from "node:stream/promises"; +import { StringDecoder } from "node:string_decoder"; +import { publishTempFile, tempPathFor } from "./atomic-file.js"; +import { CliError, type Warning } from "./errors.js"; +import { warning } from "./result.js"; + +export interface LinkableFile { + /** Stable asset key (`model.obj`, `model_mtl`, `texture.0.base_color`, `texture_0_normal` …). */ + key: string; + /** Absolute path as written. */ + path: string; +} + +export interface ReferenceLink { + /** 1-based line in the file that carried the reference. */ + line: number; + /** The reference as written before relinking. */ + reference: string; + /** Saved file name the reference now points at, or null when it could not be resolved. */ + resolved_to: string | null; + /** How the link was decided. */ + method: "unchanged" | "exact" | "channel_in_name" | "channel_of_key" | "only_texture" | "downloaded_mtl" | "unresolved"; +} + +export interface MaterialLinkReport { + obj: string; + mtl: string | null; + textures: string[]; + mtllib: ReferenceLink[]; + texture_maps: ReferenceLink[]; + /** Absolute paths whose content was rewritten. */ + rewritten: string[]; + warnings: Warning[]; +} + +const WS = /\s+/; +const TEXTURE_KEY_RE = /^(?:map_[A-Za-z0-9_]+|bump|disp|decal|refl|norm)$/i; +/** MTL files larger than this are not what Meshy produces; leave them alone. */ +const MAX_MTL_BYTES = 16 * 1024 * 1024; + +const CHANNEL_SYNONYMS: Record = { + basecolor: "basecolor", + albedo: "basecolor", + diffuse: "basecolor", + color: "basecolor", + colour: "basecolor", + metallic: "metallic", + metalness: "metallic", + metal: "metallic", + roughness: "roughness", + rough: "roughness", + normal: "normal", + normals: "normal", + nrm: "normal", + bump: "normal", + emissive: "emissive", + emission: "emissive", + emit: "emissive", + occlusion: "occlusion", + ao: "occlusion", + ambientocclusion: "occlusion", + opacity: "opacity", + alpha: "opacity", + transparency: "opacity", + displacement: "displacement", + height: "displacement", + disp: "displacement", + specular: "specular", + spec: "specular", +}; + +const MAP_KEY_CHANNEL: Record = { + map_kd: "basecolor", + map_ka: "basecolor", + map_pm: "metallic", + map_pr: "roughness", + norm: "normal", + map_bump: "normal", + bump: "normal", + map_kn: "normal", + map_ke: "emissive", + map_d: "opacity", + disp: "displacement", + map_ks: "specular", + map_ao: "occlusion", +}; + +function canonicalChannel(raw: string): string | null { + const compact = raw.toLowerCase().replace(/[^a-z0-9]/g, ""); + return CHANNEL_SYNONYMS[compact] ?? null; +} + +/** Channel encoded in an asset key: `texture.0.base_color` / `texture_0_base_color`. */ +export function channelOfTextureKey(key: string): string | null { + const m = /^texture[._](\d+)[._](.+)$/.exec(key); + return m ? canonicalChannel(m[2]!) : null; +} + +/** Channel word inside a referenced file name (`texture_normal.png`, `Body_BaseColor.jpg`). */ +export function channelInFileName(name: string): string | null { + const stem = basename(name).replace(/\.[A-Za-z0-9]+$/, ""); + const tokens = stem.split(/[^A-Za-z0-9]+|(?<=[a-z])(?=[A-Z])/).filter(Boolean); + for (let i = 0; i < tokens.length; i++) { + const pair = i + 1 < tokens.length ? canonicalChannel(`${tokens[i]}${tokens[i + 1]}`) : null; + if (pair) return pair; + const single = canonicalChannel(tokens[i]!); + if (single) return single; + } + const compact = stem.toLowerCase().replace(/[^a-z0-9]/g, ""); + for (const word of Object.keys(CHANNEL_SYNONYMS).sort((a, b) => b.length - a.length)) { + if (word.length >= 5 && compact.includes(word)) return CHANNEL_SYNONYMS[word]!; + } + return null; +} + +function keywordOf(t: string): string { + const m = WS.exec(t); + return m ? t.slice(0, m.index) : t; +} + +function looksBinary(path: string): boolean { + const fd = openSync(path, "r"); + try { + const buf = Buffer.alloc(512); + const n = readSync(fd, buf, 0, 512, 0); + const head = buf.subarray(0, n); + if (n >= 2 && head[0] === 0x50 && head[1] === 0x4b) return true; // PK: a ZIP bundle, not a text OBJ + return head.includes(0); + } finally { + closeSync(fd); + } +} + +/** sha256 + size of a file, for manifests that must describe what is actually on disk. */ +export function fileDigest(path: string): { bytes: number; sha256: string } { + const data = readFileSync(path); + return { bytes: data.length, sha256: createHash("sha256").update(data).digest("hex") }; +} + +/** + * Rewrite `path` line by line through a temp file in the same directory. The + * callback returns the replacement line (without its terminator) or null to + * keep the line. Returns true when the file was actually replaced. + */ +async function rewriteLines(path: string, transform: (line: string, lineNo: number) => string | null): Promise { + const tmp = tempPathFor(path); + const out = createWriteStream(tmp, { flags: "wx" }); + let writeError: Error | null = null; + out.on("error", (err) => { + writeError = err; + }); + let changed = false; + let buf = ""; + const flush = async (): Promise => { + if (writeError) throw writeError; + if (buf.length === 0) return; + const chunk = buf; + buf = ""; + if (!out.write(chunk)) await once(out, "drain"); + if (writeError) throw writeError; + }; + const decoder = new StringDecoder("utf8"); + let carry = ""; + let lineNo = 0; + const emitLine = (text: string, eol: string): void => { + lineNo += 1; + const next = transform(text, lineNo); + if (next !== null && next !== text) { + changed = true; + buf += next + eol; + } else { + buf += text + eol; + } + }; + try { + const stream = createReadStream(path, { highWaterMark: 256 * 1024 }); + for await (const chunk of stream) { + carry += decoder.write(chunk as Buffer); + let start = 0; + let nl: number; + while ((nl = carry.indexOf("\n", start)) !== -1) { + let end = nl; + let eol = "\n"; + if (end > start && carry.charCodeAt(end - 1) === 13) { + end -= 1; + eol = "\r\n"; + } + emitLine(carry.slice(start, end), eol); + start = nl + 1; + } + carry = start > 0 ? carry.slice(start) : carry; + await flush(); + } + carry += decoder.end(); + if (carry.length > 0) emitLine(carry, ""); + await flush(); + out.end(); + await finished(out); + } catch (err) { + out.destroy(); + try { + unlinkSync(tmp); + } catch { + /* gone */ + } + if (err instanceof CliError) throw err; + throw new CliError({ code: "local_io", message: `failed to rewrite ${path}: ${err instanceof Error ? err.message : String(err)}`, cause: err }); + } + if (!changed) { + try { + unlinkSync(tmp); + } catch { + /* gone */ + } + return false; + } + publishTempFile(tmp, path, { overwrite: true }); + return true; +} + +function isObjKey(key: string): boolean { + return key === "model.obj" || key === "model_obj"; +} + +function isMtlKey(key: string): boolean { + return key === "model.mtl" || key === "model_mtl"; +} + +function isTextureKey(key: string): boolean { + return /^texture[._]\d+[._]/.test(key); +} + +/** Split an MTL map line into indentation, key, options and the referenced file. */ +function parseMapLine(line: string): { indent: string; key: string; options: string; ref: string } | null { + const indent = /^\s*/.exec(line)?.[0] ?? ""; + const t = line.trim(); + if (!t || t.startsWith("#")) return null; + const key = keywordOf(t); + if (!TEXTURE_KEY_RE.test(key)) return null; + const rest = t.slice(key.length).trim(); + if (!rest) return null; + if (rest.startsWith("-")) { + const tokens = rest.split(WS); + return { indent, key, options: tokens.slice(0, -1).join(" "), ref: tokens.at(-1)! }; + } + return { indent, key, options: "", ref: rest }; +} + +/** + * Relink the OBJ/MTL/texture files of one download. Returns null when the set + * has no text OBJ (nothing to relink). Never throws for unresolved references; + * those become warnings and `resolved_to: null` entries. + */ +export async function relinkMaterials(files: readonly LinkableFile[]): Promise { + const obj = files.find((f) => isObjKey(f.key) && /\.obj$/i.test(f.path)); + if (!obj) return null; + if (looksBinary(obj.path)) return null; + const mtl = files.find((f) => isMtlKey(f.key)) ?? null; + const textures = files.filter((f) => isTextureKey(f.key)); + const report: MaterialLinkReport = { + obj: obj.path, + mtl: mtl?.path ?? null, + textures: textures.map((t) => t.path), + mtllib: [], + texture_maps: [], + rewritten: [], + warnings: [], + }; + + // --- OBJ: every mtllib points at the MTL that was actually saved. + const mtlName = mtl ? basename(mtl.path) : null; + const objChanged = await rewriteLines(obj.path, (line, lineNo) => { + const t = line.trim(); + if (!t || keywordOf(t) !== "mtllib") return null; + const ref = t.slice("mtllib".length).trim(); + if (!ref) return null; + if (!mtlName) { + report.mtllib.push({ line: lineNo, reference: ref, resolved_to: null, method: "unresolved" }); + return null; + } + if (ref === mtlName) { + report.mtllib.push({ line: lineNo, reference: ref, resolved_to: mtlName, method: "unchanged" }); + return null; + } + report.mtllib.push({ line: lineNo, reference: ref, resolved_to: mtlName, method: "downloaded_mtl" }); + const indent = /^\s*/.exec(line)?.[0] ?? ""; + return `${indent}mtllib ${mtlName}`; + }); + if (objChanged) report.rewritten.push(obj.path); + if (!mtlName && report.mtllib.length > 0) { + report.warnings.push( + warning( + "material_reference_unresolved", + `${basename(obj.path)} references ${report.mtllib.map((l) => `'${l.reference}'`).join(", ")} but no MTL was downloaded with it; the geometry loads without materials`, + ), + ); + } + + // --- MTL: every map_* points at a texture that was actually saved. + if (mtl) { + let size = 0; + try { + size = statSync(mtl.path).size; + } catch { + size = 0; + } + if (size > MAX_MTL_BYTES) { + report.warnings.push(warning("material_reference_unresolved", `${basename(mtl.path)} is ${size} bytes; too large for an MTL, texture references were not checked`)); + return report; + } + const textureNames = textures.map((t) => basename(t.path)); + const byChannel = new Map(); + for (const t of textures) { + const ch = channelOfTextureKey(t.key); + if (ch && !byChannel.has(ch)) byChannel.set(ch, basename(t.path)); + } + const distinctRefs = new Set(); + for (const raw of readFileSync(mtl.path, "utf8").split(/\r?\n/)) { + const parsed = parseMapLine(raw); + if (parsed) distinctRefs.add(parsed.ref); + } + const resolve = (key: string, ref: string): { name: string; method: ReferenceLink["method"] } | null => { + const refBase = basename(ref.replaceAll("\\", "/")); + const exact = textureNames.find((n) => n === refBase) ?? textureNames.find((n) => n.toLowerCase() === refBase.toLowerCase()); + if (exact) return { name: exact, method: exact === ref ? "unchanged" : "exact" }; + const inName = channelInFileName(refBase); + if (inName && byChannel.has(inName)) return { name: byChannel.get(inName)!, method: "channel_in_name" }; + const ofKey = MAP_KEY_CHANNEL[key.toLowerCase()]; + if (ofKey && byChannel.has(ofKey)) return { name: byChannel.get(ofKey)!, method: "channel_of_key" }; + if (textureNames.length === 1 && distinctRefs.size === 1) return { name: textureNames[0]!, method: "only_texture" }; + return null; + }; + const mtlChanged = await rewriteLines(mtl.path, (line, lineNo) => { + const parsed = parseMapLine(line); + if (!parsed) return null; + const hit = resolve(parsed.key, parsed.ref); + if (!hit) { + report.texture_maps.push({ line: lineNo, reference: parsed.ref, resolved_to: null, method: "unresolved" }); + return null; + } + report.texture_maps.push({ line: lineNo, reference: parsed.ref, resolved_to: hit.name, method: hit.method }); + if (hit.name === parsed.ref) return null; + return `${parsed.indent}${parsed.key}${parsed.options ? ` ${parsed.options}` : ""} ${hit.name}`; + }); + if (mtlChanged) report.rewritten.push(mtl.path); + const unresolved = report.texture_maps.filter((l) => l.resolved_to === null); + if (unresolved.length > 0) { + report.warnings.push( + warning( + "material_reference_unresolved", + `${basename(mtl.path)} references ${[...new Set(unresolved.map((l) => `'${l.reference}'`))].join(", ")} which ${textures.length === 0 ? "were not downloaded" : "match none of the downloaded textures"}; those maps stay as written`, + ), + ); + } + } + return report; +} diff --git a/src/internal/obj-transform.ts b/src/internal/obj-transform.ts index d90a131..3fa387a 100644 --- a/src/internal/obj-transform.ts +++ b/src/internal/obj-transform.ts @@ -25,7 +25,11 @@ * When the output lands in another directory they are copied alongside it; a * missing or escaping dependency is a validation error there unless the caller * asked for `geometryOnly`, because a "success" with a silently broken material - * is worse than a refusal. No env, no credentials, no network. + * is worse than a refusal. Every copy target is proven to lie inside the write + * root (`root`, i.e. the --workspace, else the output directory) on its *real* + * path before any directory is created or byte copied, and again right before + * publication — a symlinked `materials/` inside the target cannot redirect a + * copy outside. No env, no credentials, no network. */ import { @@ -49,7 +53,7 @@ import { finished } from "node:stream/promises"; import { StringDecoder } from "node:string_decoder"; import { copyFilePublished, publishTempFile, tempPathFor } from "./atomic-file.js"; import { CliError, UsageError, type Warning } from "./errors.js"; -import { isInside, realpathLenient } from "./paths.js"; +import { isInside, realpathLenient, resolveWithinRoot } from "./paths.js"; import { warning } from "./result.js"; export type Vec3 = [number, number, number]; @@ -117,6 +121,12 @@ export interface PrepareObjOptions { geometryOnly?: boolean; /** Refuse inputs larger than this many bytes (default 2 GiB). */ maxBytes?: number; + /** + * Authorised root for every write (the output and each copied dependency). + * Defaults to the output's directory; pass the --workspace to confine writes + * to it. Checked on real paths, so symlinked parents cannot escape it. + */ + root?: string; } export function rotateYUpToZUp(v: Vec3): Vec3 { @@ -445,7 +455,8 @@ export function textureReferencesInMtl(text: string): string[] { } interface MaterialPlan { - copies: Array<{ ref: string; source: string; target: string }>; + /** `target` is the path as planned (beside the output); `real` is its proven location inside the write root. */ + copies: Array<{ ref: string; source: string; target: string; real: string }>; missing: string[]; warnings: Warning[]; } @@ -479,7 +490,7 @@ function planMaterials( const target = join(outputDir, relative(inputReal, real)); if (targets.has(target)) return; targets.add(target); - plan.copies.push({ ref, source: real, target }); + plan.copies.push({ ref, source: real, target, real: target }); }; for (const written of refs) { @@ -646,6 +657,15 @@ export async function prepareObjForPrint(inputPath: string, opts: PrepareObjOpti const wantCopies = crossDir && !geometryOnly; const plan = planMaterials(scan.mtllib, inputDir, outputDir, wantCopies); const warnings: Warning[] = [...plan.warnings]; + + // Every path written by this run must resolve inside the write root: the + // output itself and each dependency copy, checked on real paths before any + // directory is created. A `materials/` symlink pointing elsewhere fails here. + const writeRoot = realpathLenient(opts.root !== undefined ? resolvePath(opts.root) : outputDir); + if (!inPlace) resolveWithinRoot(output, writeRoot, { label: "output" }); + for (const copy of plan.copies) { + copy.real = resolveWithinRoot(copy.target, writeRoot, { label: `material dependency target for '${copy.ref}'` }).path; + } if (wantCopies && plan.missing.length > 0) { throw validation( `material dependencies of ${input} cannot be carried to ${outputDir}: ${plan.missing.join(", ")} — the output would reference files that are not there. ` + @@ -689,7 +709,9 @@ export async function prepareObjForPrint(inputPath: string, opts: PrepareObjOpti renameSync(tmp, output); } else { for (const copy of plan.copies) { - if (copyDependency(copy.source, copy.target) === "copied") { + // Re-proven at publication time: the tree may have changed since planning. + copy.real = resolveWithinRoot(copy.target, writeRoot, { label: `material dependency target for '${copy.ref}'` }).path; + if (copyDependency(copy.source, copy.real) === "copied") { copied.push(copy.target); } else { warnings.push( @@ -697,6 +719,7 @@ export async function prepareObjForPrint(inputPath: string, opts: PrepareObjOpti ); } } + resolveWithinRoot(output, writeRoot, { label: "output" }); publishTempFile(tmp, output); } } catch (err) { diff --git a/src/internal/operation-store.ts b/src/internal/operation-store.ts index 88fd64b..ac732de 100644 --- a/src/internal/operation-store.ts +++ b/src/internal/operation-store.ts @@ -9,9 +9,18 @@ * instead of submitting again when the request fingerprints match, and refuses * with `operation_conflict` when they do not. * + * Identity of a request = resource + API origin + credential fingerprint + + * payload fingerprint. The credential fingerprint binds to the actual account: + * a keyed digest of the API key, or the stable OAuth subject (user id) — never + * the rotating access token, so a routine refresh is still the same identity + * while a different key under the same env variable is not. The payload + * fingerprint hashes media *content* (decoded bytes of every data URI), so two + * different images of the same size never collide. + * * This is a local record only. It is not a server-side idempotency key and it * cannot guarantee the server did not bill a request whose response was lost. - * Nothing secret is stored: no key material, no base64 media, no signed URLs. + * Nothing secret is stored: no key material, no base64 media, no signed URLs — + * only one-way digests. */ import { createHash, randomUUID } from "node:crypto"; @@ -64,27 +73,76 @@ export function newOperationId(): string { return randomUUID(); } -/** `sha256(source|profile|origin)` — enough to detect a different identity, never reversible to a key. */ -export function credentialFingerprint(parts: { source: string; profile?: string | null; origin: string; kind?: string }): string { - return sha256(`${parts.source}|${parts.profile ?? ""}|${parts.kind ?? ""}|${parts.origin}`); +export interface CredentialIdentityParts { + /** Where the credential came from: flag | env | env-file | file. */ + source: string; + /** Stored profile name (credentialSource === "file"). */ + profile?: string | null; + /** API origin the credential is used against. */ + origin: string; + /** api_key | oauth. */ + kind?: string; + /** The static API key itself (api_key kinds). Digested with a domain prefix; never stored. */ + secret?: string | null; + /** Stable account subject for OAuth profiles (user id). Tokens rotate; the subject does not. */ + subject?: string | null; +} + +const CREDENTIAL_DIGEST_DOMAIN = "meshy-cli/credential-binding/v1"; + +/** + * `sha256(source|profile|kind|origin|binding)` where the binding is a keyed + * digest of the API key, or the OAuth subject. Two different keys from the same + * source therefore have different fingerprints; a refreshed OAuth token keeps + * its fingerprint as long as the account is the same. Never reversible to a key. + */ +export function credentialFingerprint(parts: CredentialIdentityParts): string { + let binding: string; + if (parts.kind === "oauth") { + binding = parts.subject ? `subject:${parts.subject}` : "subject:unknown"; + } else if (parts.secret) { + binding = `key:${sha256(`${CREDENTIAL_DIGEST_DOMAIN}|${parts.secret}`)}`; + } else { + binding = "key:none"; + } + return sha256(`${parts.source}|${parts.profile ?? ""}|${parts.kind ?? ""}|${parts.origin}|${binding}`); } /** - * Canonical JSON with data URIs reduced to their MIME and length so two - * submissions of the same local file match while the journal never holds the - * file content. + * Canonical JSON with every data URI replaced by `data:;sha256=`: two submissions of the same file match (whatever the + * base64 line wrapping), two different files of equal size do not, and the + * journal never holds the content itself. */ export function payloadFingerprint(payload: unknown): string { return sha256(canonical(payload)); } +export function dataUriDigest(uri: string): string { + const comma = uri.indexOf(","); + const header = comma === -1 ? uri.slice(5) : uri.slice(5, comma); + const payload = comma === -1 ? "" : uri.slice(comma + 1); + const mime = (header.split(";")[0] ?? "").toLowerCase(); + const isBase64 = /(^|;)base64$/i.test(header) || /;base64(;|$)/i.test(header); + let bytes: Buffer; + if (isBase64) { + bytes = Buffer.from(payload.replace(/\s+/g, ""), "base64"); + } else { + let text = payload; + try { + text = decodeURIComponent(payload); + } catch { + /* keep the raw payload */ + } + bytes = Buffer.from(text, "utf8"); + } + return `data:${mime};sha256=${createHash("sha256").update(bytes).digest("hex")}`; +} + function canonical(v: unknown): string { if (v === null || typeof v !== "object") { - if (typeof v === "string" && v.startsWith("data:")) { - const semi = v.indexOf(";"); - const comma = v.indexOf(","); - const mime = v.slice(5, semi === -1 ? comma : Math.min(semi, comma === -1 ? v.length : comma)); - return JSON.stringify(`data:${mime};len=${v.length}`); + if (typeof v === "string" && /^data:/i.test(v)) { + return JSON.stringify(dataUriDigest(v)); } return JSON.stringify(v); } @@ -131,16 +189,16 @@ export function beginOperation(root: string, operationId: string, identity: Oper return withFileLock(lockPath(root), () => { const existing = readOperation(root, operationId); if (existing) { - if ( - existing.resource !== identity.resource || - existing.api_origin !== identity.apiOrigin || - existing.credential_fingerprint !== identity.credentialFingerprint || - existing.payload_fingerprint !== identity.payloadFingerprint - ) { + const differs: string[] = []; + if (existing.resource !== identity.resource) differs.push("resource"); + if (existing.api_origin !== identity.apiOrigin) differs.push("origin"); + if (existing.credential_fingerprint !== identity.credentialFingerprint) differs.push("credential"); + if (existing.payload_fingerprint !== identity.payloadFingerprint) differs.push("payload"); + if (differs.length > 0) { throw new CliError({ code: "operation_conflict", - message: `operation ${operationId} already exists for a different request (resource/origin/credential/payload differ); nothing was submitted`, - result: { submission: { state: existing.state, operation_id: operationId, task_id: existing.task_id } }, + message: `operation ${operationId} already exists for a different request (${differs.join(", ")} differ); nothing was submitted — use a new --operation-id for a new request`, + result: { submission: { state: existing.state, operation_id: operationId, task_id: existing.task_id }, conflict: differs }, }); } return { outcome: "existing", record: existing }; diff --git a/src/internal/payload.ts b/src/internal/payload.ts index 3044789..a8c3a8f 100644 --- a/src/internal/payload.ts +++ b/src/internal/payload.ts @@ -2,6 +2,13 @@ * Build a request payload from structured CLI flags and optional raw JSON. * Merge order (later wins): CLI defaults < --data < flag-supplied fields — * pinned defaults must never clobber an explicit --data payload. + * + * Merging is shallow on purpose: arrays and plain fields replace wholesale so a + * `--data` array is exactly what is sent. The one exception is the small set of + * nested option objects a resource declares (`nestedObjectKeys`, e.g. Creative + * Lab `options`/`output`): those are merged field by field across the same + * layers, so `--data '{"options":{...}}'` and `--options '{...}'` compose + * instead of the later one silently deleting the earlier one's settings. */ import { readFileSync } from "node:fs"; @@ -50,3 +57,34 @@ export function mergePayload(...layers: Record[]): Record { + return Boolean(v) && typeof v === "object" && !Array.isArray(v); +} + +/** + * Field-level merge for the declared nested object keys, applied on top of a + * shallow `mergePayload` result. Layers are ordered weakest → strongest; within + * a key, a later layer's fields win, unspecified fields survive, and explicit + * `false`/`0`/`""` are kept. If the strongest layer that names the key is not + * an object (a deliberate replacement), the shallow result stands. + */ +export function mergeNestedObjects( + merged: Record, + layers: readonly Record[], + keys: readonly string[], +): Record { + const out: Record = { ...merged }; + for (const key of keys) { + const present = layers.filter((l) => l[key] !== undefined && l[key] !== null); + if (present.length === 0) continue; + const strongest = present[present.length - 1]!; + if (!isPlainObject(strongest[key])) continue; + const combined: Record = {}; + for (const layer of present) { + if (isPlainObject(layer[key])) Object.assign(combined, dropNullish(layer[key])); + } + out[key] = combined; + } + return out; +} diff --git a/src/internal/poll.ts b/src/internal/poll.ts index c7b6930..78b2444 100644 --- a/src/internal/poll.ts +++ b/src/internal/poll.ts @@ -2,18 +2,26 @@ * Poll a task endpoint until it reaches a terminal status or the deadline * hits. Backoff grows 1.5× per iteration up to a 20s cap. * - * The deadline is monotonic, the sleep is cancellable, and one slow response - * consumes the same budget as any other second — a `--timeout 0` performs - * exactly one query and returns whatever status it saw. + * The deadline is monotonic and binds everything: every GET is issued with a + * request timeout no larger than the remaining budget (headers *and* body), + * the cancellable sleep never overshoots it, and no request is started once it + * has passed. A response that arrives after the budget therefore cannot be + * reported as an in-time success, and the loop never spends "one more GET" + * after the caller's deadline. `--timeout 0` is the one exception by design: + * exactly one query, bounded by the transport's own read timeout, returning + * whatever status it saw. */ import type { TaskEndpoint } from "../client/endpoints/base.js"; +import { TransportError } from "../client/transport.js"; import { isTerminalStatus, type Task } from "../client/types.js"; import { UsageError } from "./errors.js"; export interface PollOptions { timeoutSeconds: number; intervalMs: number; + /** Transport read timeout; each request is capped at min(remaining budget, this). */ + requestTimeoutMs?: number; onTick?: (task: Task, raw: unknown) => void; signal?: AbortSignal; /** Test hook: injectable clock and sleep. */ @@ -22,7 +30,11 @@ export interface PollOptions { } export interface PollResult { - task: Task; + /** + * The last task seen. `null` only when the deadline or the abort signal hit + * before the first response arrived — the caller still knows the task id. + */ + task: Task | null; raw: unknown; timedOut: boolean; polls: number; @@ -62,24 +74,62 @@ export async function pollUntilTerminal( ): Promise { const now = opts.now ?? (() => performance.now()); const sleep = opts.sleep ?? sleepWithSignal; + const single = opts.timeoutSeconds === 0; const deadline = now() + Math.max(0, opts.timeoutSeconds) * 1000; const baseInterval = Math.max(250, opts.intervalMs); let interval = baseInterval; const cap = Math.max(baseInterval, 20_000); let polls = 0; + let last: { task: Task; raw: unknown } | null = null; + + const done = (timedOut: boolean, aborted: boolean): PollResult => ({ + task: last?.task ?? null, + raw: last?.raw ?? null, + timedOut, + polls, + aborted, + }); for (;;) { - const { task, raw } = await endpoint.retrieveDetailed(taskId, { signal: opts.signal }); - polls += 1; - opts.onTick?.(task, raw); - if (isTerminalStatus(task.status)) { - return { task, raw, timedOut: false, polls, aborted: false }; - } - if (opts.signal?.aborted) return { task, raw, timedOut: false, polls, aborted: true }; const remaining = deadline - now(); - if (remaining <= 0) return { task, raw, timedOut: true, polls, aborted: false }; - await sleep(Math.min(interval, Math.max(remaining, 100)), opts.signal); - if (opts.signal?.aborted) return { task, raw, timedOut: false, polls, aborted: true }; + if (!single && polls > 0 && remaining <= 0) return done(true, false); + if (opts.signal?.aborted) return done(false, true); + + // Bound this request by what is left of the budget (and the transport's own + // read timeout, whichever is smaller). A deadline-bound request that times + // out *is* the deadline; a read-timeout-bound one is a network failure. + const requestCap = opts.requestTimeoutMs; + let timeoutMs: number | undefined; + let deadlineBound = false; + if (single) { + timeoutMs = requestCap; + } else if (requestCap === undefined || remaining <= requestCap) { + timeoutMs = Math.max(1, Math.ceil(remaining)); + deadlineBound = true; + } else { + timeoutMs = requestCap; + } + + let res: { task: Task; raw: unknown }; + try { + res = await endpoint.retrieveDetailed(taskId, { signal: opts.signal, timeoutMs }); + } catch (err) { + if (opts.signal?.aborted) return done(false, true); + if (deadlineBound && err instanceof TransportError && (err.phase === "timeout" || err.phase === "aborted")) { + return done(true, false); + } + throw err; + } + polls += 1; + last = res; + opts.onTick?.(res.task, res.raw); + if (isTerminalStatus(res.task.status)) return done(false, false); + if (opts.signal?.aborted) return done(false, true); + if (single) return done(true, false); + const left = deadline - now(); + if (left <= 0) return done(true, false); + await sleep(Math.max(1, Math.min(interval, left)), opts.signal); + if (opts.signal?.aborted) return done(false, true); interval = Math.min(cap, Math.floor(interval * 1.5)); } } diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index 9a5aa5f..bcea46a 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -14,6 +14,12 @@ * - every create is exactly one POST, journaled before it is sent; * - a lost or malformed response is `submission_unknown` (exit 10), never a * retry and never a "please run it again" hint; + * - local targets that would fail after the POST (an existing --save-json + * file, an -o path outside the workspace, a missing --project) are checked + * before it, so a detectable conflict costs zero requests; + * - once the server has accepted a task, every later failure — saving JSON, + * polling, downloading, recording, a signal — still reports that task id, + * the submission record and the `get`/`wait` commands that resume it; * - SIGINT stops waiting/streaming (exit 130) and never deletes anything. */ @@ -24,15 +30,16 @@ import type { MeshyClient } from "../client/index.js"; import { requireTaskResource, type TaskResourceDescriptor } from "../client/resource-registry.js"; import { TransportError } from "../client/transport.js"; import { isTerminalStatus, summarizeTask, type Task } from "../client/types.js"; -import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson, type OpenedCommand } from "./command-helpers.js"; +import { emitResult, openCommand, rejectOutputFlagForV1, saveRawJson, type OpenedCommand, type SavedJson } from "./command-helpers.js"; import { abortSignal, wasInterrupted } from "./context.js"; -import { downloadArtifacts } from "./download.js"; -import { CliError, UsageError, type Warning } from "./errors.js"; +import { downloadArtifacts, looksLikeFile } from "./download.js"; +import { classifyError, CliError, UsageError, type Warning } from "./errors.js"; import { normalizeMediaPayload } from "./file-input.js"; import { logger } from "./logger.js"; -import { mergePayload, parseJsonFlag } from "./payload.js"; +import type { MaterialLinkReport } from "./material-links.js"; +import { mergeNestedObjects, mergePayload, parseJsonFlag } from "./payload.js"; import { emitEnvelope, emitStreamEvent, emit } from "./output.js"; -import { parseTimeoutSeconds, pollUntilTerminal } from "./poll.js"; +import { parseTimeoutSeconds, pollUntilTerminal, type PollResult } from "./poll.js"; import { printReport } from "./report.js"; import { errorEnvelope, okEnvelope, warning, type StreamEventEnvelope } from "./result.js"; import { buildRuntime, type Runtime } from "./runtime.js"; @@ -49,9 +56,10 @@ import { type OperationRecord, } from "./operation-store.js"; import { originOf } from "./config.js"; +import { resolveWithinRoot } from "./paths.js"; import { recordTask, saveTaskSnapshot, stageFromTaskType } from "./project-store.js"; import { existsSync } from "node:fs"; -import { join, resolve as resolvePath } from "node:path"; +import { dirname, join, resolve as resolvePath } from "node:path"; export interface CreateSpec { description: string; @@ -65,6 +73,12 @@ export interface CreateSpec { * not throw (validation belongs in toPayload). */ toDefaults?(opts: Record): Record; + /** + * Payload keys holding nested option objects that must merge field by field + * across defaults < --data < flags (e.g. Creative Lab `options`, `output`). + * Everything else merges shallowly: arrays and scalars replace wholesale. + */ + nestedObjectKeys?: readonly string[]; /** * Validate the merged payload (defaults < --data < flags) before media is * normalised and before anything is sent. Throw UsageError / CliError. @@ -95,10 +109,19 @@ const TASK_JSON_OPTIONS = (cmd: Command): Command => .option("--project ", "initialised meshy_output project: save task_.json there and record the task in metadata.json") .option("--stage ", "stage label for the project record (default: derived from the task type, e.g. preview | refine | build)"); -interface DownloadOutcome { +export interface DownloadOutcome { state: "not_requested" | "not_ready" | "completed" | "failed"; files: Array<{ path: string; status: "written" }>; metadata_path: string | null; + /** OBJ/MTL/texture reference report when the download contained a text OBJ. */ + material_links?: MaterialLinkReport | null; +} + +export interface SubmissionInfo { + state: string; + operation_id: string | null; + task_id?: string | null; + request_id?: string | null; } interface TaskResultOptions { @@ -106,17 +129,19 @@ interface TaskResultOptions { raw: unknown; descriptor: TaskResourceDescriptor; includeRaw: boolean; - submission: { state: string; operation_id: string | null; task_id?: string | null; request_id?: string | null }; + submission: SubmissionInfo; downloads?: DownloadOutcome; - savedJson?: { path: string; bytes: number } | null; + savedJson?: SavedJson | null; extra?: Record; } +const NOT_REQUESTED = (): DownloadOutcome => ({ state: "not_requested", files: [], metadata_path: null }); + function taskResult(o: TaskResultOptions): Record { return { task: o.task ? toTaskView(o.raw ?? o.task, { descriptor: o.descriptor, includeRaw: o.includeRaw }) : null, submission: o.submission, - downloads: o.downloads ?? { state: "not_requested", files: [], metadata_path: null }, + downloads: o.downloads ?? NOT_REQUESTED(), saved_json: o.savedJson ?? null, ...(o.extra ?? {}), }; @@ -131,6 +156,93 @@ function nextCommands(descriptor: TaskResourceDescriptor, taskId: string): Recor }; } +/** + * What every failure after the server accepted a task must still say: which + * task, what the submission record is, and how to pick it up again. + */ +interface TaskContext { + descriptor: TaskResourceDescriptor; + taskId: string; + task?: Task | null; + raw?: unknown; + submission: SubmissionInfo; + includeRaw?: boolean; + savedJson?: SavedJson | null; + extra?: Record; +} + +/** + * Re-throw any error as a CliError whose `result` carries `result` — keeping + * the original classification (code, HTTP status, hint, recovery, exit code). + * A CliError's own partial result (files written so far, a failed download + * manifest) is merged on top, so nothing already known is lost. + */ +export function wrapWithResult(err: unknown, result: Record): CliError { + if (err instanceof CliError) { + return new CliError({ + code: err.code, + message: err.message, + exitCode: err.exitCode, + httpStatus: err.httpStatus, + retryable: err.retryable, + recovery: err.recovery, + hint: err.hint, + details: err.details, + warnings: err.warnings, + result: { ...result, ...(err.result ?? {}) }, + cause: err, + }); + } + const c = classifyError(err); + return new CliError({ + code: c.code, + message: c.message, + exitCode: c.exitCode, + httpStatus: c.httpStatus, + retryable: c.retryable, + recovery: c.recovery, + hint: c.hint, + details: c.details, + warnings: c.warnings, + result: { ...result, ...(c.result ?? {}) }, + cause: err, + }); +} + +/** `wrapWithResult` with the task result shape: the id, submission and next commands always survive. */ +function withTaskContext(err: unknown, ctx: TaskContext): CliError { + const next = nextCommands(ctx.descriptor, ctx.taskId); + const base = taskResult({ + task: ctx.task ?? null, + raw: ctx.raw ?? null, + descriptor: ctx.descriptor, + includeRaw: Boolean(ctx.includeRaw), + submission: ctx.submission, + savedJson: ctx.savedJson ?? null, + extra: { task_id: ctx.taskId, next, ...(ctx.extra ?? {}) }, + }); + let own: Record = {}; + if (err instanceof CliError && err.result) { + own = { ...err.result }; + // A bookkeeping error's `task: null` must not erase a task we do know. + if (own["task"] === null && ctx.task) delete own["task"]; + } + const wrapped = wrapWithResult(err, base); + return new CliError({ + code: wrapped.code, + message: wrapped.message, + exitCode: wrapped.exitCode, + httpStatus: wrapped.httpStatus, + retryable: wrapped.retryable, + recovery: wrapped.recovery, + hint: wrapped.hint, + details: wrapped.details, + warnings: wrapped.warnings, + result: { ...base, ...own, task_id: ctx.taskId, next }, + cause: err, + }); +} + interface ProjectAttachment { project_dir: string; snapshot: string | null; @@ -147,6 +259,19 @@ function parentTaskIdFromPayload(payload: Record | null): strin return null; } +/** Resolve --project: an initialised project directory, inside the workspace when one is set. */ +function resolveProjectDir(projectFlag: string, workspace: string | undefined, cwd = process.cwd()): string { + const projectDir = resolvePath(cwd, projectFlag); + if (!existsSync(join(projectDir, "metadata.json"))) { + throw new CliError({ + code: "local_io", + message: `--project ${projectFlag} is not an initialised project (no metadata.json); run \`meshy project init\` first`, + }); + } + if (workspace) resolveWithinRoot(projectDir, workspace, { cwd, label: "--project" }); + return projectDir; +} + /** * --project: snapshot the task (when a full task is known) and record it. * Failures keep the task id in the error result — a bookkeeping problem must @@ -154,6 +279,7 @@ function parentTaskIdFromPayload(payload: Record | null): strin */ function attachToProject( opts: Record, + opened: OpenedCommand, descriptor: TaskResourceDescriptor, taskId: string, task: Task | null, @@ -163,14 +289,7 @@ function attachToProject( ): ProjectAttachment | null { const projectFlag = opts.project as string | undefined; if (!projectFlag) return null; - const projectDir = resolvePath(projectFlag); - if (!existsSync(join(projectDir, "metadata.json"))) { - throw new CliError({ - code: "local_io", - message: `--project ${projectFlag} is not an initialised project (no metadata.json); run \`meshy project init\` first`, - result: { task_id: taskId, task: task ? toTaskView(raw ?? task, { descriptor }) : null }, - }); - } + const projectDir = resolveProjectDir(projectFlag, opened.flags.workspace); const stage = (opts.stage as string | undefined) ?? (typeof extra.payload?.["mode"] === "string" ? (extra.payload["mode"] as string) : descriptor.creativeLab?.stage ?? stageFromTaskType(task?.type, descriptor.id)); try { const snapshot = task && raw ? saveTaskSnapshot(projectDir, taskId, raw) : null; @@ -190,16 +309,88 @@ function attachToProject( if (rec.migrated_from_legacy) warnings.push(warning("metadata_migrated", "legacy metadata.json migrated to schema_version 2 (backup kept beside it)")); return { project_dir: projectDir, snapshot: snapshot?.path ?? null, stage, action: rec.action, index: rec.index }; } catch (err) { - if (err instanceof CliError && err.result) throw err; throw new CliError({ - code: "local_io", + code: err instanceof CliError ? err.code : "local_io", message: `task ${taskId} exists but recording it in ${projectDir} failed: ${err instanceof Error ? err.message : String(err)}`, - result: { task_id: taskId, task: task ? toTaskView(raw ?? task, { descriptor }) : null, submission: { state: "accepted", operation_id: extra.operationId ?? null, task_id: taskId } }, cause: err, }); } } +/** --save-json inside the task's context: a full disk or a vanished directory never hides the task id. */ +function saveJsonInContext(opts: Record, opened: OpenedCommand, raw: unknown, ctx: TaskContext): SavedJson | null { + if (!opts.saveJson) return null; + try { + return saveRawJson(String(opts.saveJson), raw, { workspace: opened.flags.workspace }); + } catch (err) { + throw withTaskContext(err, ctx); + } +} + +function attachInContext( + opts: Record, + opened: OpenedCommand, + descriptor: TaskResourceDescriptor, + taskId: string, + task: Task | null, + raw: unknown, + extra: { operationId?: string | null; payload?: Record | null; files?: string[] }, + warnings: Warning[], + ctx: TaskContext, +): ProjectAttachment | null { + try { + return attachToProject(opts, opened, descriptor, taskId, task, raw, extra, warnings); + } catch (err) { + throw withTaskContext(err, ctx); + } +} + +// --------------------------------------------------------------------------- +// Pre-submission checks: anything local that would fail *after* a billable POST +// and can be detected now is refused now, with "nothing was submitted". +// --------------------------------------------------------------------------- + +/** `-o` target: inside the workspace (or its own directory), no symlink leaf, not an existing file. */ +export function preflightOutputPath(output: string, workspace: string | undefined, cwd = process.cwd()): void { + const abs = resolvePath(cwd, output); + resolveWithinRoot(abs, workspace ?? dirname(abs), { cwd, label: "--output" }); + if (looksLikeFile(abs) && existsSync(abs)) { + throw new CliError({ + code: "local_io", + message: `--output ${output} already exists; choose another path (nothing was submitted)`, + recovery: { action: "choose_path", automatic: false }, + }); + } +} + +/** `--save-json` target: inside the workspace (or its own directory), no symlink leaf, not an existing file. */ +export function preflightSaveJsonPath(target: string, workspace: string | undefined, cwd = process.cwd()): void { + const abs = resolvePath(cwd, target); + const resolved = resolveWithinRoot(abs, workspace ?? dirname(abs), { cwd, label: "--save-json target" }); + if (existsSync(resolved.path)) { + throw new CliError({ + code: "local_io", + message: `--save-json target ${target} already exists; choose another path (nothing was submitted)`, + recovery: { action: "choose_path", automatic: false }, + }); + } +} + +function preflightLocalTargets(opts: Record, opened: OpenedCommand): void { + if (opts.saveJson) preflightSaveJsonPath(String(opts.saveJson), opened.flags.workspace); + if (opened.flags.output) preflightOutputPath(opened.flags.output, opened.flags.workspace); + if (opts.project) { + try { + resolveProjectDir(String(opts.project), opened.flags.workspace); + } catch (err) { + if (err instanceof CliError) { + throw new CliError({ code: err.code, message: `${err.message} (nothing was submitted)`, recovery: err.recovery, cause: err }); + } + throw err; + } + } +} + export function buildResourceCommand(spec: ResourceCommandSpec): Command { const descriptor = requireTaskResource(spec.name); const endpointOf = spec.endpointOf ?? ((client: MeshyClient) => client.endpointFor(descriptor)); @@ -229,6 +420,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { const opened = openCommand(thisCmd, `${prefix}.create`, defaultSchema); const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); const runAsync = Boolean(opts.async); + const includeRaw = Boolean(opts.includeRaw); const runtime = await buildRuntime(opened.flags); const endpoint = endpointOf(runtime.client); @@ -236,18 +428,27 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { const data = parseJsonFlag(opts.data as string | undefined, "--data"); const flagPayload = spec.create.toPayload(opts); const defaults = spec.create.toDefaults?.(opts) ?? {}; - const merged = mergePayload(defaults, data, flagPayload); + const layers = [defaults, data, flagPayload]; + let merged = mergePayload(...layers); + if (spec.create.nestedObjectKeys && spec.create.nestedObjectKeys.length > 0) { + merged = mergeNestedObjects(merged, layers, spec.create.nestedObjectKeys); + } spec.create.validatePayload?.(merged, opts); const { payload } = await normalizeMediaPayload(merged, descriptor.mediaFields, { signal: abortSignal() }); logger.debug("create payload", redactForLog(payload)); - // 2. Journal, then exactly one POST. - const submitted = await submitOnce(runtime, descriptor, endpoint, payload, (opts.operationId as string | undefined) ?? null, opened); + // 2. Local targets that would fail after the POST are refused before it. + preflightLocalTargets(opts, opened); + + // 3. Journal, then exactly one POST. + const submitted = await submitCreate(runtime, descriptor, endpoint, payload, (opts.operationId as string | undefined) ?? null); const { taskId, raw: createRaw, operationId, warnings } = submitted; + const submission: SubmissionInfo = { state: "accepted", operation_id: operationId, task_id: taskId, request_id: submitted.requestId }; if (runAsync) { - const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, createRaw, { workspace: opened.flags.workspace }) : null; - const project = attachToProject(opts, descriptor, taskId, null, null, { operationId, payload }, warnings); + const ctx: TaskContext = { descriptor, taskId, task: null, raw: null, submission, includeRaw }; + const savedJson = saveJsonInContext(opts, opened, createRaw, ctx); + const project = attachInContext(opts, opened, descriptor, taskId, null, null, { operationId, payload }, warnings, { ...ctx, savedJson }); if (opened.schema === "v1") { await emitEnvelope( okEnvelope( @@ -257,7 +458,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { raw: null, descriptor, includeRaw: false, - submission: { state: "accepted", operation_id: operationId, task_id: taskId, request_id: submitted.requestId }, + submission, savedJson, extra: { task_id: taskId, next: nextCommands(descriptor, taskId), project }, }), @@ -281,13 +482,8 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { return; } - // 3. Sync: poll the id we just recorded. - await waitAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, { ...opts, __payload: payload, __operationId: operationId }, { - state: "accepted", - operation_id: operationId, - task_id: taskId, - request_id: submitted.requestId, - }, warnings); + // 4. Sync: poll the id we just recorded. + await waitAndReport(opened, runtime, descriptor, endpoint, taskId, timeoutSeconds, { ...opts, __payload: payload, __operationId: operationId }, submission, warnings); }); cmd.addCommand(createCmd); @@ -297,13 +493,16 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { const opened = openCommand(thisCmd, `${prefix}.get`, defaultSchema); const runtime = await buildRuntime(opened.flags); const { task, raw } = await endpointOf(runtime.client).retrieveDetailed(taskId, { signal: abortSignal() }); - const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspace }) : null; + const submission: SubmissionInfo = { state: "accepted", operation_id: null }; + const includeRaw = Boolean(opts.includeRaw); const warnings: Warning[] = []; - const project = attachToProject(opts, descriptor, taskId, task, raw, {}, warnings); + const ctx: TaskContext = { descriptor, taskId, task, raw, submission, includeRaw }; + const savedJson = saveJsonInContext(opts, opened, raw, ctx); + const project = attachInContext(opts, opened, descriptor, taskId, task, raw, {}, warnings, { ...ctx, savedJson }); if (opened.schema === "v1") { - const downloads = await maybeDownloadV1(opened, task, spec.name); + const downloads = await maybeDownloadV1(opened, descriptor, task, raw, submission, warnings, { savedJson, includeRaw, project }); await emitEnvelope( - okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw: Boolean(opts.includeRaw), submission: { state: "accepted", operation_id: null }, downloads, savedJson, extra: project ? { project } : {} }), warnings), + okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw, submission, downloads, savedJson, extra: project ? { project } : {} }), warnings), opened.format, ); return; @@ -416,7 +615,7 @@ function redactForLog(payload: Record): Record return out; } -interface Submitted { +export interface Submitted { taskId: string; raw: unknown; requestId: string | null; @@ -424,18 +623,39 @@ interface Submitted { warnings: Warning[]; } +export interface SubmitContext { + /** Names the request in messages, e.g. "make: the text-to-3d preview (geometry) request". */ + label?: string; + /** Extra keys carried in every failure result (e.g. `{ step: 1 }`). */ + extraResult?: Record; +} + +/** The credential identity a submission is journaled under: bound to the key digest or the OAuth subject. */ +export function credentialIdentityFor(runtime: Runtime, apiOrigin: string): string { + return credentialFingerprint({ + source: runtime.config.credentialSource, + profile: runtime.config.credentialProfile ?? null, + origin: apiOrigin, + kind: runtime.config.credentialKind, + secret: runtime.config.credentialKind === "api_key" ? runtime.config.apiKey : null, + subject: runtime.config.credentialSubject ?? null, + }); +} + /** * Journal → single POST → journal update. Every failure path leaves a record * that says what is known; the unknown state is reported as exit 10 with the - * operation id and never as a suggestion to submit again. + * operation id and never as a suggestion to submit again. Shared by the + * resource commands and `make`, so there is exactly one submission state + * machine. */ -async function submitOnce( +export async function submitCreate( runtime: Runtime, descriptor: TaskResourceDescriptor, endpoint: TaskEndpoint, payload: Record, requestedOperationId: string | null, - opened: OpenedCommand, + ctx: SubmitContext = {}, ): Promise { const root = operationsRoot(); const operationId = requestedOperationId ?? newOperationId(); @@ -444,14 +664,11 @@ async function submitOnce( resource: descriptor.id, endpoint: descriptor.legacyEndpoint, apiOrigin, - credentialFingerprint: credentialFingerprint({ - source: runtime.config.credentialSource, - profile: runtime.config.credentialProfile ?? null, - origin: apiOrigin, - kind: runtime.config.credentialKind, - }), + credentialFingerprint: credentialIdentityFor(runtime, apiOrigin), payloadFingerprint: payloadFingerprint(payload), }; + const label = ctx.label ?? "the create request"; + const extra = ctx.extraResult ?? {}; const warnings: Warning[] = []; let begin: ReturnType; @@ -463,12 +680,12 @@ async function submitOnce( } if (begin.outcome === "existing") { - return replayExisting(begin.record, descriptor, operationId); + return replayExisting(begin.record, descriptor, operationId, extra); } if (abortSignal().aborted) { updateOperation(root, operationId, { state: "not_submitted", error: "interrupted before the request was sent" }); - throw new CliError({ code: "interrupted", message: "interrupted before the request was sent; nothing was submitted", result: { submission: { state: "not_submitted", operation_id: operationId }, task: null } }); + throw new CliError({ code: "interrupted", message: "interrupted before the request was sent; nothing was submitted", result: { submission: { state: "not_submitted", operation_id: operationId }, task: null, ...extra } }); } let created: { taskId: string; raw: unknown; requestId: string | null }; @@ -495,15 +712,15 @@ async function submitOnce( throw new CliError({ code: interrupted ? "interrupted" : "submission_unknown", message: interrupted - ? `interrupted while the create request was in flight; the server may or may not have created a task (operation ${operationId})` - : `the create request was sent but its outcome is unknown (${err instanceof Error ? err.message : String(err)}); the server may or may not have created a task`, + ? `interrupted while ${label} was in flight; the server may or may not have created a task (operation ${operationId})` + : `${label} was sent but its outcome is unknown (${err instanceof Error ? err.message : String(err)}); the server may or may not have created a task`, httpStatus: err instanceof MeshyApiError && err.status ? err.status : null, recovery: { action: "reconcile", automatic: false, command: `meshy ${descriptor.commandPath.join(" ")} list --output-schema v1 # then match operation ${operationId} by time/prompt before creating again`, }, - result: { submission: { state: "unknown", operation_id: operationId, task_id: null }, task: null, downloads: { state: "not_requested", files: [], metadata_path: null } }, + result: { submission: { state: "unknown", operation_id: operationId, task_id: null }, task: null, downloads: NOT_REQUESTED(), ...extra }, warnings, cause: err, }); @@ -512,19 +729,25 @@ async function submitOnce( try { updateOperation(root, operationId, { state: "accepted", task_id: created.taskId, request_id: created.requestId, http_status: 200 }); } catch (journalErr) { - // The server has the task; the id must survive this failure. + // The server has the task; the id must survive this failure — a local write + // problem is local_io, never an unknown submission. throw new CliError({ code: "local_io", message: `task ${created.taskId} was created but the operation journal could not be updated: ${journalErr instanceof Error ? journalErr.message : String(journalErr)}`, - result: { submission: { state: "accepted", operation_id: operationId, task_id: created.taskId }, task: null, task_id: created.taskId, next: nextCommands(descriptor, created.taskId) }, + result: { + submission: { state: "accepted", operation_id: operationId, task_id: created.taskId, request_id: created.requestId }, + task: null, + task_id: created.taskId, + next: nextCommands(descriptor, created.taskId), + ...extra, + }, cause: journalErr, }); } - void opened; return { taskId: created.taskId, raw: created.raw, requestId: created.requestId, operationId, warnings }; } -function replayExisting(record: OperationRecord, descriptor: TaskResourceDescriptor, operationId: string): Submitted { +function replayExisting(record: OperationRecord, descriptor: TaskResourceDescriptor, operationId: string, extra: Record): Submitted { if (record.state === "accepted" && record.task_id) { return { taskId: record.task_id, @@ -539,13 +762,13 @@ function replayExisting(record: OperationRecord, descriptor: TaskResourceDescrip code: "submission_unknown", message: `operation ${operationId} is recorded as '${record.state}' since ${record.updated_at}; reconcile it before submitting again (nothing was sent now)`, recovery: { action: "reconcile", automatic: false, command: `meshy ${descriptor.commandPath.join(" ")} list --output-schema v1` }, - result: { submission: { state: "unknown", operation_id: operationId, task_id: record.task_id }, task: null }, + result: { submission: { state: "unknown", operation_id: operationId, task_id: record.task_id }, task: null, ...extra }, }); } throw new CliError({ code: "operation_conflict", message: `operation ${operationId} was already ${record.state} on ${record.updated_at} (${record.error ?? "no detail"}); use a new --operation-id to submit again`, - result: { submission: { state: record.state, operation_id: operationId, task_id: record.task_id }, task: null }, + result: { submission: { state: record.state, operation_id: operationId, task_id: record.task_id }, task: null, ...extra }, }); } @@ -566,20 +789,47 @@ function attachSubmission(err: Error, submission: { state: string; operation_id: return err; } -async function maybeDownloadV1(opened: OpenedCommand, task: Task, resourceName: string): Promise { +/** + * v1 `-o`: download a SUCCEEDED task's assets through the legacy layout, + * confined to the workspace when one is set. A failure keeps the task in the + * result — the assets are still on the server, the task still exists. + */ +async function maybeDownloadV1( + opened: OpenedCommand, + descriptor: TaskResourceDescriptor, + task: Task, + raw: unknown, + submission: SubmissionInfo, + warnings: Warning[], + ctx: { savedJson?: SavedJson | null; includeRaw?: boolean; project?: ProjectAttachment | null }, +): Promise { const output = opened.flags.output; - if (!output) return { state: "not_requested", files: [], metadata_path: null }; + if (!output) return NOT_REQUESTED(); if (task.status !== "SUCCEEDED") return { state: "not_ready", files: [], metadata_path: null }; try { - const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName); - return { state: "completed", files: savedFiles.map((p) => ({ path: p, status: "written" as const })), metadata_path: metadataPath }; + const { savedFiles, metadataPath, materialLinks } = await downloadArtifacts(task, output, descriptor.id, { root: opened.flags.workspace }); + if (materialLinks) warnings.push(...materialLinks.warnings); + return { state: "completed", files: savedFiles.map((p) => ({ path: p, status: "written" as const })), metadata_path: metadataPath, material_links: materialLinks }; } catch (err) { - throw new CliError({ - code: "local_io", + const failure = new CliError({ + code: err instanceof CliError ? err.code : "local_io", message: `task ${task.id} is SUCCEEDED but downloading its assets failed: ${err instanceof Error ? err.message : String(err)}`, - result: { task: toTaskView(task), downloads: { state: "failed", files: [], metadata_path: null } }, + httpStatus: err instanceof CliError ? err.httpStatus : null, + recovery: err instanceof CliError ? err.recovery : null, + result: { downloads: { state: "failed", files: [], metadata_path: null } }, + warnings, cause: err, }); + throw withTaskContext(failure, { + descriptor, + taskId: task.id, + task, + raw, + submission, + includeRaw: ctx.includeRaw, + savedJson: ctx.savedJson, + extra: ctx.project ? { project: ctx.project } : {}, + }); } } @@ -591,21 +841,24 @@ async function waitAndReport( taskId: string, timeoutSeconds: number, opts: Record, - submission: { state: string; operation_id: string | null; task_id?: string | null; request_id?: string | null }, + submission: SubmissionInfo, warnings: Warning[], ): Promise { const started = performance.now(); - let last: { task: Task; raw: unknown } | null = null; - let polls = 0; - let poll: Awaited>; + const includeRaw = Boolean(opts.includeRaw); + // A holder (not a bare `let`) so the callback's assignment is visible to the + // catch block without TypeScript narrowing it away. + const seen: { last: { task: Task; raw: unknown } | null; polls: number } = { last: null, polls: 0 }; + let poll: PollResult; try { poll = await pollUntilTerminal(endpoint, taskId, { timeoutSeconds, intervalMs: runtime.config.pollIntervalMs, + requestTimeoutMs: runtime.config.readTimeoutMs, signal: abortSignal(), onTick: (task, raw) => { - last = { task, raw }; - polls += 1; + seen.last = { task, raw }; + seen.polls += 1; if (opened.schema === "v1" && opened.format !== "ndjson") { process.stderr.write(`[${descriptor.id}] ${task.status}${typeof task.progress === "number" ? ` ${task.progress}%` : ""}\n`); } @@ -613,31 +866,68 @@ async function waitAndReport( }); } catch (err) { if (wasInterrupted() || abortSignal().aborted) { - throw interruptedError(descriptor, taskId, last, submission, opts, opened); + throw interruptedError(descriptor, taskId, seen.last, submission, opts, opened); } - throw err; + // A polling failure (5xx, network, malformed task) is not "no task": the + // id, the submission and the last status seen travel with the error. + const elapsed = (performance.now() - started) / 1000; + throw withTaskContext(err, { + descriptor, + taskId, + task: seen.last?.task ?? null, + raw: seen.last?.raw ?? null, + submission, + includeRaw, + extra: { wait: { timed_out: false, elapsed_seconds: Number(elapsed.toFixed(2)), polls: seen.polls } }, + }); } const elapsed = (performance.now() - started) / 1000; const { task, raw, timedOut, aborted } = poll; - const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspace }) : null; - const project = attachToProject(opts, descriptor, taskId, task, raw, { operationId: (opts.__operationId as string | undefined) ?? submission.operation_id ?? null, payload: (opts.__payload as Record | undefined) ?? null }, warnings); - const projectExtra = project ? { project } : {}; + const waitInfo = { timed_out: timedOut, elapsed_seconds: Number(elapsed.toFixed(2)), polls: poll.polls }; - if (aborted) throw interruptedError(descriptor, taskId, { task, raw }, submission, opts, opened, savedJson); + if (aborted) throw interruptedError(descriptor, taskId, task ? { task, raw } : seen.last, submission, opts, opened); + + if (task === null) { + // The deadline passed before the first response arrived; the task id is all we know — and it is enough. + if (opened.schema !== "v1") { + emitLegacyTimeoutWithoutTask(taskId, descriptor.id, runtime); + return; + } + throw new CliError({ + code: "timed_out", + message: `task ${taskId} did not answer within ${timeoutSeconds}s (no status was received in time); the server keeps running it`, + recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, + result: taskResult({ task: null, raw: null, descriptor, includeRaw, submission, extra: { task_id: taskId, wait: waitInfo, next: nextCommands(descriptor, taskId) } }), + warnings, + }); + } + + const ctx: TaskContext = { descriptor, taskId, task, raw, submission, includeRaw, extra: { wait: waitInfo } }; + const savedJson = saveJsonInContext(opts, opened, raw, ctx); + const project = attachInContext( + opts, + opened, + descriptor, + taskId, + task, + raw, + { operationId: (opts.__operationId as string | undefined) ?? submission.operation_id ?? null, payload: (opts.__payload as Record | undefined) ?? null }, + warnings, + { ...ctx, savedJson }, + ); + const projectExtra = project ? { project } : {}; if (opened.schema !== "v1") { await emitLegacyOutcome(task, timedOut, elapsed, descriptor.id, runtime, { query: false }); return; } - const includeRaw = Boolean(opts.includeRaw); - const waitInfo = { timed_out: timedOut, elapsed_seconds: Number(elapsed.toFixed(2)), polls }; if (timedOut) { throw new CliError({ code: "timed_out", message: `task ${taskId} did not reach a terminal status within ${timeoutSeconds}s (last status: ${task.status}); the server keeps running it`, recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, - result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { wait: waitInfo, next: nextCommands(descriptor, taskId), ...projectExtra } }), + result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { task_id: taskId, wait: waitInfo, next: nextCommands(descriptor, taskId), ...projectExtra } }), warnings, }); } @@ -645,25 +935,38 @@ async function waitAndReport( throw new CliError({ code: "task_failed", message: task.task_error?.message ? `task ${taskId} ${task.status}: ${task.task_error.message}` : `task ${taskId} ended as ${task.status}`, - result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { wait: waitInfo, ...projectExtra } }), + result: taskResult({ task, raw, descriptor, includeRaw, submission, savedJson, extra: { task_id: taskId, wait: waitInfo, ...projectExtra } }), warnings, }); } - const downloads = await maybeDownloadV1(opened, task, descriptor.id); + const downloads = await maybeDownloadV1(opened, descriptor, task, raw, submission, warnings, { savedJson, includeRaw, project }); await emitEnvelope( okEnvelope(opened.command, taskResult({ task, raw, descriptor, includeRaw, submission, downloads, savedJson, extra: { wait: waitInfo, ...projectExtra } }), warnings), opened.format, ); } +/** Legacy shape for a wait that timed out before any status arrived: still the task id, still exit 8. */ +function emitLegacyTimeoutWithoutTask(taskId: string, resourceName: string, runtime: Runtime): void { + if (runtime.flags.output) { + const report: Parameters[0] = { status: "FAIL", taskId, type: resourceName, timedOut: true }; + const notice = getUpdateNotice(); + if (notice) report._notice = notice; + printReport(report); + } else { + emit({ resource: resourceName, id: taskId, status: null, timed_out: true }, { format: runtime.flags.format }); + } + process.exitCode = 8; +} + function interruptedError( descriptor: TaskResourceDescriptor, taskId: string, last: { task: Task; raw: unknown } | null, - submission: { state: string; operation_id: string | null; task_id?: string | null }, + submission: SubmissionInfo, opts: Record, opened: OpenedCommand, - savedJson: { path: string; bytes: number } | null = null, + savedJson: SavedJson | null = null, ): CliError { let saved = savedJson; if (!saved && opts.saveJson && last) { @@ -694,7 +997,7 @@ async function streamAndReport( const includeRaw = Boolean(opts.includeRaw); const ndjson = opened.format === "ndjson"; let sequence = 0; - const submission = { state: "accepted", operation_id: null }; + const submission: SubmissionInfo = { state: "accepted", operation_id: null }; const warnings: Warning[] = []; const outcome = await streamTask(endpoint, taskId, { @@ -719,12 +1022,16 @@ async function streamAndReport( }, }); - const savedJson = opts.saveJson && outcome.raw ? saveRawJson(opts.saveJson as string, outcome.raw, { workspace: opened.flags.workspace }) : null; - const project = outcome.task ? attachToProject(opts, descriptor, taskId, outcome.task, outcome.raw, {}, warnings) : null; const streamInfo = { events: outcome.events, ended: outcome.reason, elapsed_seconds: Number((outcome.elapsedMs / 1000).toFixed(2)) }; - const result = taskResult({ task: outcome.task, raw: outcome.raw, descriptor, includeRaw, submission, savedJson, extra: { stream: streamInfo, task_id: taskId, ...(project ? { project } : {}) } }); + const ctx: TaskContext = { descriptor, taskId, task: outcome.task, raw: outcome.raw, submission, includeRaw, extra: { stream: streamInfo } }; + const savedJson = opts.saveJson && outcome.raw ? saveJsonInContext(opts, opened, outcome.raw, ctx) : null; + const project = outcome.task ? attachInContext(opts, opened, descriptor, taskId, outcome.task, outcome.raw, {}, warnings, { ...ctx, savedJson }) : null; + const projectExtra = project ? { project } : {}; let finalError: CliError | null = null; + const resultWith = (downloads?: DownloadOutcome): Record => + taskResult({ task: outcome.task, raw: outcome.raw, descriptor, includeRaw, submission, downloads, savedJson, extra: { stream: streamInfo, task_id: taskId, next: nextCommands(descriptor, taskId), ...projectExtra } }); + let result = resultWith(); switch (outcome.reason) { case "terminal": if (outcome.task && outcome.task.status !== "SUCCEEDED") { @@ -749,35 +1056,40 @@ async function streamAndReport( finalError = new CliError({ code: "interrupted", message: `interrupted while streaming task ${taskId}; the server keeps running it`, recovery: { action: "wait", automatic: false, command: nextCommands(descriptor, taskId).wait }, result, warnings }); break; case "error": - finalError = wrapStreamError(outcome.error, result, warnings); - break; case "protocol": finalError = wrapStreamError(outcome.error, result, warnings); break; } if (opened.schema !== "v1") { - // Legacy has no stream shape to preserve: reuse the terminal summary path. + // Legacy has no stream shape to preserve: reuse the terminal summary path (which honours -o itself). if (finalError) throw finalError; if (outcome.task) await emitLegacyOutcome(outcome.task, false, outcome.elapsedMs / 1000, descriptor.id, runtime, { query: false }); return; } + // The terminal outcome includes the requested download whatever the output + // format: `-o` means "put the assets on disk", not "only when printing JSON". + if (!finalError && outcome.task && outcome.task.status === "SUCCEEDED") { + try { + const downloads = await maybeDownloadV1(opened, descriptor, outcome.task, outcome.raw, submission, warnings, { savedJson, includeRaw, project }); + result = resultWith(downloads); + } catch (err) { + finalError = err instanceof CliError ? err : withTaskContext(err, ctx); + } + } + if (ndjson) { sequence += 1; const body = finalError ? errorEnvelope(opened.command, finalError).envelope : okEnvelope(opened.command, result, warnings); const event: StreamEventEnvelope = { ...body, event: "outcome", sequence }; await emitStreamEvent(event); - if (finalError) { - // The outcome line already carries the error; exit with its code without a second envelope. - process.exitCode = finalError.exitCode; - return; - } + // The outcome line already carries the error; exit with its code without a second envelope. + if (finalError) process.exitCode = finalError.exitCode; return; } if (finalError) throw finalError; - const downloads = outcome.task ? await maybeDownloadV1(opened, outcome.task, descriptor.id) : undefined; - await emitEnvelope(okEnvelope(opened.command, { ...result, downloads: downloads ?? result["downloads"] }, warnings), opened.format); + await emitEnvelope(okEnvelope(opened.command, result, warnings), opened.format); } function wrapStreamError(err: MeshyApiError | CliError | null, result: Record, warnings: Warning[]): CliError { @@ -809,7 +1121,7 @@ async function emitLegacyOutcome( if (output) { if (succeeded) { - const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName); + const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName, { root: runtime.flags.workspace }); const successReport: Parameters[0] = { status: "SUCCESS", taskId: task.id, @@ -865,4 +1177,4 @@ export async function emitTerminalOutcome( } export { taskResult as buildTaskResult, nextCommands as taskNextCommands }; -export type { DownloadOutcome, TaskView }; +export type { TaskView }; diff --git a/tests/codex-review-round1.test.ts b/tests/codex-review-round1.test.ts new file mode 100644 index 0000000..2e8b475 --- /dev/null +++ b/tests/codex-review-round1.test.ts @@ -0,0 +1,757 @@ +/** + * Codex review round 1 (reviews/cli-s1-6273d9a, F01–F10) turned into positive + * regression tests. Every scenario mirrors the reviewer's independent probe + * (R01–R12): real subprocesses, a loopback API/asset host that records every + * request, synthetic credentials, isolated temp directories. Where the probe + * demonstrated a defect, the test now asserts the required behaviour. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import sharp from "sharp"; +import { jsonReply, parseNdjson, parseSingleJson, runCli, startMockApi, tmpDir, type MockApi } from "./helpers/cli.js"; + +const KEY_A = "msy_review_fixture_account_a"; +const KEY_B = "msy_review_fixture_account_b"; +const CREATE = ["text-to-3d", "create", "--mode", "preview", "--prompt", "review fixture", "--async", "--output-schema", "v1"]; + +function taskBody(fields: Record = {}): Record { + return { id: "review-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function laterReply(res: Parameters[0], ms: number, status: number, body: unknown): void { + setTimeout(() => { + try { + jsonReply(res, status, body); + } catch { + /* the client is gone — that is the point of the test */ + } + }, ms); +} + +type Env = Record; + +function withoutKey(env: Env, extra: Env = {}): Env { + const next: Env = { ...env, ...extra }; + delete next["MESHY_API_KEY"]; + return next; +} + +// --------------------------------------------------------------------------- +// F05 / R01 — credential identity +// --------------------------------------------------------------------------- + +test("R01/F05 a different API key under the same --operation-id conflicts before any request; the same key replays", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "account-a-task" }) : jsonReply(res, 404, {}))); + try { + const env = api.env({ MESHY_API_KEY: KEY_A }); + const args = [...CREATE, "--operation-id", "same-credential-op"]; + const first = await runCli(args, { env }); + assert.equal(first.code, 0, first.stderr); + const other = await runCli(args, { env: { ...env, MESHY_API_KEY: KEY_B } }); + assert.equal(other.code, 2, other.stderr); + const out = parseSingleJson(other.stdout) as { ok: boolean; error: { code: string; message: string }; result: { conflict: string[]; submission: { task_id: string } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "operation_conflict"); + assert.deepEqual(out.result.conflict, ["credential"]); + assert.equal(out.result.submission.task_id, "account-a-task", "the record is reported, never re-used for the other account"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, "account B sent nothing"); + const again = await runCli(args, { env }); + assert.equal(again.code, 0, again.stderr); + assert.equal((parseSingleJson(again.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + } finally { + await api.close(); + } +}); + +test("R01/F05 OAuth: a rotated token is the same account (replay); a different user under the same profile name conflicts", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "oauth-task" }) : jsonReply(res, 404, {}))); + try { + const dir = tmpDir(); + const credFile = join(dir, "credentials.json"); + const profile = (accessToken: string, userId: string) => + JSON.stringify({ auth_version: 1, active_profile: "default", profiles: { default: { kind: "oauth", access_token: accessToken, refresh_token: "r", expires_at: Date.now() + 3_600_000, user_id: userId, created_at: 1 } } }); + writeFileSync(credFile, profile("tok-1", "user-a")); + const env = withoutKey(api.env(), { MESHY_CREDENTIALS_PATH: credFile }); + const args = [...CREATE, "--operation-id", "oauth-op"]; + const first = await runCli(args, { env, cwd: dir }); + assert.equal(first.code, 0, first.stderr); + assert.equal(api.requests[0]!.headers["authorization"], "Bearer tok-1"); + writeFileSync(credFile, profile("tok-2", "user-a")); + const rotated = await runCli(args, { env, cwd: dir }); + assert.equal(rotated.code, 0, rotated.stderr); + assert.equal((parseSingleJson(rotated.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + writeFileSync(credFile, profile("tok-3", "user-b")); + const otherUser = await runCli(args, { env, cwd: dir }); + assert.equal(otherUser.code, 2, otherUser.stderr); + assert.equal((parseSingleJson(otherUser.stdout) as { error: { code: string } }).error.code, "operation_conflict"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F06 / R02 — media content in the fingerprint +// --------------------------------------------------------------------------- + +test("R02/F06 two equal-length images differ: the second conflicts; the same image (even re-wrapped) replays; no base64 in the journal", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "red-image-task" }) : jsonReply(res, 404, {}))); + try { + const red = await sharp({ create: { width: 1, height: 1, channels: 3, background: "#ff0000" } }).png().toBuffer(); + const green = await sharp({ create: { width: 1, height: 1, channels: 3, background: "#00ff00" } }).png().toBuffer(); + assert.ok(!red.equals(green)); + assert.equal(red.toString("base64").length, green.toString("base64").length, "the probe's premise: equal encoded length"); + const env = api.env({ MESHY_API_KEY: KEY_A }); + const args = (b64: string) => ["image-to-3d", "create", "--image-url", `data:image/png;base64,${b64}`, "--operation-id", "same-media-op", "--async", "--output-schema", "v1"]; + const first = await runCli(args(red.toString("base64")), { env }); + assert.equal(first.code, 0, first.stderr); + const changed = await runCli(args(green.toString("base64")), { env }); + assert.equal(changed.code, 2, changed.stderr); + const out = parseSingleJson(changed.stdout) as { error: { code: string }; result: { conflict: string[] } }; + assert.equal(out.error.code, "operation_conflict"); + assert.deepEqual(out.result.conflict, ["payload"]); + const wrapped = red.toString("base64").replace(/(.{40})/g, "$1\n"); + const same = await runCli(args(wrapped), { env }); + assert.equal(same.code, 0, same.stderr); + assert.equal((parseSingleJson(same.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, "exactly one billable request across three invocations"); + const ops = join(String(env["MESHY_CONFIG_DIR"]), "operations"); + for (const f of readdirSync(ops).filter((n) => n.endsWith(".json"))) { + const text = readFileSync(join(ops, f), "utf8"); + assert.ok(!text.includes(red.toString("base64").slice(0, 16)), "journal holds no image bytes"); + assert.ok(!text.includes(KEY_A), "journal holds no key"); + } + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F07 / R03 — the wait deadline binds in-flight requests +// --------------------------------------------------------------------------- + +test("R03/F07 wait: a GET that answers after the deadline is a timeout (exit 8) with the task id, not a late success", async () => { + const api = await startMockApi((_req, res) => laterReply(res, 400, 200, taskBody())); + try { + const started = Date.now(); + const r = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0.05", "--output-schema", "v1"], { env: api.env() }); + const elapsed = Date.now() - started; + assert.equal(r.code, 8, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string; recovery: { command: string } }; result: { task: unknown; task_id: string; wait: { timed_out: boolean; polls: number }; next: { wait: string } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "timed_out"); + assert.equal(out.result.task, null, "no status was received in time, none is invented"); + assert.equal(out.result.task_id, "review-task"); + assert.equal(out.result.wait.timed_out, true); + assert.equal(out.result.wait.polls, 0); + assert.match(out.error.recovery.command, /wait review-task/); + assert.ok(elapsed < 3000, `the command did not wait for the late body (${elapsed} ms)`); + // Legacy schema: same decision, legacy shape. + const legacy = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0.05"], { env: api.env() }); + assert.equal(legacy.code, 8, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as Record; + assert.equal(lp["id"], "review-task"); + assert.equal(lp["timed_out"], true); + } finally { + await api.close(); + } +}); + +test("R03/F07 wait: slow body, expiry during sleep and --timeout 0 behave as specified; no GET is started after the deadline", async () => { + let mode: "slow-body" | "instant" | "slow-200" = "slow-body"; + const stamps: number[] = []; + let origin = Date.now(); + const api = await startMockApi((_req, res) => { + stamps.push(Date.now() - origin); + if (mode === "slow-body") { + res.writeHead(200, { "content-type": "application/json" }); + setTimeout(() => { + try { + res.end(JSON.stringify(taskBody())); + } catch { + /* client gone */ + } + }, 400); + return; + } + if (mode === "slow-200") return laterReply(res, 200, 200, taskBody({ status: "IN_PROGRESS", progress: 10 })); + return jsonReply(res, 200, taskBody({ status: "IN_PROGRESS", progress: 10 })); + }); + try { + const slowBody = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0.05", "--output-schema", "v1"], { env: api.env() }); + assert.equal(slowBody.code, 8, `${slowBody.stderr}\n${slowBody.stdout}`); + assert.equal((parseSingleJson(slowBody.stdout) as { error: { code: string } }).error.code, "timed_out"); + + // Expiry while sleeping: budget 300 ms, interval 250 ms → one GET, then the + // sleep runs out the budget; no second GET after the deadline. + mode = "instant"; + stamps.length = 0; + origin = Date.now(); + const expiry = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0.3", "--output-schema", "v1"], { env: api.env({ MESHY_POLL_INTERVAL_MS: "250" }) }); + assert.equal(expiry.code, 8, expiry.stderr); + const eo = parseSingleJson(expiry.stdout) as { result: { task: { status: string }; wait: { polls: number } } }; + assert.equal(eo.result.task.status, "IN_PROGRESS", "the last status seen is reported"); + assert.ok(eo.result.wait.polls >= 1 && eo.result.wait.polls <= 2, `polls=${eo.result.wait.polls}`); + const firstGet = stamps[0]!; + assert.ok(stamps.every((t) => t - firstGet <= 300 + 60), `every GET started within the budget: ${JSON.stringify(stamps.map((t) => t - firstGet))}`); + + // --timeout 0: exactly one query bounded by the read timeout, not by a zero budget. + mode = "slow-200"; + stamps.length = 0; + const single = await runCli(["text-to-3d", "wait", "review-task", "--timeout", "0", "--output-schema", "v1"], { env: api.env() }); + assert.equal(single.code, 8, single.stderr); + const so = parseSingleJson(single.stdout) as { result: { task: { status: string }; wait: { polls: number } } }; + assert.equal(so.result.task.status, "IN_PROGRESS", "the single query completed although it took 200 ms"); + assert.equal(so.result.wait.polls, 1); + assert.equal(stamps.length, 1); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F08 / R04 — Creative Lab options compose across --data, --options and typed flags +// --------------------------------------------------------------------------- + +test("R04/F08 --data.options, --options and typed flags merge field by field (typed wins, false survives)", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "lamp-build-task" }) : jsonReply(res, 404, {}))); + try { + const r = await runCli( + [ + "creative-lab", "lamp", "build", "create", "--input-task-id", "parent-prototype", + "--data", '{"options":{"diameter_mm":180,"rotate_x_deg":90,"include_result_json":false},"output":{"format":"stl"}}', + "--options", '{"thickness_mm":1.5,"include_result_json":false}', + "--include-result-json", "--model-format", "zip", "--async", + ], + { env: api.env() }, + ); + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(api.requests.at(-1)!.json, { + input_task_id: "parent-prototype", + options: { diameter_mm: 180, rotate_x_deg: 90, include_result_json: true, thickness_mm: 1.5 }, + output: { format: "zip" }, + }); + // The reviewer's exact probe: --data options + --options. + const probe = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "parent-prototype", "--data", '{"options":{"diameter_mm":180,"rotate_x_deg":90}}', "--options", '{"thickness_mm":1.5}', "--async"], { env: api.env() }); + assert.equal(probe.code, 0, probe.stderr); + assert.deepEqual((api.requests.at(-1)!.json as { options: unknown }).options, { diameter_mm: 180, rotate_x_deg: 90, thickness_mm: 1.5 }); + // Keychain: explicit false from --data survives an --options layer. + const kc = await runCli(["creative-lab", "keychain", "build", "create", "--input-task-id", "p", "--data", '{"options":{"has_closed_back":false}}', "--options", '{"badge_shape":"star"}', "--async"], { env: api.env() }); + assert.equal(kc.code, 0, kc.stderr); + assert.deepEqual((api.requests.at(-1)!.json as { options: unknown }).options, { has_closed_back: false, badge_shape: "star" }); + // Validation still sees the merged object: an out-of-range value from --data is refused before the POST. + const before = api.requests.length; + const bad = await runCli(["creative-lab", "lamp", "build", "create", "--input-task-id", "p", "--data", '{"options":{"diameter_mm":10}}', "--options", '{"thickness_mm":1.5}', "--async"], { env: api.env() }); + assert.equal(bad.code, 2, bad.stderr); + assert.equal(api.requests.length, before); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F01 / R05, R06 — the accepted task id survives every later failure +// --------------------------------------------------------------------------- + +test("R05/F01 --save-json: an existing target is refused before the POST; a failure after the POST still reports the accepted id", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "accepted-before-save-error" }) : jsonReply(res, 404, {}))); + try { + const dir = tmpDir(); + writeFileSync(join(dir, "occupied.json"), "existing-user-data"); + const preflight = await runCli([...CREATE, "--save-json", "occupied.json"], { env: api.env(), cwd: dir }); + assert.equal(preflight.code, 11, preflight.stderr); + const pf = parseSingleJson(preflight.stdout) as { error: { code: string; message: string } }; + assert.equal(pf.error.code, "local_io"); + assert.match(pf.error.message, /nothing was submitted/); + assert.equal(api.requests.length, 0, "a detectable conflict costs zero requests"); + assert.equal(readFileSync(join(dir, "occupied.json"), "utf8"), "existing-user-data"); + + // A save that can only fail after the POST (the parent "directory" is a file). + writeFileSync(join(dir, "blocked"), "i am a file"); + const late = await runCli([...CREATE, "--save-json", join("blocked", "task.json")], { env: api.env(), cwd: dir }); + assert.equal(late.code, 11, `${late.stderr}\n${late.stdout}`); + const out = parseSingleJson(late.stdout) as { ok: boolean; error: { code: string }; result: { task_id: string; submission: { state: string; task_id: string; operation_id: string }; next: { get: string; wait: string } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "local_io"); + assert.equal(out.result.task_id, "accepted-before-save-error"); + assert.equal(out.result.submission.state, "accepted"); + assert.equal(out.result.submission.task_id, "accepted-before-save-error"); + assert.match(out.result.next.get, /get accepted-before-save-error/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + } finally { + await api.close(); + } +}); + +test("R06/F01 sync create: a 503 while polling keeps the created id, submission and resume commands; exactly one POST", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "accepted-before-get-error" }) : jsonReply(res, 503, { message: "synthetic temporary outage" }))); + try { + const r = await runCli(CREATE.filter((a) => a !== "--async"), { env: api.env() }); + assert.equal(r.code, 1, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string; http_status: number }; result: { task: unknown; task_id: string; submission: { state: string; task_id: string }; next: { wait: string }; wait: { polls: number } } }; + assert.equal(out.ok, false); + assert.equal(out.error.code, "server"); + assert.equal(out.error.http_status, 503); + assert.equal(out.result.task_id, "accepted-before-get-error"); + assert.equal(out.result.submission.state, "accepted"); + assert.equal(out.result.submission.task_id, "accepted-before-get-error"); + assert.match(out.result.next.wait, /wait accepted-before-get-error/); + assert.equal(out.result.wait.polls, 0); + assert.deepEqual(api.requests.map((q) => q.method), ["POST", "GET"]); + // The same for a bare `wait`, and for the legacy schema. + const w = await runCli(["text-to-3d", "wait", "some-task", "--output-schema", "v1"], { env: api.env() }); + assert.equal(w.code, 1, w.stderr); + assert.equal((parseSingleJson(w.stdout) as { result: { task_id: string } }).result.task_id, "some-task"); + const legacy = await runCli(CREATE.filter((a) => a !== "--async" && a !== "--output-schema" && a !== "v1"), { env: api.env() }); + assert.equal(legacy.code, 1, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as { code: string; result: { task_id: string } }; + assert.equal(lp.code, "server"); + assert.equal(lp.result.task_id, "accepted-before-get-error"); + } finally { + await api.close(); + } +}); + +test("F01 make: a polling failure after step 1 was accepted keeps executed steps, the task id and the resume command", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "prev-accepted" }) : jsonReply(res, 503, { message: "outage" }))); + try { + const r = await runCli(["make", "a fixture cactus", "--output-schema", "v1"], { env: api.env() }); + assert.equal(r.code, 1, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { error: { code: string; http_status: number }; result: { task_id: string; executed: Array<{ task_id: string }>; submission: { state: string }; next: { wait: string } } }; + assert.equal(out.error.code, "server"); + assert.equal(out.error.http_status, 503); + assert.equal(out.result.task_id, "prev-accepted"); + assert.equal(out.result.executed[0]!.task_id, "prev-accepted"); + assert.equal(out.result.submission.state, "accepted"); + assert.match(out.result.next.wait, /wait prev-accepted/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F09 / R07 — downloaded OBJ/MTL reference the files actually saved +// --------------------------------------------------------------------------- + +test("R07/F09 download: mtllib and map_* references are rewritten to the saved names; manifest digests describe the rewritten files", async () => { + const png = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#336699" } }).png().toBuffer(); + const obj = "# meshy\nmtllib box.mtl\nv 0 0 0\nv 1 1 0\nv 0 1 1\nusemtl sample\nf 1 2 3\n"; + const mtl = "newmtl sample\nKd 0.8 0.8 0.8\nmap_Kd texture.png\nmap_Bump -bm 0.5 normal_map.png\n"; + const host = await startMockApi((req, res) => { + const bodies: Record = { + "/box.obj": { body: obj, type: "model/obj" }, + "/box.mtl": { body: mtl, type: "text/plain" }, + "/bc.png": { body: png, type: "image/png" }, + "/n.png": { body: png, type: "image/png" }, + }; + const entry = bodies[req.path]; + if (!entry) return jsonReply(res, 404, {}); + res.writeHead(200, { "content-type": entry.type }); + res.end(entry.body); + }); + try { + const dir = tmpDir(); + const taskJson = join(dir, "obj-task.json"); + writeFileSync(taskJson, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/box.obj`, mtl: `${host.url}/box.mtl` }, texture_urls: [{ base_color: `${host.url}/bc.png`, normal: `${host.url}/n.png` }] }))); + const out = join(dir, "obj-download"); + const r = await runCli(["download", "--task-json", taskJson, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + assert.deepEqual(readdirSync(out).sort(), ["model.mtl", "model.obj", "texture_0_base_color.png", "texture_0_normal.png"]); + const savedObj = readFileSync(join(out, "model.obj"), "utf8"); + assert.match(savedObj, /^mtllib model\.mtl$/m); + assert.ok(!savedObj.includes("box.mtl")); + assert.ok(savedObj.includes("usemtl sample") && savedObj.includes("f 1 2 3"), "everything else is verbatim"); + const savedMtl = readFileSync(join(out, "model.mtl"), "utf8"); + assert.match(savedMtl, /^map_Kd texture_0_base_color\.png$/m); + assert.match(savedMtl, /^map_Bump -bm 0\.5 texture_0_normal\.png$/m); + for (const ref of savedMtl.match(/^map_\w+ (?:-\S+ \S+ )?(\S+)$/gm)!.map((l) => l.split(" ").at(-1)!)) { + assert.ok(existsSync(join(out, ref)), `${ref} exists next to the MTL`); + } + const env = parseSingleJson(r.stdout) as { result: { downloads: { files: Array<{ key: string; path: string; sha256: string; relinked: boolean }>; material_links: { rewritten: string[]; mtllib: Array<{ resolved_to: string }>; texture_maps: Array<{ reference: string; resolved_to: string | null; method: string }> } } }; warnings: Array<{ code: string }> }; + const files = Object.fromEntries(env.result.downloads.files.map((f) => [f.key, f])); + assert.equal(files["model.obj"]!.relinked, true); + assert.equal(files["model.mtl"]!.relinked, true); + assert.equal(files["texture.0.base_color"]!.relinked, false); + for (const f of env.result.downloads.files) assert.equal(f.sha256, sha(f.path), `${f.key}: manifest digest matches the file on disk`); + assert.equal(env.result.downloads.material_links.rewritten.length, 2); + assert.deepEqual(env.result.downloads.material_links.mtllib.map((l) => l.resolved_to), ["model.mtl"]); + assert.deepEqual(env.result.downloads.material_links.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [ + ["texture.png", "texture_0_base_color.png", "channel_of_key"], + ["normal_map.png", "texture_0_normal.png", "channel_in_name"], + ]); + assert.ok(!env.warnings.some((w) => w.code === "material_reference_unresolved")); + + // Legacy `-o` (all artifacts) relinks too. + const api = await startMockApi((req, res) => { + if (req.path.startsWith("/openapi/")) return jsonReply(res, 200, taskBody({ model_urls: { obj: `${host.url}/box.obj`, mtl: `${host.url}/box.mtl` }, texture_urls: [{ base_color: `${host.url}/bc.png`, normal: `${host.url}/n.png` }] })); + return jsonReply(res, 404, {}); + }); + try { + const legacyOut = join(dir, "legacy"); + const legacy = await runCli(["text-to-3d", "get", "review-task", "-o", legacyOut], { env: api.env(), cwd: dir }); + assert.equal(legacy.code, 0, legacy.stderr); + assert.match(readFileSync(join(legacyOut, "model.obj"), "utf8"), /^mtllib model\.mtl$/m); + assert.match(readFileSync(join(legacyOut, "model.mtl"), "utf8"), /^map_Kd texture_0_base_color\.png$/m); + } finally { + await api.close(); + } + + // Geometry only: the OBJ keeps its reference and the result says so. + const geo = await runCli(["download", "--task-json", taskJson, "--asset", "model.obj", "--geometry-only", "--output-dir", join(dir, "geo")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(geo.code, 0, geo.stderr); + assert.match(readFileSync(join(dir, "geo", "model.obj"), "utf8"), /^mtllib box\.mtl$/m); + const gw = parseSingleJson(geo.stdout) as { warnings: Array<{ code: string }> }; + assert.ok(gw.warnings.some((w) => w.code === "geometry_only")); + assert.ok(gw.warnings.some((w) => w.code === "material_reference_unresolved")); + } finally { + await host.close(); + } +}); + +test("F09 an MTL map that matches no downloaded texture stays as written and is reported, never silently dropped", async () => { + const png = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#336699" } }).png().toBuffer(); + const host = await startMockApi((req, res) => { + if (req.path === "/m.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + { + res.end("mtllib m.mtl\nv 0 0 0\n"); + return; + } + } + if (req.path === "/m.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + { + res.end("newmtl a\nmap_Kd albedo.png\nmap_Ks specular_only.png\n"); + return; + } + } + if (req.path === "/bc.png") { + res.writeHead(200, { "content-type": "image/png" }); + { + res.end(png); + return; + } + } + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/m.obj`, mtl: `${host.url}/m.mtl` }, texture_urls: [{ base_color: `${host.url}/bc.png` }] }))); + const r = await runCli(["download", "--task-json", taskJson, "--asset", "model.obj", "--output-dir", join(dir, "out")], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, r.stderr); + const savedMtl = readFileSync(join(dir, "out", "model.mtl"), "utf8"); + assert.match(savedMtl, /^map_Kd texture_0_base_color\.png$/m, "albedo → base color by channel word"); + assert.match(savedMtl, /^map_Ks specular_only\.png$/m, "unresolved reference untouched"); + const env = parseSingleJson(r.stdout) as { warnings: Array<{ code: string; message: string }>; result: { downloads: { material_links: { texture_maps: Array<{ reference: string; resolved_to: string | null }> } } } }; + assert.ok(env.warnings.some((w) => w.code === "material_reference_unresolved" && w.message.includes("specular_only.png"))); + assert.deepEqual(env.result.downloads.material_links.texture_maps.map((l) => [l.reference, l.resolved_to]), [["albedo.png", "texture_0_base_color.png"], ["specular_only.png", null]]); + } finally { + await host.close(); + } +}); + +// --------------------------------------------------------------------------- +// F03 / R08, R09 — --workspace confines every write path +// --------------------------------------------------------------------------- + +test("R08/F03 project init/record/rebuild-index refuse roots and projects outside --workspace before writing", async () => { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + const outside = join(dir, "outside"); + mkdirSync(workspace); + mkdirSync(outside); + const env = { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CLI_NO_UPDATE_NOTIFIER: "1", MESHY_CONFIG_DIR: tmpDir("cfg-") }; + const init = await runCli(["project", "init", "--workspace", workspace, "--root", join(outside, "projects"), "--name", "review"], { cwd: dir, env }); + assert.equal(init.code, 11, `${init.stderr}\n${init.stdout}`); + assert.equal((parseSingleJson(init.stdout) as { error: { code: string } }).error.code, "local_io"); + assert.deepEqual(readdirSync(outside), [], "nothing was created outside the workspace"); + + const inside = await runCli(["project", "init", "--workspace", workspace, "--root", join(workspace, "projects"), "--name", "review"], { cwd: dir, env }); + assert.equal(inside.code, 0, inside.stderr); + const projectDir = (parseSingleJson(inside.stdout) as { result: { project_dir: string } }).result.project_dir; + assert.ok(existsSync(join(projectDir, "metadata.json"))); + + // A project that lives outside the workspace cannot be written to. + const foreign = await runCli(["project", "init", "--root", join(outside, "projects"), "--name", "foreign"], { cwd: dir, env }); + assert.equal(foreign.code, 0, foreign.stderr); + const foreignDir = (parseSingleJson(foreign.stdout) as { result: { project_dir: string } }).result.project_dir; + const rec = await runCli(["project", "record", "--workspace", workspace, "--project", foreignDir, "--task-id", "t", "--stage", "preview"], { cwd: dir, env }); + assert.equal(rec.code, 11, rec.stderr); + assert.equal((JSON.parse(readFileSync(join(foreignDir, "metadata.json"), "utf8")) as { tasks: unknown[] }).tasks.length, 0); + const rebuild = await runCli(["project", "rebuild-index", "--workspace", workspace, "--root", join(outside, "projects")], { cwd: dir, env }); + assert.equal(rebuild.code, 11, rebuild.stderr); + // Task verbs: --project outside the workspace is refused before the POST. + const api = await startMockApi((req, res) => (req.method === "POST" ? jsonReply(res, 200, { result: "never" }) : jsonReply(res, 404, {}))); + try { + const create = await runCli([...CREATE, "--workspace", workspace, "--project", foreignDir], { cwd: dir, env: api.env() }); + assert.equal(create.code, 11, create.stderr); + assert.equal(api.requests.length, 0); + } finally { + await api.close(); + } +}); + +test("R09/F03 task -o (get/wait/create/make) honours --workspace like standalone download; nothing outside is written or fetched", async () => { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + const outside = join(dir, "outside"); + mkdirSync(workspace); + mkdirSync(outside); + const api = await startMockApi((req, res) => { + if (req.path === "/asset.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + { + res.end(glb()); + return; + } + } + if (req.method === "POST") return jsonReply(res, 200, { result: "review-task" }); + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const env = api.env(); + const outsideModel = join(outside, "model.glb"); + const get = await runCli(["text-to-3d", "get", "review-task", "--output-schema", "v1", "--workspace", workspace, "-o", outsideModel], { env, cwd: dir }); + assert.equal(get.code, 11, `${get.stderr}\n${get.stdout}`); + const go = parseSingleJson(get.stdout) as { error: { code: string }; result: { task_id: string; downloads: { state: string } } }; + assert.equal(go.error.code, "local_io"); + assert.equal(go.result.task_id, "review-task", "the task is still reported"); + assert.equal(go.result.downloads.state, "failed"); + assert.ok(!existsSync(outsideModel)); + assert.equal(api.requests.filter((q) => q.path === "/asset.glb").length, 0, "nothing was fetched for a refused target"); + + // Symlinked directory inside the workspace pointing outside. + symlinkSync(outside, join(workspace, "link")); + const viaLink = await runCli(["text-to-3d", "wait", "review-task", "--output-schema", "v1", "--workspace", workspace, "-o", join(workspace, "link", "m.glb")], { env, cwd: dir }); + assert.equal(viaLink.code, 11, viaLink.stderr); + assert.deepEqual(readdirSync(outside), []); + + // create / make: refused before the POST. + const before = api.requests.length; + const create = await runCli([...CREATE, "--workspace", workspace, "-o", outsideModel], { env, cwd: dir }); + assert.equal(create.code, 11, create.stderr); + const make = await runCli(["make", "a fixture cactus", "--workspace", workspace, "-o", outsideModel, "--output-schema", "v1"], { env, cwd: dir }); + assert.equal(make.code, 11, make.stderr); + assert.equal(api.requests.length, before, "no request for a target outside the workspace"); + assert.deepEqual(readdirSync(outside), []); + + // Inside the workspace the same commands work. + const ok = await runCli(["text-to-3d", "get", "review-task", "--output-schema", "v1", "--workspace", workspace, "-o", join(workspace, "sub", "model.glb")], { env, cwd: dir }); + assert.equal(ok.code, 0, ok.stderr); + assert.ok(existsSync(join(workspace, "sub", "model.glb"))); + // Legacy schema, same boundary. + const legacy = await runCli(["text-to-3d", "get", "review-task", "--workspace", workspace, "-o", join(outside, "legacy.glb")], { env, cwd: dir }); + assert.equal(legacy.code, 11, legacy.stderr); + assert.ok(!existsSync(join(outside, "legacy.glb"))); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F10 / R10 — stream honours -o in every output format +// --------------------------------------------------------------------------- + +test("R10/F10 stream -o downloads the assets in ndjson, json and pretty; a download failure is one outcome with the task kept", async () => { + let assetOk = true; + const api = await startMockApi((req, res) => { + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(`event: message\ndata: ${JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/asset.glb` } }))}\n\n`); + return; + } + if (req.path === "/asset.glb") { + if (!assetOk) return jsonReply(res, 404, { message: "gone" }); + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + return; + } + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + for (const format of ["ndjson", "json", "pretty"] as const) { + api.requests.length = 0; + const target = join(dir, `${format}.glb`); + const r = await runCli(["text-to-3d", "stream", "review-task", "--format", format, "--output-schema", "v1", "-o", target], { env: api.env(), cwd: dir }); + assert.equal(r.code, 0, `${format}: ${r.stderr}\n${r.stdout}`); + assert.ok(existsSync(target), `${format}: the asset landed`); + assert.deepEqual(api.requests.map((q) => q.path), ["/openapi/v2/text-to-3d/review-task/stream", "/asset.glb"], `${format}: one SSE GET, one asset GET`); + if (format === "ndjson") { + const lines = parseNdjson(r.stdout) as Array<{ event: string; ok: boolean; result: { downloads: { state: string; files: Array<{ path: string }> } } }>; + assert.deepEqual(lines.map((l) => l.event), ["task", "outcome"]); + assert.equal(lines[1]!.ok, true); + assert.equal(lines[1]!.result.downloads.state, "completed"); + assert.equal(lines[1]!.result.downloads.files[0]!.path, target); + } else if (format === "json") { + const env = parseSingleJson(r.stdout) as { result: { downloads: { state: string } } }; + assert.equal(env.result.downloads.state, "completed"); + } else { + assert.match(r.stdout, /state: completed/); + } + } + // Download failure: exactly one outcome line, ok:false, task kept, exit code of the failure. + assetOk = false; + api.requests.length = 0; + const target = join(dir, "failing.glb"); + const bad = await runCli(["text-to-3d", "stream", "review-task", "--format", "ndjson", "--output-schema", "v1", "-o", target], { env: api.env(), cwd: dir }); + assert.equal(bad.code, 11, `${bad.stderr}\n${bad.stdout}`); + const lines = parseNdjson(bad.stdout) as Array<{ event: string; ok: boolean; error: { code: string } | null; result: { task_id: string; task: { status: string }; downloads: { state: string } } }>; + assert.deepEqual(lines.map((l) => l.event), ["task", "outcome"], "one outcome, no second envelope"); + assert.equal(lines[1]!.ok, false); + assert.equal(lines[1]!.error!.code, "local_io"); + assert.equal(lines[1]!.result.task_id, "review-task"); + assert.equal(lines[1]!.result.task.status, "SUCCEEDED"); + assert.equal(lines[1]!.result.downloads.state, "failed"); + assert.ok(!existsSync(target)); + const badJson = await runCli(["text-to-3d", "stream", "review-task", "--output-schema", "v1", "-o", join(dir, "failing2.glb")], { env: api.env(), cwd: dir }); + assert.equal(badJson.code, 11, badJson.stderr); + assert.equal((parseSingleJson(badJson.stdout) as { result: { task_id: string } }).result.task_id, "review-task"); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F02 / R11 — make: journal failure after acceptance is local_io with the id +// --------------------------------------------------------------------------- + +test("R11/F02 make: a journal write failure after the server accepted is local_io (11) with the known id, never submission_unknown", async () => { + let operations = ""; + const api = await startMockApi((_req, res) => { + // Deterministic local journal failure after the POST reached the server: + // the `started` record vanishes before the CLI can mark it accepted. + for (const name of readdirSync(operations).filter((n) => n.endsWith(".json"))) { + const full = join(operations, name); + if ((JSON.parse(readFileSync(full, "utf8")) as { state: string }).state === "started") unlinkSync(full); + } + jsonReply(res, 200, { result: "make-known-accepted-id" }); + }); + try { + const shared = api.env(); + operations = join(String(shared["MESHY_CONFIG_DIR"]), "operations"); + const text = await runCli(["make", "a fixture cactus", "--async", "--output-schema", "v1"], { env: shared }); + assert.equal(text.code, 11, `${text.stderr}\n${text.stdout}`); + const out = parseSingleJson(text.stdout) as { error: { code: string }; result: { submission: { state: string; task_id: string }; task_id: string; step: number; next: { wait: string } } }; + assert.equal(out.error.code, "local_io"); + assert.equal(out.result.submission.state, "accepted"); + assert.equal(out.result.submission.task_id, "make-known-accepted-id"); + assert.equal(out.result.task_id, "make-known-accepted-id"); + assert.equal(out.result.step, 1); + assert.match(out.result.next.wait, /wait make-known-accepted-id/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + + // Image route: same contract. + api.requests.length = 0; + const dir = tmpDir(); + const png = join(dir, "cat.png"); + writeFileSync(png, await sharp({ create: { width: 2, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } } }).png().toBuffer()); + const img = await runCli(["make", png, "--async", "--output-schema", "v1"], { env: shared, cwd: dir }); + assert.equal(img.code, 11, `${img.stderr}\n${img.stdout}`); + const io = parseSingleJson(img.stdout) as { error: { code: string }; result: { submission: { state: string; task_id: string } } }; + assert.equal(io.error.code, "local_io"); + assert.equal(io.result.submission.state, "accepted"); + assert.equal(io.result.submission.task_id, "make-known-accepted-id"); + assert.deepEqual(api.requests.map((q) => `${q.method} ${q.path}`), ["POST /openapi/v1/image-to-3d"]); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// F04 / R12 — OBJ dependency copies cannot escape through a symlinked parent +// --------------------------------------------------------------------------- + +test("R12/F04 prepare-print: a symlinked materials/ (or texture) directory under the target cannot receive a copy; real directories still work", async () => { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + const escape = join(dir, "escaped-materials"); + mkdirSync(join(workspace, "source", "materials", "tex"), { recursive: true }); + mkdirSync(escape); + writeFileSync(join(workspace, "source", "mesh.obj"), "mtllib materials/a.mtl\nv 0 0 0\nv 1 1 0\nv 0 1 1\nf 1 2 3\n"); + writeFileSync(join(workspace, "source", "materials", "a.mtl"), "newmtl sample\nKd 0.8 0.8 0.8\nmap_Kd tex/t.png\n"); + writeFileSync(join(workspace, "source", "materials", "tex", "t.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const env = { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CLI_NO_UPDATE_NOTIFIER: "1", MESHY_CONFIG_DIR: tmpDir("cfg-") }; + + // materials/ under the target is a symlink to a directory outside the workspace. + mkdirSync(join(workspace, "target")); + symlinkSync(escape, join(workspace, "target", "materials")); + const r = await runCli(["mesh", "prepare-print", join(workspace, "source", "mesh.obj"), "--workspace", workspace, "-o", join(workspace, "target", "mesh.obj")], { cwd: dir, env }); + assert.equal(r.code, 11, `${r.stderr}\n${r.stdout}`); + assert.match((parseSingleJson(r.stdout) as { error: { message: string } }).error.message, /material dependency target/); + assert.deepEqual(readdirSync(escape), [], "nothing was written outside"); + assert.ok(!existsSync(join(workspace, "target", "mesh.obj")), "no output either: the run is all or nothing"); + + // Same escape one level down: materials/ is real, materials/tex is the symlink. + mkdirSync(join(workspace, "target2", "materials"), { recursive: true }); + symlinkSync(escape, join(workspace, "target2", "materials", "tex")); + const r2 = await runCli(["mesh", "prepare-print", join(workspace, "source", "mesh.obj"), "--workspace", workspace, "-o", join(workspace, "target2", "mesh.obj")], { cwd: dir, env }); + assert.equal(r2.code, 11, r2.stderr); + assert.deepEqual(readdirSync(escape), []); + assert.ok(!existsSync(join(workspace, "target2", "mesh.obj"))); + + // Without --workspace the output directory is the root: still refused. + mkdirSync(join(dir, "target3")); + symlinkSync(escape, join(dir, "target3", "materials")); + const r3 = await runCli(["mesh", "prepare-print", join(workspace, "source", "mesh.obj"), "-o", join(dir, "target3", "mesh.obj")], { cwd: dir, env }); + assert.equal(r3.code, 11, r3.stderr); + assert.deepEqual(readdirSync(escape), []); + + // Real directories: dependencies are copied inside the target tree and reported. + const ok = await runCli(["mesh", "prepare-print", join(workspace, "source", "mesh.obj"), "--workspace", workspace, "-o", join(workspace, "target4", "mesh.obj")], { cwd: dir, env }); + assert.equal(ok.code, 0, `${ok.stderr}\n${ok.stdout}`); + assert.ok(existsSync(join(workspace, "target4", "mesh.obj"))); + assert.ok(existsSync(join(workspace, "target4", "materials", "a.mtl"))); + assert.ok(existsSync(join(workspace, "target4", "materials", "tex", "t.png"))); + const rep = parseSingleJson(ok.stdout) as { result: { material: { copied: string[] } } }; + assert.equal(rep.result.material.copied.length, 2); +}); + +// --------------------------------------------------------------------------- +// Reviewer note — a real two-process race on one operation id at the CLI level +// --------------------------------------------------------------------------- + +test("two concurrent creates with the same --operation-id send exactly one POST", async () => { + const api = await startMockApi((req, res) => (req.method === "POST" ? laterReply(res, 150, 200, { result: "raced-task" }) : jsonReply(res, 404, {}))); + try { + const env = api.env(); + const args = [...CREATE, "--operation-id", "race-op"]; + const [a, b] = await Promise.all([runCli(args, { env }), runCli(args, { env })]); + const codes = [a.code, b.code].sort(); + assert.ok(codes.every((c) => c === 0 || c === 10), `codes ${JSON.stringify(codes)}: ${a.stderr} ${b.stderr}`); + assert.ok(codes.includes(0), "one invocation owns the accepted submission"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, "the journal serialises the two processes onto one request"); + for (const r of [a, b]) { + const out = parseSingleJson(r.stdout) as { ok: boolean; result: { submission: { state: string; operation_id: string } }; error: { code: string } | null }; + assert.equal(out.result.submission.operation_id, "race-op"); + if (!out.ok) assert.equal(out.error!.code, "submission_unknown", "the loser sees the in-flight record and never re-sends"); + } + } finally { + await api.close(); + } +}); + +// Keep the MockApi type in use for readers of this file. +export type { MockApi }; diff --git a/tests/fixtures/skill-parity/task-error.synthetic.sse b/tests/fixtures/skill-parity/task-error.synthetic.sse index 4e1cd02..590d036 100644 --- a/tests/fixtures/skill-parity/task-error.synthetic.sse +++ b/tests/fixtures/skill-parity/task-error.synthetic.sse @@ -3,4 +3,3 @@ event: error data: {"message":"Synthetic task not found","status_code":404} - diff --git a/tests/helpers/begin-operation-child.ts b/tests/helpers/begin-operation-child.ts index c1ca5c5..8cd61f2 100644 --- a/tests/helpers/begin-operation-child.ts +++ b/tests/helpers/begin-operation-child.ts @@ -1,14 +1,33 @@ /** * Child process for the operation-journal race test: begins the same - * operation id and prints the outcome. argv: + * operation id and prints the outcome. argv: [barrier-dir] + * + * With a barrier directory the child announces itself (`ready-`), then + * spins until the parent drops a `go` file, so several children enter + * beginOperation within microseconds of each other instead of being serialised + * by process start-up time. */ +import { existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { beginOperation } from "../../src/internal/operation-store.js"; -const [root, id] = process.argv.slice(2); +const [root, id, barrier] = process.argv.slice(2); if (!root || !id) { - process.stderr.write("usage: begin-operation-child \n"); + process.stderr.write("usage: begin-operation-child [barrier-dir]\n"); process.exit(2); } +if (barrier) { + writeFileSync(join(barrier, `ready-${process.pid}`), ""); + const go = join(barrier, "go"); + const deadline = Date.now() + 10_000; + while (!existsSync(go)) { + if (Date.now() > deadline) { + process.stderr.write("barrier timeout\n"); + process.exit(3); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1); + } +} const res = beginOperation(root, id, { resource: "text-to-3d", endpoint: "/openapi/v2/text-to-3d", diff --git a/tests/operation-store.test.ts b/tests/operation-store.test.ts index 8722712..62e9167 100644 --- a/tests/operation-store.test.ts +++ b/tests/operation-store.test.ts @@ -1,17 +1,20 @@ /** * Operation journal (T-043..T-046): start → accepted/unknown/rejected, replay - * on the same id, conflict on a different request, no secrets on disk. + * on the same id, conflict on a different request, identity bound to the + * actual credential and to media content, no secrets on disk, and a real + * multi-process race on one operation id. */ import test from "node:test"; import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { beginOperation, credentialFingerprint, + dataUriDigest, listOperations, payloadFingerprint, readOperation, @@ -20,11 +23,14 @@ import { import { CliError } from "../src/internal/errors.js"; import { tmpDir } from "./helpers/cli.js"; +const KEY_A = "msy_fixture_account_a_0000000000"; +const KEY_B = "msy_fixture_account_b_0000000000"; + const identity = { resource: "text-to-3d", endpoint: "/openapi/v2/text-to-3d", apiOrigin: "http://127.0.0.1:1", - credentialFingerprint: credentialFingerprint({ source: "env", origin: "http://127.0.0.1:1", kind: "api_key" }), + credentialFingerprint: credentialFingerprint({ source: "env", origin: "http://127.0.0.1:1", kind: "api_key", secret: KEY_A }), payloadFingerprint: payloadFingerprint({ mode: "preview", prompt: "a", image_url: "data:image/png;base64,AAAA" }), }; @@ -41,25 +47,55 @@ test("begin → accepted, then a repeat with the same id replays without conflic assert.equal(listOperations(root).length, 1); }); -test("a different payload, credential or origin under the same id is an operation_conflict", () => { +test("a different payload, credential or origin under the same id is an operation_conflict naming what differs", () => { const root = tmpDir("ops-"); beginOperation(root, "op-2", identity); - for (const variant of [ - { ...identity, payloadFingerprint: payloadFingerprint({ mode: "preview", prompt: "b" }) }, - { ...identity, credentialFingerprint: credentialFingerprint({ source: "file", profile: "work", origin: identity.apiOrigin }) }, - { ...identity, apiOrigin: "http://127.0.0.1:2" }, - { ...identity, resource: "image-to-3d" }, - ]) { - assert.throws(() => beginOperation(root, "op-2", variant), (e: unknown) => e instanceof CliError && e.code === "operation_conflict"); + const variants: Array<[string, typeof identity]> = [ + ["payload", { ...identity, payloadFingerprint: payloadFingerprint({ mode: "preview", prompt: "b" }) }], + ["credential", { ...identity, credentialFingerprint: credentialFingerprint({ source: "env", origin: identity.apiOrigin, kind: "api_key", secret: KEY_B }) }], + ["credential", { ...identity, credentialFingerprint: credentialFingerprint({ source: "file", profile: "work", origin: identity.apiOrigin, kind: "api_key", secret: KEY_A }) }], + ["origin", { ...identity, apiOrigin: "http://127.0.0.1:2" }], + ["resource", { ...identity, resource: "image-to-3d" }], + ]; + for (const [what, variant] of variants) { + assert.throws( + () => beginOperation(root, "op-2", variant), + (e: unknown) => e instanceof CliError && e.code === "operation_conflict" && Array.isArray(e.result?.["conflict"]) && (e.result!["conflict"] as string[]).includes(what), + what, + ); } }); -test("fingerprints hide media content and canonicalise key order", () => { - const a = payloadFingerprint({ b: 1, image_url: `data:image/png;base64,${"A".repeat(400)}` }); - const b = payloadFingerprint({ image_url: `data:image/png;base64,${"B".repeat(400)}`, b: 1 }); - assert.equal(a, b, "same length data URI of the same MIME → same fingerprint, content is not stored"); - assert.notEqual(a, payloadFingerprint({ b: 2, image_url: `data:image/png;base64,${"A".repeat(400)}` })); - assert.notEqual(credentialFingerprint({ source: "env", origin: "x" }), credentialFingerprint({ source: "file", profile: "p", origin: "x" })); +test("F05 credential identity is bound to the key digest / OAuth subject, not to the source alone", () => { + const origin = "http://127.0.0.1:1"; + const envA = credentialFingerprint({ source: "env", origin, kind: "api_key", secret: KEY_A }); + const envB = credentialFingerprint({ source: "env", origin, kind: "api_key", secret: KEY_B }); + assert.notEqual(envA, envB, "two keys from the same env source are two identities"); + assert.equal(envA, credentialFingerprint({ source: "env", origin, kind: "api_key", secret: KEY_A }), "the same key is stable"); + assert.ok(!envA.includes(KEY_A.slice(4, 20)), "the fingerprint does not contain the key"); + // OAuth: the subject binds the account; a rotated token changes nothing, a different user does. + const userA = credentialFingerprint({ source: "file", profile: "default", origin, kind: "oauth", subject: "user-a" }); + assert.equal(userA, credentialFingerprint({ source: "file", profile: "default", origin, kind: "oauth", subject: "user-a", secret: "rotated-access-token" }), "token rotation keeps the identity"); + assert.notEqual(userA, credentialFingerprint({ source: "file", profile: "default", origin, kind: "oauth", subject: "user-b" }), "same profile name, different account → different identity"); + assert.notEqual(credentialFingerprint({ source: "env", origin, kind: "api_key" }), credentialFingerprint({ source: "file", profile: "p", origin, kind: "api_key" })); +}); + +test("F06 media fingerprints hash the decoded content: same file matches, different equal-length files do not", () => { + const red = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 255, 0, 0]).toString("base64"); + const green = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 0, 255, 0]).toString("base64"); + assert.equal(red.length, green.length); + const a = payloadFingerprint({ b: 1, image_url: `data:image/png;base64,${red}` }); + const b = payloadFingerprint({ image_url: `data:image/png;base64,${red}`, b: 1 }); + assert.equal(a, b, "key order does not matter"); + assert.notEqual(a, payloadFingerprint({ b: 1, image_url: `data:image/png;base64,${green}` }), "different bytes of the same length → different fingerprint"); + assert.notEqual(a, payloadFingerprint({ b: 2, image_url: `data:image/png;base64,${red}` })); + // Equivalent encodings of the same bytes (line-wrapped base64) are the same request. + const wrapped = red.replace(/(.{4})/g, "$1\n"); + assert.equal(payloadFingerprint({ b: 1, image_url: `data:image/png;base64,${wrapped}` }), a); + // The digest string carries the MIME and a hash, never the payload. + const digest = dataUriDigest(`data:image/png;base64,${red}`); + assert.match(digest, /^data:image\/png;sha256=[0-9a-f]{64}$/); + assert.ok(!digest.includes(red.slice(0, 8))); }); test("records never contain the key, base64 media or signed URLs; files are private", () => { @@ -71,20 +107,40 @@ test("records never contain the key, base64 media or signed URLs; files are priv const text = readFileSync(join(root, files[0]!), "utf8"); assert.ok(!text.includes("AAAA"), "no base64 payload"); assert.ok(!text.includes("msy_"), "no key material"); + assert.ok(!text.includes(KEY_A.slice(4)), "no key fragment"); assert.equal(readOperation(root, "op-3")?.state, "unknown"); }); -test("T-046 two processes racing on the same operation id: exactly one creates, the other sees it", () => { +test("T-046 three processes released together on the same operation id: exactly one creates, the others see its record", async () => { const root = tmpDir("ops-"); + const barrier = tmpDir("barrier-"); const script = fileURLToPath(new URL("./helpers/begin-operation-child.ts", import.meta.url)); - const runs = [1, 2, 3].map(() => - spawnSync(process.execPath, ["--import", "tsx", script, root, "race-1"], { encoding: "utf8", env: { ...process.env, MESHY_CLI_NO_UPDATE_NOTIFIER: "1" } }), + const children = [1, 2, 3].map( + () => + new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => { + const child = spawn(process.execPath, ["--import", "tsx", script, root, "race-1", barrier], { env: { ...process.env, MESHY_CLI_NO_UPDATE_NOTIFIER: "1" } }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (c: Buffer) => (stdout += c.toString())); + child.stderr.on("data", (c: Buffer) => (stderr += c.toString())); + child.on("close", (status) => resolve({ status, stdout, stderr })); + }), ); + // Release the barrier only once every child is spinning at the gate. + const deadline = Date.now() + 15_000; + while (readdirSync(barrier).filter((f) => f.startsWith("ready-")).length < 3) { + if (Date.now() > deadline) throw new Error("children never reached the barrier"); + await new Promise((r) => setTimeout(r, 5)); + } + const { writeFileSync } = await import("node:fs"); + writeFileSync(join(barrier, "go"), ""); + const runs = await Promise.all(children); const outcomes = runs.map((r) => { assert.equal(r.status, 0, r.stderr); - return JSON.parse(r.stdout.trim()) as { outcome: string }; + return JSON.parse(r.stdout.trim()) as { outcome: string; state: string }; }); const created = outcomes.filter((o) => o.outcome === "created").length; assert.equal(created, 1, JSON.stringify(outcomes)); - assert.equal(outcomes.length - created, 2); + assert.equal(outcomes.filter((o) => o.outcome === "existing" && o.state === "started").length, 2, JSON.stringify(outcomes)); + assert.equal(listOperations(root).length, 1, "one record on disk"); }); diff --git a/tests/poll.test.ts b/tests/poll.test.ts index 39f0d2d..d697063 100644 --- a/tests/poll.test.ts +++ b/tests/poll.test.ts @@ -31,7 +31,7 @@ test("pollUntilTerminal — returns immediately when already SUCCEEDED", async ( timeoutSeconds: 5, intervalMs: 250, }); - assert.equal(task.status, "SUCCEEDED"); + assert.equal(task?.status, "SUCCEEDED"); assert.equal(timedOut, false); }); @@ -43,7 +43,7 @@ test("pollUntilTerminal — iterates through PENDING → IN_PROGRESS → SUCCEED intervalMs: 10, onTick: (t) => seen.push(t.status), }); - assert.equal(task.status, "SUCCEEDED"); + assert.equal(task?.status, "SUCCEEDED"); assert.deepEqual(seen, ["PENDING", "IN_PROGRESS", "SUCCEEDED"]); }); @@ -52,14 +52,14 @@ test("pollUntilTerminal — treats FAILED and CANCELED as terminal", async () => timeoutSeconds: 5, intervalMs: 10, }); - assert.equal(a.task.status, "FAILED"); + assert.equal(a.task?.status, "FAILED"); assert.equal(a.timedOut, false); const b = await pollUntilTerminal(fakeEndpoint(["CANCELED"]), "x", { timeoutSeconds: 5, intervalMs: 10, }); - assert.equal(b.task.status, "CANCELED"); + assert.equal(b.task?.status, "CANCELED"); assert.equal(b.timedOut, false); }); @@ -72,7 +72,41 @@ test("pollUntilTerminal — times out when a task never terminates", async () => }); const elapsed = Date.now() - started; assert.equal(timedOut, true); - assert.equal(task.status, "PENDING"); + assert.equal(task?.status, "PENDING"); assert.ok(elapsed >= 200, `elapsed=${elapsed}ms should be at least 200`); assert.ok(elapsed < 2000, `elapsed=${elapsed}ms should not blow past the deadline`); }); + +test("pollUntilTerminal — the deadline bounds the sleep: no request is started after it passed", async () => { + let calls = 0; + const stamps: number[] = []; + const started = performance.now(); + const ep = { + async retrieveDetailed(id: string): Promise<{ task: Task; raw: unknown }> { + calls += 1; + stamps.push(performance.now() - started); + const task = { id, status: "IN_PROGRESS", type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.25, intervalMs: 300 }); + assert.equal(res.timedOut, true); + assert.equal(res.task?.status, "IN_PROGRESS"); + assert.equal(calls, 1, `one GET, then the sleep runs out the budget: ${JSON.stringify(stamps)}`); + assert.ok(stamps.every((t) => t <= 250 + 20), `no GET after the deadline: ${JSON.stringify(stamps)}`); +}); + +test("pollUntilTerminal — --timeout 0 is a single query that is not bounded by the (zero) budget", async () => { + const ep = { + async retrieveDetailed(id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + assert.equal(extras?.timeoutMs, 4321, "the transport read timeout applies, not a 0 ms budget"); + await new Promise((r) => setTimeout(r, 30)); + const task = { id, status: "IN_PROGRESS", type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0, intervalMs: 300, requestTimeoutMs: 4321 }); + assert.equal(res.polls, 1); + assert.equal(res.timedOut, true); + assert.equal(res.task?.status, "IN_PROGRESS"); +}); From 730132bd99a471ed41d6bbf6b200219f13f569ba Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 17:35:34 +0800 Subject: [PATCH 11/26] docs(skill-parity): round-2 handoff after Codex review round 1 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verification.json re-bound to code HEAD 0388fe8 (install/typecheck/test 522 pass/build/pack sha256 6a05293a…/tarball install + 29 smoke checks, live catalog GET re-run) with review_rounds[0] (reviewed 6273d9a, changes_requested, 10 findings fixed, reproduce.mjs re-run: 0/12 reproduce); capability matrix marks 21 capabilities with round_1 fixes and re-review pending; REVIEW_HANDOFF.md adds §0 finding→fix→test→probe table, post-fix sample outputs and the re-review prompt. Live account/OS/GUI checks stay not_run. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/REVIEW_HANDOFF.md | 759 +++++++++++++---------- docs/skill-parity/capability-matrix.json | 403 ++++++++++-- docs/skill-parity/verification.json | 475 ++++++++------ 3 files changed, 1072 insertions(+), 565 deletions(-) diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md index 77caf37..b274e18 100644 --- a/docs/skill-parity/REVIEW_HANDOFF.md +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -1,13 +1,37 @@ # Meshy CLI S1 Review Handoff -Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 2**(Codex review 第 1 轮修复后的复审交接);第 1 轮 review 记录在 `meshy-agent-integrations-research/reviews/cli-s1-6273d9a/`。 + +## 0. 第 1 轮 review 结论与本轮修复 + +- 被 review 的 HEAD:`6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc`);结论 **changes_requested**:10 项 finding(4 P1、6 P2),12 个独立复现(R01–R12),G1-code not_accepted。 +- 修复 commit:`0388fe804b456a931f871d2f227e2acf22359d77`(`fix(review): address Codex review round 1 findings F01–F10`);` 25 files changed, 2266 insertions(+), 295 deletions(-)`(相对 6273d9a)。 +- 复现脚本重跑:`reproduce.mjs` 复制到临时目录后对 `0388fe8` 运行(reviewer 的 evidence 文件未被改动):**0/12 复现**,脚本 exit 1(其语义为"并非全部缺陷都复现")。逐项结果见 verification.json `review_rounds[0].probe_rerun`。 +- 正向回归:`tests/codex-review-round1.test.ts`(17 项,R01–R12 全部按 expected 断言 + 同 id 并发两进程仅 1 次 POST)、`tests/operation-store.test.ts`(媒体碰撞断言改为验证内容差异;T-046 改为 barrier 同步释放的真实并发)、`tests/poll.test.ts`(截止时间约束 sleep;`--timeout 0` 请求上限)。 +- 其它 reviewer 备注:`tests/fixtures/skill-parity/task-error.synthetic.sse` 末尾多余空行已去掉,并以 `.gitattributes`(`*.sse -whitespace`)声明 SSE 终止空行为协议内容;`git diff --check fd94490..HEAD` 现为 exit 0。 + +| ID | 优先级 | 问题 | 修复 | 决策 | 回归测试 | 复现脚本重跑 | +| --- | --- | --- | --- | --- | --- | --- | +| F01 | P1 | 资源命令在提交后的保存或轮询错误中丢失已知任务 ID | `withTaskContext`:受理后的每一步(save-json、轮询 5xx、下载、project 记录、SIGINT)都在任务上下文内抛错,保留原分类/退出码并附带 `result.task_id`/`submission`/`next`;`-o`/`--save-json`/`--project` 在 POST 前预检,可检测冲突 exit 11 且 0 请求 | D-032, D-034 | R05/F01、R06/F01、F01 make(codex-review-round1) | R05: exit 11, 不再复现, R06: exit 1, 不再复现 | +| F02 | P1 | make 把受理后的 journal 写入失败误报为 submission_unknown | make 改用与资源命令共享的 `submitCreate`:受理后 journal 写失败 → `local_io` exit 11,保留 task_id/next/step;单一提交状态机 | D-034 | R11/F02(文本与图片两条路径) | R11: exit 11, 不再复现 | +| F03 | P1 | project 和任务 -o 绕过用户显式指定的 workspace | `downloadArtifacts` 接受 root(=workspace),目录/每个文件/sidecar 在 mkdir 前校验;project init/record/rebuild-index、任务动词与 download 的 `--project`、`make -o` 全部受限;create/make 在 POST 前检查 `-o` | D-032 | R08/F03、R09/F03 | R08: exit 11, 不再复现, R09: exit 11, 不再复现 | +| F04 | P1 | OBJ 材质复制可通过目标父目录符号链接写出 workspace | 每个依赖复制目标按写入根(workspace,否则输出目录)做 `resolveWithinRoot`(真实路径、拒绝符号链接叶子),在 mkdir/复制前与发布前各一次;报告保留计划路径 | D-033 | R12/F04;obj-transform T-086 | R12: exit 11, 不再复现 | +| F05 | P2 | 账户指纹没有绑定实际 API Key,会跨账户重放旧任务 | 指纹加入 API Key 单向摘要(域前缀 sha256)或 OAuth 主体 `user_id`(token 轮换不改变身份);冲突消息列出差异项 `result.conflict` | D-029 | R01/F05 ×2;operation-store F05;并发同 id 两进程仅 1 次 POST | R01: exit 2, 不再复现 | +| F06 | P2 | 媒体指纹只比较 MIME 和长度,不同图片被当成同一请求 | data URI 以解码字节 sha256 参与指纹(`data:;sha256=…`),等价编码相同、等长不同内容冲突;错误的碰撞断言测试已替换 | D-030 | R02/F06;operation-store F06 | | +| F07 | P2 | wait 总截止时间没有约束在途 HTTP 请求 | 每次 GET 携带 `min(剩余预算, 读超时)`;deadline 约束的超时即 timed_out(exit 8);sleep 不越界、预算耗尽后不再发 GET;`PollResult.task` 在首个响应前超时为 null,legacy 也输出 task id + timed_out;`--timeout 0` 单次查询不受 0 预算限制 | D-031 | R03/F07 ×2(慢 header、慢 body、sleep 到期、迟到 SUCCEEDED、--timeout 0);poll.test.ts 新增 2 项 | R03: exit 8, 不再复现 | +| F08 | P2 | Creative Lab 的 --options 会整体覆盖 --data.options | `CreateSpec.nestedObjectKeys`(build: options/output)按字段合并 defaults < --data < flags,typed 优先,false/0 保留,校验看到合并后对象;其余字段仍为浅合并 | D-035 | R04/F08(三层真实 argv 检查 POST body) | | +| F09 | P2 | OBJ 与 MTL 下载重命名后没有修复相对材质引用 | 新增 `material-links.ts`:落盘后重写 OBJ `mtllib` → 实际 MTL、MTL `map_*` → 实际贴图(精确名 → 引用名中的通道词 → MTL key 通道 → 唯一贴图),未解析项原样保留并 warning;manifest 条目 `relinked`+新 sha256,`result.downloads.material_links`;legacy `-o` 同样重链接;ZIP bundle/--geometry-only 不改写 | D-036 | R07/F09、F09 unresolved(读取最终 OBJ/MTL 验证引用存在) | | +| F10 | P2 | stream 的 ndjson 模式成功退出却不执行 -o 下载 | 下载与 project 记录移到格式分支之前;ndjson 的 `outcome` 携带 downloads;下载失败只输出一条 ok:false outcome 并以其退出码退出 | D-037 | R10/F10(ndjson/json/pretty × 成功/失败) | R10: exit 0, 不再复现 | + +未改动的范围:请求路由/registry、transport 凭据边界、SSE 解析、project 锁顺序、OBJ 数值公式、slicer 规则;所有 legacy 输出形状除 §3.1(migration-notes)列出的修正外不变。 ## 1. 代码定位 - 仓库路径 / remote:`/Users/ark/Dev/meshy-cli`(fresh clone) / `https://github.com/meshy-dev/meshy-cli.git` - 分支:`feat/skill-parity-s1`(本地分支,未推送) - base SHA:`fd94490916376e691efcea51324ac4326b459e1f`(0.2.0,= 计划基线 = 开工时的 remote main) -- head SHA(代码):`e7c26fc053cea4e1bf7dee08953e25a3ec858137`;本文件与 verification.json/capability-matrix.json 在其后的 docs-only commit 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`) +- head SHA(代码):`0388fe804b456a931f871d2f227e2acf22359d77`;本文件与 verification.json/capability-matrix.json 在其后的 docs-only commit 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`) +- 上一轮被 review 的 HEAD:`6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc053cea4e1bf7dee08953e25a3ec858137`) - 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) - 实施包版本:2026-09-07 / v1 - 实际源码与计划基线的差异:无(remote main 与 Skills HEAD 均等于计划 SHA,见 `docs/skill-parity/baseline-delta.md`) @@ -16,18 +40,20 @@ Filled from the 2026-09-07 / v1 implementation package. `not_run` means not veri ## 2. 完成状态 -- G1-code / review-ready:**passed**(自评;等待 Codex review) -- G1-release / ready-for-S2:**not_run**(无 review 结论、无真实账号/多 OS/切片器验证、未发布) -- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`,全部 `implementation_status: implemented`,`review_status: not_run`) -- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **502 通过 / 0 失败 / 0 跳过**(502 项,含基线原有 363 项;覆盖 73 个 T-id 的离线部分,见 verification.json `behavior_tests`) -- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open -- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(meshyd 仅只读核对 6 个文件,见 baseline.json) +- G1-code / review-ready:**第 1 轮 10 项 finding 已全部修复并有正向回归;自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) +- G1-release / ready-for-S2:**not_run**(无复审结论、无真实账号/多 OS/切片器验证、未发布) +- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`,全部 `implementation_status: implemented`;21 项带 `review_round_1: fixed`,`review_status` = "round 1 findings fixed; re-review pending",其余 `not_run`) +- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **522 通过 / 0 失败 / 0 跳过**(522 项 = 上轮 502 + 本轮 20;含基线原有 363 项;覆盖 73 个 T-id 的离线部分,见 verification.json `behavior_tests`) +- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,本轮在 0388fe8 重跑)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open +- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(meshyd 仅只读核对 6 个文件,见 baseline.json;reviewer 目录未被改动) ## 3. 本次具体改动 `git log --oneline fd94490..HEAD`(最早在下): ``` +0388fe8 fix(review): address Codex review round 1 findings F01–F10 +6273d9a docs(skill-parity): verification record and review handoff for the 0.3.0 candidate e7c26fc chore(release): 0.3.0 candidate — README, bundled skill, env example, origin-policy test da7e1dc feat(local): B06-B08 inspect faces, OBJ prepare-print, slicers and doctor 96ee188 feat(project): B05 meshy_output project store, project command and --project bookkeeping @@ -38,7 +64,7 @@ e7ea577 feat(cli): B01 v1 envelope, exit codes, local runtime, --api-key-file an c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixtures ``` -` 108 files changed, 14888 insertions(+), 881 deletions(-)` +` 113 files changed, 18587 insertions(+), 905 deletions(-)` | 批次 | commit | 改动与用户行为 | 关键验证 | | --- | --- | --- | --- | @@ -50,22 +76,23 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt | B05 | `feat(project): B05` | metadata.json v2 + legacy 迁移备份、history 索引、(task_id,stage) 合并、锁顺序、index_dirty;`project init/record/show/list/rebuild-index`;任务动词与 download 的 `--project/--stage` | tests/project-store(含多进程) | | B06–B08 | `feat(local): B06-B08` | `inspect faces`(unknown≠0,exit 12/13,仅描述 remesh);`mesh prepare-print`(两遍流式、oracle 校验、材质依赖、`--in-place`);`slicer detect/open`(七注册、三 OS 规则、无默认应用 fallback);`doctor`(默认本地,`--check-api` 单次 balance) | tests/inspect, obj-transform, slicers, doctor | | B09 | `chore(release)` + docs commit | 0.3.0 候选版本、README、随包 SKILL.md、.env.example、T-105;verification.json、REVIEW_HANDOFF | 本节 5、7 | +| Review R1 | `fix(review)` `0388fe8` | §0 表中的 F01–F10;新增 `src/internal/material-links.ts`;`task-command.ts` 任务上下文/预检/共享 `submitCreate`;`poll.ts` 截止时间语义;`operation-store.ts` 指纹;`payload.ts` 嵌套合并;`obj-transform.ts`/`download.ts`/`project.ts`/`mesh.ts`/`make.ts` 写入根;README/SKILL/decisions D-029..D-037/migration-notes §3.1 | tests/codex-review-round1, operation-store, poll + 复现脚本重跑 | ## 4. 能力与接口证据 -- 更新后的 capability-matrix 路径:`docs/skill-parity/capability-matrix.json` -- endpoint-contracts 与模型/媒体参数映射:`docs/skill-parity/endpoint-contracts.json`(与 `src/client/resource-registry.ts` 由 `tests/resource-registry.test.ts` 逐字段对账) -- v1 JSON schema / 错误码定义:`src/internal/result.ts`(六固定键)、`src/internal/errors.ts`(`CliErrorCode` → 退出码表);`README.md` "Stable machine output" -- legacy compatibility / migration notes:`docs/skill-parity/migration-notes.md`(§3 兼容表:get 非终态 exit 0、`make --async` 单次 POST、Commander 错误 exit 2、`rigging list` 启用;其余 legacy 输出不变) +- 更新后的 capability-matrix 路径:`docs/skill-parity/capability-matrix.json`(`review_rounds`、各能力 `review_round_1`) +- endpoint-contracts 与模型/媒体参数映射:`docs/skill-parity/endpoint-contracts.json`(与 `src/client/resource-registry.ts` 由 `tests/resource-registry.test.ts` 逐字段对账;本轮未改) +- v1 JSON schema / 错误码定义:`src/internal/result.ts`(六固定键)、`src/internal/errors.ts`(`CliErrorCode` → 退出码表);`README.md` "Stable machine output"。本轮新增字段(均为附加):`result.downloads.material_links`、manifest 条目 `relinked`、`result.conflict`(operation_conflict)、失败结果统一携带 `task_id`/`next` +- legacy compatibility / migration notes:`docs/skill-parity/migration-notes.md`(§3 兼容表 + §3.1 本轮修正:create/make 对已存在 `-o`/`--save-json` 的拒绝提前到 POST 前并改为 exit 11;受理后失败保留 task 上下文;wait 迟到响应为 exit 8;`--workspace` 全面生效;OBJ 引用重写;stream ndjson `-o` 下载) - intentional differences:`docs/skill-parity/migration-notes.md` §2(2.1–2.12),关键项: - - `--api-key-file` 而非契约中的 `--env-file`:Node 22/24 会在整个 argv 预扫描 `--env-file` 并自行加载整个文件(含 NODE_OPTIONS)、缺失时 exit 9(决策 D-025,实验记录在 decisions.md) + - `--api-key-file` 而非契约中的 `--env-file`:Node 22/24 会在整个 argv 预扫描 `--env-file` 并自行加载整个文件(含 NODE_OPTIONS)、缺失时 exit 9(决策 D-025) - `showcase_type` 服务端枚举为 `animate`,文档写 `animated`;CLI 两者都接受,发送 `animate` 并给 warning(D-007) - - 公共任务 DTO 不含 `face_count`(meshyd `httpapi/dto.go` 只读核对)→ API 来源的 `inspect faces` 通常为 unknown/exit 13,绝不当 0(D-009) - - `rigging list` 启用(官方文档 + 服务端路由均存在;0.2.0 标为不支持) - - lamp prototype 已废弃 `text` 输入在提交前拒绝 -- 限权 API / 未支持的 OS / deferred 新功能:UV/Creative Lab/showcases 真实调用 not_run;Windows/Linux 仅 fixture 覆盖;keycap/vinyl-figure/brick-figure/clover-fidget 等新 Creative Lab 产品记录为 deferred(矩阵 DF-001) + - 公共任务 DTO 不含 `face_count` → API 来源的 `inspect faces` 通常为 unknown/exit 13,绝不当 0(D-009) + - `rigging list` 启用;lamp prototype 已废弃 `text` 输入在提交前拒绝 + - 本轮新增的设计选择:下载后**重写**引用而非保留服务端文件名(D-036);OAuth 无 `user_id` 的 profile 只能绑定 profile 名(D-029 记录为限制) +- 限权 API / 未支持的 OS / deferred 新功能:UV/Creative Lab/showcases 真实调用 not_run;Windows/Linux 仅 fixture 覆盖;新 Creative Lab 产品记录为 deferred(矩阵 DF-001) -### 脱敏实际 CLI 输出(loopback mock 或本地;``/`` 为替换后的路径) +### 脱敏实际 CLI 输出(tarball 安装的 `0388fe8`,loopback mock;``/`` 为替换后的路径) `text-to-3d create --mode preview --prompt "a cactus" --async --output-schema v1`(exit=0): @@ -78,7 +105,7 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "task": null, "submission": { "state": "accepted", - "operation_id": "2eb38a76-8e3f-48e1-9b0f-f8a3fff9d877", + "operation_id": "2e6f3144-d714-4e27-bc1e-c99ca1c971bc", "task_id": "fixture-task-a", "request_id": null }, @@ -244,6 +271,7 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "metadata_path": null }, "saved_json": null, + "task_id": "fixture-task-a", "wait": { "timed_out": true, "elapsed_seconds": 0.01, @@ -280,7 +308,7 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "result": { "submission": { "state": "unknown", - "operation_id": "b942c376-1d0d-430d-8419-d031041ae7a0", + "operation_id": "74ddf9fc-7ab6-4327-acca-9f65a71b3f4f", "task_id": null }, "task": null, @@ -298,14 +326,14 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "recovery": { "action": "reconcile", "automatic": false, - "command": "meshy text-to-3d list --output-schema v1 # then match operation b942c376-1d0d-430d-8419-d031041ae7a0 by time/prompt before creating again" + "command": "meshy text-to-3d list --output-schema v1 # then match operation 74ddf9fc-7ab6-4327-acca-9f65a71b3f4f by time/prompt before creating again" } }, "warnings": [] } ``` -`download --resource rigging --task-id fixture-rig-1 --asset result.basic_animations.walking_glb_url --output walking.glb`(exit=0): +`download --resource rigging --task-id fixture-rig-1 --asset result.basic_animations.walking_glb_url --output walking.glb`(exit=0;manifest 新增 `relinked`/`material_links`): ```json { @@ -340,10 +368,12 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "extracted": null, "status": "written", "error": null, - "publish_method": "link" + "publish_method": "link", + "relinked": false } ], - "metadata_path": null + "metadata_path": null, + "material_links": null }, "unknown_urls": [], "saved_json": null, @@ -354,423 +384,506 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt } ``` -`project record --project --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb`(tarball 安装 smoke,exit 0): +### 第 1 轮 finding 修复后的实际输出(复现脚本重跑,`0388fe8`,脱敏) + +R01 · 换 API Key 复用同一 `--operation-id`(exit 2,0 次新 POST): ```json { "schema_version": "meshy.cli/v1", - "command": "project.record", - "ok": true, + "command": "text-to-3d.create", + "ok": false, "result": { - "project_dir": "/meshy_output/20260907_162908_smoke-demo_fixture-", - "action": "added", - "entry": { - "task_id": "fixture-rig-1", - "task_type": "rigging", - "resource": "rigging", - "endpoint": null, - "stage": "rigged", - "parent_task_id": null, - "status": null, - "files": [ - "rigged.glb" - ], - "task_json": null, - "operation_id": null, - "created_at": "2026-09-07T08:29:09.067Z", - "updated_at": "2026-09-07T08:29:09.067Z" - }, - "task_count": 1, - "index": { - "updated": true, - "error": null + "submission": { + "state": "accepted", + "operation_id": "same-credential-op", + "task_id": "account-a-task" }, - "migrated_from_legacy": false + "conflict": [ + "credential" + ] }, - "error": null, - "warnings": [ - { - "code": "recorded_file_missing", - "message": "recorded file(s) not present in the project yet: rigged.glb" - } - ] + "error": { + "code": "operation_conflict", + "message": "operation same-credential-op already exists for a different request (credential differ); nothing was submitted — use a new --operation-id for a new request", + "http_status": null, + "retryable": false, + "recovery": null + }, + "warnings": [] } ``` -`project show --project `(exit 0): +R03 · `wait --timeout 0.05`,GET 400 ms 后才返回 SUCCEEDED(exit 8,task 为 null,保留 task_id/next): ```json { "schema_version": "meshy.cli/v1", - "command": "project.show", - "ok": true, + "command": "text-to-3d.wait", + "ok": false, "result": { - "project_dir": "/meshy_output/20260907_162908_smoke-demo_fixture-", - "legacy_format": false, - "metadata": { - "schema_version": 2, - "project_name": "smoke demo", - "folder": "20260907_162908_smoke-demo_fixture-", - "root_task_id": "fixture-rig-1", - "created_at": "2026-09-07T08:29:08.930Z", - "updated_at": "2026-09-07T08:29:09.067Z", - "tasks": [ - { - "task_id": "fixture-rig-1", - "task_type": "rigging", - "resource": "rigging", - "endpoint": null, - "stage": "rigged", - "parent_task_id": null, - "status": null, - "files": [ - "rigged.glb" - ], - "task_json": null, - "operation_id": null, - "created_at": "2026-09-07T08:29:09.067Z", - "updated_at": "2026-09-07T08:29:09.067Z" - } - ] + "task": null, + "submission": { + "state": "accepted", + "operation_id": null }, - "files": [ - { - "task_id": "fixture-rig-1", - "stage": "rigged", - "file": "rigged.glb", - "present": false - } - ] + "downloads": { + "state": "not_requested", + "files": [], + "metadata_path": null + }, + "saved_json": null, + "task_id": "review-task", + "wait": { + "timed_out": true, + "elapsed_seconds": 0.05, + "polls": 0 + }, + "next": { + "get": "meshy text-to-3d get review-task --output-schema v1", + "wait": "meshy text-to-3d wait review-task --output-schema v1", + "stream": "meshy text-to-3d stream review-task --format ndjson --output-schema v1" + } + }, + "error": { + "code": "timed_out", + "message": "task review-task did not answer within 0.05s (no status was received in time); the server keeps running it", + "http_status": null, + "retryable": false, + "recovery": { + "action": "wait", + "automatic": false, + "command": "meshy text-to-3d wait review-task --output-schema v1" + } }, - "error": null, "warnings": [] } ``` -`inspect faces --task-json - --max-faces 300000`,任务无 face_count(exit 13): +R05 · `--save-json` 指向已存在文件(exit 11,**0 次 POST**): ```json { "schema_version": "meshy.cli/v1", - "command": "inspect.faces", + "command": "text-to-3d.create", "ok": false, - "result": { - "face_count": null, - "limit": 300000, - "comparison": "lte", - "verdict": "unknown", - "reason": "face_count missing", - "source": { - "kind": "task-json", - "path": "/dev/stdin", - "shape": "api" - }, - "task_id": "x", - "status": "SUCCEEDED", - "suggestion": null, - "saved_json": null - }, + "result": null, "error": { - "code": "check_unknown", - "message": "face count unknown: face_count missing", + "code": "local_io", + "message": "--save-json target occupied.json already exists; choose another path (nothing was submitted)", "http_status": null, "retryable": false, - "recovery": null + "recovery": { + "action": "choose_path", + "automatic": false + } }, "warnings": [] } ``` -`mesh prepare-print box-y-up.obj --height-mm 80`(exit 0;与 fixture oracle 一致:scale 20,bbox [-20,-60,0]..[20,60,80]): +R06 · 同步 create,POST 受理后 GET 503(exit 1,保留 task_id/submission/next): ```json { "schema_version": "meshy.cli/v1", - "command": "mesh.prepare-print", - "ok": true, + "command": "text-to-3d.create", + "ok": false, "result": { - "input": "/box-y-up.obj", - "output": "/box-y-up.print.obj", - "height_mm": 80, - "scale": 20, - "rotation": "(x,y,z)->(x,-z,y)", - "translation": [ - 0, - 0, - -40 - ], - "before_bbox": { - "min": [ - -1, - 2, - -3 - ], - "max": [ - 1, - 6, - 3 - ] - }, - "after_bbox": { - "min": [ - -20, - -60, - 0 - ], - "max": [ - 20, - 60, - 80 - ] + "task": null, + "submission": { + "state": "accepted", + "operation_id": "", + "task_id": "accepted-before-get-error", + "request_id": null }, - "counts": { - "vertices": 8, - "normals": 6, - "uvs": 4, - "faces": 6, - "lines_total": 28 + "downloads": { + "state": "not_requested", + "files": [], + "metadata_path": null }, - "material": { - "mtllib": [ - "box.mtl" - ], - "copied": [], - "missing": [] + "saved_json": null, + "task_id": "accepted-before-get-error", + "next": { + "get": "meshy text-to-3d get accepted-before-get-error --output-schema v1", + "wait": "meshy text-to-3d wait accepted-before-get-error --output-schema v1", + "stream": "meshy text-to-3d stream accepted-before-get-error --format ndjson --output-schema v1" }, - "in_place": false, - "warnings": [] + "wait": { + "timed_out": false, + "elapsed_seconds": 0, + "polls": 0 + } + }, + "error": { + "code": "server", + "message": "meshy api 503 on /text-to-3d/accepted-before-get-error: synthetic temporary outage", + "http_status": 503, + "retryable": false, + "recovery": null, + "details": { + "path": "/text-to-3d/accepted-before-get-error", + "body": { + "message": "synthetic temporary outage" + } + } }, - "error": null, "warnings": [] } ``` -`slicer detect`(exit 0;本机未安装任何注册切片器,空列表即成功): +R09 · `get --workspace W -o OUTSIDE/model.glb`(exit 11,无资产请求、无文件): ```json { "schema_version": "meshy.cli/v1", - "command": "slicer.detect", - "ok": true, + "command": "text-to-3d.get", + "ok": false, "result": { - "platform": "darwin", - "slicers": [ - { - "id": "bambu-studio", - "name": "Bambu Studio", - "path": "/Applications/BambuStudio.app", - "multicolor": true, - "platform": "darwin" - } - ], - "unsupported": [] + "task": { + "task_id": "review-task", + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", + "name": null, + "status": "SUCCEEDED", + "progress": null, + "preceding_tasks": null, + "created_at": null, + "started_at": null, + "finished_at": null, + "expires_at": null, + "face_count": null, + "consumed_credits": null, + "model_urls": { + "glb": "http://127.0.0.1:/asset.glb" + }, + "image_urls": [], + "texture_urls": [], + "thumbnail_url": null, + "thumbnail_urls": null, + "alpha_thumbnail_url": null, + "result": null, + "printability": null, + "task_error": null + }, + "submission": { + "state": "accepted", + "operation_id": null + }, + "downloads": { + "state": "failed", + "files": [], + "metadata_path": null + }, + "saved_json": null, + "task_id": "review-task", + "next": { + "get": "meshy text-to-3d get review-task --output-schema v1", + "wait": "meshy text-to-3d wait review-task --output-schema v1", + "stream": "meshy text-to-3d stream review-task --format ndjson --output-schema v1" + } + }, + "error": { + "code": "local_io", + "message": "task review-task is SUCCEEDED but downloading its assets failed: output directory /outside resolves outside the authorised root /private/workspace", + "http_status": null, + "retryable": false, + "recovery": null }, - "error": null, "warnings": [] } ``` -`doctor`(exit 0,无网络): +R10 · `stream --format ndjson -o stream.glb`(exit 0;outcome 行携带 downloads.completed): ```json -{ - "schema_version": "meshy.cli/v1", - "command": "doctor", - "ok": true, - "result": { - "cli": { - "version": "0.3.0", - "node": "v24.20.0", - "platform": "darwin", - "arch": "arm64" - }, - "local_ready": true, - "api_ready": null, - "checks": [ - { - "id": "cli", - "status": "ok", - "detail": "meshy-cli 0.3.0 on node v24.20.0 (darwin arm64)" - }, - { - "id": "node", - "status": "ok", - "detail": "node v24.20.0 satisfies the required >=24" +[ + { + "schema_version": "meshy.cli/v1", + "command": "text-to-3d.stream", + "ok": true, + "result": { + "task": { + "task_id": "review-task", + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", + "name": null, + "status": "SUCCEEDED", + "progress": null, + "preceding_tasks": null, + "created_at": null, + "started_at": null, + "finished_at": null, + "expires_at": null, + "face_count": null, + "consumed_credits": null, + "model_urls": { + "glb": "http://127.0.0.1:/asset.glb" + }, + "image_urls": [], + "texture_urls": [], + "thumbnail_url": null, + "thumbnail_urls": null, + "alpha_thumbnail_url": null, + "result": null, + "printability": null, + "task_error": null }, - { - "id": "base_urls", - "status": "ok", - "detail": "v1 https://api.meshy.ai/openapi/v1; v2 https://api.meshy.ai/openapi/v2; creative-lab https://api.meshy.ai/openapi/creative-lab; public-web https://api.meshy.ai/web/public" + "submission": { + "state": "accepted", + "operation_id": null }, - { - "id": "api_key_file", - "status": "skipped", - "detail": "--api-key-file not given" + "downloads": { + "state": "not_requested", + "files": [], + "metadata_path": null }, - { - "id": "credentials", - "status": "warn", - "detail": "sources present: --api-key=no, MESHY_API_KEY=no, --api-key-file=none, stored profile=absent (/credentials.json); values are never read by doctor. API commands need --api-key, MESHY_API_KEY, --api-key-file or `meshy auth login`" + "saved_json": null + }, + "error": null, + "warnings": [], + "event": "task", + "sequence": 1 + }, + { + "schema_version": "meshy.cli/v1", + "command": "text-to-3d.stream", + "ok": true, + "result": { + "task": { + "task_id": "review-task", + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", + "name": null, + "status": "SUCCEEDED", + "progress": null, + "preceding_tasks": null, + "created_at": null, + "started_at": null, + "finished_at": null, + "expires_at": null, + "face_count": null, + "consumed_credits": null, + "model_urls": { + "glb": "http://127.0.0.1:/asset.glb" + }, + "image_urls": [], + "texture_urls": [], + "thumbnail_url": null, + "thumbnail_urls": null, + "alpha_thumbnail_url": null, + "result": null, + "printability": null, + "task_error": null }, - { - "id": "workspace", - "status": "skipped", - "detail": "--workspace not given (files land next to their targets)" + "submission": { + "state": "accepted", + "operation_id": null }, - { - "id": "cwd_env_files", - "status": "ok", - "detail": "no .env or .env.local in (none is ever read automatically)" + "downloads": { + "state": "completed", + "files": [ + { + "path": "/workspace/stream.glb", + "status": "written" + } + ], + "metadata_path": "/workspace/stream_meta.json", + "material_links": null }, - { - "id": "api", - "status": "skipped", - "detail": "not requested (pass --check-api for one free GET /balance)" + "saved_json": null, + "stream": { + "events": 1, + "ended": "terminal", + "elapsed_seconds": 0.01 }, - { - "id": "slicers", - "status": "skipped", - "detail": "not requested (pass --check-slicers)" - } - ], - "credential_sources": { - "flag": false, - "env": false, - "api_key_file": null, - "stored_profile": { - "path": "/credentials.json", - "exists": false + "task_id": "review-task", + "next": { + "get": "meshy text-to-3d get review-task --output-schema v1", + "wait": "meshy text-to-3d wait review-task --output-schema v1", + "stream": "meshy text-to-3d stream review-task --format ndjson --output-schema v1" } }, - "base_urls": { - "v1": "https://api.meshy.ai/openapi/v1", - "v2": "https://api.meshy.ai/openapi/v2", - "creative_lab": "https://api.meshy.ai/openapi/creative-lab", - "public_web": "https://api.meshy.ai/web/public" + "error": null, + "warnings": [], + "event": "outcome", + "sequence": 2 + } +] +``` + +R11 · `make --async`,受理后 journal 记录消失(exit 11,submission accepted,task_id 保留): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "make", + "ok": false, + "result": { + "submission": { + "state": "accepted", + "operation_id": "", + "task_id": "make-known-accepted-id", + "request_id": null }, - "workspace": { - "path": null, - "writable": null + "task": null, + "task_id": "make-known-accepted-id", + "next": { + "get": "meshy text-to-3d get make-known-accepted-id --output-schema v1", + "wait": "meshy text-to-3d wait make-known-accepted-id --output-schema v1", + "stream": "meshy text-to-3d stream make-known-accepted-id --format ndjson --output-schema v1" }, - "cwd_env_candidates": [], - "slicers": null, - "api": null, - "saved_json": null + "step": 1, + "route": "text", + "executed": [] + }, + "error": { + "code": "local_io", + "message": "task make-known-accepted-id was created but the operation journal could not be updated: operation record 3a428870-a191-4923-8dbf-ed8806e63eee disappeared before it could be updated", + "http_status": null, + "retryable": false, + "recovery": null + }, + "warnings": [] +} +``` + +R12 · `mesh prepare-print`,目标 `materials/` 为指向 workspace 外的符号链接(exit 11,外部目录为空): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "mesh.prepare-print", + "ok": false, + "result": null, + "error": { + "code": "local_io", + "message": "material dependency target for 'materials/a.mtl' /private/workspace/target/materials/a.mtl resolves outside the authorised root /private/workspace", + "http_status": null, + "retryable": false, + "recovery": null }, - "error": null, "warnings": [] } ``` ## 5. 实际验证记录 -全部绑定代码 HEAD `e7c26fc053cea4e1bf7dee08953e25a3ec858137`,环境 macOS 26.6 arm64 / Node v24.20.0 / pnpm 11.24.0;日志见 `docs/skill-parity/verification.json`(`final_runs`、`package_smoke`)。 +全部绑定代码 HEAD `0388fe804b456a931f871d2f227e2acf22359d77`,环境 macOS 26.6 arm64 / Node v24.20.0 / pnpm 11.24.0;日志见 `docs/skill-parity/verification.json`(`final_runs`、`package_smoke`、`review_rounds`)。 | 命令 / 测试 ID | 代码 SHA | 环境 | exit code | 结果 | 日志/fixture/报告 | | --- | --- | --- | --- | --- | --- | -| `node --version` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm --version` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm install --frozen-lockfile` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm typecheck` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm test` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed (502 tests, 502 pass, 0 fail) | verification.json final_runs | -| `pnpm build` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `bash -c test "$(node dist/index.js --version)" = "$(node -p "require(\"./package.json\").version")"` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/meshy-cli-0.3.0.tgz` | e7c26fc053ce | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| npm pack 文件清单 + 必需文件 + 禁止内容 | e7c26fc053ce | 同上 | 0 | passed(394 files;dist/skills/README/LICENSE/.env.example 存在;无 .env/credentials/tests/docs/src) | verification.json package_smoke | -| smoke:version | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:version_cli | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:help_cli | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:balance_help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:balance_nokey_json | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:balance_nokey_v1 | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:resources_v1 | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor_check_slicers | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor_check_api_nokey | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:slicer_detect | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:inspect_pass | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:inspect_fail | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 12 (expected 12) | passed | verification.json package_smoke | -| smoke:inspect_unknown | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 13 (expected 13) | passed | verification.json package_smoke | -| smoke:mesh_prepare | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:mesh_refuse_overwrite | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 11 (expected 11) | passed | verification.json package_smoke | -| smoke:project_init | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_record | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_show | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_list | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:download_list | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:download_no_selector | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | -| smoke:make_dry_run | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:usage_unknown_flag | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | -| smoke:uv_help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:creative_lab_help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:stream_help | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:catalog_live | e7c26fc053ce | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| 新增行为用例 T-001…T-108(离线部分) | e7c26fc053ce | node:test + tsx,loopback mock,临时目录 | 0 | passed(`pnpm test` 502/502) | tests/*.test.ts;verification.json behavior_tests | - -基线(fd94490)上 `pnpm test` 363/363 通过,无基线失败;本次没有删除或跳过任何既有测试(`tests/surface.test.ts` 的模型/废弃参数约束原样保留并通过)。 +| `node --version` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm --version` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm install --frozen-lockfile` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm typecheck` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm test` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed (522 tests, 522 pass, 0 fail) | verification.json final_runs | +| `pnpm build` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `bash -c test "$(node dist/index.js --version)" = "$(node -p "require(\"./package.json\").version")"` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/meshy-cli-0.3.0.tgz` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| npm pack 文件清单 + 必需文件 + 禁止内容 | 0388fe804b45 | 同上 | 0 | passed(398 files;dist/skills/README/LICENSE/.env.example 存在;无 .env/credentials/tests/docs/src) | verification.json package_smoke | +| smoke:version | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:version_cli | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:help_cli | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:balance_help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:balance_nokey_json | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:balance_nokey_v1 | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:resources_v1 | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor_check_slicers | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor_check_api_nokey | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:slicer_detect | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:inspect_pass | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:inspect_fail | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 12 (expected 12) | passed | verification.json package_smoke | +| smoke:inspect_unknown | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 13 (expected 13) | passed | verification.json package_smoke | +| smoke:mesh_prepare | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:mesh_refuse_overwrite | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 11 (expected 11) | passed | verification.json package_smoke | +| smoke:project_init | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_record | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_show | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_list | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:download_list | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:download_no_selector | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | +| smoke:make_dry_run | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:usage_unknown_flag | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | +| smoke:uv_help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:creative_lab_help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:stream_help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:catalog_live | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| 第 1 轮复现脚本 `reproduce.mjs`(12 场景) | 0388fe804b45 | node v24.20.0,loopback,临时目录 | 1(= 并非全部复现) | 0/12 复现 | verification.json review_rounds[0].probe_rerun | +| 新增行为用例 T-001…T-108(离线部分)+ R01–R12 | 0388fe804b45 | node:test + tsx,loopback mock,临时目录 | 0 | passed(`pnpm test` 522/522) | tests/*.test.ts;verification.json behavior_tests | + +基线(fd94490)上 `pnpm test` 363/363 通过,无基线失败;本次没有删除或跳过任何既有测试(`tests/surface.test.ts` 的模型/废弃参数约束原样保留并通过)。唯一被改写的既有断言是 reviewer 指出为错误的媒体指纹碰撞断言(tests/operation-store.test.ts)。 ## 6. 真实环境验证 | 环境/能力 | 身份/资源(脱敏) | 实际结果 | 未完成原因 | 补验方式 | | --- | --- | --- | --- | --- | -| API Key 与 OAuth profile 回归 | 无 | not_run | 本会话无测试账户/Key | 用测试 Key:`meshy doctor --check-api`、`meshy balance --output-schema v1`、`meshy auth status`;OAuth:`meshy auth login` 后 `meshy text-to-3d list --output-schema v1` | +| API Key 与 OAuth profile 回归 | 无 | not_run | 本会话无测试账户/Key | 用测试 Key:`meshy doctor --check-api`、`meshy balance --output-schema v1`、`meshy auth status`;OAuth:`meshy auth login` 后 `meshy text-to-3d list --output-schema v1`;额外核对 D-029:`auth login` 写入的 profile 是否带 `user_id`(决定 journal 的账户绑定) | | UV / Creative Lab | 无 | not_run | 计费/账号 gate,未获预算授权 | `meshy uv-unwrap create --input-task-id <已有 SUCCEEDED 任务> --async --output-schema v1` → `wait`;`meshy creative-lab figure prototype create --image-url --async` → `wait` → `build create --input-task-id`;记录 task id、请求次数、消耗额度 | | Enterprise showcases(计费查询) | 无 | not_run | 每次请求计费且需 Enterprise | `meshy showcases list --page-size 1 --output-schema v1`(一次);核对 `showcase_type=animated` 别名是否被服务端接受(D-007) | -| 公开动画目录(免费、无鉴权) | 无需身份 | passed(1 次 GET,157 条,8 条匹配 "wave") | — | verification.json live_verification T-110 partial | +| 公开动画目录(免费、无鉴权) | 无需身份 | passed(1 次 GET,157 条,8 条匹配 "wave",在 0388fe8 重跑) | — | verification.json live_verification T-110 partial | | macOS arm64 | 本机 | passed(tarball 安装 + 29 项 smoke) | — | verification.json package_smoke | | Windows x64 | 无 | not_run | 无主机 | 安装 tarball,运行 `meshy doctor --check-slicers`、`meshy slicer detect`、`meshy mesh prepare-print`;检测规则已用 fixture 覆盖(T-088/T-089) | | Linux x64 | 无 | not_run | 无主机 | 同上;CI(ubuntu, Node 24/26)会在推送后覆盖安装 smoke | | 真实 slicer open | 无 | not_run | 本机未安装任何注册切片器 | 安装 OrcaSlicer 后 `meshy slicer open --slicer OrcaSlicer --file `,仅验证启动 | +| 真实 Meshy OBJ/MTL 引用重写(D-036) | 无 | not_run | 需要一个真实 refine 任务的 OBJ+MTL+贴图 | `meshy download --resource text-to-3d --task-id --model-format obj --output-dir `,检查 `result.downloads.material_links`(尤其 texture_maps 的 method/resolved_to)与目录中文件是否一致 | ## 7. 打包证据 - 候选版本:`0.3.0`(package.json,**未发布**;版本号可由 reviewer 调整) -- tarball 路径:`/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/meshy-cli-0.3.0.tgz`(`npm pack --json --pack-destination`;复现见 §9) -- SHA256:`8e4e4d86faf18d5b5539264fdac5377059d033e07e4defc902e872f470e66367` -- npm pack 文件清单:394 个文件;必需项 dist/index.js、skills/meshy-cli/SKILL.md、skills/meshy-cli/animation-library.json、README.md、LICENSE、.env.example、package.json 均在;无 .env / credentials / tests / docs / src / node_modules +- tarball 路径:`/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/meshy-cli-0.3.0.tgz`(`npm pack --json --pack-destination`;复现见 §9) +- SHA256:`6a05293a3712a46be575f9359eeab20cc43e2de5260f42b3b2c224b989046806` +- npm pack 文件清单:398 个文件(上轮 394 + material-links.js 等 4 个);必需项 dist/index.js、skills/meshy-cli/SKILL.md、skills/meshy-cli/animation-library.json、README.md、LICENSE、.env.example、package.json 均在;无 .env / credentials / tests / docs / src / node_modules - 临时安装 prefix:`npm install -g --prefix ` → `/bin/meshy` 与 `/bin/meshy-cli` 均指向 `dist/index.js` - 两个 bin、sharp、本地无 Key/无 Python 验证:29 项 smoke 全部符合预期(含 `MESHY_API_KEY=""` 下 balance 的 stdout 为 JSON、exit 3;无 Key 的本地命令 exit 0;dist 中不含 python 引用) - 是否发布:**未发布** -## 8. 需要 Codex 优先 review 的事项 +## 8. 需要 Codex 复审的事项 -1. 请求/路由/模型参数与旧 Skill 的等价性:`src/client/resource-registry.ts` vs `docs/skill-parity/endpoint-contracts.json`;Creative Lab 逐产品 Zod 范围(`src/cmd/creative-lab.ts`)取自官方页面与 meshyd 结构体,未做真实调用;uv-unwrap 双源互斥是 CLI 选择。 -2. async、unknown submission、SIGINT/SSE 的恢复语义:`src/internal/task-command.ts`(`submitOnce`/`classifySubmissionFailure`:TransportError.phase connect/validate → not_submitted,其余 → unknown;4xx → rejected);`src/internal/stream.ts`(idle/total deadline、terminal 后 close、error event 映射);`src/internal/operation-store.ts`(重放/冲突仅限本地记录)。 -3. JSON、stderr、exit 和 legacy 兼容:legacy 变化仅 get 非终态 exit 0、`make --async` 形状、Commander 错误 exit 2、`rigging list`;v1-only 命令拒绝 `--output-schema legacy`(D-002)。 -4. credential origin / public fetch / api-key-file / 日志边界:`src/client/transport.ts`(拒绝 scheme-relative/userinfo/异源/`..`/redirect);`src/internal/config.ts`(stored profile 不发往异源 creative-lab;v2 异源 override 的既有风险仅记录,见 D-013);`--api-key-file` 命名原因 D-025;日志对 data URI 脱敏、签名 query 不入日志。 -5. 嵌套资产、最终文件名、并发覆盖和本地路径:`src/internal/artifacts.ts`(keychain/fridge OBJ 为 ZIP 容器)、`src/internal/download.ts`(link 独占发布 + `copy-exclusive` 回退、MIME 改名后再次校验、私网/降级 redirect 拒绝;DNS 名指向私网不检测)。 -6. metadata 与 history 的锁/部分提交/恢复:`src/internal/project-store.ts`(项目锁 → 提交 → 根锁 → 索引;index_dirty 语义;legacy 迁移备份)。 -7. OBJ 数值/材质与 slicer OS 行为:`src/internal/obj-transform.ts`(旋转/缩放/平移公式、法线仅旋转、两遍流式、`formatObjNumber` 6 位小数);`src/internal/slicers.ts`(win32 用检测到的绝对 exe、macOS `open -a `、无默认应用);Windows/Linux 仅 fixture 验证。 -8. 声称通过但实际缺环境的事项:无 `passed` 依赖未运行的环境;§6 列出的全部 not_run/partial 项均在 verification.json 标注。 +1. **任务上下文包装**:`src/internal/task-command.ts` `withTaskContext`/`wrapWithResult`——是否在所有受理后路径都保留了原始分类(code/http_status/hint/recovery/exit)且 `result.task`/`downloads` 合并顺序正确(bookkeeping 错误的 `task:null` 不覆盖已知任务)。 +2. **共享提交原语**:`submitCreate` 被 make 复用后,`extraResult`(step/route/executed)与资源命令结果形状的兼容;`replayExisting` 对 make 不适用(make 不接受 `--operation-id`)。 +3. **写入根覆盖面**:D-032 列出的入口是否穷尽(`emitLegacyOutcome`、`maybeDownloadV1`、`finalOutcome`、`project` 三个子命令、`attachToProject`、`download --project`、`mesh`);`project show/list` 为只读未限制是否可接受。 +4. **依赖复制的根检查**:`obj-transform.ts` 计划时与发布前两次 `resolveWithinRoot`;`copied` 报告计划路径而非 realpath 的选择。 +5. **凭据指纹**:D-029 的 `sha256(domain|key)` 是否足够(键高熵);OAuth 无 `user_id` 时退化为 profile 名的限制;`credentialSubject` 仅来自存储 profile。 +6. **媒体摘要**:D-030 对非 base64 data URI 的处理(decodeURIComponent 失败回退原文)。 +7. **截止时间语义**:`poll.ts` 用 `deadlineBound` 判定超时来源;`PollResult.task` 为 null 的 legacy 输出形状 `{resource,id,status:null,timed_out:true}` 是否可接受。 +8. **嵌套合并范围**:仅 Creative Lab build 的 `options`/`output` 声明 `nestedObjectKeys`;其余 payload 保持浅合并(T-028 数组整体替换不变)。 +9. **材质重链接**:D-036 的通道启发式(`channelInFileName`/`MAP_KEY_CHANNEL`)是否会产生错误匹配;对 ZIP bundle、二进制 OBJ、超大 MTL 的跳过条件;rewrite 后 manifest sha256 的一致性。 +10. **stream outcome**:ndjson `outcome` 现在携带 `downloads`,失败时以 `errorEnvelope` 输出一条并设置退出码;确认没有第二个 envelope。 +11. 上一轮 §8 的其余事项(路由等价、transport 边界、锁顺序、slicer OS 行为、not_run 项)仍然有效且本轮未改动相关代码。 ## 9. 最短复现步骤 ```sh git clone https://github.com/meshy-dev/meshy-cli.git && cd meshy-cli -git fetch feat/skill-parity-s1 && git checkout e7c26fc053cea4e1bf7dee08953e25a3ec858137 # 或使用本地仓库 /Users/ark/Dev/meshy-cli +git fetch feat/skill-parity-s1 && git checkout 0388fe804b456a931f871d2f227e2acf22359d77 # 或使用本地仓库 /Users/ark/Dev/meshy-cli node --version # v24.x corepack enable && pnpm --version # 11.24.0 pnpm install --frozen-lockfile pnpm typecheck -pnpm test # 先 tsc 生成 dist,再运行 node:test(约 40 s;loopback mock,无外网、无凭据) +pnpm test # 先 tsc 生成 dist,再运行 node:test(约 41 s;loopback mock,无外网、无凭据) +node --import tsx --test tests/codex-review-round1.test.ts # 仅本轮 17 项回归 pnpm build +cp /path/to/reviews/cli-s1-6273d9a/reproduce.mjs /tmp/probe/ && node /tmp/probe/reproduce.mjs "$PWD" # 期望 exit 1 且 12 个 reproduced=false npm pack --json --pack-destination /tmp/meshy-pack shasum -a 256 /tmp/meshy-pack/meshy-cli-0.3.0.tgz npm install -g --prefix /tmp/meshy-prefix /tmp/meshy-pack/meshy-cli-0.3.0.tgz export MESHY_CONFIG_DIR=$(mktemp -d) /tmp/meshy-prefix/bin/meshy --version /tmp/meshy-prefix/bin/meshy doctor --output-schema v1 -cp tests/fixtures/skill-parity/box-y-up.obj tests/fixtures/skill-parity/box.mtl /tmp/ && /tmp/meshy-prefix/bin/meshy mesh prepare-print /tmp/box-y-up.obj --height-mm 80 --output-schema v1 -/tmp/meshy-prefix/bin/meshy inspect faces --task-json tests/fixtures/skill-parity/task-rigging.synthetic.json --max-faces 300000 --output-schema v1 MESHY_API_KEY= /tmp/meshy-prefix/bin/meshy balance --output-schema v1 ; echo "exit=$?" # 3, stdout 为 v1 envelope ``` -不依赖作者机器上的凭据、Python 或全局包;`tests/helpers/cli.ts` 为每次子进程创建隔离的 `MESHY_CONFIG_DIR` 与 cwd,并显式指向 loopback mock。 +不依赖作者机器上的凭据、Python 或全局包;`tests/helpers/cli.ts` 为每次子进程创建隔离的 `MESHY_CONFIG_DIR` 与 cwd,并显式指向 loopback mock。复现脚本请从副本运行,它会在自身目录写 `reproduction-results.json`。 -## 10. 交给 Codex 的 Review 提示词 +## 10. 交给 Codex 的复审提示词 -请 review Meshy CLI S1 实现。仓库和 base/head SHA 见本文件,规范是 2026-09-07 v1 实施包。先核实 diff 与 verification 绑定同一 HEAD(代码 HEAD `e7c26fc053cea4e1bf7dee08953e25a3ec858137`;本文件所在 docs commit 不改代码),再对照 capability matrix 和 COMMAND_CONTRACTS.md 检查功能、正确性、兼容、安全及测试缺口。 +请复审 Meshy CLI S1 第 1 轮 review(reviews/cli-s1-6273d9a,F01–F10)的修复。仓库、base/head SHA 见本文件;代码 HEAD `0388fe804b456a931f871d2f227e2acf22359d77`(本文件所在 docs commit 不改代码),规范仍是 2026-09-07 v1 实施包。先核实 diff `6273d9aa6ef396cf1cc26838e2c0d09ab176f595..0388fe804b456a931f871d2f227e2acf22359d77` 与 verification.json/capability-matrix.json 绑定同一 HEAD,再逐项核对 §0 表:修复是否完整覆盖 finding 的触发条件、是否引入回归、正向回归测试是否真正断言了 expected(而不是仅命令可运行),并重跑 reproduce.mjs 与 `pnpm test`。 -请优先找可复现的 bug、重复付费风险、凭据/路径问题、丢失资产/项目记录、OBJ 错误和伪造完成状态。必要时运行有意义的离线测试;不要做未授权付费调用、发布或 Skill/MCP 迁移。每项 finding 给出优先级、文件/行号、触发条件、影响与修复建议;区分代码缺陷、测试缺口与尚未完成的外部验证。最后分别判断 G1-code 是否可接受、G1-release 是否满足以及是否允许进入 S2。没有发现也要说明验证范围和剩余限制。 +请优先检查 §8 列出的 11 项风险,以及任何新的可复现 bug、重复付费风险、凭据/路径问题、丢失资产/项目记录、OBJ 错误和伪造完成状态。不要做未授权付费调用、发布或 Skill/MCP 迁移。每项 finding 给出优先级、文件/行号、触发条件、影响与修复建议;区分代码缺陷、测试缺口与尚未完成的外部验证。最后分别判断 G1-code 是否可接受、G1-release 是否满足以及是否允许进入 S2。没有发现也要说明验证范围和剩余限制。 diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index 30f23fe..cc35de1 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -2,7 +2,7 @@ "schema_version": 1, "title": "Meshy CLI S1 capability matrix — implementation tracking", "date": "2026-09-07", - "status": "review-ready (G1-code); G1-release not_run", + "status": "review round 1 (Codex, reviewed 6273d9a): changes_requested → all 10 findings fixed in 0388fe8; G1-code re-review pending; G1-release not_run", "baseline": { "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" @@ -20,7 +20,7 @@ "target": "meshy create --async", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-040", "T-043", @@ -28,10 +28,21 @@ "T-045" ], "evidence": [ - "src/internal/task-command.ts submitOnce: journal → single POST → journal; tests/task-lifecycle.test.ts T-040/T-043/T-044" + "src/internal/task-command.ts submitOnce: journal → single POST → journal; tests/task-lifecycle.test.ts T-040/T-043/T-044", + "tests/codex-review-round1.test.ts R05/F01, R06/F01" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F01" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R05/F01, R06/F01" + ] + } }, { "id": "CAP-002", @@ -44,17 +55,29 @@ "target": "meshy get --include-raw --save-json", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-006", "T-008", "T-010" ], "evidence": [ - "task-view.ts + task-command get: any status exit 0, --include-raw, --save-json; tests T-006/T-008/T-009/T-010" + "task-view.ts + task-command get: any status exit 0, --include-raw, --save-json; tests T-006/T-008/T-009/T-010", + "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F01", + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)" + ] + } }, { "id": "CAP-003", @@ -67,17 +90,31 @@ "target": "meshy wait", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-007", "T-047", "T-048" ], "evidence": [ - "poll.ts monotonic deadline, cancellable sleep, --timeout 0 single query; tests T-007/T-047/T-048" + "poll.ts monotonic deadline, cancellable sleep, --timeout 0 single query; tests T-007/T-047/T-048", + "tests/codex-review-round1.test.ts R03/F07 ×2, R06/F01", + "tests/poll.test.ts deadline tests" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F01", + "F07" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R03/F07 ×2, R06/F01", + "tests/poll.test.ts deadline tests" + ] + } }, { "id": "CAP-004", @@ -90,7 +127,7 @@ "target": "meshy stream", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-049", "T-050", @@ -100,10 +137,21 @@ ], "evidence": [ "meshyd api_stream_handlers.go: event message (task), event error {message,status_code}, keep-alive re-emits message every 10 s", - "stream.ts SSE parser + streamTask; task-command stream (ndjson events + outcome, json envelope); tests/sse.test.ts, T-053" + "stream.ts SSE parser + streamTask; task-command stream (ndjson events + outcome, json envelope); tests/sse.test.ts, T-053", + "tests/codex-review-round1.test.ts R10/F10" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F10" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R10/F10" + ] + } }, { "id": "CAP-005", @@ -142,17 +190,28 @@ "target": "existing commands with explicit supported payload", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-028", "T-033", "T-034" ], "evidence": [ - "payload merge defaults < --data < flags with explicit false/0 preserved; tests T-028; surface.test.ts unchanged" + "payload merge defaults < --data < flags with explicit false/0 preserved; tests T-028; surface.test.ts unchanged", + "tests/codex-review-round1.test.ts R04/F08" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F08" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R04/F08" + ] + } }, { "id": "CAP-007", @@ -165,17 +224,30 @@ "target": "typed media flags and final payload normalization", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-029", "T-030", "T-031" ], "evidence": [ - "file-input.ts normalizeMediaPayload on merged payload: URL preflight (no auth), data URI, local file; tests T-029/T-030" + "file-input.ts normalizeMediaPayload on merged payload: URL preflight (no auth), data URI, local file; tests T-029/T-030", + "tests/codex-review-round1.test.ts R02/F06", + "tests/operation-store.test.ts F06" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F06" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R02/F06", + "tests/operation-store.test.ts F06" + ] + } }, { "id": "CAP-008", @@ -237,7 +309,7 @@ "target": "meshy creative-lab lamp prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-022", "T-023", @@ -247,12 +319,23 @@ "evidence": [ "docs.meshy.ai/en/api/creative-lab-lamp (image_subject; prototype model_urls.glb; build options/output stl|zip; lamp_stl/base_stl/bundle_zip)", "meshyd pkg/model/task_creative_lab_lamp.go artifact keys and enums", - "src/cmd/creative-lab.ts lamp: image_subject, text rejected, options/output stl|zip, include_result_json rule; tests T-022..T-025" + "src/cmd/creative-lab.ts lamp: image_subject, text rejected, options/output stl|zip, include_result_json rule; tests T-022..T-025", + "tests/codex-review-round1.test.ts R04/F08 (lamp)" ], "intentional_differences": [ "deprecated lamp `text` prototype input is rejected before submission; official lamp page documents image_url" ], - "external_verification": "required_when_applicable_not_run" + "external_verification": "required_when_applicable_not_run", + "review_round_1": { + "findings": [ + "F08" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R04/F08 (lamp)" + ] + } }, { "id": "CAP-011", @@ -265,7 +348,7 @@ "target": "meshy creative-lab keychain prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-022", "T-023", @@ -275,10 +358,21 @@ "evidence": [ "docs.meshy.ai/en/api/creative-lab-keychain (relief options; output glb|obj|zip; obj is a ZIP bundle)", "meshyd pkg/model/task_creative_lab_keychain.go", - "src/cmd/creative-lab.ts keychain relief options, output glb|obj|zip; tests T-022..T-025" + "src/cmd/creative-lab.ts keychain relief options, output glb|obj|zip; tests T-022..T-025", + "tests/codex-review-round1.test.ts R04/F08 (keychain)" ], "intentional_differences": [], - "external_verification": "required_when_applicable_not_run" + "external_verification": "required_when_applicable_not_run", + "review_round_1": { + "findings": [ + "F08" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R04/F08 (keychain)" + ] + } }, { "id": "CAP-012", @@ -291,7 +385,7 @@ "target": "meshy creative-lab fridge-magnet prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-022", "T-023", @@ -301,10 +395,21 @@ "evidence": [ "docs.meshy.ai/en/api/creative-lab-fridge-magnet (own defaults: rounded-rect, 60 mm, 3.3 mm relief, 2.0 mm base)", "meshyd pkg/model/task_creative_lab_fridge_magnet.go", - "src/cmd/creative-lab.ts fridge-magnet relief options; tests T-022..T-025" + "src/cmd/creative-lab.ts fridge-magnet relief options; tests T-022..T-025", + "tests/codex-review-round1.test.ts R04/F08 (relief options shared with keychain)" ], "intentional_differences": [], - "external_verification": "required_when_applicable_not_run" + "external_verification": "required_when_applicable_not_run", + "review_round_1": { + "findings": [ + "F08" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R04/F08 (relief options shared with keychain)" + ] + } }, { "id": "CAP-013", @@ -366,17 +471,28 @@ "target": "download asset registry with role and dependencies", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-060", "T-062", "T-071" ], "evidence": [ - "src/internal/artifacts.ts enumerateAssets: model formats (lamp parts STL/ZIP, keychain/fridge OBJ as ZIP bundle), images, textures, primary/multi-view/alpha thumbnails, rig + basic_animations, motion, report; unknown URLs listed; tests/artifacts.test.ts T-060/T-062/T-071" + "src/internal/artifacts.ts enumerateAssets: model formats (lamp parts STL/ZIP, keychain/fridge OBJ as ZIP bundle), images, textures, primary/multi-view/alpha thumbnails, rig + basic_animations, motion, report; unknown URLs listed; tests/artifacts.test.ts T-060/T-062/T-071", + "tests/codex-review-round1.test.ts R07/F09" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F09" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R07/F09" + ] + } }, { "id": "CAP-016", @@ -389,18 +505,29 @@ "target": "meshy download --asset/--kind/--model-format", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-061", "T-063" ], "evidence": [ - "src/cmd/download.ts selectors --asset/--model-format/--kind/--all/--list; tests/download-command.test.ts T-061, tests/artifacts.test.ts T-063" + "src/cmd/download.ts selectors --asset/--model-format/--kind/--all/--list; tests/download-command.test.ts T-061, tests/artifacts.test.ts T-063", + "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)" ], "intentional_differences": [ "download requires an explicit selector when a task exposes several assets (legacy defaulted to glb); legacy -o keeps downloading everything" ], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F09" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)" + ] + } }, { "id": "CAP-017", @@ -413,7 +540,7 @@ "target": "safe download with actual final-path manifest", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-064", "T-065", @@ -424,10 +551,22 @@ "T-070" ], "evidence": [ - "src/internal/download.ts fetchToTemp (no credential, redirect re-validation, private-network refusal, size cap, sha256), exclusive publish + realpath containment, partial manifests; tests T-064..T-070" + "src/internal/download.ts fetchToTemp (no credential, redirect re-validation, private-network refusal, size cap, sha256), exclusive publish + realpath containment, partial manifests; tests T-064..T-070", + "tests/codex-review-round1.test.ts R07/F09, R09/F03" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F03", + "F09" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R07/F09, R09/F03" + ] + } }, { "id": "CAP-018", @@ -440,16 +579,27 @@ "target": "--save-json and project task snapshots", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-010", "T-063" ], "evidence": [ - "--save-json on task verbs/list/balance/api/catalog/showcases (raw JSON, never the envelope); project task_.json snapshots; tests T-010, T-063, project-store tests" + "--save-json on task verbs/list/balance/api/catalog/showcases (raw JSON, never the envelope); project task_.json snapshots; tests T-010, T-063, project-store tests", + "tests/codex-review-round1.test.ts R05/F01" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F01" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R05/F01" + ] + } }, { "id": "CAP-019", @@ -462,16 +612,27 @@ "target": "meshy project init", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-072", "T-077" ], "evidence": [ - "src/internal/project-store.ts initProject + src/cmd/project.ts init; tests/project-store.test.ts T-072/T-077" + "src/internal/project-store.ts initProject + src/cmd/project.ts init; tests/project-store.test.ts T-072/T-077", + "tests/codex-review-round1.test.ts R08/F03" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R08/F03" + ] + } }, { "id": "CAP-020", @@ -484,17 +645,28 @@ "target": "meshy project record/show", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-073", "T-074", "T-075" ], "evidence": [ - "project-store recordTask (task_id+stage merge, legacy v1 migration with backup, unknown fields kept) + project record/show; tests T-073/T-074/T-075" + "project-store recordTask (task_id+stage merge, legacy v1 migration with backup, unknown fields kept) + project record/show; tests T-073/T-074/T-075", + "tests/codex-review-round1.test.ts R08/F03" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R08/F03" + ] + } }, { "id": "CAP-021", @@ -507,7 +679,7 @@ "target": "meshy project list/rebuild-index", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-073", "T-075", @@ -515,10 +687,21 @@ "T-077" ], "evidence": [ - "project list (index vs folders, index_dirty) and rebuild-index (backup, symlink/escape skipped); tests T-073/T-075/T-076/T-077" + "project list (index vs folders, index_dirty) and rebuild-index (backup, symlink/escape skipped); tests T-073/T-075/T-076/T-077", + "tests/codex-review-round1.test.ts R08/F03 (rebuild-index)" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R08/F03 (rebuild-index)" + ] + } }, { "id": "CAP-022", @@ -557,7 +740,7 @@ "target": "meshy mesh prepare-print", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-083", "T-084", @@ -566,12 +749,25 @@ "T-087" ], "evidence": [ - "src/internal/obj-transform.ts two-pass streaming transform + src/cmd/mesh.ts prepare-print; fixture oracle box-height-80.expected.json; tests/obj-transform.test.ts T-083..T-087" + "src/internal/obj-transform.ts two-pass streaming transform + src/cmd/mesh.ts prepare-print; fixture oracle box-height-80.expected.json; tests/obj-transform.test.ts T-083..T-087", + "tests/codex-review-round1.test.ts R12/F04", + "tests/obj-transform.test.ts T-086 (planned paths reported)" ], "intentional_differences": [ "default output is .print.obj, in-place only with --in-place; NaN/Infinity/degenerate input is a validation error instead of silent scale 1.0 output" ], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F04" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R12/F04", + "tests/obj-transform.test.ts T-086 (planned paths reported)" + ] + } }, { "id": "CAP-024", @@ -654,18 +850,29 @@ "target": "--env-file without automatic dotenv/shell scanning", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-100", "T-101" ], "evidence": [ - "config.ts + env-file.ts (--api-key-file); tests env-file.test.ts, T-100" + "config.ts + env-file.ts (--api-key-file); tests env-file.test.ts, T-100", + "tests/codex-review-round1.test.ts R01/F05 (API key digest)" ], "intentional_differences": [ "flag is --api-key-file, not --env-file: Node.js intercepts --env-file anywhere in argv (decisions D-025)" ], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F05" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R01/F05 (API key digest)" + ] + } }, { "id": "CAP-028", @@ -678,16 +885,27 @@ "target": "preserve existing auth commands and token protocol", "baseline_status": "existing_needs_regression", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-104", "T-105" ], "evidence": [ - "auth commands untouched; runtime refresh path unchanged; tests/auth-headless.test.ts, oauth.test.ts, device*.test.ts, credentials.test.ts, runtime.test.ts still pass (regression)" + "auth commands untouched; runtime refresh path unchanged; tests/auth-headless.test.ts, oauth.test.ts, device*.test.ts, credentials.test.ts, runtime.test.ts still pass (regression)", + "tests/codex-review-round1.test.ts R01/F05 (OAuth subject, token rotation)" ], "intentional_differences": [], - "external_verification": "required_when_applicable_not_run" + "external_verification": "required_when_applicable_not_run", + "review_round_1": { + "findings": [ + "F05" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R01/F05 (OAuth subject, token rotation)" + ] + } }, { "id": "CAP-029", @@ -726,16 +944,29 @@ "target": "make --async / --stop-after-first", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-041", "T-042" ], "evidence": [ - "src/cmd/make.ts --async single POST, --stop-after-first, mutual exclusion; tests T-041/T-042" + "src/cmd/make.ts --async single POST, --stop-after-first, mutual exclusion; tests T-041/T-042", + "tests/codex-review-round1.test.ts R11/F02, F01 make polling, R09/F03 make -o" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F01", + "F02", + "F03" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R11/F02, F01 make polling, R09/F03 make -o" + ] + } }, { "id": "CAP-031", @@ -748,7 +979,7 @@ "target": "operation journal; never claim server idempotency", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 findings fixed; re-review pending", "test_ids": [ "T-043", "T-044", @@ -757,10 +988,24 @@ "T-048" ], "evidence": [ - "operation-store.ts journal with lock, replay and conflict; tests operation-store.test.ts, T-046" + "operation-store.ts journal with lock, replay and conflict; tests operation-store.test.ts, T-046", + "tests/codex-review-round1.test.ts R01/F05, R02/F06, concurrent same-id creates", + "tests/operation-store.test.ts (barrier race, F05, F06)" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_1": { + "findings": [ + "F05", + "F06" + ], + "status": "fixed", + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "tests": [ + "tests/codex-review-round1.test.ts R01/F05, R02/F06, concurrent same-id creates", + "tests/operation-store.test.ts (barrier race, F05, F06)" + ] + } }, { "id": "CAP-032", @@ -874,7 +1119,31 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-07T08:30:28.993Z", - "code_head": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" - } + "updated_at": "2026-09-07T09:32:18.044Z", + "code_head": "0388fe804b456a931f871d2f227e2acf22359d77" + }, + "review_rounds": [ + { + "round": 1, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-6273d9a", + "reviewed_head": "6273d9aa6ef396cf1cc26838e2c0d09ab176f595", + "verdict": "changes_requested", + "findings": [ + "F01", + "F02", + "F03", + "F04", + "F05", + "F06", + "F07", + "F08", + "F09", + "F10" + ], + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "regression_tests": "tests/codex-review-round1.test.ts (R01–R12 as positive assertions) + tests/operation-store.test.ts + tests/poll.test.ts", + "probe_rerun": "reviews/cli-s1-6273d9a/reproduce.mjs on 0388fe8: 0/12 defects reproduce" + } + ] } diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json index d06484e..5439e40 100644 --- a/docs/skill-parity/verification.json +++ b/docs/skill-parity/verification.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "status": "review-ready (G1-code); live checks not_run", + "status": "review round 1 fixes applied (Codex reviewed 6273d9a: changes_requested, 10 findings); re-review pending; live checks not_run", "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", "environment": { "os": "macOS 26.6 (Darwin 25.6.0) arm64", @@ -43,69 +43,69 @@ "final_runs": [ { "command": "node --version", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm --version", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { "command": "pnpm install --frozen-lockfile", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm typecheck", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "duration_s": 1, "result": "passed" }, { "command": "pnpm test", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "duration_s": 41, "result": "passed", - "tests": 502, - "pass": 502, + "tests": 522, + "pass": 522, "fail": 0, "skipped": 0 }, { "command": "pnpm build", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { - "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, - "duration_s": 0, + "duration_s": 1, "result": "passed" }, { - "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/meshy-cli-0.3.0.tgz", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/meshy-cli-0.3.0.tgz", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, - "duration_s": 2, + "duration_s": 3, "result": "passed" } ], @@ -115,7 +115,7 @@ "files": [ "tests/cli-contract.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-002": { "result": "passed", @@ -123,63 +123,67 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-003": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-004": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-005": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-006": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-007": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-008": { "result": "passed", "files": [ "tests/task-view.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-009": { "result": "passed", "files": [ - "tests/task-lifecycle.test.ts" + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F10: R10 stream -o in ndjson/json/pretty; one outcome on download failure" }, "T-010": { "result": "passed", "files": [ - "tests/cli-contract.test.ts" + "tests/cli-contract.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F01: R05 save-json preflight + post-POST failure keeps the id" }, "T-011": { "result": "passed", @@ -187,28 +191,28 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-012": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-020": { "result": "passed", "files": [ "tests/resource-registry.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-021": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-022": { "result": "passed", @@ -216,7 +220,7 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-023": { "result": "passed", @@ -224,91 +228,101 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-024": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-025": { "result": "passed", "files": [ - "tests/uv-creative-lab.test.ts" + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F08: R04 three-layer options merge on the real POST body" }, "T-026": { "result": "passed", "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-027": { "result": "passed", "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-028": { "result": "passed", "files": [ - "tests/uv-creative-lab.test.ts" + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F08: R04" }, "T-029": { "result": "passed", "files": [ - "tests/file-input.test.ts" + "tests/file-input.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F06: R02 equal-length images conflict, re-wrapped base64 replays, no base64 in journal" }, "T-030": { "result": "passed", "files": [ "tests/file-input.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-031": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-032": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-040": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-041": { "result": "passed", "files": [ - "tests/uv-creative-lab.test.ts" + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F02: R11 make journal failure after acceptance is local_io with the id (text + image routes)" }, "T-042": { "result": "passed", "files": [ - "tests/uv-creative-lab.test.ts" + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F02: R11" }, "T-043": { "result": "passed", @@ -316,86 +330,97 @@ "tests/operation-store.test.ts", "tests/task-lifecycle.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-044": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-045": { "result": "passed", "files": [ - "tests/task-lifecycle.test.ts" + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F01: R06 polling 5xx keeps id/submission/next; make polling failure keeps executed steps" }, "T-046": { "result": "passed", "files": [ "tests/operation-store.test.ts", - "tests/task-lifecycle.test.ts" + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "barrier-released three-process race; F05/F06 fingerprint unit tests" }, "T-047": { "result": "passed", "files": [ - "tests/task-lifecycle.test.ts" + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts", + "tests/poll.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F07: deadline bounds the sleep; --timeout 0 request cap" }, "T-048": { "result": "passed", "files": [ - "tests/task-lifecycle.test.ts" + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F01: task context on every post-acceptance failure" }, "T-049": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-050": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-051": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-052": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-053": { "result": "passed", "files": [ "tests/sse.test.ts", - "tests/task-lifecycle.test.ts" + "tests/task-lifecycle.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F10: R10" }, "T-060": { "result": "passed", "files": [ "tests/artifacts.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-061": { "result": "passed", @@ -403,64 +428,68 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-062": { "result": "passed", "files": [ "tests/artifacts.test.ts", - "tests/download-command.test.ts" + "tests/download-command.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F09: R07 rewritten mtllib/map_* references verified on disk; unresolved map reported" }, "T-063": { "result": "passed", "files": [ "tests/artifacts.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-064": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-066": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-067": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-068": { "result": "passed", "files": [ - "tests/download-command.test.ts" + "tests/download-command.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F03/F04: R08 project roots, R09 task -o, R12 dependency symlink escapes" }, "T-069": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-070": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-071": { "result": "passed", @@ -468,148 +497,154 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-072": { "result": "passed", "files": [ - "tests/project-store.test.ts" + "tests/project-store.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F03: R08 project init/record/rebuild-index confined to --workspace" }, "T-073": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-074": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-075": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-076": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-077": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-080": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-081": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-082": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-083": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-084": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-085": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-086": { "result": "passed", "files": [ - "tests/obj-transform.test.ts" + "tests/obj-transform.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F04: R12 symlinked materials/ and texture dir refused; real dirs copied" }, "T-087": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-088": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-089": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-090": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-091": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-100": { "result": "passed", "files": [ "tests/cli-contract.test.ts", - "tests/env-file.test.ts" + "tests/env-file.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F05: R01 API key digest binding" }, "T-101": { "result": "passed", "files": [ "tests/env-file.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-102": { "result": "passed", @@ -617,21 +652,23 @@ "tests/cli-contract.test.ts", "tests/doctor.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-103": { "result": "passed", "files": [ "tests/doctor.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77" }, "T-105": { "result": "passed", "files": [ - "tests/uv-creative-lab.test.ts" + "tests/uv-creative-lab.test.ts", + "tests/codex-review-round1.test.ts" ], - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137" + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "review_round_1": "F05: R01 OAuth subject binding (token rotation keeps identity)" } }, "live_verification": [ @@ -645,13 +682,13 @@ "id": "T-109", "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "partial", - "detail": "macOS 26.6 arm64, Node 24.20.0: tarball install + 31 smoke checks passed (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" + "detail": "macOS 26.6 arm64, Node 24.20.0: tarball install + 29 smoke checks passed on 0388fe8 (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" }, { "id": "T-110", "scope": "real free queries, existing task get, asset download", "result": "partial", - "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions) returned 157 entries, 8 matched \"wave\". Authenticated get/download: not_run (no credential)" + "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions) on 0388fe8 returned 157 entries, 8 matched \"wave\". Authenticated get/download: not_run (no credential)" }, { "id": "T-111", @@ -666,13 +703,13 @@ "reason": "no slicer application installed on this host (slicer detect returned an empty list)" } ], - "code_head": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", - "recorded_at": "2026-09-07T08:30:28.989Z", - "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run.", + "code_head": "0388fe804b456a931f871d2f227e2acf22359d77", + "recorded_at": "2026-09-07T09:32:03.977Z", + "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1 notes name the Codex finding (F01–F10) and probe (R01–R12) the file now covers.", "package_smoke": { "tarball": "meshy-cli-0.3.0.tgz", - "sha256": "8e4e4d86faf18d5b5539264fdac5377059d033e07e4defc902e872f470e66367", - "file_count": 394, + "sha256": "6a05293a3712a46be575f9359eeab20cc43e2de5260f42b3b2c224b989046806", + "file_count": 398, "required_present": true, "forbidden_absent": true, "install_prefix": "temporary npm prefix (npm install -g --prefix )", @@ -684,8 +721,8 @@ "checks": [ { "id": "version", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy --version", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy --version", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -693,8 +730,8 @@ }, { "id": "version_cli", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy-cli --version", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy-cli --version", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -702,8 +739,8 @@ }, { "id": "help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy --help", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy --help", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -711,8 +748,8 @@ }, { "id": "help_cli", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy-cli --help", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy-cli --help", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -720,8 +757,8 @@ }, { "id": "balance_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy balance --help", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy balance --help", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -729,8 +766,8 @@ }, { "id": "balance_nokey_json", - "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy balance", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy balance", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -738,8 +775,8 @@ }, { "id": "balance_nokey_v1", - "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy balance --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy balance --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -747,8 +784,8 @@ }, { "id": "resources_v1", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy resources --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy resources --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -756,8 +793,8 @@ }, { "id": "doctor", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy doctor --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy doctor --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -765,8 +802,8 @@ }, { "id": "doctor_check_slicers", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy doctor --check-slicers --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy doctor --check-slicers --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -774,8 +811,8 @@ }, { "id": "doctor_check_api_nokey", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy doctor --check-api --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy doctor --check-api --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -783,8 +820,8 @@ }, { "id": "slicer_detect", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy slicer detect --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy slicer detect --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -792,8 +829,8 @@ }, { "id": "inspect_pass", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -801,8 +838,8 @@ }, { "id": "inspect_fail", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 12, "expected_exit": 12, "result": "passed", @@ -810,8 +847,8 @@ }, { "id": "inspect_unknown", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy inspect faces --task-json /dev/stdin --max-faces 300000 --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy inspect faces --task-json /dev/stdin --max-faces 300000 --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 13, "expected_exit": 13, "result": "passed", @@ -819,8 +856,8 @@ }, { "id": "mesh_prepare", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -828,8 +865,8 @@ }, { "id": "mesh_refuse_overwrite", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 11, "expected_exit": 11, "result": "passed", @@ -837,8 +874,8 @@ }, { "id": "project_init", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -846,8 +883,8 @@ }, { "id": "project_record", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/work/meshy_output/20260907_162908_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/work/meshy_output/20260907_172958_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -855,8 +892,8 @@ }, { "id": "project_show", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/work/meshy_output/20260907_162908_smoke-demo_fixture- --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/work/meshy_output/20260907_172958_smoke-demo_fixture- --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -864,8 +901,8 @@ }, { "id": "project_list", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -873,8 +910,8 @@ }, { "id": "download_list", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -882,8 +919,8 @@ }, { "id": "download_no_selector", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -891,8 +928,8 @@ }, { "id": "make_dry_run", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -900,8 +937,8 @@ }, { "id": "usage_unknown_flag", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy balance --bogus --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy balance --bogus --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -909,8 +946,8 @@ }, { "id": "uv_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy uv-unwrap create --help", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy uv-unwrap create --help", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -918,8 +955,8 @@ }, { "id": "creative_lab_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy creative-lab lamp build create --help", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy creative-lab lamp build create --help", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -927,8 +964,8 @@ }, { "id": "stream_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy text-to-3d stream --help", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy text-to-3d stream --help", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -936,13 +973,101 @@ }, { "id": "catalog_live", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", - "sha": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", "exit_code": 0, "expected_exit": 0, "result": "passed", "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" } ] - } + }, + "review_rounds": [ + { + "round": 1, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-6273d9a", + "reviewed_head": "6273d9aa6ef396cf1cc26838e2c0d09ab176f595", + "reviewed_code_head": "e7c26fc053cea4e1bf7dee08953e25a3ec858137", + "verdict": "changes_requested (G1-code not_accepted, G1-release not_satisfied, S2 not_ready)", + "findings": 10, + "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", + "regression_tests": [ + "tests/codex-review-round1.test.ts", + "tests/operation-store.test.ts", + "tests/poll.test.ts" + ], + "probe_rerun": { + "script": "reviews/cli-s1-6273d9a/reproduce.mjs (copied to a scratch directory so the reviewer's evidence file is untouched)", + "node": "v24.20.0", + "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "reproduced_after_fix": 0, + "total": 12, + "exit_code": 1, + "note": "the script exits 1 when not every defect reproduces; after the fixes 0/12 reproduce", + "cases": [ + { + "id": "R01-credential-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R02-media-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R03-wait-deadline", + "reproduced": false, + "exit_code": 8 + }, + { + "id": "R04-options-shallow-merge", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R05-post-save-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R06-post-poll-lost-id", + "reproduced": false, + "exit_code": 1 + }, + { + "id": "R07-obj-broken-material", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R08-project-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R09-task-output-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R10-stream-output-ignored", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R11-make-journal-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R12-obj-dependency-symlink", + "reproduced": false, + "exit_code": 11 + } + ] + } + } + ] } From cf8905dd285bf16896df053aac253dbf8c672979 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 18:23:14 +0800 Subject: [PATCH 12/26] =?UTF-8?q?fix(review):=20address=20Codex=20review?= =?UTF-8?q?=20round=202=20findings=20R2-F01=E2=80=93R2-F07?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-F01 --workspace confines every write: report-only -o (saveReportOnly checks before mkdir), downloadAssets checks before creating the directory, and the implicit history root is confined — with --workspace equal to the project dir, metadata is recorded and the index skipped (index.updated=false, index_dirty) instead of writing history.json into the parent R2-F02 material relinking maps textures by the server-side file name (then stem, channel word, MTL key), one candidate only; ambiguous maps stay as written with candidates, material_links.status=incomplete and a material_reference_ambiguous warning; legacy -o uses the same resolver R2-F03 stream: save-json/project failures after the SSE events are part of the single terminal outcome (ndjson outcome with the next sequence, one envelope for json/pretty) R2-F04 task/make -o downloads return a per-file manifest; a failure keeps the original class, HTTP status and recovery and carries the files already written (downloads.state=partial) instead of files: [] R2-F05 the abort signal reaches fetchToTemp for every task -o and make download: Ctrl-C aborts the transfer, removes the temp file, skips relink/sidecar and exits 130 with task id, submission, next and the committed files; index.ts keeps result/recovery when re-wrapping R2-F06 OAuth identity: auth login mints a login_id kept across refresh and replaced by re-login; journal identity = user_id, else login_id; a profile with neither is refused a replay (operation_conflict, credential_unverified, recovery meshy auth login) R2-F07 poll deadline tests drive the injected now/sleep clock (exact expiry, early/late wake, deadline-bound timeout); the real-timer smoke asserts only that no GET starts after the deadline Also: saveTaskSnapshot computes task_json in the real-path frame; round-1 R10 expectation follows the preserved HTTP class (404 → not_found/5); tests/codex-review-round2.test.ts mirrors probes N01–N08; decisions D-038..D-044, migration notes §3.2, README and bundled SKILL.md updated. Co-Authored-By: Claude Fable 5.1 --- README.md | 32 +- docs/skill-parity/decisions.md | 85 ++++ docs/skill-parity/migration-notes.md | 13 + skills/meshy-cli/SKILL.md | 6 + src/cmd/auth.ts | 5 + src/cmd/download.ts | 5 +- src/cmd/make.ts | 20 +- src/cmd/project.ts | 9 +- src/index.ts | 13 +- src/internal/config.ts | 5 + src/internal/credentials.ts | 10 + src/internal/download.ts | 240 +++++++---- src/internal/material-links.ts | 181 ++++++-- src/internal/operation-store.ts | 65 ++- src/internal/project-store.ts | 32 +- src/internal/runtime.ts | 1 + src/internal/task-command.ts | 86 +++- tests/auth-headless.test.ts | 3 +- tests/codex-review-round1.test.ts | 11 +- tests/codex-review-round2.test.ts | 615 +++++++++++++++++++++++++++ tests/helpers/cli.ts | 5 +- tests/poll.test.ts | 126 +++++- 22 files changed, 1379 insertions(+), 189 deletions(-) create mode 100644 tests/codex-review-round2.test.ts diff --git a/README.md b/README.md index dbe7eb7..46c95a1 100644 --- a/README.md +++ b/README.md @@ -229,8 +229,12 @@ one JSON document (`ndjson` streams emit one line per event plus a final request instead of submitting again; a different request under the same id is refused (`operation_conflict`, exit 2, naming what differs). "Identical" means the same resource, API origin, credential — a one-way digest of the API key, - or the OAuth account — and payload, with every inline image or model hashed by - content. This is a local record, not a server-side idempotency key. + or the OAuth account (its user id, else the login id `meshy auth login` mints + for the profile) — and payload, with every inline image or model hashed by + content. An OAuth profile saved before login ids existed carries no verifiable + identity: it can start operations but is refused a replay (exit 2, + `credential_unverified`) until you log in again. This is a local record, not a + server-side idempotency key. - Local targets that would fail after the POST are checked before it: an existing `--save-json` file, an `-o` path outside `--workspace`, a missing `--project` all exit 11 with "nothing was submitted" and cost no request. @@ -248,8 +252,14 @@ one JSON document (`ndjson` streams emit one line per event plus a final `--idle-timeout` (silence, keep-alives reset it); `-o` downloads the assets in every output format, and in `ndjson` the final `outcome` line carries the download manifest. -- Ctrl-C stops waiting or downloading (exit 130) and sends no DELETE; the - envelope carries the task id and the command that resumes. +- `-o` on `get`/`wait`/`stream`/`make` downloads every artifact of a SUCCEEDED + task; `result.downloads.files` is a per-file manifest (key, path, bytes, + sha256, status). When the second asset fails, the state is `partial`, the files + already written stay listed and on disk, and the error keeps the asset host's + class and HTTP status (a 503 is `network`, exit 7, not a local I/O error). +- Ctrl-C stops waiting, streaming or downloading (exit 130) and sends no DELETE; + the envelope carries the task id, the command that resumes and the files that + had already landed. An interrupted transfer leaves no temp file behind. ## Resources @@ -351,10 +361,14 @@ command lists the candidates and exits 2 instead of guessing. Selecting an OBJ pulls its MTL and textures (`--geometry-only` to skip); once the set has landed the OBJ's `mtllib` and the MTL's `map_*` references are rewritten to the names actually saved (`model.mtl`, `texture_0_base_color.png`, …) so the model loads -from that directory — every link is listed under -`result.downloads.material_links`, rewritten files carry `relinked: true` with -their final sha256, and a reference that matches no downloaded file stays as -written and is warned (`material_reference_unresolved`). Files are published +from that directory. Textures are matched by the name the server served them +under (then by channel), one candidate only: with several material groups a +reference that could mean two files is left as written and reported as +ambiguous. Every link is listed under `result.downloads.material_links` +(`status: complete | incomplete`, the `newmtl` group of each map), rewritten +files carry `relinked: true` with their final sha256, and a reference that +matches no or several downloaded files stays as written and is warned +(`material_reference_unresolved` / `material_reference_ambiguous`). Files are published exclusively (never overwritten without `--overwrite`), checked against the content type and magic bytes, kept inside the output directory (or `--workspace`), and listed with size and sha256 in `result.downloads.files`. Asset hosts never @@ -428,7 +442,7 @@ message before any task is created. GLB-only fields (`uv-unwrap`, `rigging` | `--output-schema legacy\|v1` | Stdout data model (existing commands default to `legacy`; new commands are `v1`) | | `--format json\|pretty\|ndjson` | Stdout rendering (default `json`) | | `-o, --output ` | Download artifacts to a file/directory (task commands); output file for `mesh prepare-print` | -| `--workspace ` | Confine every written file to this directory: `download`, `-o` on task verbs and `make`, `--save-json`, `--project`/project folders, `mesh prepare-print` outputs and their copied materials — checked on real paths before anything is created | +| `--workspace ` | Confine every written file to this directory: `download`, `-o` on task verbs and `make` (report-only tasks included), `--save-json`, `--project`/project folders and the history index (skipped with `index_dirty` when its root would fall outside), `mesh prepare-print` outputs and their copied materials — checked on real paths before anything, even a directory, is created | | `--no-update-check` | Skip the background npm version check in this process | | `-v, --verbose` | Debug logging to stderr | | `--log-level ` | `debug \| info \| warn \| error \| silent` | diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index 51f71dd..0887958 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -369,3 +369,88 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu download (and the project record) now happen before the format branch; the ndjson `outcome` line carries the manifest, a download failure is one `ok:false` outcome (exit code of the failure, task kept), and json/pretty behave the same. + +## D-038 The write root also covers report-only tasks and the implicit history root + +- Codex review round 2 (R2-F01): `-o` on a report-only task (analyze-printability) + returned through `saveReportOnly` before the workspace check; `project record` + with `--workspace` equal to the project directory wrote `history.json` into the + parent; `downloadAssets` created the output directory before refusing it. Now + `saveReportOnly` receives the workspace and proves the file/`meta.json` path + inside it before any `mkdir`; `downloadAssets` checks directory and planned leaves + first and creates the directory after; `indexRootFor` decides the history root + (explicit `--root`, else the project's parent) and, when it resolves outside the + workspace, the project verbs (`project record`, task `--project`, `download + --project`) record `metadata.json` and skip the index with + `index.updated=false` + the reason (`index_dirty` warning) — nothing is created, + locked or temp-filed outside the boundary. An explicit `--root` outside the + workspace stays a refusal before any write. + +## D-039 Material relinking resolves by source name and never guesses between groups + +- R2-F02: `byChannel` kept the first texture per channel, so two `newmtl` groups' + `map_Kd` lines both pointed at `texture_0_base_color.png`. Every downloaded + texture now carries the name the server served it under (`basenameOfUrl`), and a + reference resolves only when exactly one texture matches, in this order: saved + name; source name (case-insensitive); source stem (extension and directories + ignored); channel word in the reference; channel of the MTL key; the only texture + when the MTL has one distinct reference. Several matches for a rule are an + ambiguity: the reference stays as written, the candidates are listed on the + `texture_maps` entry (`method: "ambiguous"`), `material_links.status` is + `incomplete` and `material_reference_ambiguous` is warned. The `newmtl` group of + every map is recorded. The legacy `-o` layout uses the same resolver. + +## D-040 Bookkeeping failures after a stream are part of its terminal outcome + +- R2-F03: a `--save-json` conflict or `--project` failure after the SSE stream had + emitted task events surfaced as a bare error envelope without `event`/`sequence`. + `streamAndReport` now runs save/record inside the same terminal handling: the + failure becomes the single `outcome` (ndjson, next sequence) or the single + envelope (json/pretty), keeps the task context, and when the stream itself ended + in a failure the bookkeeping error rides along as a `bookkeeping_failed` warning. + +## D-041 Task `-o` downloads carry a per-file manifest and keep the failure class + +- R2-F04: `maybeDownloadV1`/`make` reported `files: []` on any download failure + and the legacy downloader turned every fetch error into a plain `Error`, so an + asset host 503 read as `local_io` without status while `model.glb` sat on disk. + `downloadArtifacts` now returns `files` (key, path, bytes, sha256, status) and, + on failure, throws a CliError with the *original* code/HTTP status/recovery and + `result.downloads = { state: partial|failed, files }`; `maybeDownloadV1` and + `make` merge that manifest instead of replacing it. Legacy `-o` error payloads + therefore now carry `code`, `status` and `result.downloads` (they used to be + `{name:"Error", message}`); the file layout and success output are unchanged. + +## D-042 Task `-o` downloads are cancellable + +- R2-F05: the legacy downloader never received the abort signal, so Ctrl-C during + an asset transfer printed "interrupted" and then finished the download with + exit 0. The signal now travels from every task verb, `make` and the legacy + reporter into `fetchToTemp`; an abort stops the transfer, deletes the temp file, + skips relink/sidecar and surfaces as `interrupted` (130) with the task id, + submission, `next` and the files already committed. `index.ts` re-wraps a + post-SIGINT failure as `interrupted` *without* dropping `result`/`recovery`. + +## D-043 OAuth logins are identified by user id or a per-login id — never "unknown" + +- R2-F06: D-029 bound OAuth profiles without `user_id` to the profile name, so a + different account logged into the same profile replayed the old journal record. + `meshy auth login` now mints a random `login_id` on every OAuth profile; a silent + refresh preserves it, a new login replaces it. The journal identity is + `subject:` when the token endpoint reported one, else + `login:`. A profile with neither (written before login ids existed) has + no verifiable identity: it may start new operations, but `beginOperation` refuses + to replay an existing record for it (`operation_conflict`, `result.conflict: + ["credential_unverified"]`, recovery `meshy auth login`). Migration is a + re-login; nothing secret (token, login id) is written to the journal — only the + one-way fingerprint. Supersedes the "profile name only" limitation in D-029. + +## D-044 Deadline tests are deterministic + +- R2-F07: the round-1 test "one GET within a 250 ms budget" used real timers and + failed intermittently when `setTimeout` woke a fraction early. `pollUntilTerminal` + already injects `now`/`sleep`; the tests now drive a fake clock (exact expiry, + early wake, late wake, deadline-bound request timeout, read-timeout-bound failure) + and the one real-timer smoke asserts only the invariant a real clock can prove: + no GET *starts* after the deadline. The subprocess tests with slow headers/bodies + (R03) are kept. diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index b9bd735..785e831 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -129,6 +129,19 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | downloaded OBJ/MTL references | pointed at server-side names | rewritten to the saved names, reported | F09 | | `stream --format ndjson -o` | assets not downloaded | downloaded; `outcome` carries the manifest | F10 | +### 3.2 Corrections from Codex review round 2 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| report-only task `-o` outside `--workspace` | file written | exit 11, no directory created | R2-F01 | +| `--workspace` = project dir, implicit history root | `history.json` written in the parent | metadata recorded, `index.updated=false` with the reason, parent untouched | R2-F01 | +| refused `download` target | directory created before the refusal | nothing created | R2-F01 | +| OBJ with several material groups | every same-channel map → first texture | mapped by the server-side file name; ambiguous maps kept as written + `material_reference_ambiguous`, `material_links.status=incomplete` | R2-F02 | +| `stream` + `--save-json`/`--project` failure | bare error envelope after the task events | one `outcome` line (sequence continues) / one envelope | R2-F03 | +| task `-o` fails on the 2nd asset | `downloads.files: []`, `local_io` | `downloads.state=partial` with a per-file manifest; HTTP class and status kept (legacy error payload gains `code`/`status`/`result.downloads`) | R2-F04 | +| Ctrl-C during a task `-o` transfer | download completes, exit 0 | exit 130 `interrupted`, manifest of what landed, temp file removed | R2-F05 | +| OAuth profile without `user_id` and the same `--operation-id` | replayed the other login's task | `login_id` minted at login binds the journal; profiles with neither are refused a replay (exit 2, `credential_unverified`) until `meshy auth login` | R2-F06 | + New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, `--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 35a2a82..e912203 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -68,6 +68,8 @@ meshy list [--page-size ] --output-schema v1 (`… list`) and reconcile first. Pass `--operation-id ` to a create so a repeat of the same request replays the recorded outcome instead of billing again; a different key/account, payload or image under the same id is refused (exit 2). + An OAuth profile without an account id or login id (saved before `login_id` existed) + is refused a replay (`credential_unverified`) — run `meshy auth login` once first. - **An error after `create` was accepted still names the task**: read `result.task_id` / `result.submission.task_id` and `result.next` from any non-zero exit (11 local I/O, 1 polling failure, 130 interrupt) before deciding @@ -168,6 +170,10 @@ Exit codes: `0` ok · `1` task FAILED/CANCELED while waiting, or unclassified · - Exit 9 → run `meshy balance`, relay the number, do not retry. - Exit 6 → back off; the CLI does not retry for you. - Exit 8 → `wait`/`stream` the same task id again; it was not cancelled. +- Exit 130 after `-o` → the transfer was cancelled; `result.downloads.files` lists what + already landed (`status: written`), the rest can be fetched with `meshy download`. +- `result.downloads.material_links.status: "incomplete"` → name the references that + stayed unresolved or ambiguous (`texture_maps[].method`) instead of claiming the model loads. - Exit 10 → reconcile with ` list`; never submit the same create again blindly. - A `FAILED` task → relay `result.task.task_error.message` verbatim; do not guess a cause. - Any error may carry `error.recovery.command` — prefer it over improvising. diff --git a/src/cmd/auth.ts b/src/cmd/auth.ts index 955c177..a1727b1 100644 --- a/src/cmd/auth.ts +++ b/src/cmd/auth.ts @@ -16,6 +16,7 @@ */ import * as readline from "node:readline"; +import { randomUUID } from "node:crypto"; import { Command } from "commander"; import { MeshyClient, MeshyApiError } from "../client/index.js"; import { loadConfig } from "../internal/config.js"; @@ -116,12 +117,16 @@ async function finishLogin( }, ): Promise { const file = resolveFile(flags); + // Every login gets its own identifier: the operation journal binds a + // submission to the account (user_id) when the token endpoint reports one, + // and to this login otherwise — never to a token that rotates on refresh. const profileData = { kind: "oauth" as const, access_token: tok.access_token, refresh_token: tok.refresh_token, expires_at: Date.now() + tok.expires_in * 1000, ...(tok.user_id ? { user_id: tok.user_id } : {}), + login_id: randomUUID(), }; const credState = saveProfile(file, opts.profile, profileData); diff --git a/src/cmd/download.ts b/src/cmd/download.ts index 3429741..7543cf1 100644 --- a/src/cmd/download.ts +++ b/src/cmd/download.ts @@ -24,7 +24,7 @@ import { downloadAssets, type DownloadedFile } from "../internal/download.js"; import { CliError, UsageError, type Warning } from "../internal/errors.js"; import { resolveWithinRoot, safeSegment } from "../internal/paths.js"; import { warning } from "../internal/result.js"; -import { recordTask, stageFromTaskType } from "../internal/project-store.js"; +import { indexRootFor, recordTask, stageFromTaskType } from "../internal/project-store.js"; import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; import { extractTaskObject } from "../internal/task-view.js"; import { existsSync } from "node:fs"; @@ -266,6 +266,7 @@ export const downloadCommand = new Command("download") if (projectDir && task) { const taskId = String(task["id"] ?? ""); const files = result.files.filter((f) => f.status === "written").map((f) => relative(projectDir, f.path).split(/[\\/]/).join("/")).filter((f) => !f.startsWith("..")); + const indexRoot = indexRootFor(projectDir, undefined, opened.flags.workspace); const rec = recordTask(projectDir, { taskId, stage: opts.stage ?? stageFromTaskType(task["type"], descriptor?.id ?? "download"), @@ -274,7 +275,7 @@ export const downloadCommand = new Command("download") endpoint: descriptor?.legacyEndpoint ?? null, status: typeof task["status"] === "string" ? (task["status"] as string) : null, files, - }); + }, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}`)); if (files.length !== result.files.filter((f) => f.status === "written").length) warnings.push(warning("files_outside_project", "some files were written outside the project directory and were not recorded")); project = { project_dir: projectDir, action: rec.action, stage: rec.entry.stage, recorded_files: files }; diff --git a/src/cmd/make.ts b/src/cmd/make.ts index 88402b2..086285f 100644 --- a/src/cmd/make.ts +++ b/src/cmd/make.ts @@ -320,14 +320,24 @@ async function finalOutcome( let downloads = base.downloads as Record; if (runtime.flags.output) { try { - const { savedFiles, metadataPath, materialLinks } = await downloadArtifacts(task, runtime.flags.output, step.resource, { root: runtime.flags.workspace }); + const { files, metadataPath, materialLinks } = await downloadArtifacts(task, runtime.flags.output, step.resource, { root: runtime.flags.workspace, signal: abortSignal() }); if (materialLinks) warnings.push(...materialLinks.warnings); - downloads = { state: "completed", files: savedFiles.map((p) => ({ path: p, status: "written" })), metadata_path: metadataPath, material_links: materialLinks }; + downloads = { state: "completed", files: files.map((f) => ({ key: f.key, path: f.path, status: f.status, bytes: f.bytes, sha256: f.sha256, error: f.error })), metadata_path: metadataPath, material_links: materialLinks }; } catch (err) { + // Whatever the downloader already committed stays in the manifest; the + // failure keeps its own class (HTTP status, interrupted, local I/O). + const partial = err instanceof CliError && err.result && typeof err.result["downloads"] === "object" ? (err.result["downloads"] as Record) : { state: "failed", files: [], metadata_path: null }; + const interrupted = (err instanceof CliError && err.code === "interrupted") || wasInterrupted(); throw new CliError({ - code: err instanceof CliError ? err.code : "local_io", - message: `make finished (task ${task.id}) but downloading its assets failed: ${err instanceof Error ? err.message : String(err)}`, - result: { route: plan.route, executed, task_id: task.id, task: base.task, submission: base.submission, downloads: { state: "failed", files: [], metadata_path: null }, next: taskNextCommands(descriptor, task.id) }, + code: interrupted ? "interrupted" : err instanceof CliError ? err.code : "local_io", + message: `make finished (task ${task.id}) but downloading its assets ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`, + httpStatus: err instanceof CliError ? err.httpStatus : null, + retryable: err instanceof CliError ? err.retryable : false, + recovery: err instanceof CliError && err.recovery ? err.recovery : { action: "download", automatic: false, command: `meshy download --resource ${step.resource} --task-id ${task.id} --all --output-dir ` }, + hint: err instanceof CliError ? err.hint : undefined, + details: err instanceof CliError ? err.details : undefined, + warnings: [...warnings, ...(err instanceof CliError ? err.warnings : [])], + result: { route: plan.route, executed, task_id: task.id, task: base.task, submission: base.submission, downloads: partial, next: taskNextCommands(descriptor, task.id) }, cause: err, }); } diff --git a/src/cmd/project.ts b/src/cmd/project.ts index 934dea4..1ef7f59 100644 --- a/src/cmd/project.ts +++ b/src/cmd/project.ts @@ -19,7 +19,7 @@ import { emitResult, openCommand } from "../internal/command-helpers.js"; import { UsageError } from "../internal/errors.js"; import { collect } from "../internal/flags.js"; import { resolveWithinRoot } from "../internal/paths.js"; -import { initProject, listProjects, readProject, rebuildIndex, recordTask } from "../internal/project-store.js"; +import { indexRootFor, initProject, listProjects, readProject, rebuildIndex, recordTask } from "../internal/project-store.js"; import { warning, type Warning } from "../internal/result.js"; import { buildLocalRuntime } from "../internal/runtime.js"; @@ -75,6 +75,11 @@ const recordCommand = new Command("record") if (!existsSync(join(projectDir, "metadata.json"))) { throw new UsageError(`${projectDir} has no metadata.json; run \`meshy project init\` first`); } + // An explicit --root outside the workspace is refused up front; the + // implicit root (the project's parent) is checked the same way and, when + // it lies outside, the index is skipped rather than written across the boundary. + if (opts.root) confine(resolvePath(opts.root), opened.flags.workspace, "--root"); + const indexRoot = indexRootFor(projectDir, opts.root, opened.flags.workspace); const res = recordTask( projectDir, { @@ -88,7 +93,7 @@ const recordCommand = new Command("record") taskJson: opts.taskJson ?? null, operationId: opts.operationId ?? null, }, - { root: opts.root ? confine(resolvePath(opts.root), opened.flags.workspace, "--root") : undefined }, + { root: indexRoot.root, skipIndex: indexRoot.skipIndex }, ); const warnings: Warning[] = []; const missing = (opts.file ?? []).filter((f) => !existsSync(join(projectDir, f))); diff --git a/src/index.ts b/src/index.ts index 19a47cb..f3e4751 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,9 +75,20 @@ async function reportFailure(program: Command, argv: string[], err: unknown): Pr const ctx = currentCommand(); const schema: OutputSchema = ctx?.schema ?? resolveSchemaHeuristically(program, argv); const format: OutputFormat = ctx?.format ?? resolveErrorFormat(program); + // A SIGINT that surfaced as some other failure is still reported as + // interrupted — but whatever the command already knew (task id, submission, + // files written so far, recovery command) travels with it. const interruptedErr = wasInterrupted() && !(err instanceof CliError && err.code === "interrupted") - ? new CliError({ code: "interrupted", message: "interrupted by SIGINT", cause: err }) + ? new CliError({ + code: "interrupted", + message: `interrupted by SIGINT${err instanceof Error && err.message ? ` (${err.message})` : ""}`, + result: err instanceof CliError ? err.result : null, + warnings: err instanceof CliError ? err.warnings : [], + recovery: err instanceof CliError ? err.recovery : null, + hint: err instanceof CliError ? err.hint : undefined, + cause: err, + }) : err; if (schema === "v1") { diff --git a/src/internal/config.ts b/src/internal/config.ts index 627d54c..9dd6641 100644 --- a/src/internal/config.ts +++ b/src/internal/config.ts @@ -68,6 +68,8 @@ export interface MeshyConfig { * API keys (the key itself is digested) and for profiles without a user id. */ credentialSubject?: string; + /** Per-login identifier of an OAuth profile (minted at `auth login`), the fallback identity when no user id exists. */ + credentialLoginId?: string; } export interface ConfigOverrides { @@ -164,6 +166,7 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { let credentialProfile: string | undefined; let credentialKind: CredentialKind = "api_key"; let credentialSubject: string | undefined; + let credentialLoginId: string | undefined; if (!PLACEHOLDER_KEYS.has(flagKey)) { apiKey = flagKey; @@ -194,6 +197,7 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { credentialProfile = stored.profile; credentialKind = stored.kind; credentialSubject = stored.userId; + credentialLoginId = stored.loginId; } else { throw authRequiredError( "No credentials found. Pass --api-key, export MESHY_API_KEY, or log in.", @@ -217,6 +221,7 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { credentialsFile: credFile, credentialKind, credentialSubject, + credentialLoginId, }; setLogLevel(cfg.logLevel); diff --git a/src/internal/credentials.ts b/src/internal/credentials.ts index c81f5c3..55abc05 100644 --- a/src/internal/credentials.ts +++ b/src/internal/credentials.ts @@ -52,6 +52,13 @@ export interface CredentialProfile { /** Unix epoch millis. */ expires_at?: number; user_id?: string; + /** + * Random identifier minted by `meshy auth login` for OAuth profiles. It + * survives token refreshes and is replaced by a new login, so the operation + * journal can bind a submission to *this* login even when the token endpoint + * reports no user_id. + */ + login_id?: string; created_at?: number; } @@ -321,6 +328,8 @@ export interface ResolvedStoredCredential { expiresAt?: number; /** Stable account subject of an OAuth profile, when the token endpoint reported one. */ userId?: string; + /** Per-login identifier of an OAuth profile (see CredentialProfile.login_id). */ + loginId?: string; } /** @@ -344,6 +353,7 @@ export function resolveStoredCredential(file: string): ResolvedStoredCredential accessToken: profile.access_token, expiresAt: profile.expires_at, ...(profile.user_id ? { userId: profile.user_id } : {}), + ...(profile.login_id ? { loginId: profile.login_id } : {}), }; } diff --git a/src/internal/download.ts b/src/internal/download.ts index e221ee0..24f423b 100644 --- a/src/internal/download.ts +++ b/src/internal/download.ts @@ -352,14 +352,14 @@ export async function downloadAssets(assets: readonly Asset[], opts: DownloadAss const warnings: Array<{ code: string; message: string }> = []; const dir = opts.targetFile ? dirname(resolvePath(opts.targetFile)) : resolvePath(opts.targetDir!); const rootReal = realpathLenient(opts.root); - mkdirSync(dir, { recursive: true }); // The directory and every planned leaf must be inside the root (and no - // symlink) before anything is fetched; the check repeats after any - // MIME-driven rename. + // symlink) before anything at all is created — a refused target must not + // leave a directory behind; the check repeats after any MIME-driven rename. resolveWithinRoot(dir, rootReal, { label: "output directory" }); for (const asset of assets) { resolveWithinRoot(join(dir, plannedName(asset, opts.targetFile)), rootReal, { label: "planned download path" }); } + mkdirSync(dir, { recursive: true }); let refreshed: Map | null | undefined; for (const asset of assets) { @@ -409,14 +409,31 @@ export async function downloadAssets(assets: readonly Asset[], opts: DownloadAss files.push(entry); opts.onFile?.(entry); } - const materialLinks = await relinkWritten(files, warnings); + const sources = new Map(assets.map((a) => [a.key, a.url ? basenameOfUrl(a.url) : null] as const)); + const materialLinks = await relinkWritten(files, warnings, sources); return { files, complete: files.every((f) => f.status === "written"), warnings, materialLinks }; } +/** Last path segment of an asset URL (decoded), the name the server knew the file by; null when unparseable. */ +export function basenameOfUrl(url: string): string | null { + try { + const segment = new URL(url).pathname.split("/").filter(Boolean).at(-1) ?? ""; + let name = segment; + try { + name = decodeURIComponent(segment); + } catch { + /* keep the raw segment */ + } + return name || null; + } catch { + return null; + } +} + /** After the set landed: point OBJ → MTL → textures at the saved names and re-take the digests of rewritten files. */ -async function relinkWritten(files: DownloadedFile[], warnings: Array<{ code: string; message: string }>): Promise { +async function relinkWritten(files: DownloadedFile[], warnings: Array<{ code: string; message: string }>, sources: Map): Promise { const written = files.filter((f) => f.status === "written" && f.container_format === null); - const links = await relinkMaterials(written.map((f) => ({ key: f.key, path: f.path }))); + const links = await relinkMaterials(written.map((f) => ({ key: f.key, path: f.path, sourceName: sources.get(f.key) ?? null }))); if (!links) return null; for (const path of links.rewritten) { const entry = files.find((f) => f.path === path); @@ -618,16 +635,68 @@ export function looksLikeFile(path: string): boolean { return /\.[A-Za-z0-9]{2,6}$/.test(path); } +export interface LegacyDownloadedFile { + /** Legacy artifact slot ("model_glb", "thumbnail", "texture_0_base_color", …). */ + key: string; + /** Final absolute path (after any content-type driven rename) — or the planned path for a failed entry. */ + path: string; + bytes: number; + sha256: string; + content_type: string | null; + status: "written" | "failed"; + error: string | null; + /** True when the file's material references were rewritten to the saved names. */ + relinked: boolean; +} + export interface DownloadResult { savedFiles: string[]; metadataPath: string; /** OBJ/MTL/texture reference report when the artifacts contained a text OBJ. */ materialLinks: MaterialLinkReport | null; + /** + * Per-file manifest in download order. When the download stops early the + * same list (written files + the failed one) travels on the thrown + * CliError's `result.downloads`, so nothing already on disk is forgotten. + */ + files: LegacyDownloadedFile[]; } export interface DownloadArtifactsOptions { /** Authorised root (the --workspace); every directory and file must resolve inside it. Default: the output directory itself. */ root?: string; + /** Cooperative cancellation (SIGINT): aborts the in-flight transfer and stops every later download, relink and publish. */ + signal?: AbortSignal; +} + +function interruptedBefore(what: string): CliError { + return new CliError({ code: "interrupted", message: `interrupted before ${what} was downloaded; nothing further was fetched` }); +} + +/** + * Wrap a per-artifact failure so the caller sees what already landed. The + * original classification (network / not_found / validation / interrupted / + * local_io, HTTP status, recovery) is kept; only the message names the artifact. + */ +function downloadFailure(artifact: Artifact, err: unknown, files: LegacyDownloadedFile[]): CliError { + const message = err instanceof Error ? err.message : String(err); + const downloads = { state: files.some((f) => f.status === "written") ? "partial" : "failed", files, metadata_path: null }; + if (err instanceof CliError) { + return new CliError({ + code: err.code, + message: `download failed for ${artifact.key}: ${message}`, + exitCode: err.exitCode, + httpStatus: err.httpStatus, + retryable: err.retryable, + recovery: err.recovery, + hint: err.hint, + details: err.details, + warnings: err.warnings, + result: { ...(err.result ?? {}), downloads }, + cause: err, + }); + } + return new CliError({ code: "local_io", message: `download failed for ${artifact.key}: ${message}`, result: { downloads }, cause: err }); } export async function downloadArtifacts( @@ -637,12 +706,15 @@ export async function downloadArtifacts( opts: DownloadArtifactsOptions = {}, ): Promise { const artifacts = enumerateArtifacts(task); + // An explicit workspace is the root for everything written here — the + // directory, every planned file and the sidecar — checked before mkdir. + const workspaceReal = opts.root !== undefined ? realpathLenient(resolvePath(opts.root)) : null; if (artifacts.length === 0) { // Report-only tasks (analyze-printability) carry their result in a // structured field instead of downloadable files. Persist the full task // JSON so `-o` still means "give me the result on disk". if (task.printability != null) { - return saveReportOnly(task, outputPath, resource); + return saveReportOnly(task, outputPath, resource, workspaceReal); } throw new Error(`task ${task.id} has no downloadable artifacts`); } @@ -659,15 +731,13 @@ export async function downloadArtifacts( // work when a destination already exists. This prevents silent overwrite // of prior runs. let targetDir: string; - const plannedArtifactPaths: string[] = []; + const plan: Array<{ artifact: Artifact; target: string }> = []; if (singleFileMode) { targetDir = dirname(outputPath) || "."; - plannedArtifactPaths.push(outputPath); + plan.push({ artifact: artifacts[0]!, target: outputPath }); } else { targetDir = outputPath; - for (const artifact of artifacts) { - plannedArtifactPaths.push(join(targetDir, deriveFilename(artifact))); - } + for (const artifact of artifacts) plan.push({ artifact, target: join(targetDir, deriveFilename(artifact)) }); } // Per-file meta in single-file mode (`-o a.png` → `a_meta.json`) so two // outputs can share a directory without trampling each other. Directory @@ -676,7 +746,7 @@ export async function downloadArtifacts( ? `${stripExt(outputPath)}_meta.json` : join(targetDir, "meta.json"); - const existing = [...plannedArtifactPaths, metadataPath].filter((p) => existsSync(p)); + const existing = [...plan.map((p) => p.target), metadataPath].filter((p) => existsSync(p)); if (existing.length > 0) { throw new UsageError( `refusing to overwrite existing file(s):\n ${existing.join("\n ")}\n` + @@ -684,29 +754,41 @@ export async function downloadArtifacts( ); } - // An explicit workspace is the root for everything written here — the - // directory, every planned file and the sidecar — checked before mkdir. - const workspaceReal = opts.root !== undefined ? realpathLenient(resolvePath(opts.root)) : null; if (workspaceReal) { resolveWithinRoot(targetDir, workspaceReal, { label: "output directory" }); - for (const p of [...plannedArtifactPaths, metadataPath]) resolveWithinRoot(p, workspaceReal, { label: "planned download path" }); + for (const p of [...plan.map((x) => x.target), metadataPath]) resolveWithinRoot(p, workspaceReal, { label: "planned download path" }); } mkdirSync(targetDir, { recursive: true }); const root = workspaceReal ?? realpathLenient(targetDir); + const files: LegacyDownloadedFile[] = []; const saved: string[] = []; - if (singleFileMode) { - saved.push(await downloadArtifact(artifacts[0]!, outputPath, root)); - } else { - for (const artifact of artifacts) { - const targetPath = join(targetDir, deriveFilename(artifact)); - saved.push(await downloadArtifact(artifact, targetPath, root)); + for (const { artifact, target } of plan) { + if (opts.signal?.aborted) throw downloadFailure(artifact, interruptedBefore(artifact.key), files); + try { + const placed = await downloadArtifact(artifact, target, root, opts.signal); + saved.push(placed.path); + files.push({ key: artifact.key, path: placed.path, bytes: placed.bytes, sha256: placed.sha256, content_type: placed.contentType, status: "written", error: null, relinked: false }); + } catch (err) { + files.push({ key: artifact.key, path: target, bytes: 0, sha256: "", content_type: null, status: "failed", error: err instanceof Error ? err.message : String(err), relinked: false }); + throw downloadFailure(artifact, err, files); } } - const materialLinks = await relinkMaterials(artifacts.map((a, i) => ({ key: a.key, path: resolvePath(saved[i]!) }))); - for (const w of materialLinks?.warnings ?? []) logger.warn(w.message); + if (opts.signal?.aborted) throw downloadFailure(plan[plan.length - 1]!.artifact, new CliError({ code: "interrupted", message: "interrupted before the material references were relinked and the sidecar written" }), files); + const materialLinks = await relinkMaterials(plan.map((p, i) => ({ key: p.artifact.key, path: resolvePath(saved[i]!), sourceName: basenameOfUrl(p.artifact.url) }))); + if (materialLinks) { + for (const path of materialLinks.rewritten) { + const entry = files.find((f) => resolvePath(f.path) === path); + if (!entry) continue; + const digest = fileDigest(path); + entry.bytes = digest.bytes; + entry.sha256 = digest.sha256; + entry.relinked = true; + } + for (const w of materialLinks.warnings) logger.warn(w.message); + } writeMeta(task, resource, metadataPath, saved); - return { savedFiles: saved, metadataPath, materialLinks }; + return { savedFiles: saved, metadataPath, materialLinks, files }; } function deriveFilename(artifact: Artifact): string { @@ -726,45 +808,59 @@ function deriveFilename(artifact: Artifact): string { return artifact.preferredExt ? `${stem}.${artifact.preferredExt}` : stem; } -async function downloadArtifact(artifact: Artifact, targetPath: string, root: string): Promise { +interface PlacedArtifact { + path: string; + bytes: number; + sha256: string; + contentType: string | null; +} + +/** Fetch one artifact into place. Errors keep their class: a CliError from the fetch/publish core is rethrown untouched. */ +async function downloadArtifact(artifact: Artifact, targetPath: string, root: string, signal: AbortSignal | undefined): Promise { logger.debug(`GET ${redact(artifact.url)}`); - let fetched: FetchedFile; - try { - fetched = await fetchToTemp(artifact.url, targetPath); - } catch (err) { - throw new Error(`download failed for ${artifact.key} (${err instanceof Error ? err.message : String(err)})`); - } + const fetched = await fetchToTemp(artifact.url, targetPath, { signal }); const actualExt = extFromContentType(fetched.contentType); const requestedExt = extname(targetPath).slice(1).toLowerCase(); let finalPath = targetPath; let tmp = fetched.tmpPath; + let bytes = fetched.bytes; + let sha = fetched.sha256; - if (!requestedExt && actualExt) { - // No extension requested (e.g. unknown-format image artifact) — pick one - // from the content-type so the file has a reasonable suffix. - finalPath = `${targetPath}.${actualExt}`; - } else if (actualExt && requestedExt && actualExt !== requestedExt && !extEquivalent(actualExt, requestedExt)) { - if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) { - // Both sides are image formats sharp understands — convert. - logger.debug(`converting ${actualExt} → ${requestedExt} for ${artifact.key}`); - const converted = `${tmp}.conv`; - await convertImage(readFileSync(tmp), requestedExt, converted); - removeQuietly(tmp); - tmp = converted; - } else { - // Can't convert safely — save with the true extension so the file isn't a lie. - finalPath = `${targetPath.slice(0, targetPath.length - (requestedExt.length + 1))}.${actualExt}`; - logger.warn( - `extension mismatch for ${artifact.key}: requested .${requestedExt}, got ${fetched.contentType || "?"}; ` + - `cannot transcode ${actualExt} → ${requestedExt}, saving as ${finalPath}`, - ); + try { + if (!requestedExt && actualExt) { + // No extension requested (e.g. unknown-format image artifact) — pick one + // from the content-type so the file has a reasonable suffix. + finalPath = `${targetPath}.${actualExt}`; + } else if (actualExt && requestedExt && actualExt !== requestedExt && !extEquivalent(actualExt, requestedExt)) { + if (CONVERTIBLE_IMAGE_EXTS.has(actualExt) && CONVERTIBLE_IMAGE_EXTS.has(requestedExt)) { + // Both sides are image formats sharp understands — convert. + logger.debug(`converting ${actualExt} → ${requestedExt} for ${artifact.key}`); + const converted = `${tmp}.conv`; + await convertImage(readFileSync(tmp), requestedExt, converted); + removeQuietly(tmp); + tmp = converted; + const digest = fileDigest(converted); + bytes = digest.bytes; + sha = digest.sha256; + } else { + // Can't convert safely — save with the true extension so the file isn't a lie. + finalPath = `${targetPath.slice(0, targetPath.length - (requestedExt.length + 1))}.${actualExt}`; + logger.warn( + `extension mismatch for ${artifact.key}: requested .${requestedExt}, got ${fetched.contentType || "?"}; ` + + `cannot transcode ${actualExt} → ${requestedExt}, saving as ${finalPath}`, + ); + } } + if (signal?.aborted) throw new CliError({ code: "interrupted", message: `interrupted before ${artifact.key} was published` }); + // The final name may differ from the pre-checked one; it still may not + // clobber anything and must stay under the output directory. + const resolved = resolveWithinRoot(finalPath, root, { label: "download target" }).path; + publishTempFile(tmp, resolved, { overwrite: false }); + } catch (err) { + removeQuietly(tmp); + throw err; } - // The final name may differ from the pre-checked one; it still may not - // clobber anything and must stay under the output directory. - const resolved = resolveWithinRoot(finalPath, root, { label: "download target" }).path; - publishTempFile(tmp, resolved, { overwrite: false }); - return finalPath === targetPath ? targetPath : finalPath; + return { path: finalPath === targetPath ? targetPath : finalPath, bytes, sha256: sha, contentType: fetched.contentType }; } /** @@ -774,41 +870,43 @@ async function downloadArtifact(artifact: Artifact, targetPath: string, root: st * - `-o some/dir/` (or any non-file path): write `meta.json` inside, * matching the directory-mode layout used elsewhere. * Other single-file extensions are rejected — the data is JSON, lying about - * the extension would be worse than a clear error. + * the extension would be worse than a clear error. Every path is proven inside + * the workspace (when given) before any directory or file is created. */ -function saveReportOnly(task: Task, outputPath: string, resource: string): DownloadResult { +function saveReportOnly(task: Task, outputPath: string, resource: string, workspaceReal: string | null): DownloadResult { const singleFileMode = looksLikeFile(outputPath); + const abs = resolvePath(outputPath); if (singleFileMode) { - const ext = extname(outputPath).slice(1).toLowerCase(); + const ext = extname(abs).slice(1).toLowerCase(); if (ext !== "json") { throw new UsageError( `${resource} produces a JSON report — pass '-o .json' or a directory path (got '${outputPath}').`, ); } - if (existsSync(outputPath)) { + if (existsSync(abs)) { throw new UsageError( `refusing to overwrite existing file:\n ${outputPath}\n` + `(delete it or choose a different --output path to rerun)`, ); } - mkdirSync(dirname(outputPath) || ".", { recursive: true }); - writeFileSync( - outputPath, - `${JSON.stringify({ resource, task, downloaded_at: new Date().toISOString() }, null, 2)}\n`, - "utf8", - ); - return { savedFiles: [], metadataPath: outputPath, materialLinks: null }; + if (workspaceReal) resolveWithinRoot(abs, workspaceReal, { label: "report path" }); + writeJsonFile(abs, { resource, task, downloaded_at: new Date().toISOString() }, { overwrite: false, mode: 0o644 }); + return { savedFiles: [], metadataPath: abs, materialLinks: null, files: [] }; } - const metadataPath = join(outputPath, "meta.json"); + const metadataPath = join(abs, "meta.json"); if (existsSync(metadataPath)) { throw new UsageError( `refusing to overwrite existing file:\n ${metadataPath}\n` + `(delete it or choose a different --output path to rerun)`, ); } - mkdirSync(outputPath, { recursive: true }); + if (workspaceReal) { + resolveWithinRoot(abs, workspaceReal, { label: "output directory" }); + resolveWithinRoot(metadataPath, workspaceReal, { label: "planned download path" }); + } + mkdirSync(abs, { recursive: true }); writeMeta(task, resource, metadataPath, []); - return { savedFiles: [], metadataPath, materialLinks: null }; + return { savedFiles: [], metadataPath, materialLinks: null, files: [] }; } function writeMeta(task: Task, resource: string, path: string, savedFiles: string[]): void { diff --git a/src/internal/material-links.ts b/src/internal/material-links.ts index 683a478..54c7f82 100644 --- a/src/internal/material-links.ts +++ b/src/internal/material-links.ts @@ -11,11 +11,16 @@ * text files the CLI itself just wrote are touched; nothing is renamed, the * rewritten paths are listed, and their digests are re-taken by the caller. * - * Texture references are matched in this order: the exact saved file name, a - * channel word in the referenced name (…_normal.png), the MTL key's channel - * (map_Kd → base color), and finally "the only texture there is" when the MTL - * has exactly one distinct reference. Anything else stays as written and is - * reported as `material_reference_unresolved`. + * A texture reference is resolved only when exactly one downloaded texture + * matches, in this order: the saved file name itself; the name the server + * served the texture under (the URL's last segment — `body.png` for + * `…/body.png`); the same name ignoring extension and directories; a channel + * word inside the referenced name (…_normal.png); the channel implied by the + * MTL key (map_Kd → base color); finally "the only texture there is" when the + * MTL has exactly one distinct reference. Several candidates for the same rule + * is an *ambiguity*: the reference stays as written, the candidates are listed + * and the report is `incomplete` — the CLI never picks the first of several + * material groups' textures. Unresolved references are reported the same way. */ import { createHash } from "node:crypto"; @@ -33,27 +38,59 @@ export interface LinkableFile { key: string; /** Absolute path as written. */ path: string; + /** The file name the server served the asset under (last URL path segment), when known. */ + sourceName?: string | null; } +export type LinkMethod = + | "unchanged" + | "exact" + | "source_name" + | "source_stem" + | "channel_in_name" + | "channel_of_key" + | "only_texture" + | "downloaded_mtl" + | "ambiguous" + | "unresolved"; + export interface ReferenceLink { /** 1-based line in the file that carried the reference. */ line: number; + /** `newmtl` group the map belongs to (MTL only). */ + material: string | null; /** The reference as written before relinking. */ reference: string; - /** Saved file name the reference now points at, or null when it could not be resolved. */ + /** Saved file name the reference now points at, or null when it stays as written. */ resolved_to: string | null; /** How the link was decided. */ - method: "unchanged" | "exact" | "channel_in_name" | "channel_of_key" | "only_texture" | "downloaded_mtl" | "unresolved"; + method: LinkMethod; + /** Saved names that matched when the reference was ambiguous. */ + candidates?: string[]; +} + +export interface TextureDescriptor { + key: string; + /** Saved file name. */ + name: string; + /** Name the server served it under, when known. */ + source_name: string | null; + /** `texture_urls` set index (material group) the texture came from. */ + set: number | null; + /** Canonical channel (basecolor, normal, …) from the asset key. */ + channel: string | null; } export interface MaterialLinkReport { obj: string; mtl: string | null; - textures: string[]; + textures: TextureDescriptor[]; mtllib: ReferenceLink[]; texture_maps: ReferenceLink[]; /** Absolute paths whose content was rewritten. */ rewritten: string[]; + /** `complete` when every reference points at a downloaded file; `incomplete` when any stayed as written. */ + status: "complete" | "incomplete"; warnings: Warning[]; } @@ -114,10 +151,16 @@ function canonicalChannel(raw: string): string | null { return CHANNEL_SYNONYMS[compact] ?? null; } -/** Channel encoded in an asset key: `texture.0.base_color` / `texture_0_base_color`. */ -export function channelOfTextureKey(key: string): string | null { +/** Set index and channel encoded in an asset key: `texture.0.base_color` / `texture_0_base_color`. */ +export function describeTextureKey(key: string): { set: number | null; channel: string | null } { const m = /^texture[._](\d+)[._](.+)$/.exec(key); - return m ? canonicalChannel(m[2]!) : null; + if (!m) return { set: null, channel: null }; + return { set: Number(m[1]), channel: canonicalChannel(m[2]!) }; +} + +/** @deprecated kept for callers of the round-1 API. */ +export function channelOfTextureKey(key: string): string | null { + return describeTextureKey(key).channel; } /** Channel word inside a referenced file name (`texture_normal.png`, `Body_BaseColor.jpg`). */ @@ -142,6 +185,10 @@ function keywordOf(t: string): string { return m ? t.slice(0, m.index) : t; } +function stemOf(name: string): string { + return name.replace(/\.[A-Za-z0-9]+$/, "").toLowerCase(); +} + function looksBinary(path: string): boolean { const fd = openSync(path, "r"); try { @@ -270,24 +317,66 @@ function parseMapLine(line: string): { indent: string; key: string; options: str return { indent, key, options: "", ref: rest }; } +type Resolution = { kind: "hit"; name: string; method: LinkMethod } | { kind: "ambiguous"; method: LinkMethod; candidates: string[] } | { kind: "none" }; + +/** Apply one rule: exactly one candidate resolves, several are an ambiguity, none falls through. */ +function pick(candidates: TextureDescriptor[], method: LinkMethod): Resolution | null { + if (candidates.length === 1) return { kind: "hit", name: candidates[0]!.name, method }; + if (candidates.length > 1) return { kind: "ambiguous", method, candidates: candidates.map((c) => c.name) }; + return null; +} + +function resolveTextureReference(key: string, ref: string, textures: TextureDescriptor[], distinctRefs: number): Resolution { + const refBase = basename(ref.replaceAll("\\", "/")); + const lower = refBase.toLowerCase(); + const exact = textures.filter((t) => t.name === refBase) ; + if (exact.length === 1) return { kind: "hit", name: exact[0]!.name, method: ref === refBase ? "unchanged" : "exact" }; + const exactCi = pick(textures.filter((t) => t.name.toLowerCase() === lower), "exact"); + if (exactCi) return exactCi; + const bySource = pick(textures.filter((t) => t.source_name !== null && t.source_name.toLowerCase() === lower), "source_name"); + if (bySource) return bySource; + const stem = stemOf(refBase); + const byStem = pick(textures.filter((t) => t.source_name !== null && stemOf(t.source_name) === stem), "source_stem"); + if (byStem) return byStem; + const inName = channelInFileName(refBase); + if (inName) { + const r = pick(textures.filter((t) => t.channel === inName), "channel_in_name"); + if (r) return r; + } + const ofKey = MAP_KEY_CHANNEL[key.toLowerCase()]; + if (ofKey) { + const r = pick(textures.filter((t) => t.channel === ofKey), "channel_of_key"); + if (r) return r; + } + if (textures.length === 1 && distinctRefs === 1) return { kind: "hit", name: textures[0]!.name, method: "only_texture" }; + return { kind: "none" }; +} + /** * Relink the OBJ/MTL/texture files of one download. Returns null when the set - * has no text OBJ (nothing to relink). Never throws for unresolved references; - * those become warnings and `resolved_to: null` entries. + * has no text OBJ (nothing to relink). Never throws for unresolved or + * ambiguous references; those become warnings, `resolved_to: null` entries + * and `status: "incomplete"`. */ export async function relinkMaterials(files: readonly LinkableFile[]): Promise { const obj = files.find((f) => isObjKey(f.key) && /\.obj$/i.test(f.path)); if (!obj) return null; if (looksBinary(obj.path)) return null; const mtl = files.find((f) => isMtlKey(f.key)) ?? null; - const textures = files.filter((f) => isTextureKey(f.key)); + const textures: TextureDescriptor[] = files + .filter((f) => isTextureKey(f.key)) + .map((f) => { + const d = describeTextureKey(f.key); + return { key: f.key, name: basename(f.path), source_name: f.sourceName ?? null, set: d.set, channel: d.channel }; + }); const report: MaterialLinkReport = { obj: obj.path, mtl: mtl?.path ?? null, - textures: textures.map((t) => t.path), + textures, mtllib: [], texture_maps: [], rewritten: [], + status: "complete", warnings: [], }; @@ -299,19 +388,20 @@ export async function relinkMaterials(files: readonly LinkableFile[]): Promise 0) { + report.status = "incomplete"; report.warnings.push( warning( "material_reference_unresolved", @@ -320,7 +410,7 @@ export async function relinkMaterials(files: readonly LinkableFile[]): Promise MAX_MTL_BYTES) { + report.status = "incomplete"; report.warnings.push(warning("material_reference_unresolved", `${basename(mtl.path)} is ${size} bytes; too large for an MTL, texture references were not checked`)); return report; } - const textureNames = textures.map((t) => basename(t.path)); - const byChannel = new Map(); - for (const t of textures) { - const ch = channelOfTextureKey(t.key); - if (ch && !byChannel.has(ch)) byChannel.set(ch, basename(t.path)); - } const distinctRefs = new Set(); for (const raw of readFileSync(mtl.path, "utf8").split(/\r?\n/)) { const parsed = parseMapLine(raw); if (parsed) distinctRefs.add(parsed.ref); } - const resolve = (key: string, ref: string): { name: string; method: ReferenceLink["method"] } | null => { - const refBase = basename(ref.replaceAll("\\", "/")); - const exact = textureNames.find((n) => n === refBase) ?? textureNames.find((n) => n.toLowerCase() === refBase.toLowerCase()); - if (exact) return { name: exact, method: exact === ref ? "unchanged" : "exact" }; - const inName = channelInFileName(refBase); - if (inName && byChannel.has(inName)) return { name: byChannel.get(inName)!, method: "channel_in_name" }; - const ofKey = MAP_KEY_CHANNEL[key.toLowerCase()]; - if (ofKey && byChannel.has(ofKey)) return { name: byChannel.get(ofKey)!, method: "channel_of_key" }; - if (textureNames.length === 1 && distinctRefs.size === 1) return { name: textureNames[0]!, method: "only_texture" }; - return null; - }; + let material: string | null = null; const mtlChanged = await rewriteLines(mtl.path, (line, lineNo) => { + const t = line.trim(); + if (keywordOf(t) === "newmtl") { + material = t.slice("newmtl".length).trim() || null; + return null; + } const parsed = parseMapLine(line); if (!parsed) return null; - const hit = resolve(parsed.key, parsed.ref); - if (!hit) { - report.texture_maps.push({ line: lineNo, reference: parsed.ref, resolved_to: null, method: "unresolved" }); + const res = resolveTextureReference(parsed.key, parsed.ref, textures, distinctRefs.size); + if (res.kind === "none") { + report.texture_maps.push({ line: lineNo, material, reference: parsed.ref, resolved_to: null, method: "unresolved" }); return null; } - report.texture_maps.push({ line: lineNo, reference: parsed.ref, resolved_to: hit.name, method: hit.method }); - if (hit.name === parsed.ref) return null; - return `${parsed.indent}${parsed.key}${parsed.options ? ` ${parsed.options}` : ""} ${hit.name}`; + if (res.kind === "ambiguous") { + report.texture_maps.push({ line: lineNo, material, reference: parsed.ref, resolved_to: null, method: "ambiguous", candidates: res.candidates }); + return null; + } + report.texture_maps.push({ line: lineNo, material, reference: parsed.ref, resolved_to: res.name, method: res.method }); + if (res.name === parsed.ref) return null; + return `${parsed.indent}${parsed.key}${parsed.options ? ` ${parsed.options}` : ""} ${res.name}`; }); if (mtlChanged) report.rewritten.push(mtl.path); - const unresolved = report.texture_maps.filter((l) => l.resolved_to === null); + const ambiguous = report.texture_maps.filter((l) => l.method === "ambiguous"); + const unresolved = report.texture_maps.filter((l) => l.method === "unresolved"); + if (ambiguous.length > 0) { + report.status = "incomplete"; + report.warnings.push( + warning( + "material_reference_ambiguous", + `${basename(mtl.path)}: ${ambiguous.map((l) => `'${l.reference}'${l.material ? ` (${l.material})` : ""} could be ${l.candidates!.join(" or ")}`).join("; ")}; the references stay as written — the CLI does not guess between material groups`, + ), + ); + } if (unresolved.length > 0) { + report.status = "incomplete"; report.warnings.push( warning( "material_reference_unresolved", diff --git a/src/internal/operation-store.ts b/src/internal/operation-store.ts index ac732de..e6e91f5 100644 --- a/src/internal/operation-store.ts +++ b/src/internal/operation-store.ts @@ -11,9 +11,15 @@ * * Identity of a request = resource + API origin + credential fingerprint + * payload fingerprint. The credential fingerprint binds to the actual account: - * a keyed digest of the API key, or the stable OAuth subject (user id) — never - * the rotating access token, so a routine refresh is still the same identity - * while a different key under the same env variable is not. The payload + * a keyed digest of the API key, or the stable OAuth subject (user id), or — + * when the token endpoint reported no user id — the per-login identifier + * minted at `meshy auth login`. Never the rotating access token, so a routine + * refresh is still the same identity while a different key under the same env + * variable, or a new login under the same profile name, is not. An OAuth + * profile that carries neither a user id nor a login id (written before login + * ids existed) has *no* verifiable identity: such a credential can start new + * operations but is refused a replay of an existing one, because "unknown" + * must never be read as "the same account". The payload * fingerprint hashes media *content* (decoded bytes of every data URI), so two * different images of the same size never collide. * @@ -61,6 +67,12 @@ export interface OperationIdentity { endpoint: string; apiOrigin: string; credentialFingerprint: string; + /** + * False when nothing stable identifies the credential (an OAuth profile + * without user id or login id). Such a credential may start a new operation + * but is never granted a replay of an existing record. Default true. + */ + credentialVerified?: boolean; payloadFingerprint: string; project?: string | null; } @@ -86,25 +98,40 @@ export interface CredentialIdentityParts { secret?: string | null; /** Stable account subject for OAuth profiles (user id). Tokens rotate; the subject does not. */ subject?: string | null; + /** Per-login identifier of an OAuth profile (minted at `auth login`), used when no subject exists. */ + loginId?: string | null; +} + +export interface CredentialBinding { + /** What the fingerprint binds to; `unverified` when nothing stable identifies the account. */ + binding: string; + verified: boolean; +} + +/** Decide what identifies this credential — and whether anything does. */ +export function credentialBinding(parts: CredentialIdentityParts): CredentialBinding { + if (parts.kind === "oauth") { + if (parts.subject) return { binding: `subject:${parts.subject}`, verified: true }; + if (parts.loginId) return { binding: `login:${parts.loginId}`, verified: true }; + return { binding: "unverified", verified: false }; + } + if (parts.secret) return { binding: `key:${sha256(`${CREDENTIAL_DIGEST_DOMAIN}|${parts.secret}`)}`, verified: true }; + return { binding: "key:none", verified: false }; } const CREDENTIAL_DIGEST_DOMAIN = "meshy-cli/credential-binding/v1"; /** * `sha256(source|profile|kind|origin|binding)` where the binding is a keyed - * digest of the API key, or the OAuth subject. Two different keys from the same - * source therefore have different fingerprints; a refreshed OAuth token keeps - * its fingerprint as long as the account is the same. Never reversible to a key. + * digest of the API key, the OAuth subject, or the OAuth login id. Two + * different keys from the same source therefore have different fingerprints; + * a refreshed OAuth token keeps its fingerprint as long as the account (or the + * login) is the same. Never reversible to a key. Callers must also consult + * `credentialBinding(parts).verified`: an unverified fingerprint identifies + * nothing and must not be matched against an existing record. */ export function credentialFingerprint(parts: CredentialIdentityParts): string { - let binding: string; - if (parts.kind === "oauth") { - binding = parts.subject ? `subject:${parts.subject}` : "subject:unknown"; - } else if (parts.secret) { - binding = `key:${sha256(`${CREDENTIAL_DIGEST_DOMAIN}|${parts.secret}`)}`; - } else { - binding = "key:none"; - } + const { binding } = credentialBinding(parts); return sha256(`${parts.source}|${parts.profile ?? ""}|${parts.kind ?? ""}|${parts.origin}|${binding}`); } @@ -189,6 +216,16 @@ export function beginOperation(root: string, operationId: string, identity: Oper return withFileLock(lockPath(root), () => { const existing = readOperation(root, operationId); if (existing) { + if (identity.credentialVerified === false) { + throw new CliError({ + code: "operation_conflict", + message: + `operation ${operationId} already exists but the current OAuth login has no account identity (profile without user_id or login_id), so it cannot be confirmed as the same account; ` + + "nothing was submitted — run `meshy auth login` to bind this login, or use a new --operation-id", + recovery: { action: "login", automatic: false, command: "meshy auth login" }, + result: { submission: { state: existing.state, operation_id: operationId, task_id: existing.task_id }, conflict: ["credential_unverified"] }, + }); + } const differs: string[] = []; if (existing.resource !== identity.resource) differs.push("resource"); if (existing.api_origin !== identity.apiOrigin) differs.push("origin"); diff --git a/src/internal/project-store.ts b/src/internal/project-store.ts index ffa7cf7..8952bb3 100644 --- a/src/internal/project-store.ts +++ b/src/internal/project-store.ts @@ -92,6 +92,25 @@ export interface StoreOptions { lockTimeoutMs?: number; } +/** + * Where a project's history index lives (its parent directory unless the + * caller named a root) and whether this invocation may write there. With an + * explicit --workspace the index root must resolve inside it; when it does not + * (the workspace *is* the project directory), metadata is still recorded and + * the index is left alone with an explicit reason — nothing is ever written, + * locked or temp-filed outside the workspace. + */ +export function indexRootFor(projectDir: string, explicitRoot: string | undefined, workspace: string | undefined): { root: string; skipIndex?: string } { + const root = explicitRoot !== undefined ? resolvePath(explicitRoot) : resolvePath(projectDir, ".."); + if (!workspace) return { root }; + try { + resolveWithinRoot(root, workspace, { label: "history root" }); + return { root }; + } catch (err) { + return { root, skipIndex: `history root ${root} resolves outside --workspace ${resolvePath(workspace)}; metadata.json was recorded but history.json was not touched (${err instanceof Error ? err.message : String(err)}) — run \`meshy project rebuild-index --root ${root}\` from a workspace that contains it` }; + } +} + const ISO = (d: Date) => d.toISOString(); function projectLock(projectDir: string): string { @@ -260,7 +279,7 @@ export interface RecordResult { * a repeat merges files (union) and refreshes status/task_json instead of * appending a duplicate entry. */ -export function recordTask(projectDir: string, input: RecordInput, opts: StoreOptions & { root?: string } = {}): RecordResult { +export function recordTask(projectDir: string, input: RecordInput, opts: StoreOptions & { root?: string; skipIndex?: string } = {}): RecordResult { const now = (opts.now ?? (() => new Date()))(); const dir = resolvePath(projectDir); if (!input.taskId) throw new UsageError("--task-id is required"); @@ -318,7 +337,7 @@ export function recordTask(projectDir: string, input: RecordInput, opts: StoreOp ); const root = opts.root ? resolvePath(opts.root) : resolvePath(dir, ".."); - const index = refreshIndexEntry(root, dir, result.metadata, opts); + const index = opts.skipIndex ? { updated: false, error: opts.skipIndex } : refreshIndexEntry(root, dir, result.metadata, opts); return { project_dir: dir, metadata: result.metadata, entry: result.entry, action: result.action, migrated_from_legacy: result.legacy, index }; } @@ -463,9 +482,12 @@ export function rebuildIndex(root: string, opts: StoreOptions = {}): RebuildResu export function saveTaskSnapshot(projectDir: string, taskId: string, raw: unknown): { path: string; relative: string } { const dir = resolvePath(projectDir); const name = `task_${safeSegment(taskId, "task")}.json`; - const target = resolveWithinRoot(join(dir, name), dir, { label: "task snapshot" }).path; - writeJsonFile(target, raw, { overwrite: true, mode: 0o600 }); - return { path: target, relative: relative(dir, target).split(sep).join("/") }; + // Both sides of the relative path come from the same (real-path) frame, so a + // symlinked temp dir (macOS /var → /private/var) cannot turn "task_x.json" + // into a ../.. path that the metadata store then rightly refuses. + const resolved = resolveWithinRoot(join(dir, name), dir, { label: "task snapshot" }); + writeJsonFile(resolved.path, raw, { overwrite: true, mode: 0o600 }); + return { path: resolved.path, relative: relative(resolved.root, resolved.path).split(sep).join("/") }; } /** Derive a stage name from a task type (`text-to-3d-preview` → preview, `creative-lab-lamp-build` → build). */ diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index 16340cd..fb3af3a 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -121,6 +121,7 @@ export async function refreshOAuthCredentialIfNeeded( refresh_token: tok.refresh_token, expires_at: Date.now() + tok.expires_in * 1000, user_id: tok.user_id ?? profile.user_id, + ...(profile.login_id ? { login_id: profile.login_id } : {}), created_at: profile.created_at, }, { makeActive: false }, diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index bcea46a..e6ef433 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -48,6 +48,7 @@ import { streamTask } from "./stream.js"; import { toTaskView, type TaskView } from "./task-view.js"; import { beginOperation, + credentialBinding, credentialFingerprint, newOperationId, operationsRoot, @@ -57,7 +58,7 @@ import { } from "./operation-store.js"; import { originOf } from "./config.js"; import { resolveWithinRoot } from "./paths.js"; -import { recordTask, saveTaskSnapshot, stageFromTaskType } from "./project-store.js"; +import { indexRootFor, recordTask, saveTaskSnapshot, stageFromTaskType } from "./project-store.js"; import { existsSync } from "node:fs"; import { dirname, join, resolve as resolvePath } from "node:path"; @@ -110,8 +111,8 @@ const TASK_JSON_OPTIONS = (cmd: Command): Command => .option("--stage ", "stage label for the project record (default: derived from the task type, e.g. preview | refine | build)"); export interface DownloadOutcome { - state: "not_requested" | "not_ready" | "completed" | "failed"; - files: Array<{ path: string; status: "written" }>; + state: "not_requested" | "not_ready" | "completed" | "partial" | "failed"; + files: Array<{ key?: string; path: string; status: "written" | "failed"; bytes?: number; sha256?: string; error?: string | null }>; metadata_path: string | null; /** OBJ/MTL/texture reference report when the download contained a text OBJ. */ material_links?: MaterialLinkReport | null; @@ -293,6 +294,7 @@ function attachToProject( const stage = (opts.stage as string | undefined) ?? (typeof extra.payload?.["mode"] === "string" ? (extra.payload["mode"] as string) : descriptor.creativeLab?.stage ?? stageFromTaskType(task?.type, descriptor.id)); try { const snapshot = task && raw ? saveTaskSnapshot(projectDir, taskId, raw) : null; + const indexRoot = indexRootFor(projectDir, undefined, opened.flags.workspace); const rec = recordTask(projectDir, { taskId, stage, @@ -304,7 +306,7 @@ function attachToProject( taskJson: snapshot?.relative ?? null, operationId: extra.operationId ?? null, files: extra.files ?? [], - }); + }, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}; run \`meshy project rebuild-index\``)); if (rec.migrated_from_legacy) warnings.push(warning("metadata_migrated", "legacy metadata.json migrated to schema_version 2 (backup kept beside it)")); return { project_dir: projectDir, snapshot: snapshot?.path ?? null, stage, action: rec.action, index: rec.index }; @@ -630,16 +632,23 @@ export interface SubmitContext { extraResult?: Record; } -/** The credential identity a submission is journaled under: bound to the key digest or the OAuth subject. */ -export function credentialIdentityFor(runtime: Runtime, apiOrigin: string): string { - return credentialFingerprint({ +/** + * The credential identity a submission is journaled under: bound to the key + * digest, the OAuth subject or the OAuth login id. `verified` is false when + * none of those exists (a pre-login-id OAuth profile): such a credential may + * start operations but is refused a replay of an existing record. + */ +export function credentialIdentityFor(runtime: Runtime, apiOrigin: string): { fingerprint: string; verified: boolean } { + const parts = { source: runtime.config.credentialSource, profile: runtime.config.credentialProfile ?? null, origin: apiOrigin, kind: runtime.config.credentialKind, secret: runtime.config.credentialKind === "api_key" ? runtime.config.apiKey : null, subject: runtime.config.credentialSubject ?? null, - }); + loginId: runtime.config.credentialLoginId ?? null, + }; + return { fingerprint: credentialFingerprint(parts), verified: credentialBinding(parts).verified }; } /** @@ -660,11 +669,13 @@ export async function submitCreate( const root = operationsRoot(); const operationId = requestedOperationId ?? newOperationId(); const apiOrigin = originOf(endpoint.transportBaseUrl) ?? endpoint.transportBaseUrl; + const credential = credentialIdentityFor(runtime, apiOrigin); const identity = { resource: descriptor.id, endpoint: descriptor.legacyEndpoint, apiOrigin, - credentialFingerprint: credentialIdentityFor(runtime, apiOrigin), + credentialFingerprint: credential.fingerprint, + credentialVerified: credential.verified, payloadFingerprint: payloadFingerprint(payload), }; const label = ctx.label ?? "the create request"; @@ -807,17 +818,33 @@ async function maybeDownloadV1( if (!output) return NOT_REQUESTED(); if (task.status !== "SUCCEEDED") return { state: "not_ready", files: [], metadata_path: null }; try { - const { savedFiles, metadataPath, materialLinks } = await downloadArtifacts(task, output, descriptor.id, { root: opened.flags.workspace }); + const { files, metadataPath, materialLinks } = await downloadArtifacts(task, output, descriptor.id, { root: opened.flags.workspace, signal: abortSignal() }); if (materialLinks) warnings.push(...materialLinks.warnings); - return { state: "completed", files: savedFiles.map((p) => ({ path: p, status: "written" as const })), metadata_path: metadataPath, material_links: materialLinks }; + return { + state: "completed", + files: files.map((f) => ({ key: f.key, path: f.path, status: f.status, bytes: f.bytes, sha256: f.sha256, error: f.error })), + metadata_path: metadataPath, + material_links: materialLinks, + }; } catch (err) { + // Whatever the downloader already committed stays in the manifest, and the + // failure keeps its own class: an HTTP 503 on the second asset is a network + // failure with its status, a Ctrl-C is `interrupted` (130) — never a bare local_io. + const partial: DownloadOutcome = + err instanceof CliError && err.result && err.result["downloads"] && typeof err.result["downloads"] === "object" + ? (err.result["downloads"] as DownloadOutcome) + : { state: "failed", files: [], metadata_path: null }; + const interrupted = (err instanceof CliError && err.code === "interrupted") || wasInterrupted(); const failure = new CliError({ - code: err instanceof CliError ? err.code : "local_io", - message: `task ${task.id} is SUCCEEDED but downloading its assets failed: ${err instanceof Error ? err.message : String(err)}`, + code: interrupted ? "interrupted" : err instanceof CliError ? err.code : "local_io", + message: `task ${task.id} is SUCCEEDED but downloading its assets ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`, httpStatus: err instanceof CliError ? err.httpStatus : null, - recovery: err instanceof CliError ? err.recovery : null, - result: { downloads: { state: "failed", files: [], metadata_path: null } }, - warnings, + retryable: err instanceof CliError ? err.retryable : false, + recovery: err instanceof CliError && err.recovery ? err.recovery : { action: "download", automatic: false, command: `meshy download --resource ${descriptor.id} --task-id ${task.id} --all --output-dir ` }, + hint: err instanceof CliError ? err.hint : undefined, + details: err instanceof CliError ? err.details : undefined, + result: { downloads: partial }, + warnings: [...warnings, ...(err instanceof CliError ? err.warnings : [])], cause: err, }); throw withTaskContext(failure, { @@ -1023,9 +1050,20 @@ async function streamAndReport( }); const streamInfo = { events: outcome.events, ended: outcome.reason, elapsed_seconds: Number((outcome.elapsedMs / 1000).toFixed(2)) }; - const ctx: TaskContext = { descriptor, taskId, task: outcome.task, raw: outcome.raw, submission, includeRaw, extra: { stream: streamInfo } }; - const savedJson = opts.saveJson && outcome.raw ? saveJsonInContext(opts, opened, outcome.raw, ctx) : null; - const project = outcome.task ? attachInContext(opts, opened, descriptor, taskId, outcome.task, outcome.raw, {}, warnings, { ...ctx, savedJson }) : null; + const ctx: TaskContext = { descriptor, taskId, task: outcome.task, raw: outcome.raw, submission, includeRaw, extra: { stream: streamInfo, task_id: taskId } }; + + // Once the stream has started, every later step — saving JSON, recording the + // project, downloading — is part of the same terminal outcome: one `outcome` + // event (ndjson) or one envelope (json/pretty), never a bare error after it. + let savedJson: SavedJson | null = null; + let project: ProjectAttachment | null = null; + let bookkeepingError: CliError | null = null; + try { + savedJson = opts.saveJson && outcome.raw ? saveJsonInContext(opts, opened, outcome.raw, ctx) : null; + project = outcome.task ? attachInContext(opts, opened, descriptor, taskId, outcome.task, outcome.raw, {}, warnings, { ...ctx, savedJson }) : null; + } catch (err) { + bookkeepingError = err instanceof CliError ? err : withTaskContext(err, ctx); + } const projectExtra = project ? { project } : {}; let finalError: CliError | null = null; @@ -1060,6 +1098,14 @@ async function streamAndReport( finalError = wrapStreamError(outcome.error, result, warnings); break; } + if (bookkeepingError) { + if (finalError) { + // The stream's own failure is the outcome; the bookkeeping failure rides along as a warning. + finalError.warnings.push(warning("bookkeeping_failed", bookkeepingError.message)); + } else { + finalError = bookkeepingError; + } + } if (opened.schema !== "v1") { // Legacy has no stream shape to preserve: reuse the terminal summary path (which honours -o itself). @@ -1121,7 +1167,7 @@ async function emitLegacyOutcome( if (output) { if (succeeded) { - const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName, { root: runtime.flags.workspace }); + const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName, { root: runtime.flags.workspace, signal: abortSignal() }); const successReport: Parameters[0] = { status: "SUCCESS", taskId: task.id, diff --git a/tests/auth-headless.test.ts b/tests/auth-headless.test.ts index 151b1ff..31cfbff 100644 --- a/tests/auth-headless.test.ts +++ b/tests/auth-headless.test.ts @@ -172,10 +172,11 @@ test( // Credentials file should exist with oauth kind. assert.ok(existsSync(credFile), "credentials file should exist"); const creds = JSON.parse(readFileSync(credFile, "utf8")) as { - profiles: Record; + profiles: Record; }; assert.equal(creds.profiles["default"]?.kind, "oauth"); assert.equal(creds.profiles["default"]?.access_token, "e2e-device-access-token"); + assert.match(String(creds.profiles["default"]?.login_id), /^[0-9a-f-]{36}$/, "every OAuth login mints a login id (R2-F06)"); } finally { await stub.close(); } diff --git a/tests/codex-review-round1.test.ts b/tests/codex-review-round1.test.ts index 2e8b475..7aff4e4 100644 --- a/tests/codex-review-round1.test.ts +++ b/tests/codex-review-round1.test.ts @@ -617,17 +617,20 @@ test("R10/F10 stream -o downloads the assets in ndjson, json and pretty; a downl api.requests.length = 0; const target = join(dir, "failing.glb"); const bad = await runCli(["text-to-3d", "stream", "review-task", "--format", "ndjson", "--output-schema", "v1", "-o", target], { env: api.env(), cwd: dir }); - assert.equal(bad.code, 11, `${bad.stderr}\n${bad.stdout}`); - const lines = parseNdjson(bad.stdout) as Array<{ event: string; ok: boolean; error: { code: string } | null; result: { task_id: string; task: { status: string }; downloads: { state: string } } }>; + // The asset host's 404 keeps its own class (round 2, R2-F04): not_found / exit 5, never a bare local_io. + assert.equal(bad.code, 5, `${bad.stderr}\n${bad.stdout}`); + const lines = parseNdjson(bad.stdout) as Array<{ event: string; ok: boolean; error: { code: string; http_status: number | null } | null; result: { task_id: string; task: { status: string }; downloads: { state: string; files: Array<{ key: string; status: string }> } } }>; assert.deepEqual(lines.map((l) => l.event), ["task", "outcome"], "one outcome, no second envelope"); assert.equal(lines[1]!.ok, false); - assert.equal(lines[1]!.error!.code, "local_io"); + assert.equal(lines[1]!.error!.code, "not_found"); + assert.equal(lines[1]!.error!.http_status, 404); assert.equal(lines[1]!.result.task_id, "review-task"); assert.equal(lines[1]!.result.task.status, "SUCCEEDED"); assert.equal(lines[1]!.result.downloads.state, "failed"); + assert.deepEqual(lines[1]!.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]]); assert.ok(!existsSync(target)); const badJson = await runCli(["text-to-3d", "stream", "review-task", "--output-schema", "v1", "-o", join(dir, "failing2.glb")], { env: api.env(), cwd: dir }); - assert.equal(badJson.code, 11, badJson.stderr); + assert.equal(badJson.code, 5, badJson.stderr); assert.equal((parseSingleJson(badJson.stdout) as { result: { task_id: string } }).result.task_id, "review-task"); } finally { await api.close(); diff --git a/tests/codex-review-round2.test.ts b/tests/codex-review-round2.test.ts new file mode 100644 index 0000000..3470a8b --- /dev/null +++ b/tests/codex-review-round2.test.ts @@ -0,0 +1,615 @@ +/** + * Codex review round 2 (reviews/cli-s1-730132b, R2-F01–R2-F07) as positive + * regressions. Every scenario mirrors the reviewer's probe (N01–N08) with real + * subprocesses, a loopback API/asset host that records every request, synthetic + * credentials and isolated temp directories; each asserts the required + * behaviour: exit code, envelope shape, task_id/submission, request counts, + * bytes on disk, per-file manifests, one ndjson outcome with increasing sequence. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { credentialBinding } from "../src/internal/operation-store.js"; +import { jsonReply, parseNdjson, parseSingleJson, runCli, startMockApi, tmpDir, type MockApi } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; +const CREATE = ["text-to-3d", "create", "--mode", "preview", "--prompt", "round two", "--async", ...V1]; + +function taskBody(fields: Record = {}): Record { + return { id: "round2-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +/** Directory snapshot (names only) — used to prove nothing appeared outside a boundary. */ +function listing(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).sort() : [""]; +} + +function localEnv(): Record { + return { PATH: process.env["PATH"], HOME: process.env["HOME"], MESHY_CLI_NO_UPDATE_NOTIFIER: "1", MESHY_CONFIG_DIR: tmpDir("cfg-") }; +} + +async function pngBytes(color: string): Promise { + return sharp({ create: { width: 2, height: 2, channels: 3, background: color } }).png().toBuffer(); +} + +// --------------------------------------------------------------------------- +// R2-F01 / N01 — report-only tasks honour --workspace like every other write +// --------------------------------------------------------------------------- + +test("N01/R2-F01 analyze-printability get/wait/stream -o: outside targets are refused before any directory exists; inside works", async () => { + const report = taskBody({ type: "print-analyze", printability: { status: "healthy", issue_count: 0 } }); + const api = await startMockApi((req, res) => { + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(`event: message\ndata: ${JSON.stringify(report)}\n\n`); + return; + } + return jsonReply(res, 200, report); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + const outside = join(dir, "outside"); + mkdirSync(workspace); + mkdirSync(outside); + const env = api.env(); + const outsideBefore = listing(outside); + for (const verb of ["get", "wait", "stream"]) { + // File form. + const file = join(outside, `report-${verb}`, "report.json"); + const r = await runCli(["analyze-printability", verb, "round2-task", ...V1, "--workspace", workspace, "-o", file], { env, cwd: dir }); + assert.equal(r.code, 11, `${verb} file: ${r.stderr}\n${r.stdout}`); + const out = (verb === "stream" ? parseSingleJson(r.stdout) : parseSingleJson(r.stdout)) as { error: { code: string }; result: { task_id: string; downloads: { state: string } } }; + assert.equal(out.error.code, "local_io"); + assert.equal(out.result.task_id, "round2-task"); + assert.equal(out.result.downloads.state, "failed"); + assert.ok(!existsSync(join(outside, `report-${verb}`)), `${verb}: the parent directory was not created`); + // Directory form. + const d = await runCli(["analyze-printability", verb, "round2-task", ...V1, "--workspace", workspace, "-o", join(outside, `dir-${verb}`)], { env, cwd: dir }); + assert.equal(d.code, 11, `${verb} dir: ${d.stderr}`); + assert.ok(!existsSync(join(outside, `dir-${verb}`))); + } + assert.deepEqual(listing(outside), outsideBefore, "nothing outside the workspace changed"); + // Symlinked parent inside the workspace pointing outside. + symlinkSync(outside, join(workspace, "link")); + const viaLink = await runCli(["analyze-printability", "get", "round2-task", ...V1, "--workspace", workspace, "-o", join(workspace, "link", "report.json")], { env, cwd: dir }); + assert.equal(viaLink.code, 11, viaLink.stderr); + assert.deepEqual(listing(outside), outsideBefore); + // Legacy schema, same boundary. + const legacy = await runCli(["analyze-printability", "get", "round2-task", "--workspace", workspace, "-o", join(outside, "legacy.json")], { env, cwd: dir }); + assert.equal(legacy.code, 11, legacy.stderr); + assert.deepEqual(listing(outside), outsideBefore); + // Inside the workspace: file and directory forms both write the report. + const okFile = await runCli(["analyze-printability", "get", "round2-task", ...V1, "--workspace", workspace, "-o", join(workspace, "reports", "r.json")], { env, cwd: dir }); + assert.equal(okFile.code, 0, `${okFile.stderr}\n${okFile.stdout}`); + const saved = JSON.parse(readFileSync(join(workspace, "reports", "r.json"), "utf8")) as { task: { printability: { status: string } } }; + assert.equal(saved.task.printability.status, "healthy"); + assert.equal((parseSingleJson(okFile.stdout) as { result: { downloads: { state: string; metadata_path: string } } }).result.downloads.state, "completed"); + const okDir = await runCli(["analyze-printability", "wait", "round2-task", ...V1, "--workspace", workspace, "-o", join(workspace, "report-dir")], { env, cwd: dir }); + assert.equal(okDir.code, 0, okDir.stderr); + assert.ok(existsSync(join(workspace, "report-dir", "meta.json"))); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F01 / N02 — the implicit history root is confined too +// --------------------------------------------------------------------------- + +test("N02/R2-F01 --workspace equal to the project dir: metadata is recorded, the parent's index/lock/temp files are never touched (record, task --project, download --project)", async () => { + const api = await startMockApi((req, res) => { + if (req.path === "/asset.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const dir = tmpDir(); + const root = join(dir, "project-root"); + const env = api.env(); + const init = await runCli(["project", "init", "--root", root, "--name", "review"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const projectDir = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + unlinkSync(join(root, "history.json")); + const rootBefore = listing(root); + + const rec = await runCli(["project", "record", "--project", projectDir, "--workspace", projectDir, "--task-id", "round2-task", "--stage", "preview"], { env, cwd: dir }); + assert.equal(rec.code, 0, `${rec.stderr}\n${rec.stdout}`); + const ro = parseSingleJson(rec.stdout) as { result: { index: { updated: boolean; error: string }; task_count: number }; warnings: Array<{ code: string }> }; + assert.equal(ro.result.index.updated, false); + assert.match(ro.result.index.error, /outside --workspace/); + assert.equal(ro.result.task_count, 1, "metadata inside the workspace was recorded"); + assert.ok(ro.warnings.some((w) => w.code === "index_dirty")); + assert.deepEqual(listing(root), rootBefore, "no history.json, lock or temp file appeared in the parent"); + + const get = await runCli(["text-to-3d", "get", "round2-task", ...V1, "--project", projectDir, "--workspace", projectDir], { env, cwd: dir }); + assert.equal(get.code, 0, `${get.stderr}\n${get.stdout}`); + const go = parseSingleJson(get.stdout) as { result: { project: { index: { updated: boolean } } }; warnings: Array<{ code: string }> }; + assert.equal(go.result.project.index.updated, false); + assert.ok(go.warnings.some((w) => w.code === "index_dirty")); + assert.deepEqual(listing(root), rootBefore); + + const taskJson = join(projectDir, "t.json"); + writeFileSync(taskJson, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/asset.glb` } }))); + const dl = await runCli(["download", "--task-json", taskJson, "--asset", "model.glb", "--project", projectDir, "--workspace", projectDir], { env, cwd: dir }); + assert.equal(dl.code, 0, `${dl.stderr}\n${dl.stdout}`); + assert.ok((parseSingleJson(dl.stdout) as { warnings: Array<{ code: string }> }).warnings.some((w) => w.code === "index_dirty")); + assert.ok(existsSync(join(projectDir, "model.glb"))); + assert.deepEqual(listing(root), rootBefore); + + // An explicit --root outside the workspace stays a refusal before any write. + const outsideRoot = join(dir, "elsewhere"); + const explicit = await runCli(["project", "record", "--project", projectDir, "--workspace", projectDir, "--root", outsideRoot, "--task-id", "t2", "--stage", "preview"], { env, cwd: dir }); + assert.equal(explicit.code, 11, explicit.stderr); + assert.ok(!existsSync(outsideRoot)); + // Workspace containing both project and root: the index is written normally. + const normal = await runCli(["project", "record", "--project", projectDir, "--workspace", root, "--task-id", "t3", "--stage", "preview"], { env, cwd: dir }); + assert.equal(normal.code, 0, normal.stderr); + assert.equal((parseSingleJson(normal.stdout) as { result: { index: { updated: boolean } } }).result.index.updated, true); + assert.ok(existsSync(join(root, "history.json"))); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F01 / N03 — a refused download creates no directory +// --------------------------------------------------------------------------- + +test("N03/R2-F01 download: a target outside --workspace is refused before mkdir (directory and file forms), with zero requests", async () => { + const host = await startMockApi((_req, res) => { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + const outside = join(dir, "standalone-outside"); + const env = host.env({ MESHY_API_KEY: undefined }); + const byDir = await runCli(["download", "--url", `${host.url}/model.glb`, "--workspace", workspace, "--output-dir", join(outside, "new-subdir")], { env, cwd: dir }); + assert.equal(byDir.code, 11, byDir.stderr); + assert.equal((parseSingleJson(byDir.stdout) as { error: { code: string } }).error.code, "local_io"); + assert.ok(!existsSync(outside), "neither the target nor its parent was created"); + const byFile = await runCli(["download", "--url", `${host.url}/model.glb`, "--workspace", workspace, "--output", join(outside, "sub", "model.glb")], { env, cwd: dir }); + assert.equal(byFile.code, 11, byFile.stderr); + assert.ok(!existsSync(outside)); + const taskJson = join(dir, "t.json"); + writeFileSync(taskJson, JSON.stringify(taskBody({ model_urls: { glb: `${host.url}/model.glb` } }))); + const byTask = await runCli(["download", "--task-json", taskJson, "--all", "--workspace", workspace, "--output-dir", join(outside, "x")], { env, cwd: dir }); + assert.equal(byTask.code, 11, byTask.stderr); + assert.ok(!existsSync(outside)); + assert.equal(host.requests.length, 0, "nothing was fetched for a refused target"); + const inside = await runCli(["download", "--url", `${host.url}/model.glb`, "--workspace", workspace, "--output-dir", join(workspace, "new", "deep")], { env, cwd: dir }); + assert.equal(inside.code, 0, inside.stderr); + assert.ok(existsSync(join(workspace, "new", "deep"))); + } finally { + await host.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F02 / N04 — multi-material texture mapping +// --------------------------------------------------------------------------- + +async function materialHost(files: Record): Promise { + return startMockApi((req, res) => { + const entry = files[req.path]; + if (!entry) return jsonReply(res, 404, {}); + res.writeHead(200, { "content-type": entry.type }); + res.end(entry.body); + }); +} + +test("N04/R2-F02 two material groups keep distinct textures: source names decide, never the first candidate; case/dir/extension variants resolve", async () => { + const red = await pngBytes("#ff0000"); + const green = await pngBytes("#00ff00"); + const obj = "mtllib character.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 1\nusemtl body\nf 1 2 3\nusemtl eyes\nf 3 2 1\n"; + const mtl = "newmtl body\nmap_Kd body.png\nmap_Bump -bm 0.5 textures/Body_N.PNG\nnewmtl eyes\nmap_Kd eyes.jpeg\nmap_Bump eyes_n.png\n"; + const host = await materialHost({ + "/character.obj": { body: obj, type: "model/obj" }, + "/character.mtl": { body: mtl, type: "text/plain" }, + "/assets/body.png": { body: red, type: "image/png" }, + "/assets/body_n.png": { body: red, type: "image/png" }, + "/assets/eyes.jpg": { body: green, type: "image/png" }, + "/assets/eyes_n.png": { body: green, type: "image/png" }, + }); + try { + const dir = tmpDir(); + const fixture = join(dir, "multi.json"); + writeFileSync( + fixture, + JSON.stringify( + taskBody({ + model_urls: { obj: `${host.url}/character.obj`, mtl: `${host.url}/character.mtl` }, + texture_urls: [ + { base_color: `${host.url}/assets/body.png?sig=1`, normal: `${host.url}/assets/body_n.png?sig=2` }, + { base_color: `${host.url}/assets/eyes.jpg?sig=3`, normal: `${host.url}/assets/eyes_n.png?sig=4` }, + ], + }), + ), + ); + const out = join(dir, "materials"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const savedMtl = readFileSync(join(out, "model.mtl"), "utf8"); + const lines = savedMtl.split("\n"); + const body = lines.indexOf("newmtl body"); + const eyes = lines.indexOf("newmtl eyes"); + assert.equal(lines[body + 1], "map_Kd texture_0_base_color.png", "body base color → set 0 (source name body.png)"); + assert.equal(lines[body + 2], "map_Bump -bm 0.5 texture_0_normal.png", "body normal → set 0 (source name, directory and case ignored)"); + assert.equal(lines[eyes + 1], "map_Kd texture_1_base_color.png", "eyes base color → set 1 (source stem eyes, .jpeg vs .jpg)"); + assert.equal(lines[eyes + 2], "map_Bump texture_1_normal.png", "eyes normal → set 1"); + for (const ref of ["texture_0_base_color.png", "texture_0_normal.png", "texture_1_base_color.png", "texture_1_normal.png"]) assert.ok(existsSync(join(out, ref))); + assert.ok(readFileSync(join(out, "texture_1_base_color.png")).equals(green), "set 1 really is the second texture"); + const env = parseSingleJson(r.stdout) as { warnings: Array<{ code: string }>; result: { downloads: { material_links: { status: string; texture_maps: Array<{ material: string; reference: string; resolved_to: string; method: string }> } } } }; + assert.equal(env.result.downloads.material_links.status, "complete"); + assert.deepEqual( + env.result.downloads.material_links.texture_maps.map((l) => [l.material, l.reference, l.resolved_to, l.method]), + [ + ["body", "body.png", "texture_0_base_color.png", "source_name"], + ["body", "textures/Body_N.PNG", "texture_0_normal.png", "source_name"], + ["eyes", "eyes.jpeg", "texture_1_base_color.png", "source_stem"], + ["eyes", "eyes_n.png", "texture_1_normal.png", "source_name"], + ], + ); + assert.ok(!env.warnings.some((w) => w.code.startsWith("material_reference"))); + + // Legacy `-o` uses the same mapping. + const api = await startMockApi((req, res) => { + if (req.path.startsWith("/openapi/")) { + return jsonReply(res, 200, taskBody({ model_urls: { obj: `${host.url}/character.obj`, mtl: `${host.url}/character.mtl` }, texture_urls: [{ base_color: `${host.url}/assets/body.png` }, { base_color: `${host.url}/assets/eyes.jpg` }] })); + } + return jsonReply(res, 404, {}); + }); + try { + const legacyOut = join(dir, "legacy"); + const legacy = await runCli(["text-to-3d", "get", "round2-task", "-o", legacyOut], { env: api.env(), cwd: dir }); + assert.equal(legacy.code, 0, legacy.stderr); + const lm = readFileSync(join(legacyOut, "model.mtl"), "utf8"); + assert.match(lm, /^map_Kd texture_0_base_color\.png$/m); + assert.match(lm, /^map_Kd texture_1_base_color\.png$/m); + } finally { + await api.close(); + } + } finally { + await host.close(); + } +}); + +test("N04/R2-F02 ambiguous references stay as written and are reported: two base-color candidates, no source match → no guess", async () => { + const red = await pngBytes("#ff0000"); + const green = await pngBytes("#00ff00"); + const mtl = "newmtl body\nmap_Kd skin.png\nnewmtl eyes\nmap_Kd face.png\n"; + const host = await materialHost({ + "/c.obj": { body: "mtllib c.mtl\nv 0 0 0\n", type: "model/obj" }, + "/c.mtl": { body: mtl, type: "text/plain" }, + "/a.png": { body: red, type: "image/png" }, + "/b.png": { body: green, type: "image/png" }, + }); + try { + const dir = tmpDir(); + const fixture = join(dir, "ambiguous.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/c.obj`, mtl: `${host.url}/c.mtl` }, texture_urls: [{ base_color: `${host.url}/a.png` }, { base_color: `${host.url}/b.png` }] }))); + const out = join(dir, "amb"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const savedMtl = readFileSync(join(out, "model.mtl"), "utf8"); + assert.match(savedMtl, /^map_Kd skin\.png$/m, "kept as written"); + assert.match(savedMtl, /^map_Kd face\.png$/m, "kept as written"); + assert.ok(!savedMtl.includes("texture_0_base_color.png"), "the first candidate was not picked"); + const env = parseSingleJson(r.stdout) as { warnings: Array<{ code: string; message: string }>; result: { downloads: { state: string; material_links: { status: string; texture_maps: Array<{ reference: string; resolved_to: string | null; method: string; candidates?: string[] }> } } } }; + assert.equal(env.result.downloads.state, "completed", "every file landed"); + assert.equal(env.result.downloads.material_links.status, "incomplete"); + assert.deepEqual(env.result.downloads.material_links.texture_maps.map((l) => [l.reference, l.resolved_to, l.method, l.candidates]), [ + ["skin.png", null, "ambiguous", ["texture_0_base_color.png", "texture_1_base_color.png"]], + ["face.png", null, "ambiguous", ["texture_0_base_color.png", "texture_1_base_color.png"]], + ]); + const warn = env.warnings.find((w) => w.code === "material_reference_ambiguous"); + assert.ok(warn, "ambiguity is warned"); + assert.match(warn!.message, /skin\.png.*texture_0_base_color\.png or texture_1_base_color\.png/); + } finally { + await host.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F03 / N05 — every post-stream failure ends in one outcome event +// --------------------------------------------------------------------------- + +test("N05/R2-F03 stream: save-json conflict, project failure and download failure each end in exactly one outcome with the next sequence", async () => { + const api = await startMockApi((req, res) => { + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(`event: message\ndata: ${JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/missing.glb` } }))}\n\n`); + return; + } + return jsonReply(res, 404, { message: "gone" }); + }); + try { + const dir = tmpDir(); + const occupied = join(dir, "occupied.json"); + writeFileSync(occupied, "do not overwrite"); + const cases: Array<[string, string[], number, string]> = [ + ["save-json conflict", ["--save-json", occupied], 11, "local_io"], + ["project not initialised", ["--project", join(dir, "nope")], 11, "local_io"], + ["asset 404", ["-o", join(dir, "asset.glb")], 5, "not_found"], + ]; + for (const [label, extra, exitCode, code] of cases) { + const nd = await runCli(["text-to-3d", "stream", "round2-task", ...V1, "--format", "ndjson", ...extra], { env: api.env(), cwd: dir }); + assert.equal(nd.code, exitCode, `${label}: ${nd.stderr}\n${nd.stdout}`); + const lines = parseNdjson(nd.stdout) as Array<{ event?: string; sequence?: number; ok: boolean; error: { code: string } | null; result: { task_id: string; task: { status: string } } }>; + assert.deepEqual(lines.map((l) => l.event), ["task", "outcome"], `${label}: one task event, one outcome`); + assert.deepEqual(lines.map((l) => l.sequence), [1, 2], `${label}: sequence keeps counting`); + assert.equal(lines[1]!.ok, false); + assert.equal(lines[1]!.error!.code, code); + assert.equal(lines[1]!.result.task_id, "round2-task"); + assert.equal(lines[1]!.result.task.status, "SUCCEEDED"); + // json/pretty: exactly one final document with the same classification. + const js = await runCli(["text-to-3d", "stream", "round2-task", ...V1, ...extra], { env: api.env(), cwd: dir }); + assert.equal(js.code, exitCode, `${label} json: ${js.stderr}`); + const env = parseSingleJson(js.stdout) as { ok: boolean; error: { code: string }; result: { task_id: string } }; + assert.equal(env.ok, false); + assert.equal(env.error.code, code); + assert.equal(env.result.task_id, "round2-task"); + const pretty = await runCli(["text-to-3d", "stream", "round2-task", ...V1, "--format", "pretty", ...extra], { env: api.env(), cwd: dir }); + assert.equal(pretty.code, exitCode, `${label} pretty: ${pretty.stderr}`); + assert.equal((pretty.stdout.match(/^ok: false$/gm) ?? []).length, 1, `${label} pretty: one result`); + } + assert.equal(readFileSync(occupied, "utf8"), "do not overwrite"); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F04 / N06 — partial downloads keep their manifest and HTTP class +// --------------------------------------------------------------------------- + +test("N06/R2-F04 task -o: first asset written, second asset 503/404/403 → the manifest lists both, the HTTP class and status survive, files stay on disk", async () => { + let second: { status: number; body: unknown } = { status: 503, body: { message: "temporary asset host outage" } }; + const api = await startMockApi((req, res) => { + if (req.path === "/first.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb("first")); + } + if (req.path === "/second.png") return jsonReply(res, second.status, second.body); + if (req.method === "POST") return jsonReply(res, 200, { result: "round2-task" }); + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/first.glb` }, thumbnail_url: `${api.url}/second.png` })); + }); + try { + const dir = tmpDir(); + const expectations: Array<[number, number, string]> = [ + [503, 7, "network"], + [404, 5, "not_found"], + [403, 4, "validation"], + ]; + for (const [status, exitCode, code] of expectations) { + second = { status, body: { message: `asset ${status}` } }; + for (const verb of ["get", "wait"]) { + const out = join(dir, `${verb}-${status}`); + const r = await runCli(["text-to-3d", verb, "round2-task", ...V1, "-o", out], { env: api.env(), cwd: dir }); + assert.equal(r.code, exitCode, `${verb} ${status}: ${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as { error: { code: string; http_status: number }; result: { task_id: string; downloads: { state: string; files: Array<{ key: string; path: string; status: string; sha256: string; error: string | null }> } } }; + assert.equal(env.error.code, code); + assert.equal(env.error.http_status, status); + assert.equal(env.result.task_id, "round2-task"); + assert.equal(env.result.downloads.state, "partial"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "written"], ["thumbnail", "failed"]]); + const written = env.result.downloads.files[0]!; + assert.equal(written.path, join(out, "model.glb")); + assert.ok(existsSync(written.path), "the committed file is not rolled back"); + assert.equal(sha(written.path), written.sha256); + assert.match(env.result.downloads.files[1]!.error ?? "", new RegExp(String(status))); + assert.ok(!existsSync(join(out, "meta.json")), "no sidecar for an incomplete set"); + assert.deepEqual(readdirSync(out), ["model.glb"], "no temp files linger"); + } + } + // make: same rule on its final download. + second = { status: 503, body: { message: "outage" } }; + const makeOut = join(dir, "make-out"); + const mk = await runCli(["make", "a cactus", ...V1, "-o", makeOut], { env: api.env(), cwd: dir }); + assert.equal(mk.code, 7, `${mk.stderr}\n${mk.stdout}`); + const mo = parseSingleJson(mk.stdout) as { error: { code: string; http_status: number }; result: { task_id: string; executed: unknown[]; downloads: { state: string; files: Array<{ key: string; status: string }> } } }; + assert.equal(mo.error.code, "network"); + assert.equal(mo.error.http_status, 503); + assert.equal(mo.result.task_id, "round2-task"); + assert.equal(mo.result.executed.length, 2); + assert.equal(mo.result.downloads.state, "partial"); + assert.deepEqual(mo.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "written"], ["thumbnail", "failed"]]); + assert.ok(existsSync(join(makeOut, "model.glb"))); + // Legacy schema keeps the class in its payload too. + const legacy = await runCli(["text-to-3d", "get", "round2-task", "-o", join(dir, "legacy-out")], { env: api.env(), cwd: dir }); + assert.equal(legacy.code, 7, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as { code: string; status: number; result: { downloads: { state: string; files: unknown[] } } }; + assert.equal(lp.code, "network"); + assert.equal(lp.status, 503); + assert.equal(lp.result.downloads.state, "partial"); + assert.equal(lp.result.downloads.files.length, 2); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F05 / N08 — SIGINT cancels the asset transfer +// --------------------------------------------------------------------------- + +test("N08/R2-F05 SIGINT during the asset transfer (before headers, mid-body, on the second asset) exits 130 with the task kept, no extra requests, no leftovers", async () => { + let child: ChildProcess | null = null; + let mode: "headers" | "body" | "second" = "headers"; + const api = await startMockApi(async (req, res) => { + if (req.method === "POST" || req.method === "DELETE") return jsonReply(res, 500, { message: "unexpected" }); + if (req.path === "/fast.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb("fast")); + } + if (req.path === "/slow.glb" || req.path === "/slow.png") { + if (mode === "body") { + res.writeHead(200, { "content-type": req.path.endsWith(".png") ? "image/png" : "model/gltf-binary" }); + res.write(glb("slow").subarray(0, 8)); + } + child?.kill("SIGINT"); + await new Promise((r) => setTimeout(r, 400)); + try { + if (mode !== "body") res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(mode === "body" ? glb("slow").subarray(8) : glb("slow")); + } catch { + /* client gone */ + } + return; + } + if (mode === "second") return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/fast.glb` }, thumbnail_url: `${api.url}/slow.png` })); + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/slow.glb` } })); + }); + try { + const dir = tmpDir(); + for (const m of ["headers", "body"] as const) { + mode = m; + api.requests.length = 0; + const target = join(dir, `${m}.glb`); + const r = await runCli(["text-to-3d", "get", "round2-task", ...V1, "-o", target], { env: api.env(), cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(r.code, 130, `${m}: ${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as { error: { code: string }; result: { task_id: string; submission: { state: string }; next: { get: string }; downloads: { state: string; files: Array<{ key: string; status: string; error: string | null }> } } }; + assert.equal(env.error.code, "interrupted"); + assert.equal(env.result.task_id, "round2-task"); + assert.equal(env.result.submission.state, "accepted"); + assert.match(env.result.next.get, /get round2-task/); + assert.equal(env.result.downloads.state, "failed"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]]); + assert.ok(!existsSync(target), `${m}: no final file`); + assert.ok(!existsSync(join(dir, `${m}_meta.json`)), `${m}: no sidecar`); + assert.ok(!readdirSync(dir).some((f) => f.includes(".tmp-")), `${m}: temp file cleaned`); + assert.deepEqual(api.requests.map((q) => q.method), ["GET", "GET"], `${m}: task GET + one asset GET, nothing else`); + } + // Second asset: the first stays committed in the manifest. + mode = "second"; + api.requests.length = 0; + const out = join(dir, "second"); + const r = await runCli(["text-to-3d", "wait", "round2-task", ...V1, "-o", out], { env: api.env(), cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(r.code, 130, `${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as { error: { code: string }; result: { downloads: { state: string; files: Array<{ key: string; status: string; path: string }> } } }; + assert.equal(env.error.code, "interrupted"); + assert.equal(env.result.downloads.state, "partial"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "written"], ["thumbnail", "failed"]]); + assert.ok(existsSync(env.result.downloads.files[0]!.path)); + assert.deepEqual(readdirSync(out), ["model.glb"], "no temp file, no sidecar"); + assert.ok(!api.requests.some((q) => q.method === "DELETE" || q.method === "POST")); + // Legacy schema and make share the behaviour. + mode = "headers"; + api.requests.length = 0; + const legacy = await runCli(["text-to-3d", "get", "round2-task", "-o", join(dir, "legacy.glb")], { env: api.env(), cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(legacy.code, 130, legacy.stderr); + assert.ok(!existsSync(join(dir, "legacy.glb"))); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R2-F06 / N07 — OAuth logins without a user id +// --------------------------------------------------------------------------- + +test("N07/R2-F06 an OAuth profile without user_id or login_id cannot replay an existing operation; a login id binds the login and survives refresh", async () => { + let tokens = 0; + const api = await startMockApi((req, res) => { + if (req.path === "/openapi/v1/oauth/token") { + tokens += 1; + return jsonReply(res, 200, { access_token: `refreshed-${tokens}`, token_type: "Bearer", expires_in: 3600, refresh_token: "fixture-refresh" }); + } + if (req.method === "POST") return jsonReply(res, 200, { result: req.headers["authorization"] === "Bearer fixture-account-b-token" ? "oauth-account-b-task" : "oauth-account-a-task" }); + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + const creds = join(dir, "oauth-credentials.json"); + const profile = (token: string, extra: Record = {}, expiresAt = Date.now() + 3_600_000) => + JSON.stringify({ auth_version: 1, active_profile: "default", profiles: { default: { kind: "oauth", access_token: token, refresh_token: "fixture-refresh", expires_at: expiresAt, created_at: 1, ...extra } } }); + const env: Record = { ...api.env(), MESHY_CREDENTIALS_PATH: creds }; + delete env["MESHY_API_KEY"]; + const args = [...CREATE, "--operation-id", "missing-subject-op"]; + + // No identity at all: the first submission works, a second login under the same profile name is refused a replay. + writeFileSync(creds, profile("fixture-account-a-token")); + const a = await runCli(args, { env, cwd: dir }); + assert.equal(a.code, 0, a.stderr); + writeFileSync(creds, profile("fixture-account-b-token")); + const b = await runCli(args, { env, cwd: dir }); + assert.equal(b.code, 2, `${b.stderr}\n${b.stdout}`); + const bo = parseSingleJson(b.stdout) as { error: { code: string; message: string; recovery: { command: string } }; result: { conflict: string[]; submission: { task_id: string } } }; + assert.equal(bo.error.code, "operation_conflict"); + assert.deepEqual(bo.result.conflict, ["credential_unverified"]); + assert.match(bo.error.recovery.command, /meshy auth login/); + assert.equal(bo.result.submission.task_id, "oauth-account-a-task", "the record is shown, never re-used"); + assert.equal(api.requests.filter((q) => q.method === "POST" && !q.path.endsWith("/oauth/token")).length, 1, "account B sent nothing"); + + // A login id binds the login: rotated token → replay; a new login (new id) → conflict. + const withLogin = [...CREATE, "--operation-id", "login-bound-op"]; + writeFileSync(creds, profile("tok-1", { login_id: "login-aaaa" })); + const first = await runCli(withLogin, { env, cwd: dir }); + assert.equal(first.code, 0, first.stderr); + writeFileSync(creds, profile("tok-2", { login_id: "login-aaaa" })); + const rotated = await runCli(withLogin, { env, cwd: dir }); + assert.equal(rotated.code, 0, rotated.stderr); + assert.equal((parseSingleJson(rotated.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + writeFileSync(creds, profile("tok-3", { login_id: "login-bbbb" })); + const relogin = await runCli(withLogin, { env, cwd: dir }); + assert.equal(relogin.code, 2, relogin.stderr); + assert.deepEqual((parseSingleJson(relogin.stdout) as { result: { conflict: string[] } }).result.conflict, ["credential"]); + assert.equal(api.requests.filter((q) => q.method === "POST" && !q.path.endsWith("/oauth/token")).length, 2); + + // A silent refresh keeps the login id (and therefore the identity): expired token → refresh → replay, no new POST. + const refreshOp = [...CREATE, "--operation-id", "refresh-op"]; + writeFileSync(creds, profile("tok-old", { login_id: "login-cccc" })); + const before = await runCli(refreshOp, { env, cwd: dir }); + assert.equal(before.code, 0, before.stderr); + writeFileSync(creds, profile("tok-expired", { login_id: "login-cccc" }, Date.now() - 1000)); + const after = await runCli(refreshOp, { env, cwd: dir }); + assert.equal(after.code, 0, `${after.stderr}\n${after.stdout}`); + assert.equal((parseSingleJson(after.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); + const saved = JSON.parse(readFileSync(creds, "utf8")) as { profiles: { default: { access_token: string; login_id: string } } }; + assert.equal(saved.profiles.default.login_id, "login-cccc", "refresh preserved the login id"); + assert.match(saved.profiles.default.access_token, /^refreshed-/, "the token was rotated by the refresh"); + assert.equal(api.requests.filter((q) => q.method === "POST" && !q.path.endsWith("/oauth/token")).length, 3); + // Journals hold neither tokens nor login ids in the clear. + const ops = join(String(env["MESHY_CONFIG_DIR"]), "operations"); + for (const f of readdirSync(ops).filter((n) => n.endsWith(".json"))) { + const text = readFileSync(join(ops, f), "utf8"); + assert.ok(!/fixture-account|tok-|refreshed-|login-[abc]{4}/.test(text), `${f}: no secret or raw login id`); + } + } finally { + await api.close(); + } +}); + +test("R2-F06 credentialBinding: OAuth without subject or login id is unverified; subject or login id verifies; API keys bind to the key", () => { + assert.deepEqual(credentialBinding({ source: "file", origin: "o", kind: "oauth" }), { binding: "unverified", verified: false }); + assert.equal(credentialBinding({ source: "file", origin: "o", kind: "oauth", subject: "u1" }).verified, true); + assert.equal(credentialBinding({ source: "file", origin: "o", kind: "oauth", loginId: "l1" }).verified, true); + assert.equal(credentialBinding({ source: "file", origin: "o", kind: "oauth", loginId: "l1" }).binding, "login:l1"); + assert.equal(credentialBinding({ source: "file", origin: "o", kind: "oauth", subject: "u1", loginId: "l1" }).binding, "subject:u1", "the account subject wins over the login id"); + assert.equal(credentialBinding({ source: "env", origin: "o", kind: "api_key", secret: "msy_x" }).verified, true); + assert.ok(!credentialBinding({ source: "env", origin: "o", kind: "api_key", secret: "msy_x" }).binding.includes("msy_x")); +}); diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index fddcaeb..9587690 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -7,7 +7,7 @@ * MESHY_BASE_URL_V1/V2 when they need one. */ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -31,6 +31,8 @@ export interface RunOptions { /** Send SIGINT after this many ms. */ sigintAfterMs?: number; stdin?: string; + /** Receives the child right after spawn (e.g. to signal it from a mock handler). */ + onSpawn?: (child: ChildProcess) => void; } export function tmpDir(prefix = "meshy-cli-test-"): string { @@ -65,6 +67,7 @@ export function runCli(args: string[], opts: RunOptions = {}): Promise child.kill("SIGKILL"), opts.timeoutMs ?? 20_000); diff --git a/tests/poll.test.ts b/tests/poll.test.ts index d697063..9a8e48a 100644 --- a/tests/poll.test.ts +++ b/tests/poll.test.ts @@ -7,6 +7,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import type { TaskEndpoint } from "../src/client/endpoints/base.js"; import { pollUntilTerminal } from "../src/internal/poll.js"; +import { TransportError } from "../src/client/transport.js"; import type { Task } from "../src/client/types.js"; function fakeEndpoint(statuses: string[]): TaskEndpoint { @@ -77,23 +78,126 @@ test("pollUntilTerminal — times out when a task never terminates", async () => assert.ok(elapsed < 2000, `elapsed=${elapsed}ms should not blow past the deadline`); }); -test("pollUntilTerminal — the deadline bounds the sleep: no request is started after it passed", async () => { - let calls = 0; - const stamps: number[] = []; - const started = performance.now(); +/** Deterministic clock: `now` reads a counter, `sleep` advances it by the requested ms (optionally skewed). */ +function fakeClock(skew: (requested: number) => number = (ms) => ms) { + let t = 0; + return { + now: () => t, + sleep: async (ms: number) => { + t += skew(ms); + }, + advance: (ms: number) => { + t += ms; + }, + }; +} + +function inProgress(id: string): Task { + return { id, status: "IN_PROGRESS", type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; +} + +test("pollUntilTerminal — deterministic: the budget runs out during the sleep, no GET starts after the deadline", async () => { + const clock = fakeClock(); + const starts: number[] = []; const ep = { - async retrieveDetailed(id: string): Promise<{ task: Task; raw: unknown }> { - calls += 1; - stamps.push(performance.now() - started); - const task = { id, status: "IN_PROGRESS", type: "", progress: 0, preceding_tasks: 0, created_at: 0, started_at: 0, finished_at: 0, expires_at: 0 } as unknown as Task; + async retrieveDetailed(id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + starts.push(clock.now()); + assert.ok((extras?.timeoutMs ?? Infinity) <= 250 - clock.now() || clock.now() === 0, "each GET is capped by the remaining budget"); + const task = inProgress(id); return { task, raw: task }; }, } as unknown as TaskEndpoint; - const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.25, intervalMs: 300 }); + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.25, intervalMs: 300, now: clock.now, sleep: clock.sleep }); assert.equal(res.timedOut, true); assert.equal(res.task?.status, "IN_PROGRESS"); - assert.equal(calls, 1, `one GET, then the sleep runs out the budget: ${JSON.stringify(stamps)}`); - assert.ok(stamps.every((t) => t <= 250 + 20), `no GET after the deadline: ${JSON.stringify(stamps)}`); + assert.deepEqual(starts, [0], "one GET at t=0; the sleep consumed the whole budget (min(interval 300, remaining 250)) and no GET followed"); + assert.equal(res.polls, 1); +}); + +test("pollUntilTerminal — deterministic: a timer that wakes early may poll again, but never after the deadline", async () => { + // The sleep wakes half a millisecond early (as real timers may); the second + // GET starts inside the budget, is capped to the 1 ms left, and nothing starts + // after 250 — the next sleep carries the clock to the deadline and the loop stops. + const clock = fakeClock((ms) => ms - 0.5); + const starts: number[] = []; + const caps: Array = []; + const ep = { + async retrieveDetailed(id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + starts.push(clock.now()); + caps.push(extras?.timeoutMs); + const task = inProgress(id); + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.25, intervalMs: 300, requestTimeoutMs: 5000, now: clock.now, sleep: clock.sleep }); + assert.equal(res.timedOut, true); + assert.ok(starts.every((t) => t < 250), `every GET started before the deadline: ${JSON.stringify(starts)}`); + assert.deepEqual(starts, [0, 249.5]); + assert.deepEqual(caps, [250, 1], "the request cap is the remaining budget, rounded up to a whole millisecond"); + // The second sleep starts at 249.5 with 0.5 ms left → the loop finds the budget spent and stops. + assert.equal(res.polls, 2); +}); + +test("pollUntilTerminal — deterministic: a timer that wakes late never polls again; a deadline-bound request timeout is the timeout", async () => { + const late = fakeClock((ms) => ms + 20); + const starts: number[] = []; + const ep = { + async retrieveDetailed(id: string): Promise<{ task: Task; raw: unknown }> { + starts.push(late.now()); + const task = inProgress(id); + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.25, intervalMs: 300, now: late.now, sleep: late.sleep }); + assert.equal(res.timedOut, true); + assert.deepEqual(starts, [0]); + + // The very first GET takes longer than the whole budget: the transport + // aborts it (phase timeout) and the poll reports a timeout with no task. + const slow = fakeClock(); + const slowEp = { + async retrieveDetailed(_id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + slow.advance(extras?.timeoutMs ?? 0); + throw new TransportError({ message: "request timed out", phase: "timeout", path: "/x" }); + }, + } as unknown as TaskEndpoint; + const out = await pollUntilTerminal(slowEp, "abc", { timeoutSeconds: 0.25, intervalMs: 300, now: slow.now, sleep: slow.sleep }); + assert.equal(out.timedOut, true); + assert.equal(out.task, null); + assert.equal(out.polls, 0); + // A read-timeout-bound failure (budget still left) is a network error, not a timeout. + const readCap = fakeClock(); + const readEp = { + async retrieveDetailed(_id: string, extras?: { timeoutMs?: number }): Promise<{ task: Task; raw: unknown }> { + readCap.advance(extras?.timeoutMs ?? 0); + throw new TransportError({ message: "request timed out", phase: "timeout", path: "/x" }); + }, + } as unknown as TaskEndpoint; + await assert.rejects(pollUntilTerminal(readEp, "abc", { timeoutSeconds: 10, intervalMs: 300, requestTimeoutMs: 50, now: readCap.now, sleep: readCap.sleep }), TransportError); +}); + +test("pollUntilTerminal — real timers (smoke): no GET starts after the deadline, whatever the timer jitter", async () => { + // Real setTimeout may wake a fraction early or late; the only invariant a + // real clock can prove is that no request *starts* past the deadline. + let origin: number | null = null; + const now = () => { + const t = performance.now(); + if (origin === null) origin = t; + return t; + }; + const starts: number[] = []; + const ep = { + async retrieveDetailed(id: string): Promise<{ task: Task; raw: unknown }> { + starts.push(performance.now()); + const task = inProgress(id); + return { task, raw: task }; + }, + } as unknown as TaskEndpoint; + const res = await pollUntilTerminal(ep, "abc", { timeoutSeconds: 0.12, intervalMs: 300, now }); + assert.equal(res.timedOut, true); + assert.ok(starts.length >= 1); + const deadline = (origin ?? 0) + 120; + assert.ok(starts.every((t) => t <= deadline), `GET start times relative to the deadline: ${JSON.stringify(starts.map((t) => Number((t - deadline).toFixed(3))))}`); }); test("pollUntilTerminal — --timeout 0 is a single query that is not bounded by the (zero) budget", async () => { From 566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 18:28:43 +0800 Subject: [PATCH 13/26] docs(skill-parity): round-3 handoff after Codex review round 2 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verification.json re-bound to code HEAD cf8905d (install/typecheck/test 535 pass/build/pack sha256 3077563a…/tarball install + 29 smoke checks, git diff --check, poll.test.ts 8/8 repeats, live catalog GET) with review_rounds[1] (reviewed 730132b, changes_requested, 7 findings fixed, round2-probes.mjs re-run 0/8, round1-probes.mjs re-run 0/12); capability matrix marks 13 capabilities with round-2 fixes; REVIEW_HANDOFF.md carries the round-2 finding→fix→test→probe table, post-fix samples and the re-review prompt. Live account/OS/GUI checks stay not_run. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/REVIEW_HANDOFF.md | 700 ++++++++++++----------- docs/skill-parity/capability-matrix.json | 257 +++++++-- docs/skill-parity/verification.json | 486 ++++++++++------ 3 files changed, 902 insertions(+), 541 deletions(-) diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md index b274e18..f863eb3 100644 --- a/docs/skill-parity/REVIEW_HANDOFF.md +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -1,37 +1,34 @@ # Meshy CLI S1 Review Handoff -Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 2**(Codex review 第 1 轮修复后的复审交接);第 1 轮 review 记录在 `meshy-agent-integrations-research/reviews/cli-s1-6273d9a/`。 +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 3**(Codex review 第 2 轮修复后的复审交接)。第 1 轮 review 见 `meshy-agent-integrations-research/reviews/cli-s1-6273d9a/`,第 2 轮见 `…/reviews/cli-s1-730132b/`;两处 reviewer 证据目录均未被改动,复现脚本从副本运行。 -## 0. 第 1 轮 review 结论与本轮修复 +## 0. 第 2 轮 review 结论与本轮修复 -- 被 review 的 HEAD:`6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc`);结论 **changes_requested**:10 项 finding(4 P1、6 P2),12 个独立复现(R01–R12),G1-code not_accepted。 -- 修复 commit:`0388fe804b456a931f871d2f227e2acf22359d77`(`fix(review): address Codex review round 1 findings F01–F10`);` 25 files changed, 2266 insertions(+), 295 deletions(-)`(相对 6273d9a)。 -- 复现脚本重跑:`reproduce.mjs` 复制到临时目录后对 `0388fe8` 运行(reviewer 的 evidence 文件未被改动):**0/12 复现**,脚本 exit 1(其语义为"并非全部缺陷都复现")。逐项结果见 verification.json `review_rounds[0].probe_rerun`。 -- 正向回归:`tests/codex-review-round1.test.ts`(17 项,R01–R12 全部按 expected 断言 + 同 id 并发两进程仅 1 次 POST)、`tests/operation-store.test.ts`(媒体碰撞断言改为验证内容差异;T-046 改为 barrier 同步释放的真实并发)、`tests/poll.test.ts`(截止时间约束 sleep;`--timeout 0` 请求上限)。 -- 其它 reviewer 备注:`tests/fixtures/skill-parity/task-error.synthetic.sse` 末尾多余空行已去掉,并以 `.gitattributes`(`*.sse -whitespace`)声明 SSE 终止空行为协议内容;`git diff --check fd94490..HEAD` 现为 exit 0。 +- 被 review 的 HEAD:`730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe8`);结论 **changes_requested**:7 项 finding(1 P1、6 P2),8 个独立复现(N01–N08);reviewer 全量测试 521/522(唯一失败为真实时钟的 poll 断言);首轮 12 个场景已被 reviewer 正向核对通过。 +- 修复 commit:`cf8905dd285bf16896df053aac253dbf8c672979`(`fix(review): address Codex review round 2 findings R2-F01–R2-F07`);` 22 files changed, 1379 insertions(+), 189 deletions(-)`(相对 730132b)。 +- 复现脚本重跑(副本,`cf8905d`):`round2-probes.mjs` **0/8 复现**(exit 1 = 并非全部复现);`round1-probes.mjs` **0/12 复现**(首轮修复未回退)。逐项结果与 exit code 见 verification.json `review_rounds[1].probe_rerun`。 +- 正向回归:`tests/codex-review-round2.test.ts`(10 项,N01–N08 逐项按 acceptance 断言:退出码、envelope 形状、task_id/submission、请求次数、落盘内容与部分 manifest、ndjson 唯一 outcome 与递增 sequence、被拒目标连目录都不创建);`tests/poll.test.ts` 改为确定性时钟(9 项,连续 8 次 8/8);`tests/auth-headless.test.ts` 断言设备登录写入 `login_id`;round-1 `R10` 的期望随 HTTP 分类保留改为 not_found/exit 5。 +- 全量:`pnpm typecheck` 通过;`pnpm test` **535/535**(上轮 522 + 本轮 13);`git diff --check fd94490` exit 0。 | ID | 优先级 | 问题 | 修复 | 决策 | 回归测试 | 复现脚本重跑 | | --- | --- | --- | --- | --- | --- | --- | -| F01 | P1 | 资源命令在提交后的保存或轮询错误中丢失已知任务 ID | `withTaskContext`:受理后的每一步(save-json、轮询 5xx、下载、project 记录、SIGINT)都在任务上下文内抛错,保留原分类/退出码并附带 `result.task_id`/`submission`/`next`;`-o`/`--save-json`/`--project` 在 POST 前预检,可检测冲突 exit 11 且 0 请求 | D-032, D-034 | R05/F01、R06/F01、F01 make(codex-review-round1) | R05: exit 11, 不再复现, R06: exit 1, 不再复现 | -| F02 | P1 | make 把受理后的 journal 写入失败误报为 submission_unknown | make 改用与资源命令共享的 `submitCreate`:受理后 journal 写失败 → `local_io` exit 11,保留 task_id/next/step;单一提交状态机 | D-034 | R11/F02(文本与图片两条路径) | R11: exit 11, 不再复现 | -| F03 | P1 | project 和任务 -o 绕过用户显式指定的 workspace | `downloadArtifacts` 接受 root(=workspace),目录/每个文件/sidecar 在 mkdir 前校验;project init/record/rebuild-index、任务动词与 download 的 `--project`、`make -o` 全部受限;create/make 在 POST 前检查 `-o` | D-032 | R08/F03、R09/F03 | R08: exit 11, 不再复现, R09: exit 11, 不再复现 | -| F04 | P1 | OBJ 材质复制可通过目标父目录符号链接写出 workspace | 每个依赖复制目标按写入根(workspace,否则输出目录)做 `resolveWithinRoot`(真实路径、拒绝符号链接叶子),在 mkdir/复制前与发布前各一次;报告保留计划路径 | D-033 | R12/F04;obj-transform T-086 | R12: exit 11, 不再复现 | -| F05 | P2 | 账户指纹没有绑定实际 API Key,会跨账户重放旧任务 | 指纹加入 API Key 单向摘要(域前缀 sha256)或 OAuth 主体 `user_id`(token 轮换不改变身份);冲突消息列出差异项 `result.conflict` | D-029 | R01/F05 ×2;operation-store F05;并发同 id 两进程仅 1 次 POST | R01: exit 2, 不再复现 | -| F06 | P2 | 媒体指纹只比较 MIME 和长度,不同图片被当成同一请求 | data URI 以解码字节 sha256 参与指纹(`data:;sha256=…`),等价编码相同、等长不同内容冲突;错误的碰撞断言测试已替换 | D-030 | R02/F06;operation-store F06 | | -| F07 | P2 | wait 总截止时间没有约束在途 HTTP 请求 | 每次 GET 携带 `min(剩余预算, 读超时)`;deadline 约束的超时即 timed_out(exit 8);sleep 不越界、预算耗尽后不再发 GET;`PollResult.task` 在首个响应前超时为 null,legacy 也输出 task id + timed_out;`--timeout 0` 单次查询不受 0 预算限制 | D-031 | R03/F07 ×2(慢 header、慢 body、sleep 到期、迟到 SUCCEEDED、--timeout 0);poll.test.ts 新增 2 项 | R03: exit 8, 不再复现 | -| F08 | P2 | Creative Lab 的 --options 会整体覆盖 --data.options | `CreateSpec.nestedObjectKeys`(build: options/output)按字段合并 defaults < --data < flags,typed 优先,false/0 保留,校验看到合并后对象;其余字段仍为浅合并 | D-035 | R04/F08(三层真实 argv 检查 POST body) | | -| F09 | P2 | OBJ 与 MTL 下载重命名后没有修复相对材质引用 | 新增 `material-links.ts`:落盘后重写 OBJ `mtllib` → 实际 MTL、MTL `map_*` → 实际贴图(精确名 → 引用名中的通道词 → MTL key 通道 → 唯一贴图),未解析项原样保留并 warning;manifest 条目 `relinked`+新 sha256,`result.downloads.material_links`;legacy `-o` 同样重链接;ZIP bundle/--geometry-only 不改写 | D-036 | R07/F09、F09 unresolved(读取最终 OBJ/MTL 验证引用存在) | | -| F10 | P2 | stream 的 ndjson 模式成功退出却不执行 -o 下载 | 下载与 project 记录移到格式分支之前;ndjson 的 `outcome` 携带 downloads;下载失败只输出一条 ok:false outcome 并以其退出码退出 | D-037 | R10/F10(ndjson/json/pretty × 成功/失败) | R10: exit 0, 不再复现 | - -未改动的范围:请求路由/registry、transport 凭据边界、SSE 解析、project 锁顺序、OBJ 数值公式、slicer 规则;所有 legacy 输出形状除 §3.1(migration-notes)列出的修正外不变。 +| R2-F01 | P1 | --workspace 仍未限制所有实际写入路径(报告任务 -o、隐式索引根、拒绝前 mkdir) | `saveReportOnly` 接收 workspace 并在 mkdir 前校验文件/`meta.json`;`downloadAssets` 先校验目录与每个计划叶子再创建目录;`indexRootFor` 判定历史索引根(显式 `--root`,否则项目父目录):越界时 `project record`/任务 `--project`/`download --project` 只记录 metadata,索引以 `index.updated=false` + 原因跳过并给 `index_dirty`;显式 `--root` 越界仍在写入前拒绝 | D-038 | N01、N02、N03(codex-review-round2;报告任务 get/wait/stream 的文件/目录/符号链接父目录/工作区内正常路径;父目录清单前后一致;被拒目标目录不存在) | N01: exit 11, 不再复现 / N02: exit 0, 不再复现 / N03: exit 11, 不再复现 | +| R2-F02 | P2 | 多组材质的同通道贴图被错误地指向第一张 | 每张贴图携带服务端文件名(URL 末段);解析顺序:已保存名 → 源文件名(忽略大小写)→ 源文件 stem(忽略扩展名/目录)→ 引用名中的通道词 → MTL key 通道 → 唯一贴图;每条规则只在**恰好一个**候选时命中,多候选为 ambiguous:引用原样保留、列出 candidates、`material_links.status=incomplete`、warning `material_reference_ambiguous`;记录 `newmtl` 组;legacy `-o` 同一解析器 | D-039 | N04 ×2(双材质各自正确;大小写/目录/扩展名变体;歧义保留并报警,且断言未指向第一张) | N04: exit 0, 不再复现 | +| R2-F03 | P2 | SSE 保存或项目记账失败时没有终结 outcome 事件 | stream 结束后的 save-json/project 记录在同一终结处理内执行:失败成为唯一的 `outcome`(ndjson,序号递增)或唯一 envelope(json/pretty),保留任务上下文;若流本身已失败,记账错误作为 `bookkeeping_failed` warning 附带 | D-040 | N05(save-json 冲突、project 未初始化、资产 404 × ndjson/json/pretty) | N05: exit 11, 不再复现 | +| R2-F04 | P2 | 任务 -o 下载失败仍丢失已落盘文件和 HTTP 分类 | `downloadArtifacts` 返回逐文件 manifest(key/path/bytes/sha256/status),失败抛出保留原 code/http_status/recovery 的 CliError 并携带 `result.downloads={state: partial|failed, files}`;`maybeDownloadV1`/make 合并而非覆盖;legacy 错误 payload 现含 `code`/`status`/`result.downloads` | D-041 | N06(503/404/403 × get/wait;make;legacy);round-1 R10 期望改为 not_found/5 | N06: exit 7, 不再复现 | +| R2-F05 | P2 | 任务 -o 下载没有接入 SIGINT,取消后仍返回成功 | abort signal 从所有任务动词、make、legacy 报告器一路传入 `fetchToTemp`;中断即停止传输、删除临时文件、跳过重链接/sidecar,以 `interrupted`/130 返回 task_id/submission/next 与已提交文件;`index.ts` 顶层重包装保留 result/recovery | D-042 | N08(响应头前、body 中途、第二个资产;legacy;无 DELETE/POST;无临时文件) | N08: exit 130, 不再复现 | +| R2-F06 | P2 | OAuth 缺少 user_id 时仍会把不同登录视为同一账户 | `meshy auth login` 为每个 OAuth profile 生成 `login_id`(refresh 保留、重新登录更换);journal 身份 = `subject:`,否则 `login:`;两者皆无的存量 profile 为 unverified:可发起新操作,但对已有记录的重放被拒绝(`operation_conflict`,`result.conflict=["credential_unverified"]`,recovery `meshy auth login`);journal 不含 token 或 login_id 明文 | D-043(取代 D-029 的 profile 名限制) | N07(无 subject 换号被拒;login_id 轮换 token 重放;新 login_id 冲突;过期 token 静默 refresh 保留 login_id);credentialBinding 单测;auth-headless 设备登录写入 login_id | N07: exit 2, 不再复现 | +| R2-F07 | P2 | 新增 deadline 测试依赖真实毫秒时序,导致测试套件间歇失败 | poll 测试改用 `pollUntilTerminal` 的 `now`/`sleep` 注入:精确到期、提前 0.5 ms 唤醒、迟到唤醒、deadline 约束的请求超时、读超时约束的网络错误均为确定性;唯一真实定时器 smoke 只断言“截止时间后不启动 GET”;慢 header/body 的子进程测试保留 | D-044 | tests/poll.test.ts 9 项;连续 8 次运行 8/8 通过;全量 535/535 | 全量 `pnpm test` 535/535;poll 重复 8/8 | + +第 1 轮(F01–F10)修复保持不变,仅 R10 的断言随 R2-F04 的分类保留而更新(404 → `not_found`/exit 5,仍是一条 outcome)。 ## 1. 代码定位 - 仓库路径 / remote:`/Users/ark/Dev/meshy-cli`(fresh clone) / `https://github.com/meshy-dev/meshy-cli.git` - 分支:`feat/skill-parity-s1`(本地分支,未推送) - base SHA:`fd94490916376e691efcea51324ac4326b459e1f`(0.2.0,= 计划基线 = 开工时的 remote main) -- head SHA(代码):`0388fe804b456a931f871d2f227e2acf22359d77`;本文件与 verification.json/capability-matrix.json 在其后的 docs-only commit 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`) -- 上一轮被 review 的 HEAD:`6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc053cea4e1bf7dee08953e25a3ec858137`) +- head SHA(代码):`cf8905dd285bf16896df053aac253dbf8c672979`;本文件与 verification.json/capability-matrix.json 在其后的 docs-only commit 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`) +- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc053cea4e1bf7dee08953e25a3ec858137`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe804b456a931f871d2f227e2acf22359d77`) - 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) - 实施包版本:2026-09-07 / v1 - 实际源码与计划基线的差异:无(remote main 与 Skills HEAD 均等于计划 SHA,见 `docs/skill-parity/baseline-delta.md`) @@ -40,18 +37,21 @@ Filled from the 2026-09-07 / v1 implementation package. `not_run` means not veri ## 2. 完成状态 -- G1-code / review-ready:**第 1 轮 10 项 finding 已全部修复并有正向回归;自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) +- G1-code / review-ready:**第 2 轮 7 项 finding 已全部修复并有正向回归,首轮 10 项在新 HEAD 复核未回退;自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) - G1-release / ready-for-S2:**not_run**(无复审结论、无真实账号/多 OS/切片器验证、未发布) -- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`,全部 `implementation_status: implemented`;21 项带 `review_round_1: fixed`,`review_status` = "round 1 findings fixed; re-review pending",其余 `not_run`) -- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **522 通过 / 0 失败 / 0 跳过**(522 项 = 上轮 502 + 本轮 20;含基线原有 363 项;覆盖 73 个 T-id 的离线部分,见 verification.json `behavior_tests`) -- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,本轮在 0388fe8 重跑)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open -- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(meshyd 仅只读核对 6 个文件,见 baseline.json;reviewer 目录未被改动) +- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;13 项带 `review_round_2: fixed`,21 项带 `review_round_1: fixed`,`review_status` 均为 "re-review pending") +- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **535 通过 / 0 失败 / 0 跳过**(535 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) +- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 cf8905d 重跑)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id、真实重新登录)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open +- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(meshyd 仅只读核对 6 个文件,见 baseline.json;两个 reviewer 目录未被改动) ## 3. 本次具体改动 `git log --oneline fd94490..HEAD`(最早在下): ``` +1d97ada docs(skill-parity): round-3 handoff after Codex review round 2 fixes +cf8905d fix(review): address Codex review round 2 findings R2-F01–R2-F07 +730132b docs(skill-parity): round-2 handoff after Codex review round 1 fixes 0388fe8 fix(review): address Codex review round 1 findings F01–F10 6273d9a docs(skill-parity): verification record and review handoff for the 0.3.0 candidate e7c26fc chore(release): 0.3.0 candidate — README, bundled skill, env example, origin-policy test @@ -64,7 +64,7 @@ e7ea577 feat(cli): B01 v1 envelope, exit codes, local runtime, --api-key-file an c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixtures ``` -` 113 files changed, 18587 insertions(+), 905 deletions(-)` +` 116 files changed, 20618 insertions(+), 930 deletions(-)` | 批次 | commit | 改动与用户行为 | 关键验证 | | --- | --- | --- | --- | @@ -76,23 +76,19 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt | B05 | `feat(project): B05` | metadata.json v2 + legacy 迁移备份、history 索引、(task_id,stage) 合并、锁顺序、index_dirty;`project init/record/show/list/rebuild-index`;任务动词与 download 的 `--project/--stage` | tests/project-store(含多进程) | | B06–B08 | `feat(local): B06-B08` | `inspect faces`(unknown≠0,exit 12/13,仅描述 remesh);`mesh prepare-print`(两遍流式、oracle 校验、材质依赖、`--in-place`);`slicer detect/open`(七注册、三 OS 规则、无默认应用 fallback);`doctor`(默认本地,`--check-api` 单次 balance) | tests/inspect, obj-transform, slicers, doctor | | B09 | `chore(release)` + docs commit | 0.3.0 候选版本、README、随包 SKILL.md、.env.example、T-105;verification.json、REVIEW_HANDOFF | 本节 5、7 | -| Review R1 | `fix(review)` `0388fe8` | §0 表中的 F01–F10;新增 `src/internal/material-links.ts`;`task-command.ts` 任务上下文/预检/共享 `submitCreate`;`poll.ts` 截止时间语义;`operation-store.ts` 指纹;`payload.ts` 嵌套合并;`obj-transform.ts`/`download.ts`/`project.ts`/`mesh.ts`/`make.ts` 写入根;README/SKILL/decisions D-029..D-037/migration-notes §3.1 | tests/codex-review-round1, operation-store, poll + 复现脚本重跑 | +| Review R1 | `fix(review)` `0388fe8` | F01–F10(任务上下文/预检/共享 `submitCreate`、workspace 写入根、依赖复制根检查、凭据/媒体指纹、截止时间、嵌套合并、材质重链接、stream -o) | tests/codex-review-round1, operation-store, poll | +| Review R2 | `fix(review)` `cf8905d` | §0 表中的 R2-F01–R2-F07:`download.ts` legacy 下载器逐文件 manifest/信号/报告分支 root/mkdir 顺序;`material-links.ts` 源文件名解析与歧义报告;`task-command.ts` stream 统一 outcome、部分 manifest、索引根、OAuth 身份校验;`project-store.ts` `indexRootFor`/`skipIndex`、快照相对路径 realpath 帧;`operation-store.ts` `credentialBinding`/unverified 拒绝;`credentials.ts`/`config.ts`/`auth.ts`/`runtime.ts` `login_id`;`index.ts` 中断重包装保留上下文;`make.ts`/`project.ts`/`cmd/download.ts` 相应接线;decisions D-038..D-044、migration-notes §3.2、README、SKILL.md | tests/codex-review-round2, poll(确定性), auth-headless + 两个复现脚本重跑 | ## 4. 能力与接口证据 -- 更新后的 capability-matrix 路径:`docs/skill-parity/capability-matrix.json`(`review_rounds`、各能力 `review_round_1`) +- 更新后的 capability-matrix 路径:`docs/skill-parity/capability-matrix.json`(`review_rounds`、各能力 `review_round_1`/`review_round_2`) - endpoint-contracts 与模型/媒体参数映射:`docs/skill-parity/endpoint-contracts.json`(与 `src/client/resource-registry.ts` 由 `tests/resource-registry.test.ts` 逐字段对账;本轮未改) -- v1 JSON schema / 错误码定义:`src/internal/result.ts`(六固定键)、`src/internal/errors.ts`(`CliErrorCode` → 退出码表);`README.md` "Stable machine output"。本轮新增字段(均为附加):`result.downloads.material_links`、manifest 条目 `relinked`、`result.conflict`(operation_conflict)、失败结果统一携带 `task_id`/`next` -- legacy compatibility / migration notes:`docs/skill-parity/migration-notes.md`(§3 兼容表 + §3.1 本轮修正:create/make 对已存在 `-o`/`--save-json` 的拒绝提前到 POST 前并改为 exit 11;受理后失败保留 task 上下文;wait 迟到响应为 exit 8;`--workspace` 全面生效;OBJ 引用重写;stream ndjson `-o` 下载) -- intentional differences:`docs/skill-parity/migration-notes.md` §2(2.1–2.12),关键项: - - `--api-key-file` 而非契约中的 `--env-file`:Node 22/24 会在整个 argv 预扫描 `--env-file` 并自行加载整个文件(含 NODE_OPTIONS)、缺失时 exit 9(决策 D-025) - - `showcase_type` 服务端枚举为 `animate`,文档写 `animated`;CLI 两者都接受,发送 `animate` 并给 warning(D-007) - - 公共任务 DTO 不含 `face_count` → API 来源的 `inspect faces` 通常为 unknown/exit 13,绝不当 0(D-009) - - `rigging list` 启用;lamp prototype 已废弃 `text` 输入在提交前拒绝 - - 本轮新增的设计选择:下载后**重写**引用而非保留服务端文件名(D-036);OAuth 无 `user_id` 的 profile 只能绑定 profile 名(D-029 记录为限制) +- v1 JSON schema / 错误码定义:`src/internal/result.ts`(六固定键)、`src/internal/errors.ts`(`CliErrorCode` → 退出码表);`README.md` "Stable machine output"。本轮新增/变化字段(均为附加或收窄):任务 `-o` 的 `result.downloads.files[]` 现为逐文件 manifest(`key/path/bytes/sha256/status/error`),`downloads.state` 新增 `partial`;`material_links.status`(complete|incomplete)、`texture_maps[].material`/`candidates`、method 新值 `source_name`/`source_stem`/`ambiguous`;`result.conflict` 新值 `credential_unverified`;stream 的记账失败进入 `outcome`(流本身失败时附 `bookkeeping_failed` warning,D-040);凭据文件 OAuth profile 新字段 `login_id` +- legacy compatibility / migration notes:`docs/skill-parity/migration-notes.md`(§3 兼容表、§3.1 首轮修正、§3.2 本轮修正:legacy `-o` 错误 payload 现含 `code`/`status`/`result.downloads`,其余 legacy 输出与文件布局不变) +- intentional differences:`docs/skill-parity/migration-notes.md` §2(2.1–2.12),关键项:`--api-key-file`(D-025)、`showcase_type=animate`(D-007)、公共 DTO 无 `face_count`(D-009)、`rigging list` 启用、lamp `text` 拒绝;本轮设计选择:材质重链接**拒绝在多候选间猜测**(D-039);无账户身份的 OAuth 存量 profile **拒绝重放而非视为同一账户**(D-043,迁移路径为重新登录) - 限权 API / 未支持的 OS / deferred 新功能:UV/Creative Lab/showcases 真实调用 not_run;Windows/Linux 仅 fixture 覆盖;新 Creative Lab 产品记录为 deferred(矩阵 DF-001) -### 脱敏实际 CLI 输出(tarball 安装的 `0388fe8`,loopback mock;``/`` 为替换后的路径) +### 脱敏实际 CLI 输出(tarball 安装的 `cf8905d`,loopback mock;``/`` 为替换后的路径) `text-to-3d create --mode preview --prompt "a cactus" --async --output-schema v1`(exit=0): @@ -105,7 +101,7 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "task": null, "submission": { "state": "accepted", - "operation_id": "2e6f3144-d714-4e27-bc1e-c99ca1c971bc", + "operation_id": "86ff89fe-8775-4654-a8f0-8ee924a23615", "task_id": "fixture-task-a", "request_id": null }, @@ -308,7 +304,7 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "result": { "submission": { "state": "unknown", - "operation_id": "74ddf9fc-7ab6-4327-acca-9f65a71b3f4f", + "operation_id": "f62a0362-51ce-45b4-a68d-f686a51062cf", "task_id": null }, "task": null, @@ -326,14 +322,14 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "recovery": { "action": "reconcile", "automatic": false, - "command": "meshy text-to-3d list --output-schema v1 # then match operation 74ddf9fc-7ab6-4327-acca-9f65a71b3f4f by time/prompt before creating again" + "command": "meshy text-to-3d list --output-schema v1 # then match operation f62a0362-51ce-45b4-a68d-f686a51062cf by time/prompt before creating again" } }, "warnings": [] } ``` -`download --resource rigging --task-id fixture-rig-1 --asset result.basic_animations.walking_glb_url --output walking.glb`(exit=0;manifest 新增 `relinked`/`material_links`): +`download --resource rigging --task-id fixture-rig-1 --asset result.basic_animations.walking_glb_url --output walking.glb`(exit=0): ```json { @@ -384,167 +380,21 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt } ``` -### 第 1 轮 finding 修复后的实际输出(复现脚本重跑,`0388fe8`,脱敏) +### 第 2 轮 finding 修复后的实际输出(`round2-probes.mjs` 副本重跑,`cf8905d`,脱敏) -R01 · 换 API Key 复用同一 `--operation-id`(exit 2,0 次新 POST): +N01 · `analyze-printability get --workspace W -o OUTSIDE/report.json`(exit 11,`outside_file_created=false`): ```json { "schema_version": "meshy.cli/v1", - "command": "text-to-3d.create", - "ok": false, - "result": { - "submission": { - "state": "accepted", - "operation_id": "same-credential-op", - "task_id": "account-a-task" - }, - "conflict": [ - "credential" - ] - }, - "error": { - "code": "operation_conflict", - "message": "operation same-credential-op already exists for a different request (credential differ); nothing was submitted — use a new --operation-id for a new request", - "http_status": null, - "retryable": false, - "recovery": null - }, - "warnings": [] -} -``` - -R03 · `wait --timeout 0.05`,GET 400 ms 后才返回 SUCCEEDED(exit 8,task 为 null,保留 task_id/next): - -```json -{ - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.wait", - "ok": false, - "result": { - "task": null, - "submission": { - "state": "accepted", - "operation_id": null - }, - "downloads": { - "state": "not_requested", - "files": [], - "metadata_path": null - }, - "saved_json": null, - "task_id": "review-task", - "wait": { - "timed_out": true, - "elapsed_seconds": 0.05, - "polls": 0 - }, - "next": { - "get": "meshy text-to-3d get review-task --output-schema v1", - "wait": "meshy text-to-3d wait review-task --output-schema v1", - "stream": "meshy text-to-3d stream review-task --format ndjson --output-schema v1" - } - }, - "error": { - "code": "timed_out", - "message": "task review-task did not answer within 0.05s (no status was received in time); the server keeps running it", - "http_status": null, - "retryable": false, - "recovery": { - "action": "wait", - "automatic": false, - "command": "meshy text-to-3d wait review-task --output-schema v1" - } - }, - "warnings": [] -} -``` - -R05 · `--save-json` 指向已存在文件(exit 11,**0 次 POST**): - -```json -{ - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.create", - "ok": false, - "result": null, - "error": { - "code": "local_io", - "message": "--save-json target occupied.json already exists; choose another path (nothing was submitted)", - "http_status": null, - "retryable": false, - "recovery": { - "action": "choose_path", - "automatic": false - } - }, - "warnings": [] -} -``` - -R06 · 同步 create,POST 受理后 GET 503(exit 1,保留 task_id/submission/next): - -```json -{ - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.create", - "ok": false, - "result": { - "task": null, - "submission": { - "state": "accepted", - "operation_id": "", - "task_id": "accepted-before-get-error", - "request_id": null - }, - "downloads": { - "state": "not_requested", - "files": [], - "metadata_path": null - }, - "saved_json": null, - "task_id": "accepted-before-get-error", - "next": { - "get": "meshy text-to-3d get accepted-before-get-error --output-schema v1", - "wait": "meshy text-to-3d wait accepted-before-get-error --output-schema v1", - "stream": "meshy text-to-3d stream accepted-before-get-error --format ndjson --output-schema v1" - }, - "wait": { - "timed_out": false, - "elapsed_seconds": 0, - "polls": 0 - } - }, - "error": { - "code": "server", - "message": "meshy api 503 on /text-to-3d/accepted-before-get-error: synthetic temporary outage", - "http_status": 503, - "retryable": false, - "recovery": null, - "details": { - "path": "/text-to-3d/accepted-before-get-error", - "body": { - "message": "synthetic temporary outage" - } - } - }, - "warnings": [] -} -``` - -R09 · `get --workspace W -o OUTSIDE/model.glb`(exit 11,无资产请求、无文件): - -```json -{ - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.get", + "command": "analyze-printability.get", "ok": false, "result": { "task": { - "task_id": "review-task", - "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", + "task_id": "round2-task", + "resource": "analyze-printability", + "endpoint": "/openapi/v1/print/analyze", + "type": "analyze-printability", "name": null, "status": "SUCCEEDED", "progress": null, @@ -555,16 +405,17 @@ R09 · `get --workspace W -o OUTSIDE/model.glb`(exit 11,无资产请求、 "expires_at": null, "face_count": null, "consumed_credits": null, - "model_urls": { - "glb": "http://127.0.0.1:/asset.glb" - }, + "model_urls": {}, "image_urls": [], "texture_urls": [], "thumbnail_url": null, "thumbnail_urls": null, "alpha_thumbnail_url": null, "result": null, - "printability": null, + "printability": { + "score": 0.9, + "issues": [] + }, "task_error": null }, "submission": { @@ -577,25 +428,79 @@ R09 · `get --workspace W -o OUTSIDE/model.glb`(exit 11,无资产请求、 "metadata_path": null }, "saved_json": null, - "task_id": "review-task", + "task_id": "round2-task", "next": { - "get": "meshy text-to-3d get review-task --output-schema v1", - "wait": "meshy text-to-3d wait review-task --output-schema v1", - "stream": "meshy text-to-3d stream review-task --format ndjson --output-schema v1" + "get": "meshy analyze-printability get round2-task --output-schema v1", + "wait": "meshy analyze-printability wait round2-task --output-schema v1", + "stream": "meshy analyze-printability stream round2-task --format ndjson --output-schema v1" } }, "error": { "code": "local_io", - "message": "task review-task is SUCCEEDED but downloading its assets failed: output directory /outside resolves outside the authorised root /private/workspace", + "message": "task round2-task is SUCCEEDED but downloading its assets failed: report path /outside-report/report.json resolves outside the authorised root /private/workspace", "http_status": null, "retryable": false, - "recovery": null + "recovery": { + "action": "download", + "automatic": false, + "command": "meshy download --resource analyze-printability --task-id round2-task --all --output-dir " + } }, "warnings": [] } ``` -R10 · `stream --format ndjson -o stream.glb`(exit 0;outcome 行携带 downloads.completed): +N02 · `project record --project P --workspace P`(无 `--root`;exit 0,metadata 记录、`index.updated=false`、父目录无 history.json): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "project.record", + "ok": true, + "result": { + "project_dir": "/private/project-root/20260907_182132_review_48aa", + "action": "added", + "entry": { + "task_id": "round2-task", + "task_type": null, + "resource": null, + "endpoint": null, + "stage": "preview", + "parent_task_id": null, + "status": null, + "files": [], + "task_json": null, + "operation_id": null, + "created_at": "2026-09-07T10:21:32.365Z", + "updated_at": "2026-09-07T10:21:32.365Z" + }, + "task_count": 1, + "index": { + "updated": false, + "error": "history root /private/project-root resolves outside --workspace /project-root/20260907_182132_review_48aa; metadata.json was recorded but history.json was not touched (history root /private/project-root resolves outside the authorised root /private/project-root/20260907_182132_review_48aa) — run `meshy project rebuild-index --root /private/project-root` from a workspace that contains it" + }, + "migrated_from_legacy": false + }, + "error": null, + "warnings": [ + { + "code": "index_dirty", + "message": "metadata.json committed but history.json was not updated: history root /private/project-root resolves outside --workspace /project-root/20260907_182132_review_48aa; metadata.json was recorded but history.json was not touched (history root /private/project-root resolves outside the authorised root /private/project-root/20260907_182132_review_48aa) — run `meshy project rebuild-index --root /private/project-root` from a workspace that contains it; run `meshy project rebuild-index`" + } + ] +} +``` + +N04 · 双材质 OBJ 下载后的 `model.mtl`(两组各自指向自己的贴图): + +``` +newmtl body +map_Kd texture_0_base_color.png +newmtl eyes +map_Kd texture_1_base_color.png +``` + +N05 · `stream --format ndjson --save-json <已存在文件>`(exit 11;事件序列 task → outcome): ```json [ @@ -605,7 +510,7 @@ R10 · `stream --format ndjson -o stream.glb`(exit 0;outcome 行携带 downl "ok": true, "result": { "task": { - "task_id": "review-task", + "task_id": "round2-task", "resource": "text-to-3d", "endpoint": "/openapi/v2/text-to-3d", "type": "text-to-3d-preview", @@ -619,9 +524,7 @@ R10 · `stream --format ndjson -o stream.glb`(exit 0;outcome 行携带 downl "expires_at": null, "face_count": null, "consumed_credits": null, - "model_urls": { - "glb": "http://127.0.0.1:/asset.glb" - }, + "model_urls": {}, "image_urls": [], "texture_urls": [], "thumbnail_url": null, @@ -650,10 +553,10 @@ R10 · `stream --format ndjson -o stream.glb`(exit 0;outcome 行携带 downl { "schema_version": "meshy.cli/v1", "command": "text-to-3d.stream", - "ok": true, + "ok": false, "result": { "task": { - "task_id": "review-task", + "task_id": "round2-task", "resource": "text-to-3d", "endpoint": "/openapi/v2/text-to-3d", "type": "text-to-3d-preview", @@ -667,9 +570,7 @@ R10 · `stream --format ndjson -o stream.glb`(exit 0;outcome 行携带 downl "expires_at": null, "face_count": null, "consumed_credits": null, - "model_urls": { - "glb": "http://127.0.0.1:/asset.glb" - }, + "model_urls": {}, "image_urls": [], "texture_urls": [], "thumbnail_url": null, @@ -684,30 +585,33 @@ R10 · `stream --format ndjson -o stream.glb`(exit 0;outcome 行携带 downl "operation_id": null }, "downloads": { - "state": "completed", - "files": [ - { - "path": "/workspace/stream.glb", - "status": "written" - } - ], - "metadata_path": "/workspace/stream_meta.json", - "material_links": null + "state": "not_requested", + "files": [], + "metadata_path": null }, "saved_json": null, + "task_id": "round2-task", + "next": { + "get": "meshy text-to-3d get round2-task --output-schema v1", + "wait": "meshy text-to-3d wait round2-task --output-schema v1", + "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" + }, "stream": { "events": 1, "ended": "terminal", "elapsed_seconds": 0.01 - }, - "task_id": "review-task", - "next": { - "get": "meshy text-to-3d get review-task --output-schema v1", - "wait": "meshy text-to-3d wait review-task --output-schema v1", - "stream": "meshy text-to-3d stream review-task --format ndjson --output-schema v1" } }, - "error": null, + "error": { + "code": "local_io", + "message": "refusing to overwrite existing file: /private/workspace/occupied.json (pass --overwrite, or choose another path)", + "http_status": null, + "retryable": false, + "recovery": { + "action": "choose_path", + "automatic": false + } + }, "warnings": [], "event": "outcome", "sequence": 2 @@ -715,56 +619,202 @@ R10 · `stream --format ndjson -o stream.glb`(exit 0;outcome 行携带 downl ] ``` -R11 · `make --async`,受理后 journal 记录消失(exit 11,submission accepted,task_id 保留): +N06 · `get -o dir`,第一个资产成功、第二个 503(exit 7,`downloads.state=partial`,model.glb 在磁盘): ```json { "schema_version": "meshy.cli/v1", - "command": "make", + "command": "text-to-3d.get", "ok": false, "result": { + "task": { + "task_id": "round2-task", + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", + "name": null, + "status": "SUCCEEDED", + "progress": null, + "preceding_tasks": null, + "created_at": null, + "started_at": null, + "finished_at": null, + "expires_at": null, + "face_count": null, + "consumed_credits": null, + "model_urls": { + "glb": "http://127.0.0.1:/first.glb" + }, + "image_urls": [], + "texture_urls": [], + "thumbnail_url": "http://127.0.0.1:/second.png", + "thumbnail_urls": null, + "alpha_thumbnail_url": null, + "result": null, + "printability": null, + "task_error": null + }, "submission": { "state": "accepted", - "operation_id": "", - "task_id": "make-known-accepted-id", - "request_id": null + "operation_id": null }, - "task": null, - "task_id": "make-known-accepted-id", + "downloads": { + "state": "partial", + "files": [ + { + "key": "model_glb", + "path": "/workspace/partial/model.glb", + "bytes": 49, + "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", + "content_type": "model/gltf-binary", + "status": "written", + "error": null, + "relinked": false + }, + { + "key": "thumbnail", + "path": "/workspace/partial/thumbnail.png", + "bytes": 0, + "sha256": "", + "content_type": null, + "status": "failed", + "error": "download failed for http://127.0.0.1:/second.png (HTTP 503 Service Unavailable)", + "relinked": false + } + ], + "metadata_path": null + }, + "saved_json": null, + "task_id": "round2-task", "next": { - "get": "meshy text-to-3d get make-known-accepted-id --output-schema v1", - "wait": "meshy text-to-3d wait make-known-accepted-id --output-schema v1", - "stream": "meshy text-to-3d stream make-known-accepted-id --format ndjson --output-schema v1" + "get": "meshy text-to-3d get round2-task --output-schema v1", + "wait": "meshy text-to-3d wait round2-task --output-schema v1", + "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" + } + }, + "error": { + "code": "network", + "message": "task round2-task is SUCCEEDED but downloading its assets failed: download failed for thumbnail: download failed for http://127.0.0.1:/second.png (HTTP 503 Service Unavailable)", + "http_status": 503, + "retryable": false, + "recovery": { + "action": "download", + "automatic": false, + "command": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " + }, + "details": { + "expired_or_denied": false + } + }, + "warnings": [] +} +``` + +N07 · 无 user_id 的同名 OAuth profile 换 token 复用 `--operation-id`(exit 2,`credential_unverified`): + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "text-to-3d.create", + "ok": false, + "result": { + "submission": { + "state": "accepted", + "operation_id": "missing-subject-op", + "task_id": "oauth-account-a-task" }, - "step": 1, - "route": "text", - "executed": [] + "conflict": [ + "credential_unverified" + ] }, "error": { - "code": "local_io", - "message": "task make-known-accepted-id was created but the operation journal could not be updated: operation record 3a428870-a191-4923-8dbf-ed8806e63eee disappeared before it could be updated", + "code": "operation_conflict", + "message": "operation missing-subject-op already exists but the current OAuth login has no account identity (profile without user_id or login_id), so it cannot be confirmed as the same account; nothing was submitted — run `meshy auth login` to bind this login, or use a new --operation-id", "http_status": null, "retryable": false, - "recovery": null + "recovery": { + "action": "login", + "automatic": false, + "command": "meshy auth login" + } }, "warnings": [] } ``` -R12 · `mesh prepare-print`,目标 `materials/` 为指向 workspace 外的符号链接(exit 11,外部目录为空): +N08 · 资产传输中收到 SIGINT(exit 130,`file_created=false`): ```json { "schema_version": "meshy.cli/v1", - "command": "mesh.prepare-print", + "command": "text-to-3d.get", "ok": false, - "result": null, + "result": { + "task": { + "task_id": "round2-task", + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", + "name": null, + "status": "SUCCEEDED", + "progress": null, + "preceding_tasks": null, + "created_at": null, + "started_at": null, + "finished_at": null, + "expires_at": null, + "face_count": null, + "consumed_credits": null, + "model_urls": { + "glb": "http://127.0.0.1:/slow.glb" + }, + "image_urls": [], + "texture_urls": [], + "thumbnail_url": null, + "thumbnail_urls": null, + "alpha_thumbnail_url": null, + "result": null, + "printability": null, + "task_error": null + }, + "submission": { + "state": "accepted", + "operation_id": null + }, + "downloads": { + "state": "failed", + "files": [ + { + "key": "model_glb", + "path": "/workspace/interrupted.glb", + "bytes": 0, + "sha256": "", + "content_type": null, + "status": "failed", + "error": "download of http://127.0.0.1:/slow.glb interrupted", + "relinked": false + } + ], + "metadata_path": null + }, + "saved_json": null, + "task_id": "round2-task", + "next": { + "get": "meshy text-to-3d get round2-task --output-schema v1", + "wait": "meshy text-to-3d wait round2-task --output-schema v1", + "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" + } + }, "error": { - "code": "local_io", - "message": "material dependency target for 'materials/a.mtl' /private/workspace/target/materials/a.mtl resolves outside the authorised root /private/workspace", + "code": "interrupted", + "message": "task round2-task is SUCCEEDED but downloading its assets was interrupted: download failed for model_glb: download of http://127.0.0.1:/slow.glb interrupted", "http_status": null, "retryable": false, - "recovery": null + "recovery": { + "action": "download", + "automatic": false, + "command": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " + } }, "warnings": [] } @@ -772,118 +822,120 @@ R12 · `mesh prepare-print`,目标 `materials/` 为指向 workspace 外的符 ## 5. 实际验证记录 -全部绑定代码 HEAD `0388fe804b456a931f871d2f227e2acf22359d77`,环境 macOS 26.6 arm64 / Node v24.20.0 / pnpm 11.24.0;日志见 `docs/skill-parity/verification.json`(`final_runs`、`package_smoke`、`review_rounds`)。 +全部绑定代码 HEAD `cf8905dd285bf16896df053aac253dbf8c672979`,环境 macOS 26.6 arm64 / Node v24.20.0 / pnpm 11.24.0;日志见 `docs/skill-parity/verification.json`(`final_runs`、`package_smoke`、`review_rounds`)。 | 命令 / 测试 ID | 代码 SHA | 环境 | exit code | 结果 | 日志/fixture/报告 | | --- | --- | --- | --- | --- | --- | -| `node --version` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm --version` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm install --frozen-lockfile` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm typecheck` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm test` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed (522 tests, 522 pass, 0 fail) | verification.json final_runs | -| `pnpm build` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `bash -c test "$(node dist/index.js --version)" = "$(node -p "require(\"./package.json\").version")"` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/meshy-cli-0.3.0.tgz` | 0388fe804b45 | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| npm pack 文件清单 + 必需文件 + 禁止内容 | 0388fe804b45 | 同上 | 0 | passed(398 files;dist/skills/README/LICENSE/.env.example 存在;无 .env/credentials/tests/docs/src) | verification.json package_smoke | -| smoke:version | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:version_cli | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:help_cli | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:balance_help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:balance_nokey_json | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:balance_nokey_v1 | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:resources_v1 | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor_check_slicers | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor_check_api_nokey | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:slicer_detect | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:inspect_pass | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:inspect_fail | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 12 (expected 12) | passed | verification.json package_smoke | -| smoke:inspect_unknown | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 13 (expected 13) | passed | verification.json package_smoke | -| smoke:mesh_prepare | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:mesh_refuse_overwrite | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 11 (expected 11) | passed | verification.json package_smoke | -| smoke:project_init | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_record | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_show | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_list | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:download_list | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:download_no_selector | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | -| smoke:make_dry_run | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:usage_unknown_flag | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | -| smoke:uv_help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:creative_lab_help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:stream_help | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:catalog_live | 0388fe804b45 | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| 第 1 轮复现脚本 `reproduce.mjs`(12 场景) | 0388fe804b45 | node v24.20.0,loopback,临时目录 | 1(= 并非全部复现) | 0/12 复现 | verification.json review_rounds[0].probe_rerun | -| 新增行为用例 T-001…T-108(离线部分)+ R01–R12 | 0388fe804b45 | node:test + tsx,loopback mock,临时目录 | 0 | passed(`pnpm test` 522/522) | tests/*.test.ts;verification.json behavior_tests | - -基线(fd94490)上 `pnpm test` 363/363 通过,无基线失败;本次没有删除或跳过任何既有测试(`tests/surface.test.ts` 的模型/废弃参数约束原样保留并通过)。唯一被改写的既有断言是 reviewer 指出为错误的媒体指纹碰撞断言(tests/operation-store.test.ts)。 +| `node --version` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm --version` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm install --frozen-lockfile` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm typecheck` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm test` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed (535 tests, 535 pass, 0 fail) | verification.json final_runs | +| `pnpm build` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `bash -c test "$(node dist/index.js --version)" = "$(node -p "require(\"./package.json\").version")"` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/meshy-cli-0.3.0.tgz` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `git diff --check fd94490` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `for i in 1..8: node --import tsx --test tests/poll.test.ts` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed — 8/8 runs, 9 tests each (deterministic clock tests + real-timer smoke asserting no GET starts after the deadline) | verification.json final_runs | +| npm pack 文件清单 + 必需文件 + 禁止内容 | cf8905dd285b | 同上 | 0 | passed(398 files;dist/skills/README/LICENSE/.env.example 存在;无 .env/credentials/tests/docs/src) | verification.json package_smoke | +| smoke:version | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:version_cli | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:help_cli | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:balance_help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:balance_nokey_json | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:balance_nokey_v1 | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:resources_v1 | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor_check_slicers | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor_check_api_nokey | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:slicer_detect | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:inspect_pass | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:inspect_fail | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 12 (expected 12) | passed | verification.json package_smoke | +| smoke:inspect_unknown | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 13 (expected 13) | passed | verification.json package_smoke | +| smoke:mesh_prepare | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:mesh_refuse_overwrite | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 11 (expected 11) | passed | verification.json package_smoke | +| smoke:project_init | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_record | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_show | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_list | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:download_list | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:download_no_selector | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | +| smoke:make_dry_run | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:usage_unknown_flag | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | +| smoke:uv_help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:creative_lab_help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:stream_help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:catalog_live | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| 第 2 轮复现脚本 `round2-probes.mjs`(8 场景) | cf8905dd285b | node v24.20.0,loopback,临时目录 | 1(= 并非全部复现) | 0/8 复现 | verification.json review_rounds[1].probe_rerun | +| 第 1 轮复现脚本 `round1-probes.mjs`(12 场景) | cf8905dd285b | 同上 | 1 | 0/12 复现(无回退) | verification.json review_rounds[0].reverified_on | +| 新增行为用例 T-001…T-108(离线部分)+ R01–R12 + N01–N08 | cf8905dd285b | node:test + tsx,loopback mock,临时目录 | 0 | passed(`pnpm test` 535/535) | tests/*.test.ts;verification.json behavior_tests | + +基线(fd94490)上 `pnpm test` 363/363 通过,无基线失败;本次没有删除或跳过任何既有测试。被改写的既有断言:round-1 `R10` 的下载失败退出码随 HTTP 分类保留由 11 改为 5(仍要求唯一 outcome 与 task 上下文);round-1 的真实时钟 poll 断言按 R2-F07 改为确定性时钟。 ## 6. 真实环境验证 | 环境/能力 | 身份/资源(脱敏) | 实际结果 | 未完成原因 | 补验方式 | | --- | --- | --- | --- | --- | -| API Key 与 OAuth profile 回归 | 无 | not_run | 本会话无测试账户/Key | 用测试 Key:`meshy doctor --check-api`、`meshy balance --output-schema v1`、`meshy auth status`;OAuth:`meshy auth login` 后 `meshy text-to-3d list --output-schema v1`;额外核对 D-029:`auth login` 写入的 profile 是否带 `user_id`(决定 journal 的账户绑定) | +| API Key 与 OAuth profile 回归 | 无 | not_run | 本会话无测试账户/Key | 用测试 Key:`meshy doctor --check-api`、`meshy balance --output-schema v1`、`meshy auth status`;OAuth:`meshy auth login` 后 `meshy text-to-3d list --output-schema v1`;核对 D-043:真实 token 端点是否返回 `user_id`,登录后 profile 是否带 `login_id`,重新登录后 `--operation-id` 重放是否按预期冲突 | | UV / Creative Lab | 无 | not_run | 计费/账号 gate,未获预算授权 | `meshy uv-unwrap create --input-task-id <已有 SUCCEEDED 任务> --async --output-schema v1` → `wait`;`meshy creative-lab figure prototype create --image-url --async` → `wait` → `build create --input-task-id`;记录 task id、请求次数、消耗额度 | | Enterprise showcases(计费查询) | 无 | not_run | 每次请求计费且需 Enterprise | `meshy showcases list --page-size 1 --output-schema v1`(一次);核对 `showcase_type=animated` 别名是否被服务端接受(D-007) | -| 公开动画目录(免费、无鉴权) | 无需身份 | passed(1 次 GET,157 条,8 条匹配 "wave",在 0388fe8 重跑) | — | verification.json live_verification T-110 partial | +| 公开动画目录(免费、无鉴权) | 无需身份 | passed(1 次 GET,157 条,8 条匹配 "wave",在 cf8905d 重跑) | — | verification.json live_verification T-110 partial | | macOS arm64 | 本机 | passed(tarball 安装 + 29 项 smoke) | — | verification.json package_smoke | | Windows x64 | 无 | not_run | 无主机 | 安装 tarball,运行 `meshy doctor --check-slicers`、`meshy slicer detect`、`meshy mesh prepare-print`;检测规则已用 fixture 覆盖(T-088/T-089) | | Linux x64 | 无 | not_run | 无主机 | 同上;CI(ubuntu, Node 24/26)会在推送后覆盖安装 smoke | | 真实 slicer open | 无 | not_run | 本机未安装任何注册切片器 | 安装 OrcaSlicer 后 `meshy slicer open --slicer OrcaSlicer --file `,仅验证启动 | -| 真实 Meshy OBJ/MTL 引用重写(D-036) | 无 | not_run | 需要一个真实 refine 任务的 OBJ+MTL+贴图 | `meshy download --resource text-to-3d --task-id --model-format obj --output-dir `,检查 `result.downloads.material_links`(尤其 texture_maps 的 method/resolved_to)与目录中文件是否一致 | +| 真实 Meshy 多材质 OBJ 的引用重链接(D-039) | 无 | not_run | 需要真实 refine 任务的 OBJ+MTL+多组贴图 | `meshy download --resource text-to-3d --task-id --model-format obj --output-dir `,检查 `material_links.status` 与 `texture_maps[].method`(真实 MTL 是否引用 URL 末段文件名) | ## 7. 打包证据 - 候选版本:`0.3.0`(package.json,**未发布**;版本号可由 reviewer 调整) -- tarball 路径:`/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/meshy-cli-0.3.0.tgz`(`npm pack --json --pack-destination`;复现见 §9) -- SHA256:`6a05293a3712a46be575f9359eeab20cc43e2de5260f42b3b2c224b989046806` -- npm pack 文件清单:398 个文件(上轮 394 + material-links.js 等 4 个);必需项 dist/index.js、skills/meshy-cli/SKILL.md、skills/meshy-cli/animation-library.json、README.md、LICENSE、.env.example、package.json 均在;无 .env / credentials / tests / docs / src / node_modules +- tarball 路径:`/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/meshy-cli-0.3.0.tgz`(`npm pack --json --pack-destination`;复现见 §9) +- SHA256:`3077563a16fb9ad00b3be5c01d69724ba3122fe1c60d7abb69cc47e201835f2a` +- npm pack 文件清单:398 个文件;必需项 dist/index.js、skills/meshy-cli/SKILL.md、skills/meshy-cli/animation-library.json、README.md、LICENSE、.env.example、package.json 均在;无 .env / credentials / tests / docs / src / node_modules - 临时安装 prefix:`npm install -g --prefix ` → `/bin/meshy` 与 `/bin/meshy-cli` 均指向 `dist/index.js` - 两个 bin、sharp、本地无 Key/无 Python 验证:29 项 smoke 全部符合预期(含 `MESHY_API_KEY=""` 下 balance 的 stdout 为 JSON、exit 3;无 Key 的本地命令 exit 0;dist 中不含 python 引用) - 是否发布:**未发布** ## 8. 需要 Codex 复审的事项 -1. **任务上下文包装**:`src/internal/task-command.ts` `withTaskContext`/`wrapWithResult`——是否在所有受理后路径都保留了原始分类(code/http_status/hint/recovery/exit)且 `result.task`/`downloads` 合并顺序正确(bookkeeping 错误的 `task:null` 不覆盖已知任务)。 -2. **共享提交原语**:`submitCreate` 被 make 复用后,`extraResult`(step/route/executed)与资源命令结果形状的兼容;`replayExisting` 对 make 不适用(make 不接受 `--operation-id`)。 -3. **写入根覆盖面**:D-032 列出的入口是否穷尽(`emitLegacyOutcome`、`maybeDownloadV1`、`finalOutcome`、`project` 三个子命令、`attachToProject`、`download --project`、`mesh`);`project show/list` 为只读未限制是否可接受。 -4. **依赖复制的根检查**:`obj-transform.ts` 计划时与发布前两次 `resolveWithinRoot`;`copied` 报告计划路径而非 realpath 的选择。 -5. **凭据指纹**:D-029 的 `sha256(domain|key)` 是否足够(键高熵);OAuth 无 `user_id` 时退化为 profile 名的限制;`credentialSubject` 仅来自存储 profile。 -6. **媒体摘要**:D-030 对非 base64 data URI 的处理(decodeURIComponent 失败回退原文)。 -7. **截止时间语义**:`poll.ts` 用 `deadlineBound` 判定超时来源;`PollResult.task` 为 null 的 legacy 输出形状 `{resource,id,status:null,timed_out:true}` 是否可接受。 -8. **嵌套合并范围**:仅 Creative Lab build 的 `options`/`output` 声明 `nestedObjectKeys`;其余 payload 保持浅合并(T-028 数组整体替换不变)。 -9. **材质重链接**:D-036 的通道启发式(`channelInFileName`/`MAP_KEY_CHANNEL`)是否会产生错误匹配;对 ZIP bundle、二进制 OBJ、超大 MTL 的跳过条件;rewrite 后 manifest sha256 的一致性。 -10. **stream outcome**:ndjson `outcome` 现在携带 `downloads`,失败时以 `errorEnvelope` 输出一条并设置退出码;确认没有第二个 envelope。 -11. 上一轮 §8 的其余事项(路由等价、transport 边界、锁顺序、slicer OS 行为、not_run 项)仍然有效且本轮未改动相关代码。 +1. **写入根覆盖面(R2-F01)**:`saveReportOnly`/`downloadAssets`/`downloadArtifacts` 的检查是否都在任何 mkdir/临时文件之前;`indexRootFor` 的“越界则跳过索引”是否可接受(reviewer 允许拒绝或跳过,本轮选择跳过并显式 `index_dirty`);`project show/list` 仍为只读未限制。 +2. **材质映射规则(R2-F02)**:源文件名/stem 匹配的大小写与扩展名策略;`only_texture` 规则仅在“唯一贴图且唯一引用”时生效;是否需要把 `material_links.status=incomplete` 提升为非零退出(当前所有文件都已落盘,命令 exit 0 + warning)。 +3. **stream 收尾(R2-F03)**:`bookkeepingError` 与流本身失败并存时的取舍(流失败为 outcome,记账失败为 warning)。 +4. **manifest 合并(R2-F04)**:`withTaskContext` 中 `err.result.downloads` 覆盖默认 `not_requested` 的顺序;legacy 错误 payload 从 `{name:"Error"}` 变为带 `code/status/result` 的 CliError payload 是否可接受(迁移说明 §3.2)。 +5. **信号传递(R2-F05)**:`fetchToTemp` 对 body 中途 abort 的临时文件清理;`index.ts` 重包装保留 `result/recovery/hint` 但仍改写 `code=interrupted`。 +6. **OAuth 身份(R2-F06)**:`login_id` 的生成点(`finishLogin`)是否覆盖所有登录路径(loopback/device/manual 均经 `finishLogin`);refresh 保留;unverified 只拒绝重放而不阻止新操作的取舍;D-043 对存量 profile 的迁移说明。 +7. **确定性测试(R2-F07)**:fake clock 的 early-wake 建模(-0.5 ms)是否足以代表真实定时器;真实时钟 smoke 只断言“截止后不启动 GET”。 +8. 上两轮 §8 的其余事项仍然有效且本轮未改动相关代码(路由等价、transport 边界、锁顺序、slicer OS 行为、not_run 项)。 ## 9. 最短复现步骤 ```sh git clone https://github.com/meshy-dev/meshy-cli.git && cd meshy-cli -git fetch feat/skill-parity-s1 && git checkout 0388fe804b456a931f871d2f227e2acf22359d77 # 或使用本地仓库 /Users/ark/Dev/meshy-cli +git fetch feat/skill-parity-s1 && git checkout cf8905dd285bf16896df053aac253dbf8c672979 # 或使用本地仓库 /Users/ark/Dev/meshy-cli node --version # v24.x corepack enable && pnpm --version # 11.24.0 pnpm install --frozen-lockfile pnpm typecheck pnpm test # 先 tsc 生成 dist,再运行 node:test(约 41 s;loopback mock,无外网、无凭据) -node --import tsx --test tests/codex-review-round1.test.ts # 仅本轮 17 项回归 +node --import tsx --test tests/codex-review-round2.test.ts tests/codex-review-round1.test.ts tests/poll.test.ts # 两轮回归 + 确定性 deadline +for i in 1 2 3 4 5 6 7 8; do node --import tsx --test tests/poll.test.ts >/dev/null || echo "run $i failed"; done pnpm build -cp /path/to/reviews/cli-s1-6273d9a/reproduce.mjs /tmp/probe/ && node /tmp/probe/reproduce.mjs "$PWD" # 期望 exit 1 且 12 个 reproduced=false -npm pack --json --pack-destination /tmp/meshy-pack -shasum -a 256 /tmp/meshy-pack/meshy-cli-0.3.0.tgz +mkdir -p /tmp/probes && cp /path/to/reviews/cli-s1-730132b/round2-probes.mjs /path/to/reviews/cli-s1-730132b/round1-probes.mjs /tmp/probes/ +node /tmp/probes/round2-probes.mjs "$PWD" # 期望 exit 1 且 8 个 reproduced=false +node /tmp/probes/round1-probes.mjs "$PWD" # 期望 exit 1 且 12 个 reproduced=false +git diff --check fd94490 +npm pack --json --pack-destination /tmp/meshy-pack && shasum -a 256 /tmp/meshy-pack/meshy-cli-0.3.0.tgz npm install -g --prefix /tmp/meshy-prefix /tmp/meshy-pack/meshy-cli-0.3.0.tgz export MESHY_CONFIG_DIR=$(mktemp -d) -/tmp/meshy-prefix/bin/meshy --version -/tmp/meshy-prefix/bin/meshy doctor --output-schema v1 +/tmp/meshy-prefix/bin/meshy --version && /tmp/meshy-prefix/bin/meshy doctor --output-schema v1 MESHY_API_KEY= /tmp/meshy-prefix/bin/meshy balance --output-schema v1 ; echo "exit=$?" # 3, stdout 为 v1 envelope ``` -不依赖作者机器上的凭据、Python 或全局包;`tests/helpers/cli.ts` 为每次子进程创建隔离的 `MESHY_CONFIG_DIR` 与 cwd,并显式指向 loopback mock。复现脚本请从副本运行,它会在自身目录写 `reproduction-results.json`。 +不依赖作者机器上的凭据、Python 或全局包;`tests/helpers/cli.ts` 为每次子进程创建隔离的 `MESHY_CONFIG_DIR` 与 cwd,并显式指向 loopback mock。复现脚本请从副本运行(它们在自身目录写结果文件);脚本 exit 0 表示缺陷全部复现,exit 1 表示并非全部复现——正向验收以仓库内测试为准。 ## 10. 交给 Codex 的复审提示词 -请复审 Meshy CLI S1 第 1 轮 review(reviews/cli-s1-6273d9a,F01–F10)的修复。仓库、base/head SHA 见本文件;代码 HEAD `0388fe804b456a931f871d2f227e2acf22359d77`(本文件所在 docs commit 不改代码),规范仍是 2026-09-07 v1 实施包。先核实 diff `6273d9aa6ef396cf1cc26838e2c0d09ab176f595..0388fe804b456a931f871d2f227e2acf22359d77` 与 verification.json/capability-matrix.json 绑定同一 HEAD,再逐项核对 §0 表:修复是否完整覆盖 finding 的触发条件、是否引入回归、正向回归测试是否真正断言了 expected(而不是仅命令可运行),并重跑 reproduce.mjs 与 `pnpm test`。 +请复审 Meshy CLI S1 第 2 轮 review(reviews/cli-s1-730132b,R2-F01–R2-F07)的修复,并确认第 1 轮(reviews/cli-s1-6273d9a,F01–F10)修复未回退。仓库、base/head SHA 见本文件;代码 HEAD `cf8905dd285bf16896df053aac253dbf8c672979`(本文件所在 docs commit 不改代码),规范仍是 2026-09-07 v1 实施包。先核实 diff `730132bd99a471ed41d6bbf6b200219f13f569ba..cf8905dd285bf16896df053aac253dbf8c672979` 与 verification.json/capability-matrix.json 绑定同一 HEAD,再逐项核对 §0 表:修复是否完整覆盖 finding 的触发条件与 acceptance、是否引入回归、正向回归测试是否真正断言了 expected(退出码、JSON 形状、task_id/submission、请求次数、落盘内容与 manifest、ndjson 唯一 outcome 与序号、被拒目标连目录都不创建),并重跑两个复现脚本副本、`pnpm test`、`git diff --check`。 -请优先检查 §8 列出的 11 项风险,以及任何新的可复现 bug、重复付费风险、凭据/路径问题、丢失资产/项目记录、OBJ 错误和伪造完成状态。不要做未授权付费调用、发布或 Skill/MCP 迁移。每项 finding 给出优先级、文件/行号、触发条件、影响与修复建议;区分代码缺陷、测试缺口与尚未完成的外部验证。最后分别判断 G1-code 是否可接受、G1-release 是否满足以及是否允许进入 S2。没有发现也要说明验证范围和剩余限制。 +请优先检查 §8 列出的 8 项风险,以及任何新的可复现 bug、重复付费风险、凭据/路径问题、丢失资产/项目记录、OBJ 错误和伪造完成状态。不要做未授权付费调用、发布或 Skill/MCP 迁移。每项 finding 给出优先级、文件/行号、触发条件、影响与修复建议;区分代码缺陷、测试缺口与尚未完成的外部验证。最后分别判断 G1-code 是否可接受、G1-release 是否满足以及是否允许进入 S2。没有发现也要说明验证范围和剩余限制。 diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index cc35de1..646e04e 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -2,7 +2,7 @@ "schema_version": 1, "title": "Meshy CLI S1 capability matrix — implementation tracking", "date": "2026-09-07", - "status": "review round 1 (Codex, reviewed 6273d9a): changes_requested → all 10 findings fixed in 0388fe8; G1-code re-review pending; G1-release not_run", + "status": "review round 2 (Codex, reviewed 730132b): changes_requested → all 7 findings fixed in cf8905d (round 1: 10/10 fixed in 0388fe8, re-verified); G1-code re-review pending; G1-release not_run", "baseline": { "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" @@ -20,7 +20,7 @@ "target": "meshy create --async", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", "test_ids": [ "T-040", "T-043", @@ -55,7 +55,7 @@ "target": "meshy get --include-raw --save-json", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-006", "T-008", @@ -63,7 +63,8 @@ ], "evidence": [ "task-view.ts + task-command get: any status exit 0, --include-raw, --save-json; tests T-006/T-008/T-009/T-010", - "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)" + "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)", + "tests/codex-review-round2.test.ts N01 (report-only get -o), N06 (partial manifest), N08 (SIGINT)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -77,6 +78,18 @@ "tests": [ "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)" ] + }, + "review_round_2": { + "findings": [ + "R2-F01", + "R2-F04", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N01 (report-only get -o), N06 (partial manifest), N08 (SIGINT)" + ] } }, { @@ -90,7 +103,7 @@ "target": "meshy wait", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-007", "T-047", @@ -99,7 +112,9 @@ "evidence": [ "poll.ts monotonic deadline, cancellable sleep, --timeout 0 single query; tests T-007/T-047/T-048", "tests/codex-review-round1.test.ts R03/F07 ×2, R06/F01", - "tests/poll.test.ts deadline tests" + "tests/poll.test.ts deadline tests", + "tests/codex-review-round2.test.ts N01/N06/N08 (wait -o)", + "tests/poll.test.ts deterministic deadline tests" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -114,6 +129,20 @@ "tests/codex-review-round1.test.ts R03/F07 ×2, R06/F01", "tests/poll.test.ts deadline tests" ] + }, + "review_round_2": { + "findings": [ + "R2-F01", + "R2-F04", + "R2-F05", + "R2-F07" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N01/N06/N08 (wait -o)", + "tests/poll.test.ts deterministic deadline tests" + ] } }, { @@ -127,7 +156,7 @@ "target": "meshy stream", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-049", "T-050", @@ -138,7 +167,8 @@ "evidence": [ "meshyd api_stream_handlers.go: event message (task), event error {message,status_code}, keep-alive re-emits message every 10 s", "stream.ts SSE parser + streamTask; task-command stream (ndjson events + outcome, json envelope); tests/sse.test.ts, T-053", - "tests/codex-review-round1.test.ts R10/F10" + "tests/codex-review-round1.test.ts R10/F10", + "tests/codex-review-round2.test.ts N01 (stream -o), N05 (single outcome on post-stream failures)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -151,6 +181,19 @@ "tests": [ "tests/codex-review-round1.test.ts R10/F10" ] + }, + "review_round_2": { + "findings": [ + "R2-F01", + "R2-F03", + "R2-F04", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N01 (stream -o), N05 (single outcome on post-stream failures)" + ] } }, { @@ -190,7 +233,7 @@ "target": "existing commands with explicit supported payload", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", "test_ids": [ "T-028", "T-033", @@ -224,7 +267,7 @@ "target": "typed media flags and final payload normalization", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", "test_ids": [ "T-029", "T-030", @@ -309,7 +352,7 @@ "target": "meshy creative-lab lamp prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", "test_ids": [ "T-022", "T-023", @@ -348,7 +391,7 @@ "target": "meshy creative-lab keychain prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", "test_ids": [ "T-022", "T-023", @@ -385,7 +428,7 @@ "target": "meshy creative-lab fridge-magnet prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", "test_ids": [ "T-022", "T-023", @@ -471,7 +514,7 @@ "target": "download asset registry with role and dependencies", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-060", "T-062", @@ -479,7 +522,8 @@ ], "evidence": [ "src/internal/artifacts.ts enumerateAssets: model formats (lamp parts STL/ZIP, keychain/fridge OBJ as ZIP bundle), images, textures, primary/multi-view/alpha thumbnails, rig + basic_animations, motion, report; unknown URLs listed; tests/artifacts.test.ts T-060/T-062/T-071", - "tests/codex-review-round1.test.ts R07/F09" + "tests/codex-review-round1.test.ts R07/F09", + "tests/codex-review-round2.test.ts N04 (source-name mapping, ambiguity)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -492,6 +536,16 @@ "tests": [ "tests/codex-review-round1.test.ts R07/F09" ] + }, + "review_round_2": { + "findings": [ + "R2-F02" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N04 (source-name mapping, ambiguity)" + ] } }, { @@ -505,14 +559,15 @@ "target": "meshy download --asset/--kind/--model-format", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-061", "T-063" ], "evidence": [ "src/cmd/download.ts selectors --asset/--model-format/--kind/--all/--list; tests/download-command.test.ts T-061, tests/artifacts.test.ts T-063", - "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)" + "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)", + "tests/codex-review-round2.test.ts N04" ], "intentional_differences": [ "download requires an explicit selector when a task exposes several assets (legacy defaulted to glb); legacy -o keeps downloading everything" @@ -527,6 +582,16 @@ "tests": [ "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)" ] + }, + "review_round_2": { + "findings": [ + "R2-F02" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N04" + ] } }, { @@ -540,7 +605,7 @@ "target": "safe download with actual final-path manifest", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-064", "T-065", @@ -552,7 +617,8 @@ ], "evidence": [ "src/internal/download.ts fetchToTemp (no credential, redirect re-validation, private-network refusal, size cap, sha256), exclusive publish + realpath containment, partial manifests; tests T-064..T-070", - "tests/codex-review-round1.test.ts R07/F09, R09/F03" + "tests/codex-review-round1.test.ts R07/F09, R09/F03", + "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -566,6 +632,19 @@ "tests": [ "tests/codex-review-round1.test.ts R07/F09, R09/F03" ] + }, + "review_round_2": { + "findings": [ + "R2-F01", + "R2-F02", + "R2-F04", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08" + ] } }, { @@ -579,14 +658,15 @@ "target": "--save-json and project task snapshots", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-010", "T-063" ], "evidence": [ "--save-json on task verbs/list/balance/api/catalog/showcases (raw JSON, never the envelope); project task_.json snapshots; tests T-010, T-063, project-store tests", - "tests/codex-review-round1.test.ts R05/F01" + "tests/codex-review-round1.test.ts R05/F01", + "tests/codex-review-round2.test.ts N05 (save-json conflict after the stream → outcome)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -599,6 +679,16 @@ "tests": [ "tests/codex-review-round1.test.ts R05/F01" ] + }, + "review_round_2": { + "findings": [ + "R2-F03" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N05 (save-json conflict after the stream → outcome)" + ] } }, { @@ -612,7 +702,7 @@ "target": "meshy project init", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", "test_ids": [ "T-072", "T-077" @@ -645,7 +735,7 @@ "target": "meshy project record/show", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-073", "T-074", @@ -653,7 +743,8 @@ ], "evidence": [ "project-store recordTask (task_id+stage merge, legacy v1 migration with backup, unknown fields kept) + project record/show; tests T-073/T-074/T-075", - "tests/codex-review-round1.test.ts R08/F03" + "tests/codex-review-round1.test.ts R08/F03", + "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -666,6 +757,16 @@ "tests": [ "tests/codex-review-round1.test.ts R08/F03" ] + }, + "review_round_2": { + "findings": [ + "R2-F01" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)" + ] } }, { @@ -679,7 +780,7 @@ "target": "meshy project list/rebuild-index", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-073", "T-075", @@ -688,7 +789,8 @@ ], "evidence": [ "project list (index vs folders, index_dirty) and rebuild-index (backup, symlink/escape skipped); tests T-073/T-075/T-076/T-077", - "tests/codex-review-round1.test.ts R08/F03 (rebuild-index)" + "tests/codex-review-round1.test.ts R08/F03 (rebuild-index)", + "tests/codex-review-round2.test.ts N02 (index skipped with index.updated=false + reason)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -701,6 +803,16 @@ "tests": [ "tests/codex-review-round1.test.ts R08/F03 (rebuild-index)" ] + }, + "review_round_2": { + "findings": [ + "R2-F01" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N02 (index skipped with index.updated=false + reason)" + ] } }, { @@ -740,7 +852,7 @@ "target": "meshy mesh prepare-print", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", "test_ids": [ "T-083", "T-084", @@ -850,7 +962,7 @@ "target": "--env-file without automatic dotenv/shell scanning", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", "test_ids": [ "T-100", "T-101" @@ -885,14 +997,16 @@ "target": "preserve existing auth commands and token protocol", "baseline_status": "existing_needs_regression", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-104", "T-105" ], "evidence": [ "auth commands untouched; runtime refresh path unchanged; tests/auth-headless.test.ts, oauth.test.ts, device*.test.ts, credentials.test.ts, runtime.test.ts still pass (regression)", - "tests/codex-review-round1.test.ts R01/F05 (OAuth subject, token rotation)" + "tests/codex-review-round1.test.ts R01/F05 (OAuth subject, token rotation)", + "tests/codex-review-round2.test.ts N07 (login_id binding, refresh keeps it, unverified refused)", + "tests/auth-headless.test.ts (device login mints login_id)" ], "intentional_differences": [], "external_verification": "required_when_applicable_not_run", @@ -905,6 +1019,17 @@ "tests": [ "tests/codex-review-round1.test.ts R01/F05 (OAuth subject, token rotation)" ] + }, + "review_round_2": { + "findings": [ + "R2-F06" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N07 (login_id binding, refresh keeps it, unverified refused)", + "tests/auth-headless.test.ts (device login mints login_id)" + ] } }, { @@ -918,7 +1043,7 @@ "target": "--output-schema v1; legacy compatibility", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "not_run", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-001", "T-002", @@ -928,10 +1053,22 @@ "T-011" ], "evidence": [ - "result.ts envelope, errors.ts codes/exits, Commander errors → exit 2; tests result.test.ts, cli-contract T-002..T-005" + "result.ts envelope, errors.ts codes/exits, Commander errors → exit 2; tests result.test.ts, cli-contract T-002..T-005", + "tests/codex-review-round2.test.ts N05 (ndjson outcome/sequence), N08 (exit 130 keeps result)" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "offline_primary", + "review_round_2": { + "findings": [ + "R2-F03", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N05 (ndjson outcome/sequence), N08 (exit 130 keeps result)" + ] + } }, { "id": "CAP-030", @@ -944,14 +1081,15 @@ "target": "make --async / --stop-after-first", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-041", "T-042" ], "evidence": [ "src/cmd/make.ts --async single POST, --stop-after-first, mutual exclusion; tests T-041/T-042", - "tests/codex-review-round1.test.ts R11/F02, F01 make polling, R09/F03 make -o" + "tests/codex-review-round1.test.ts R11/F02, F01 make polling, R09/F03 make -o", + "tests/codex-review-round2.test.ts N06 (make -o partial manifest)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -966,6 +1104,17 @@ "tests": [ "tests/codex-review-round1.test.ts R11/F02, F01 make polling, R09/F03 make -o" ] + }, + "review_round_2": { + "findings": [ + "R2-F04", + "R2-F05" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N06 (make -o partial manifest)" + ] } }, { @@ -979,7 +1128,7 @@ "target": "operation journal; never claim server idempotency", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed; re-review pending", + "review_status": "round 1 + round 2 findings fixed; re-review pending", "test_ids": [ "T-043", "T-044", @@ -990,7 +1139,8 @@ "evidence": [ "operation-store.ts journal with lock, replay and conflict; tests operation-store.test.ts, T-046", "tests/codex-review-round1.test.ts R01/F05, R02/F06, concurrent same-id creates", - "tests/operation-store.test.ts (barrier race, F05, F06)" + "tests/operation-store.test.ts (barrier race, F05, F06)", + "tests/codex-review-round2.test.ts N07, credentialBinding unit test" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -1005,6 +1155,16 @@ "tests/codex-review-round1.test.ts R01/F05, R02/F06, concurrent same-id creates", "tests/operation-store.test.ts (barrier race, F05, F06)" ] + }, + "review_round_2": { + "findings": [ + "R2-F06" + ], + "status": "fixed", + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "tests": [ + "tests/codex-review-round2.test.ts N07, credentialBinding unit test" + ] } }, { @@ -1119,8 +1279,8 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-07T09:32:18.044Z", - "code_head": "0388fe804b456a931f871d2f227e2acf22359d77" + "updated_at": "2026-09-07T10:25:20.965Z", + "code_head": "cf8905dd285bf16896df053aac253dbf8c672979" }, "review_rounds": [ { @@ -1144,6 +1304,25 @@ "fixed_in": "0388fe804b456a931f871d2f227e2acf22359d77", "regression_tests": "tests/codex-review-round1.test.ts (R01–R12 as positive assertions) + tests/operation-store.test.ts + tests/poll.test.ts", "probe_rerun": "reviews/cli-s1-6273d9a/reproduce.mjs on 0388fe8: 0/12 defects reproduce" + }, + { + "round": 2, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-730132b", + "reviewed_head": "730132bd99a471ed41d6bbf6b200219f13f569ba", + "verdict": "changes_requested", + "findings": [ + "R2-F01", + "R2-F02", + "R2-F03", + "R2-F04", + "R2-F05", + "R2-F06", + "R2-F07" + ], + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "regression_tests": "tests/codex-review-round2.test.ts (N01–N08 as positive assertions) + tests/poll.test.ts (deterministic clock) + tests/auth-headless.test.ts (login_id)", + "probe_rerun": "reviews/cli-s1-730132b/round2-probes.mjs on cf8905d: 0/8 defects reproduce; round1-probes.mjs: 0/12" } ] } diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json index 5439e40..cfa81b8 100644 --- a/docs/skill-parity/verification.json +++ b/docs/skill-parity/verification.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "status": "review round 1 fixes applied (Codex reviewed 6273d9a: changes_requested, 10 findings); re-review pending; live checks not_run", + "status": "review round 2 fixes applied (Codex reviewed 730132b: changes_requested, 7 findings); re-review pending; live checks not_run", "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", "environment": { "os": "macOS 26.6 (Darwin 25.6.0) arm64", @@ -43,70 +43,85 @@ "final_runs": [ { "command": "node --version", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm --version", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, - "duration_s": 0, + "duration_s": 1, "result": "passed" }, { "command": "pnpm install --frozen-lockfile", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm typecheck", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { "command": "pnpm test", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "duration_s": 41, "result": "passed", - "tests": 522, - "pass": 522, + "tests": 535, + "pass": 535, "fail": 0, "skipped": 0 }, { "command": "pnpm build", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, - "duration_s": 0, + "duration_s": 1, "result": "passed" }, { "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "duration_s": 0, "result": "passed" }, { - "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { - "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/meshy-cli-0.3.0.tgz", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/meshy-cli-0.3.0.tgz", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, - "duration_s": 3, + "duration_s": 2, "result": "passed" + }, + { + "command": "git diff --check fd94490", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "for i in 1..8: node --import tsx --test tests/poll.test.ts", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "exit_code": 0, + "duration_s": 4, + "result": "passed", + "detail": "8/8 runs, 9 tests each (deterministic clock tests + real-timer smoke asserting no GET starts after the deadline)" } ], "behavior_tests": { @@ -115,7 +130,7 @@ "files": [ "tests/cli-contract.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-002": { "result": "passed", @@ -123,67 +138,71 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-003": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-004": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-005": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-006": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-007": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-008": { "result": "passed", "files": [ "tests/task-view.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-009": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F10: R10 stream -o in ndjson/json/pretty; one outcome on download failure" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F10: R10 stream -o in ndjson/json/pretty; one outcome on download failure", + "review_round_2": "R2-F01 N01 report-only get/wait/stream -o confined; R2-F04 N06 partial manifest; R2-F05 N08 SIGINT" }, "T-010": { "result": "passed", "files": [ "tests/cli-contract.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F01: R05 save-json preflight + post-POST failure keeps the id" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F01: R05 save-json preflight + post-POST failure keeps the id", + "review_round_2": "R2-F04 N06: task -o failure keeps HTTP class/status and the files already written" }, "T-011": { "result": "passed", @@ -191,28 +210,28 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-012": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-020": { "result": "passed", "files": [ "tests/resource-registry.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-021": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-022": { "result": "passed", @@ -220,7 +239,7 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-023": { "result": "passed", @@ -228,14 +247,14 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-024": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-025": { "result": "passed", @@ -243,7 +262,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "review_round_1": "F08: R04 three-layer options merge on the real POST body" }, "T-026": { @@ -251,14 +270,14 @@ "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-027": { "result": "passed", "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-028": { "result": "passed", @@ -266,7 +285,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "review_round_1": "F08: R04" }, "T-029": { @@ -275,7 +294,7 @@ "tests/file-input.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "review_round_1": "F06: R02 equal-length images conflict, re-wrapped base64 replays, no base64 in journal" }, "T-030": { @@ -283,28 +302,28 @@ "files": [ "tests/file-input.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-031": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-032": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-040": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-041": { "result": "passed", @@ -312,7 +331,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "review_round_1": "F02: R11 make journal failure after acceptance is local_io with the id (text + image routes)" }, "T-042": { @@ -321,7 +340,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "review_round_1": "F02: R11" }, "T-043": { @@ -330,14 +349,14 @@ "tests/operation-store.test.ts", "tests/task-lifecycle.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-044": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-045": { "result": "passed", @@ -345,7 +364,7 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "review_round_1": "F01: R06 polling 5xx keeps id/submission/next; make polling failure keeps executed steps" }, "T-046": { @@ -355,7 +374,7 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "review_round_1": "barrier-released three-process race; F05/F06 fingerprint unit tests" }, "T-047": { @@ -365,62 +384,67 @@ "tests/codex-review-round1.test.ts", "tests/poll.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F07: deadline bounds the sleep; --timeout 0 request cap" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F07: deadline bounds the sleep; --timeout 0 request cap", + "review_round_2": "R2-F07: deterministic fake-clock deadline tests; real-timer smoke asserts only no GET after the deadline" }, "T-048": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F01: task context on every post-acceptance failure" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F01: task context on every post-acceptance failure", + "review_round_2": "R2-F05 N08: SIGINT before headers / mid-body / on the second asset → 130 with manifest, no leftovers" }, "T-049": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-050": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-051": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-052": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-053": { "result": "passed", "files": [ "tests/sse.test.ts", "tests/task-lifecycle.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F10: R10" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F10: R10", + "review_round_2": "R2-F03 N05: save-json/project/download failures after the stream end in one outcome with the next sequence" }, "T-060": { "result": "passed", "files": [ "tests/artifacts.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-061": { "result": "passed", @@ -428,68 +452,74 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-062": { "result": "passed", "files": [ "tests/artifacts.test.ts", "tests/download-command.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F09: R07 rewritten mtllib/map_* references verified on disk; unresolved map reported" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F09: R07 rewritten mtllib/map_* references verified on disk; unresolved map reported", + "review_round_2": "R2-F02 N04: multi-material textures mapped by source name; ambiguity kept + warned (v1 and legacy -o)" }, "T-063": { "result": "passed", "files": [ "tests/artifacts.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-064": { "result": "passed", "files": [ - "tests/download-command.test.ts" + "tests/download-command.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_2": "R2-F01 N03: refused download creates no directory; zero requests" }, "T-066": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-067": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-068": { "result": "passed", "files": [ "tests/download-command.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F03/F04: R08 project roots, R09 task -o, R12 dependency symlink escapes" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F03/F04: R08 project roots, R09 task -o, R12 dependency symlink escapes", + "review_round_2": "R2-F01 N01/N03: outside/symlinked targets refused before mkdir for report tasks and downloads" }, "T-069": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-070": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-071": { "result": "passed", @@ -497,93 +527,97 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-072": { "result": "passed", "files": [ "tests/project-store.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F03: R08 project init/record/rebuild-index confined to --workspace" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F03: R08 project init/record/rebuild-index confined to --workspace", + "review_round_2": "R2-F01 N02: implicit history root confined; metadata recorded, parent untouched" }, "T-073": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-074": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-075": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-076": { "result": "passed", "files": [ - "tests/project-store.test.ts" + "tests/project-store.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_2": "R2-F01 N02: index skipped with index.updated=false + reason when the root is outside --workspace" }, "T-077": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-080": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-081": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-082": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-083": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-084": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-085": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-086": { "result": "passed", @@ -591,7 +625,7 @@ "tests/obj-transform.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "review_round_1": "F04: R12 symlinked materials/ and texture dir refused; real dirs copied" }, "T-087": { @@ -599,52 +633,54 @@ "files": [ "tests/obj-transform.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-088": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-089": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-090": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-091": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-100": { "result": "passed", "files": [ "tests/cli-contract.test.ts", "tests/env-file.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F05: R01 API key digest binding" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F05: R01 API key digest binding", + "review_round_2": "R2-F06 N07: OAuth without user_id/login_id refused a replay; login_id binds; refresh keeps it" }, "T-101": { "result": "passed", "files": [ "tests/env-file.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-102": { "result": "passed", @@ -652,23 +688,33 @@ "tests/cli-contract.test.ts", "tests/doctor.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-103": { "result": "passed", "files": [ "tests/doctor.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" }, "T-105": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round2.test.ts" ], - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", - "review_round_1": "F05: R01 OAuth subject binding (token rotation keeps identity)" + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_1": "F05: R01 OAuth subject binding (token rotation keeps identity)", + "review_round_2": "R2-F06 N07" + }, + "T-104": { + "result": "passed", + "files": [ + "tests/auth-headless.test.ts" + ], + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "review_round_2": "R2-F06: a device-flow login mints login_id (offline E2E stub)" } }, "live_verification": [ @@ -676,19 +722,19 @@ "id": "T-104", "scope": "API Key and OAuth profile regression against a real account", "result": "not_run", - "reason": "no credential configured in this session; offline regression suites (auth-headless, oauth, device, credentials, runtime) pass" + "reason": "no credential configured in this session; offline regression suites (auth-headless, oauth, device, credentials, runtime) pass. Round 2 adds login_id minting at auth login — whether the real token endpoint returns user_id, and a real re-login, remain not_run" }, { "id": "T-109", "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "partial", - "detail": "macOS 26.6 arm64, Node 24.20.0: tarball install + 29 smoke checks passed on 0388fe8 (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" + "detail": "macOS 26.6 arm64, Node 24.20.0: tarball install + 29 smoke checks passed on cf8905d (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" }, { "id": "T-110", "scope": "real free queries, existing task get, asset download", "result": "partial", - "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions) on 0388fe8 returned 157 entries, 8 matched \"wave\". Authenticated get/download: not_run (no credential)" + "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions) on cf8905d returned 157 entries, 8 matched \"wave\". Authenticated get/download: not_run (no credential)" }, { "id": "T-111", @@ -703,12 +749,12 @@ "reason": "no slicer application installed on this host (slicer detect returned an empty list)" } ], - "code_head": "0388fe804b456a931f871d2f227e2acf22359d77", - "recorded_at": "2026-09-07T09:32:03.977Z", - "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1 notes name the Codex finding (F01–F10) and probe (R01–R12) the file now covers.", + "code_head": "cf8905dd285bf16896df053aac253dbf8c672979", + "recorded_at": "2026-09-07T10:25:06.450Z", + "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1 / review_round_2 notes name the Codex finding and probe the file now covers.", "package_smoke": { "tarball": "meshy-cli-0.3.0.tgz", - "sha256": "6a05293a3712a46be575f9359eeab20cc43e2de5260f42b3b2c224b989046806", + "sha256": "3077563a16fb9ad00b3be5c01d69724ba3122fe1c60d7abb69cc47e201835f2a", "file_count": 398, "required_present": true, "forbidden_absent": true, @@ -721,8 +767,8 @@ "checks": [ { "id": "version", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy --version", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy --version", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -730,8 +776,8 @@ }, { "id": "version_cli", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy-cli --version", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy-cli --version", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -739,8 +785,8 @@ }, { "id": "help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy --help", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy --help", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -748,8 +794,8 @@ }, { "id": "help_cli", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy-cli --help", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy-cli --help", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -757,8 +803,8 @@ }, { "id": "balance_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy balance --help", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy balance --help", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -766,8 +812,8 @@ }, { "id": "balance_nokey_json", - "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy balance", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy balance", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -775,8 +821,8 @@ }, { "id": "balance_nokey_v1", - "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy balance --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy balance --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -784,8 +830,8 @@ }, { "id": "resources_v1", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy resources --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy resources --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -793,8 +839,8 @@ }, { "id": "doctor", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy doctor --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy doctor --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -802,8 +848,8 @@ }, { "id": "doctor_check_slicers", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy doctor --check-slicers --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy doctor --check-slicers --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -811,8 +857,8 @@ }, { "id": "doctor_check_api_nokey", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy doctor --check-api --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy doctor --check-api --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -820,8 +866,8 @@ }, { "id": "slicer_detect", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy slicer detect --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy slicer detect --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -829,8 +875,8 @@ }, { "id": "inspect_pass", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -838,8 +884,8 @@ }, { "id": "inspect_fail", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 12, "expected_exit": 12, "result": "passed", @@ -847,8 +893,8 @@ }, { "id": "inspect_unknown", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy inspect faces --task-json /dev/stdin --max-faces 300000 --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy inspect faces --task-json /dev/stdin --max-faces 300000 --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 13, "expected_exit": 13, "result": "passed", @@ -856,8 +902,8 @@ }, { "id": "mesh_prepare", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -865,8 +911,8 @@ }, { "id": "mesh_refuse_overwrite", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 11, "expected_exit": 11, "result": "passed", @@ -874,8 +920,8 @@ }, { "id": "project_init", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -883,8 +929,8 @@ }, { "id": "project_record", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/work/meshy_output/20260907_172958_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/work/meshy_output/20260907_182403_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -892,8 +938,8 @@ }, { "id": "project_show", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/work/meshy_output/20260907_172958_smoke-demo_fixture- --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/work/meshy_output/20260907_182403_smoke-demo_fixture- --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -901,8 +947,8 @@ }, { "id": "project_list", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -910,8 +956,8 @@ }, { "id": "download_list", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -919,8 +965,8 @@ }, { "id": "download_no_selector", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -928,8 +974,8 @@ }, { "id": "make_dry_run", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -937,8 +983,8 @@ }, { "id": "usage_unknown_flag", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy balance --bogus --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy balance --bogus --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -946,8 +992,8 @@ }, { "id": "uv_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy uv-unwrap create --help", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy uv-unwrap create --help", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -955,8 +1001,8 @@ }, { "id": "creative_lab_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy creative-lab lamp build create --help", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy creative-lab lamp build create --help", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -964,8 +1010,8 @@ }, { "id": "stream_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy text-to-3d stream --help", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy text-to-3d stream --help", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -973,8 +1019,8 @@ }, { "id": "catalog_live", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r2/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", - "sha": "0388fe804b456a931f871d2f227e2acf22359d77", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1067,6 +1113,90 @@ "exit_code": 11 } ] + }, + "reverified_on": { + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "probe": "round1-probes.mjs re-run", + "reproduced": 0, + "total": 12 + } + }, + { + "round": 2, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-730132b", + "reviewed_head": "730132bd99a471ed41d6bbf6b200219f13f569ba", + "reviewed_code_head": "0388fe804b456a931f871d2f227e2acf22359d77", + "verdict": "changes_requested (G1-code not_accepted, G1-release not_satisfied, S2 not_ready)", + "findings": 7, + "priorities": { + "P1": 1, + "P2": 6 + }, + "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", + "regression_tests": [ + "tests/codex-review-round2.test.ts", + "tests/poll.test.ts", + "tests/auth-headless.test.ts (login_id)", + "tests/codex-review-round1.test.ts (R10 expectation follows the preserved HTTP class)" + ], + "reviewer_test_result_before_fix": "pnpm test 521/522 (flaky real-timer poll test), 8/8 round-2 probes reproduced", + "probe_rerun": { + "script": "reviews/cli-s1-730132b/round2-probes.mjs (copied to a scratch directory; the reviewer's evidence files are untouched)", + "node": "v24.20.0", + "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "reproduced_after_fix": 0, + "total": 8, + "exit_code": 1, + "note": "the script exits 1 when not every defect reproduces; after the fixes 0/8 reproduce. Positive acceptance is asserted by tests/codex-review-round2.test.ts, not by this flag", + "cases": [ + { + "id": "N01-report-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N02-project-index-outside-workspace", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N03-rejected-download-creates-outside-directory", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N04-multiple-textures-collapse-to-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N05-stream-save-error-has-no-outcome-event", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N06-task-download-partial-files-lost", + "reproduced": false, + "exit_code": 7 + }, + { + "id": "N07-oauth-without-subject-replays-other-token", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "N08-task-download-ignores-sigint", + "reproduced": false, + "exit_code": 130 + } + ] + }, + "poll_stability": { + "runs": 8, + "passed": 8, + "failed": 0, + "sha": "cf8905dd285bf16896df053aac253dbf8c672979" } } ] From 30536d8daa92dc9e8d39d99cbdf24d0d20799438 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 23:24:32 +0800 Subject: [PATCH 14/26] =?UTF-8?q?fix(review):=20address=20Codex=20review?= =?UTF-8?q?=20round=203=20findings=20R3-F01=E2=80=93R3-F06?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3-F01 the legacy sidecar (meta.json / _meta.json) is published like an asset: root re-proven at publication, symlink refused, exclusive atomic writeJsonFile — a target planted after the preflight is never followed or truncated; the committed model stays in the manifest R3-F02 legacy-schema post-processing (create/wait/get/stream/make -o) runs in the task context: additive task_id/operation_id payload fields, real submission, next and partial manifest, hint names the task on stderr R3-F03 texture identity follows the server-side name before any saved-name match; a generated name that belongs to another source is ambiguous (note, warning, incomplete); channel rules refuse when several distinct references compete for the only texture of a channel R3-F04 relink, digest refresh and sidecar publication share one failure handler: partial manifest with on-disk digests and failed_step; the same for meshy download's relink R3-F05 the abort signal reaches relinkMaterials/rewriteLines (checked before reads, after each chunk, before publication) and the sidecar step: Ctrl-C during the rewrite is interrupted/130 with committed files kept, temp file removed, no sidecar R3-F06 download --project computes recorded files in the real-path frame so an aliased project directory records its assets without a false files_outside_project warning tests/codex-review-round3.test.ts mirrors probes C01–C06 (plus relink abort unit tests and make interrupt); decisions D-045..D-050, migration notes §3.3, README and bundled SKILL.md updated. Co-Authored-By: Claude Fable 5.1 --- README.md | 14 +- docs/skill-parity/decisions.md | 73 ++++ docs/skill-parity/migration-notes.md | 11 + skills/meshy-cli/SKILL.md | 8 +- src/cmd/download.ts | 11 +- src/cmd/make.ts | 27 +- src/internal/download.ts | 126 +++++-- src/internal/errors.ts | 10 +- src/internal/material-links.ts | 113 +++++-- src/internal/task-command.ts | 28 +- tests/codex-review-round3.test.ts | 478 +++++++++++++++++++++++++++ 11 files changed, 837 insertions(+), 62 deletions(-) create mode 100644 tests/codex-review-round3.test.ts diff --git a/README.md b/README.md index 46c95a1..e80c2b2 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,15 @@ one JSON document (`ndjson` streams emit one line per event plus a final task; `result.downloads.files` is a per-file manifest (key, path, bytes, sha256, status). When the second asset fails, the state is `partial`, the files already written stay listed and on disk, and the error keeps the asset host's - class and HTTP status (a 503 is `network`, exit 7, not a local I/O error). + class and HTTP status (a 503 is `network`, exit 7, not a local I/O error). The + same holds for a failure *after* the transfers — relinking, digesting or + publishing the `meta.json` sidecar: `downloads.failed_step` names the step, the + manifest carries the digests actually on disk. The sidecar itself is published + like an asset (exclusive, no symlink, inside the root), so a file that appears + at its path during the download is never overwritten. The legacy schema + reports the same failures with additive `task_id`/`operation_id` fields and the + resume command as `hint`, so the accepted task is never lost from a default + `create -o` error. - Ctrl-C stops waiting, streaming or downloading (exit 130) and sends no DELETE; the envelope carries the task id, the command that resumes and the files that had already landed. An interrupted transfer leaves no temp file behind. @@ -364,7 +372,9 @@ actually saved (`model.mtl`, `texture_0_base_color.png`, …) so the model loads from that directory. Textures are matched by the name the server served them under (then by channel), one candidate only: with several material groups a reference that could mean two files is left as written and reported as -ambiguous. Every link is listed under `result.downloads.material_links` +ambiguous, and a reference that merely equals one of the CLI's generated names +(`texture_0_base_color.png`) while the server called that image something else +is ambiguous too — identity follows the source, never the file name on disk. Every link is listed under `result.downloads.material_links` (`status: complete | incomplete`, the `newmtl` group of each map), rewritten files carry `relinked: true` with their final sha256, and a reference that matches no or several downloaded files stays as written and is warned diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index 0887958..b67ca33 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -454,3 +454,76 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu and the one real-timer smoke asserts only the invariant a real clock can prove: no GET *starts* after the deadline. The subprocess tests with slow headers/bodies (R03) are kept. + +## D-045 The legacy sidecar is published like an asset + +- Codex review round 3 (R3-F01): `writeMeta` wrote `meta.json` / `_meta.json` + with a truncating `writeFileSync` after a preflight that could be minutes old, + so a symlink or file planted in the output directory during the transfer was + followed or overwritten — even outside `--workspace`. The sidecar now goes + through the same rules as every asset: the real path is re-proven inside the + root at publication time (symlink leaf refused), the JSON is published + exclusively and atomically (`writeJsonFile` → `link`), and an existing file, + symlink or directory is a `local_io` refusal that keeps the committed model in + the manifest. Preflight remains an early exit, never a substitute for the + publication check. `saveReportOnly` (directory mode) uses the same path. + +## D-046 Legacy-schema post-processing runs in the task's context + +- R3-F02: only the v1 `maybeDownloadV1` wrapped download failures in the task + context; the legacy reporter (`-o` on a default-schema `create`, `wait`, `get`, + `stream`, `make`) let the raw error escape, so a paid create whose asset host + answered 503 printed an error without the accepted task id. Every legacy + post-processing call is now wrapped with `withTaskContext` / `wrapWithResult`: + the legacy error payload keeps its shape (`name`, `message`, `code`, `status`, + `hint`, `result`) and gains additive `task_id` and `operation_id` fields, the + `result` carries the real `submission`, `next` and the partial manifest, and the + `hint` (printed on stderr) is the resume/download command, which names the task. + +## D-047 Source identity beats a generated file name + +- R3-F03: a saved-name match ran before the source-name match, so an MTL that + referenced `texture_1_base_color.png` — the *server's* name for the image the + CLI saved as `texture_0_base_color.png` — kept pointing at the wrong file and + was reported `unchanged`/`complete`. The order is now: the server-side source + name; then a saved file of that name *only if* its own source is unknown or the + same name (a generated name that belongs to a different source is an ambiguity + with an explanatory `note`); then source stem, channel word, MTL-key channel and + the single-texture rule. Channel rules additionally refuse to decide when two + distinct references compete for the only texture of that channel. The + conservative fallback without source evidence (unit callers) is unchanged. + +## D-048 Download finalisation shares the transfer failure handling + +- R3-F04: relink, digest refresh and sidecar publication ran outside the + per-artifact `try`, so a failure there reached the caller as a bare error and + the manifest collapsed to `files: []` although every asset was on disk. The + three steps now run under one handler (`finalisationFailure`): the thrown + CliError keeps the original class, re-takes every committed file's digest from + disk (a relink may have rewritten some), marks `relinked` from the digest + change, reports `downloads.state = "partial"` with the full `files` list and + names the step in `downloads.failed_step` (`relink` | `digest` | `sidecar`). + `downloadAssets` (`meshy download`) does the same for its relink step. + +## D-049 The material rewrite is cooperative with SIGINT + +- R3-F05: the abort signal stopped at the HTTP transfer; the asynchronous relink + that followed did not receive it, so a Ctrl-C during a multi-megabyte OBJ + rewrite printed "interrupted" and then exited 0 with a published sidecar. + `relinkMaterials` now takes the signal and `rewriteLines` checks it before the + first read, after every chunk and before publication, removing its temp file + and throwing `interrupted`; `downloadArtifacts`/`downloadAssets` check it again + before the sidecar. The result is exit 130 with the task id, `next`, the + committed files (digests re-taken) and `failed_step: "relink"`; the OBJ on disk + is either the original or the fully rewritten file, never a partial one. + +## D-050 Project file records are computed in the real-path frame + +- R3-F06: `download --project` compared the downloader's real paths with the + project directory as given, so a project reached through an alias (a symlinked + parent, macOS `/var` → `/private/var`) produced `../…` relative paths that were + filtered out: the asset was inside the project but `metadata.tasks[].files` + stayed empty and a false `files_outside_project` warning appeared. Both sides + are now resolved with `realpathLenient` before the containment test and the + relative path (as `saveTaskSnapshot` already did); the user-facing paths in the + result are unchanged. Files genuinely outside the project are still not recorded. diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index 785e831..bca9b42 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -142,6 +142,17 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | Ctrl-C during a task `-o` transfer | download completes, exit 0 | exit 130 `interrupted`, manifest of what landed, temp file removed | R2-F05 | | OAuth profile without `user_id` and the same `--operation-id` | replayed the other login's task | `login_id` minted at login binds the journal; profiles with neither are refused a replay (exit 2, `credential_unverified`) until `meshy auth login` | R2-F06 | +### 3.3 Corrections from Codex review round 3 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| a file/symlink/directory appears at `meta.json` (or `_meta.json`) during the transfer | followed or truncated, even outside `--workspace` | refused at publication (`local_io`), model kept in the manifest, nothing outside touched | R3-F01 | +| legacy-schema `-o` failure after a `create`/`wait`/`get`/`stream`/`make` | payload without the task | additive `task_id`/`operation_id` fields, `result.submission`/`next`/manifest, hint names the task on stderr | R3-F02 | +| MTL reference equal to a generated texture name that belongs to another source | rewritten to the wrong image, `complete` | mapped by the server-side name; a generated-name reference with a different source is `ambiguous` (kept, warned, `incomplete`) | R3-F03 | +| relink/digest/sidecar failure after every asset landed | `downloads.files: []` | `downloads.state=partial`, full manifest with on-disk digests, `failed_step` | R3-F04 | +| Ctrl-C during the OBJ/MTL rewrite | download completes, exit 0 | exit 130 `interrupted`, committed files kept, no sidecar, no temp file | R3-F05 | +| `download --project` through an alias path | files not recorded, false `files_outside_project` | files recorded relative to the real project directory | R3-F06 | + New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, `--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index e912203..7977690 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -170,8 +170,12 @@ Exit codes: `0` ok · `1` task FAILED/CANCELED while waiting, or unclassified · - Exit 9 → run `meshy balance`, relay the number, do not retry. - Exit 6 → back off; the CLI does not retry for you. - Exit 8 → `wait`/`stream` the same task id again; it was not cancelled. -- Exit 130 after `-o` → the transfer was cancelled; `result.downloads.files` lists what - already landed (`status: written`), the rest can be fetched with `meshy download`. +- Exit 130 after `-o` → the transfer (or the material rewrite) was cancelled; + `result.downloads.files` lists what already landed (`status: written`), the rest can + be fetched with `meshy download`. `downloads.failed_step` (`relink` | `digest` | + `sidecar`) means every asset is on disk and only that step is missing. +- Legacy-schema errors (no `--output-schema v1`) after a create carry additive + `task_id` / `operation_id` fields and a `hint` with the resume command — never re-create. - `result.downloads.material_links.status: "incomplete"` → name the references that stayed unresolved or ambiguous (`texture_maps[].method`) instead of claiming the model loads. - Exit 10 → reconcile with ` list`; never submit the same create again blindly. diff --git a/src/cmd/download.ts b/src/cmd/download.ts index 7543cf1..ccebbc9 100644 --- a/src/cmd/download.ts +++ b/src/cmd/download.ts @@ -22,7 +22,7 @@ import { emitResult, openCommand, saveRawJson, type OpenedCommand } from "../int import { abortSignal } from "../internal/context.js"; import { downloadAssets, type DownloadedFile } from "../internal/download.js"; import { CliError, UsageError, type Warning } from "../internal/errors.js"; -import { resolveWithinRoot, safeSegment } from "../internal/paths.js"; +import { realpathLenient, resolveWithinRoot, safeSegment } from "../internal/paths.js"; import { warning } from "../internal/result.js"; import { indexRootFor, recordTask, stageFromTaskType } from "../internal/project-store.js"; import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; @@ -265,7 +265,14 @@ export const downloadCommand = new Command("download") let project: Record | null = null; if (projectDir && task) { const taskId = String(task["id"] ?? ""); - const files = result.files.filter((f) => f.status === "written").map((f) => relative(projectDir, f.path).split(/[\\/]/).join("/")).filter((f) => !f.startsWith("..")); + // Compare in one real-path frame: the project may be reached through an + // alias (a symlinked parent, macOS /var → /private/var) while the + // downloader reports real paths; the recorded name is relative to the real project. + const projectReal = realpathLenient(projectDir); + const files = result.files + .filter((f) => f.status === "written") + .map((f) => relative(projectReal, realpathLenient(f.path)).split(/[\\/]/).join("/")) + .filter((f) => f.length > 0 && !f.startsWith("..") && !f.startsWith("/")); const indexRoot = indexRootFor(projectDir, undefined, opened.flags.workspace); const rec = recordTask(projectDir, { taskId, diff --git a/src/cmd/make.ts b/src/cmd/make.ts index 086285f..ce6465a 100644 --- a/src/cmd/make.ts +++ b/src/cmd/make.ts @@ -311,12 +311,33 @@ async function finalOutcome( executed: ExecutedStep[], warnings: Warning[], ): Promise { + const descriptor = requireTaskResource(step.resource); + const submission = { state: "accepted", operation_id: executed[executed.length - 1]?.operation_id ?? null, task_id: task.id }; if (opened.schema !== "v1") { - await emitTerminalOutcome(task, false, elapsed, step.resource, runtime); + // Legacy shape, same rule as the resource commands: a download failure + // after the chain succeeded still names the task, its submission and the + // command that fetches the assets again. + try { + await emitTerminalOutcome(task, false, elapsed, step.resource, runtime); + } catch (err) { + const wrapped = wrapWithResult(err, { route: plan.route, executed, task_id: task.id, submission, next: taskNextCommands(descriptor, task.id) }); + throw new CliError({ + code: wrapped.code, + message: wrapped.message, + exitCode: wrapped.exitCode, + httpStatus: wrapped.httpStatus, + retryable: wrapped.retryable, + recovery: wrapped.recovery ?? { action: "download", automatic: false, command: `meshy download --resource ${step.resource} --task-id ${task.id} --all --output-dir ` }, + hint: wrapped.hint ?? wrapped.recovery?.command ?? `meshy download --resource ${step.resource} --task-id ${task.id} --all --output-dir `, + details: wrapped.details, + warnings: wrapped.warnings, + result: wrapped.result, + cause: err, + }); + } return; } - const descriptor = requireTaskResource(step.resource); - const base = buildTaskResult({ task, raw, descriptor, includeRaw: false, submission: { state: "accepted", operation_id: null, task_id: task.id } }); + const base = buildTaskResult({ task, raw, descriptor, includeRaw: false, submission }); let downloads = base.downloads as Record; if (runtime.flags.output) { try { diff --git a/src/internal/download.ts b/src/internal/download.ts index 24f423b..28ed013 100644 --- a/src/internal/download.ts +++ b/src/internal/download.ts @@ -21,7 +21,7 @@ */ import { createHash } from "node:crypto"; -import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs"; import { Transform } from "node:stream"; import { basename, dirname, extname, join, relative, resolve as resolvePath } from "node:path"; import { Readable } from "node:stream"; @@ -410,10 +410,41 @@ export async function downloadAssets(assets: readonly Asset[], opts: DownloadAss opts.onFile?.(entry); } const sources = new Map(assets.map((a) => [a.key, a.url ? basenameOfUrl(a.url) : null] as const)); - const materialLinks = await relinkWritten(files, warnings, sources); + let materialLinks: MaterialLinkReport | null = null; + try { + if (opts.signal?.aborted) throw new CliError({ code: "interrupted", message: "interrupted before the material references were relinked" }); + materialLinks = await relinkWritten(files, warnings, sources, opts.signal); + } catch (err) { + // Every file is on disk; say so, with the bytes actually there. + redigest(files); + const interrupted = (err instanceof CliError && err.code === "interrupted") || Boolean(opts.signal?.aborted); + throw new CliError({ + code: interrupted ? "interrupted" : err instanceof CliError ? err.code : "local_io", + message: `${files.length} file(s) were written but relinking the material references ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`, + httpStatus: err instanceof CliError ? err.httpStatus : null, + result: { downloads: { state: "partial", files, metadata_path: null, failed_step: "relink" } }, + warnings, + cause: err, + }); + } return { files, complete: files.every((f) => f.status === "written"), warnings, materialLinks }; } +/** Re-take each committed file's digest from disk (a relink may have rewritten it before a later step failed). */ +function redigest(files: Array<{ status: string; path: string; bytes: number; sha256: string; relinked: boolean }>): void { + for (const f of files) { + if (f.status !== "written") continue; + try { + const d = fileDigest(f.path); + if (d.sha256 !== f.sha256) f.relinked = true; + f.bytes = d.bytes; + f.sha256 = d.sha256; + } catch { + /* keep the recorded digest */ + } + } +} + /** Last path segment of an asset URL (decoded), the name the server knew the file by; null when unparseable. */ export function basenameOfUrl(url: string): string | null { try { @@ -431,9 +462,9 @@ export function basenameOfUrl(url: string): string | null { } /** After the set landed: point OBJ → MTL → textures at the saved names and re-take the digests of rewritten files. */ -async function relinkWritten(files: DownloadedFile[], warnings: Array<{ code: string; message: string }>, sources: Map): Promise { +async function relinkWritten(files: DownloadedFile[], warnings: Array<{ code: string; message: string }>, sources: Map, signal: AbortSignal | undefined): Promise { const written = files.filter((f) => f.status === "written" && f.container_format === null); - const links = await relinkMaterials(written.map((f) => ({ key: f.key, path: f.path, sourceName: sources.get(f.key) ?? null }))); + const links = await relinkMaterials(written.map((f) => ({ key: f.key, path: f.path, sourceName: sources.get(f.key) ?? null })), { signal }); if (!links) return null; for (const path of links.rewritten) { const entry = files.find((f) => f.path === path); @@ -670,7 +701,36 @@ export interface DownloadArtifactsOptions { } function interruptedBefore(what: string): CliError { - return new CliError({ code: "interrupted", message: `interrupted before ${what} was downloaded; nothing further was fetched` }); + return new CliError({ code: "interrupted", message: `interrupted before ${what}; nothing further was written` }); +} + +/** + * A failure *after* every transfer landed (relink, digest refresh, sidecar). + * The manifest keeps every committed file with the bytes actually on disk — + * a relink may already have rewritten some — and names the step that failed; + * a cooperative interrupt is `interrupted` (130), everything else keeps its class. + */ +function finalisationFailure(step: "relink" | "digest" | "sidecar", err: unknown, files: LegacyDownloadedFile[], signal: AbortSignal | undefined): CliError { + redigest(files); + const downloads = { state: "partial", files, metadata_path: null, failed_step: step }; + const written = files.filter((f) => f.status === "written").length; + const interrupted = (err instanceof CliError && err.code === "interrupted") || Boolean(signal?.aborted); + const message = `${written} file(s) were written but the ${step} step ${interrupted ? "was interrupted" : "failed"}: ${err instanceof Error ? err.message : String(err)}`; + if (err instanceof CliError) { + return new CliError({ + code: interrupted ? "interrupted" : err.code, + message, + httpStatus: err.httpStatus, + retryable: err.retryable, + recovery: err.recovery, + hint: err.hint, + details: err.details, + warnings: err.warnings, + result: { ...(err.result ?? {}), downloads }, + cause: err, + }); + } + return new CliError({ code: interrupted ? "interrupted" : "local_io", message, result: { downloads }, cause: err }); } /** @@ -764,7 +824,7 @@ export async function downloadArtifacts( const files: LegacyDownloadedFile[] = []; const saved: string[] = []; for (const { artifact, target } of plan) { - if (opts.signal?.aborted) throw downloadFailure(artifact, interruptedBefore(artifact.key), files); + if (opts.signal?.aborted) throw downloadFailure(artifact, interruptedBefore(`${artifact.key} was downloaded`), files); try { const placed = await downloadArtifact(artifact, target, root, opts.signal); saved.push(placed.path); @@ -774,20 +834,36 @@ export async function downloadArtifacts( throw downloadFailure(artifact, err, files); } } - if (opts.signal?.aborted) throw downloadFailure(plan[plan.length - 1]!.artifact, new CliError({ code: "interrupted", message: "interrupted before the material references were relinked and the sidecar written" }), files); - const materialLinks = await relinkMaterials(plan.map((p, i) => ({ key: p.artifact.key, path: resolvePath(saved[i]!), sourceName: basenameOfUrl(p.artifact.url) }))); - if (materialLinks) { - for (const path of materialLinks.rewritten) { - const entry = files.find((f) => resolvePath(f.path) === path); - if (!entry) continue; - const digest = fileDigest(path); - entry.bytes = digest.bytes; - entry.sha256 = digest.sha256; - entry.relinked = true; + // --- Finalisation: relink, refresh digests, publish the sidecar. Every step + // runs under the same failure handling as the transfers: whatever fails or is + // interrupted, the manifest still lists every committed file with the bytes + // actually on disk, and the sidecar is published like an asset (root re-proven + // at publication, symlink refused, exclusive — never truncating a file that + // appeared since the preflight). + const linkables = plan.map((p, i) => ({ key: p.artifact.key, path: resolvePath(saved[i]!), sourceName: basenameOfUrl(p.artifact.url) })); + let step: "relink" | "digest" | "sidecar" = "relink"; + let materialLinks: MaterialLinkReport | null = null; + try { + if (opts.signal?.aborted) throw interruptedBefore("the material references were relinked"); + materialLinks = await relinkMaterials(linkables, { signal: opts.signal }); + step = "digest"; + if (materialLinks) { + for (const path of materialLinks.rewritten) { + const entry = files.find((f) => resolvePath(f.path) === path); + if (!entry) continue; + const digest = fileDigest(path); + entry.bytes = digest.bytes; + entry.sha256 = digest.sha256; + entry.relinked = true; + } + for (const w of materialLinks.warnings) logger.warn(w.message); } - for (const w of materialLinks.warnings) logger.warn(w.message); + step = "sidecar"; + if (opts.signal?.aborted) throw interruptedBefore("the sidecar was written"); + writeMeta(task, resource, metadataPath, saved, root); + } catch (err) { + throw finalisationFailure(step, err, files, opts.signal); } - writeMeta(task, resource, metadataPath, saved); return { savedFiles: saved, metadataPath, materialLinks, files }; } @@ -905,19 +981,25 @@ function saveReportOnly(task: Task, outputPath: string, resource: string, worksp resolveWithinRoot(metadataPath, workspaceReal, { label: "planned download path" }); } mkdirSync(abs, { recursive: true }); - writeMeta(task, resource, metadataPath, []); + writeMeta(task, resource, metadataPath, [], workspaceReal ?? realpathLenient(abs)); return { savedFiles: [], metadataPath, materialLinks: null, files: [] }; } -function writeMeta(task: Task, resource: string, path: string, savedFiles: string[]): void { +/** + * The legacy sidecar (`meta.json` / `_meta.json`), published under the + * same rules as an asset: the real path is re-proven inside `root` at + * publication time (a symlink or file that appeared since the preflight is + * refused, never followed or truncated) and the write is exclusive and atomic. + */ +function writeMeta(task: Task, resource: string, path: string, savedFiles: string[], root: string): void { const meta = { resource, task, saved_files: savedFiles, downloaded_at: new Date().toISOString(), }; - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(meta, null, 2)}\n`, "utf8"); + const resolved = resolveWithinRoot(path, root, { label: "sidecar path" }).path; + writeJsonFile(resolved, meta, { overwrite: false, mode: 0o644 }); } export { safeSegment as _safeSegmentForTests }; diff --git a/src/internal/errors.ts b/src/internal/errors.ts index e62ca30..e65bb04 100644 --- a/src/internal/errors.ts +++ b/src/internal/errors.ts @@ -373,13 +373,21 @@ export function toErrorPayload(err: unknown): { name: string; message: string; [ return { ...err.toJSON(), ...hintForApiError(err), name: err.name, message: err.message }; } if (err instanceof CliError) { + // Additive convenience fields: a legacy consumer reading an error after a + // create must find the accepted task without digging into `result`. + const result = err.result ?? undefined; + const taskId = typeof result?.["task_id"] === "string" ? (result["task_id"] as string) : undefined; + const submission = result?.["submission"] as { operation_id?: unknown } | undefined; + const operationId = typeof submission?.operation_id === "string" ? submission.operation_id : undefined; return { name: err.name, message: err.message, code: err.code, ...(err.httpStatus !== null ? { status: err.httpStatus } : {}), ...(err.hint ? { hint: err.hint } : {}), - ...(err.result ? { result: err.result } : {}), + ...(taskId ? { task_id: taskId } : {}), + ...(operationId ? { operation_id: operationId } : {}), + ...(result ? { result } : {}), }; } if (err instanceof CommanderError) { diff --git a/src/internal/material-links.ts b/src/internal/material-links.ts index 54c7f82..10ca990 100644 --- a/src/internal/material-links.ts +++ b/src/internal/material-links.ts @@ -12,15 +12,21 @@ * rewritten paths are listed, and their digests are re-taken by the caller. * * A texture reference is resolved only when exactly one downloaded texture - * matches, in this order: the saved file name itself; the name the server - * served the texture under (the URL's last segment — `body.png` for - * `…/body.png`); the same name ignoring extension and directories; a channel - * word inside the referenced name (…_normal.png); the channel implied by the - * MTL key (map_Kd → base color); finally "the only texture there is" when the - * MTL has exactly one distinct reference. Several candidates for the same rule - * is an *ambiguity*: the reference stays as written, the candidates are listed - * and the report is `incomplete` — the CLI never picks the first of several - * material groups' textures. Unresolved references are reported the same way. + * matches, in this order: the name the server served a texture under (the + * URL's last segment — `body.png` for `…/body.png`), which is the only evidence + * of *which* image the MTL meant; a saved file of that name, but only when it + * is not known to come from a different source (the CLI's generated names + * `texture__` can collide with a server-side name of another + * texture — that is an ambiguity, not a match); the same name ignoring + * extension and directories; a channel word inside the referenced name + * (…_normal.png); the channel implied by the MTL key (map_Kd → base color); + * finally "the only texture there is" when the MTL has exactly one distinct + * reference. Several candidates for the same rule is an *ambiguity*: the + * reference stays as written, the candidates are listed and the report is + * `incomplete` — the CLI never picks the first of several material groups' + * textures. Unresolved references are reported the same way. The whole pass is + * cooperative: an abort signal stops it before the next read, write or + * publication, leaving no temp file behind. */ import { createHash } from "node:crypto"; @@ -67,6 +73,8 @@ export interface ReferenceLink { method: LinkMethod; /** Saved names that matched when the reference was ambiguous. */ candidates?: string[]; + /** Why an apparently matching saved name was not accepted (identity conflict). */ + note?: string; } export interface TextureDescriptor { @@ -213,7 +221,8 @@ export function fileDigest(path: string): { bytes: number; sha256: string } { * callback returns the replacement line (without its terminator) or null to * keep the line. Returns true when the file was actually replaced. */ -async function rewriteLines(path: string, transform: (line: string, lineNo: number) => string | null): Promise { +async function rewriteLines(path: string, transform: (line: string, lineNo: number) => string | null, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new CliError({ code: "interrupted", message: `interrupted before ${basename(path)} was rewritten` }); const tmp = tempPathFor(path); const out = createWriteStream(tmp, { flags: "wx" }); let writeError: Error | null = null; @@ -246,6 +255,10 @@ async function rewriteLines(path: string, transform: (line: string, lineNo: numb try { const stream = createReadStream(path, { highWaterMark: 256 * 1024 }); for await (const chunk of stream) { + if (signal?.aborted) { + stream.destroy(); + throw new CliError({ code: "interrupted", message: `interrupted while rewriting ${basename(path)}` }); + } carry += decoder.write(chunk as Buffer); let start = 0; let nl: number; @@ -277,12 +290,13 @@ async function rewriteLines(path: string, transform: (line: string, lineNo: numb if (err instanceof CliError) throw err; throw new CliError({ code: "local_io", message: `failed to rewrite ${path}: ${err instanceof Error ? err.message : String(err)}`, cause: err }); } - if (!changed) { + if (!changed || signal?.aborted) { try { unlinkSync(tmp); } catch { /* gone */ } + if (signal?.aborted) throw new CliError({ code: "interrupted", message: `interrupted before the rewritten ${basename(path)} was published; the original is untouched` }); return false; } publishTempFile(tmp, path, { overwrite: true }); @@ -317,7 +331,7 @@ function parseMapLine(line: string): { indent: string; key: string; options: str return { indent, key, options: "", ref: rest }; } -type Resolution = { kind: "hit"; name: string; method: LinkMethod } | { kind: "ambiguous"; method: LinkMethod; candidates: string[] } | { kind: "none" }; +type Resolution = { kind: "hit"; name: string; method: LinkMethod } | { kind: "ambiguous"; method: LinkMethod; candidates: string[]; note?: string } | { kind: "none" }; /** Apply one rule: exactly one candidate resolves, several are an ambiguity, none falls through. */ function pick(candidates: TextureDescriptor[], method: LinkMethod): Resolution | null { @@ -326,26 +340,61 @@ function pick(candidates: TextureDescriptor[], method: LinkMethod): Resolution | return null; } -function resolveTextureReference(key: string, ref: string, textures: TextureDescriptor[], distinctRefs: number): Resolution { +/** Channel a map line speaks about: a channel word in the referenced name, else the MTL key's channel. */ +function channelOfMapLine(key: string, ref: string): string | null { + return channelInFileName(basename(ref.replaceAll("\\", "/"))) ?? MAP_KEY_CHANNEL[key.toLowerCase()] ?? null; +} + +/** + * Channel-based rules may only decide when one distinct reference speaks about + * that channel: two materials both wanting "the base color" while a single base + * color texture was downloaded is a choice the CLI must not make. + */ +function pickByChannel(channel: string, textures: TextureDescriptor[], competing: Map>, method: LinkMethod): Resolution | null { + const candidates = textures.filter((t) => t.channel === channel); + if (candidates.length === 0) return null; + if (candidates.length > 1) return { kind: "ambiguous", method: "ambiguous", candidates: candidates.map((c) => c.name) }; + const refs = competing.get(channel); + if (refs && refs.size > 1) { + return { kind: "ambiguous", method: "ambiguous", candidates: candidates.map((c) => c.name), note: `${refs.size} different references (${[...refs].map((r) => `'${r}'`).join(", ")}) all point at the only ${channel} texture, ${candidates[0]!.name}` }; + } + return { kind: "hit", name: candidates[0]!.name, method }; +} + +function resolveTextureReference(key: string, ref: string, textures: TextureDescriptor[], distinctRefs: number, competing: Map>): Resolution { const refBase = basename(ref.replaceAll("\\", "/")); const lower = refBase.toLowerCase(); - const exact = textures.filter((t) => t.name === refBase) ; - if (exact.length === 1) return { kind: "hit", name: exact[0]!.name, method: ref === refBase ? "unchanged" : "exact" }; - const exactCi = pick(textures.filter((t) => t.name.toLowerCase() === lower), "exact"); - if (exactCi) return exactCi; + // 1. The name the server served a texture under is the only evidence of which image the MTL meant. const bySource = pick(textures.filter((t) => t.source_name !== null && t.source_name.toLowerCase() === lower), "source_name"); if (bySource) return bySource; + // 2. A saved file of that name — unless it is known to come from a different + // source: the CLI's generated names can collide with another texture's + // server-side name, and "the file exists" says nothing about its identity. + const named = textures.filter((t) => t.name.toLowerCase() === lower); + if (named.length === 1) { + const t = named[0]!; + if (t.source_name === null || t.source_name.toLowerCase() === lower) { + return { kind: "hit", name: t.name, method: t.name === ref ? "unchanged" : "exact" }; + } + return { + kind: "ambiguous", + method: "ambiguous", + candidates: [t.name], + note: `'${refBase}' is the CLI's name for a texture the server served as '${t.source_name}', so it cannot be the file this reference meant`, + }; + } + if (named.length > 1) return { kind: "ambiguous", method: "ambiguous", candidates: named.map((t) => t.name) }; const stem = stemOf(refBase); const byStem = pick(textures.filter((t) => t.source_name !== null && stemOf(t.source_name) === stem), "source_stem"); if (byStem) return byStem; const inName = channelInFileName(refBase); if (inName) { - const r = pick(textures.filter((t) => t.channel === inName), "channel_in_name"); + const r = pickByChannel(inName, textures, competing, "channel_in_name"); if (r) return r; } const ofKey = MAP_KEY_CHANNEL[key.toLowerCase()]; if (ofKey) { - const r = pick(textures.filter((t) => t.channel === ofKey), "channel_of_key"); + const r = pickByChannel(ofKey, textures, competing, "channel_of_key"); if (r) return r; } if (textures.length === 1 && distinctRefs === 1) return { kind: "hit", name: textures[0]!.name, method: "only_texture" }; @@ -358,9 +407,11 @@ function resolveTextureReference(key: string, ref: string, textures: TextureDesc * ambiguous references; those become warnings, `resolved_to: null` entries * and `status: "incomplete"`. */ -export async function relinkMaterials(files: readonly LinkableFile[]): Promise { +export async function relinkMaterials(files: readonly LinkableFile[], opts: { signal?: AbortSignal } = {}): Promise { + const signal = opts.signal; const obj = files.find((f) => isObjKey(f.key) && /\.obj$/i.test(f.path)); if (!obj) return null; + if (signal?.aborted) throw new CliError({ code: "interrupted", message: "interrupted before the material references were relinked" }); if (looksBinary(obj.path)) return null; const mtl = files.find((f) => isMtlKey(f.key)) ?? null; const textures: TextureDescriptor[] = files @@ -398,7 +449,7 @@ export async function relinkMaterials(files: readonly LinkableFile[]): Promise 0) { report.status = "incomplete"; @@ -412,6 +463,7 @@ export async function relinkMaterials(files: readonly LinkableFile[]): Promise(); + // Which distinct references speak about each channel — a single texture + // cannot serve two different references. + const competing = new Map>(); for (const raw of readFileSync(mtl.path, "utf8").split(/\r?\n/)) { const parsed = parseMapLine(raw); - if (parsed) distinctRefs.add(parsed.ref); + if (!parsed) continue; + distinctRefs.add(parsed.ref); + const channel = channelOfMapLine(parsed.key, parsed.ref); + if (channel) { + if (!competing.has(channel)) competing.set(channel, new Set()); + competing.get(channel)!.add(parsed.ref); + } } let material: string | null = null; const mtlChanged = await rewriteLines(mtl.path, (line, lineNo) => { @@ -437,19 +498,19 @@ export async function relinkMaterials(files: readonly LinkableFile[]): Promise l.method === "ambiguous"); const unresolved = report.texture_maps.filter((l) => l.method === "unresolved"); @@ -458,7 +519,7 @@ export async function relinkMaterials(files: readonly LinkableFile[]): Promise `'${l.reference}'${l.material ? ` (${l.material})` : ""} could be ${l.candidates!.join(" or ")}`).join("; ")}; the references stay as written — the CLI does not guess between material groups`, + `${basename(mtl.path)}: ${ambiguous.map((l) => (l.note ? `${l.note}${l.material ? ` (${l.material})` : ""}` : `'${l.reference}'${l.material ? ` (${l.material})` : ""} could be ${l.candidates!.join(" or ")}`)).join("; ")}; the references stay as written — the CLI does not guess between material groups or sources`, ), ); } diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index e6ef433..d8d0b59 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -116,6 +116,8 @@ export interface DownloadOutcome { metadata_path: string | null; /** OBJ/MTL/texture reference report when the download contained a text OBJ. */ material_links?: MaterialLinkReport | null; + /** Set when the transfers landed but a later step (relink | digest | sidecar) failed or was interrupted. */ + failed_step?: string; } export interface SubmissionInfo { @@ -236,7 +238,7 @@ function withTaskContext(err: unknown, ctx: TaskContext): CliError { httpStatus: wrapped.httpStatus, retryable: wrapped.retryable, recovery: wrapped.recovery, - hint: wrapped.hint, + hint: wrapped.hint ?? wrapped.recovery?.command ?? next.wait, details: wrapped.details, warnings: wrapped.warnings, result: { ...base, ...own, task_id: ctx.taskId, next }, @@ -509,7 +511,12 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { ); return; } - await emitLegacyOutcome(task, false, undefined, spec.name, runtime, { query: true }); + // Legacy shape, same rule: a download failure still names the task. + try { + await emitLegacyOutcome(task, false, undefined, spec.name, runtime, { query: true }); + } catch (err) { + throw withTaskContext(err, { ...ctx, savedJson, extra: project ? { project } : {} }); + } }, ); @@ -945,7 +952,14 @@ async function waitAndReport( const projectExtra = project ? { project } : {}; if (opened.schema !== "v1") { - await emitLegacyOutcome(task, timedOut, elapsed, descriptor.id, runtime, { query: false }); + // The legacy reporter downloads too; whatever fails there — an asset host + // 503, a sidecar that cannot be published, Ctrl-C — the error still + // carries the accepted task id, the real submission and the resume command. + try { + await emitLegacyOutcome(task, timedOut, elapsed, descriptor.id, runtime, { query: false }); + } catch (err) { + throw withTaskContext(err, { ...ctx, savedJson, extra: { wait: waitInfo, ...projectExtra } }); + } return; } @@ -1110,7 +1124,13 @@ async function streamAndReport( if (opened.schema !== "v1") { // Legacy has no stream shape to preserve: reuse the terminal summary path (which honours -o itself). if (finalError) throw finalError; - if (outcome.task) await emitLegacyOutcome(outcome.task, false, outcome.elapsedMs / 1000, descriptor.id, runtime, { query: false }); + if (outcome.task) { + try { + await emitLegacyOutcome(outcome.task, false, outcome.elapsedMs / 1000, descriptor.id, runtime, { query: false }); + } catch (err) { + throw withTaskContext(err, { ...ctx, savedJson, extra: { stream: streamInfo, ...projectExtra } }); + } + } return; } diff --git a/tests/codex-review-round3.test.ts b/tests/codex-review-round3.test.ts new file mode 100644 index 0000000..30c8d53 --- /dev/null +++ b/tests/codex-review-round3.test.ts @@ -0,0 +1,478 @@ +/** + * Codex review round 3 (reviews/cli-s1-cf8905d, R3-F01–R3-F06) as positive + * regressions. Each scenario mirrors the reviewer's probe (C01–C06) with real + * subprocesses, a loopback API/asset host that records every request, synthetic + * credentials and isolated temp directories, and asserts the required outcome: + * the sidecar is published like an asset (no overwrite, no new symlink, root + * re-proven at publication), every failure after the transfers keeps the + * per-file manifest with the bytes actually on disk, Ctrl-C during the material + * rewrite is an interrupt, legacy-schema errors still name the accepted task, + * texture identity follows the server-side name even when it collides with a + * generated one, and an aliased project path still records its files. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import sharp from "sharp"; +import { CliError } from "../src/internal/errors.js"; +import { relinkMaterials } from "../src/internal/material-links.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; + +function taskBody(fields: Record = {}): Record { + return { id: "round3-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function tmpFiles(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).filter((f) => f.includes(".tmp-")) : []; +} + +function journalOperationIds(configDir: string): string[] { + const ops = join(configDir, "operations"); + return readdirSync(ops) + .filter((f) => f.endsWith(".json")) + .map((f) => (JSON.parse(readFileSync(join(ops, f), "utf8")) as { operation_id: string; state: string; task_id: string | null }).operation_id); +} + +interface LegacyErrorPayload { + code: string; + status?: number; + hint?: string; + task_id?: string; + operation_id?: string; + result?: { task_id?: string; submission?: { state: string; operation_id: string | null; task_id?: string }; next?: { wait: string; get: string }; downloads?: { state: string; files: Array<{ key: string; status: string }>; failed_step?: string } }; +} + +// --------------------------------------------------------------------------- +// R3-F01 / C01 + R3-F04 / C02 — the sidecar is published like an asset +// --------------------------------------------------------------------------- + +test("C01+C02/R3-F01,R3-F04 sidecar targets planted after the preflight (symlink, file, directory) are refused; outside bytes untouched; the model manifest survives", async () => { + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/asset.glb") { + // Another process changes the sidecar target between preflight and publication. + plant?.(); + plant = null; + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + const outside = join(dir, "outside-user-file.json"); + writeFileSync(outside, "original-user-data"); + const env = api.env(); + + const cases: Array<[string, (out: string) => void, RegExp]> = [ + ["symlink", (out) => symlinkSync(outside, join(out, "meta.json")), /symbolic link/], + ["file", (out) => writeFileSync(join(out, "meta.json"), "keep me"), /refusing to overwrite/], + ["directory", (out) => mkdirSync(join(out, "meta.json")), /refusing to overwrite|EEXIST|EISDIR/], + ]; + for (const [label, planter, re] of cases) { + const out = join(workspace, `race-${label}`); + plant = () => { + mkdirSync(out, { recursive: true }); + planter(out); + }; + const r = await runCli(["text-to-3d", "get", "round3-task", ...V1, "--workspace", workspace, "-o", out], { env, cwd: dir }); + assert.equal(r.code, 11, `${label}: ${r.stderr}\n${r.stdout}`); + const env1 = parseSingleJson(r.stdout) as { error: { code: string; message: string }; result: { task_id: string; downloads: { state: string; failed_step: string; files: Array<{ key: string; path: string; status: string; sha256: string }> } } }; + assert.equal(env1.error.code, "local_io"); + assert.match(env1.error.message, re, `${label}: the refusal names its cause`); + assert.equal(env1.result.task_id, "round3-task"); + assert.equal(env1.result.downloads.state, "partial"); + assert.equal(env1.result.downloads.failed_step, "sidecar"); + assert.deepEqual(env1.result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "written"]]); + const model = env1.result.downloads.files[0]!; + assert.ok(existsSync(model.path), `${label}: the committed model is not rolled back`); + assert.equal(sha(model.path), model.sha256); + assert.equal(readFileSync(outside, "utf8"), "original-user-data", `${label}: nothing outside the workspace changed`); + if (label === "file") assert.equal(readFileSync(join(out, "meta.json"), "utf8"), "keep me", "the planted file keeps its content"); + assert.deepEqual(tmpFiles(out), [], `${label}: no temp file left behind`); + } + assert.equal(api.requests.filter((q) => q.method !== "GET").length, 0); + + // Single-file mode: the per-file sidecar (`_meta.json`) follows the same rule. + const single = join(workspace, "single.glb"); + plant = () => symlinkSync(outside, join(workspace, "single_meta.json")); + const s = await runCli(["text-to-3d", "get", "round3-task", ...V1, "--workspace", workspace, "-o", single], { env, cwd: dir }); + assert.equal(s.code, 11, `${s.stderr}\n${s.stdout}`); + assert.equal(readFileSync(outside, "utf8"), "original-user-data"); + assert.ok(existsSync(single), "the model itself was published"); + // Legacy schema: same refusal, the task stays discoverable. + const legacyOut = join(workspace, "race-legacy"); + plant = () => { + mkdirSync(legacyOut, { recursive: true }); + symlinkSync(outside, join(legacyOut, "meta.json")); + }; + const legacy = await runCli(["text-to-3d", "get", "round3-task", "--workspace", workspace, "-o", legacyOut], { env, cwd: dir }); + assert.equal(legacy.code, 11, legacy.stderr); + const lp = parseSingleJson(legacy.stdout) as LegacyErrorPayload; + assert.equal(lp.code, "local_io"); + assert.equal(lp.task_id, "round3-task"); + assert.equal(lp.result?.downloads?.failed_step, "sidecar"); + assert.deepEqual(lp.result?.downloads?.files.map((f) => [f.key, f.status]), [["model_glb", "written"]]); + assert.equal(readFileSync(outside, "utf8"), "original-user-data"); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R3-F05 / C03 — SIGINT during the material rewrite +// --------------------------------------------------------------------------- + +const BIG_OBJ = `mtllib original.mtl\n${"v 0.123456 0.654321 0.111111\n".repeat(400_000)}f 1 2 3\n`; + +test("C03/R3-F05 SIGINT while the OBJ is being relinked exits 130, keeps the committed files and their real digests, publishes no sidecar, leaves no temp file", async () => { + let child: ChildProcess | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/large.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + return void res.end(BIG_OBJ); + } + if (req.path === "/original.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + return void res.end("newmtl a\nKd 1 0 0\n"); + } + if (req.method !== "GET") return jsonReply(res, 500, { message: "unexpected" }); + return jsonReply(res, 200, taskBody({ model_urls: { obj: `${api.url}/large.obj`, mtl: `${api.url}/original.mtl` } })); + }); + try { + const dir = tmpDir(); + const out = join(dir, "relink-interrupt"); + mkdirSync(out); + let signalSent = false; + // Fire once the relink pass has started: the rewrite goes through a temp + // file beside model.obj, and both downloads must already be on disk. + const poll = setInterval(() => { + if (signalSent || !existsSync(out)) return; + const names = readdirSync(out); + if (names.some((n) => n.startsWith(".model.obj.tmp-")) && names.includes("model.mtl")) { + signalSent = true; + child?.kill("SIGINT"); + } + }, 1); + const r = await runCli(["text-to-3d", "get", "round3-task", ...V1, "-o", out], { env: api.env(), cwd: dir, onSpawn: (c) => (child = c), timeoutMs: 60_000 }); + clearInterval(poll); + assert.ok(signalSent, "the test caught the relink phase (temp file observed)"); + assert.equal(r.code, 130, `${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as { ok: boolean; error: { code: string }; result: { task_id: string; next: { get: string }; downloads: { state: string; failed_step: string; files: Array<{ key: string; path: string; status: string; sha256: string; bytes: number; relinked: boolean }> } } }; + assert.equal(env.ok, false); + assert.equal(env.error.code, "interrupted"); + assert.equal(env.result.task_id, "round3-task"); + assert.equal(env.result.downloads.state, "partial"); + assert.equal(env.result.downloads.failed_step, "relink"); + assert.deepEqual(env.result.downloads.files.map((f) => [f.key, f.status]), [["model_obj", "written"], ["model_mtl", "written"]]); + for (const f of env.result.downloads.files) { + assert.ok(existsSync(f.path), `${f.key} stays on disk`); + assert.equal(sha(f.path), f.sha256, `${f.key}: manifest digest is the file actually on disk`); + assert.equal(readFileSync(f.path).length, f.bytes); + } + const obj = readFileSync(join(out, "model.obj"), "utf8"); + assert.ok(obj === BIG_OBJ || obj === BIG_OBJ.replace("mtllib original.mtl", "mtllib model.mtl"), "the OBJ is either the original or the fully rewritten file, never a partial one"); + assert.ok(!existsSync(join(out, "meta.json")), "no sidecar after an interrupt"); + assert.deepEqual(tmpFiles(out), [], "the unfinished temp file was removed"); + assert.equal(api.requests.filter((q) => q.method !== "GET").length, 0, "no DELETE, no POST"); + } finally { + await api.close(); + } +}); + +test("R3-F05 relinkMaterials is cooperative: an aborted signal stops before any read/write and mid-way, leaving the originals intact and no temp files", async () => { + const dir = tmpDir(); + const objPath = join(dir, "model.obj"); + const mtlPath = join(dir, "model.mtl"); + writeFileSync(objPath, BIG_OBJ); + writeFileSync(mtlPath, "newmtl a\nmap_Kd tex.png\n"); + const before = sha(objPath); + const files = [ + { key: "model_obj", path: objPath, sourceName: "large.obj" }, + { key: "model_mtl", path: mtlPath, sourceName: "original.mtl" }, + ]; + // Already aborted: nothing is touched. + const done = new AbortController(); + done.abort(); + await assert.rejects(relinkMaterials(files, { signal: done.signal }), (e: unknown) => e instanceof CliError && e.code === "interrupted"); + assert.equal(sha(objPath), before); + // Aborted while the (large) OBJ is being rewritten: the temp file is removed, the original stays. + const midway = new AbortController(); + setTimeout(() => midway.abort(), 2); + await assert.rejects(relinkMaterials(files, { signal: midway.signal }), (e: unknown) => e instanceof CliError && e.code === "interrupted"); + assert.equal(sha(objPath), before, "the original OBJ is untouched"); + assert.deepEqual(tmpFiles(dir), []); + // Without a signal the same set relinks normally. + const report = await relinkMaterials(files); + assert.equal(report?.rewritten.length, 1); + assert.match(readFileSync(objPath, "utf8").slice(0, 40), /^mtllib model\.mtl\n/); +}); + +// --------------------------------------------------------------------------- +// R3-F06 / C04 — project reached through an alias path +// --------------------------------------------------------------------------- + +test("C04/R3-F06 a project reached through a symlinked parent (or the macOS /var alias) still records its downloaded files; files truly outside are not recorded", async () => { + const host = await startMockApi((_req, res) => { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + }); + try { + const dir = tmpDir(); // on macOS this is itself an alias (/var → /private/var) + const realRoot = join(dir, "real-projects"); + mkdirSync(realRoot); + const alias = join(dir, "alias-projects"); + symlinkSync(realRoot, alias); + const env = host.env({ MESHY_API_KEY: undefined }); + const init = await runCli(["project", "init", "--root", alias, "--name", "alias-review"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const projectDir = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + assert.ok(projectDir.startsWith(alias), "the project is addressed through the alias"); + const fixture = join(projectDir, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${host.url}/model.glb` } }))); + const r = await runCli(["download", "--task-json", fixture, "--all", "--project", projectDir], { env, cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as { result: { project: { recorded_files: string[] } }; warnings: Array<{ code: string }> }; + assert.deepEqual(out.result.project.recorded_files, ["model.glb"]); + assert.ok(!out.warnings.some((w) => w.code === "files_outside_project"), "no false files_outside_project warning"); + const meta = JSON.parse(readFileSync(join(projectDir, "metadata.json"), "utf8")) as { tasks: Array<{ files: string[] }> }; + assert.deepEqual(meta.tasks[0]!.files, ["model.glb"]); + assert.ok(existsSync(join(realRoot, readdirSync(realRoot).find((n) => n !== "history.json")!, "model.glb"))); + // Files written outside the project (explicit --output-dir elsewhere, no workspace) are still not recorded. + const elsewhere = join(dir, "elsewhere"); + const outside = await runCli(["download", "--task-json", fixture, "--all", "--project", projectDir, "--output-dir", elsewhere], { env, cwd: dir }); + assert.equal(outside.code, 0, outside.stderr); + const oo = parseSingleJson(outside.stdout) as { result: { project: { recorded_files: string[] } }; warnings: Array<{ code: string }> }; + assert.deepEqual(oo.result.project.recorded_files, []); + assert.ok(oo.warnings.some((w) => w.code === "files_outside_project")); + assert.ok(existsSync(join(elsewhere, "model.glb"))); + } finally { + await host.close(); + } +}); + +// --------------------------------------------------------------------------- +// R3-F03 / C05 — texture identity when source names collide with generated names +// --------------------------------------------------------------------------- + +test("C05/R3-F03 source names that collide with the CLI's generated names still map to the right image (verified by bytes); a generated-name reference with a different source is ambiguous", async () => { + const red = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#ff0000" } }).png().toBuffer(); + const green = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#00ff00" } }).png().toBuffer(); + const mtl = "newmtl red\nmap_Kd texture_1_base_color.png\nnewmtl green\nmap_Kd texture_0_base_color.png\n"; + const host = await startMockApi((req, res) => { + if (req.path === "/model.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + return void res.end("mtllib model.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 1\nf 1 2 3\n"); + } + if (req.path === "/model.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + return void res.end(mtl); + } + if (req.path === "/texture_1_base_color.png") { + res.writeHead(200, { "content-type": "image/png" }); + return void res.end(red); + } + if (req.path === "/texture_0_base_color.png" || req.path === "/a.png") { + res.writeHead(200, { "content-type": "image/png" }); + return void res.end(green); + } + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + const env = host.env({ MESHY_API_KEY: undefined }); + // texture_urls[0] is served as texture_1_base_color.png (red) and lands as texture_0_base_color.png; [1] the other way round. + const fixture = join(dir, "collision.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/model.obj`, mtl: `${host.url}/model.mtl` }, texture_urls: [{ base_color: `${host.url}/texture_1_base_color.png` }, { base_color: `${host.url}/texture_0_base_color.png` }] }))); + const out = join(dir, "collision"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env, cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const lines = readFileSync(join(out, "model.mtl"), "utf8").split("\n"); + const redRef = lines[lines.indexOf("newmtl red") + 1]!.split(" ")[1]!; + const greenRef = lines[lines.indexOf("newmtl green") + 1]!.split(" ")[1]!; + assert.ok(readFileSync(join(out, redRef)).equals(red), `the red material references the red image (${redRef})`); + assert.ok(readFileSync(join(out, greenRef)).equals(green), `the green material references the green image (${greenRef})`); + assert.equal(redRef, "texture_0_base_color.png"); + assert.equal(greenRef, "texture_1_base_color.png"); + const env1 = parseSingleJson(r.stdout) as { warnings: Array<{ code: string }>; result: { downloads: { material_links: { status: string; texture_maps: Array<{ material: string; reference: string; resolved_to: string; method: string }> } } } }; + assert.equal(env1.result.downloads.material_links.status, "complete"); + assert.deepEqual(env1.result.downloads.material_links.texture_maps.map((l) => [l.material, l.reference, l.resolved_to, l.method]), [ + ["red", "texture_1_base_color.png", "texture_0_base_color.png", "source_name"], + ["green", "texture_0_base_color.png", "texture_1_base_color.png", "source_name"], + ]); + assert.ok(!env1.warnings.some((w) => w.code.startsWith("material_reference"))); + + // A reference that equals a generated name whose source is something else, with no source matching it: ambiguous, kept, warned. + const fixture2 = join(dir, "collision2.json"); + writeFileSync(fixture2, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/model.obj`, mtl: `${host.url}/model.mtl` }, texture_urls: [{ base_color: `${host.url}/a.png` }] }))); + const out2 = join(dir, "collision2"); + const r2 = await runCli(["download", "--task-json", fixture2, "--model-format", "obj", "--output-dir", out2], { env, cwd: dir }); + assert.equal(r2.code, 0, r2.stderr); + const saved2 = readFileSync(join(out2, "model.mtl"), "utf8"); + assert.equal(saved2, mtl, "both references stay as written"); + const env2 = parseSingleJson(r2.stdout) as { warnings: Array<{ code: string; message: string }>; result: { downloads: { material_links: { status: string; texture_maps: Array<{ reference: string; resolved_to: string | null; method: string; note?: string }> } } } }; + assert.equal(env2.result.downloads.material_links.status, "incomplete"); + const generated = env2.result.downloads.material_links.texture_maps.find((l) => l.reference === "texture_0_base_color.png")!; + assert.equal(generated.method, "ambiguous"); + assert.equal(generated.resolved_to, null); + assert.match(generated.note ?? "", /served as 'a\.png'/); + assert.ok(env2.warnings.some((w) => w.code === "material_reference_ambiguous" && w.message.includes("served as 'a.png'"))); + } finally { + await host.close(); + } +}); + +test("R3-F03 without source evidence a saved-name match is still accepted (conservative fallback)", async () => { + const dir = tmpDir(); + writeFileSync(join(dir, "model.obj"), "mtllib m.mtl\nv 0 0 0\n"); + writeFileSync(join(dir, "model.mtl"), "newmtl a\nmap_Kd texture_0_base_color.png\n"); + writeFileSync(join(dir, "texture_0_base_color.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const report = await relinkMaterials([ + { key: "model.obj", path: join(dir, "model.obj") }, + { key: "model.mtl", path: join(dir, "model.mtl") }, + { key: "texture.0.base_color", path: join(dir, "texture_0_base_color.png") }, + ]); + assert.equal(report?.status, "complete"); + assert.deepEqual(report?.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [["texture_0_base_color.png", "texture_0_base_color.png", "unchanged"]]); +}); + +// --------------------------------------------------------------------------- +// R3-F02 / C06 — legacy schema keeps the accepted task on every download failure +// --------------------------------------------------------------------------- + +test("C06/R3-F02 legacy sync create/wait/make: an asset 503, a sidecar failure and SIGINT still report task_id, the real operation_id, next and the manifest; exactly one POST", async () => { + let assetMode: "503" | "ok" | "slow" = "503"; + let plant: (() => void) | null = null; + let child: ChildProcess | null = null; + const api = await startMockApi(async (req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "paid-legacy-created-id" }); + if (req.method === "DELETE") return jsonReply(res, 500, { message: "never" }); + if (req.path === "/asset.glb") { + plant?.(); + plant = null; + if (assetMode === "503") return jsonReply(res, 503, { message: "asset host down" }); + if (assetMode === "slow") { + child?.kill("SIGINT"); + await new Promise((r) => setTimeout(r, 400)); + } + try { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + } catch { + /* client gone */ + } + return; + } + return jsonReply(res, 200, taskBody({ id: "paid-legacy-created-id", model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + + // Sync create (default schema), asset host 503. + const created = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "fixture legacy", "-o", join(dir, "legacy-created")], { env, cwd: dir }); + assert.equal(created.code, 7, `${created.stderr}\n${created.stdout}`); + const payload = parseSingleJson(created.stdout) as LegacyErrorPayload; + assert.equal(payload.code, "network"); + assert.equal(payload.status, 503); + assert.equal(payload.task_id, "paid-legacy-created-id"); + assert.equal(payload.result?.task_id, "paid-legacy-created-id"); + assert.equal(payload.result?.submission?.state, "accepted"); + const [operationId] = journalOperationIds(configDir); + assert.ok(operationId, "the journal holds the accepted record"); + assert.equal(payload.operation_id, operationId, "the error names the journal's operation id"); + assert.equal(payload.result?.submission?.operation_id, operationId); + assert.match(payload.result?.next?.wait ?? "", /wait paid-legacy-created-id/); + assert.deepEqual(payload.result?.downloads?.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]]); + assert.ok(payload.hint && /paid-legacy-created-id/.test(payload.hint), "the hint (also printed on stderr) names the task"); + assert.match(created.stderr, /paid-legacy-created-id/); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1); + assert.equal(api.requests.filter((q) => q.method === "DELETE").length, 0); + + // Legacy wait -o: same fields. + const waited = await runCli(["text-to-3d", "wait", "paid-legacy-created-id", "-o", join(dir, "legacy-wait")], { env, cwd: dir }); + assert.equal(waited.code, 7, waited.stderr); + const wp = parseSingleJson(waited.stdout) as LegacyErrorPayload; + assert.equal(wp.task_id, "paid-legacy-created-id"); + assert.equal(wp.status, 503); + + // Legacy sidecar failure after the asset landed. + assetMode = "ok"; + const sideDir = join(dir, "legacy-sidecar"); + plant = () => { + mkdirSync(sideDir, { recursive: true }); + mkdirSync(join(sideDir, "meta.json")); + }; + const side = await runCli(["text-to-3d", "get", "paid-legacy-created-id", "-o", sideDir], { env, cwd: dir }); + assert.equal(side.code, 11, `${side.stderr}\n${side.stdout}`); + const sp = parseSingleJson(side.stdout) as LegacyErrorPayload; + assert.equal(sp.task_id, "paid-legacy-created-id"); + assert.equal(sp.result?.downloads?.state, "partial"); + assert.equal(sp.result?.downloads?.failed_step, "sidecar"); + assert.deepEqual(sp.result?.downloads?.files.map((f) => [f.key, f.status]), [["model_glb", "written"]]); + assert.ok(existsSync(join(sideDir, "model.glb"))); + + // Legacy SIGINT during the transfer. + assetMode = "slow"; + const target = join(dir, "legacy-int.glb"); + const interrupted = await runCli(["text-to-3d", "get", "paid-legacy-created-id", "-o", target], { env, cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(interrupted.code, 130, `${interrupted.stderr}\n${interrupted.stdout}`); + const ip = parseSingleJson(interrupted.stdout) as LegacyErrorPayload; + assert.equal(ip.code, "interrupted"); + assert.equal(ip.task_id, "paid-legacy-created-id"); + assert.ok(!existsSync(target)); + + // Legacy make -o: chain done, download 503 → task id, executed steps, next. + assetMode = "503"; + api.requests.length = 0; + const made = await runCli(["make", "a fixture cactus", "-o", join(dir, "legacy-make")], { env, cwd: dir }); + assert.equal(made.code, 7, `${made.stderr}\n${made.stdout}`); + const mp = parseSingleJson(made.stdout) as LegacyErrorPayload & { result?: { executed?: unknown[] } }; + assert.equal(mp.code, "network"); + assert.equal(mp.status, 503); + assert.equal(mp.task_id, "paid-legacy-created-id"); + assert.equal(mp.result?.executed?.length, 2); + assert.match(mp.result?.next?.wait ?? "", /wait paid-legacy-created-id/); + assert.ok(mp.operation_id, "make reports the real operation id of the finished step"); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 2, "two chain steps, no re-submission"); + + // v1 make -o: SIGINT during the final download keeps the chain context (round-2 N08 named make; this asserts it). + assetMode = "slow"; + api.requests.length = 0; + const madeInt = await runCli(["make", "a fixture cactus", ...V1, "-o", join(dir, "make-int.glb")], { env, cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(madeInt.code, 130, `${madeInt.stderr}\n${madeInt.stdout}`); + const mi = parseSingleJson(madeInt.stdout) as { error: { code: string }; result: { task_id: string; executed: unknown[]; downloads: { state: string } } }; + assert.equal(mi.error.code, "interrupted"); + assert.equal(mi.result.task_id, "paid-legacy-created-id"); + assert.equal(mi.result.executed.length, 2); + assert.equal(mi.result.downloads.state, "failed"); + assert.ok(!existsSync(join(dir, "make-int.glb"))); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 2); + } finally { + await api.close(); + } +}); From 235d6de34f4e78210cdf418a7273542fbd4ce906 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 23:27:21 +0800 Subject: [PATCH 15/26] test(poll): judge the real-timer smoke with the loop's own clock reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke asserted GET start times taken by a fresh performance.now() inside the endpoint against the deadline; call overhead of a few microseconds after a remaining>0 decision made it fail once in a full run (+0.003 ms). The test now records the last value the injected now() returned before each GET — the very reading the loop used — and asserts it lies before the deadline. 12/12 repeats pass; the deterministic fake-clock tests are unchanged. Co-Authored-By: Claude Fable 5.1 --- tests/poll.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/poll.test.ts b/tests/poll.test.ts index 9a8e48a..49fbd7f 100644 --- a/tests/poll.test.ts +++ b/tests/poll.test.ts @@ -178,17 +178,23 @@ test("pollUntilTerminal — deterministic: a timer that wakes late never polls a test("pollUntilTerminal — real timers (smoke): no GET starts after the deadline, whatever the timer jitter", async () => { // Real setTimeout may wake a fraction early or late; the only invariant a - // real clock can prove is that no request *starts* past the deadline. + // real clock can prove is that no request *starts* past the deadline. The + // decision is judged with the very clock reading the loop used (the last + // value the injected `now` returned before the GET), not with a fresh + // performance.now() taken microseconds later inside the endpoint — that + // would turn call overhead into a false failure. let origin: number | null = null; + let lastNow = 0; const now = () => { const t = performance.now(); if (origin === null) origin = t; + lastNow = t; return t; }; const starts: number[] = []; const ep = { async retrieveDetailed(id: string): Promise<{ task: Task; raw: unknown }> { - starts.push(performance.now()); + starts.push(lastNow); const task = inProgress(id); return { task, raw: task }; }, @@ -197,7 +203,7 @@ test("pollUntilTerminal — real timers (smoke): no GET starts after the deadlin assert.equal(res.timedOut, true); assert.ok(starts.length >= 1); const deadline = (origin ?? 0) + 120; - assert.ok(starts.every((t) => t <= deadline), `GET start times relative to the deadline: ${JSON.stringify(starts.map((t) => Number((t - deadline).toFixed(3))))}`); + assert.ok(starts.every((t) => t < deadline), `GET decisions relative to the deadline: ${JSON.stringify(starts.map((t) => Number((t - deadline).toFixed(3))))}`); }); test("pollUntilTerminal — --timeout 0 is a single query that is not bounded by the (zero) budget", async () => { From 9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 7 Sep 2026 23:35:51 +0800 Subject: [PATCH 16/26] docs(skill-parity): round-4 handoff after Codex review round 3 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verification.json re-bound to code HEAD 235d6de (install/typecheck/test 542 pass/build/pack sha256 f29e8a16…/tarball install + 29 smoke checks, git diff --check, poll.test.ts 12/12 and review suites 3/3 repeats, live catalog GET) with review_rounds[2] (reviewed 566f3bd/cf8905d, changes_requested, 6 findings fixed in 30536d8, round3-probes.mjs re-run 0/6, round2 0/8, round1 0/12, reviewer's verify-original-regressions.py 20/20, stream-finalization-check passed); capability matrix marks 10 capabilities with round-3 fixes; REVIEW_HANDOFF.md carries the round-3 finding→fix→test→probe table, post-fix samples and the re-review prompt. Live account/OS/GUI checks stay not_run. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/REVIEW_HANDOFF.md | 652 +++++++++++------------ docs/skill-parity/capability-matrix.json | 207 +++++-- docs/skill-parity/verification.json | 460 +++++++++------- 3 files changed, 761 insertions(+), 558 deletions(-) diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md index f863eb3..075f88b 100644 --- a/docs/skill-parity/REVIEW_HANDOFF.md +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -1,34 +1,31 @@ # Meshy CLI S1 Review Handoff -Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 3**(Codex review 第 2 轮修复后的复审交接)。第 1 轮 review 见 `meshy-agent-integrations-research/reviews/cli-s1-6273d9a/`,第 2 轮见 `…/reviews/cli-s1-730132b/`;两处 reviewer 证据目录均未被改动,复现脚本从副本运行。 +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 4**(Codex review 第 3 轮修复后的复审交接)。历次 review:第 1 轮 `…/reviews/cli-s1-6273d9a/`,第 2 轮 `…/reviews/cli-s1-730132b/`,第 3 轮 `…/reviews/cli-s1-cf8905d/`;三处 reviewer 证据目录均未被改动,所有复现脚本从副本运行。 -## 0. 第 2 轮 review 结论与本轮修复 +## 0. 第 3 轮 review 结论与本轮修复 -- 被 review 的 HEAD:`730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe8`);结论 **changes_requested**:7 项 finding(1 P1、6 P2),8 个独立复现(N01–N08);reviewer 全量测试 521/522(唯一失败为真实时钟的 poll 断言);首轮 12 个场景已被 reviewer 正向核对通过。 -- 修复 commit:`cf8905dd285bf16896df053aac253dbf8c672979`(`fix(review): address Codex review round 2 findings R2-F01–R2-F07`);` 22 files changed, 1379 insertions(+), 189 deletions(-)`(相对 730132b)。 -- 复现脚本重跑(副本,`cf8905d`):`round2-probes.mjs` **0/8 复现**(exit 1 = 并非全部复现);`round1-probes.mjs` **0/12 复现**(首轮修复未回退)。逐项结果与 exit code 见 verification.json `review_rounds[1].probe_rerun`。 -- 正向回归:`tests/codex-review-round2.test.ts`(10 项,N01–N08 逐项按 acceptance 断言:退出码、envelope 形状、task_id/submission、请求次数、落盘内容与部分 manifest、ndjson 唯一 outcome 与递增 sequence、被拒目标连目录都不创建);`tests/poll.test.ts` 改为确定性时钟(9 项,连续 8 次 8/8);`tests/auth-headless.test.ts` 断言设备登录写入 `login_id`;round-1 `R10` 的期望随 HTTP 分类保留改为 not_found/exit 5。 -- 全量:`pnpm typecheck` 通过;`pnpm test` **535/535**(上轮 522 + 本轮 13);`git diff --check fd94490` exit 0。 +- 被 review 的 HEAD:`566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905d`);结论 **changes_requested**:6 项 finding(2 P1、4 P2),6 个扩展复现(C01–C06);reviewer 全量 535/535、首两轮 20 项正向核对通过。 +- 修复 commit:`30536d8`(`fix(review): address Codex review round 3 findings R3-F01–R3-F06`);测试稳定性 commit:`235d6de`(`test(poll)`:真实定时器 smoke 改用循环自身的时钟读数判定——在 30536d8 的一次全量运行中它以 +0.003 ms 误报失败一次,源于 endpoint 内重新取 `performance.now()` 的调用开销;不放宽容差、不跳过)。` 12 files changed, 846 insertions(+), 65 deletions(-)`(相对 566f3bd)。 +- 复现脚本重跑(副本,`235d6de`):`round3-probes.mjs` **0/6 复现**;`round2-probes.mjs` **0/8**;`round1-probes.mjs` **0/12**(均 exit 1 = 并非全部复现);reviewer 自己的正向核对脚本 `verify-original-regressions.py` 对重跑结果 **20/20 通过**;`stream-finalization-check.mjs` 通过。 +- 正向回归:`tests/codex-review-round3.test.ts`(7 项,C01–C06 逐条按 acceptance 断言 + relinkMaterials 已中止/中途中止单测 + v1 make SIGINT);两轮旧回归全部保留并通过。 +- 全量:`pnpm typecheck` 通过;`pnpm test` **542/542**;`git diff --check fd94490` exit 0;`poll.test.ts` 连续 12 次 12/12;round2+round3 套件连续 3 次 3/3。 | ID | 优先级 | 问题 | 修复 | 决策 | 回归测试 | 复现脚本重跑 | | --- | --- | --- | --- | --- | --- | --- | -| R2-F01 | P1 | --workspace 仍未限制所有实际写入路径(报告任务 -o、隐式索引根、拒绝前 mkdir) | `saveReportOnly` 接收 workspace 并在 mkdir 前校验文件/`meta.json`;`downloadAssets` 先校验目录与每个计划叶子再创建目录;`indexRootFor` 判定历史索引根(显式 `--root`,否则项目父目录):越界时 `project record`/任务 `--project`/`download --project` 只记录 metadata,索引以 `index.updated=false` + 原因跳过并给 `index_dirty`;显式 `--root` 越界仍在写入前拒绝 | D-038 | N01、N02、N03(codex-review-round2;报告任务 get/wait/stream 的文件/目录/符号链接父目录/工作区内正常路径;父目录清单前后一致;被拒目标目录不存在) | N01: exit 11, 不再复现 / N02: exit 0, 不再复现 / N03: exit 11, 不再复现 | -| R2-F02 | P2 | 多组材质的同通道贴图被错误地指向第一张 | 每张贴图携带服务端文件名(URL 末段);解析顺序:已保存名 → 源文件名(忽略大小写)→ 源文件 stem(忽略扩展名/目录)→ 引用名中的通道词 → MTL key 通道 → 唯一贴图;每条规则只在**恰好一个**候选时命中,多候选为 ambiguous:引用原样保留、列出 candidates、`material_links.status=incomplete`、warning `material_reference_ambiguous`;记录 `newmtl` 组;legacy `-o` 同一解析器 | D-039 | N04 ×2(双材质各自正确;大小写/目录/扩展名变体;歧义保留并报警,且断言未指向第一张) | N04: exit 0, 不再复现 | -| R2-F03 | P2 | SSE 保存或项目记账失败时没有终结 outcome 事件 | stream 结束后的 save-json/project 记录在同一终结处理内执行:失败成为唯一的 `outcome`(ndjson,序号递增)或唯一 envelope(json/pretty),保留任务上下文;若流本身已失败,记账错误作为 `bookkeeping_failed` warning 附带 | D-040 | N05(save-json 冲突、project 未初始化、资产 404 × ndjson/json/pretty) | N05: exit 11, 不再复现 | -| R2-F04 | P2 | 任务 -o 下载失败仍丢失已落盘文件和 HTTP 分类 | `downloadArtifacts` 返回逐文件 manifest(key/path/bytes/sha256/status),失败抛出保留原 code/http_status/recovery 的 CliError 并携带 `result.downloads={state: partial|failed, files}`;`maybeDownloadV1`/make 合并而非覆盖;legacy 错误 payload 现含 `code`/`status`/`result.downloads` | D-041 | N06(503/404/403 × get/wait;make;legacy);round-1 R10 期望改为 not_found/5 | N06: exit 7, 不再复现 | -| R2-F05 | P2 | 任务 -o 下载没有接入 SIGINT,取消后仍返回成功 | abort signal 从所有任务动词、make、legacy 报告器一路传入 `fetchToTemp`;中断即停止传输、删除临时文件、跳过重链接/sidecar,以 `interrupted`/130 返回 task_id/submission/next 与已提交文件;`index.ts` 顶层重包装保留 result/recovery | D-042 | N08(响应头前、body 中途、第二个资产;legacy;无 DELETE/POST;无临时文件) | N08: exit 130, 不再复现 | -| R2-F06 | P2 | OAuth 缺少 user_id 时仍会把不同登录视为同一账户 | `meshy auth login` 为每个 OAuth profile 生成 `login_id`(refresh 保留、重新登录更换);journal 身份 = `subject:`,否则 `login:`;两者皆无的存量 profile 为 unverified:可发起新操作,但对已有记录的重放被拒绝(`operation_conflict`,`result.conflict=["credential_unverified"]`,recovery `meshy auth login`);journal 不含 token 或 login_id 明文 | D-043(取代 D-029 的 profile 名限制) | N07(无 subject 换号被拒;login_id 轮换 token 重放;新 login_id 冲突;过期 token 静默 refresh 保留 login_id);credentialBinding 单测;auth-headless 设备登录写入 login_id | N07: exit 2, 不再复现 | -| R2-F07 | P2 | 新增 deadline 测试依赖真实毫秒时序,导致测试套件间歇失败 | poll 测试改用 `pollUntilTerminal` 的 `now`/`sleep` 注入:精确到期、提前 0.5 ms 唤醒、迟到唤醒、deadline 约束的请求超时、读超时约束的网络错误均为确定性;唯一真实定时器 smoke 只断言“截止时间后不启动 GET”;慢 header/body 的子进程测试保留 | D-044 | tests/poll.test.ts 9 项;连续 8 次运行 8/8 通过;全量 535/535 | 全量 `pnpm test` 535/535;poll 重复 8/8 | - -第 1 轮(F01–F10)修复保持不变,仅 R10 的断言随 R2-F04 的分类保留而更新(404 → `not_found`/exit 5,仍是一条 outcome)。 +| R3-F01 | P1 | 下载 sidecar 仍可覆盖并发创建的文件或跟随新符号链接越界 | `writeMeta` 改为与资产相同的发布规则:发布时按 root 重新 `resolveWithinRoot`(拒绝符号链接叶子),用 `writeJsonFile` 独占原子发布(`link`),预检后出现的文件/符号链接/目录一律 `local_io` 拒绝且不截断;目录模式报告(`saveReportOnly`)同路径 | D-045 | C01+C02(预检后植入符号链接/普通文件/目录 × 目录模式、单文件 `_meta.json`、legacy;workspace 外文件字节前后一致;manifest 保留 model_glb written) | C01: exit 11, 不再复现 / C02: exit 11, 不再复现 | +| R3-F02 | P1 | 默认 legacy 同步 create 下载失败时仍丢失已受理任务的输出上下文 | get/wait(同步 create)/stream/make 的 legacy 收尾全部套入 `withTaskContext`/`wrapWithResult`;legacy 错误 payload 保持 `name/message/code/status/hint/result` 形状并附加 `task_id`、`operation_id`;`result` 含真实 `submission`、`next`、部分 manifest;`hint`(stderr)为恢复命令,带任务 id | D-046 | C06(默认 schema 同步 create 资产 503:exit 7、task_id、journal 中真实 operation_id、next、manifest、stderr 含 id、1 次 POST;legacy wait 503;legacy sidecar 失败;legacy SIGINT;legacy make 503;v1 make SIGINT) | C06: exit 7, 不再复现 | +| R3-F03 | P2 | 生成的落盘文件名抢先匹配,导致源贴图身份被错误替换 | 解析顺序改为:服务端源文件名 → 落盘名(仅当该文件来源未知或就是同名;若来源为其它文件则 ambiguous 并给出 `note`)→ 源 stem → 引用名通道词 → MTL key 通道 → 唯一贴图;通道规则在多个不同引用竞争同一唯一通道贴图时也判 ambiguous;无来源证据时保留保守回退 | D-047 | C05(源名与生成名交叉碰撞:按图像字节验证红/绿材质各指向正确文件;生成名引用而来源为 a.png → ambiguous + note + incomplete;无来源证据单测) | C05: exit 0, 不再复现 | +| R3-F04 | P2 | 资产全部落盘后的收尾失败仍丢失 manifest | relink / 摘要刷新 / sidecar 发布纳入同一 `finalisationFailure`:保留原分类,重新从磁盘取每个已提交文件的 bytes/sha256(重写过的标 `relinked`),`downloads.state=partial`、完整 `files`、`failed_step`;`meshy download` 的 relink 同样处理 | D-048 | C01+C02(sidecar 目录/文件/符号链接失败后 manifest 完整且 sha 与磁盘一致)、C03(relink 中断)、C06 legacy sidecar 失败 | C02: exit 11, 不再复现 | +| R3-F05 | P2 | SIGINT 在材质重写阶段仍被忽略,最终误报成功 | signal 传入 `relinkMaterials`/`rewriteLines`:首次读取前、每个 chunk 后、发布前检查,中断即删除临时文件并抛 `interrupted`;sidecar 前再检查;结果 exit 130,manifest 摘要重取,OBJ 为原文件或完整重写文件 | D-049 | C03(约 12 MB OBJ,观察到 `.model.obj.tmp-*` 后发送 SIGINT:exit 130、failed_step=relink、无 meta.json、无临时文件、manifest sha=磁盘 sha、OBJ 非部分文件);relinkMaterials 已中止/中途中止单测 | C03: exit 130, 不再复现 | +| R3-F06 | P2 | 项目目录经过路径别名时,成功下载的资产未记入 metadata | `download --project` 先把项目目录与文件路径 `realpathLenient` 到同一坐标系再算包含关系与相对路径(同 saveTaskSnapshot);用户可见路径不变;真正在项目外的文件仍不记录 | D-050 | C04(符号链接父目录 + macOS /var 别名:recorded_files 与 metadata.tasks[].files 含 model.glb、无 files_outside_project;显式 --output-dir 项目外仍不记录且有 warning) | C04: exit 0, 不再复现 | ## 1. 代码定位 - 仓库路径 / remote:`/Users/ark/Dev/meshy-cli`(fresh clone) / `https://github.com/meshy-dev/meshy-cli.git` - 分支:`feat/skill-parity-s1`(本地分支,未推送) - base SHA:`fd94490916376e691efcea51324ac4326b459e1f`(0.2.0,= 计划基线 = 开工时的 remote main) -- head SHA(代码):`cf8905dd285bf16896df053aac253dbf8c672979`;本文件与 verification.json/capability-matrix.json 在其后的 docs-only commit 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`) -- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc053cea4e1bf7dee08953e25a3ec858137`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe804b456a931f871d2f227e2acf22359d77`) +- head SHA(代码):`235d6de34f4e78210cdf418a7273542fbd4ce906`(= 修复 `30536d8daa92dc9e8d39d99cbdf24d0d20799438` + 测试稳定性提交);本文件与 verification.json/capability-matrix.json 在其后的 docs-only commit 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`) +- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc053cea4e1bf7dee08953e25a3ec858137`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe804b456a931f871d2f227e2acf22359d77`);round 3 `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905dd285bf16896df053aac253dbf8c672979`) - 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) - 实施包版本:2026-09-07 / v1 - 实际源码与计划基线的差异:无(remote main 与 Skills HEAD 均等于计划 SHA,见 `docs/skill-parity/baseline-delta.md`) @@ -37,19 +34,21 @@ Filled from the 2026-09-07 / v1 implementation package. `not_run` means not veri ## 2. 完成状态 -- G1-code / review-ready:**第 2 轮 7 项 finding 已全部修复并有正向回归,首轮 10 项在新 HEAD 复核未回退;自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) +- G1-code / review-ready:**第 3 轮 6 项 finding 已全部修复并有正向回归,前两轮 17 项在新 HEAD 复核未回退(reviewer 正向脚本 20/20);自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) - G1-release / ready-for-S2:**not_run**(无复审结论、无真实账号/多 OS/切片器验证、未发布) -- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;13 项带 `review_round_2: fixed`,21 项带 `review_round_1: fixed`,`review_status` 均为 "re-review pending") -- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **535 通过 / 0 失败 / 0 跳过**(535 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) -- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 cf8905d 重跑)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id、真实重新登录)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open -- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(meshyd 仅只读核对 6 个文件,见 baseline.json;两个 reviewer 目录未被改动) +- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;10 项带 `review_round_3: fixed`,13 项 `review_round_2`,21 项 `review_round_1`,`review_status` 均为 "re-review pending") +- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **542 通过 / 0 失败 / 0 跳过**(542 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) +- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 235d6de 重跑)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open +- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(meshyd 仅只读核对 6 个文件,见 baseline.json;三个 reviewer 目录未被改动) ## 3. 本次具体改动 `git log --oneline fd94490..HEAD`(最早在下): ``` -1d97ada docs(skill-parity): round-3 handoff after Codex review round 2 fixes +235d6de test(poll): judge the real-timer smoke with the loop's own clock reading +30536d8 fix(review): address Codex review round 3 findings R3-F01–R3-F06 +566f3bd docs(skill-parity): round-3 handoff after Codex review round 2 fixes cf8905d fix(review): address Codex review round 2 findings R2-F01–R2-F07 730132b docs(skill-parity): round-2 handoff after Codex review round 1 fixes 0388fe8 fix(review): address Codex review round 1 findings F01–F10 @@ -64,7 +63,7 @@ e7ea577 feat(cli): B01 v1 envelope, exit codes, local runtime, --api-key-file an c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixtures ``` -` 116 files changed, 20618 insertions(+), 930 deletions(-)` +` 117 files changed, 21456 insertions(+), 935 deletions(-)` | 批次 | commit | 改动与用户行为 | 关键验证 | | --- | --- | --- | --- | @@ -77,18 +76,19 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt | B06–B08 | `feat(local): B06-B08` | `inspect faces`(unknown≠0,exit 12/13,仅描述 remesh);`mesh prepare-print`(两遍流式、oracle 校验、材质依赖、`--in-place`);`slicer detect/open`(七注册、三 OS 规则、无默认应用 fallback);`doctor`(默认本地,`--check-api` 单次 balance) | tests/inspect, obj-transform, slicers, doctor | | B09 | `chore(release)` + docs commit | 0.3.0 候选版本、README、随包 SKILL.md、.env.example、T-105;verification.json、REVIEW_HANDOFF | 本节 5、7 | | Review R1 | `fix(review)` `0388fe8` | F01–F10(任务上下文/预检/共享 `submitCreate`、workspace 写入根、依赖复制根检查、凭据/媒体指纹、截止时间、嵌套合并、材质重链接、stream -o) | tests/codex-review-round1, operation-store, poll | -| Review R2 | `fix(review)` `cf8905d` | §0 表中的 R2-F01–R2-F07:`download.ts` legacy 下载器逐文件 manifest/信号/报告分支 root/mkdir 顺序;`material-links.ts` 源文件名解析与歧义报告;`task-command.ts` stream 统一 outcome、部分 manifest、索引根、OAuth 身份校验;`project-store.ts` `indexRootFor`/`skipIndex`、快照相对路径 realpath 帧;`operation-store.ts` `credentialBinding`/unverified 拒绝;`credentials.ts`/`config.ts`/`auth.ts`/`runtime.ts` `login_id`;`index.ts` 中断重包装保留上下文;`make.ts`/`project.ts`/`cmd/download.ts` 相应接线;decisions D-038..D-044、migration-notes §3.2、README、SKILL.md | tests/codex-review-round2, poll(确定性), auth-headless + 两个复现脚本重跑 | +| Review R2 | `fix(review)` `cf8905d` | R2-F01–R2-F07(报告 -o/索引根/mkdir 顺序、多材质源名映射、stream 统一 outcome、部分 manifest、传输阶段 SIGINT、OAuth login_id、确定性 deadline 测试) | tests/codex-review-round2, poll, auth-headless | +| Review R3 | `fix(review)` `30536d8` + `test(poll)` `235d6de` | §0 表中的 R3-F01–R3-F06:`download.ts` sidecar 安全发布、统一收尾失败处理(`finalisationFailure`、`redigest`、`failed_step`)、relink 信号;`material-links.ts` 源身份优先与通道竞争歧义、`rewriteLines` 合作式中断;`task-command.ts`/`make.ts` legacy 收尾任务上下文;`errors.ts` legacy payload 附加 `task_id`/`operation_id`;`cmd/download.ts` realpath 坐标系记录项目文件;decisions D-045..D-050、migration-notes §3.3、README、SKILL.md;poll smoke 以循环时钟读数判定 | tests/codex-review-round3 + 三轮复现脚本重跑 + reviewer 正向脚本 20/20 | ## 4. 能力与接口证据 -- 更新后的 capability-matrix 路径:`docs/skill-parity/capability-matrix.json`(`review_rounds`、各能力 `review_round_1`/`review_round_2`) +- 更新后的 capability-matrix 路径:`docs/skill-parity/capability-matrix.json`(`review_rounds[0..2]`、各能力 `review_round_1/2/3`) - endpoint-contracts 与模型/媒体参数映射:`docs/skill-parity/endpoint-contracts.json`(与 `src/client/resource-registry.ts` 由 `tests/resource-registry.test.ts` 逐字段对账;本轮未改) -- v1 JSON schema / 错误码定义:`src/internal/result.ts`(六固定键)、`src/internal/errors.ts`(`CliErrorCode` → 退出码表);`README.md` "Stable machine output"。本轮新增/变化字段(均为附加或收窄):任务 `-o` 的 `result.downloads.files[]` 现为逐文件 manifest(`key/path/bytes/sha256/status/error`),`downloads.state` 新增 `partial`;`material_links.status`(complete|incomplete)、`texture_maps[].material`/`candidates`、method 新值 `source_name`/`source_stem`/`ambiguous`;`result.conflict` 新值 `credential_unverified`;stream 的记账失败进入 `outcome`(流本身失败时附 `bookkeeping_failed` warning,D-040);凭据文件 OAuth profile 新字段 `login_id` -- legacy compatibility / migration notes:`docs/skill-parity/migration-notes.md`(§3 兼容表、§3.1 首轮修正、§3.2 本轮修正:legacy `-o` 错误 payload 现含 `code`/`status`/`result.downloads`,其余 legacy 输出与文件布局不变) -- intentional differences:`docs/skill-parity/migration-notes.md` §2(2.1–2.12),关键项:`--api-key-file`(D-025)、`showcase_type=animate`(D-007)、公共 DTO 无 `face_count`(D-009)、`rigging list` 启用、lamp `text` 拒绝;本轮设计选择:材质重链接**拒绝在多候选间猜测**(D-039);无账户身份的 OAuth 存量 profile **拒绝重放而非视为同一账户**(D-043,迁移路径为重新登录) +- v1 JSON schema / 错误码定义:`src/internal/result.ts`(六固定键)、`src/internal/errors.ts`(`CliErrorCode` → 退出码表);`README.md` "Stable machine output"。本轮新增/变化字段(均为附加):`result.downloads.failed_step`(relink | digest | sidecar);`material_links.texture_maps[].note`;legacy 错误 payload 顶层 `task_id`、`operation_id`;legacy 错误 `hint` 为恢复命令 +- legacy compatibility / migration notes:`docs/skill-parity/migration-notes.md`(§3 兼容表、§3.1–§3.3 三轮修正;legacy 输出与文件布局不变,错误 payload 只增字段) +- intentional differences:`docs/skill-parity/migration-notes.md` §2(2.1–2.12);三轮设计选择:材质映射按来源身份、多候选与通道竞争拒绝猜测(D-039/D-047);无账户身份 OAuth 存量 profile 拒绝重放(D-043);sidecar 与资产同规则发布(D-045);越界索引根跳过而非越界写入(D-038) - 限权 API / 未支持的 OS / deferred 新功能:UV/Creative Lab/showcases 真实调用 not_run;Windows/Linux 仅 fixture 覆盖;新 Creative Lab 产品记录为 deferred(矩阵 DF-001) -### 脱敏实际 CLI 输出(tarball 安装的 `cf8905d`,loopback mock;``/`` 为替换后的路径) +### 脱敏实际 CLI 输出(tarball 安装的 `235d6de`,loopback mock;``/`` 为替换后的路径) `text-to-3d create --mode preview --prompt "a cactus" --async --output-schema v1`(exit=0): @@ -101,7 +101,7 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "task": null, "submission": { "state": "accepted", - "operation_id": "86ff89fe-8775-4654-a8f0-8ee924a23615", + "operation_id": "f64531e3-a77a-4223-a4dd-de9de9d1fabb", "task_id": "fixture-task-a", "request_id": null }, @@ -304,7 +304,7 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "result": { "submission": { "state": "unknown", - "operation_id": "f62a0362-51ce-45b4-a68d-f686a51062cf", + "operation_id": "d7354b5e-d51d-439a-8e8d-e47cd1738dce", "task_id": null }, "task": null, @@ -322,7 +322,7 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "recovery": { "action": "reconcile", "automatic": false, - "command": "meshy text-to-3d list --output-schema v1 # then match operation f62a0362-51ce-45b4-a68d-f686a51062cf by time/prompt before creating again" + "command": "meshy text-to-3d list --output-schema v1 # then match operation d7354b5e-d51d-439a-8e8d-e47cd1738dce by time/prompt before creating again" } }, "warnings": [] @@ -380,21 +380,21 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt } ``` -### 第 2 轮 finding 修复后的实际输出(`round2-probes.mjs` 副本重跑,`cf8905d`,脱敏) +### 第 3 轮 finding 修复后的实际输出(`round3-probes.mjs` 副本重跑,`235d6de`,脱敏) -N01 · `analyze-printability get --workspace W -o OUTSIDE/report.json`(exit 11,`outside_file_created=false`): +C01 · `get --workspace W -o W/out`,资产 GET 期间在 `W/out/meta.json` 植入指向 W 外文件的符号链接(exit 11,外部文件内容不变,manifest 保留 model_glb): ```json { "schema_version": "meshy.cli/v1", - "command": "analyze-printability.get", + "command": "text-to-3d.get", "ok": false, "result": { "task": { "task_id": "round2-task", - "resource": "analyze-printability", - "endpoint": "/openapi/v1/print/analyze", - "type": "analyze-printability", + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", "name": null, "status": "SUCCEEDED", "progress": null, @@ -405,17 +405,16 @@ N01 · `analyze-printability get --workspace W -o OUTSIDE/report.json`(exit 11 "expires_at": null, "face_count": null, "consumed_credits": null, - "model_urls": {}, + "model_urls": { + "glb": "http://127.0.0.1:/asset.glb" + }, "image_urls": [], "texture_urls": [], "thumbnail_url": null, "thumbnail_urls": null, "alpha_thumbnail_url": null, "result": null, - "printability": { - "score": 0.9, - "issues": [] - }, + "printability": null, "task_error": null }, "submission": { @@ -423,203 +422,126 @@ N01 · `analyze-printability get --workspace W -o OUTSIDE/report.json`(exit 11 "operation_id": null }, "downloads": { - "state": "failed", - "files": [], - "metadata_path": null + "state": "partial", + "files": [ + { + "key": "model_glb", + "path": "/workspace/sidecar-race/model.glb", + "bytes": 49, + "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", + "content_type": "model/gltf-binary", + "status": "written", + "error": null, + "relinked": false + } + ], + "metadata_path": null, + "failed_step": "sidecar" }, "saved_json": null, "task_id": "round2-task", "next": { - "get": "meshy analyze-printability get round2-task --output-schema v1", - "wait": "meshy analyze-printability wait round2-task --output-schema v1", - "stream": "meshy analyze-printability stream round2-task --format ndjson --output-schema v1" + "get": "meshy text-to-3d get round2-task --output-schema v1", + "wait": "meshy text-to-3d wait round2-task --output-schema v1", + "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" } }, "error": { "code": "local_io", - "message": "task round2-task is SUCCEEDED but downloading its assets failed: report path /outside-report/report.json resolves outside the authorised root /private/workspace", + "message": "task round2-task is SUCCEEDED but downloading its assets failed: 1 file(s) were written but the sidecar step failed: sidecar path /workspace/sidecar-race/meta.json is a symbolic link; refusing to write through it", "http_status": null, "retryable": false, "recovery": { "action": "download", "automatic": false, - "command": "meshy download --resource analyze-printability --task-id round2-task --all --output-dir " - } + "command": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " + }, + "hint": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " }, "warnings": [] } ``` -N02 · `project record --project P --workspace P`(无 `--root`;exit 0,metadata 记录、`index.updated=false`、父目录无 history.json): +C02 · 资产 GET 期间在 `meta.json` 位置出现同名目录(exit 11,`downloads.state=partial`、`failed_step=sidecar`、model.glb 在磁盘): ```json { "schema_version": "meshy.cli/v1", - "command": "project.record", - "ok": true, + "command": "text-to-3d.get", + "ok": false, "result": { - "project_dir": "/private/project-root/20260907_182132_review_48aa", - "action": "added", - "entry": { + "task": { "task_id": "round2-task", - "task_type": null, - "resource": null, - "endpoint": null, - "stage": "preview", - "parent_task_id": null, - "status": null, - "files": [], - "task_json": null, - "operation_id": null, - "created_at": "2026-09-07T10:21:32.365Z", - "updated_at": "2026-09-07T10:21:32.365Z" + "resource": "text-to-3d", + "endpoint": "/openapi/v2/text-to-3d", + "type": "text-to-3d-preview", + "name": null, + "status": "SUCCEEDED", + "progress": null, + "preceding_tasks": null, + "created_at": null, + "started_at": null, + "finished_at": null, + "expires_at": null, + "face_count": null, + "consumed_credits": null, + "model_urls": { + "glb": "http://127.0.0.1:/asset.glb" + }, + "image_urls": [], + "texture_urls": [], + "thumbnail_url": null, + "thumbnail_urls": null, + "alpha_thumbnail_url": null, + "result": null, + "printability": null, + "task_error": null }, - "task_count": 1, - "index": { - "updated": false, - "error": "history root /private/project-root resolves outside --workspace /project-root/20260907_182132_review_48aa; metadata.json was recorded but history.json was not touched (history root /private/project-root resolves outside the authorised root /private/project-root/20260907_182132_review_48aa) — run `meshy project rebuild-index --root /private/project-root` from a workspace that contains it" + "submission": { + "state": "accepted", + "operation_id": null }, - "migrated_from_legacy": false - }, - "error": null, - "warnings": [ - { - "code": "index_dirty", - "message": "metadata.json committed but history.json was not updated: history root /private/project-root resolves outside --workspace /project-root/20260907_182132_review_48aa; metadata.json was recorded but history.json was not touched (history root /private/project-root resolves outside the authorised root /private/project-root/20260907_182132_review_48aa) — run `meshy project rebuild-index --root /private/project-root` from a workspace that contains it; run `meshy project rebuild-index`" - } - ] -} -``` - -N04 · 双材质 OBJ 下载后的 `model.mtl`(两组各自指向自己的贴图): - -``` -newmtl body -map_Kd texture_0_base_color.png -newmtl eyes -map_Kd texture_1_base_color.png -``` - -N05 · `stream --format ndjson --save-json <已存在文件>`(exit 11;事件序列 task → outcome): - -```json -[ - { - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.stream", - "ok": true, - "result": { - "task": { - "task_id": "round2-task", - "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", - "name": null, - "status": "SUCCEEDED", - "progress": null, - "preceding_tasks": null, - "created_at": null, - "started_at": null, - "finished_at": null, - "expires_at": null, - "face_count": null, - "consumed_credits": null, - "model_urls": {}, - "image_urls": [], - "texture_urls": [], - "thumbnail_url": null, - "thumbnail_urls": null, - "alpha_thumbnail_url": null, - "result": null, - "printability": null, - "task_error": null - }, - "submission": { - "state": "accepted", - "operation_id": null - }, - "downloads": { - "state": "not_requested", - "files": [], - "metadata_path": null - }, - "saved_json": null + "downloads": { + "state": "partial", + "files": [ + { + "key": "model_glb", + "path": "/workspace/sidecar-failure/model.glb", + "bytes": 49, + "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", + "content_type": "model/gltf-binary", + "status": "written", + "error": null, + "relinked": false + } + ], + "metadata_path": null, + "failed_step": "sidecar" }, - "error": null, - "warnings": [], - "event": "task", - "sequence": 1 + "saved_json": null, + "task_id": "round2-task", + "next": { + "get": "meshy text-to-3d get round2-task --output-schema v1", + "wait": "meshy text-to-3d wait round2-task --output-schema v1", + "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" + } }, - { - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.stream", - "ok": false, - "result": { - "task": { - "task_id": "round2-task", - "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", - "name": null, - "status": "SUCCEEDED", - "progress": null, - "preceding_tasks": null, - "created_at": null, - "started_at": null, - "finished_at": null, - "expires_at": null, - "face_count": null, - "consumed_credits": null, - "model_urls": {}, - "image_urls": [], - "texture_urls": [], - "thumbnail_url": null, - "thumbnail_urls": null, - "alpha_thumbnail_url": null, - "result": null, - "printability": null, - "task_error": null - }, - "submission": { - "state": "accepted", - "operation_id": null - }, - "downloads": { - "state": "not_requested", - "files": [], - "metadata_path": null - }, - "saved_json": null, - "task_id": "round2-task", - "next": { - "get": "meshy text-to-3d get round2-task --output-schema v1", - "wait": "meshy text-to-3d wait round2-task --output-schema v1", - "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" - }, - "stream": { - "events": 1, - "ended": "terminal", - "elapsed_seconds": 0.01 - } - }, - "error": { - "code": "local_io", - "message": "refusing to overwrite existing file: /private/workspace/occupied.json (pass --overwrite, or choose another path)", - "http_status": null, - "retryable": false, - "recovery": { - "action": "choose_path", - "automatic": false - } + "error": { + "code": "local_io", + "message": "task round2-task is SUCCEEDED but downloading its assets failed: 1 file(s) were written but the sidecar step failed: refusing to overwrite existing file: /private/workspace/sidecar-failure/meta.json (pass --overwrite, or choose another path)", + "http_status": null, + "retryable": false, + "recovery": { + "action": "choose_path", + "automatic": false }, - "warnings": [], - "event": "outcome", - "sequence": 2 - } -] + "hint": "meshy text-to-3d wait round2-task --output-schema v1" + }, + "warnings": [] +} ``` -N06 · `get -o dir`,第一个资产成功、第二个 503(exit 7,`downloads.state=partial`,model.glb 在磁盘): +C03 · OBJ 重写阶段发送 SIGINT(exit 130,`failed_step=relink`,无 meta.json): ```json { @@ -643,11 +565,12 @@ N06 · `get -o dir`,第一个资产成功、第二个 503(exit 7,`download "face_count": null, "consumed_credits": null, "model_urls": { - "glb": "http://127.0.0.1:/first.glb" + "obj": "http://127.0.0.1:/large.obj", + "mtl": "http://127.0.0.1:/original.mtl" }, "image_urls": [], "texture_urls": [], - "thumbnail_url": "http://127.0.0.1:/second.png", + "thumbnail_url": null, "thumbnail_urls": null, "alpha_thumbnail_url": null, "result": null, @@ -662,27 +585,28 @@ N06 · `get -o dir`,第一个资产成功、第二个 503(exit 7,`download "state": "partial", "files": [ { - "key": "model_glb", - "path": "/workspace/partial/model.glb", - "bytes": 49, - "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", - "content_type": "model/gltf-binary", + "key": "model_obj", + "path": "/workspace/relink-interrupt/model.obj", + "bytes": 4000028, + "sha256": "f00b46cb6d822e2ce93c6a7cf61251e80b76eb6ef71d9ef30bb07129330ac34d", + "content_type": "model/obj", "status": "written", "error": null, "relinked": false }, { - "key": "thumbnail", - "path": "/workspace/partial/thumbnail.png", - "bytes": 0, - "sha256": "", - "content_type": null, - "status": "failed", - "error": "download failed for http://127.0.0.1:/second.png (HTTP 503 Service Unavailable)", + "key": "model_mtl", + "path": "/workspace/relink-interrupt/model.mtl", + "bytes": 18, + "sha256": "997fd7d9f6b03ab40a1e252c6319d76c351d6f5ea2a431859077faee004243d6", + "content_type": "text/plain", + "status": "written", + "error": null, "relinked": false } ], - "metadata_path": null + "metadata_path": null, + "failed_step": "relink" }, "saved_json": null, "task_id": "round2-task", @@ -693,65 +617,103 @@ N06 · `get -o dir`,第一个资产成功、第二个 503(exit 7,`download } }, "error": { - "code": "network", - "message": "task round2-task is SUCCEEDED but downloading its assets failed: download failed for thumbnail: download failed for http://127.0.0.1:/second.png (HTTP 503 Service Unavailable)", - "http_status": 503, + "code": "interrupted", + "message": "task round2-task is SUCCEEDED but downloading its assets was interrupted: 2 file(s) were written but the relink step was interrupted: interrupted while rewriting model.obj", + "http_status": null, "retryable": false, "recovery": { "action": "download", "automatic": false, "command": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " }, - "details": { - "expired_or_denied": false - } + "hint": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " }, "warnings": [] } ``` -N07 · 无 user_id 的同名 OAuth profile 换 token 复用 `--operation-id`(exit 2,`credential_unverified`): +C04 · 通过符号链接父目录访问的项目(exit 0,`recorded_files=["model.glb"]`): ```json { "schema_version": "meshy.cli/v1", - "command": "text-to-3d.create", - "ok": false, + "command": "download", + "ok": true, "result": { - "submission": { - "state": "accepted", - "operation_id": "missing-subject-op", - "task_id": "oauth-account-a-task" + "source": { + "kind": "task-json", + "path": "/alias-projects/20260907_232307_alias-review_3a3b/task.json", + "shape": "api", + "resource": "text-to-3d", + "task_id": "round2-task" }, - "conflict": [ - "credential_unverified" - ] - }, - "error": { - "code": "operation_conflict", - "message": "operation missing-subject-op already exists but the current OAuth login has no account identity (profile without user_id or login_id), so it cannot be confirmed as the same account; nothing was submitted — run `meshy auth login` to bind this login, or use a new --operation-id", - "http_status": null, - "retryable": false, - "recovery": { - "action": "login", - "automatic": false, - "command": "meshy auth login" + "selection": { + "selected": [ + "model.glb" + ], + "dependencies": [] + }, + "downloads": { + "state": "completed", + "files": [ + { + "key": "model.glb", + "path": "/private/real-projects/20260907_232307_alias-review_3a3b/model.glb", + "relative_path": "model.glb", + "bytes": 49, + "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", + "content_type": "model/gltf-binary", + "format": "glb", + "container_format": null, + "extracted": null, + "status": "written", + "error": null, + "publish_method": "link", + "relinked": false + } + ], + "metadata_path": null, + "material_links": null + }, + "unknown_urls": [], + "saved_json": null, + "project": { + "project_dir": "/alias-projects/20260907_232307_alias-review_3a3b", + "action": "added", + "stage": "preview", + "recorded_files": [ + "model.glb" + ] } }, + "error": null, "warnings": [] } ``` -N08 · 资产传输中收到 SIGINT(exit 130,`file_created=false`): +C05 · 源文件名与生成名交叉碰撞后的 `model.mtl`(红材质 → 红图 `texture_0_base_color.png`): + +``` +newmtl red +map_Kd texture_0_base_color.png +newmtl green +map_Kd texture_1_base_color.png +``` + +C06 · 默认 legacy schema 同步 create,资产 503(exit 7;payload 含 `task_id`/`operation_id`/`result.submission`/`next`/manifest): ```json { - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.get", - "ok": false, + "name": "CliError", + "message": "download failed for model_glb: download failed for http://127.0.0.1:/missing.glb (HTTP 503 Service Unavailable)", + "code": "network", + "status": 503, + "hint": "meshy text-to-3d wait paid-legacy-created-id --output-schema v1", + "task_id": "paid-legacy-created-id", + "operation_id": "", "result": { "task": { - "task_id": "round2-task", + "task_id": "paid-legacy-created-id", "resource": "text-to-3d", "endpoint": "/openapi/v2/text-to-3d", "type": "text-to-3d-preview", @@ -766,7 +728,7 @@ N08 · 资产传输中收到 SIGINT(exit 130,`file_created=false`): "face_count": null, "consumed_credits": null, "model_urls": { - "glb": "http://127.0.0.1:/slow.glb" + "glb": "http://127.0.0.1:/missing.glb" }, "image_urls": [], "texture_urls": [], @@ -779,119 +741,118 @@ N08 · 资产传输中收到 SIGINT(exit 130,`file_created=false`): }, "submission": { "state": "accepted", - "operation_id": null + "operation_id": "", + "task_id": "paid-legacy-created-id", + "request_id": null }, "downloads": { "state": "failed", "files": [ { "key": "model_glb", - "path": "/workspace/interrupted.glb", + "path": "/workspace/legacy-created/model.glb", "bytes": 0, "sha256": "", "content_type": null, "status": "failed", - "error": "download of http://127.0.0.1:/slow.glb interrupted", + "error": "download failed for http://127.0.0.1:/missing.glb (HTTP 503 Service Unavailable)", "relinked": false } ], "metadata_path": null }, "saved_json": null, - "task_id": "round2-task", + "task_id": "paid-legacy-created-id", "next": { - "get": "meshy text-to-3d get round2-task --output-schema v1", - "wait": "meshy text-to-3d wait round2-task --output-schema v1", - "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" - } - }, - "error": { - "code": "interrupted", - "message": "task round2-task is SUCCEEDED but downloading its assets was interrupted: download failed for model_glb: download of http://127.0.0.1:/slow.glb interrupted", - "http_status": null, - "retryable": false, - "recovery": { - "action": "download", - "automatic": false, - "command": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " + "get": "meshy text-to-3d get paid-legacy-created-id --output-schema v1", + "wait": "meshy text-to-3d wait paid-legacy-created-id --output-schema v1", + "stream": "meshy text-to-3d stream paid-legacy-created-id --format ndjson --output-schema v1" + }, + "wait": { + "timed_out": false, + "elapsed_seconds": 0, + "polls": 1 } - }, - "warnings": [] + } } ``` ## 5. 实际验证记录 -全部绑定代码 HEAD `cf8905dd285bf16896df053aac253dbf8c672979`,环境 macOS 26.6 arm64 / Node v24.20.0 / pnpm 11.24.0;日志见 `docs/skill-parity/verification.json`(`final_runs`、`package_smoke`、`review_rounds`)。 +全部绑定代码 HEAD `235d6de34f4e78210cdf418a7273542fbd4ce906`,环境 macOS 26.6 arm64 / Node v24.20.0 / pnpm 11.24.0;日志见 `docs/skill-parity/verification.json`(`final_runs`、`package_smoke`、`review_rounds`)。 | 命令 / 测试 ID | 代码 SHA | 环境 | exit code | 结果 | 日志/fixture/报告 | | --- | --- | --- | --- | --- | --- | -| `node --version` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm --version` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm install --frozen-lockfile` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm typecheck` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm test` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed (535 tests, 535 pass, 0 fail) | verification.json final_runs | -| `pnpm build` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `bash -c test "$(node dist/index.js --version)" = "$(node -p "require(\"./package.json\").version")"` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/meshy-cli-0.3.0.tgz` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `git diff --check fd94490` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `for i in 1..8: node --import tsx --test tests/poll.test.ts` | cf8905dd285b | macOS arm64, Node 24.20.0 | 0 | passed — 8/8 runs, 9 tests each (deterministic clock tests + real-timer smoke asserting no GET starts after the deadline) | verification.json final_runs | -| npm pack 文件清单 + 必需文件 + 禁止内容 | cf8905dd285b | 同上 | 0 | passed(398 files;dist/skills/README/LICENSE/.env.example 存在;无 .env/credentials/tests/docs/src) | verification.json package_smoke | -| smoke:version | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:version_cli | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:help_cli | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:balance_help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:balance_nokey_json | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:balance_nokey_v1 | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:resources_v1 | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor_check_slicers | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor_check_api_nokey | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:slicer_detect | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:inspect_pass | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:inspect_fail | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 12 (expected 12) | passed | verification.json package_smoke | -| smoke:inspect_unknown | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 13 (expected 13) | passed | verification.json package_smoke | -| smoke:mesh_prepare | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:mesh_refuse_overwrite | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 11 (expected 11) | passed | verification.json package_smoke | -| smoke:project_init | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_record | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_show | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_list | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:download_list | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:download_no_selector | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | -| smoke:make_dry_run | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:usage_unknown_flag | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | -| smoke:uv_help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:creative_lab_help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:stream_help | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:catalog_live | cf8905dd285b | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| 第 2 轮复现脚本 `round2-probes.mjs`(8 场景) | cf8905dd285b | node v24.20.0,loopback,临时目录 | 1(= 并非全部复现) | 0/8 复现 | verification.json review_rounds[1].probe_rerun | -| 第 1 轮复现脚本 `round1-probes.mjs`(12 场景) | cf8905dd285b | 同上 | 1 | 0/12 复现(无回退) | verification.json review_rounds[0].reverified_on | -| 新增行为用例 T-001…T-108(离线部分)+ R01–R12 + N01–N08 | cf8905dd285b | node:test + tsx,loopback mock,临时目录 | 0 | passed(`pnpm test` 535/535) | tests/*.test.ts;verification.json behavior_tests | - -基线(fd94490)上 `pnpm test` 363/363 通过,无基线失败;本次没有删除或跳过任何既有测试。被改写的既有断言:round-1 `R10` 的下载失败退出码随 HTTP 分类保留由 11 改为 5(仍要求唯一 outcome 与 task 上下文);round-1 的真实时钟 poll 断言按 R2-F07 改为确定性时钟。 +| `node --version` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm --version` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm install --frozen-lockfile` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm typecheck` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `pnpm test` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed (542 tests, 542 pass, 0 fail) | verification.json final_runs | +| `pnpm build` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `bash -c test "$(node dist/index.js --version)" = "$(node -p "require(\"./package.json\").version")"` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/meshy-cli-0.3.0.tgz` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `git diff --check fd94490` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | +| `for i in 1..12: node --import tsx --test tests/poll.test.ts` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed — 12/12 runs; the real-timer smoke now judges with the loop's own clock reading (one +0.003 ms false failure was seen on 30536d8 before this change) | verification.json final_runs | +| `for i in 1..3: node --import tsx --test tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed — 3/3 runs (SIGINT-during-relink, sidecar race and other timing-sensitive cases) | verification.json final_runs | +| npm pack 文件清单 + 必需文件 + 禁止内容 | 235d6de34f4e | 同上 | 0 | passed(398 files;dist/skills/README/LICENSE/.env.example 存在;无 .env/credentials/tests/docs/src) | verification.json package_smoke | +| smoke:version | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:version_cli | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:help_cli | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:balance_help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:balance_nokey_json | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:balance_nokey_v1 | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:resources_v1 | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor_check_slicers | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:doctor_check_api_nokey | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | +| smoke:slicer_detect | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:inspect_pass | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:inspect_fail | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 12 (expected 12) | passed | verification.json package_smoke | +| smoke:inspect_unknown | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 13 (expected 13) | passed | verification.json package_smoke | +| smoke:mesh_prepare | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:mesh_refuse_overwrite | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 11 (expected 11) | passed | verification.json package_smoke | +| smoke:project_init | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_record | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_show | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:project_list | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:download_list | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:download_no_selector | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | +| smoke:make_dry_run | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:usage_unknown_flag | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | +| smoke:uv_help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:creative_lab_help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:stream_help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| smoke:catalog_live | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | +| 第 3 轮复现脚本 `round3-probes.mjs`(6 场景) | 235d6de34f4e | node v24.20.0,loopback,临时目录 | 1(= 并非全部复现) | 0/6 复现 | verification.json review_rounds[2].probe_rerun | +| 第 2 轮复现脚本 `round2-probes.mjs`(8 场景) | 235d6de34f4e | 同上 | 1 | 0/8 复现(无回退) | verification.json review_rounds[1].reverified_on | +| 第 1 轮复现脚本 `round1-probes.mjs`(12 场景) | 235d6de34f4e | 同上 | 1 | 0/12 复现(无回退) | verification.json review_rounds[0].reverified_on | +| reviewer 正向核对 `verify-original-regressions.py`(20 项) | 235d6de34f4e | 对上述重跑结果 | 0 | 20/20 passed | verification.json review_rounds[2].reviewer_positive_checker | +| reviewer 补测 `stream-finalization-check.mjs` | 235d6de34f4e | 同上 | 0 | passed | verification.json review_rounds[2].stream_finalization_check | +| 新增行为用例 T-001…T-108(离线部分)+ R01–R12 + N01–N08 + C01–C06 | 235d6de34f4e | node:test + tsx,loopback mock,临时目录 | 0 | passed(`pnpm test` 542/542) | tests/*.test.ts;verification.json behavior_tests | + +基线(fd94490)上 `pnpm test` 363/363 通过,无基线失败;本次没有删除或跳过任何既有测试。唯一改写的既有断言:poll 真实定时器 smoke 的判定时钟(见 §0),预期不放宽。 ## 6. 真实环境验证 | 环境/能力 | 身份/资源(脱敏) | 实际结果 | 未完成原因 | 补验方式 | | --- | --- | --- | --- | --- | -| API Key 与 OAuth profile 回归 | 无 | not_run | 本会话无测试账户/Key | 用测试 Key:`meshy doctor --check-api`、`meshy balance --output-schema v1`、`meshy auth status`;OAuth:`meshy auth login` 后 `meshy text-to-3d list --output-schema v1`;核对 D-043:真实 token 端点是否返回 `user_id`,登录后 profile 是否带 `login_id`,重新登录后 `--operation-id` 重放是否按预期冲突 | +| API Key 与 OAuth profile 回归 | 无 | not_run | 本会话无测试账户/Key | 用测试 Key:`meshy doctor --check-api`、`meshy balance --output-schema v1`、`meshy auth status`;OAuth:`meshy auth login` 后 `meshy text-to-3d list --output-schema v1`;核对 D-043:真实 token 端点是否返回 `user_id`,登录后 profile 是否带 `login_id` | | UV / Creative Lab | 无 | not_run | 计费/账号 gate,未获预算授权 | `meshy uv-unwrap create --input-task-id <已有 SUCCEEDED 任务> --async --output-schema v1` → `wait`;`meshy creative-lab figure prototype create --image-url --async` → `wait` → `build create --input-task-id`;记录 task id、请求次数、消耗额度 | | Enterprise showcases(计费查询) | 无 | not_run | 每次请求计费且需 Enterprise | `meshy showcases list --page-size 1 --output-schema v1`(一次);核对 `showcase_type=animated` 别名是否被服务端接受(D-007) | -| 公开动画目录(免费、无鉴权) | 无需身份 | passed(1 次 GET,157 条,8 条匹配 "wave",在 cf8905d 重跑) | — | verification.json live_verification T-110 partial | +| 公开动画目录(免费、无鉴权) | 无需身份 | passed(1 次 GET,157 条,8 条匹配 "wave",在 235d6de 重跑) | — | verification.json live_verification T-110 partial | | macOS arm64 | 本机 | passed(tarball 安装 + 29 项 smoke) | — | verification.json package_smoke | | Windows x64 | 无 | not_run | 无主机 | 安装 tarball,运行 `meshy doctor --check-slicers`、`meshy slicer detect`、`meshy mesh prepare-print`;检测规则已用 fixture 覆盖(T-088/T-089) | | Linux x64 | 无 | not_run | 无主机 | 同上;CI(ubuntu, Node 24/26)会在推送后覆盖安装 smoke | | 真实 slicer open | 无 | not_run | 本机未安装任何注册切片器 | 安装 OrcaSlicer 后 `meshy slicer open --slicer OrcaSlicer --file `,仅验证启动 | -| 真实 Meshy 多材质 OBJ 的引用重链接(D-039) | 无 | not_run | 需要真实 refine 任务的 OBJ+MTL+多组贴图 | `meshy download --resource text-to-3d --task-id --model-format obj --output-dir `,检查 `material_links.status` 与 `texture_maps[].method`(真实 MTL 是否引用 URL 末段文件名) | +| 真实 Meshy 多材质 OBJ 的引用重链接(D-039/D-047) | 无 | not_run | 需要真实 refine 任务的 OBJ+MTL+多组贴图 | `meshy download --resource text-to-3d --task-id --model-format obj --output-dir `,检查 `material_links.status` 与 `texture_maps[].method/note`(真实 MTL 是否引用 URL 末段文件名) | ## 7. 打包证据 - 候选版本:`0.3.0`(package.json,**未发布**;版本号可由 reviewer 调整) -- tarball 路径:`/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/meshy-cli-0.3.0.tgz`(`npm pack --json --pack-destination`;复现见 §9) -- SHA256:`3077563a16fb9ad00b3be5c01d69724ba3122fe1c60d7abb69cc47e201835f2a` +- tarball 路径:`/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/meshy-cli-0.3.0.tgz`(`npm pack --json --pack-destination`;复现见 §9) +- SHA256:`f29e8a168af1c66fe58c9ee53a4b392beca41350bccf55bf58c22eebf5e5f930` - npm pack 文件清单:398 个文件;必需项 dist/index.js、skills/meshy-cli/SKILL.md、skills/meshy-cli/animation-library.json、README.md、LICENSE、.env.example、package.json 均在;无 .env / credentials / tests / docs / src / node_modules - 临时安装 prefix:`npm install -g --prefix ` → `/bin/meshy` 与 `/bin/meshy-cli` 均指向 `dist/index.js` - 两个 bin、sharp、本地无 Key/无 Python 验证:29 项 smoke 全部符合预期(含 `MESHY_API_KEY=""` 下 balance 的 stdout 为 JSON、exit 3;无 Key 的本地命令 exit 0;dist 中不含 python 引用) @@ -899,31 +860,34 @@ N08 · 资产传输中收到 SIGINT(exit 130,`file_created=false`): ## 8. 需要 Codex 复审的事项 -1. **写入根覆盖面(R2-F01)**:`saveReportOnly`/`downloadAssets`/`downloadArtifacts` 的检查是否都在任何 mkdir/临时文件之前;`indexRootFor` 的“越界则跳过索引”是否可接受(reviewer 允许拒绝或跳过,本轮选择跳过并显式 `index_dirty`);`project show/list` 仍为只读未限制。 -2. **材质映射规则(R2-F02)**:源文件名/stem 匹配的大小写与扩展名策略;`only_texture` 规则仅在“唯一贴图且唯一引用”时生效;是否需要把 `material_links.status=incomplete` 提升为非零退出(当前所有文件都已落盘,命令 exit 0 + warning)。 -3. **stream 收尾(R2-F03)**:`bookkeepingError` 与流本身失败并存时的取舍(流失败为 outcome,记账失败为 warning)。 -4. **manifest 合并(R2-F04)**:`withTaskContext` 中 `err.result.downloads` 覆盖默认 `not_requested` 的顺序;legacy 错误 payload 从 `{name:"Error"}` 变为带 `code/status/result` 的 CliError payload 是否可接受(迁移说明 §3.2)。 -5. **信号传递(R2-F05)**:`fetchToTemp` 对 body 中途 abort 的临时文件清理;`index.ts` 重包装保留 `result/recovery/hint` 但仍改写 `code=interrupted`。 -6. **OAuth 身份(R2-F06)**:`login_id` 的生成点(`finishLogin`)是否覆盖所有登录路径(loopback/device/manual 均经 `finishLogin`);refresh 保留;unverified 只拒绝重放而不阻止新操作的取舍;D-043 对存量 profile 的迁移说明。 -7. **确定性测试(R2-F07)**:fake clock 的 early-wake 建模(-0.5 ms)是否足以代表真实定时器;真实时钟 smoke 只断言“截止后不启动 GET”。 -8. 上两轮 §8 的其余事项仍然有效且本轮未改动相关代码(路由等价、transport 边界、锁顺序、slicer OS 行为、not_run 项)。 +1. **sidecar 发布(R3-F01)**:`writeMeta` 现在 `resolveWithinRoot`(拒绝符号链接叶子)+ `writeJsonFile` 独占发布;预检仍保留为早退。`saveReportOnly` 目录模式同路径;单文件模式的报告 JSON 已在写入前紧邻校验。legacy sidecar 文件权限从默认改为显式 0o644。 +2. **legacy 任务上下文(R3-F02)**:`toErrorPayload` 为 CliError 附加顶层 `task_id`/`operation_id`(仅在 result 中存在时);`hint` 回退为恢复命令 → stderr 出现任务 id;`make` legacy 的 `submission.operation_id` 取自最后一步 executed 记录。 +3. **材质身份(R3-F03)**:来源名优先于落盘名;生成名引用而来源为其它文件 → ambiguous + `note`;通道规则在多个不同引用竞争同一唯一贴图时判 ambiguous(两个材质各引用一张不同图但只下载到一张同通道贴图时保持原样并报警,不把唯一贴图分给其中一个);无来源证据的单测回退保留。 +4. **收尾失败处理(R3-F04)**:`finalisationFailure` 从磁盘重取摘要并以摘要变化推断 `relinked`;`failed_step` 三值;`downloadAssets` 的 relink 同样包裹;digest 步骤本身失败(读文件失败)也走同一路径。 +5. **relink 中断(R3-F05)**:`rewriteLines` 在每个 chunk 后检查 signal,发布前再检查并删除临时文件;OBJ 要么原样要么完整重写;C03 用轮询观察 `.model.obj.tmp-*` 触发 SIGINT(约 12 MB OBJ),并有 relinkMaterials 已中止 / 2 ms 后中止的进程内单测。 +6. **别名路径(R3-F06)**:`download --project` 的 `relative(realpath(project), realpath(file))`;`attachToProject` 的 `extra.files` 目前无调用方传入路径,故未改。 +7. **测试稳定性**:poll smoke 以循环的 `now()` 读数判定“截止前决策”(`starts.every(t < deadline)`),不再用 endpoint 内新取的时钟;确定性 fake-clock 用例不变;12/12 重复通过。 +8. 前三轮 §8 的其余事项仍然有效且本轮未改动相关代码(路由等价、transport 边界、锁顺序、slicer OS 行为、not_run 项)。 ## 9. 最短复现步骤 ```sh git clone https://github.com/meshy-dev/meshy-cli.git && cd meshy-cli -git fetch feat/skill-parity-s1 && git checkout cf8905dd285bf16896df053aac253dbf8c672979 # 或使用本地仓库 /Users/ark/Dev/meshy-cli +git fetch feat/skill-parity-s1 && git checkout 235d6de34f4e78210cdf418a7273542fbd4ce906 # 或使用本地仓库 /Users/ark/Dev/meshy-cli node --version # v24.x corepack enable && pnpm --version # 11.24.0 pnpm install --frozen-lockfile pnpm typecheck pnpm test # 先 tsc 生成 dist,再运行 node:test(约 41 s;loopback mock,无外网、无凭据) -node --import tsx --test tests/codex-review-round2.test.ts tests/codex-review-round1.test.ts tests/poll.test.ts # 两轮回归 + 确定性 deadline -for i in 1 2 3 4 5 6 7 8; do node --import tsx --test tests/poll.test.ts >/dev/null || echo "run $i failed"; done +node --import tsx --test tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts tests/codex-review-round1.test.ts tests/poll.test.ts +for i in $(seq 1 12); do node --import tsx --test tests/poll.test.ts >/dev/null || echo "run $i failed"; done pnpm build -mkdir -p /tmp/probes && cp /path/to/reviews/cli-s1-730132b/round2-probes.mjs /path/to/reviews/cli-s1-730132b/round1-probes.mjs /tmp/probes/ +mkdir -p /tmp/probes && cp /path/to/reviews/cli-s1-cf8905d/round3-probes.mjs /path/to/reviews/cli-s1-cf8905d/round2-probes.mjs /path/to/reviews/cli-s1-cf8905d/round1-probes.mjs /path/to/reviews/cli-s1-cf8905d/stream-finalization-check.mjs /path/to/reviews/cli-s1-cf8905d/verify-original-regressions.py /tmp/probes/ +node /tmp/probes/round3-probes.mjs "$PWD" # 期望 exit 1 且 6 个 reproduced=false node /tmp/probes/round2-probes.mjs "$PWD" # 期望 exit 1 且 8 个 reproduced=false node /tmp/probes/round1-probes.mjs "$PWD" # 期望 exit 1 且 12 个 reproduced=false +python3 /tmp/probes/verify-original-regressions.py # 期望 passed 20 / total 20 +node /tmp/probes/stream-finalization-check.mjs "$PWD" git diff --check fd94490 npm pack --json --pack-destination /tmp/meshy-pack && shasum -a 256 /tmp/meshy-pack/meshy-cli-0.3.0.tgz npm install -g --prefix /tmp/meshy-prefix /tmp/meshy-pack/meshy-cli-0.3.0.tgz @@ -932,10 +896,10 @@ export MESHY_CONFIG_DIR=$(mktemp -d) MESHY_API_KEY= /tmp/meshy-prefix/bin/meshy balance --output-schema v1 ; echo "exit=$?" # 3, stdout 为 v1 envelope ``` -不依赖作者机器上的凭据、Python 或全局包;`tests/helpers/cli.ts` 为每次子进程创建隔离的 `MESHY_CONFIG_DIR` 与 cwd,并显式指向 loopback mock。复现脚本请从副本运行(它们在自身目录写结果文件);脚本 exit 0 表示缺陷全部复现,exit 1 表示并非全部复现——正向验收以仓库内测试为准。 +不依赖作者机器上的凭据、Python 或全局包;`tests/helpers/cli.ts` 为每次子进程创建隔离的 `MESHY_CONFIG_DIR` 与 cwd,并显式指向 loopback mock。复现脚本请从副本运行(它们在自身目录写结果文件);脚本 exit 0 表示缺陷全部复现,exit 1 表示并非全部复现——正向验收以仓库内测试与 reviewer 的正向核对脚本为准。 ## 10. 交给 Codex 的复审提示词 -请复审 Meshy CLI S1 第 2 轮 review(reviews/cli-s1-730132b,R2-F01–R2-F07)的修复,并确认第 1 轮(reviews/cli-s1-6273d9a,F01–F10)修复未回退。仓库、base/head SHA 见本文件;代码 HEAD `cf8905dd285bf16896df053aac253dbf8c672979`(本文件所在 docs commit 不改代码),规范仍是 2026-09-07 v1 实施包。先核实 diff `730132bd99a471ed41d6bbf6b200219f13f569ba..cf8905dd285bf16896df053aac253dbf8c672979` 与 verification.json/capability-matrix.json 绑定同一 HEAD,再逐项核对 §0 表:修复是否完整覆盖 finding 的触发条件与 acceptance、是否引入回归、正向回归测试是否真正断言了 expected(退出码、JSON 形状、task_id/submission、请求次数、落盘内容与 manifest、ndjson 唯一 outcome 与序号、被拒目标连目录都不创建),并重跑两个复现脚本副本、`pnpm test`、`git diff --check`。 +请复审 Meshy CLI S1 第 3 轮 review(reviews/cli-s1-cf8905d,R3-F01–R3-F06)的修复,并确认前两轮(reviews/cli-s1-6273d9a F01–F10、reviews/cli-s1-730132b R2-F01–R2-F07)修复未回退。仓库、base/head SHA 见本文件;代码 HEAD `235d6de34f4e78210cdf418a7273542fbd4ce906`(= 修复 `30536d8` + 测试稳定性提交;本文件所在 docs commit 不改代码),规范仍是 2026-09-07 v1 实施包。先核实 diff `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a..235d6de34f4e78210cdf418a7273542fbd4ce906` 与 verification.json/capability-matrix.json 绑定同一 HEAD,再逐项核对 §0 表:修复是否完整覆盖 finding 的触发条件与 acceptance(预检后目标变化、legacy 任务身份、下载完成后的失败与中断、材质来源身份、项目路径别名)、是否引入回归、正向回归测试是否真正断言了 expected(退出码、JSON 形状、task_id/submission/operation_id、请求次数、落盘内容与 manifest hash、ndjson 唯一 outcome 与序号、被拒目标连目录都不创建、外部文件字节不变),并重跑三个复现脚本副本、`verify-original-regressions.py`、`pnpm test`、`git diff --check`。 请优先检查 §8 列出的 8 项风险,以及任何新的可复现 bug、重复付费风险、凭据/路径问题、丢失资产/项目记录、OBJ 错误和伪造完成状态。不要做未授权付费调用、发布或 Skill/MCP 迁移。每项 finding 给出优先级、文件/行号、触发条件、影响与修复建议;区分代码缺陷、测试缺口与尚未完成的外部验证。最后分别判断 G1-code 是否可接受、G1-release 是否满足以及是否允许进入 S2。没有发现也要说明验证范围和剩余限制。 diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index 646e04e..1519875 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -2,7 +2,7 @@ "schema_version": 1, "title": "Meshy CLI S1 capability matrix — implementation tracking", "date": "2026-09-07", - "status": "review round 2 (Codex, reviewed 730132b): changes_requested → all 7 findings fixed in cf8905d (round 1: 10/10 fixed in 0388fe8, re-verified); G1-code re-review pending; G1-release not_run", + "status": "review round 3 (Codex, reviewed 566f3bd/cf8905d): changes_requested → all 6 findings fixed in 30536d8 (+ test stabilisation 235d6de); rounds 1–2 (17 findings) re-verified; G1-code re-review pending; G1-release not_run", "baseline": { "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" @@ -20,7 +20,7 @@ "target": "meshy create --async", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-040", "T-043", @@ -29,7 +29,8 @@ ], "evidence": [ "src/internal/task-command.ts submitOnce: journal → single POST → journal; tests/task-lifecycle.test.ts T-040/T-043/T-044", - "tests/codex-review-round1.test.ts R05/F01, R06/F01" + "tests/codex-review-round1.test.ts R05/F01, R06/F01", + "tests/codex-review-round3.test.ts C06 (legacy sync create keeps the accepted task)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -42,6 +43,16 @@ "tests": [ "tests/codex-review-round1.test.ts R05/F01, R06/F01" ] + }, + "review_round_3": { + "findings": [ + "R3-F02" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy sync create keeps the accepted task)" + ] } }, { @@ -55,7 +66,7 @@ "target": "meshy get --include-raw --save-json", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-006", "T-008", @@ -64,7 +75,8 @@ "evidence": [ "task-view.ts + task-command get: any status exit 0, --include-raw, --save-json; tests T-006/T-008/T-009/T-010", "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)", - "tests/codex-review-round2.test.ts N01 (report-only get -o), N06 (partial manifest), N08 (SIGINT)" + "tests/codex-review-round2.test.ts N01 (report-only get -o), N06 (partial manifest), N08 (SIGINT)", + "tests/codex-review-round3.test.ts C01+C02 (get -o sidecar race / finalisation manifest), C03 (relink interrupt), C06 (legacy get)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -90,6 +102,19 @@ "tests": [ "tests/codex-review-round2.test.ts N01 (report-only get -o), N06 (partial manifest), N08 (SIGINT)" ] + }, + "review_round_3": { + "findings": [ + "R3-F01", + "R3-F02", + "R3-F04", + "R3-F05" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C01+C02 (get -o sidecar race / finalisation manifest), C03 (relink interrupt), C06 (legacy get)" + ] } }, { @@ -103,7 +128,7 @@ "target": "meshy wait", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-007", "T-047", @@ -114,7 +139,8 @@ "tests/codex-review-round1.test.ts R03/F07 ×2, R06/F01", "tests/poll.test.ts deadline tests", "tests/codex-review-round2.test.ts N01/N06/N08 (wait -o)", - "tests/poll.test.ts deterministic deadline tests" + "tests/poll.test.ts deterministic deadline tests", + "tests/codex-review-round3.test.ts C06 (legacy wait -o)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -143,6 +169,16 @@ "tests/codex-review-round2.test.ts N01/N06/N08 (wait -o)", "tests/poll.test.ts deterministic deadline tests" ] + }, + "review_round_3": { + "findings": [ + "R3-F02" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy wait -o)" + ] } }, { @@ -156,7 +192,7 @@ "target": "meshy stream", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-049", "T-050", @@ -168,7 +204,8 @@ "meshyd api_stream_handlers.go: event message (task), event error {message,status_code}, keep-alive re-emits message every 10 s", "stream.ts SSE parser + streamTask; task-command stream (ndjson events + outcome, json envelope); tests/sse.test.ts, T-053", "tests/codex-review-round1.test.ts R10/F10", - "tests/codex-review-round2.test.ts N01 (stream -o), N05 (single outcome on post-stream failures)" + "tests/codex-review-round2.test.ts N01 (stream -o), N05 (single outcome on post-stream failures)", + "tests/codex-review-round3.test.ts C06 (legacy stream path shares emitLegacyOutcome wrapping)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -194,6 +231,16 @@ "tests": [ "tests/codex-review-round2.test.ts N01 (stream -o), N05 (single outcome on post-stream failures)" ] + }, + "review_round_3": { + "findings": [ + "R3-F02" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy stream path shares emitLegacyOutcome wrapping)" + ] } }, { @@ -233,7 +280,7 @@ "target": "existing commands with explicit supported payload", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-028", "T-033", @@ -267,7 +314,7 @@ "target": "typed media flags and final payload normalization", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-029", "T-030", @@ -352,7 +399,7 @@ "target": "meshy creative-lab lamp prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-022", "T-023", @@ -391,7 +438,7 @@ "target": "meshy creative-lab keychain prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-022", "T-023", @@ -428,7 +475,7 @@ "target": "meshy creative-lab fridge-magnet prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-022", "T-023", @@ -514,7 +561,7 @@ "target": "download asset registry with role and dependencies", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-060", "T-062", @@ -523,7 +570,8 @@ "evidence": [ "src/internal/artifacts.ts enumerateAssets: model formats (lamp parts STL/ZIP, keychain/fridge OBJ as ZIP bundle), images, textures, primary/multi-view/alpha thumbnails, rig + basic_animations, motion, report; unknown URLs listed; tests/artifacts.test.ts T-060/T-062/T-071", "tests/codex-review-round1.test.ts R07/F09", - "tests/codex-review-round2.test.ts N04 (source-name mapping, ambiguity)" + "tests/codex-review-round2.test.ts N04 (source-name mapping, ambiguity)", + "tests/codex-review-round3.test.ts C05 (source identity vs generated names)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -546,6 +594,16 @@ "tests": [ "tests/codex-review-round2.test.ts N04 (source-name mapping, ambiguity)" ] + }, + "review_round_3": { + "findings": [ + "R3-F03" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C05 (source identity vs generated names)" + ] } }, { @@ -559,7 +617,7 @@ "target": "meshy download --asset/--kind/--model-format", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-061", "T-063" @@ -567,7 +625,8 @@ "evidence": [ "src/cmd/download.ts selectors --asset/--model-format/--kind/--all/--list; tests/download-command.test.ts T-061, tests/artifacts.test.ts T-063", "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)", - "tests/codex-review-round2.test.ts N04" + "tests/codex-review-round2.test.ts N04", + "tests/codex-review-round3.test.ts C05" ], "intentional_differences": [ "download requires an explicit selector when a task exposes several assets (legacy defaulted to glb); legacy -o keeps downloading everything" @@ -592,6 +651,16 @@ "tests": [ "tests/codex-review-round2.test.ts N04" ] + }, + "review_round_3": { + "findings": [ + "R3-F03" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C05" + ] } }, { @@ -605,7 +674,7 @@ "target": "safe download with actual final-path manifest", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-064", "T-065", @@ -618,7 +687,8 @@ "evidence": [ "src/internal/download.ts fetchToTemp (no credential, redirect re-validation, private-network refusal, size cap, sha256), exclusive publish + realpath containment, partial manifests; tests T-064..T-070", "tests/codex-review-round1.test.ts R07/F09, R09/F03", - "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08" + "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08", + "tests/codex-review-round3.test.ts C01+C02, C03, C05" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -645,6 +715,19 @@ "tests": [ "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08" ] + }, + "review_round_3": { + "findings": [ + "R3-F01", + "R3-F03", + "R3-F04", + "R3-F05" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C01+C02, C03, C05" + ] } }, { @@ -658,7 +741,7 @@ "target": "--save-json and project task snapshots", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-010", "T-063" @@ -702,7 +785,7 @@ "target": "meshy project init", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-072", "T-077" @@ -735,7 +818,7 @@ "target": "meshy project record/show", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-073", "T-074", @@ -744,7 +827,8 @@ "evidence": [ "project-store recordTask (task_id+stage merge, legacy v1 migration with backup, unknown fields kept) + project record/show; tests T-073/T-074/T-075", "tests/codex-review-round1.test.ts R08/F03", - "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)" + "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)", + "tests/codex-review-round3.test.ts C04 (aliased project records files)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -767,6 +851,16 @@ "tests": [ "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)" ] + }, + "review_round_3": { + "findings": [ + "R3-F06" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C04 (aliased project records files)" + ] } }, { @@ -780,7 +874,7 @@ "target": "meshy project list/rebuild-index", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-073", "T-075", @@ -852,7 +946,7 @@ "target": "meshy mesh prepare-print", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-083", "T-084", @@ -962,7 +1056,7 @@ "target": "--env-file without automatic dotenv/shell scanning", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 findings fixed (re-verified on cf8905d); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-100", "T-101" @@ -997,7 +1091,7 @@ "target": "preserve existing auth commands and token protocol", "baseline_status": "existing_needs_regression", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-104", "T-105" @@ -1043,7 +1137,7 @@ "target": "--output-schema v1; legacy compatibility", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-001", "T-002", @@ -1054,7 +1148,8 @@ ], "evidence": [ "result.ts envelope, errors.ts codes/exits, Commander errors → exit 2; tests result.test.ts, cli-contract T-002..T-005", - "tests/codex-review-round2.test.ts N05 (ndjson outcome/sequence), N08 (exit 130 keeps result)" + "tests/codex-review-round2.test.ts N05 (ndjson outcome/sequence), N08 (exit 130 keeps result)", + "tests/codex-review-round3.test.ts C06 (legacy error payload additive task_id/operation_id), C03 (exit 130 after relink interrupt)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -1068,6 +1163,17 @@ "tests": [ "tests/codex-review-round2.test.ts N05 (ndjson outcome/sequence), N08 (exit 130 keeps result)" ] + }, + "review_round_3": { + "findings": [ + "R3-F02", + "R3-F05" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy error payload additive task_id/operation_id), C03 (exit 130 after relink interrupt)" + ] } }, { @@ -1081,7 +1187,7 @@ "target": "make --async / --stop-after-first", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "rounds 1–3 findings fixed; re-review pending", "test_ids": [ "T-041", "T-042" @@ -1089,7 +1195,8 @@ "evidence": [ "src/cmd/make.ts --async single POST, --stop-after-first, mutual exclusion; tests T-041/T-042", "tests/codex-review-round1.test.ts R11/F02, F01 make polling, R09/F03 make -o", - "tests/codex-review-round2.test.ts N06 (make -o partial manifest)" + "tests/codex-review-round2.test.ts N06 (make -o partial manifest)", + "tests/codex-review-round3.test.ts C06 (legacy make -o 503; v1 make SIGINT)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -1115,6 +1222,17 @@ "tests": [ "tests/codex-review-round2.test.ts N06 (make -o partial manifest)" ] + }, + "review_round_3": { + "findings": [ + "R3-F02", + "R3-F05" + ], + "status": "fixed", + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "tests": [ + "tests/codex-review-round3.test.ts C06 (legacy make -o 503; v1 make SIGINT)" + ] } }, { @@ -1128,7 +1246,7 @@ "target": "operation journal; never claim server idempotency", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "round 1 + round 2 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", "test_ids": [ "T-043", "T-044", @@ -1279,8 +1397,8 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-07T10:25:20.965Z", - "code_head": "cf8905dd285bf16896df053aac253dbf8c672979" + "updated_at": "2026-09-07T15:29:23.126Z", + "code_head": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "review_rounds": [ { @@ -1323,6 +1441,25 @@ "fixed_in": "cf8905dd285bf16896df053aac253dbf8c672979", "regression_tests": "tests/codex-review-round2.test.ts (N01–N08 as positive assertions) + tests/poll.test.ts (deterministic clock) + tests/auth-headless.test.ts (login_id)", "probe_rerun": "reviews/cli-s1-730132b/round2-probes.mjs on cf8905d: 0/8 defects reproduce; round1-probes.mjs: 0/12" + }, + { + "round": 3, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-cf8905d", + "reviewed_head": "566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a", + "verdict": "changes_requested", + "findings": [ + "R3-F01", + "R3-F02", + "R3-F03", + "R3-F04", + "R3-F05", + "R3-F06" + ], + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "code_head": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "regression_tests": "tests/codex-review-round3.test.ts (C01–C06 as positive assertions, relink abort unit tests, make interrupt)", + "probe_rerun": "reviews/cli-s1-cf8905d/round3-probes.mjs on 235d6de: 0/6 defects reproduce; round2 0/8; round1 0/12; reviewer's verify-original-regressions.py 20/20" } ] } diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json index cfa81b8..4bba377 100644 --- a/docs/skill-parity/verification.json +++ b/docs/skill-parity/verification.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "status": "review round 2 fixes applied (Codex reviewed 730132b: changes_requested, 7 findings); re-review pending; live checks not_run", + "status": "review round 3 fixes applied (Codex reviewed 566f3bd/cf8905d: changes_requested, 6 findings); re-review pending; live checks not_run", "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", "environment": { "os": "macOS 26.6 (Darwin 25.6.0) arm64", @@ -43,85 +43,93 @@ "final_runs": [ { "command": "node --version", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm --version", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { "command": "pnpm install --frozen-lockfile", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, - "duration_s": 0, + "duration_s": 1, "result": "passed" }, { "command": "pnpm typecheck", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm test", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "duration_s": 41, "result": "passed", - "tests": 535, - "pass": 535, + "tests": 542, + "pass": 542, "fail": 0, "skipped": 0 }, { "command": "pnpm build", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, - "duration_s": 0, + "duration_s": 1, "result": "passed" }, { - "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "duration_s": 0, "result": "passed" }, { - "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/meshy-cli-0.3.0.tgz", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/meshy-cli-0.3.0.tgz", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, - "duration_s": 2, + "duration_s": 1, "result": "passed" }, { "command": "git diff --check fd94490", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "duration_s": 0, "result": "passed" }, { - "command": "for i in 1..8: node --import tsx --test tests/poll.test.ts", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "for i in 1..12: node --import tsx --test tests/poll.test.ts", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "exit_code": 0, + "duration_s": 6, + "result": "passed", + "detail": "12/12 runs; the real-timer smoke now judges with the loop's own clock reading (one +0.003 ms false failure was seen on 30536d8 before this change)" + }, + { + "command": "for i in 1..3: node --import tsx --test tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, - "duration_s": 4, + "duration_s": 20, "result": "passed", - "detail": "8/8 runs, 9 tests each (deterministic clock tests + real-timer smoke asserting no GET starts after the deadline)" + "detail": "3/3 runs (SIGINT-during-relink, sidecar race and other timing-sensitive cases)" } ], "behavior_tests": { @@ -130,7 +138,7 @@ "files": [ "tests/cli-contract.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-002": { "result": "passed", @@ -138,71 +146,75 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-003": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-004": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-005": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-006": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-007": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-008": { "result": "passed", "files": [ "tests/task-view.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-009": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts", - "tests/codex-review-round2.test.ts" + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F10: R10 stream -o in ndjson/json/pretty; one outcome on download failure", - "review_round_2": "R2-F01 N01 report-only get/wait/stream -o confined; R2-F04 N06 partial manifest; R2-F05 N08 SIGINT" + "review_round_2": "R2-F01 N01 report-only get/wait/stream -o confined; R2-F04 N06 partial manifest; R2-F05 N08 SIGINT", + "review_round_3": "R3-F02 C06: legacy get/wait/create/make -o failures name the task (task_id, operation_id, next, manifest, hint)" }, "T-010": { "result": "passed", "files": [ "tests/cli-contract.test.ts", "tests/codex-review-round1.test.ts", - "tests/codex-review-round2.test.ts" + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F01: R05 save-json preflight + post-POST failure keeps the id", - "review_round_2": "R2-F04 N06: task -o failure keeps HTTP class/status and the files already written" + "review_round_2": "R2-F04 N06: task -o failure keeps HTTP class/status and the files already written", + "review_round_3": "R3-F01/R3-F04 C01+C02: sidecar planted after the preflight refused; committed model kept in the manifest with failed_step" }, "T-011": { "result": "passed", @@ -210,28 +222,28 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-012": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-020": { "result": "passed", "files": [ "tests/resource-registry.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-021": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-022": { "result": "passed", @@ -239,7 +251,7 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-023": { "result": "passed", @@ -247,14 +259,14 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-024": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-025": { "result": "passed", @@ -262,7 +274,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F08: R04 three-layer options merge on the real POST body" }, "T-026": { @@ -270,14 +282,14 @@ "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-027": { "result": "passed", "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-028": { "result": "passed", @@ -285,7 +297,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F08: R04" }, "T-029": { @@ -294,7 +306,7 @@ "tests/file-input.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F06: R02 equal-length images conflict, re-wrapped base64 replays, no base64 in journal" }, "T-030": { @@ -302,28 +314,28 @@ "files": [ "tests/file-input.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-031": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-032": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-040": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-041": { "result": "passed", @@ -331,7 +343,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F02: R11 make journal failure after acceptance is local_io with the id (text + image routes)" }, "T-042": { @@ -340,7 +352,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F02: R11" }, "T-043": { @@ -349,23 +361,25 @@ "tests/operation-store.test.ts", "tests/task-lifecycle.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-044": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-045": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/codex-review-round3.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", - "review_round_1": "F01: R06 polling 5xx keeps id/submission/next; make polling failure keeps executed steps" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "review_round_1": "F01: R06 polling 5xx keeps id/submission/next; make polling failure keeps executed steps", + "review_round_3": "R3-F02 C06: legacy sync create keeps the accepted task on an asset 503 with exactly one POST" }, "T-046": { "result": "passed", @@ -374,7 +388,7 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "barrier-released three-process race; F05/F06 fingerprint unit tests" }, "T-047": { @@ -384,7 +398,7 @@ "tests/codex-review-round1.test.ts", "tests/poll.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F07: deadline bounds the sleep; --timeout 0 request cap", "review_round_2": "R2-F07: deterministic fake-clock deadline tests; real-timer smoke asserts only no GET after the deadline" }, @@ -393,39 +407,41 @@ "files": [ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts", - "tests/codex-review-round2.test.ts" + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F01: task context on every post-acceptance failure", - "review_round_2": "R2-F05 N08: SIGINT before headers / mid-body / on the second asset → 130 with manifest, no leftovers" + "review_round_2": "R2-F05 N08: SIGINT before headers / mid-body / on the second asset → 130 with manifest, no leftovers", + "review_round_3": "R3-F05 C03: SIGINT during the OBJ rewrite → 130, digests re-taken, no sidecar, no temp file; make interrupt" }, "T-049": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-050": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-051": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-052": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-053": { "result": "passed", @@ -435,7 +451,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F10: R10", "review_round_2": "R2-F03 N05: save-json/project/download failures after the stream end in one outcome with the next sequence" }, @@ -444,7 +460,7 @@ "files": [ "tests/artifacts.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-061": { "result": "passed", @@ -452,7 +468,7 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-062": { "result": "passed", @@ -460,66 +476,72 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts", "tests/codex-review-round1.test.ts", - "tests/codex-review-round2.test.ts" + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F09: R07 rewritten mtllib/map_* references verified on disk; unresolved map reported", - "review_round_2": "R2-F02 N04: multi-material textures mapped by source name; ambiguity kept + warned (v1 and legacy -o)" + "review_round_2": "R2-F02 N04: multi-material textures mapped by source name; ambiguity kept + warned (v1 and legacy -o)", + "review_round_3": "R3-F03 C05: source name beats a colliding generated name (verified by image bytes); generated-name reference with another source is ambiguous" }, "T-063": { "result": "passed", "files": [ "tests/artifacts.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-064": { "result": "passed", "files": [ "tests/download-command.test.ts", - "tests/codex-review-round2.test.ts" + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", - "review_round_2": "R2-F01 N03: refused download creates no directory; zero requests" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "review_round_2": "R2-F01 N03: refused download creates no directory; zero requests", + "review_round_3": "R3-F01 C01: sidecar publication re-proves the root and refuses symlink/file/directory targets" }, "T-066": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-067": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-068": { "result": "passed", "files": [ "tests/download-command.test.ts", "tests/codex-review-round1.test.ts", - "tests/codex-review-round2.test.ts" + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F03/F04: R08 project roots, R09 task -o, R12 dependency symlink escapes", - "review_round_2": "R2-F01 N01/N03: outside/symlinked targets refused before mkdir for report tasks and downloads" + "review_round_2": "R2-F01 N01/N03: outside/symlinked targets refused before mkdir for report tasks and downloads", + "review_round_3": "R3-F01 C01: outside file bytes unchanged across the sidecar race, single-file and legacy variants" }, "T-069": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-070": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-071": { "result": "passed", @@ -527,39 +549,41 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-072": { "result": "passed", "files": [ "tests/project-store.test.ts", "tests/codex-review-round1.test.ts", - "tests/codex-review-round2.test.ts" + "tests/codex-review-round2.test.ts", + "tests/codex-review-round3.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F03: R08 project init/record/rebuild-index confined to --workspace", - "review_round_2": "R2-F01 N02: implicit history root confined; metadata recorded, parent untouched" + "review_round_2": "R2-F01 N02: implicit history root confined; metadata recorded, parent untouched", + "review_round_3": "R3-F06 C04: aliased project directory records its files; truly outside files still not recorded" }, "T-073": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-074": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-075": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-076": { "result": "passed", @@ -567,7 +591,7 @@ "tests/project-store.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_2": "R2-F01 N02: index skipped with index.updated=false + reason when the root is outside --workspace" }, "T-077": { @@ -575,49 +599,49 @@ "files": [ "tests/project-store.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-080": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-081": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-082": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-083": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-084": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-085": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-086": { "result": "passed", @@ -625,7 +649,7 @@ "tests/obj-transform.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F04: R12 symlinked materials/ and texture dir refused; real dirs copied" }, "T-087": { @@ -633,35 +657,35 @@ "files": [ "tests/obj-transform.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-088": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-089": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-090": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-091": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-100": { "result": "passed", @@ -671,7 +695,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F05: R01 API key digest binding", "review_round_2": "R2-F06 N07: OAuth without user_id/login_id refused a replay; login_id binds; refresh keeps it" }, @@ -680,7 +704,7 @@ "files": [ "tests/env-file.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-102": { "result": "passed", @@ -688,14 +712,14 @@ "tests/cli-contract.test.ts", "tests/doctor.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-103": { "result": "passed", "files": [ "tests/doctor.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" }, "T-105": { "result": "passed", @@ -704,7 +728,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_1": "F05: R01 OAuth subject binding (token rotation keeps identity)", "review_round_2": "R2-F06 N07" }, @@ -713,7 +737,7 @@ "files": [ "tests/auth-headless.test.ts" ], - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "review_round_2": "R2-F06: a device-flow login mints login_id (offline E2E stub)" } }, @@ -728,13 +752,13 @@ "id": "T-109", "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "partial", - "detail": "macOS 26.6 arm64, Node 24.20.0: tarball install + 29 smoke checks passed on cf8905d (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" + "detail": "macOS 26.6 arm64, Node 24.20.0: tarball install + 29 smoke checks passed on 235d6de (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" }, { "id": "T-110", "scope": "real free queries, existing task get, asset download", "result": "partial", - "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions) on cf8905d returned 157 entries, 8 matched \"wave\". Authenticated get/download: not_run (no credential)" + "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions) on 235d6de returned 157 entries, 8 matched \"wave\". Authenticated get/download: not_run (no credential)" }, { "id": "T-111", @@ -749,12 +773,12 @@ "reason": "no slicer application installed on this host (slicer detect returned an empty list)" } ], - "code_head": "cf8905dd285bf16896df053aac253dbf8c672979", - "recorded_at": "2026-09-07T10:25:06.450Z", - "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1 / review_round_2 notes name the Codex finding and probe the file now covers.", + "code_head": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "recorded_at": "2026-09-07T15:29:10.625Z", + "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1/2/3 notes name the Codex finding and probe the file now covers.", "package_smoke": { "tarball": "meshy-cli-0.3.0.tgz", - "sha256": "3077563a16fb9ad00b3be5c01d69724ba3122fe1c60d7abb69cc47e201835f2a", + "sha256": "f29e8a168af1c66fe58c9ee53a4b392beca41350bccf55bf58c22eebf5e5f930", "file_count": 398, "required_present": true, "forbidden_absent": true, @@ -767,8 +791,8 @@ "checks": [ { "id": "version", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy --version", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy --version", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -776,8 +800,8 @@ }, { "id": "version_cli", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy-cli --version", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy-cli --version", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -785,8 +809,8 @@ }, { "id": "help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy --help", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy --help", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -794,8 +818,8 @@ }, { "id": "help_cli", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy-cli --help", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy-cli --help", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -803,8 +827,8 @@ }, { "id": "balance_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy balance --help", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy balance --help", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -812,8 +836,8 @@ }, { "id": "balance_nokey_json", - "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy balance", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy balance", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -821,8 +845,8 @@ }, { "id": "balance_nokey_v1", - "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy balance --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy balance --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -830,8 +854,8 @@ }, { "id": "resources_v1", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy resources --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy resources --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -839,8 +863,8 @@ }, { "id": "doctor", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy doctor --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy doctor --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -848,8 +872,8 @@ }, { "id": "doctor_check_slicers", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy doctor --check-slicers --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy doctor --check-slicers --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -857,8 +881,8 @@ }, { "id": "doctor_check_api_nokey", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy doctor --check-api --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy doctor --check-api --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -866,8 +890,8 @@ }, { "id": "slicer_detect", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy slicer detect --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy slicer detect --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -875,8 +899,8 @@ }, { "id": "inspect_pass", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -884,8 +908,8 @@ }, { "id": "inspect_fail", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 12, "expected_exit": 12, "result": "passed", @@ -893,8 +917,8 @@ }, { "id": "inspect_unknown", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy inspect faces --task-json /dev/stdin --max-faces 300000 --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy inspect faces --task-json /dev/stdin --max-faces 300000 --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 13, "expected_exit": 13, "result": "passed", @@ -902,8 +926,8 @@ }, { "id": "mesh_prepare", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -911,8 +935,8 @@ }, { "id": "mesh_refuse_overwrite", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 11, "expected_exit": 11, "result": "passed", @@ -920,8 +944,8 @@ }, { "id": "project_init", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -929,8 +953,8 @@ }, { "id": "project_record", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/work/meshy_output/20260907_182403_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/work/meshy_output/20260907_232807_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -938,8 +962,8 @@ }, { "id": "project_show", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/work/meshy_output/20260907_182403_smoke-demo_fixture- --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/work/meshy_output/20260907_232807_smoke-demo_fixture- --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -947,8 +971,8 @@ }, { "id": "project_list", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -956,8 +980,8 @@ }, { "id": "download_list", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -965,8 +989,8 @@ }, { "id": "download_no_selector", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -974,8 +998,8 @@ }, { "id": "make_dry_run", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -983,8 +1007,8 @@ }, { "id": "usage_unknown_flag", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy balance --bogus --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy balance --bogus --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -992,8 +1016,8 @@ }, { "id": "uv_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy uv-unwrap create --help", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy uv-unwrap create --help", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1001,8 +1025,8 @@ }, { "id": "creative_lab_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy creative-lab lamp build create --help", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy creative-lab lamp build create --help", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1010,8 +1034,8 @@ }, { "id": "stream_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy text-to-3d stream --help", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy text-to-3d stream --help", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1019,8 +1043,8 @@ }, { "id": "catalog_live", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r3/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1115,7 +1139,7 @@ ] }, "reverified_on": { - "sha": "cf8905dd285bf16896df053aac253dbf8c672979", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", "probe": "round1-probes.mjs re-run", "reproduced": 0, "total": 12 @@ -1197,6 +1221,84 @@ "passed": 8, "failed": 0, "sha": "cf8905dd285bf16896df053aac253dbf8c672979" + }, + "reverified_on": { + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "probe": "round2-probes.mjs re-run", + "reproduced": 0, + "total": 8 + } + }, + { + "round": 3, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-cf8905d", + "reviewed_head": "566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a", + "reviewed_code_head": "cf8905dd285bf16896df053aac253dbf8c672979", + "verdict": "changes_requested (G1-code not_accepted, G1-release not_satisfied, S2 not_ready)", + "findings": 6, + "priorities": { + "P1": 2, + "P2": 4 + }, + "fixed_in": "30536d8daa92dc9e8d39d99cbdf24d0d20799438", + "test_stabilised_in": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "regression_tests": [ + "tests/codex-review-round3.test.ts", + "tests/poll.test.ts (real-timer smoke judged with the loop's clock reading)" + ], + "reviewer_test_result_before_fix": "pnpm test 535/535; 20/20 round 1+2 positive checks; 6/6 round-3 probes reproduced", + "probe_rerun": { + "script": "reviews/cli-s1-cf8905d/round3-probes.mjs (copied to a scratch directory; the reviewer's evidence files are untouched)", + "node": "v24.20.0", + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "reproduced_after_fix": 0, + "total": 6, + "exit_code": 1, + "note": "the script exits 1 when not every defect reproduces; after the fixes 0/6 reproduce. Positive acceptance is asserted by tests/codex-review-round3.test.ts", + "cases": [ + { + "id": "C01-sidecar-follows-new-symlink-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C02-sidecar-failure-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C03-sigint-during-relink-still-succeeds", + "reproduced": false, + "exit_code": 130 + }, + { + "id": "C04-project-alias-drops-asset-record", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C05-saved-name-match-overrides-source-identity", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C06-legacy-create-download-error-loses-task-id", + "reproduced": false, + "exit_code": 7 + } + ] + }, + "reviewer_positive_checker": { + "script": "verify-original-regressions.py on the re-run round-1/round-2 results", + "passed": 20, + "total": 20, + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + }, + "stream_finalization_check": { + "script": "stream-finalization-check.mjs", + "passed": true, + "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" } } ] From 93e55bcc717ceb9a368ee43aeeeac69a3fb52197 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 10:15:53 +0800 Subject: [PATCH 17/26] fix(review): address Codex review round 4 findings R4-F01, R4-F02 and test gap R4-T01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4-F01 material-links: resolve every distinct (key, reference) pair on its own first, then arbitrate heuristic hits on the texture they actually reach — a texture that any other distinct reference contends for (by a hit of either kind or as an ambiguity) serves none of them, and a reference that different keys would send to different textures is not rewritten; identity matches (source name, saved name, source stem) are never vetoed. One shared note per group, one warning per reason, order of the references irrelevant. R4-F02 download --project: preflight metadata.json (present, regular file, parses) and --stage before any transfer; wrap the record phase so a failure keeps the whole result (source, selection, manifest with on-disk digests, saved_json) plus project.action="failed" with the error and a record_project recovery whose command is the exact `meshy project record …` invocation (also the hint). The task verbs' --project attachment reports the same recovery. Nothing is rolled back, re-downloaded or re-submitted; index_dirty keeps its meaning. R4-T01 tests: make's task_id / submission.operation_id / executed[-1] / legacy operation_id are reconciled with the last accepted journal record using distinct step ids (legacy and v1, asset 503 and SIGINT, POST GET POST GET GET, refine.preview_task_id = step 1); C06 keeps the create/wait/get scenarios. Co-Authored-By: Claude Fable 5.1 --- README.md | 18 +- docs/skill-parity/decisions.md | 58 +++ docs/skill-parity/migration-notes.md | 9 + skills/meshy-cli/SKILL.md | 7 +- src/cmd/download.ts | 140 ++++-- src/internal/material-links.ts | 163 +++++-- src/internal/project-store.ts | 23 + src/internal/task-command.ts | 36 +- tests/codex-review-round3.test.ts | 28 +- tests/codex-review-round4.test.ts | 648 +++++++++++++++++++++++++++ 10 files changed, 1013 insertions(+), 117 deletions(-) create mode 100644 tests/codex-review-round4.test.ts diff --git a/README.md b/README.md index e80c2b2..dc2dabe 100644 --- a/README.md +++ b/README.md @@ -374,14 +374,28 @@ under (then by channel), one candidate only: with several material groups a reference that could mean two files is left as written and reported as ambiguous, and a reference that merely equals one of the CLI's generated names (`texture_0_base_color.png`) while the server called that image something else -is ambiguous too — identity follows the source, never the file name on disk. Every link is listed under `result.downloads.material_links` +is ambiguous too — identity follows the source, never the file name on disk. +Channel fallbacks (a channel word in the reference, the MTL key's channel, the +only texture there is) are heuristics and compete on the texture they actually +reach: when two different references would both fall back to the same image, or +one reference would go to different images under different keys, they all stay +as written and are reported as ambiguous — the CLI never merges material groups +without evidence that they name the same file. Every link is listed under `result.downloads.material_links` (`status: complete | incomplete`, the `newmtl` group of each map), rewritten files carry `relinked: true` with their final sha256, and a reference that matches no or several downloaded files stays as written and is warned (`material_reference_unresolved` / `material_reference_ambiguous`). Files are published exclusively (never overwritten without `--overwrite`), checked against the content type and magic bytes, kept inside the output directory (or `--workspace`), -and listed with size and sha256 in `result.downloads.files`. Asset hosts never +and listed with size and sha256 in `result.downloads.files`. With `--project ` +the files that landed inside the project are recorded in its `metadata.json`; +when that record fails after the transfer (metadata.json replaced by a symlink, +damaged, or locked) the command exits 11 with the complete `result` — manifest, +`saved_json`, `project.action: "failed"` — and `error.recovery.command` is the +one `meshy project record …` invocation that redoes the record; the assets stay +where they landed. Problems visible before the transfer (no metadata.json, a +symlink or invalid JSON in its place, a blank `--stage`) are refused with no +request made. Asset hosts never receive the API credential; an expired signed URL is refreshed once when the task came from the API and reported as unrefreshable when it came from a file. diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index b67ca33..db8d8ec 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -527,3 +527,61 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu are now resolved with `realpathLenient` before the containment test and the relative path (as `saveTaskSnapshot` already did); the user-facing paths in the result are unchanged. Files genuinely outside the project are still not recorded. + +## D-051 Material heuristics compete on the texture they actually reach + +- R4-F01: channel competition was computed on the channel each reference + *declared* (a channel word in its name, else the MTL key's channel), while + resolution fell through: `map_Kd body_normal.png` declared normal, found no + normal texture and fell back to the key's base color — the very texture + `map_Kd eyes_diffuse.png` reached through its name — and the base-color + competition set had never counted it. Two material groups were rewritten to + one image and the report said `complete`. Resolution now runs in two passes + over the whole MTL: every distinct (key, reference) pair is resolved on its + own (`source_name` / `exact` / `source_stem` are *identity* evidence, the + channel and only-texture rules are *heuristics*), then `arbitrate` vetoes a + heuristic hit whenever any other distinct reference contends for that + texture — by a hit of either kind, or as an ambiguity it could not decide — + and when the same reference would land on different textures under different + keys. Identity hits are never vetoed, so C05's source-name mapping, N04's + multi-material set and the round-1 distinct-channel fallbacks are unchanged; + a lone reference may still fall back by key. Vetoed references stay as + written with `method: "ambiguous"`, `candidates: []` and one `note` + shared by the group; `material_links.status` is `incomplete` and the single + `material_reference_ambiguous` warning states each reason once, naming the + material groups. The order of the references is irrelevant. + +## D-052 A project-record failure keeps the download result and says how to redo the record alone + +- R4-F02: in `meshy download --project` the realpath / `indexRootFor` / + `recordTask` phase ran after the download's try/catch, so a refused + metadata.json replacement (a symlink planted during the transfer), a damaged + metadata.json or a lock/permission failure surfaced as `local_io` with + `result: null` although every asset was on disk. The phase is now wrapped + (`projectRecordFailure`): the error keeps its class (code, exit code, HTTP + status), carries the complete result — `source`, `selection`, `downloads` + with the digests actually on disk, `unknown_urls`, `saved_json` — plus + `project: { action: "failed", stage, recorded_files: [], error, recovery }`, + and `error.recovery = { action: "record_project", automatic: false, command }` + (also the `hint`), where `command` is the exact `meshy project record …` + invocation with the task id, stage, resource, task type, status and the files + that landed inside the project (`projectRecordCommand`). Nothing is rolled + back, re-downloaded or re-submitted, and `index_dirty` keeps its meaning + (metadata committed, history index not). What can be seen before the + transfer is refused before it (`preflightProject`: metadata.json must exist, + be a regular file and parse as a project; `--stage` must not be blank) with + "nothing was downloaded" and no request. The task verbs' `--project` + attachment reports the same `record_project` recovery and hint on failure. + +## D-053 `make`'s reported identity is asserted against the journal (R4-T01) + +- Test-only. The round-3 C06 make scenarios had both POSTs return the same task + id and only checked `operation_id` for presence, so a future regression that + reported step 1's id would have passed. `tests/codex-review-round4.test.ts` + runs the two-step text chain with distinct step ids under both schemas, for an + asset 503 and for a SIGINT during the final download, and checks + `result.task_id`, `submission.operation_id`, `executed[-1].operation_id` and + the legacy top-level `operation_id` against the *last* accepted journal + record, `executed[0].operation_id` against step 1's record, the refine + payload's `preview_task_id` against step 1, and the request sequence + POST GET POST GET GET. C06 keeps the create/wait/get/sidecar/SIGINT scenarios. diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index bca9b42..d373014 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -153,6 +153,15 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | Ctrl-C during the OBJ/MTL rewrite | download completes, exit 0 | exit 130 `interrupted`, committed files kept, no sidecar, no temp file | R3-F05 | | `download --project` through an alias path | files not recorded, false `files_outside_project` | files recorded relative to the real project directory | R3-F06 | +### 3.4 Corrections from Codex review round 4 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| MTL whose different references reach the sole texture only through channel fallbacks (name channel for one, key channel for the other) | both rewritten to the one texture, `material_links.status=complete` | both kept as written, `method: ambiguous` with one shared `note`, `status=incomplete`, one `material_reference_ambiguous` warning; identity matches (source name) keep their texture | R4-F01 | +| `download --project` when the project record fails after the transfer (metadata.json replaced by a symlink, damaged, unwritable directory, lock) | exit 11 with `result: null` | exit 11 (same class), full `result` incl. `downloads` manifest and `saved_json`, `project.action="failed"` with `error`, `error.recovery.action="record_project"` and `hint` = the `meshy project record …` command; assets kept, nothing re-downloaded or re-submitted | R4-F02 | +| `download --project` with a metadata.json that is a symlink or not valid JSON, or a blank `--stage` | detected after the transfer | refused before any request ("nothing was downloaded") | R4-F02 | +| task verbs `--project` record failure | `local_io` without a recovery | same class, plus `recovery.action="record_project"` and the record command as hint | R4-F02 | + New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, `--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 7977690..5c6e8b0 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -177,7 +177,12 @@ Exit codes: `0` ok · `1` task FAILED/CANCELED while waiting, or unclassified · - Legacy-schema errors (no `--output-schema v1`) after a create carry additive `task_id` / `operation_id` fields and a `hint` with the resume command — never re-create. - `result.downloads.material_links.status: "incomplete"` → name the references that - stayed unresolved or ambiguous (`texture_maps[].method`) instead of claiming the model loads. + stayed unresolved or ambiguous (`texture_maps[].method`, `note`) instead of claiming the + model loads; two material groups that would share one texture without evidence are + deliberately left as written. +- `result.project.action: "failed"` (download `--project`, exit 11) → the assets are on + disk (`result.downloads.files`); repair the project's `metadata.json`, then run + `error.recovery.command` (`meshy project record …`) — do not download again. - Exit 10 → reconcile with ` list`; never submit the same create again blindly. - A `FAILED` task → relay `result.task.task_error.message` verbatim; do not guess a cause. - Any error may carry `error.recovery.command` — prefer it over improvising. diff --git a/src/cmd/download.ts b/src/cmd/download.ts index ccebbc9..d9d799d 100644 --- a/src/cmd/download.ts +++ b/src/cmd/download.ts @@ -14,7 +14,7 @@ */ import { Command, Option } from "commander"; -import { readFileSync, statSync } from "node:fs"; +import { lstatSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { findTaskResource, TASK_RESOURCES, type TaskResourceDescriptor } from "../client/resource-registry.js"; import { enumerateAssets, selectAssets, SelectionError, type Asset, type AssetKind } from "../internal/artifacts.js"; @@ -24,10 +24,9 @@ import { downloadAssets, type DownloadedFile } from "../internal/download.js"; import { CliError, UsageError, type Warning } from "../internal/errors.js"; import { realpathLenient, resolveWithinRoot, safeSegment } from "../internal/paths.js"; import { warning } from "../internal/result.js"; -import { indexRootFor, recordTask, stageFromTaskType } from "../internal/project-store.js"; +import { indexRootFor, projectRecordCommand, readProject, recordTask, stageFromTaskType, type RecordInput } from "../internal/project-store.js"; import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; import { extractTaskObject } from "../internal/task-view.js"; -import { existsSync } from "node:fs"; import { relative } from "node:path"; const TASK_JSON_MAX_BYTES = 16 * 1024 * 1024; @@ -126,9 +125,7 @@ export const downloadCommand = new Command("download") ? resolveWithinRoot(resolvePath(opts.project), opened.flags.workspace, { label: "--project" }).path : resolvePath(opts.project) : null; - if (projectDir && !existsSync(join(projectDir, "metadata.json"))) { - throw new UsageError(`--project ${opts.project} is not an initialised project (no metadata.json); run \`meshy project init\` first`); - } + if (projectDir) preflightProject(projectDir, opts.project!, opts.stage); if (projectDir && opts.url) throw new UsageError("--project needs a task context; it cannot be combined with --url"); const warnings: Warning[] = []; @@ -262,42 +259,121 @@ export const downloadCommand = new Command("download") throw err; } warnings.push(...result.warnings.map((w) => warning(w.code, w.message))); + // Everything the caller must still learn if the project bookkeeping below + // fails: what was asked for, what landed (with the digests on disk), where + // the raw task went. The project phase never owns this state. + const outcome = { + source: sourceInfo, + selection: { selected: selected.map((a) => a.key), dependencies: dependencies.map((a) => a.key) }, + downloads: { state: result.complete ? "completed" : "partial", files: result.files, metadata_path: null, material_links: result.materialLinks }, + unknown_urls: enumeration?.unknown_urls ?? [], + saved_json: savedJson, + ...(opts.includeRaw ? { raw } : {}), + }; let project: Record | null = null; if (projectDir && task) { - const taskId = String(task["id"] ?? ""); - // Compare in one real-path frame: the project may be reached through an - // alias (a symlinked parent, macOS /var → /private/var) while the - // downloader reports real paths; the recorded name is relative to the real project. - const projectReal = realpathLenient(projectDir); - const files = result.files - .filter((f) => f.status === "written") - .map((f) => relative(projectReal, realpathLenient(f.path)).split(/[\\/]/).join("/")) - .filter((f) => f.length > 0 && !f.startsWith("..") && !f.startsWith("/")); - const indexRoot = indexRootFor(projectDir, undefined, opened.flags.workspace); - const rec = recordTask(projectDir, { - taskId, + const written = result.files.filter((f) => f.status === "written"); + const input: RecordInput = { + taskId: String(task["id"] ?? opts.taskId ?? ""), stage: opts.stage ?? stageFromTaskType(task["type"], descriptor?.id ?? "download"), resource: descriptor?.id ?? null, taskType: typeof task["type"] === "string" ? (task["type"] as string) : null, endpoint: descriptor?.legacyEndpoint ?? null, status: typeof task["status"] === "string" ? (task["status"] as string) : null, - files, - }, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); - if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}`)); - if (files.length !== result.files.filter((f) => f.status === "written").length) warnings.push(warning("files_outside_project", "some files were written outside the project directory and were not recorded")); - project = { project_dir: projectDir, action: rec.action, stage: rec.entry.stage, recorded_files: files }; + files: [], + }; + try { + // Compare in one real-path frame: the project may be reached through an + // alias (a symlinked parent, macOS /var → /private/var) while the + // downloader reports real paths; the recorded name is relative to the real project. + const projectReal = realpathLenient(projectDir); + input.files = written + .map((f) => relative(projectReal, realpathLenient(f.path)).split(/[\\/]/).join("/")) + .filter((f) => f.length > 0 && !f.startsWith("..") && !f.startsWith("/")); + const indexRoot = indexRootFor(projectDir, undefined, opened.flags.workspace); + const rec = recordTask(projectDir, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); + if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}`)); + if (input.files.length !== written.length) warnings.push(warning("files_outside_project", "some files were written outside the project directory and were not recorded")); + project = { project_dir: projectDir, action: rec.action, stage: rec.entry.stage, recorded_files: input.files }; + } catch (err) { + throw projectRecordFailure(err, { projectDir, dir, input, written: written.length, outcome, warnings }); + } } - await emitResult(opened, null, { - source: sourceInfo, - selection: { selected: selected.map((a) => a.key), dependencies: dependencies.map((a) => a.key) }, - downloads: { state: result.complete ? "completed" : "partial", files: result.files, metadata_path: null, material_links: result.materialLinks }, - unknown_urls: enumeration?.unknown_urls ?? [], - saved_json: savedJson, - project, - ...(opts.includeRaw ? { raw } : {}), - }, { warnings }); + await emitResult(opened, null, { ...outcome, project }, { warnings }); }); +/** + * `--project` checks that can fail before any transfer, so that they do: + * metadata.json must exist, be a regular file (never a symlink the record + * step would refuse to replace) and parse as a project; `--stage` must not be + * blank. Nothing is downloaded when one of these fails. What changes *after* + * this check is caught by `projectRecordFailure`. + */ +function preflightProject(projectDir: string, flag: string, stage: string | undefined): void { + const metaPath = join(projectDir, "metadata.json"); + let st: ReturnType | null = null; + try { + st = lstatSync(metaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT" && (err as NodeJS.ErrnoException).code !== "ENOTDIR") throw err; + } + if (!st) throw new UsageError(`--project ${flag} is not an initialised project (no metadata.json); run \`meshy project init\` first`); + if (!st.isFile()) { + throw new CliError({ code: "local_io", message: `--project ${flag}: metadata.json is not a regular file (${st.isSymbolicLink() ? "a symbolic link" : st.isDirectory() ? "a directory" : "special"}); nothing was downloaded` }); + } + try { + readProject(projectDir); + } catch (err) { + if (err instanceof CliError) { + throw new CliError({ code: err.code, message: `--project ${flag}: ${err.message} (nothing was downloaded)`, recovery: err.recovery, cause: err }); + } + throw err; + } + if (stage !== undefined && stage.trim() === "") throw new UsageError("--stage must not be blank"); +} + +/** + * The transfers are done and the files are on disk; only the project entry + * could not be written. The error keeps its own class (a refused symlink, + * a damaged metadata.json, a lock timeout, a full disk are all local_io) and + * carries the complete download result plus a `project` record that says what + * failed and the one command that redoes just the bookkeeping. Nothing is + * rolled back, re-downloaded or re-submitted. + */ +function projectRecordFailure( + err: unknown, + ctx: { projectDir: string; dir: string; input: RecordInput; written: number; outcome: Record; warnings: Warning[] }, +): CliError { + const base = err instanceof CliError ? err : null; + const code = base?.code ?? "local_io"; + const reason = err instanceof Error ? err.message : String(err); + const command = projectRecordCommand(ctx.projectDir, ctx.input); + const recovery = { action: "record_project", automatic: false, command }; + return new CliError({ + code, + message: `${ctx.written} file(s) were downloaded to ${ctx.dir} but recording task ${ctx.input.taskId} in project ${ctx.projectDir} failed: ${reason}`, + exitCode: base?.exitCode, + httpStatus: base?.httpStatus ?? null, + retryable: base?.retryable ?? false, + recovery, + hint: command, + details: base?.details, + warnings: [...ctx.warnings, ...(base?.warnings ?? [])], + result: { + ...ctx.outcome, + project: { + project_dir: ctx.projectDir, + action: "failed", + stage: ctx.input.stage, + recorded_files: [], + error: { code, message: reason }, + recovery, + }, + }, + cause: err, + }); +} + function describeAsset(a: Asset): Record { return { key: a.key, diff --git a/src/internal/material-links.ts b/src/internal/material-links.ts index 10ca990..170aade 100644 --- a/src/internal/material-links.ts +++ b/src/internal/material-links.ts @@ -24,7 +24,19 @@ * reference. Several candidates for the same rule is an *ambiguity*: the * reference stays as written, the candidates are listed and the report is * `incomplete` — the CLI never picks the first of several material groups' - * textures. Unresolved references are reported the same way. The whole pass is + * textures. + * + * The first three rules are *identity* evidence; the channel and only-texture + * rules are *heuristics*. Resolution therefore runs in two passes over the + * whole MTL: every distinct reference is resolved on its own first, then the + * heuristic results are checked against each other — a texture that several + * different references would fall back to (whichever channel rule each one + * took to get there, in whichever order they appear) serves none of them, and a + * reference that different keys would send to different textures is not + * rewritten either. Two material groups collapsing onto one image without + * evidence that they name the same file is exactly the guess this module must + * not make; a reference with identity evidence keeps its texture regardless. + * Unresolved references are reported the same way. The whole pass is * cooperative: an abort signal stops it before the next read, write or * publication, leaving no temp file behind. */ @@ -331,41 +343,37 @@ function parseMapLine(line: string): { indent: string; key: string; options: str return { indent, key, options: "", ref: rest }; } -type Resolution = { kind: "hit"; name: string; method: LinkMethod } | { kind: "ambiguous"; method: LinkMethod; candidates: string[]; note?: string } | { kind: "none" }; +/** + * One reference's verdict. `identity` marks evidence of *which* file was meant + * (source name, saved name, source stem) as opposed to a channel/only-texture + * heuristic, which the second pass may still veto. + */ +type Resolution = + | { kind: "hit"; name: string; method: LinkMethod; identity: boolean } + | { kind: "ambiguous"; method: LinkMethod; candidates: string[]; note?: string } + | { kind: "none" }; /** Apply one rule: exactly one candidate resolves, several are an ambiguity, none falls through. */ -function pick(candidates: TextureDescriptor[], method: LinkMethod): Resolution | null { - if (candidates.length === 1) return { kind: "hit", name: candidates[0]!.name, method }; +function pick(candidates: TextureDescriptor[], method: LinkMethod, identity: boolean): Resolution | null { + if (candidates.length === 1) return { kind: "hit", name: candidates[0]!.name, method, identity }; if (candidates.length > 1) return { kind: "ambiguous", method, candidates: candidates.map((c) => c.name) }; return null; } -/** Channel a map line speaks about: a channel word in the referenced name, else the MTL key's channel. */ -function channelOfMapLine(key: string, ref: string): string | null { - return channelInFileName(basename(ref.replaceAll("\\", "/"))) ?? MAP_KEY_CHANNEL[key.toLowerCase()] ?? null; -} - -/** - * Channel-based rules may only decide when one distinct reference speaks about - * that channel: two materials both wanting "the base color" while a single base - * color texture was downloaded is a choice the CLI must not make. - */ -function pickByChannel(channel: string, textures: TextureDescriptor[], competing: Map>, method: LinkMethod): Resolution | null { +/** Channel rule: one texture of that channel is a (heuristic) hit, several are an ambiguity, none falls through. */ +function pickByChannel(channel: string, textures: TextureDescriptor[], method: LinkMethod): Resolution | null { const candidates = textures.filter((t) => t.channel === channel); if (candidates.length === 0) return null; if (candidates.length > 1) return { kind: "ambiguous", method: "ambiguous", candidates: candidates.map((c) => c.name) }; - const refs = competing.get(channel); - if (refs && refs.size > 1) { - return { kind: "ambiguous", method: "ambiguous", candidates: candidates.map((c) => c.name), note: `${refs.size} different references (${[...refs].map((r) => `'${r}'`).join(", ")}) all point at the only ${channel} texture, ${candidates[0]!.name}` }; - } - return { kind: "hit", name: candidates[0]!.name, method }; + return { kind: "hit", name: candidates[0]!.name, method, identity: false }; } -function resolveTextureReference(key: string, ref: string, textures: TextureDescriptor[], distinctRefs: number, competing: Map>): Resolution { +/** First pass: resolve one (key, reference) pair on its own evidence; competition between references is decided afterwards. */ +function resolveTextureReference(key: string, ref: string, textures: TextureDescriptor[], distinctRefs: number): Resolution { const refBase = basename(ref.replaceAll("\\", "/")); const lower = refBase.toLowerCase(); // 1. The name the server served a texture under is the only evidence of which image the MTL meant. - const bySource = pick(textures.filter((t) => t.source_name !== null && t.source_name.toLowerCase() === lower), "source_name"); + const bySource = pick(textures.filter((t) => t.source_name !== null && t.source_name.toLowerCase() === lower), "source_name", true); if (bySource) return bySource; // 2. A saved file of that name — unless it is known to come from a different // source: the CLI's generated names can collide with another texture's @@ -374,7 +382,7 @@ function resolveTextureReference(key: string, ref: string, textures: TextureDesc if (named.length === 1) { const t = named[0]!; if (t.source_name === null || t.source_name.toLowerCase() === lower) { - return { kind: "hit", name: t.name, method: t.name === ref ? "unchanged" : "exact" }; + return { kind: "hit", name: t.name, method: t.name === ref ? "unchanged" : "exact", identity: true }; } return { kind: "ambiguous", @@ -385,22 +393,81 @@ function resolveTextureReference(key: string, ref: string, textures: TextureDesc } if (named.length > 1) return { kind: "ambiguous", method: "ambiguous", candidates: named.map((t) => t.name) }; const stem = stemOf(refBase); - const byStem = pick(textures.filter((t) => t.source_name !== null && stemOf(t.source_name) === stem), "source_stem"); + const byStem = pick(textures.filter((t) => t.source_name !== null && stemOf(t.source_name) === stem), "source_stem", true); if (byStem) return byStem; + // 3. Heuristics: a channel word in the referenced name, then the channel the + // MTL key implies. Whichever one lands is what the second pass compares. const inName = channelInFileName(refBase); if (inName) { - const r = pickByChannel(inName, textures, competing, "channel_in_name"); + const r = pickByChannel(inName, textures, "channel_in_name"); if (r) return r; } const ofKey = MAP_KEY_CHANNEL[key.toLowerCase()]; if (ofKey) { - const r = pickByChannel(ofKey, textures, competing, "channel_of_key"); + const r = pickByChannel(ofKey, textures, "channel_of_key"); if (r) return r; } - if (textures.length === 1 && distinctRefs === 1) return { kind: "hit", name: textures[0]!.name, method: "only_texture" }; + if (textures.length === 1 && distinctRefs === 1) return { kind: "hit", name: textures[0]!.name, method: "only_texture", identity: false }; return { kind: "none" }; } +interface MapPair { + key: string; + ref: string; + res: Resolution; +} + +function pairId(key: string, ref: string): string { + return `${key.toLowerCase()}\u0000${ref}`; +} + +/** + * Second pass: heuristic hits compete on the texture they actually reached. + * A texture that any *other* distinct reference also reaches — by a hit of + * either kind, or as an ambiguity it could not decide — is not handed to a + * heuristic one: nothing shows those references name the same image. A single + * reference that different keys would send to different textures is not + * rewritten at all. Identity hits are kept as they are. + */ +function arbitrate(pairs: MapPair[]): Map { + // texture name → the distinct references contending for it and how they got there + const contenders = new Map>(); + const contend = (name: string, ref: string, method: LinkMethod, identity: boolean): void => { + const byRef = contenders.get(name) ?? new Map(); + if (!byRef.has(ref) || identity) byRef.set(ref, method); + contenders.set(name, byRef); + }; + for (const { ref, res } of pairs) { + if (res.kind === "hit") contend(res.name, ref, res.method, res.identity); + else if (res.kind === "ambiguous") for (const c of res.candidates) contend(c, ref, "ambiguous", false); + } + const decided = new Map(); + for (const { key, ref, res } of pairs) { + const id = pairId(key, ref); + if (res.kind !== "hit" || res.identity) { + decided.set(id, res); + continue; + } + const rivals = [...(contenders.get(res.name) ?? new Map())].filter(([r]) => r !== ref); + const elsewhere = pairs.filter((p) => p.ref === ref && p.res.kind === "hit" && p.res.name !== res.name); + if (rivals.length === 0 && elsewhere.length === 0) { + decided.set(id, res); + continue; + } + // The note is the same for every member of the group, so the caller's + // warning can say it once. + const note = + rivals.length > 0 + ? `${[[ref, res.method] as [string, LinkMethod], ...rivals] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([r, m]) => `'${r}' (${m})`) + .join(" and ")} compete for ${res.name}; different references cannot share one texture without evidence that they name the same image` + : `'${ref}' would be rewritten to ${res.name} for ${key} but to ${elsewhere.map((p) => `${(p.res as { name: string }).name} for ${p.key}`).join(", ")}; one reference names one file`; + decided.set(id, { kind: "ambiguous", method: "ambiguous", candidates: [res.name], note }); + } + return decided; +} + /** * Relink the OBJ/MTL/texture files of one download. Returns null when the set * has no text OBJ (nothing to relink). Never throws for unresolved or @@ -475,20 +542,22 @@ export async function relinkMaterials(files: readonly LinkableFile[], opts: { si report.warnings.push(warning("material_reference_unresolved", `${basename(mtl.path)} is ${size} bytes; too large for an MTL, texture references were not checked`)); return report; } - const distinctRefs = new Set(); - // Which distinct references speak about each channel — a single texture - // cannot serve two different references. - const competing = new Map>(); - for (const raw of readFileSync(mtl.path, "utf8").split(/\r?\n/)) { - const parsed = parseMapLine(raw); - if (!parsed) continue; - distinctRefs.add(parsed.ref); - const channel = channelOfMapLine(parsed.key, parsed.ref); - if (channel) { - if (!competing.has(channel)) competing.set(channel, new Set()); - competing.get(channel)!.add(parsed.ref); - } + // Pass 1: every distinct (key, reference) pair on its own evidence. + const parsedLines = readFileSync(mtl.path, "utf8") + .split(/\r?\n/) + .map(parseMapLine) + .filter((p): p is NonNullable> => p !== null); + const distinctRefs = new Set(parsedLines.map((p) => p.ref)); + const pairs: MapPair[] = []; + const seenPairs = new Set(); + for (const p of parsedLines) { + const id = pairId(p.key, p.ref); + if (seenPairs.has(id)) continue; + seenPairs.add(id); + pairs.push({ key: p.key, ref: p.ref, res: resolveTextureReference(p.key, p.ref, textures, distinctRefs.size) }); } + // Pass 2: heuristic hits compete on the texture they actually reached. + const decided = arbitrate(pairs); let material: string | null = null; const mtlChanged = await rewriteLines(mtl.path, (line, lineNo) => { const t = line.trim(); @@ -498,7 +567,7 @@ export async function relinkMaterials(files: readonly LinkableFile[], opts: { si } const parsed = parseMapLine(line); if (!parsed) return null; - const res = resolveTextureReference(parsed.key, parsed.ref, textures, distinctRefs.size, competing); + const res: Resolution = decided.get(pairId(parsed.key, parsed.ref)) ?? { kind: "none" }; if (res.kind === "none") { report.texture_maps.push({ line: lineNo, material, reference: parsed.ref, resolved_to: null, method: "unresolved" }); return null; @@ -516,10 +585,20 @@ export async function relinkMaterials(files: readonly LinkableFile[], opts: { si const unresolved = report.texture_maps.filter((l) => l.method === "unresolved"); if (ambiguous.length > 0) { report.status = "incomplete"; + // One sentence per distinct reason; a note shared by a group of + // references (they compete for one texture) is said once, naming the + // material groups involved. + const reasons = new Map(); + for (const l of ambiguous) { + const text = l.note ?? `'${l.reference}' could be ${l.candidates!.join(" or ")}`; + const materials = reasons.get(text) ?? []; + if (l.material && !materials.includes(l.material)) materials.push(l.material); + reasons.set(text, materials); + } report.warnings.push( warning( "material_reference_ambiguous", - `${basename(mtl.path)}: ${ambiguous.map((l) => (l.note ? `${l.note}${l.material ? ` (${l.material})` : ""}` : `'${l.reference}'${l.material ? ` (${l.material})` : ""} could be ${l.candidates!.join(" or ")}`)).join("; ")}; the references stay as written — the CLI does not guess between material groups or sources`, + `${basename(mtl.path)}: ${[...reasons].map(([text, materials]) => `${text}${materials.length ? ` (${materials.join(", ")})` : ""}`).join("; ")}; the references stay as written — the CLI does not guess between material groups or sources`, ), ); } diff --git a/src/internal/project-store.ts b/src/internal/project-store.ts index 8952bb3..ebdea5c 100644 --- a/src/internal/project-store.ts +++ b/src/internal/project-store.ts @@ -490,6 +490,29 @@ export function saveTaskSnapshot(projectDir: string, taskId: string, raw: unknow return { path: resolved.path, relative: relative(resolved.root, resolved.path).split(sep).join("/") }; } +/** Quote one argument for a command a human can paste into a shell; plain tokens stay bare. */ +function shellArg(value: string): string { + return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; +} + +/** + * The `meshy project record` invocation that redoes exactly one thing: the + * metadata entry a command could not write. Assets, task and journal are left + * alone — the caller runs it once the project directory is repaired. + */ +export function projectRecordCommand(projectDir: string, input: RecordInput, opts: { root?: string } = {}): string { + const parts = ["meshy", "project", "record", "--project", shellArg(projectDir), "--task-id", shellArg(input.taskId), "--stage", shellArg(input.stage)]; + if (input.resource) parts.push("--resource", shellArg(input.resource)); + if (input.taskType) parts.push("--task-type", shellArg(input.taskType)); + if (input.parentTaskId) parts.push("--parent-task-id", shellArg(input.parentTaskId)); + if (input.status) parts.push("--status", shellArg(input.status)); + for (const f of input.files ?? []) parts.push("--file", shellArg(f)); + if (input.taskJson) parts.push("--task-json", shellArg(input.taskJson)); + if (input.operationId) parts.push("--operation-id", shellArg(input.operationId)); + if (opts.root) parts.push("--root", shellArg(opts.root)); + return parts.join(" "); +} + /** Derive a stage name from a task type (`text-to-3d-preview` → preview, `creative-lab-lamp-build` → build). */ export function stageFromTaskType(type: unknown, fallback: string): string { if (typeof type !== "string" || !type) return fallback; diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index d8d0b59..37a23c0 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -58,7 +58,7 @@ import { } from "./operation-store.js"; import { originOf } from "./config.js"; import { resolveWithinRoot } from "./paths.js"; -import { indexRootFor, recordTask, saveTaskSnapshot, stageFromTaskType } from "./project-store.js"; +import { indexRootFor, projectRecordCommand, recordTask, saveTaskSnapshot, stageFromTaskType, type RecordInput } from "./project-store.js"; import { existsSync } from "node:fs"; import { dirname, join, resolve as resolvePath } from "node:path"; @@ -294,28 +294,38 @@ function attachToProject( if (!projectFlag) return null; const projectDir = resolveProjectDir(projectFlag, opened.flags.workspace); const stage = (opts.stage as string | undefined) ?? (typeof extra.payload?.["mode"] === "string" ? (extra.payload["mode"] as string) : descriptor.creativeLab?.stage ?? stageFromTaskType(task?.type, descriptor.id)); + const input: RecordInput = { + taskId, + stage, + resource: descriptor.id, + taskType: task?.type ?? null, + endpoint: descriptor.legacyEndpoint, + parentTaskId: parentTaskIdFromPayload(extra.payload ?? null), + status: task?.status ?? null, + taskJson: null, + operationId: extra.operationId ?? null, + files: extra.files ?? [], + }; try { const snapshot = task && raw ? saveTaskSnapshot(projectDir, taskId, raw) : null; + input.taskJson = snapshot?.relative ?? null; const indexRoot = indexRootFor(projectDir, undefined, opened.flags.workspace); - const rec = recordTask(projectDir, { - taskId, - stage, - resource: descriptor.id, - taskType: task?.type ?? null, - endpoint: descriptor.legacyEndpoint, - parentTaskId: parentTaskIdFromPayload(extra.payload ?? null), - status: task?.status ?? null, - taskJson: snapshot?.relative ?? null, - operationId: extra.operationId ?? null, - files: extra.files ?? [], - }, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); + const rec = recordTask(projectDir, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}; run \`meshy project rebuild-index\``)); if (rec.migrated_from_legacy) warnings.push(warning("metadata_migrated", "legacy metadata.json migrated to schema_version 2 (backup kept beside it)")); return { project_dir: projectDir, snapshot: snapshot?.path ?? null, stage, action: rec.action, index: rec.index }; } catch (err) { + // The task exists and the journal is written; only the project entry is + // missing. The recovery redoes that one step — nothing is re-submitted. + const command = projectRecordCommand(projectDir, input); throw new CliError({ code: err instanceof CliError ? err.code : "local_io", message: `task ${taskId} exists but recording it in ${projectDir} failed: ${err instanceof Error ? err.message : String(err)}`, + httpStatus: err instanceof CliError ? err.httpStatus : null, + retryable: err instanceof CliError ? err.retryable : false, + recovery: { action: "record_project", automatic: false, command }, + hint: command, + details: err instanceof CliError ? err.details : undefined, cause: err, }); } diff --git a/tests/codex-review-round3.test.ts b/tests/codex-review-round3.test.ts index 30c8d53..b841126 100644 --- a/tests/codex-review-round3.test.ts +++ b/tests/codex-review-round3.test.ts @@ -363,7 +363,7 @@ test("R3-F03 without source evidence a saved-name match is still accepted (conse // R3-F02 / C06 — legacy schema keeps the accepted task on every download failure // --------------------------------------------------------------------------- -test("C06/R3-F02 legacy sync create/wait/make: an asset 503, a sidecar failure and SIGINT still report task_id, the real operation_id, next and the manifest; exactly one POST", async () => { +test("C06/R3-F02 legacy sync create/wait/get: an asset 503, a sidecar failure and SIGINT still report task_id, the real operation_id, next and the manifest; exactly one POST (make: codex-review-round4 R4-T01 with distinct step ids)", async () => { let assetMode: "503" | "ok" | "slow" = "503"; let plant: (() => void) | null = null; let child: ChildProcess | null = null; @@ -446,32 +446,6 @@ test("C06/R3-F02 legacy sync create/wait/make: an asset 503, a sidecar failure a assert.equal(ip.task_id, "paid-legacy-created-id"); assert.ok(!existsSync(target)); - // Legacy make -o: chain done, download 503 → task id, executed steps, next. - assetMode = "503"; - api.requests.length = 0; - const made = await runCli(["make", "a fixture cactus", "-o", join(dir, "legacy-make")], { env, cwd: dir }); - assert.equal(made.code, 7, `${made.stderr}\n${made.stdout}`); - const mp = parseSingleJson(made.stdout) as LegacyErrorPayload & { result?: { executed?: unknown[] } }; - assert.equal(mp.code, "network"); - assert.equal(mp.status, 503); - assert.equal(mp.task_id, "paid-legacy-created-id"); - assert.equal(mp.result?.executed?.length, 2); - assert.match(mp.result?.next?.wait ?? "", /wait paid-legacy-created-id/); - assert.ok(mp.operation_id, "make reports the real operation id of the finished step"); - assert.equal(api.requests.filter((q) => q.method === "POST").length, 2, "two chain steps, no re-submission"); - - // v1 make -o: SIGINT during the final download keeps the chain context (round-2 N08 named make; this asserts it). - assetMode = "slow"; - api.requests.length = 0; - const madeInt = await runCli(["make", "a fixture cactus", ...V1, "-o", join(dir, "make-int.glb")], { env, cwd: dir, onSpawn: (c) => (child = c) }); - assert.equal(madeInt.code, 130, `${madeInt.stderr}\n${madeInt.stdout}`); - const mi = parseSingleJson(madeInt.stdout) as { error: { code: string }; result: { task_id: string; executed: unknown[]; downloads: { state: string } } }; - assert.equal(mi.error.code, "interrupted"); - assert.equal(mi.result.task_id, "paid-legacy-created-id"); - assert.equal(mi.result.executed.length, 2); - assert.equal(mi.result.downloads.state, "failed"); - assert.ok(!existsSync(join(dir, "make-int.glb"))); - assert.equal(api.requests.filter((q) => q.method === "POST").length, 2); } finally { await api.close(); } diff --git a/tests/codex-review-round4.test.ts b/tests/codex-review-round4.test.ts new file mode 100644 index 0000000..2905154 --- /dev/null +++ b/tests/codex-review-round4.test.ts @@ -0,0 +1,648 @@ +/** + * Codex review round 4 (reviews/cli-s1-235d6de: R4-F01, R4-F02, R4-T01) as + * positive regressions. Each scenario mirrors the reviewer's probe (D01, D02, + * round4-context-checks) with real subprocesses, a loopback API/asset host that + * records every request, synthetic credentials and isolated temp directories, + * and asserts the required outcome: two different MTL references that only + * reach the same sole texture through channel fallbacks are never merged (in + * either order, whichever channel rule each one took), a project-record failure + * after the transfers keeps the whole download result and says how to redo the + * bookkeeping alone, foreseeable --project problems are refused before any + * transfer, and `make`'s reported operation id is the *last* accepted journal + * record's (two steps with different ids, asset 503 and SIGINT, both schemas). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import sharp from "sharp"; +import { relinkMaterials } from "../src/internal/material-links.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; +const ENVELOPE_KEYS = ["schema_version", "command", "ok", "result", "error", "warnings"].sort(); + +function taskBody(fields: Record = {}): Record { + return { id: "round4-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function tmpFiles(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).filter((f) => f.includes(".tmp-")) : []; +} + +interface JournalRecord { + operation_id: string; + state: string; + task_id: string | null; +} + +function journal(configDir: string): JournalRecord[] { + const ops = join(configDir, "operations"); + return readdirSync(ops) + .filter((f) => f.endsWith(".json")) + .map((f) => JSON.parse(readFileSync(join(ops, f), "utf8")) as JournalRecord); +} + +/** Split a recovery command the way a POSIX shell would (bare words, single quotes with the '\'' escape). */ +function shellSplit(command: string): string[] { + const words: string[] = []; + let cur = ""; + let pending = false; + let inQuote = false; + let escape = false; + for (const ch of command) { + if (escape) { + cur += ch; + escape = false; + pending = true; + continue; + } + if (inQuote) { + if (ch === "'") inQuote = false; + else cur += ch; + pending = true; + continue; + } + if (ch === "'") { + inQuote = true; + pending = true; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (/\s/.test(ch)) { + if (pending) { + words.push(cur); + cur = ""; + pending = false; + } + continue; + } + cur += ch; + pending = true; + } + if (pending) words.push(cur); + return words; +} + +interface ManifestFile { + key: string; + path: string; + status: string; + bytes: number; + sha256: string; + relinked: boolean; +} + +interface TextureMap { + line: number; + material: string | null; + reference: string; + resolved_to: string | null; + method: string; + candidates?: string[]; + note?: string; +} + +interface MaterialEnvelope { + ok: boolean; + result: { + downloads: { + state: string; + files: ManifestFile[]; + material_links: { status: string; rewritten: string[]; texture_maps: TextureMap[] }; + }; + }; + warnings: Array<{ code: string; message: string }>; +} + +// --------------------------------------------------------------------------- +// R4-F01 / D01 — channel fallbacks compete on the texture they actually reach +// --------------------------------------------------------------------------- + +test("D01/R4-F01 two different references that reach the sole base color only through channel fallbacks (key channel and name channel) stay as written in both orders; ambiguous + incomplete with one shared note; MTL byte-identical; digests match disk", async () => { + const red = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#ff0000" } }).png().toBuffer(); + const refs = ["body_normal.png", "eyes_diffuse.png"] as const; + for (const order of [ + [0, 1], + [1, 0], + ] as const) { + const first = refs[order[0]]; + const second = refs[order[1]]; + const mtl = `newmtl body\nmap_Kd ${first}\nnewmtl eyes\nmap_Kd ${second}\n`; + const host = await startMockApi((req, res) => { + if (req.path === "/model.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + return void res.end("mtllib original.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 1\nf 1 2 3\n"); + } + if (req.path === "/original.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + return void res.end(mtl); + } + if (req.path === "/a.png") { + res.writeHead(200, { "content-type": "image/png" }); + return void res.end(red); + } + return jsonReply(res, 404, {}); + }); + try { + const label = `${first} then ${second}`; + const dir = tmpDir(); + const fixture = join(dir, "channel-fallback.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/model.obj`, mtl: `${host.url}/original.mtl` }, texture_urls: [{ base_color: `${host.url}/a.png` }] }))); + const out = join(dir, "channel-fallback"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${label}: ${r.stderr}\n${r.stdout}`); + const env = parseSingleJson(r.stdout) as MaterialEnvelope & Record; + assert.deepEqual(Object.keys(env).sort(), ENVELOPE_KEYS); + assert.equal(env.ok, true); + assert.equal(readFileSync(join(out, "model.mtl"), "utf8"), mtl, `${label}: the MTL is byte-identical — neither reference was rewritten`); + assert.match(readFileSync(join(out, "model.obj"), "utf8"), /^mtllib model\.mtl$/m, `${label}: the OBJ still points at the saved MTL`); + assert.ok(readFileSync(join(out, "texture_0_base_color.png")).equals(red), `${label}: the one texture is the served image`); + const dl = env.result.downloads; + assert.equal(dl.state, "completed", `${label}: every file landed`); + assert.equal(dl.material_links.status, "incomplete"); + assert.deepEqual( + dl.material_links.texture_maps.map((l) => [l.material, l.reference, l.resolved_to, l.method, l.candidates]), + [ + ["body", first, null, "ambiguous", ["texture_0_base_color.png"]], + ["eyes", second, null, "ambiguous", ["texture_0_base_color.png"]], + ], + `${label}: both references are ambiguous, not resolved`, + ); + for (const l of dl.material_links.texture_maps) { + assert.match(l.note ?? "", /'body_normal\.png' \(channel_of_key\)/, `${label}: the note names the key-channel fallback`); + assert.match(l.note ?? "", /'eyes_diffuse\.png' \(channel_in_name\)/, `${label}: the note names the name-channel fallback`); + assert.match(l.note ?? "", /compete for texture_0_base_color\.png/); + } + assert.equal(dl.material_links.texture_maps[0]!.note, dl.material_links.texture_maps[1]!.note, `${label}: one shared note for the group`); + assert.deepEqual(dl.material_links.rewritten.map((p) => basename(p)), ["model.obj"], `${label}: only the OBJ's mtllib was rewritten`); + const byKey = Object.fromEntries(dl.files.map((f) => [f.key, f])); + assert.equal(byKey["model.obj"]!.relinked, true); + assert.equal(byKey["model.mtl"]!.relinked, false, `${label}: the MTL was not touched`); + assert.equal(byKey["texture.0.base_color"]!.relinked, false); + for (const f of dl.files) { + assert.equal(f.status, "written"); + assert.equal(sha(f.path), f.sha256, `${label}: ${f.key} manifest digest is the file on disk`); + assert.equal(readFileSync(f.path).length, f.bytes); + } + const ambiguous = env.warnings.filter((w) => w.code === "material_reference_ambiguous"); + assert.equal(ambiguous.length, 1, `${label}: exactly one ambiguity warning`); + assert.match(ambiguous[0]!.message, /'body_normal\.png' \(channel_of_key\) and 'eyes_diffuse\.png' \(channel_in_name\) compete for texture_0_base_color\.png/); + assert.match(ambiguous[0]!.message, /\(body, eyes\)|\(eyes, body\)/, `${label}: the warning names both material groups`); + assert.equal(ambiguous[0]!.message.split("compete for").length, 2, `${label}: the shared reason is said once`); + assert.ok(!env.warnings.some((w) => w.code === "material_reference_unresolved")); + assert.deepEqual(host.requests.map((q) => [q.method, q.path]), [ + ["GET", "/model.obj"], + ["GET", "/original.mtl"], + ["GET", "/a.png"], + ]); + assert.deepEqual(tmpFiles(out), []); + } finally { + await host.close(); + } + } +}); + +test("R4-F01 arbitration: an identity match keeps its texture while a heuristic rival stays as written; an unresolved rival that could mean the texture blocks it too; one reference sent to different textures by different keys is not rewritten; lone and distinct-channel fallbacks still resolve", async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const setup = (mtl: string, textures: Array<{ key: string; name: string; sourceName: string | null }>) => { + const dir = tmpDir(); + writeFileSync(join(dir, "model.obj"), "mtllib m.mtl\nv 0 0 0\n"); + writeFileSync(join(dir, "model.mtl"), mtl); + for (const t of textures) writeFileSync(join(dir, t.name), png); + return { + dir, + files: [ + { key: "model.obj", path: join(dir, "model.obj"), sourceName: "m.obj" }, + { key: "model.mtl", path: join(dir, "model.mtl"), sourceName: "m.mtl" }, + ...textures.map((t) => ({ key: t.key, path: join(dir, t.name), sourceName: t.sourceName })), + ], + }; + }; + + // a) identity (source name) + heuristic (name channel) on the same sole texture: identity wins, the heuristic stays as written. + { + const mtl = "newmtl body\nmap_Kd body.png\nnewmtl eyes\nmap_Kd eyes_diffuse.png\n"; + const { dir, files } = setup(mtl, [{ key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "body.png" }]); + const report = (await relinkMaterials(files))!; + assert.equal(report.status, "incomplete"); + assert.deepEqual(report.texture_maps.map((l) => [l.material, l.reference, l.resolved_to, l.method]), [ + ["body", "body.png", "texture_0_base_color.png", "source_name"], + ["eyes", "eyes_diffuse.png", null, "ambiguous"], + ]); + assert.match(report.texture_maps[1]!.note ?? "", /'body\.png' \(source_name\) and 'eyes_diffuse\.png' \(channel_in_name\) compete for texture_0_base_color\.png/); + assert.equal(readFileSync(join(dir, "model.mtl"), "utf8"), "newmtl body\nmap_Kd texture_0_base_color.png\nnewmtl eyes\nmap_Kd eyes_diffuse.png\n"); + assert.equal(report.warnings.length, 1); + assert.match(report.warnings[0]!.message, /compete for texture_0_base_color\.png; .* name the same image \(eyes\); the references stay as written/); + } + + // b) a rival that could not be resolved (generated name, other source) still contends for the texture: the heuristic must not take it. + { + const mtl = "newmtl red\nmap_Kd texture_1_base_color.png\nnewmtl green\nmap_Kd texture_0_base_color.png\n"; + const { dir, files } = setup(mtl, [{ key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "a.png" }]); + const report = (await relinkMaterials(files))!; + assert.equal(report.status, "incomplete"); + assert.deepEqual(report.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [ + ["texture_1_base_color.png", null, "ambiguous"], + ["texture_0_base_color.png", null, "ambiguous"], + ]); + assert.match(report.texture_maps[0]!.note ?? "", /'texture_0_base_color\.png' \(ambiguous\) and 'texture_1_base_color\.png' \(channel_in_name\) compete for texture_0_base_color\.png/); + assert.match(report.texture_maps[1]!.note ?? "", /served as 'a\.png'/); + assert.equal(readFileSync(join(dir, "model.mtl"), "utf8"), mtl, "nothing rewritten"); + } + + // c) the same reference under two keys would land on two textures: one reference names one file, so it stays as written. + { + const mtl = "newmtl a\nmap_Kd shared.png\nmap_Bump shared.png\n"; + const { dir, files } = setup(mtl, [ + { key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "x.png" }, + { key: "texture.0.normal", name: "texture_0_normal.png", sourceName: "y.png" }, + ]); + const report = (await relinkMaterials(files))!; + assert.equal(report.status, "incomplete"); + assert.deepEqual(report.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [ + ["shared.png", null, "ambiguous"], + ["shared.png", null, "ambiguous"], + ]); + for (const l of report.texture_maps) assert.match(l.note ?? "", /one reference names one file/); + assert.equal(readFileSync(join(dir, "model.mtl"), "utf8"), mtl); + } + + // d) a lone reference may still fall back by key; two references on distinct channels resolve independently. + { + const lone = setup("newmtl a\nmap_Kd body_normal.png\n", [{ key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "a.png" }]); + const r1 = (await relinkMaterials(lone.files))!; + assert.equal(r1.status, "complete"); + assert.deepEqual(r1.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [["body_normal.png", "texture_0_base_color.png", "channel_of_key"]]); + assert.equal(readFileSync(join(lone.dir, "model.mtl"), "utf8"), "newmtl a\nmap_Kd texture_0_base_color.png\n"); + + const two = setup("newmtl a\nmap_Kd skin.png\nmap_Bump -bm 0.5 Body_Normal.png\n", [ + { key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "a.png" }, + { key: "texture.0.normal", name: "texture_0_normal.png", sourceName: "b.png" }, + ]); + const r2 = (await relinkMaterials(two.files))!; + assert.equal(r2.status, "complete"); + assert.deepEqual(r2.texture_maps.map((l) => [l.reference, l.resolved_to, l.method]), [ + ["skin.png", "texture_0_base_color.png", "channel_of_key"], + ["Body_Normal.png", "texture_0_normal.png", "channel_in_name"], + ]); + assert.equal(readFileSync(join(two.dir, "model.mtl"), "utf8"), "newmtl a\nmap_Kd texture_0_base_color.png\nmap_Bump -bm 0.5 texture_0_normal.png\n"); + assert.deepEqual(r2.warnings, []); + } +}); + +// --------------------------------------------------------------------------- +// R4-F02 / D02 — a project-record failure after the transfer keeps the result +// --------------------------------------------------------------------------- + +interface ProjectFailureEnvelope { + schema_version: string; + command: string; + ok: boolean; + result: { + source: { kind: string; task_id: string }; + selection: { selected: string[]; dependencies: string[] }; + downloads: { state: string; files: ManifestFile[]; metadata_path: null; material_links: unknown }; + unknown_urls: unknown[]; + saved_json: { path: string; bytes: number } | null; + project: { + project_dir: string; + action: string; + stage: string; + recorded_files: string[]; + error: { code: string; message: string }; + recovery: { action: string; automatic: boolean; command: string }; + }; + }; + error: { code: string; message: string; http_status: number | null; retryable: boolean; recovery: { action: string; automatic: boolean; command: string }; hint?: string }; + warnings: Array<{ code: string }>; +} + +test("D02/R4-F02 download --project: a record failure after the transfer (metadata.json swapped for a symlink, damaged, project dir unwritable) keeps source/selection/manifest/saved_json, reports the project failure with a record-only recovery, exit 11, one GET, assets kept, outside bytes unchanged; the recovery command then records the task", async () => { + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/model.glb") { + // Another process changes the project between the preflight and the record step. + plant?.(); + plant = null; + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + const env = api.env({ MESHY_API_KEY: undefined }); + const initProject = async (name: string): Promise => { + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", name, "--workspace", workspace], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + return (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + }; + const fixture = join(workspace, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/model.glb` } }))); + const expectFailure = (r: { code: number; stdout: string; stderr: string }, label: string, recordedFiles: string[] = ["model.glb"]): ProjectFailureEnvelope => { + assert.equal(r.code, 11, `${label}: ${r.stderr}\n${r.stdout}`); + const e = parseSingleJson(r.stdout) as ProjectFailureEnvelope; + assert.deepEqual(Object.keys(e).sort(), ENVELOPE_KEYS, label); + assert.equal(e.schema_version, "meshy.cli/v1"); + assert.equal(e.command, "download"); + assert.equal(e.ok, false); + assert.equal(e.error.code, "local_io", label); + assert.equal(e.error.http_status, null); + assert.equal(e.error.retryable, false); + assert.match(e.error.message, /^1 file\(s\) were downloaded to .* but recording task round4-task in project .* failed: /, label); + assert.equal(e.error.recovery.action, "record_project", label); + assert.equal(e.error.recovery.automatic, false); + assert.equal(e.error.hint, e.error.recovery.command, `${label}: the hint is the recovery command`); + assert.match(r.stderr, /^hint: meshy project record /m, `${label}: stderr carries the same command`); + // The whole download result survives. + assert.equal(e.result.source.kind, "task-json"); + assert.equal(e.result.source.task_id, "round4-task"); + assert.deepEqual(e.result.selection, { selected: ["model.glb"], dependencies: [] }); + assert.equal(e.result.downloads.state, "completed", `${label}: the transfer itself completed`); + assert.deepEqual(e.result.downloads.files.map((f) => [f.key, f.status]), [["model.glb", "written"]]); + const model = e.result.downloads.files[0]!; + assert.ok(existsSync(model.path), `${label}: the asset is not rolled back`); + assert.ok(readFileSync(model.path).equals(glb()), `${label}: the asset has the served bytes`); + assert.equal(sha(model.path), model.sha256, `${label}: manifest digest is the file on disk`); + assert.equal(readFileSync(model.path).length, model.bytes); + assert.deepEqual(e.result.unknown_urls, []); + // The project record says what failed and how to redo just that. + assert.equal(e.result.project.action, "failed", label); + assert.equal(e.result.project.stage, "preview"); + assert.deepEqual(e.result.project.recorded_files, []); + assert.equal(e.result.project.error.code, "local_io"); + assert.deepEqual(e.result.project.recovery, e.error.recovery); + assert.match( + e.error.recovery.command, + new RegExp(`^meshy project record --project \\S+ --task-id round4-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED${recordedFiles.map((f) => ` --file ${f.replaceAll(".", "\\.")}`).join("")}$`), + `${label}: the recovery redoes exactly the metadata entry (files that landed inside the project)`, + ); + return e; + }; + + // (1) metadata.json becomes a symlink to a valid metadata file outside the workspace during the GET. + const proj1 = await initProject("record-failure"); + const meta1 = join(proj1, "metadata.json"); + const outside = join(dir, "outside-metadata.json"); + copyFileSync(meta1, outside); + const outsideBefore = readFileSync(outside); + const backup = join(proj1, "original-metadata.json"); + plant = () => { + renameSync(meta1, backup); + symlinkSync(outside, meta1); + }; + const saveJson = join(workspace, "task-copy.json"); + const r1 = await runCli(["download", "--task-json", fixture, "--all", "--project", proj1, "--workspace", workspace, "--save-json", saveJson], { env, cwd: dir }); + const e1 = expectFailure(r1, "symlink"); + assert.match(e1.error.message, /not a regular file/); + assert.match(e1.result.project.error.message, /not a regular file/); + assert.equal(realpathSync(e1.result.project.project_dir), realpathSync(proj1)); + assert.ok(e1.result.saved_json && existsSync(e1.result.saved_json.path), "the raw task JSON was saved and is reported"); + assert.equal((JSON.parse(readFileSync(e1.result.saved_json!.path, "utf8")) as { id: string }).id, "round4-task"); + assert.ok(readFileSync(outside).equals(outsideBefore), "the outside metadata file is untouched"); + assert.ok(lstatSync(meta1).isSymbolicLink(), "the planted symlink was not replaced"); + assert.ok(readFileSync(backup).equals(outsideBefore), "the original metadata is intact"); + assert.deepEqual(tmpFiles(proj1), [], "no temp file left in the project"); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [["GET", "/model.glb"]], "one asset GET, nothing else"); + // Repair the directory and run the recovery command verbatim: it records exactly the downloaded file and makes no request. + unlinkSync(meta1); + renameSync(backup, meta1); + const words = shellSplit(e1.error.recovery.command); + assert.equal(words[0], "meshy"); + const rec = await runCli([...words.slice(1), "--workspace", workspace], { env, cwd: dir }); + assert.equal(rec.code, 0, `${rec.stderr}\n${rec.stdout}`); + const meta = JSON.parse(readFileSync(meta1, "utf8")) as { tasks: Array<{ task_id: string; stage: string; files: string[]; status: string | null; resource: string | null }> }; + assert.deepEqual(meta.tasks.map((t) => [t.task_id, t.stage, t.files, t.status, t.resource]), [["round4-task", "preview", ["model.glb"], "SUCCEEDED", "text-to-3d"]]); + assert.equal(api.requests.length, 1, "the recovery made no request"); + assert.ok(existsSync(join(proj1, "model.glb"))); + + // (2) metadata.json is damaged (not JSON) during the GET: refused, never overwritten, result kept. + const proj2 = await initProject("damaged"); + const meta2 = join(proj2, "metadata.json"); + plant = () => writeFileSync(meta2, "{ not json"); + const r2 = await runCli(["download", "--task-json", fixture, "--all", "--project", proj2, "--workspace", workspace], { env, cwd: dir }); + const e2 = expectFailure(r2, "damaged"); + assert.match(e2.error.message, /not valid JSON/); + assert.equal(readFileSync(meta2, "utf8"), "{ not json", "the damaged file is left for the user to repair, not overwritten"); + assert.equal(e2.result.saved_json, null); + assert.ok(existsSync(join(proj2, "model.glb"))); + assert.deepEqual(api.requests.slice(1).map((q) => [q.method, q.path]), [["GET", "/model.glb"]]); + + // (3) the project directory becomes unwritable during the GET (the asset goes elsewhere in the workspace): the lock cannot be created — a plain errno, still local_io with the result. + if (process.platform !== "win32" && process.getuid?.() !== 0) { + const proj3 = await initProject("unwritable"); + const elsewhere = join(workspace, "elsewhere"); + plant = () => chmodSync(proj3, 0o555); + try { + const r3 = await runCli(["download", "--task-json", fixture, "--all", "--project", proj3, "--output-dir", elsewhere, "--workspace", workspace], { env, cwd: dir }); + const e3 = expectFailure(r3, "unwritable", []); + assert.match(e3.error.message, /EACCES|permission denied/i); + assert.ok(e3.warnings.some((w) => w.code === "files_outside_project") === false, "the record never ran, so no outside-file warning is invented"); + assert.ok(existsSync(join(elsewhere, "model.glb"))); + assert.equal(readdirSync(proj3).includes(".meshy.lock"), false, "no lock file appeared"); + } finally { + chmodSync(proj3, 0o755); + } + assert.equal((JSON.parse(readFileSync(join(proj3, "metadata.json"), "utf8")) as { tasks: unknown[] }).tasks.length, 0, "metadata untouched"); + } + } finally { + await api.close(); + } +}); + +test("R4-F02 preflight: a --project whose metadata.json is a symlink or damaged, a task JSON without an id, or a blank --stage is refused before any transfer — no request, nothing written; a healthy project still records normally", async () => { + const api = await startMockApi((req, res) => { + if (req.path === "/model.glb") { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + const env = api.env({ MESHY_API_KEY: undefined }); + const initProject = async (name: string): Promise => { + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", name, "--workspace", workspace], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + return (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + }; + const fixture = join(workspace, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/model.glb` } }))); + const refused = (r: { code: number; stdout: string; stderr: string }, code: number, re: RegExp, label: string): void => { + assert.equal(r.code, code, `${label}: ${r.stderr}\n${r.stdout}`); + const e = parseSingleJson(r.stdout) as { ok: boolean; result: unknown; error: { code: string; message: string } }; + assert.equal(e.ok, false); + assert.match(e.error.message, re, label); + assert.equal(api.requests.length, 0, `${label}: nothing was requested`); + }; + + // metadata.json is a symlink before the run + const projA = await initProject("symlinked"); + const outside = join(dir, "outside.json"); + renameSync(join(projA, "metadata.json"), outside); + symlinkSync(outside, join(projA, "metadata.json")); + const a = await runCli(["download", "--task-json", fixture, "--all", "--project", projA, "--workspace", workspace], { env, cwd: dir }); + refused(a, 11, /metadata\.json is not a regular file \(a symbolic link\); nothing was downloaded/, "symlink"); + assert.ok(!existsSync(join(projA, "model.glb")), "symlink: nothing written"); + + // metadata.json is damaged before the run + const projB = await initProject("damaged"); + writeFileSync(join(projB, "metadata.json"), "{ not json"); + const b = await runCli(["download", "--task-json", fixture, "--all", "--project", projB, "--workspace", workspace], { env, cwd: dir }); + refused(b, 11, /not valid JSON.*\(nothing was downloaded\)/, "damaged"); + assert.ok(!existsSync(join(projB, "model.glb"))); + + // a task JSON without an id is not a task the CLI can record: refused as usage before the transfer + const projC = await initProject("healthy"); + const noId = join(workspace, "no-id.json"); + const body = taskBody({ model_urls: { glb: `${api.url}/model.glb` } }); + delete body["id"]; + writeFileSync(noId, JSON.stringify(body)); + const c = await runCli(["download", "--task-json", noId, "--all", "--project", projC, "--workspace", workspace], { env, cwd: dir }); + refused(c, 2, /does not contain a task/, "no id"); + assert.ok(!existsSync(join(projC, "model.glb"))); + + // blank --stage + const d = await runCli(["download", "--task-json", fixture, "--all", "--project", projC, "--stage", " ", "--workspace", workspace], { env, cwd: dir }); + refused(d, 2, /--stage must not be blank/, "blank stage"); + + // the healthy project records as before + const ok = await runCli(["download", "--task-json", fixture, "--all", "--project", projC, "--workspace", workspace], { env, cwd: dir }); + assert.equal(ok.code, 0, `${ok.stderr}\n${ok.stdout}`); + const e = parseSingleJson(ok.stdout) as { result: { project: { project_dir: string; action: string; stage: string; recorded_files: string[] } }; warnings: Array<{ code: string }> }; + assert.deepEqual(e.result.project, { project_dir: e.result.project.project_dir, action: "added", stage: "preview", recorded_files: ["model.glb"] }); + assert.ok(!e.warnings.some((w) => w.code === "index_dirty" || w.code === "files_outside_project")); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [["GET", "/model.glb"]]); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R4-T01 — make reports the last step's identity, reconciled with the journal +// --------------------------------------------------------------------------- + +interface MakeResult { + task_id: string; + submission: { state: string; operation_id: string | null; task_id?: string }; + executed: Array<{ step: number; task_id: string; status: string | null; operation_id: string }>; + next: { wait: string }; + downloads: { state: string; files: Array<{ key: string; status: string }> }; +} + +test("R4-T01 make: two chain steps get different task ids; submission.operation_id, executed[-1].operation_id and the legacy top-level operation_id are the last accepted journal record's, refine carries step 1's id as preview_task_id — asset 503 and SIGINT, legacy and v1, POST GET POST GET GET", async () => { + for (const schema of ["legacy", "v1"] as const) { + for (const mode of ["503", "sigint"] as const) { + const label = `${schema}/${mode}`; + let n = 0; + let child: ChildProcess | null = null; + const api = await startMockApi(async (req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: `${schema}-${mode}-step-${++n}` }); + if (req.path === "/asset.glb") { + if (mode === "503") return jsonReply(res, 503, { message: "asset host down" }); + child?.kill("SIGINT"); + await new Promise((r) => setTimeout(r, 400)); + try { + res.writeHead(200, { "content-type": "model/gltf-binary" }); + res.end(glb()); + } catch { + /* client gone */ + } + return; + } + if (req.method !== "GET") return jsonReply(res, 500, { message: "unexpected" }); + const id = req.path.split("/").at(-1)!; + return jsonReply(res, 200, taskBody({ id, model_urls: { glb: `${api.url}/asset.glb` } })); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const target = mode === "503" ? join(dir, "out") : join(dir, "out.glb"); + const r = await runCli(["make", "a fixture cactus", ...(schema === "v1" ? V1 : []), "-o", target], { env, cwd: dir, onSpawn: (c) => (child = c) }); + assert.equal(r.code, mode === "503" ? 7 : 130, `${label}: ${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as Record & { result: MakeResult }; + const step1 = `${schema}-${mode}-step-1`; + const step2 = `${schema}-${mode}-step-2`; + const result = out.result; + if (schema === "v1") { + assert.deepEqual(Object.keys(out).sort(), ENVELOPE_KEYS, label); + assert.equal(out["ok"], false); + const error = out["error"] as { code: string; http_status: number | null }; + assert.equal(error.code, mode === "503" ? "network" : "interrupted", label); + assert.equal(error.http_status, mode === "503" ? 503 : null); + } else { + assert.equal(out["code"], mode === "503" ? "network" : "interrupted", label); + if (mode === "503") assert.equal(out["status"], 503); + assert.equal(out["task_id"], step2, `${label}: the legacy top-level task_id is the last step's`); + } + assert.equal(result.task_id, step2, `${label}: the reported task is the last step, not the preview`); + assert.equal(result.submission.state, "accepted"); + assert.equal(result.submission.task_id, step2); + assert.deepEqual( + result.executed.map((e) => [e.step, e.task_id, e.status]), + [ + [1, step1, "SUCCEEDED"], + [2, step2, "SUCCEEDED"], + ], + label, + ); + const records = journal(configDir); + assert.equal(records.length, 2, `${label}: exactly two journal records — nothing was re-submitted`); + const rec1 = records.find((o) => o.task_id === step1)!; + const rec2 = records.find((o) => o.task_id === step2)!; + assert.ok(rec1 && rec2, `${label}: both steps are journaled`); + assert.equal(rec1.state, "accepted"); + assert.equal(rec2.state, "accepted"); + assert.notEqual(rec1.operation_id, rec2.operation_id); + assert.equal(result.submission.operation_id, rec2.operation_id, `${label}: submission.operation_id is the last accepted record's`); + assert.equal(result.executed.at(-1)!.operation_id, rec2.operation_id, `${label}: executed[-1].operation_id matches the journal`); + assert.equal(result.executed[0]!.operation_id, rec1.operation_id, `${label}: executed[0].operation_id is step 1's own record`); + if (schema === "legacy") assert.equal(out["operation_id"], rec2.operation_id, `${label}: the legacy top-level operation_id is the last step's`); + assert.match(result.next.wait, new RegExp(`wait ${step2}`), label); + assert.deepEqual(api.requests.map((q) => q.method), ["POST", "GET", "POST", "GET", "GET"], label); + const bodies = api.requests.map((q) => q.json as Record | undefined); + assert.equal(bodies[0]!["mode"], "preview"); + assert.equal(bodies[2]!["mode"], "refine"); + assert.equal(bodies[2]!["preview_task_id"], step1, `${label}: refine names step 1`); + assert.ok(api.requests[1]!.path.endsWith(`/${step1}`), `${label}: step 1 was polled by its own id`); + assert.ok(api.requests[3]!.path.endsWith(`/${step2}`), `${label}: step 2 was polled by its own id`); + assert.equal(api.requests[4]!.path, "/asset.glb"); + if (mode === "503") { + assert.deepEqual(result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]], label); + } else { + assert.notEqual(result.downloads.state, "completed", label); + assert.ok(!existsSync(target), `${label}: no final file after the interrupt`); + } + } finally { + await api.close(); + } + } + } +}); From 68690f9273ff20bbf95e6ac586766e30d977b6b4 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 10:15:54 +0800 Subject: [PATCH 18/26] test(wait): allow the deadline wake-up's one extra GET that D-044 permits The R03/F07 subprocess check "expiry during the sleep" asserted at most two polls. When the final budget-cut sleep wakes a fraction before the deadline the loop may issue one more deadline-bound GET (the early-wake case D-044 documents), which made the check fail about once in six runs on this host. It now asserts the invariants a real clock can prove: every GET the server saw started within the budget, the second waited the full interval, a third can only sit at the deadline itself, and the counted polls match the GETs seen (at most one cut off). Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/decisions.md | 7 +++++++ tests/codex-review-round1.test.ts | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index db8d8ec..b091c10 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -454,6 +454,13 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu and the one real-timer smoke asserts only the invariant a real clock can prove: no GET *starts* after the deadline. The subprocess tests with slow headers/bodies (R03) are kept. +- Round 4 (test-only): the R03 subprocess check "expiry during the sleep" capped + the poll count at two, but the final budget-cut sleep may wake a fraction before + the deadline and issue one more deadline-bound GET — the early-wake case above — + so it failed about once in six runs. It now asserts what a real clock can prove: + every GET the server saw started within the budget, the second waited the full + interval, a third can only be the deadline wake-up, and the counted polls match + the GETs seen (at most one cut off by the deadline). ## D-045 The legacy sidecar is published like an asset diff --git a/tests/codex-review-round1.test.ts b/tests/codex-review-round1.test.ts index 7aff4e4..15ec49c 100644 --- a/tests/codex-review-round1.test.ts +++ b/tests/codex-review-round1.test.ts @@ -202,8 +202,12 @@ test("R03/F07 wait: slow body, expiry during sleep and --timeout 0 behave as spe assert.equal(slowBody.code, 8, `${slowBody.stderr}\n${slowBody.stdout}`); assert.equal((parseSingleJson(slowBody.stdout) as { error: { code: string } }).error.code, "timed_out"); - // Expiry while sleeping: budget 300 ms, interval 250 ms → one GET, then the - // sleep runs out the budget; no second GET after the deadline. + // Expiry while sleeping: budget 300 ms, interval 250 ms → a GET at 0 and at + // ~250 ms, then the sleep is cut to the ~50 ms left. The loop may wake a + // fraction before the deadline and issue one more deadline-bound GET (D-044: + // an early wake may poll again, never after the deadline), so the count is + // 1–3 and a third GET can only sit at the deadline itself — never inside the + // interval — and every GET the server saw started within the budget. mode = "instant"; stamps.length = 0; origin = Date.now(); @@ -211,9 +215,13 @@ test("R03/F07 wait: slow body, expiry during sleep and --timeout 0 behave as spe assert.equal(expiry.code, 8, expiry.stderr); const eo = parseSingleJson(expiry.stdout) as { result: { task: { status: string }; wait: { polls: number } } }; assert.equal(eo.result.task.status, "IN_PROGRESS", "the last status seen is reported"); - assert.ok(eo.result.wait.polls >= 1 && eo.result.wait.polls <= 2, `polls=${eo.result.wait.polls}`); + assert.ok(eo.result.wait.polls >= 1 && eo.result.wait.polls <= 3, `polls=${eo.result.wait.polls}`); const firstGet = stamps[0]!; - assert.ok(stamps.every((t) => t - firstGet <= 300 + 60), `every GET started within the budget: ${JSON.stringify(stamps.map((t) => t - firstGet))}`); + const offsets = stamps.map((t) => t - firstGet); + assert.ok(offsets.length >= eo.result.wait.polls && offsets.length - eo.result.wait.polls <= 1, `every counted poll is a GET the server saw (at most one deadline-bound GET was cut off): polls=${eo.result.wait.polls} gets=${JSON.stringify(offsets)}`); + assert.ok(offsets.every((t) => t <= 300 + 60), `every GET started within the budget: ${JSON.stringify(offsets)}`); + if (offsets.length >= 2) assert.ok(offsets[1]! >= 250 - 20, `the second GET waited the full interval: ${JSON.stringify(offsets)}`); + if (offsets.length === 3) assert.ok(offsets[2]! >= 300 - 20, `a third GET can only be the deadline wake-up: ${JSON.stringify(offsets)}`); // --timeout 0: exactly one query bounded by the read timeout, not by a zero budget. mode = "slow-200"; From e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 10:26:10 +0800 Subject: [PATCH 19/26] docs(skill-parity): round-5 handoff after Codex review round 4 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verification.json, capability-matrix.json and REVIEW_HANDOFF.md bound to code HEAD 68690f9 (fix 93e55bc + test stabilisation): pnpm test 547/547, typecheck, diff --check, poll ×12, round-1 ×8, rounds 2–4 ×3, reviewer script copies (round1 0/12, round2 0/8, round3 0/6, round4 D01/D02 fixed, D03 positive, context checks 5/5, stream check, verify-original-regressions 20/20), tarball smoke 29/29. Live account / Windows / Linux / slicer / paid checks stay not_run. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/REVIEW_HANDOFF.md | 1267 ++++++++++------------ docs/skill-parity/capability-matrix.json | 134 ++- docs/skill-parity/verification.json | 641 +++++++---- 3 files changed, 1117 insertions(+), 925 deletions(-) diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md index 075f88b..6a77573 100644 --- a/docs/skill-parity/REVIEW_HANDOFF.md +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -1,51 +1,51 @@ # Meshy CLI S1 Review Handoff -Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 4**(Codex review 第 3 轮修复后的复审交接)。历次 review:第 1 轮 `…/reviews/cli-s1-6273d9a/`,第 2 轮 `…/reviews/cli-s1-730132b/`,第 3 轮 `…/reviews/cli-s1-cf8905d/`;三处 reviewer 证据目录均未被改动,所有复现脚本从副本运行。 +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 5**(Codex review 第 4 轮修复后的复审交接)。历次 review:第 1 轮 `…/reviews/cli-s1-6273d9a/`,第 2 轮 `…/reviews/cli-s1-730132b/`,第 3 轮 `…/reviews/cli-s1-cf8905d/`,第 4 轮 `…/reviews/cli-s1-235d6de/`;四处 reviewer 证据目录均未被改动,所有复现/核对脚本逐字节复制到独立证据目录后运行(副本 sha256 与原件一致)。 -## 0. 第 3 轮 review 结论与本轮修复 +## 0. 第 4 轮 review 结论与本轮修复 -- 被 review 的 HEAD:`566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905d`);结论 **changes_requested**:6 项 finding(2 P1、4 P2),6 个扩展复现(C01–C06);reviewer 全量 535/535、首两轮 20 项正向核对通过。 -- 修复 commit:`30536d8`(`fix(review): address Codex review round 3 findings R3-F01–R3-F06`);测试稳定性 commit:`235d6de`(`test(poll)`:真实定时器 smoke 改用循环自身的时钟读数判定——在 30536d8 的一次全量运行中它以 +0.003 ms 误报失败一次,源于 endpoint 内重新取 `performance.now()` 的调用开销;不放宽容差、不跳过)。` 12 files changed, 846 insertions(+), 65 deletions(-)`(相对 566f3bd)。 -- 复现脚本重跑(副本,`235d6de`):`round3-probes.mjs` **0/6 复现**;`round2-probes.mjs` **0/8**;`round1-probes.mjs` **0/12**(均 exit 1 = 并非全部复现);reviewer 自己的正向核对脚本 `verify-original-regressions.py` 对重跑结果 **20/20 通过**;`stream-finalization-check.mjs` 通过。 -- 正向回归:`tests/codex-review-round3.test.ts`(7 项,C01–C06 逐条按 acceptance 断言 + relinkMaterials 已中止/中途中止单测 + v1 make SIGINT);两轮旧回归全部保留并通过。 -- 全量:`pnpm typecheck` 通过;`pnpm test` **542/542**;`git diff --check fd94490` exit 0;`poll.test.ts` 连续 12 次 12/12;round2+round3 套件连续 3 次 3/3。 +- 被 review 的 HEAD:`9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4`(代码 `235d6de`);结论 **changes_requested**:2 项 P2 代码缺陷(R4-F01、R4-F02)+ 1 项 P3 测试缺口(R4-T01);reviewer 全量 542/542,R3 六项正向 6/6,前两轮 20 项正向 20/20,5 项上下文检查 5/5,未发现新的 P1。 +- 修复 commit:`93e55bc`(`fix(review): address Codex review round 4 findings R4-F01, R4-F02 and test gap R4-T01`);测试稳定性 commit:`68690f9`(`test(wait)`:round-1 R03 “expiry during the sleep” 子进程检查原本把 poll 数上限设为 2,而 D-044 已记录“提前唤醒可再发一次 deadline-bound GET”,该检查在本机约 1/6 次误报——改为断言真实时钟能证明的不变量:服务端看到的每次 GET 都在预算内开始、第二次等满间隔、第三次只能出现在 deadline 唤醒、计入的 polls 与服务端看到的 GET 一致(最多一个被 deadline 截断);不放宽语义、不跳过)。`11 files changed, 1032 insertions(+), 121 deletions(-)`(相对 9e43a77,含 docs)。 +- 复现脚本重跑(副本,`68690f9`):`round4-probes.mjs` D01/D02 **不再呈现旧行为**(reproduced=false 不是验收依据,正向断言见 `tests/codex-review-round4.test.ts`),D03 正向中断探针 signal_sent=true / exit 130;`round3-probes.mjs` **0/6 复现**(原 fs.watch C03 本轮副本运行**触发成功**:signal_sent=true / exit 130、reproduced=false(上一轮 reviewer 运行中它曾未触发;触发与否取决于 fs.watch 时机,故中断证据仍以 D03、`round4-context-checks` 的 standalone-relink-interrupt 与仓库 C03/12 MB 轮询用例为主));`round2-probes.mjs` **0/8**;`round1-probes.mjs` **0/12**(三者均 exit 1 = 并非全部复现);reviewer 的 `verify-original-regressions.py` 对重跑结果 **20/20 通过**;`round4-context-checks.mjs` **5/5**;`stream-finalization-check.mjs` 通过。 +- 正向回归:`tests/codex-review-round4.test.ts`(5 项:D01 两种顺序 + 仲裁单测 4 例;D02 三种记账失败 + 恢复命令回放;预检 4 例 + 健康项目;R4-T01 legacy/v1 × 503/SIGINT);三轮旧回归全部保留并通过(C06 的 make 部分迁入 R4-T01 并按 reviewer 要求改为不同 step id)。 +- 全量:`pnpm typecheck` 通过;`pnpm test` **547/547**(0 失败、0 跳过);`git diff --check fd94490` 与工作树 exit 0;`poll.test.ts` 连续 12 次 12/12;`codex-review-round1.test.ts` 连续 8 次 8/8;round2+round3+round4 套件连续 3 次 3/3。 | ID | 优先级 | 问题 | 修复 | 决策 | 回归测试 | 复现脚本重跑 | | --- | --- | --- | --- | --- | --- | --- | -| R3-F01 | P1 | 下载 sidecar 仍可覆盖并发创建的文件或跟随新符号链接越界 | `writeMeta` 改为与资产相同的发布规则:发布时按 root 重新 `resolveWithinRoot`(拒绝符号链接叶子),用 `writeJsonFile` 独占原子发布(`link`),预检后出现的文件/符号链接/目录一律 `local_io` 拒绝且不截断;目录模式报告(`saveReportOnly`)同路径 | D-045 | C01+C02(预检后植入符号链接/普通文件/目录 × 目录模式、单文件 `_meta.json`、legacy;workspace 外文件字节前后一致;manifest 保留 model_glb written) | C01: exit 11, 不再复现 / C02: exit 11, 不再复现 | -| R3-F02 | P1 | 默认 legacy 同步 create 下载失败时仍丢失已受理任务的输出上下文 | get/wait(同步 create)/stream/make 的 legacy 收尾全部套入 `withTaskContext`/`wrapWithResult`;legacy 错误 payload 保持 `name/message/code/status/hint/result` 形状并附加 `task_id`、`operation_id`;`result` 含真实 `submission`、`next`、部分 manifest;`hint`(stderr)为恢复命令,带任务 id | D-046 | C06(默认 schema 同步 create 资产 503:exit 7、task_id、journal 中真实 operation_id、next、manifest、stderr 含 id、1 次 POST;legacy wait 503;legacy sidecar 失败;legacy SIGINT;legacy make 503;v1 make SIGINT) | C06: exit 7, 不再复现 | -| R3-F03 | P2 | 生成的落盘文件名抢先匹配,导致源贴图身份被错误替换 | 解析顺序改为:服务端源文件名 → 落盘名(仅当该文件来源未知或就是同名;若来源为其它文件则 ambiguous 并给出 `note`)→ 源 stem → 引用名通道词 → MTL key 通道 → 唯一贴图;通道规则在多个不同引用竞争同一唯一通道贴图时也判 ambiguous;无来源证据时保留保守回退 | D-047 | C05(源名与生成名交叉碰撞:按图像字节验证红/绿材质各指向正确文件;生成名引用而来源为 a.png → ambiguous + note + incomplete;无来源证据单测) | C05: exit 0, 不再复现 | -| R3-F04 | P2 | 资产全部落盘后的收尾失败仍丢失 manifest | relink / 摘要刷新 / sidecar 发布纳入同一 `finalisationFailure`:保留原分类,重新从磁盘取每个已提交文件的 bytes/sha256(重写过的标 `relinked`),`downloads.state=partial`、完整 `files`、`failed_step`;`meshy download` 的 relink 同样处理 | D-048 | C01+C02(sidecar 目录/文件/符号链接失败后 manifest 完整且 sha 与磁盘一致)、C03(relink 中断)、C06 legacy sidecar 失败 | C02: exit 11, 不再复现 | -| R3-F05 | P2 | SIGINT 在材质重写阶段仍被忽略,最终误报成功 | signal 传入 `relinkMaterials`/`rewriteLines`:首次读取前、每个 chunk 后、发布前检查,中断即删除临时文件并抛 `interrupted`;sidecar 前再检查;结果 exit 130,manifest 摘要重取,OBJ 为原文件或完整重写文件 | D-049 | C03(约 12 MB OBJ,观察到 `.model.obj.tmp-*` 后发送 SIGINT:exit 130、failed_step=relink、无 meta.json、无临时文件、manifest sha=磁盘 sha、OBJ 非部分文件);relinkMaterials 已中止/中途中止单测 | C03: exit 130, 不再复现 | -| R3-F06 | P2 | 项目目录经过路径别名时,成功下载的资产未记入 metadata | `download --project` 先把项目目录与文件路径 `realpathLenient` 到同一坐标系再算包含关系与相对路径(同 saveTaskSnapshot);用户可见路径不变;真正在项目外的文件仍不记录 | D-050 | C04(符号链接父目录 + macOS /var 别名:recorded_files 与 metadata.tasks[].files 含 model.glb、无 files_outside_project;显式 --output-dir 项目外仍不记录且有 warning) | C04: exit 0, 不再复现 | +| R4-F01 | P2 | 通道回退按“声明的通道”计竞争,实际回退落到同一贴图的两条不同引用被合并并报告 complete | `material-links.ts` 两遍解析:先按各自证据解析每个 distinct (key, reference)(`source_name`/`exact`/`source_stem` 为身份证据,通道与唯一贴图规则为启发式),再由 `arbitrate` 在**实际落到的贴图**上做全局竞争——任何其它 distinct 引用(命中或 ambiguous 候选)争用同一贴图时启发式命中一律否决;同一引用在不同 key 下落到不同贴图也不重写;身份命中永不否决。被否决的引用保持原文、`method: ambiguous`、`candidates: [贴图]`、组内共享一条 `note`;`status=incomplete`;warning 每个原因只说一次并列出材质组 | D-051 | D01(两种顺序:MTL 字节不变、两条引用 ambiguous、note 同时点名 channel_of_key 与 channel_in_name、rewritten 仅 OBJ、bytes/sha 与磁盘一致、贴图字节为源图、3 GET 0 POST);仲裁单测:source_name 命中 + 启发式竞争者(身份保留、竞争者原文)、无法解析的竞争者(C05 生成名冲突)同样阻止、同一引用两 key 两贴图、单引用回退与不同通道回退仍解析;C05/N04/round-1 材质测试不变 | D01: exit 0, `status=incomplete`, MTL 未改写 | +| R4-F02 | P2 | `download --project` 的 realpath/indexRootFor/recordTask 阶段在下载异常包装之外,记账失败返回 `result=null` | 进入项目阶段前固化 `outcome`(source/selection/downloads 含磁盘摘要/unknown_urls/saved_json);整段包进 `projectRecordFailure`:保留原分类(code/exit/http),`result` 完整 + `project: { action: "failed", stage, recorded_files: [], error, recovery }`,`error.recovery = { action: "record_project", command }`(同为 `hint`),command 为精确的 `meshy project record …`(task id、stage、resource、type、status、落在项目内的文件,`projectRecordCommand`);不回滚、不重下、不重提;`index_dirty` 语义不变。能预检的先预检(`preflightProject`:metadata.json 存在、是常规文件、可解析;`--stage` 非空)→ “nothing was downloaded”、0 请求。任务动词的 `--project` 记账失败同样给出 `record_project` recovery/hint | D-052 | D02(GET 期间 metadata.json 换成指向工作区外有效 metadata 的符号链接:exit 11、完整 v1 形状、result.source/selection/downloads(written, sha=磁盘)/saved_json、project.action=failed + error + recovery、hint=命令、外部与备份 metadata 字节不变、符号链接未被替换、无临时文件、恰 1 次 GET;随后修复目录并**逐字回放恢复命令**:exit 0、metadata.tasks 恰好记录 model.glb、0 请求);GET 期间 metadata 损坏为非 JSON(同上,损坏文件不被覆盖);GET 期间项目目录变为不可写(锁文件无法创建的原生 errno → local_io + 完整 result);预检:符号链接/损坏 metadata → exit 11 且 0 请求、无 id 任务 → exit 2、空 `--stage` → exit 2;健康项目照常记录 | D02: exit 11, result 含 downloads/project/recovery | +| R4-T01 | P3 | make 两步 mock 同一 task_id,只断言 operation_id 非空 | 新增 R4-T01:两步返回不同 id,对账 `result.task_id`、`submission.operation_id`、`executed[-1].operation_id`、legacy 顶层 `operation_id` = 最后一条 accepted journal,`executed[0].operation_id` = 第一步记录,`preview_task_id` = 第一步,请求序列 POST GET POST GET GET,恰 2 条 journal;legacy/v1 × 资产 503/SIGINT 四组;C06 保留 create/wait/get/sidecar/SIGINT | D-053 | R4-T01(4 组);reviewer 的 `round4-context-checks.mjs` 副本 5/5 | context checks 5/5 | ## 1. 代码定位 - 仓库路径 / remote:`/Users/ark/Dev/meshy-cli`(fresh clone) / `https://github.com/meshy-dev/meshy-cli.git` - 分支:`feat/skill-parity-s1`(本地分支,未推送) - base SHA:`fd94490916376e691efcea51324ac4326b459e1f`(0.2.0,= 计划基线 = 开工时的 remote main) -- head SHA(代码):`235d6de34f4e78210cdf418a7273542fbd4ce906`(= 修复 `30536d8daa92dc9e8d39d99cbdf24d0d20799438` + 测试稳定性提交);本文件与 verification.json/capability-matrix.json 在其后的 docs-only commit 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`) -- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc053cea4e1bf7dee08953e25a3ec858137`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe804b456a931f871d2f227e2acf22359d77`);round 3 `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905dd285bf16896df053aac253dbf8c672979`) +- head SHA(代码):`68690f9273ff20bbf95e6ac586766e30d977b6b4`(= 修复 `93e55bcc717ceb9a368ee43aeeeac69a3fb52197` + 测试稳定性提交 `68690f9`);本文件与 verification.json/capability-matrix.json 在其后的 **docs-only commit** 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`、`pnpm-lock.yaml`) +- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe8`);round 3 `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905d`);round 4 `9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4`(代码 `235d6de`) - 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) - 实施包版本:2026-09-07 / v1 -- 实际源码与计划基线的差异:无(remote main 与 Skills HEAD 均等于计划 SHA,见 `docs/skill-parity/baseline-delta.md`) +- 实际源码与计划基线的差异:无(见 `docs/skill-parity/baseline-delta.md`) - PR URL:未创建(未获创建 PR / 推送授权) - Node / pnpm / OS / arch:Node v24.20.0(fnm)/ pnpm 11.24.0(corepack,`packageManager`)/ macOS 26.6 (Darwin 25.6.0) / arm64 ## 2. 完成状态 -- G1-code / review-ready:**第 3 轮 6 项 finding 已全部修复并有正向回归,前两轮 17 项在新 HEAD 复核未回退(reviewer 正向脚本 20/20);自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) +- G1-code / review-ready:**第 4 轮 2 项代码 finding 已修复并有正向回归,1 项测试缺口已补;前三轮 23 项在新 HEAD 复核未回退(reviewer 正向脚本 20/20、R3 探针 0/6 复现且 D03/上下文检查通过);自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) - G1-release / ready-for-S2:**not_run**(无复审结论、无真实账号/多 OS/切片器验证、未发布) -- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;10 项带 `review_round_3: fixed`,13 项 `review_round_2`,21 项 `review_round_1`,`review_status` 均为 "re-review pending") -- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **542 通过 / 0 失败 / 0 跳过**(542 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) -- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 235d6de 重跑)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open -- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(meshyd 仅只读核对 6 个文件,见 baseline.json;三个 reviewer 目录未被改动) +- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;5 项带 `review_round_4`,10 项 `review_round_3`,13 项 `review_round_2`,21 项 `review_round_1`,`review_status` 均为 "re-review pending") +- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **547 通过 / 0 失败 / 0 跳过**(547 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) +- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 68690f9 重跑:exit 0)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open +- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(四个 reviewer 目录未被改动;未调用付费接口;未发布) ## 3. 本次具体改动 `git log --oneline fd94490..HEAD`(最早在下): ``` +68690f9 test(wait): allow the deadline wake-up's one extra GET that D-044 permits +93e55bc fix(review): address Codex review round 4 findings R4-F01, R4-F02 and test gap R4-T01 +9e43a77 docs(skill-parity): round-4 handoff after Codex review round 3 fixes 235d6de test(poll): judge the real-timer smoke with the loop's own clock reading 30536d8 fix(review): address Codex review round 3 findings R3-F01–R3-F06 566f3bd docs(skill-parity): round-3 handoff after Codex review round 2 fixes @@ -63,289 +63,137 @@ e7ea577 feat(cli): B01 v1 envelope, exit codes, local runtime, --api-key-file an c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixtures ``` -` 117 files changed, 21456 insertions(+), 935 deletions(-)` - -| 批次 | commit | 改动与用户行为 | 关键验证 | -| --- | --- | --- | --- | -| B00 | `docs(skill-parity)` | baseline/endpoint-contracts/decisions/migration-notes/fixtures;无代码 | `validate` 由 tests/resource-registry.test.ts 绑定 | -| B01 | `feat(cli): B01` | v1 envelope、退出码 10–13/130、`CliError` 分类;全局 `--output-schema/--api-key-file/--workspace/--no-update-check/--base-url-creative-lab`;Commander 参数错误 → exit 2;本地 runtime;SIGINT→130;stdout flush;update-check 策略;balance/api/delete 的 v1 + `--save-json` | tests/cli-contract, env-file, result | -| B02 | `feat(client): B02` | transport(路径校验、拒绝 redirect、body 级超时、body 上限、connect/unknown phase);resource registry(16 task + 8 Creative Lab stage + 3 query);TaskView(缺失字段为 null);媒体规范化移到合并后的 payload、支持 data URI;animation-catalog(无鉴权)、showcases(单次计费 GET);registry 驱动的 `resources` | tests/transport, resource-registry, task-view, catalog-showcases, file-input | -| B03 | `feat(tasks): B03` | create/get/wait/stream/delete/list 双 schema;get 任意状态 exit 0;`--timeout 0` 单次查询;operation journal(POST 前落盘、5xx/畸形 2xx/断线 → exit 10、`--operation-id` 重放/冲突);SSE 解析与 stream 语义(ndjson 事件 + outcome);`make --async` 单次 POST + `--stop-after-first`;uv-unwrap;creative-lab 4×2 命令树与逐产品校验 | tests/task-lifecycle, sse, operation-store, uv-creative-lab | -| B04 | `feat(download): B04` | 资产枚举(lamp 部件 STL/ZIP、keychain/fridge OBJ=ZIP bundle、嵌套 basic_animations、多视角/alpha 缩略图、报告);安全下载(无凭据、逐跳重定向校验、私网拒绝、大小上限、sha256、MIME 校正、magic 校验、link 独占发布、realpath 作用根);`meshy download` | tests/artifacts, download-command, download(legacy) | -| B05 | `feat(project): B05` | metadata.json v2 + legacy 迁移备份、history 索引、(task_id,stage) 合并、锁顺序、index_dirty;`project init/record/show/list/rebuild-index`;任务动词与 download 的 `--project/--stage` | tests/project-store(含多进程) | -| B06–B08 | `feat(local): B06-B08` | `inspect faces`(unknown≠0,exit 12/13,仅描述 remesh);`mesh prepare-print`(两遍流式、oracle 校验、材质依赖、`--in-place`);`slicer detect/open`(七注册、三 OS 规则、无默认应用 fallback);`doctor`(默认本地,`--check-api` 单次 balance) | tests/inspect, obj-transform, slicers, doctor | -| B09 | `chore(release)` + docs commit | 0.3.0 候选版本、README、随包 SKILL.md、.env.example、T-105;verification.json、REVIEW_HANDOFF | 本节 5、7 | -| Review R1 | `fix(review)` `0388fe8` | F01–F10(任务上下文/预检/共享 `submitCreate`、workspace 写入根、依赖复制根检查、凭据/媒体指纹、截止时间、嵌套合并、材质重链接、stream -o) | tests/codex-review-round1, operation-store, poll | -| Review R2 | `fix(review)` `cf8905d` | R2-F01–R2-F07(报告 -o/索引根/mkdir 顺序、多材质源名映射、stream 统一 outcome、部分 manifest、传输阶段 SIGINT、OAuth login_id、确定性 deadline 测试) | tests/codex-review-round2, poll, auth-headless | -| Review R3 | `fix(review)` `30536d8` + `test(poll)` `235d6de` | §0 表中的 R3-F01–R3-F06:`download.ts` sidecar 安全发布、统一收尾失败处理(`finalisationFailure`、`redigest`、`failed_step`)、relink 信号;`material-links.ts` 源身份优先与通道竞争歧义、`rewriteLines` 合作式中断;`task-command.ts`/`make.ts` legacy 收尾任务上下文;`errors.ts` legacy payload 附加 `task_id`/`operation_id`;`cmd/download.ts` realpath 坐标系记录项目文件;decisions D-045..D-050、migration-notes §3.3、README、SKILL.md;poll smoke 以循环时钟读数判定 | tests/codex-review-round3 + 三轮复现脚本重跑 + reviewer 正向脚本 20/20 | +修复 commit: -## 4. 能力与接口证据 - -- 更新后的 capability-matrix 路径:`docs/skill-parity/capability-matrix.json`(`review_rounds[0..2]`、各能力 `review_round_1/2/3`) -- endpoint-contracts 与模型/媒体参数映射:`docs/skill-parity/endpoint-contracts.json`(与 `src/client/resource-registry.ts` 由 `tests/resource-registry.test.ts` 逐字段对账;本轮未改) -- v1 JSON schema / 错误码定义:`src/internal/result.ts`(六固定键)、`src/internal/errors.ts`(`CliErrorCode` → 退出码表);`README.md` "Stable machine output"。本轮新增/变化字段(均为附加):`result.downloads.failed_step`(relink | digest | sidecar);`material_links.texture_maps[].note`;legacy 错误 payload 顶层 `task_id`、`operation_id`;legacy 错误 `hint` 为恢复命令 -- legacy compatibility / migration notes:`docs/skill-parity/migration-notes.md`(§3 兼容表、§3.1–§3.3 三轮修正;legacy 输出与文件布局不变,错误 payload 只增字段) -- intentional differences:`docs/skill-parity/migration-notes.md` §2(2.1–2.12);三轮设计选择:材质映射按来源身份、多候选与通道竞争拒绝猜测(D-039/D-047);无账户身份 OAuth 存量 profile 拒绝重放(D-043);sidecar 与资产同规则发布(D-045);越界索引根跳过而非越界写入(D-038) -- 限权 API / 未支持的 OS / deferred 新功能:UV/Creative Lab/showcases 真实调用 not_run;Windows/Linux 仅 fixture 覆盖;新 Creative Lab 产品记录为 deferred(矩阵 DF-001) +``` +93e55bc fix(review): address Codex review round 4 findings R4-F01, R4-F02 and test gap R4-T01 + + README.md | 18 +- + docs/skill-parity/decisions.md | 58 ++++ + docs/skill-parity/migration-notes.md | 9 + + skills/meshy-cli/SKILL.md | 7 +- + src/cmd/download.ts | 140 ++++++-- + src/internal/material-links.ts | 163 ++++++--- + src/internal/project-store.ts | 23 ++ + src/internal/task-command.ts | 36 +- + tests/codex-review-round3.test.ts | 28 +- + tests/codex-review-round4.test.ts | 648 +++++++++++++++++++++++++++++++++++ + 10 files changed, 1013 insertions(+), 117 deletions(-) +``` -### 脱敏实际 CLI 输出(tarball 安装的 `235d6de`,loopback mock;``/`` 为替换后的路径) +测试稳定性 commit(仅测试与 decisions.md 的 D-044 备注): -`text-to-3d create --mode preview --prompt "a cactus" --async --output-schema v1`(exit=0): +``` +68690f9 test(wait): allow the deadline wake-up's one extra GET that D-044 permits -```json -{ - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.create", - "ok": true, - "result": { - "task": null, - "submission": { - "state": "accepted", - "operation_id": "f64531e3-a77a-4223-a4dd-de9de9d1fabb", - "task_id": "fixture-task-a", - "request_id": null - }, - "downloads": { - "state": "not_requested", - "files": [], - "metadata_path": null - }, - "saved_json": null, - "task_id": "fixture-task-a", - "next": { - "get": "meshy text-to-3d get fixture-task-a --output-schema v1", - "wait": "meshy text-to-3d wait fixture-task-a --output-schema v1", - "stream": "meshy text-to-3d stream fixture-task-a --format ndjson --output-schema v1" - }, - "project": null - }, - "error": null, - "warnings": [] -} + docs/skill-parity/decisions.md | 7 +++++++ + tests/codex-review-round1.test.ts | 16 ++++++++++++---- + 2 files changed, 19 insertions(+), 4 deletions(-) ``` -`text-to-3d get fixture-task-a --output-schema v1`(exit=0,IN_PROGRESS): +要点: -```json -{ - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.get", - "ok": true, - "result": { - "task": { - "task_id": "fixture-task-a", - "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", - "name": null, - "status": "IN_PROGRESS", - "progress": 52, - "preceding_tasks": null, - "created_at": 1757200000000, - "started_at": 1757200010000, - "finished_at": 0, - "expires_at": 0, - "face_count": null, - "consumed_credits": null, - "model_urls": {}, - "image_urls": [], - "texture_urls": [], - "thumbnail_url": null, - "thumbnail_urls": null, - "alpha_thumbnail_url": null, - "result": null, - "printability": null, - "task_error": null - }, - "submission": { - "state": "accepted", - "operation_id": null - }, - "downloads": { - "state": "not_requested", - "files": [], - "metadata_path": null - }, - "saved_json": null - }, - "error": null, - "warnings": [] -} -``` +- `src/internal/material-links.ts`:`Resolution` 增加 `identity`;`resolveTextureReference` 不再接收 competing 集合;新增 `MapPair`/`pairId`/`arbitrate`(全局竞争);MTL 处理改为“先解析全部 distinct 对,再仲裁,再重写”;ambiguous warning 按原因去重并列出材质组;模块注释描述身份/启发式两类规则。 +- `src/cmd/download.ts`:`preflightProject`(lstat 常规文件、`readProject` 可解析、`--stage` 非空);项目阶段前固化 `outcome`;`projectRecordFailure` 包装 realpath/indexRootFor/recordTask;成功输出形状不变(`project: { project_dir, action, stage, recorded_files }`)。 +- `src/internal/project-store.ts`:`projectRecordCommand(projectDir, input, { root? })` + `shellArg`(仅在需要时加引号)。 +- `src/internal/task-command.ts`:`attachToProject` 失败时附加 `recovery: { action: "record_project", command }`、`hint`、原 http/retryable/details。 +- `tests/codex-review-round4.test.ts`(新,5 项);`tests/codex-review-round3.test.ts` C06 移除 make 段(迁入 R4-T01);`tests/codex-review-round1.test.ts` R03 “expiry during the sleep” 改为 D-044 不变量断言。 +- 文档:`decisions.md` D-051–D-053 + D-044 备注;`migration-notes.md` §3.4;README 材质段与 `--project` 记账失败段;SKILL.md 新增 `project.action: "failed"` 处置与材质 note 说明。 -`text-to-3d get fixture-task-failed --output-schema v1`(exit=0,FAILED 仍是成功的查询): +## 4. 能力与接口证据 + +### 4.1 第 4 轮 finding 修复后的实际输出(`round4-probes.mjs` 副本重跑,`68690f9`,脱敏) + +**D01**(`download --task-json /channel-fallback.json --model-format obj --output-dir /workspace/channel-fallback` → exit 0)。保存的 MTL 与服务端原文逐字节相同: + +```text +newmtl body +map_Kd body_normal.png +newmtl eyes +map_Kd eyes_diffuse.png -```json -{ - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.get", - "ok": true, - "result": { - "task": { - "task_id": "fixture-task-failed", - "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", - "name": null, - "status": "FAILED", - "progress": 0, - "preceding_tasks": null, - "created_at": 1757200000000, - "started_at": 1757200010000, - "finished_at": 1757200020000, - "expires_at": 0, - "face_count": null, - "consumed_credits": 0, - "model_urls": {}, - "image_urls": [], - "texture_urls": [], - "thumbnail_url": null, - "thumbnail_urls": null, - "alpha_thumbnail_url": null, - "result": null, - "printability": null, - "task_error": { - "message": "The server is busy. Please try again later." - } - }, - "submission": { - "state": "accepted", - "operation_id": null - }, - "downloads": { - "state": "not_requested", - "files": [], - "metadata_path": null - }, - "saved_json": null - }, - "error": null, - "warnings": [] -} ``` -`text-to-3d wait fixture-task-a --timeout 0 --output-schema v1`(exit=8): +`result.downloads.material_links`(贴图、mtllib、texture_maps、状态、warnings): ```json { - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.wait", - "ok": false, - "result": { - "task": { - "task_id": "fixture-task-a", - "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", - "name": null, - "status": "IN_PROGRESS", - "progress": 52, - "preceding_tasks": null, - "created_at": 1757200000000, - "started_at": 1757200010000, - "finished_at": 0, - "expires_at": 0, - "face_count": null, - "consumed_credits": null, - "model_urls": {}, - "image_urls": [], - "texture_urls": [], - "thumbnail_url": null, - "thumbnail_urls": null, - "alpha_thumbnail_url": null, - "result": null, - "printability": null, - "task_error": null - }, - "submission": { - "state": "accepted", - "operation_id": null - }, - "downloads": { - "state": "not_requested", - "files": [], - "metadata_path": null - }, - "saved_json": null, - "task_id": "fixture-task-a", - "wait": { - "timed_out": true, - "elapsed_seconds": 0.01, - "polls": 1 - }, - "next": { - "get": "meshy text-to-3d get fixture-task-a --output-schema v1", - "wait": "meshy text-to-3d wait fixture-task-a --output-schema v1", - "stream": "meshy text-to-3d stream fixture-task-a --format ndjson --output-schema v1" + "status": "incomplete", + "textures": [ + { + "key": "texture.0.base_color", + "name": "texture_0_base_color.png", + "source_name": "a.png", + "set": 0, + "channel": "basecolor" } - }, - "error": { - "code": "timed_out", - "message": "task fixture-task-a did not reach a terminal status within 0s (last status: IN_PROGRESS); the server keeps running it", - "http_status": null, - "retryable": false, - "recovery": { - "action": "wait", - "automatic": false, - "command": "meshy text-to-3d wait fixture-task-a --output-schema v1" + ], + "mtllib": [ + { + "line": 1, + "material": null, + "reference": "original.mtl", + "resolved_to": "model.mtl", + "method": "downloaded_mtl" } - }, - "warnings": [] + ], + "texture_maps": [ + { + "line": 2, + "material": "body", + "reference": "body_normal.png", + "resolved_to": null, + "method": "ambiguous", + "candidates": [ + "texture_0_base_color.png" + ], + "note": "'body_normal.png' (channel_of_key) and 'eyes_diffuse.png' (channel_in_name) compete for texture_0_base_color.png; different references cannot share one texture without evidence that they name the same image" + }, + { + "line": 4, + "material": "eyes", + "reference": "eyes_diffuse.png", + "resolved_to": null, + "method": "ambiguous", + "candidates": [ + "texture_0_base_color.png" + ], + "note": "'body_normal.png' (channel_of_key) and 'eyes_diffuse.png' (channel_in_name) compete for texture_0_base_color.png; different references cannot share one texture without evidence that they name the same image" + } + ], + "rewritten": [ + "/workspace/channel-fallback/model.obj" + ] } ``` -`text-to-3d create ... --async`,服务端返回 500(exit=10,仅一次 POST,日志记录 unknown): - ```json -{ - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.create", - "ok": false, - "result": { - "submission": { - "state": "unknown", - "operation_id": "d7354b5e-d51d-439a-8e8d-e47cd1738dce", - "task_id": null - }, - "task": null, - "downloads": { - "state": "not_requested", - "files": [], - "metadata_path": null - } - }, - "error": { - "code": "submission_unknown", - "message": "the create request was sent but its outcome is unknown (meshy api 500 on /text-to-3d: internal error); the server may or may not have created a task", - "http_status": 500, - "retryable": false, - "recovery": { - "action": "reconcile", - "automatic": false, - "command": "meshy text-to-3d list --output-schema v1 # then match operation d7354b5e-d51d-439a-8e8d-e47cd1738dce by time/prompt before creating again" - } - }, - "warnings": [] -} +[ + { + "code": "material_reference_ambiguous", + "message": "model.mtl: 'body_normal.png' (channel_of_key) and 'eyes_diffuse.png' (channel_in_name) compete for texture_0_base_color.png; different references cannot share one texture without evidence that they name the same image (body, eyes); the references stay as written — the CLI does not guess between material groups or sources" + } +] ``` -`download --resource rigging --task-id fixture-rig-1 --asset result.basic_animations.walking_glb_url --output walking.glb`(exit=0): +**D02**(`download --task-json /project-failure-task.json --all --project /workspace/projects/_record-failure_958d --workspace /workspace` → exit 11;model.glb 已落盘 = true,工作区外 metadata 字节未变 = true;请求:GET /model.glb): ```json { "schema_version": "meshy.cli/v1", "command": "download", - "ok": true, + "ok": false, "result": { "source": { - "kind": "api", - "resource": "rigging", - "task_id": "fixture-rig-1", - "status": "SUCCEEDED" + "kind": "task-json", + "path": "/project-failure-task.json", + "shape": "api", + "resource": "text-to-3d", + "task_id": "round2-task" }, "selection": { "selected": [ - "result.basic_animations.walking_glb_url" + "model.glb" ], "dependencies": [] }, @@ -353,11 +201,11 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt "state": "completed", "files": [ { - "key": "result.basic_animations.walking_glb_url", - "path": "/walking.glb", - "relative_path": "walking.glb", - "bytes": 28, - "sha256": "617b6574607e137a4eeb3821a0e3ce60abd56e34d8c4d256e159f61764f156a8", + "key": "model.glb", + "path": "/workspace/projects/_record-failure_958d/model.glb", + "relative_path": "projects/_record-failure_958d/model.glb", + "bytes": 49, + "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", "content_type": "model/gltf-binary", "format": "glb", "container_format": null, @@ -373,266 +221,303 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt }, "unknown_urls": [], "saved_json": null, - "project": null - }, - "error": null, - "warnings": [] -} -``` - -### 第 3 轮 finding 修复后的实际输出(`round3-probes.mjs` 副本重跑,`235d6de`,脱敏) - -C01 · `get --workspace W -o W/out`,资产 GET 期间在 `W/out/meta.json` 植入指向 W 外文件的符号链接(exit 11,外部文件内容不变,manifest 保留 model_glb): - -```json -{ - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.get", - "ok": false, - "result": { - "task": { - "task_id": "round2-task", - "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", - "name": null, - "status": "SUCCEEDED", - "progress": null, - "preceding_tasks": null, - "created_at": null, - "started_at": null, - "finished_at": null, - "expires_at": null, - "face_count": null, - "consumed_credits": null, - "model_urls": { - "glb": "http://127.0.0.1:/asset.glb" + "project": { + "project_dir": "/workspace/projects/_record-failure_958d", + "action": "failed", + "stage": "preview", + "recorded_files": [], + "error": { + "code": "local_io", + "message": "refusing to replace /workspace/projects/_record-failure_958d/metadata.json: it is not a regular file" }, - "image_urls": [], - "texture_urls": [], - "thumbnail_url": null, - "thumbnail_urls": null, - "alpha_thumbnail_url": null, - "result": null, - "printability": null, - "task_error": null - }, - "submission": { - "state": "accepted", - "operation_id": null - }, - "downloads": { - "state": "partial", - "files": [ - { - "key": "model_glb", - "path": "/workspace/sidecar-race/model.glb", - "bytes": 49, - "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", - "content_type": "model/gltf-binary", - "status": "written", - "error": null, - "relinked": false - } - ], - "metadata_path": null, - "failed_step": "sidecar" - }, - "saved_json": null, - "task_id": "round2-task", - "next": { - "get": "meshy text-to-3d get round2-task --output-schema v1", - "wait": "meshy text-to-3d wait round2-task --output-schema v1", - "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" + "recovery": { + "action": "record_project", + "automatic": false, + "command": "meshy project record --project /workspace/projects/_record-failure_958d --task-id round2-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --file model.glb" + } } }, "error": { "code": "local_io", - "message": "task round2-task is SUCCEEDED but downloading its assets failed: 1 file(s) were written but the sidecar step failed: sidecar path /workspace/sidecar-race/meta.json is a symbolic link; refusing to write through it", + "message": "1 file(s) were downloaded to /workspace/projects/_record-failure_958d but recording task round2-task in project /workspace/projects/_record-failure_958d failed: refusing to replace /workspace/projects/_record-failure_958d/metadata.json: it is not a regular file", "http_status": null, "retryable": false, "recovery": { - "action": "download", + "action": "record_project", "automatic": false, - "command": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " + "command": "meshy project record --project /workspace/projects/_record-failure_958d --task-id round2-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --file model.glb" }, - "hint": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " + "hint": "meshy project record --project /workspace/projects/_record-failure_958d --task-id round2-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --file model.glb" }, "warnings": [] } ``` -C02 · 资产 GET 期间在 `meta.json` 位置出现同名目录(exit 11,`downloads.state=partial`、`failed_step=sidecar`、model.glb 在磁盘): +**D03**(reviewer 的稳定中断探针,signal_sent=true,OBJ 完整 = true,磁盘文件 = ['model.mtl', 'model.obj'])→ exit 130,`error.code=interrupted`,`downloads.failed_step=relink`,manifest: ```json { - "schema_version": "meshy.cli/v1", - "command": "text-to-3d.get", - "ok": false, - "result": { - "task": { - "task_id": "round2-task", + "state": "partial", + "files": [ + { + "key": "model_obj", + "path": "/workspace/relink-interrupt/model.obj", + "bytes": 11600028, + "sha256": "91bd24a25a223efabd173c0ab2a9bd6125a984d8a0bed601a09ca4c593c6acec", + "content_type": "model/obj", + "status": "written", + "error": null, + "relinked": false + }, + { + "key": "model_mtl", + "path": "/workspace/relink-interrupt/model.mtl", + "bytes": 18, + "sha256": "997fd7d9f6b03ab40a1e252c6319d76c351d6f5ea2a431859077faee004243d6", + "content_type": "text/plain", + "status": "written", + "error": null, + "relinked": false + } + ], + "metadata_path": null, + "failed_step": "relink" +} +``` + +### 4.2 `round4-context-checks.mjs` 副本(`68690f9`):make 最后一步身份 + +legacy(exit 7,`task_id`/`operation_id` 顶层字段与 `result.submission`、`executed[-1]`、journal 一致): + +```json +{ + "name": "CliError", + "code": "network", + "status": 503, + "task_id": "legacy-step-2", + "operation_id": "e421a751-67a3-409c-b27e-8279e35693f8", + "hint": "meshy download --resource text-to-3d --task-id legacy-step-2 --all --output-dir " +} +``` + +```json +{ + "route": "text", + "task_id": "legacy-step-2", + "submission": { + "state": "accepted", + "operation_id": "e421a751-67a3-409c-b27e-8279e35693f8", + "task_id": "legacy-step-2" + }, + "executed": [ + { + "step": 1, "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", - "name": null, + "action": "preview", + "task_id": "legacy-step-1", "status": "SUCCEEDED", - "progress": null, - "preceding_tasks": null, - "created_at": null, - "started_at": null, - "finished_at": null, - "expires_at": null, - "face_count": null, - "consumed_credits": null, - "model_urls": { - "glb": "http://127.0.0.1:/asset.glb" - }, - "image_urls": [], - "texture_urls": [], - "thumbnail_url": null, - "thumbnail_urls": null, - "alpha_thumbnail_url": null, - "result": null, - "printability": null, - "task_error": null - }, - "submission": { - "state": "accepted", - "operation_id": null - }, - "downloads": { - "state": "partial", - "files": [ - { - "key": "model_glb", - "path": "/workspace/sidecar-failure/model.glb", - "bytes": 49, - "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", - "content_type": "model/gltf-binary", - "status": "written", - "error": null, - "relinked": false - } - ], - "metadata_path": null, - "failed_step": "sidecar" + "operation_id": "4e862c11-4ce8-438e-ad68-d3e2fe51cfb9" }, - "saved_json": null, - "task_id": "round2-task", - "next": { - "get": "meshy text-to-3d get round2-task --output-schema v1", - "wait": "meshy text-to-3d wait round2-task --output-schema v1", - "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" + { + "step": 2, + "resource": "text-to-3d", + "action": "refine", + "task_id": "legacy-step-2", + "status": "SUCCEEDED", + "operation_id": "e421a751-67a3-409c-b27e-8279e35693f8" } - }, - "error": { - "code": "local_io", - "message": "task round2-task is SUCCEEDED but downloading its assets failed: 1 file(s) were written but the sidecar step failed: refusing to overwrite existing file: /private/workspace/sidecar-failure/meta.json (pass --overwrite, or choose another path)", - "http_status": null, - "retryable": false, - "recovery": { - "action": "choose_path", - "automatic": false - }, - "hint": "meshy text-to-3d wait round2-task --output-schema v1" - }, - "warnings": [] + ], + "next": { + "get": "meshy text-to-3d get legacy-step-2 --output-schema v1", + "wait": "meshy text-to-3d wait legacy-step-2 --output-schema v1", + "stream": "meshy text-to-3d stream legacy-step-2 --format ndjson --output-schema v1" + } } ``` -C03 · OBJ 重写阶段发送 SIGINT(exit 130,`failed_step=relink`,无 meta.json): +v1(exit 7): ```json { "schema_version": "meshy.cli/v1", - "command": "text-to-3d.get", + "command": "make", "ok": false, - "result": { - "task": { - "task_id": "round2-task", - "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", - "name": null, - "status": "SUCCEEDED", - "progress": null, - "preceding_tasks": null, - "created_at": null, - "started_at": null, - "finished_at": null, - "expires_at": null, - "face_count": null, - "consumed_credits": null, - "model_urls": { - "obj": "http://127.0.0.1:/large.obj", - "mtl": "http://127.0.0.1:/original.mtl" - }, - "image_urls": [], - "texture_urls": [], - "thumbnail_url": null, - "thumbnail_urls": null, - "alpha_thumbnail_url": null, - "result": null, - "printability": null, - "task_error": null + "error": { + "code": "network", + "message": "make finished (task v1-step-2) but downloading its assets failed: download failed for model_glb: download failed for http://127.0.0.1:/asset.glb (HTTP 503 Service Unavailable)", + "http_status": 503, + "retryable": false, + "recovery": { + "action": "download", + "automatic": false, + "command": "meshy download --resource text-to-3d --task-id v1-step-2 --all --output-dir " }, + "details": { + "expired_or_denied": false + } + }, + "result": { + "route": "text", + "task_id": "v1-step-2", "submission": { "state": "accepted", - "operation_id": null + "operation_id": "d51c6fe0-f66b-4ee7-8c76-ae677e20f796", + "task_id": "v1-step-2" + }, + "executed": [ + { + "step": 1, + "resource": "text-to-3d", + "action": "preview", + "task_id": "v1-step-1", + "status": "SUCCEEDED", + "operation_id": "7fd4af5a-ac51-4dfc-b55c-e963e43282bf" + }, + { + "step": 2, + "resource": "text-to-3d", + "action": "refine", + "task_id": "v1-step-2", + "status": "SUCCEEDED", + "operation_id": "d51c6fe0-f66b-4ee7-8c76-ae677e20f796" + } + ], + "next": { + "get": "meshy text-to-3d get v1-step-2 --output-schema v1", + "wait": "meshy text-to-3d wait v1-step-2 --output-schema v1", + "stream": "meshy text-to-3d stream v1-step-2 --format ndjson --output-schema v1" }, "downloads": { - "state": "partial", + "state": "failed", "files": [ { - "key": "model_obj", - "path": "/workspace/relink-interrupt/model.obj", - "bytes": 4000028, - "sha256": "f00b46cb6d822e2ce93c6a7cf61251e80b76eb6ef71d9ef30bb07129330ac34d", - "content_type": "model/obj", - "status": "written", - "error": null, - "relinked": false - }, - { - "key": "model_mtl", - "path": "/workspace/relink-interrupt/model.mtl", - "bytes": 18, - "sha256": "997fd7d9f6b03ab40a1e252c6319d76c351d6f5ea2a431859077faee004243d6", - "content_type": "text/plain", - "status": "written", - "error": null, + "key": "model_glb", + "path": "/make-v1/model.glb", + "bytes": 0, + "sha256": "", + "content_type": null, + "status": "failed", + "error": "download failed for http://127.0.0.1:/asset.glb (HTTP 503 Service Unavailable)", "relinked": false } ], - "metadata_path": null, - "failed_step": "relink" - }, - "saved_json": null, - "task_id": "round2-task", - "next": { - "get": "meshy text-to-3d get round2-task --output-schema v1", - "wait": "meshy text-to-3d wait round2-task --output-schema v1", - "stream": "meshy text-to-3d stream round2-task --format ndjson --output-schema v1" + "metadata_path": null } - }, - "error": { - "code": "interrupted", - "message": "task round2-task is SUCCEEDED but downloading its assets was interrupted: 2 file(s) were written but the relink step was interrupted: interrupted while rewriting model.obj", - "http_status": null, - "retryable": false, - "recovery": { - "action": "download", - "automatic": false, - "command": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " - }, - "hint": "meshy download --resource text-to-3d --task-id round2-task --all --output-dir " - }, - "warnings": [] + } } ``` -C04 · 通过符号链接父目录访问的项目(exit 0,`recorded_files=["model.glb"]`): +### 4.3 tarball 安装 smoke(`68690f9`,`meshy-cli-0.3.0.tgz`;`` 为临时 npm prefix) + +`/prefix/bin/meshy resources --output-schema v1` → exit 0 + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "resources", + "ok": true, + "result": { + "items": [ + { + "name": "text-to-3d", + "kind": "task", + "command": "meshy text-to-3d", + "summary": "two-stage 3D generation from text (preview → refine)", + "endpoint": "/openapi/v2/text-to-3d", + "verbs": [ + "create", + "get", + "list", + "wait", + "stream", + "delete" + ] + }, + { + "name": "image-to-3d", + "kind": "task", + "command": "meshy image-to-3d", + "summary": "3D from a single image (standard or smart-topology low-poly)", + "endpoint": "/openapi/v1/image-to-3d", + "verbs": [ + "create", + "get", + "list", + "wait", + "stream", + "delete" + ] + }, + { + "name": "multi-image-to-3d", + "kind": "task", + "command": "meshy multi-image-to-3d", + "summary": "3D from multiple views (beta; prefer image-to-3d)", + "endpoint": "/openapi/v1/multi-image-to-3d", + "verbs": [ + "create", + "get", + "list", + "wait", + +… (truncated) +``` + +`/prefix/bin/meshy doctor --output-schema v1` → exit 0 + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "doctor", + "ok": true, + "result": { + "cli": { + "version": "0.3.0", + "node": "v24.20.0", + "platform": "darwin", + "arch": "arm64" + }, + "local_ready": true, + "api_ready": null, + "checks": [ + { + "id": "cli", + "status": "ok", + "detail": "meshy-cli 0.3.0 on node v24.20.0 (darwin arm64)" + }, + { + "id": "node", + "status": "ok", + "detail": "node v24.20.0 satisfies the required >=24" + }, + { + "id": "base_urls", + "status": "ok", + "detail": "v1 https://api.meshy.ai/openapi/v1; v2 https://api.meshy.ai/openapi/v2; creative-lab https://api.meshy.ai/openapi/creative-lab; public-web https://api.meshy.ai/web/public" + }, + { + "id": "api_key_file", + "status": "skipped", + "detail": "--api-key-file not given" + }, + { + "id": "credentials", + "status": "warn", + "detail": "sources present: --api-key=no, MESHY_API_KEY=no, --api-key-file=none, stored profile=absent (/config/credentials.json); values are never read by doctor. API commands need --api-key, MESHY_API_KEY, --api-key-file or `meshy auth login`" + }, + { + "id": "workspace", + "status": "skipped", + "detail": "--workspace not given (files land next to their targets)" + }, + { + "id": "cwd_env_files", + "status": "ok", + "detail": "no .env or .env.local in /pri +… (truncated) +``` + +`/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1` → exit 0 ```json { @@ -642,264 +527,222 @@ C04 · 通过符号链接父目录访问的项目(exit 0,`recorded_files=["m "result": { "source": { "kind": "task-json", - "path": "/alias-projects/20260907_232307_alias-review_3a3b/task.json", + "path": "/work/task-rigging.synthetic.json", "shape": "api", - "resource": "text-to-3d", - "task_id": "round2-task" - }, - "selection": { - "selected": [ - "model.glb" - ], - "dependencies": [] - }, - "downloads": { - "state": "completed", - "files": [ - { - "key": "model.glb", - "path": "/private/real-projects/20260907_232307_alias-review_3a3b/model.glb", - "relative_path": "model.glb", - "bytes": 49, - "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", - "content_type": "model/gltf-binary", - "format": "glb", - "container_format": null, - "extracted": null, - "status": "written", - "error": null, - "publish_method": "link", - "relinked": false - } - ], - "metadata_path": null, - "material_links": null - }, + "resource": null, + "task_id": "fixture-rig-1" + }, + "assets": [ + { + "key": "result.rigged_character_glb_url", + "kind": "rig", + "format": "glb", + "model_format": "glb", + "container_format": null, + "filename": "rigged_character.glb", + "dependencies": [], + "has_url": true + }, + { + "key": "result.basic_animations.walking_glb_url", + "kind": "animation", + "format": "glb", + "model_format": "glb", + "container_format": null, + "filename": "walking_glb.glb", + "dependencies": [], + "has_url": true + }, + { + "key": "result.basic_animations.running_glb_url", + "kind": "animation", + "format": "glb", + "model_format": "glb", + "container_format": null, + "filename": "running_glb.glb", + "dependencies": [], + "has_url": true + } + ], "unknown_urls": [], - "saved_json": null, - "project": { - "project_dir": "/alias-projects/20260907_232307_alias-review_3a3b", - "action": "added", - "stage": "preview", - "recorded_files": [ - "model.glb" - ] - } + "product": null, + "saved_json": null }, "error": null, "warnings": [] } ``` -C05 · 源文件名与生成名交叉碰撞后的 `model.mtl`(红材质 → 红图 `texture_0_base_color.png`): +`/prefix/bin/meshy make a red sports car --dry-run --output-schema v1` → exit 0 -``` -newmtl red -map_Kd texture_0_base_color.png -newmtl green -map_Kd texture_1_base_color.png +```json +{ + "schema_version": "meshy.cli/v1", + "command": "make", + "ok": true, + "result": { + "command": "make", + "route": "text", + "steps": [ + { + "step": 1, + "resource": "text-to-3d", + "action": "preview", + "estimated_credits": 20 + }, + { + "step": 2, + "resource": "text-to-3d", + "action": "refine", + "estimated_credits": 10 + } + ], + "estimated_credits": 30, + "note": "Estimates only — confirm prices at https://docs.meshy.ai/en/api/pricing and your balance with `meshy balance`.", + "dry_run": true, + "requests_made": 0 + }, + "error": null, + "warnings": [] +} ``` -C06 · 默认 legacy schema 同步 create,资产 503(exit 7;payload 含 `task_id`/`operation_id`/`result.submission`/`next`/manifest): +`/prefix/bin/meshy project record --project /work/meshy_output/_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1` → exit 0 ```json { - "name": "CliError", - "message": "download failed for model_glb: download failed for http://127.0.0.1:/missing.glb (HTTP 503 Service Unavailable)", - "code": "network", - "status": 503, - "hint": "meshy text-to-3d wait paid-legacy-created-id --output-schema v1", - "task_id": "paid-legacy-created-id", - "operation_id": "", + "schema_version": "meshy.cli/v1", + "command": "project.record", + "ok": true, "result": { - "task": { - "task_id": "paid-legacy-created-id", - "resource": "text-to-3d", - "endpoint": "/openapi/v2/text-to-3d", - "type": "text-to-3d-preview", - "name": null, - "status": "SUCCEEDED", - "progress": null, - "preceding_tasks": null, - "created_at": null, - "started_at": null, - "finished_at": null, - "expires_at": null, - "face_count": null, - "consumed_credits": null, - "model_urls": { - "glb": "http://127.0.0.1:/missing.glb" - }, - "image_urls": [], - "texture_urls": [], - "thumbnail_url": null, - "thumbnail_urls": null, - "alpha_thumbnail_url": null, - "result": null, - "printability": null, - "task_error": null - }, - "submission": { - "state": "accepted", - "operation_id": "", - "task_id": "paid-legacy-created-id", - "request_id": null - }, - "downloads": { - "state": "failed", + "project_dir": "/work/meshy_output/_smoke-demo_fixture-", + "action": "added", + "entry": { + "task_id": "fixture-rig-1", + "task_type": "rigging", + "resource": "rigging", + "endpoint": null, + "stage": "rigged", + "parent_task_id": null, + "status": null, "files": [ - { - "key": "model_glb", - "path": "/workspace/legacy-created/model.glb", - "bytes": 0, - "sha256": "", - "content_type": null, - "status": "failed", - "error": "download failed for http://127.0.0.1:/missing.glb (HTTP 503 Service Unavailable)", - "relinked": false - } + "rigged.glb" ], - "metadata_path": null + "task_json": null, + "operation_id": null, + "created_at": "2026-09-08T02:24:23.911Z", + "updated_at": "2026-09-08T02:24:23.911Z" }, - "saved_json": null, - "task_id": "paid-legacy-created-id", - "next": { - "get": "meshy text-to-3d get paid-legacy-created-id --output-schema v1", - "wait": "meshy text-to-3d wait paid-legacy-created-id --output-schema v1", - "stream": "meshy text-to-3d stream paid-legacy-created-id --format ndjson --output-schema v1" + "task_count": 1, + "index": { + "updated": true, + "error": null }, - "wait": { - "timed_out": false, - "elapsed_seconds": 0, - "polls": 1 + "migrated_from_legacy": false + }, + "error": null, + "warnings": [ + { + "code": "recorded_file_missing", + "message": "recorded file(s) not present in the project yet: rigged.glb" } - } + ] } ``` +`/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1` → exit 0 + +```json +{ + "schema_version": "meshy.cli/v1", + "command": "animation-catalog.list", + "ok": true, + "result": { + "items": [ + { + "action_id": 28, + "name": "Big Wave Hello", + "key": "Big_Wave_Hello", + "category": "DailyActions", + "sub_category": "Interacting", + "preview_url": "https://cdn.meshy.ai/webapp-assets/feature-demo/animation/preview/biped/Big_Wave_Hello.gif", + "rig_type": "style_02", + "is_default": false, + "is_free": false + }, + { + "action_id": 290, + "name": "Wave One Hand", + "key": "Wave_One_Hand", + "category": "DailyActions", + "sub_category": "Interacting", + "preview_url": "https://cdn.meshy.ai/webapp-assets/feature-demo/animation/preview/biped/Wave_One_Hand.gif", + "rig_type": "style_02", + "is_default": false, + "is_free": false + }, + { + +… (truncated) +``` + ## 5. 实际验证记录 -全部绑定代码 HEAD `235d6de34f4e78210cdf418a7273542fbd4ce906`,环境 macOS 26.6 arm64 / Node v24.20.0 / pnpm 11.24.0;日志见 `docs/skill-parity/verification.json`(`final_runs`、`package_smoke`、`review_rounds`)。 - -| 命令 / 测试 ID | 代码 SHA | 环境 | exit code | 结果 | 日志/fixture/报告 | -| --- | --- | --- | --- | --- | --- | -| `node --version` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm --version` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm install --frozen-lockfile` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm typecheck` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `pnpm test` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed (542 tests, 542 pass, 0 fail) | verification.json final_runs | -| `pnpm build` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `bash -c test "$(node dist/index.js --version)" = "$(node -p "require(\"./package.json\").version")"` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/meshy-cli-0.3.0.tgz` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `git diff --check fd94490` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed | verification.json final_runs | -| `for i in 1..12: node --import tsx --test tests/poll.test.ts` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed — 12/12 runs; the real-timer smoke now judges with the loop's own clock reading (one +0.003 ms false failure was seen on 30536d8 before this change) | verification.json final_runs | -| `for i in 1..3: node --import tsx --test tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts` | 235d6de34f4e | macOS arm64, Node 24.20.0 | 0 | passed — 3/3 runs (SIGINT-during-relink, sidecar race and other timing-sensitive cases) | verification.json final_runs | -| npm pack 文件清单 + 必需文件 + 禁止内容 | 235d6de34f4e | 同上 | 0 | passed(398 files;dist/skills/README/LICENSE/.env.example 存在;无 .env/credentials/tests/docs/src) | verification.json package_smoke | -| smoke:version | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:version_cli | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:help_cli | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:balance_help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:balance_nokey_json | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:balance_nokey_v1 | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:resources_v1 | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor_check_slicers | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:doctor_check_api_nokey | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 3 (expected 3) | passed | verification.json package_smoke | -| smoke:slicer_detect | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:inspect_pass | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:inspect_fail | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 12 (expected 12) | passed | verification.json package_smoke | -| smoke:inspect_unknown | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 13 (expected 13) | passed | verification.json package_smoke | -| smoke:mesh_prepare | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:mesh_refuse_overwrite | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 11 (expected 11) | passed | verification.json package_smoke | -| smoke:project_init | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_record | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_show | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:project_list | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:download_list | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:download_no_selector | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | -| smoke:make_dry_run | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:usage_unknown_flag | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 2 (expected 2) | passed | verification.json package_smoke | -| smoke:uv_help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:creative_lab_help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:stream_help | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| smoke:catalog_live | 235d6de34f4e | tarball in temp prefix, macOS arm64, Node 24.20.0 | 0 (expected 0) | passed | verification.json package_smoke | -| 第 3 轮复现脚本 `round3-probes.mjs`(6 场景) | 235d6de34f4e | node v24.20.0,loopback,临时目录 | 1(= 并非全部复现) | 0/6 复现 | verification.json review_rounds[2].probe_rerun | -| 第 2 轮复现脚本 `round2-probes.mjs`(8 场景) | 235d6de34f4e | 同上 | 1 | 0/8 复现(无回退) | verification.json review_rounds[1].reverified_on | -| 第 1 轮复现脚本 `round1-probes.mjs`(12 场景) | 235d6de34f4e | 同上 | 1 | 0/12 复现(无回退) | verification.json review_rounds[0].reverified_on | -| reviewer 正向核对 `verify-original-regressions.py`(20 项) | 235d6de34f4e | 对上述重跑结果 | 0 | 20/20 passed | verification.json review_rounds[2].reviewer_positive_checker | -| reviewer 补测 `stream-finalization-check.mjs` | 235d6de34f4e | 同上 | 0 | passed | verification.json review_rounds[2].stream_finalization_check | -| 新增行为用例 T-001…T-108(离线部分)+ R01–R12 + N01–N08 + C01–C06 | 235d6de34f4e | node:test + tsx,loopback mock,临时目录 | 0 | passed(`pnpm test` 542/542) | tests/*.test.ts;verification.json behavior_tests | - -基线(fd94490)上 `pnpm test` 363/363 通过,无基线失败;本次没有删除或跳过任何既有测试。唯一改写的既有断言:poll 真实定时器 smoke 的判定时钟(见 §0),预期不放宽。 +全部绑定代码 HEAD `68690f9273ff20bbf95e6ac586766e30d977b6b4`(Node v24.20.0,pnpm 11.24.0);机器记录见 `docs/skill-parity/verification.json`。 + +| 检查 | 结果 | 说明 | +| --- | --- | --- | +| `pnpm install --frozen-lockfile` | exit 0 | | +| `pnpm typecheck` | exit 0 | src + tests | +| `pnpm build` | exit 0 | | +| `pnpm test` | **547 pass / 0 fail / 0 skip**(547 项,41 s) | 含 5 项 round-4 新回归 | +| `dist/index.js --version` = package.json | exit 0 | | +| `git diff --check fd94490` / 工作树 | exit 0 / 0 | | +| `poll.test.ts` × 12 | 12/12 | | +| `codex-review-round1.test.ts` × 8 | 8/8 | R03 稳定性提交后;提交前在 `93e55bc` 上观察到 1/6 失败(polls=3) | +| round2+round3+round4 套件 × 3 | 3/3 | 含 SIGINT/竞争用例 | +| `round4-probes.mjs` 副本 | D01/D02 不再复现,D03 signal_sent=true exit 130 | 正向验收在仓库测试 | +| `round3-probes.mjs` 副本 | 0/6 复现(exit 1) | fs.watch C03 signal_sent=true, exit 130(本轮触发成功) | +| `round2-probes.mjs` 副本 | 0/8 复现(exit 1) | | +| `round1-probes.mjs` 副本 | 0/12 复现(exit 1) | | +| `verify-original-regressions.py` | **20/20** | reviewer 的正向核对脚本 | +| `round4-context-checks.mjs` 副本 | **5/5** | make legacy/v1、stream legacy/v1 sidecar、standalone relink SIGINT | +| `stream-finalization-check.mjs` 副本 | passed | 唯一 outcome、连续序号 | +| tarball smoke | **29/29** | `meshy-cli-0.3.0.tgz` sha256 `37c279550179ab1998278b92d828148858f1f2bf3dcc0247306813720e26f35c`,398 files,bins ['meshy', 'meshy-cli'] | ## 6. 真实环境验证 -| 环境/能力 | 身份/资源(脱敏) | 实际结果 | 未完成原因 | 补验方式 | -| --- | --- | --- | --- | --- | -| API Key 与 OAuth profile 回归 | 无 | not_run | 本会话无测试账户/Key | 用测试 Key:`meshy doctor --check-api`、`meshy balance --output-schema v1`、`meshy auth status`;OAuth:`meshy auth login` 后 `meshy text-to-3d list --output-schema v1`;核对 D-043:真实 token 端点是否返回 `user_id`,登录后 profile 是否带 `login_id` | -| UV / Creative Lab | 无 | not_run | 计费/账号 gate,未获预算授权 | `meshy uv-unwrap create --input-task-id <已有 SUCCEEDED 任务> --async --output-schema v1` → `wait`;`meshy creative-lab figure prototype create --image-url --async` → `wait` → `build create --input-task-id`;记录 task id、请求次数、消耗额度 | -| Enterprise showcases(计费查询) | 无 | not_run | 每次请求计费且需 Enterprise | `meshy showcases list --page-size 1 --output-schema v1`(一次);核对 `showcase_type=animated` 别名是否被服务端接受(D-007) | -| 公开动画目录(免费、无鉴权) | 无需身份 | passed(1 次 GET,157 条,8 条匹配 "wave",在 235d6de 重跑) | — | verification.json live_verification T-110 partial | -| macOS arm64 | 本机 | passed(tarball 安装 + 29 项 smoke) | — | verification.json package_smoke | -| Windows x64 | 无 | not_run | 无主机 | 安装 tarball,运行 `meshy doctor --check-slicers`、`meshy slicer detect`、`meshy mesh prepare-print`;检测规则已用 fixture 覆盖(T-088/T-089) | -| Linux x64 | 无 | not_run | 无主机 | 同上;CI(ubuntu, Node 24/26)会在推送后覆盖安装 smoke | -| 真实 slicer open | 无 | not_run | 本机未安装任何注册切片器 | 安装 OrcaSlicer 后 `meshy slicer open --slicer OrcaSlicer --file `,仅验证启动 | -| 真实 Meshy 多材质 OBJ 的引用重链接(D-039/D-047) | 无 | not_run | 需要真实 refine 任务的 OBJ+MTL+多组贴图 | `meshy download --resource text-to-3d --task-id --model-format obj --output-dir `,检查 `material_links.status` 与 `texture_maps[].method/note`(真实 MTL 是否引用 URL 末段文件名) | +- 已执行:真实公开动画目录 GET(免费、无鉴权;`/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1` → exit 0;public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157);macOS arm64 tarball 安装 + 29 项本地命令 smoke。 +- **not_run**(保持):T-104 真实 API Key/OAuth 登录、重新登录、真实 token 端点 `user_id`;T-109 Windows x64 / Linux x64;T-110 鉴权 get / 已有任务资产下载;T-111 UV Unwrap / Creative Lab / showcases(付费或权限门控);T-112 真实切片器 GUI;正式发布(未授权、未执行)。 +- 不把离线通过写成 G1-release passed;不进入 S2。 ## 7. 打包证据 -- 候选版本:`0.3.0`(package.json,**未发布**;版本号可由 reviewer 调整) -- tarball 路径:`/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/meshy-cli-0.3.0.tgz`(`npm pack --json --pack-destination`;复现见 §9) -- SHA256:`f29e8a168af1c66fe58c9ee53a4b392beca41350bccf55bf58c22eebf5e5f930` -- npm pack 文件清单:398 个文件;必需项 dist/index.js、skills/meshy-cli/SKILL.md、skills/meshy-cli/animation-library.json、README.md、LICENSE、.env.example、package.json 均在;无 .env / credentials / tests / docs / src / node_modules -- 临时安装 prefix:`npm install -g --prefix ` → `/bin/meshy` 与 `/bin/meshy-cli` 均指向 `dist/index.js` -- 两个 bin、sharp、本地无 Key/无 Python 验证:29 项 smoke 全部符合预期(含 `MESHY_API_KEY=""` 下 balance 的 stdout 为 JSON、exit 3;无 Key 的本地命令 exit 0;dist 中不含 python 引用) -- 是否发布:**未发布** +- `npm pack` → `meshy-cli-0.3.0.tgz`,sha256 `37c279550179ab1998278b92d828148858f1f2bf3dcc0247306813720e26f35c`,398 个文件;必需文件(package.json、dist/index.js、README.md、LICENSE、.env.example、skills/meshy-cli/SKILL.md)齐全 = true;禁止内容(src/、tests/、docs/、node_modules/、.env、pnpm-lock.yaml)不存在 = true。 +- `npm install -g --prefix ` → bins ['meshy', 'meshy-cli'];无 Python 依赖。 +- 未 `npm publish`,未推送。 ## 8. 需要 Codex 复审的事项 -1. **sidecar 发布(R3-F01)**:`writeMeta` 现在 `resolveWithinRoot`(拒绝符号链接叶子)+ `writeJsonFile` 独占发布;预检仍保留为早退。`saveReportOnly` 目录模式同路径;单文件模式的报告 JSON 已在写入前紧邻校验。legacy sidecar 文件权限从默认改为显式 0o644。 -2. **legacy 任务上下文(R3-F02)**:`toErrorPayload` 为 CliError 附加顶层 `task_id`/`operation_id`(仅在 result 中存在时);`hint` 回退为恢复命令 → stderr 出现任务 id;`make` legacy 的 `submission.operation_id` 取自最后一步 executed 记录。 -3. **材质身份(R3-F03)**:来源名优先于落盘名;生成名引用而来源为其它文件 → ambiguous + `note`;通道规则在多个不同引用竞争同一唯一贴图时判 ambiguous(两个材质各引用一张不同图但只下载到一张同通道贴图时保持原样并报警,不把唯一贴图分给其中一个);无来源证据的单测回退保留。 -4. **收尾失败处理(R3-F04)**:`finalisationFailure` 从磁盘重取摘要并以摘要变化推断 `relinked`;`failed_step` 三值;`downloadAssets` 的 relink 同样包裹;digest 步骤本身失败(读文件失败)也走同一路径。 -5. **relink 中断(R3-F05)**:`rewriteLines` 在每个 chunk 后检查 signal,发布前再检查并删除临时文件;OBJ 要么原样要么完整重写;C03 用轮询观察 `.model.obj.tmp-*` 触发 SIGINT(约 12 MB OBJ),并有 relinkMaterials 已中止 / 2 ms 后中止的进程内单测。 -6. **别名路径(R3-F06)**:`download --project` 的 `relative(realpath(project), realpath(file))`;`attachToProject` 的 `extra.files` 目前无调用方传入路径,故未改。 -7. **测试稳定性**:poll smoke 以循环的 `now()` 读数判定“截止前决策”(`starts.every(t < deadline)`),不再用 endpoint 内新取的时钟;确定性 fake-clock 用例不变;12/12 重复通过。 -8. 前三轮 §8 的其余事项仍然有效且本轮未改动相关代码(路由等价、transport 边界、锁顺序、slicer OS 行为、not_run 项)。 +1. R4-F01 的仲裁语义:身份证据(source_name / 落盘名无来源冲突 / source_stem)永不被否决;启发式命中只要有其它 distinct 引用(命中或 ambiguous 候选)争用同一贴图即否决;同一引用在不同 key 下落到不同贴图不重写。请确认这与 §8.3 的意图一致,特别是“身份命中 + 启发式竞争者”时保留身份命中、竞争者原文的选择。 +2. R4-F02 的 `project` 失败形状(`action: "failed"` + `error` + `recovery`,`error.recovery.action = "record_project"`)与预检范围;`index_dirty` 未被用于表达记账失败。 +3. R4-T01 覆盖是否满足长期保护要求;C06 的 make 段迁出(而非并存)是否可接受。 +4. 测试稳定性提交 `68690f9`(R03 “expiry during the sleep”):把 poll 数上限 2 改为 D-044 不变量断言的理由与证据(提交前 1/6 失败,提交后 8/8);产品逻辑未改。 +5. 旧 `round3-probes.mjs` 的 fs.watch C03 依赖 fs.watch 时机:本轮副本运行 signal_sent=true / exit 130(触发成功,与 D03 结论一致);稳定的中断证据为 D03、context 的 standalone-relink-interrupt 与仓库 C03(12 MB OBJ + 1 ms 轮询)。 ## 9. 最短复现步骤 -```sh -git clone https://github.com/meshy-dev/meshy-cli.git && cd meshy-cli -git fetch feat/skill-parity-s1 && git checkout 235d6de34f4e78210cdf418a7273542fbd4ce906 # 或使用本地仓库 /Users/ark/Dev/meshy-cli -node --version # v24.x -corepack enable && pnpm --version # 11.24.0 -pnpm install --frozen-lockfile -pnpm typecheck -pnpm test # 先 tsc 生成 dist,再运行 node:test(约 41 s;loopback mock,无外网、无凭据) -node --import tsx --test tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts tests/codex-review-round1.test.ts tests/poll.test.ts -for i in $(seq 1 12); do node --import tsx --test tests/poll.test.ts >/dev/null || echo "run $i failed"; done -pnpm build -mkdir -p /tmp/probes && cp /path/to/reviews/cli-s1-cf8905d/round3-probes.mjs /path/to/reviews/cli-s1-cf8905d/round2-probes.mjs /path/to/reviews/cli-s1-cf8905d/round1-probes.mjs /path/to/reviews/cli-s1-cf8905d/stream-finalization-check.mjs /path/to/reviews/cli-s1-cf8905d/verify-original-regressions.py /tmp/probes/ -node /tmp/probes/round3-probes.mjs "$PWD" # 期望 exit 1 且 6 个 reproduced=false -node /tmp/probes/round2-probes.mjs "$PWD" # 期望 exit 1 且 8 个 reproduced=false -node /tmp/probes/round1-probes.mjs "$PWD" # 期望 exit 1 且 12 个 reproduced=false -python3 /tmp/probes/verify-original-regressions.py # 期望 passed 20 / total 20 -node /tmp/probes/stream-finalization-check.mjs "$PWD" +```bash +cd /Users/ark/Dev/meshy-cli && git checkout 68690f9 +export FNM_DIR="$HOME/.local/share/fnm"; eval "$(fnm env --shell bash)"; fnm use 24 +pnpm install --frozen-lockfile && pnpm typecheck && pnpm test # 547/547 +node --import tsx --test tests/codex-review-round4.test.ts # D01, arbitration, D02, preflight, R4-T01 +mkdir -p /tmp/r4 && cp /Users/ark/Dev/meshy-agent-integrations-research/reviews/cli-s1-235d6de/{round4-probes.mjs,round4-context-checks.mjs} /tmp/r4/ +node /tmp/r4/round4-probes.mjs /Users/ark/Dev/meshy-cli # D01/D02 reproduced=false, D03 signal_sent=true exit 130 +node /tmp/r4/round4-context-checks.mjs /Users/ark/Dev/meshy-cli # 5/5 git diff --check fd94490 -npm pack --json --pack-destination /tmp/meshy-pack && shasum -a 256 /tmp/meshy-pack/meshy-cli-0.3.0.tgz -npm install -g --prefix /tmp/meshy-prefix /tmp/meshy-pack/meshy-cli-0.3.0.tgz -export MESHY_CONFIG_DIR=$(mktemp -d) -/tmp/meshy-prefix/bin/meshy --version && /tmp/meshy-prefix/bin/meshy doctor --output-schema v1 -MESHY_API_KEY= /tmp/meshy-prefix/bin/meshy balance --output-schema v1 ; echo "exit=$?" # 3, stdout 为 v1 envelope ``` -不依赖作者机器上的凭据、Python 或全局包;`tests/helpers/cli.ts` 为每次子进程创建隔离的 `MESHY_CONFIG_DIR` 与 cwd,并显式指向 loopback mock。复现脚本请从副本运行(它们在自身目录写结果文件);脚本 exit 0 表示缺陷全部复现,exit 1 表示并非全部复现——正向验收以仓库内测试与 reviewer 的正向核对脚本为准。 - ## 10. 交给 Codex 的复审提示词 -请复审 Meshy CLI S1 第 3 轮 review(reviews/cli-s1-cf8905d,R3-F01–R3-F06)的修复,并确认前两轮(reviews/cli-s1-6273d9a F01–F10、reviews/cli-s1-730132b R2-F01–R2-F07)修复未回退。仓库、base/head SHA 见本文件;代码 HEAD `235d6de34f4e78210cdf418a7273542fbd4ce906`(= 修复 `30536d8` + 测试稳定性提交;本文件所在 docs commit 不改代码),规范仍是 2026-09-07 v1 实施包。先核实 diff `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a..235d6de34f4e78210cdf418a7273542fbd4ce906` 与 verification.json/capability-matrix.json 绑定同一 HEAD,再逐项核对 §0 表:修复是否完整覆盖 finding 的触发条件与 acceptance(预检后目标变化、legacy 任务身份、下载完成后的失败与中断、材质来源身份、项目路径别名)、是否引入回归、正向回归测试是否真正断言了 expected(退出码、JSON 形状、task_id/submission/operation_id、请求次数、落盘内容与 manifest hash、ndjson 唯一 outcome 与序号、被拒目标连目录都不创建、外部文件字节不变),并重跑三个复现脚本副本、`verify-original-regressions.py`、`pnpm test`、`git diff --check`。 - -请优先检查 §8 列出的 8 项风险,以及任何新的可复现 bug、重复付费风险、凭据/路径问题、丢失资产/项目记录、OBJ 错误和伪造完成状态。不要做未授权付费调用、发布或 Skill/MCP 迁移。每项 finding 给出优先级、文件/行号、触发条件、影响与修复建议;区分代码缺陷、测试缺口与尚未完成的外部验证。最后分别判断 G1-code 是否可接受、G1-release 是否满足以及是否允许进入 S2。没有发现也要说明验证范围和剩余限制。 +> 请对 `/Users/ark/Dev/meshy-cli` 分支 `feat/skill-parity-s1` 做 Meshy CLI S1 第 5 轮 review。规范:2026-09-07 v1 实施包。代码 HEAD `68690f9273ff20bbf95e6ac586766e30d977b6b4`(= 修复 `93e55bcc717ceb9a368ee43aeeeac69a3fb52197` + 测试稳定性提交);docs HEAD 为其后的 docs-only commit(`git log -1`)。上一轮(`…/reviews/cli-s1-235d6de/`)结论 changes_requested:R4-F01、R4-F02(P2)与 R4-T01(P3 测试缺口)。请核对:(1) `tests/codex-review-round4.test.ts` 是否按每项 acceptance 做了正向断言(D01 两种顺序、MTL 字节、method/note、bytes/hash、请求次数;D02 完整 v1 形状、exit 11、source/task_id、manifest 摘要、project 失败与 `record_project` 恢复、外部字节不变、恢复命令回放;预检 0 请求;R4-T01 与 journal 对账、POST GET POST GET GET);(2) `src/internal/material-links.ts` 的两遍解析/仲裁是否覆盖 §8.3 的其它组合(身份 + 启发式、ambiguous 竞争者、同一引用多 key、顺序互换),且 C05/N04/round-1 材质用例未回退;(3) `src/cmd/download.ts` 的 `preflightProject`/`projectRecordFailure` 与 `task-command.ts` `attachToProject` 的恢复信息是否在所有入口一致,`index_dirty` 语义未被滥用;(4) 测试稳定性提交 `68690f9` 是否只改测试且理由成立(D-044);(5) 前四轮 26 项 finding 是否保持通过(可从副本重跑 round1–4 探针、`verify-original-regressions.py`、`round4-context-checks.mjs`、`stream-finalization-check.mjs`;旧 fs.watch C03 未触发时不得计为通过)。请勿修改 reviewer 证据目录;禁止付费调用、发布、Skill/MCP 迁移。真实账号、Windows/Linux、真实切片器、UV/Creative Lab/showcases 仍为 not_run;G1-release 与 S2 不在本轮范围。 diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index 1519875..0b81c64 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -1,8 +1,8 @@ { "schema_version": 1, "title": "Meshy CLI S1 capability matrix — implementation tracking", - "date": "2026-09-07", - "status": "review round 3 (Codex, reviewed 566f3bd/cf8905d): changes_requested → all 6 findings fixed in 30536d8 (+ test stabilisation 235d6de); rounds 1–2 (17 findings) re-verified; G1-code re-review pending; G1-release not_run", + "date": "2026-09-08", + "status": "review round 4 (Codex, reviewed 9e43a77/235d6de): changes_requested → R4-F01, R4-F02 fixed and R4-T01 covered in 93e55bc (+ test stabilisation 68690f9); rounds 1–3 (23 findings) re-verified; G1-code re-review pending; G1-release not_run", "baseline": { "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" @@ -20,7 +20,7 @@ "target": "meshy create --async", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-040", "T-043", @@ -66,7 +66,7 @@ "target": "meshy get --include-raw --save-json", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-006", "T-008", @@ -128,7 +128,7 @@ "target": "meshy wait", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-007", "T-047", @@ -192,7 +192,7 @@ "target": "meshy stream", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-049", "T-050", @@ -280,7 +280,7 @@ "target": "existing commands with explicit supported payload", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-028", "T-033", @@ -314,7 +314,7 @@ "target": "typed media flags and final payload normalization", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-029", "T-030", @@ -399,7 +399,7 @@ "target": "meshy creative-lab lamp prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-022", "T-023", @@ -438,7 +438,7 @@ "target": "meshy creative-lab keychain prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-022", "T-023", @@ -475,7 +475,7 @@ "target": "meshy creative-lab fridge-magnet prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-022", "T-023", @@ -561,7 +561,7 @@ "target": "download asset registry with role and dependencies", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "rounds 1–4 findings fixed; re-review pending", "test_ids": [ "T-060", "T-062", @@ -571,7 +571,8 @@ "src/internal/artifacts.ts enumerateAssets: model formats (lamp parts STL/ZIP, keychain/fridge OBJ as ZIP bundle), images, textures, primary/multi-view/alpha thumbnails, rig + basic_animations, motion, report; unknown URLs listed; tests/artifacts.test.ts T-060/T-062/T-071", "tests/codex-review-round1.test.ts R07/F09", "tests/codex-review-round2.test.ts N04 (source-name mapping, ambiguity)", - "tests/codex-review-round3.test.ts C05 (source identity vs generated names)" + "tests/codex-review-round3.test.ts C05 (source identity vs generated names)", + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -604,6 +605,16 @@ "tests": [ "tests/codex-review-round3.test.ts C05 (source identity vs generated names)" ] + }, + "review_round_4": { + "findings": [ + "R4-F01" + ], + "status": "fixed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" + ] } }, { @@ -617,7 +628,7 @@ "target": "meshy download --asset/--kind/--model-format", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "rounds 1–4 findings fixed; re-review pending", "test_ids": [ "T-061", "T-063" @@ -626,7 +637,8 @@ "src/cmd/download.ts selectors --asset/--model-format/--kind/--all/--list; tests/download-command.test.ts T-061, tests/artifacts.test.ts T-063", "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)", "tests/codex-review-round2.test.ts N04", - "tests/codex-review-round3.test.ts C05" + "tests/codex-review-round3.test.ts C05", + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" ], "intentional_differences": [ "download requires an explicit selector when a task exposes several assets (legacy defaulted to glb); legacy -o keeps downloading everything" @@ -661,6 +673,16 @@ "tests": [ "tests/codex-review-round3.test.ts C05" ] + }, + "review_round_4": { + "findings": [ + "R4-F01" + ], + "status": "fixed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" + ] } }, { @@ -674,7 +696,7 @@ "target": "safe download with actual final-path manifest", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "rounds 1–4 findings fixed; re-review pending", "test_ids": [ "T-064", "T-065", @@ -688,7 +710,8 @@ "src/internal/download.ts fetchToTemp (no credential, redirect re-validation, private-network refusal, size cap, sha256), exclusive publish + realpath containment, partial manifests; tests T-064..T-070", "tests/codex-review-round1.test.ts R07/F09, R09/F03", "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08", - "tests/codex-review-round3.test.ts C01+C02, C03, C05" + "tests/codex-review-round3.test.ts C01+C02, C03, C05", + "tests/codex-review-round4.test.ts D01, D02 (symlink/damaged/unwritable + recovery replay), preflight" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -728,6 +751,17 @@ "tests": [ "tests/codex-review-round3.test.ts C01+C02, C03, C05" ] + }, + "review_round_4": { + "findings": [ + "R4-F01", + "R4-F02" + ], + "status": "fixed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts D01, D02 (symlink/damaged/unwritable + recovery replay), preflight" + ] } }, { @@ -741,7 +775,7 @@ "target": "--save-json and project task snapshots", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-010", "T-063" @@ -785,7 +819,7 @@ "target": "meshy project init", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-072", "T-077" @@ -818,7 +852,7 @@ "target": "meshy project record/show", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "rounds 1–4 findings fixed; re-review pending", "test_ids": [ "T-073", "T-074", @@ -828,7 +862,8 @@ "project-store recordTask (task_id+stage merge, legacy v1 migration with backup, unknown fields kept) + project record/show; tests T-073/T-074/T-075", "tests/codex-review-round1.test.ts R08/F03", "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)", - "tests/codex-review-round3.test.ts C04 (aliased project records files)" + "tests/codex-review-round3.test.ts C04 (aliased project records files)", + "tests/codex-review-round4.test.ts D02 + preflight; project.action=failed with record_project recovery" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -861,6 +896,16 @@ "tests": [ "tests/codex-review-round3.test.ts C04 (aliased project records files)" ] + }, + "review_round_4": { + "findings": [ + "R4-F02" + ], + "status": "fixed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts D02 + preflight; project.action=failed with record_project recovery" + ] } }, { @@ -874,7 +919,7 @@ "target": "meshy project list/rebuild-index", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-073", "T-075", @@ -946,7 +991,7 @@ "target": "meshy mesh prepare-print", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-083", "T-084", @@ -1056,7 +1101,7 @@ "target": "--env-file without automatic dotenv/shell scanning", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-100", "T-101" @@ -1091,7 +1136,7 @@ "target": "preserve existing auth commands and token protocol", "baseline_status": "existing_needs_regression", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-104", "T-105" @@ -1137,7 +1182,7 @@ "target": "--output-schema v1; legacy compatibility", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-001", "T-002", @@ -1187,7 +1232,7 @@ "target": "make --async / --stop-after-first", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–3 findings fixed; re-review pending", + "review_status": "rounds 1–4 findings fixed; re-review pending", "test_ids": [ "T-041", "T-042" @@ -1196,7 +1241,8 @@ "src/cmd/make.ts --async single POST, --stop-after-first, mutual exclusion; tests T-041/T-042", "tests/codex-review-round1.test.ts R11/F02, F01 make polling, R09/F03 make -o", "tests/codex-review-round2.test.ts N06 (make -o partial manifest)", - "tests/codex-review-round3.test.ts C06 (legacy make -o 503; v1 make SIGINT)" + "tests/codex-review-round3.test.ts C06 (legacy make -o 503; v1 make SIGINT)", + "tests/codex-review-round4.test.ts R4-T01 make identity (legacy/v1 × 503/SIGINT) reconciled with the journal" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -1233,6 +1279,16 @@ "tests": [ "tests/codex-review-round3.test.ts C06 (legacy make -o 503; v1 make SIGINT)" ] + }, + "review_round_4": { + "findings": [ + "R4-T01" + ], + "status": "test_gap_closed", + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "tests": [ + "tests/codex-review-round4.test.ts R4-T01 make identity (legacy/v1 × 503/SIGINT) reconciled with the journal" + ] } }, { @@ -1246,7 +1302,7 @@ "target": "operation journal; never claim server idempotency", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 235d6de); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", "test_ids": [ "T-043", "T-044", @@ -1397,8 +1453,8 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-07T15:29:23.126Z", - "code_head": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "updated_at": "2026-09-08T02:25:00.312725Z", + "code_head": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "review_rounds": [ { @@ -1460,6 +1516,22 @@ "code_head": "235d6de34f4e78210cdf418a7273542fbd4ce906", "regression_tests": "tests/codex-review-round3.test.ts (C01–C06 as positive assertions, relink abort unit tests, make interrupt)", "probe_rerun": "reviews/cli-s1-cf8905d/round3-probes.mjs on 235d6de: 0/6 defects reproduce; round2 0/8; round1 0/12; reviewer's verify-original-regressions.py 20/20" + }, + { + "round": 4, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-235d6de", + "reviewed_head": "9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4", + "verdict": "changes_requested", + "findings": [ + "R4-F01", + "R4-F02", + "R4-T01 (test gap)" + ], + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "code_head": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "regression_tests": "tests/codex-review-round4.test.ts (D01 both orders + arbitration cases, D02 three failure modes + recovery replay, preflight, R4-T01 make identity); C06 keeps create/wait/get; R03 asserts D-044 invariants", + "probe_rerun": "reviews/cli-s1-235d6de/round4-probes.mjs copy on 68690f9: D01/D02 no longer reproduce, D03 positive (signal_sent=true, exit 130); round3 0/6 (fs.watch C03 trigger missed, not counted), round2 0/8, round1 0/12; verify-original-regressions.py 20/20; round4-context-checks 5/5; stream check passed" } ] } diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json index 4bba377..93757a3 100644 --- a/docs/skill-parity/verification.json +++ b/docs/skill-parity/verification.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "status": "review round 3 fixes applied (Codex reviewed 566f3bd/cf8905d: changes_requested, 6 findings); re-review pending; live checks not_run", + "status": "review round 4 fixes applied (Codex reviewed 9e43a77/235d6de: changes_requested, 2 P2 code findings + 1 P3 test gap); re-review pending; live checks not_run", "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", "environment": { "os": "macOS 26.6 (Darwin 25.6.0) arm64", @@ -43,93 +43,108 @@ "final_runs": [ { "command": "node --version", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm --version", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm install --frozen-lockfile", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "duration_s": 1, "result": "passed" }, { "command": "pnpm typecheck", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "pnpm build", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm test", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "duration_s": 41, "result": "passed", - "tests": 542, - "pass": 542, + "tests": 547, + "pass": 547, "fail": 0, "skipped": 0 }, { - "command": "pnpm build", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "duration_s": 0, "result": "passed" }, { - "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, - "duration_s": 1, + "duration_s": 6, "result": "passed" }, { - "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/meshy-cli-0.3.0.tgz", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, - "duration_s": 0, + "duration_s": 6, "result": "passed" }, { - "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/meshy-cli-0.3.0.tgz", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "git diff --check fd94490", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { - "command": "git diff --check fd94490", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "git diff --check", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "for i in 1..12: node --import tsx --test tests/poll.test.ts", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, - "duration_s": 6, + "duration_s": 15, + "result": "passed", + "detail": "12/12 runs" + }, + { + "command": "for i in 1..8: node --import tsx --test tests/codex-review-round1.test.ts", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "exit_code": 0, + "duration_s": 49, "result": "passed", - "detail": "12/12 runs; the real-timer smoke now judges with the loop's own clock reading (one +0.003 ms false failure was seen on 30536d8 before this change)" + "detail": "8/8 runs; the R03 'expiry during the sleep' check now asserts the D-044 invariants (it failed 1/6 runs on 93e55bc before the test-only commit 68690f9)" }, { - "command": "for i in 1..3: node --import tsx --test tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "for i in 1..3: node --import tsx --test tests/codex-review-round4.test.ts tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, - "duration_s": 20, + "duration_s": 17, "result": "passed", - "detail": "3/3 runs (SIGINT-during-relink, sidecar race and other timing-sensitive cases)" + "detail": "3/3 runs (SIGINT-during-relink, sidecar race, project-record race, make SIGINT and other timing-sensitive cases)" } ], "behavior_tests": { @@ -138,7 +153,7 @@ "files": [ "tests/cli-contract.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-002": { "result": "passed", @@ -146,49 +161,49 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-003": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-004": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-005": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-006": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-007": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-008": { "result": "passed", "files": [ "tests/task-view.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-009": { "result": "passed", @@ -196,12 +211,14 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts", - "tests/codex-review-round3.test.ts" + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F10: R10 stream -o in ndjson/json/pretty; one outcome on download failure", "review_round_2": "R2-F01 N01 report-only get/wait/stream -o confined; R2-F04 N06 partial manifest; R2-F05 N08 SIGINT", - "review_round_3": "R3-F02 C06: legacy get/wait/create/make -o failures name the task (task_id, operation_id, next, manifest, hint)" + "review_round_3": "R3-F02 C06: legacy get/wait/create/make -o failures name the task (task_id, operation_id, next, manifest, hint)", + "review_round_4": "R4-T01: make's task_id, submission.operation_id, executed[-1].operation_id and the legacy top-level operation_id are the last accepted journal record's (distinct step ids; legacy/v1 × asset 503/SIGINT)" }, "T-010": { "result": "passed", @@ -211,7 +228,7 @@ "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F01: R05 save-json preflight + post-POST failure keeps the id", "review_round_2": "R2-F04 N06: task -o failure keeps HTTP class/status and the files already written", "review_round_3": "R3-F01/R3-F04 C01+C02: sidecar planted after the preflight refused; committed model kept in the manifest with failed_step" @@ -222,28 +239,28 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-012": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-020": { "result": "passed", "files": [ "tests/resource-registry.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-021": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-022": { "result": "passed", @@ -251,7 +268,7 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-023": { "result": "passed", @@ -259,14 +276,14 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-024": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-025": { "result": "passed", @@ -274,7 +291,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F08: R04 three-layer options merge on the real POST body" }, "T-026": { @@ -282,14 +299,14 @@ "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-027": { "result": "passed", "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-028": { "result": "passed", @@ -297,7 +314,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F08: R04" }, "T-029": { @@ -306,7 +323,7 @@ "tests/file-input.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F06: R02 equal-length images conflict, re-wrapped base64 replays, no base64 in journal" }, "T-030": { @@ -314,28 +331,28 @@ "files": [ "tests/file-input.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-031": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-032": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-040": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-041": { "result": "passed", @@ -343,7 +360,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F02: R11 make journal failure after acceptance is local_io with the id (text + image routes)" }, "T-042": { @@ -352,7 +369,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F02: R11" }, "T-043": { @@ -361,25 +378,27 @@ "tests/operation-store.test.ts", "tests/task-lifecycle.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-044": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-045": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts", - "tests/codex-review-round3.test.ts" + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F01: R06 polling 5xx keeps id/submission/next; make polling failure keeps executed steps", - "review_round_3": "R3-F02 C06: legacy sync create keeps the accepted task on an asset 503 with exactly one POST" + "review_round_3": "R3-F02 C06: legacy sync create keeps the accepted task on an asset 503 with exactly one POST", + "review_round_4": "R4-T01: two journal records, no re-submission; POST GET POST GET GET; refine.preview_task_id = step 1" }, "T-046": { "result": "passed", @@ -388,7 +407,7 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "barrier-released three-process race; F05/F06 fingerprint unit tests" }, "T-047": { @@ -398,9 +417,10 @@ "tests/codex-review-round1.test.ts", "tests/poll.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F07: deadline bounds the sleep; --timeout 0 request cap", - "review_round_2": "R2-F07: deterministic fake-clock deadline tests; real-timer smoke asserts only no GET after the deadline" + "review_round_2": "R2-F07: deterministic fake-clock deadline tests; real-timer smoke asserts only no GET after the deadline", + "review_round_4": "R03 'expiry during the sleep' asserts the D-044 invariants (test-only commit 68690f9); 8/8 standalone runs" }, "T-048": { "result": "passed", @@ -408,40 +428,42 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts", - "tests/codex-review-round3.test.ts" + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F01: task context on every post-acceptance failure", "review_round_2": "R2-F05 N08: SIGINT before headers / mid-body / on the second asset → 130 with manifest, no leftovers", - "review_round_3": "R3-F05 C03: SIGINT during the OBJ rewrite → 130, digests re-taken, no sidecar, no temp file; make interrupt" + "review_round_3": "R3-F05 C03: SIGINT during the OBJ rewrite → 130, digests re-taken, no sidecar, no temp file; make interrupt", + "review_round_4": "R4-T01: SIGINT during make's final download under both schemas keeps the last step's identity; no final file" }, "T-049": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-050": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-051": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-052": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-053": { "result": "passed", @@ -451,7 +473,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F10: R10", "review_round_2": "R2-F03 N05: save-json/project/download failures after the stream end in one outcome with the next sequence" }, @@ -460,7 +482,7 @@ "files": [ "tests/artifacts.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-061": { "result": "passed", @@ -468,7 +490,7 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-062": { "result": "passed", @@ -477,44 +499,48 @@ "tests/download-command.test.ts", "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts", - "tests/codex-review-round3.test.ts" + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F09: R07 rewritten mtllib/map_* references verified on disk; unresolved map reported", "review_round_2": "R2-F02 N04: multi-material textures mapped by source name; ambiguity kept + warned (v1 and legacy -o)", - "review_round_3": "R3-F03 C05: source name beats a colliding generated name (verified by image bytes); generated-name reference with another source is ambiguous" + "review_round_3": "R3-F03 C05: source name beats a colliding generated name (verified by image bytes); generated-name reference with another source is ambiguous", + "review_round_4": "R4-F01 D01: two references reaching the sole texture only through channel fallbacks stay as written in both orders (MTL byte-identical, one shared note, digests match disk); arbitration unit cases (identity vs heuristic, ambiguous rival, one reference/two keys, lone and distinct-channel fallbacks)" }, "T-063": { "result": "passed", "files": [ "tests/artifacts.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-064": { "result": "passed", "files": [ "tests/download-command.test.ts", "tests/codex-review-round2.test.ts", - "tests/codex-review-round3.test.ts" + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_2": "R2-F01 N03: refused download creates no directory; zero requests", - "review_round_3": "R3-F01 C01: sidecar publication re-proves the root and refuses symlink/file/directory targets" + "review_round_3": "R3-F01 C01: sidecar publication re-proves the root and refuses symlink/file/directory targets", + "review_round_4": "R4-F02 D02: project record failure after the transfer keeps the full result and a record_project recovery; preflight refuses symlink/damaged metadata.json and blank --stage before any request" }, "T-066": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-067": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-068": { "result": "passed", @@ -522,26 +548,28 @@ "tests/download-command.test.ts", "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts", - "tests/codex-review-round3.test.ts" + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F03/F04: R08 project roots, R09 task -o, R12 dependency symlink escapes", "review_round_2": "R2-F01 N01/N03: outside/symlinked targets refused before mkdir for report tasks and downloads", - "review_round_3": "R3-F01 C01: outside file bytes unchanged across the sidecar race, single-file and legacy variants" + "review_round_3": "R3-F01 C01: outside file bytes unchanged across the sidecar race, single-file and legacy variants", + "review_round_4": "R4-F02 D02: outside metadata bytes and the original metadata stay unchanged; planted symlink not replaced; the recovery command records exactly the downloaded file with no request" }, "T-069": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-070": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-071": { "result": "passed", @@ -549,7 +577,7 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-072": { "result": "passed", @@ -557,33 +585,35 @@ "tests/project-store.test.ts", "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts", - "tests/codex-review-round3.test.ts" + "tests/codex-review-round3.test.ts", + "tests/codex-review-round4.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F03: R08 project init/record/rebuild-index confined to --workspace", "review_round_2": "R2-F01 N02: implicit history root confined; metadata recorded, parent untouched", - "review_round_3": "R3-F06 C04: aliased project directory records its files; truly outside files still not recorded" + "review_round_3": "R3-F06 C04: aliased project directory records its files; truly outside files still not recorded", + "review_round_4": "R4-F02 D02: project.action=failed with error + recovery; damaged metadata.json is left for repair; unwritable project directory (lock cannot be created) is local_io with the result; healthy project still records" }, "T-073": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-074": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-075": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-076": { "result": "passed", @@ -591,7 +621,7 @@ "tests/project-store.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_2": "R2-F01 N02: index skipped with index.updated=false + reason when the root is outside --workspace" }, "T-077": { @@ -599,49 +629,49 @@ "files": [ "tests/project-store.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-080": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-081": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-082": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-083": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-084": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-085": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-086": { "result": "passed", @@ -649,7 +679,7 @@ "tests/obj-transform.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F04: R12 symlinked materials/ and texture dir refused; real dirs copied" }, "T-087": { @@ -657,35 +687,35 @@ "files": [ "tests/obj-transform.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-088": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-089": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-090": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-091": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-100": { "result": "passed", @@ -695,7 +725,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F05: R01 API key digest binding", "review_round_2": "R2-F06 N07: OAuth without user_id/login_id refused a replay; login_id binds; refresh keeps it" }, @@ -704,7 +734,7 @@ "files": [ "tests/env-file.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-102": { "result": "passed", @@ -712,14 +742,14 @@ "tests/cli-contract.test.ts", "tests/doctor.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-103": { "result": "passed", "files": [ "tests/doctor.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" }, "T-105": { "result": "passed", @@ -728,7 +758,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_1": "F05: R01 OAuth subject binding (token rotation keeps identity)", "review_round_2": "R2-F06 N07" }, @@ -737,7 +767,7 @@ "files": [ "tests/auth-headless.test.ts" ], - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "review_round_2": "R2-F06: a device-flow login mints login_id (offline E2E stub)" } }, @@ -752,13 +782,13 @@ "id": "T-109", "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "partial", - "detail": "macOS 26.6 arm64, Node 24.20.0: tarball install + 29 smoke checks passed on 235d6de (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" + "detail": "macOS 26.6 arm64, Node v24.20.0: tarball install + 29 smoke checks passed on 68690f9 (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" }, { "id": "T-110", "scope": "real free queries, existing task get, asset download", "result": "partial", - "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions) on 235d6de returned 157 entries, 8 matched \"wave\". Authenticated get/download: not_run (no credential)" + "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions, free) on 68690f9: exit 0; public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157. Authenticated get/download: not_run (no credential)" }, { "id": "T-111", @@ -773,12 +803,12 @@ "reason": "no slicer application installed on this host (slicer detect returned an empty list)" } ], - "code_head": "235d6de34f4e78210cdf418a7273542fbd4ce906", - "recorded_at": "2026-09-07T15:29:10.625Z", - "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1/2/3 notes name the Codex finding and probe the file now covers.", + "code_head": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "recorded_at": "2026-09-08T02:25:00.312725Z", + "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1/2/3/4 notes name the Codex finding and probe the file now covers.", "package_smoke": { "tarball": "meshy-cli-0.3.0.tgz", - "sha256": "f29e8a168af1c66fe58c9ee53a4b392beca41350bccf55bf58c22eebf5e5f930", + "sha256": "37c279550179ab1998278b92d828148858f1f2bf3dcc0247306813720e26f35c", "file_count": 398, "required_present": true, "forbidden_absent": true, @@ -791,8 +821,8 @@ "checks": [ { "id": "version", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy --version", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --version", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -800,8 +830,8 @@ }, { "id": "version_cli", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy-cli --version", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --version", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -809,8 +839,8 @@ }, { "id": "help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy --help", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --help", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -818,8 +848,8 @@ }, { "id": "help_cli", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy-cli --help", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --help", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -827,8 +857,8 @@ }, { "id": "balance_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy balance --help", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --help", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -836,8 +866,8 @@ }, { "id": "balance_nokey_json", - "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy balance", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -845,8 +875,8 @@ }, { "id": "balance_nokey_v1", - "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy balance --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -854,8 +884,8 @@ }, { "id": "resources_v1", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy resources --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy resources --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -863,8 +893,8 @@ }, { "id": "doctor", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy doctor --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -872,8 +902,8 @@ }, { "id": "doctor_check_slicers", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy doctor --check-slicers --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-slicers --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -881,8 +911,8 @@ }, { "id": "doctor_check_api_nokey", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy doctor --check-api --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-api --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -890,8 +920,8 @@ }, { "id": "slicer_detect", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy slicer detect --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy slicer detect --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -899,8 +929,8 @@ }, { "id": "inspect_pass", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -908,8 +938,8 @@ }, { "id": "inspect_fail", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 12, "expected_exit": 12, "result": "passed", @@ -917,8 +947,8 @@ }, { "id": "inspect_unknown", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy inspect faces --task-json /dev/stdin --max-faces 300000 --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-unknown-faces.json --max-faces 300000 --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 13, "expected_exit": 13, "result": "passed", @@ -926,8 +956,8 @@ }, { "id": "mesh_prepare", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -935,8 +965,8 @@ }, { "id": "mesh_refuse_overwrite", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 11, "expected_exit": 11, "result": "passed", @@ -944,8 +974,8 @@ }, { "id": "project_init", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -953,8 +983,8 @@ }, { "id": "project_record", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/work/meshy_output/20260907_232807_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_102423_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -962,8 +992,8 @@ }, { "id": "project_show", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/work/meshy_output/20260907_232807_smoke-demo_fixture- --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_102423_smoke-demo_fixture- --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -971,8 +1001,8 @@ }, { "id": "project_list", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -980,8 +1010,8 @@ }, { "id": "download_list", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -989,8 +1019,8 @@ }, { "id": "download_no_selector", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -998,8 +1028,8 @@ }, { "id": "make_dry_run", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1007,8 +1037,8 @@ }, { "id": "usage_unknown_flag", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy balance --bogus --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --bogus --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -1016,8 +1046,8 @@ }, { "id": "uv_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy uv-unwrap create --help", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy uv-unwrap create --help", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1025,8 +1055,8 @@ }, { "id": "creative_lab_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy creative-lab lamp build create --help", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy creative-lab lamp build create --help", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1034,8 +1064,8 @@ }, { "id": "stream_help", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy text-to-3d stream --help", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy text-to-3d stream --help", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1043,12 +1073,13 @@ }, { "id": "catalog_live", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r5/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", - "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "exit_code": 0, "expected_exit": 0, "result": "passed", - "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential" + "environment": "tarball installed into a temporary npm prefix; isolated MESHY_CONFIG_DIR; no credential", + "note": "public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157" } ] }, @@ -1300,6 +1331,252 @@ "passed": true, "sha": "235d6de34f4e78210cdf418a7273542fbd4ce906" } + }, + { + "round": 4, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-235d6de", + "reviewed_head": "9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4", + "reviewed_code_head": "235d6de34f4e78210cdf418a7273542fbd4ce906", + "verdict": "changes_requested (G1-code not_accepted, G1-release not_satisfied, S2 not allowed)", + "findings": 2, + "test_gaps": 1, + "priorities": { + "P2": 2, + "P3": 1 + }, + "fixed_in": "93e55bcc717ceb9a368ee43aeeeac69a3fb52197", + "test_stabilised_in": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "regression_tests": [ + "tests/codex-review-round4.test.ts (D01 both orders + arbitration unit cases; D02 symlink/damaged/unwritable + recovery command replay; preflight; R4-T01 make identity legacy/v1 × 503/SIGINT)", + "tests/codex-review-round3.test.ts C06 (make scenarios moved to round 4 with distinct ids; create/wait/get kept)", + "tests/codex-review-round1.test.ts R03 (D-044 invariants instead of a poll-count cap; test-only)" + ], + "reviewer_test_result_before_fix": "pnpm test 542/542; 20/20 round 1+2 positive checks; 6/6 round-3 positive checks; 5/5 context checks; D01/D02 reproduced, D03 positive", + "probe_rerun": { + "script": "reviews/cli-s1-235d6de/round4-probes.mjs (byte-identical copy in a scratch directory; the reviewer's evidence directories are untouched)", + "node": "v24.20.0", + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "reproduced_after_fix": 0, + "total": 3, + "exit_code": 0, + "note": "D01 and D02 no longer show the old behaviour; D03 is the reviewer's positive interrupt probe (signal_sent=true, exit 130). reproduced=false is not the acceptance — positive assertions are in tests/codex-review-round4.test.ts", + "cases": [ + { + "id": "D01-channel-fallback-collapses-different-references", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "D02-project-finalization-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "D03-stable-relink-interrupt-positive", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + } + ] + }, + "older_probe_reruns": { + "round3": { + "script": "round3-probes.mjs copy", + "reproduced": 0, + "total": 6, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "note": "original fs.watch C03: signal_sent=True, exit 130 — its trigger fired in this run (it had not in the reviewer's run; fs.watch timing decides), the stable D03 probe (signal_sent=True, exit 130), round4-context standalone-relink-interrupt and the repo C03 test are the primary evidence", + "cases": [ + { + "id": "C01-sidecar-follows-new-symlink-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C02-sidecar-failure-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C03-sigint-during-relink-still-succeeds", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + }, + { + "id": "C04-project-alias-drops-asset-record", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C05-saved-name-match-overrides-source-identity", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C06-legacy-create-download-error-loses-task-id", + "reproduced": false, + "exit_code": 7 + } + ] + }, + "round2": { + "script": "round2-probes.mjs copy", + "reproduced": 0, + "total": 8, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "cases": [ + { + "id": "N01-report-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N02-project-index-outside-workspace", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N03-rejected-download-creates-outside-directory", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N04-multiple-textures-collapse-to-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N05-stream-save-error-has-no-outcome-event", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N06-task-download-partial-files-lost", + "reproduced": false, + "exit_code": 7 + }, + { + "id": "N07-oauth-without-subject-replays-other-token", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "N08-task-download-ignores-sigint", + "reproduced": false, + "exit_code": 130 + } + ] + }, + "round1": { + "script": "round1-probes.mjs copy", + "reproduced": 0, + "total": 12, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "cases": [ + { + "id": "R01-credential-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R02-media-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R03-wait-deadline", + "reproduced": false, + "exit_code": 8 + }, + { + "id": "R04-options-shallow-merge", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R05-post-save-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R06-post-poll-lost-id", + "reproduced": false, + "exit_code": 1 + }, + { + "id": "R07-obj-broken-material", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R08-project-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R09-task-output-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R10-stream-output-ignored", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R11-make-journal-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R12-obj-dependency-symlink", + "reproduced": false, + "exit_code": 11 + } + ] + } + }, + "reviewer_positive_checker": { + "script": "verify-original-regressions.py on the re-run round-1/round-2 results", + "passed": 20, + "total": 20, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + }, + "context_checks": { + "script": "round4-context-checks.mjs copy", + "passed": 5, + "total": 5, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "checks": [ + { + "id": "make-legacy-last-operation-identity", + "passed": true + }, + { + "id": "make-v1-last-operation-identity", + "passed": true + }, + { + "id": "stream-legacy-sidecar-failure-context", + "passed": true + }, + { + "id": "stream-v1-sidecar-failure-context", + "passed": true + }, + { + "id": "standalone-relink-interrupt", + "passed": true + } + ] + }, + "stream_finalization_check": { + "script": "stream-finalization-check.mjs copy", + "passed": true, + "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + } } ] } From 7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 11:03:33 +0800 Subject: [PATCH 20/26] =?UTF-8?q?fix(review):=20address=20Codex=20review?= =?UTF-8?q?=20round=205=20findings=20R5-F01=E2=80=93R5-F03?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R5-F01 project-store / download / task-command: the record_project recovery command carries the invocation's resolved --workspace (shell-quoted like every other argument), from both callers. A replayed recovery never writes further than the command that failed: with a workspace equal to the project it records metadata, skips the parent's history index (index_dirty) and creates no lock or temp file outside. Without an explicit workspace nothing is appended. R5-F02 material-links: arbitration first reconciles every reference across its keys — identity evidence is key-independent and wins; otherwise each key's channel rule yields a candidate set (a hit, an ambiguity's candidates, nothing for a key whose channel has no texture) and all sets must be the same single texture, else every line of that reference stays as written with its own candidates and one cross-key note ("one reference names one file") — then the round-4 cross-reference contention applies to the reconciled hits. R5-F03 task-command: the project checks run inside the recovery context, with task id, journal operation, stage and workspace known first. A project that no longer resolves inside the workspace (or became a symlink) is a boundary failure: the error names the task and operation and says nothing was recorded, but hands out no command that would cross the boundary. metadata.json missing / damaged / locked after the preflight gets the one `meshy project record …` command with --operation-id and --workspace (also the legacy hint), across get / wait / stream / create --async / sync create. download applies the same metadata assertion before its record step (local_io, not not_found), with the file list computed first so the command still names what landed. Tests: codex-review-round5 (E01 verbatim replay incl. a workspace path with a space and a quote, no-workspace semantics kept; E02 both orders + the 8x2 arbitration matrix; E03 20-entry legacy/v1 × verb × missing/damaged matrix with journal reconciliation and verbatim replay; escaped-project boundary). codex-review-round4: D02 replays the command verbatim; R4-T01 asserts the SIGINT manifest as failed + [model_glb failed]. Co-Authored-By: Claude Fable 5.1 --- README.md | 5 +- docs/skill-parity/REVIEW_HANDOFF.md | 2 +- docs/skill-parity/decisions.md | 60 +++ docs/skill-parity/migration-notes.md | 9 + skills/meshy-cli/SKILL.md | 6 +- src/cmd/download.ts | 13 +- src/internal/material-links.ts | 86 +++-- src/internal/project-store.ts | 42 ++- src/internal/task-command.ts | 51 ++- tests/codex-review-round4.test.ts | 13 +- tests/codex-review-round5.test.ts | 522 +++++++++++++++++++++++++++ 11 files changed, 757 insertions(+), 52 deletions(-) create mode 100644 tests/codex-review-round5.test.ts diff --git a/README.md b/README.md index dc2dabe..646fb52 100644 --- a/README.md +++ b/README.md @@ -392,8 +392,9 @@ the files that landed inside the project are recorded in its `metadata.json`; when that record fails after the transfer (metadata.json replaced by a symlink, damaged, or locked) the command exits 11 with the complete `result` — manifest, `saved_json`, `project.action: "failed"` — and `error.recovery.command` is the -one `meshy project record …` invocation that redoes the record; the assets stay -where they landed. Problems visible before the transfer (no metadata.json, a +one `meshy project record …` invocation that redoes the record — carrying the +original `--workspace`, so a recovery never writes further than the command that +failed; the assets stay where they landed. Problems visible before the transfer (no metadata.json, a symlink or invalid JSON in its place, a blank `--stage`) are refused with no request made. Asset hosts never receive the API credential; an expired signed URL is refreshed once when the diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md index 6a77573..05ebe42 100644 --- a/docs/skill-parity/REVIEW_HANDOFF.md +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -459,7 +459,7 @@ v1(exit 7): "get", "list", "wait", - + … (truncated) ``` diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index b091c10..857fd5e 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -592,3 +592,63 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu record, `executed[0].operation_id` against step 1's record, the refine payload's `preview_task_id` against step 1, and the request sequence POST GET POST GET GET. C06 keeps the create/wait/get/sidecar/SIGINT scenarios. + +## D-054 A recovery command carries the original write boundary + +- R5-F01: `projectRecordCommand` emitted `meshy project record …` without the + invocation's `--workspace`. Replayed after a repair, the command wrote where + the original could not: with a workspace equal to the project, the original + records metadata and skips the parent's history index (`index_dirty`), while + the recovery refreshed that index and created its lock outside the boundary. + The helper now takes the resolved absolute workspace and appends + `--workspace ` (shell-quoted like every other argument); both callers — + `download`'s `projectRecordFailure` and the task verbs' `attachToProject` — + pass it. A recovery never reaches further than the command that failed; it is + not made to succeed by dropping a constraint. Without an explicit workspace + nothing is appended and the parent index is refreshed as before. The tests + replay the command verbatim (nothing appended), including a workspace path + with a space and a quote character. + +## D-055 One reference is one file: keys are reconciled before textures compete + +- R5-F02: the same reference under two MTL keys — `map_Kd shared.png` + (heuristic hit on the only base color) and `map_Bump shared.png` (ambiguous + between two normals) — had only its *hits* compared across keys, so the base + color line was rewritten while the normal line stayed, splitting one file into + two. `arbitrate` now runs in two steps. Step 1 reconciles every reference + across its keys: identity evidence (source name, safe saved name, source stem) + is key-independent and wins as before; otherwise each key's channel rule yields + a candidate set (a hit is a set of one, an ambiguity its candidates, a key + whose channel has no texture contributes nothing) and all sets must be the same + single texture — if not, every line of that reference stays as written with + `method: "ambiguous"`, its own candidates and one note that spells out what + each key would have made of it ("one reference names one file"). Step 2 is the + round-4 rule: a heuristic hit is vetoed when any other distinct reference + contends for the texture. The reviewer's 8 × 2 matrix (identity + heuristic, + ambiguous rival, same reference with two hits / hit + ambiguity / same identity + / same fallback, distinct channels, identity with an ambiguous rival) is a + repository test; D01, C05, N04 and the round-1 relink cases are unchanged. + +## D-056 The task verbs check the project inside the recovery context + +- R5-F03: `attachToProject` resolved `--project` (metadata present, inside the + workspace) before its try block, so a project whose metadata.json vanished + between the preflight and the record step failed with `local_io` and a bare + `wait` hint — task id, submission and journal intact, but no way to redo the + record — across legacy/v1 × get/wait/stream/create --async/sync create (the + damaged-JSON branch already had `record_project`). The recovery context (task + id, journal operation, stage, resolved workspace) is now built first, then two + distinct checks run inside the phase: a project that no longer resolves inside + the workspace (or became a symlink) is a *boundary* failure — the error names + the task and operation, says nothing was recorded and how to proceed, but hands + out no command that would write across the boundary; everything else + (`assertProjectMetadataPresent`: metadata.json missing or not a regular file + after the preflight, a damaged file, a lock, a full disk) is *repairable* and + gets the one `meshy project record …` command with `--operation-id` and the + original `--workspace` (also the `hint`, so the legacy payload carries it). + Nothing is re-submitted, no `project init` is suggested for a project that was + valid, and `index_dirty` still means only "metadata committed, history index + not". `meshy download --project` applies the same metadata assertion before its + record step, so a metadata.json that disappears during the transfer is + `local_io` with the record_project recovery rather than an API-style + `not_found`. diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index d373014..cb66a5c 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -162,6 +162,15 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | `download --project` with a metadata.json that is a symlink or not valid JSON, or a blank `--stage` | detected after the transfer | refused before any request ("nothing was downloaded") | R4-F02 | | task verbs `--project` record failure | `local_io` without a recovery | same class, plus `recovery.action="record_project"` and the record command as hint | R4-F02 | +### 3.5 Corrections from Codex review round 5 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| `record_project` recovery command after `--project P --workspace P` | no `--workspace`; replayed, it refreshed the parent's history.json and locked outside the boundary | carries `--workspace ` (shell-quoted); replayed verbatim it records metadata, skips the parent index with `index_dirty`, writes nothing outside; no workspace → nothing appended | R5-F01 | +| MTL reference used under two keys where one key hits a texture and the other is ambiguous (`map_Kd shared.png` + `map_Bump shared.png`, one base color, two normals) | the hit line rewritten, the other left | neither line rewritten; both `ambiguous` with their own candidates and one cross-key note ("one reference names one file") | R5-F02 | +| task verbs `--project` when metadata.json disappears (or the project leaves the workspace) after the preflight | `local_io`, `recovery: null`, hint = `wait` | missing/damaged/locked → `record_project` command with `--operation-id` and `--workspace` (hint too); project outside the workspace → explicit boundary message, task and journal named, no command | R5-F03 | +| `download --project` when metadata.json disappears during the transfer | `not_found` (exit 5) from the record step | `local_io` (exit 11) with the full result and the record_project recovery | R5-F03 | + New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, `--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 5c6e8b0..0f8a034 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -182,7 +182,11 @@ Exit codes: `0` ok · `1` task FAILED/CANCELED while waiting, or unclassified · deliberately left as written. - `result.project.action: "failed"` (download `--project`, exit 11) → the assets are on disk (`result.downloads.files`); repair the project's `metadata.json`, then run - `error.recovery.command` (`meshy project record …`) — do not download again. + `error.recovery.command` (`meshy project record …`, it already carries the original + `--workspace` and `--operation-id`) verbatim — do not download or create again. The same + command appears after a task verb's `--project` record failure (legacy: `hint`); when the + error says the project is *no longer inside the workspace* there is deliberately no + command — move the project back first. - Exit 10 → reconcile with ` list`; never submit the same create again blindly. - A `FAILED` task → relay `result.task.task_error.message` verbatim; do not guess a cause. - Any error may carry `error.recovery.command` — prefer it over improvising. diff --git a/src/cmd/download.ts b/src/cmd/download.ts index d9d799d..ee8e895 100644 --- a/src/cmd/download.ts +++ b/src/cmd/download.ts @@ -24,7 +24,7 @@ import { downloadAssets, type DownloadedFile } from "../internal/download.js"; import { CliError, UsageError, type Warning } from "../internal/errors.js"; import { realpathLenient, resolveWithinRoot, safeSegment } from "../internal/paths.js"; import { warning } from "../internal/result.js"; -import { indexRootFor, projectRecordCommand, readProject, recordTask, stageFromTaskType, type RecordInput } from "../internal/project-store.js"; +import { assertProjectMetadataPresent, indexRootFor, projectRecordCommand, readProject, recordTask, stageFromTaskType, type RecordInput } from "../internal/project-store.js"; import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; import { extractTaskObject } from "../internal/task-view.js"; import { relative } from "node:path"; @@ -282,21 +282,26 @@ export const downloadCommand = new Command("download") status: typeof task["status"] === "string" ? (task["status"] as string) : null, files: [], }; + const workspace = opened.flags.workspace ? resolvePath(opened.flags.workspace) : undefined; try { // Compare in one real-path frame: the project may be reached through an // alias (a symlinked parent, macOS /var → /private/var) while the // downloader reports real paths; the recorded name is relative to the real project. + // The file list is known before the project is examined, so a recovery + // command always names what landed inside the project. const projectReal = realpathLenient(projectDir); input.files = written .map((f) => relative(projectReal, realpathLenient(f.path)).split(/[\\/]/).join("/")) .filter((f) => f.length > 0 && !f.startsWith("..") && !f.startsWith("/")); + // The project passed the preflight; it must still be one now. + assertProjectMetadataPresent(projectDir, opts.project!); const indexRoot = indexRootFor(projectDir, undefined, opened.flags.workspace); const rec = recordTask(projectDir, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}`)); if (input.files.length !== written.length) warnings.push(warning("files_outside_project", "some files were written outside the project directory and were not recorded")); project = { project_dir: projectDir, action: rec.action, stage: rec.entry.stage, recorded_files: input.files }; } catch (err) { - throw projectRecordFailure(err, { projectDir, dir, input, written: written.length, outcome, warnings }); + throw projectRecordFailure(err, { projectDir, workspace, dir, input, written: written.length, outcome, warnings }); } } await emitResult(opened, null, { ...outcome, project }, { warnings }); @@ -342,12 +347,12 @@ function preflightProject(projectDir: string, flag: string, stage: string | unde */ function projectRecordFailure( err: unknown, - ctx: { projectDir: string; dir: string; input: RecordInput; written: number; outcome: Record; warnings: Warning[] }, + ctx: { projectDir: string; workspace: string | undefined; dir: string; input: RecordInput; written: number; outcome: Record; warnings: Warning[] }, ): CliError { const base = err instanceof CliError ? err : null; const code = base?.code ?? "local_io"; const reason = err instanceof Error ? err.message : String(err); - const command = projectRecordCommand(ctx.projectDir, ctx.input); + const command = projectRecordCommand(ctx.projectDir, ctx.input, { workspace: ctx.workspace }); const recovery = { action: "record_project", automatic: false, command }; return new CliError({ code, diff --git a/src/internal/material-links.ts b/src/internal/material-links.ts index 170aade..dea72c9 100644 --- a/src/internal/material-links.ts +++ b/src/internal/material-links.ts @@ -422,47 +422,75 @@ function pairId(key: string, ref: string): string { } /** - * Second pass: heuristic hits compete on the texture they actually reached. - * A texture that any *other* distinct reference also reaches — by a hit of - * either kind, or as an ambiguity it could not decide — is not handed to a - * heuristic one: nothing shows those references name the same image. A single - * reference that different keys would send to different textures is not - * rewritten at all. Identity hits are kept as they are. + * Second pass, two steps. + * + * Step 1 — one reference is one file. Every line that names the same file is + * reconciled across its keys: with identity evidence the keys agree by + * construction; otherwise each key's channel rule yields a candidate set (a hit + * is a set of one, an ambiguity its candidates, a key whose channel has no + * texture contributes nothing) and all of them must be the same single texture. + * When they are not — one key would make the file the base color while another + * could only make it one of two normal maps — no single texture fits every use, + * so every line of that reference stays as written and says why. + * + * Step 2 — one texture is one file too. A heuristic hit is vetoed when any + * *other* distinct reference contends for the same texture, by a hit of either + * kind or as an ambiguity it could not decide: nothing shows those references + * name the same image. Identity hits are never vetoed. */ function arbitrate(pairs: MapPair[]): Map { - // texture name → the distinct references contending for it and how they got there + const decided = new Map(); + // --- step 1: reconcile every reference across its keys + const byRef = new Map(); + for (const p of pairs) { + const group = byRef.get(p.ref) ?? []; + group.push(p); + byRef.set(p.ref, group); + } + const candidatesOf = (res: Resolution): string[] | null => (res.kind === "hit" ? [res.name] : res.kind === "ambiguous" ? res.candidates : null); + const sameSet = (a: string[], b: string[]): boolean => a.length === b.length && a.every((x) => b.includes(x)); + for (const [ref, group] of byRef) { + const evidence = group.filter((p) => candidatesOf(p.res) !== null); + const identity = group.some((p) => p.res.kind === "hit" && p.res.identity); + const first = evidence[0] ? candidatesOf(evidence[0].res)! : null; + const consistent = identity || evidence.length <= 1 || evidence.every((p) => sameSet(candidatesOf(p.res)!, first!)); + if (consistent) { + for (const p of group) decided.set(pairId(p.key, p.ref), p.res); + continue; + } + const uses = evidence + .map((p) => (p.res.kind === "hit" ? `${p.key} would make it ${p.res.name} (${p.res.method})` : `${p.key} could only make it ${(p.res as { candidates: string[] }).candidates.join(" or ")}`)) + .join(" while "); + const note = `'${ref}' is used by ${evidence.map((p) => p.key).join(" and ")} but resolves differently per key: ${uses}; one reference names one file, so none of its lines is rewritten`; + for (const p of group) { + const own = candidatesOf(p.res); + decided.set(pairId(p.key, p.ref), own ? { kind: "ambiguous", method: "ambiguous", candidates: own, note } : p.res); + } + } + // --- step 2: heuristic hits compete on the texture they actually reached const contenders = new Map>(); const contend = (name: string, ref: string, method: LinkMethod, identity: boolean): void => { - const byRef = contenders.get(name) ?? new Map(); - if (!byRef.has(ref) || identity) byRef.set(ref, method); - contenders.set(name, byRef); + const byRefName = contenders.get(name) ?? new Map(); + if (!byRefName.has(ref) || identity) byRefName.set(ref, method); + contenders.set(name, byRefName); }; - for (const { ref, res } of pairs) { + for (const { key, ref } of pairs) { + const res = decided.get(pairId(key, ref))!; if (res.kind === "hit") contend(res.name, ref, res.method, res.identity); else if (res.kind === "ambiguous") for (const c of res.candidates) contend(c, ref, "ambiguous", false); } - const decided = new Map(); - for (const { key, ref, res } of pairs) { + for (const { key, ref } of pairs) { const id = pairId(key, ref); - if (res.kind !== "hit" || res.identity) { - decided.set(id, res); - continue; - } + const res = decided.get(id)!; + if (res.kind !== "hit" || res.identity) continue; const rivals = [...(contenders.get(res.name) ?? new Map())].filter(([r]) => r !== ref); - const elsewhere = pairs.filter((p) => p.ref === ref && p.res.kind === "hit" && p.res.name !== res.name); - if (rivals.length === 0 && elsewhere.length === 0) { - decided.set(id, res); - continue; - } + if (rivals.length === 0) continue; // The note is the same for every member of the group, so the caller's // warning can say it once. - const note = - rivals.length > 0 - ? `${[[ref, res.method] as [string, LinkMethod], ...rivals] - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([r, m]) => `'${r}' (${m})`) - .join(" and ")} compete for ${res.name}; different references cannot share one texture without evidence that they name the same image` - : `'${ref}' would be rewritten to ${res.name} for ${key} but to ${elsewhere.map((p) => `${(p.res as { name: string }).name} for ${p.key}`).join(", ")}; one reference names one file`; + const note = `${[[ref, res.method] as [string, LinkMethod], ...rivals] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([r, m]) => `'${r}' (${m})`) + .join(" and ")} compete for ${res.name}; different references cannot share one texture without evidence that they name the same image`; decided.set(id, { kind: "ambiguous", method: "ambiguous", candidates: [res.name], note }); } return decided; diff --git a/src/internal/project-store.ts b/src/internal/project-store.ts index ebdea5c..32277c3 100644 --- a/src/internal/project-store.ts +++ b/src/internal/project-store.ts @@ -19,7 +19,7 @@ * The CLI's own download `meta.json` is a third format and never written here. */ -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, copyFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, copyFileSync } from "node:fs"; import { basename, join, relative, resolve as resolvePath, sep } from "node:path"; import { randomBytes } from "node:crypto"; import { writeJsonFile } from "./atomic-file.js"; @@ -495,12 +495,47 @@ function shellArg(value: string): string { return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; } +/** + * A project that was initialised when a command started must still be one when + * its record is written. metadata.json missing or replaced by something that is + * not a regular file is a local condition of the project — reported as + * `local_io`, never as an API "not found" — and the caller says how to redo the + * record once the project is restored. + */ +export function assertProjectMetadataPresent(projectDir: string, flag: string): void { + const target = metadataPath(projectDir); + let st: ReturnType; + try { + st = lstatSync(target); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new CliError({ + code: "local_io", + message: `--project ${flag} has no metadata.json any more (it was an initialised project when this command started)`, + cause: err, + }); + } + throw err; + } + if (!st.isFile()) { + throw new CliError({ + code: "local_io", + message: `--project ${flag}: metadata.json is not a regular file (${st.isSymbolicLink() ? "a symbolic link" : st.isDirectory() ? "a directory" : "special"}); refusing to write through it`, + }); + } +} + /** * The `meshy project record` invocation that redoes exactly one thing: the * metadata entry a command could not write. Assets, task and journal are left - * alone — the caller runs it once the project directory is repaired. + * alone — the caller runs it once the project directory is repaired. The + * original write boundary (`--workspace`, resolved to an absolute path) travels + * with the command: a recovery never reaches further than the invocation that + * failed, so a workspace equal to the project still leaves the parent's + * history index alone (`index_dirty`), exactly as the original would have. */ -export function projectRecordCommand(projectDir: string, input: RecordInput, opts: { root?: string } = {}): string { +export function projectRecordCommand(projectDir: string, input: RecordInput, opts: { root?: string; workspace?: string } = {}): string { const parts = ["meshy", "project", "record", "--project", shellArg(projectDir), "--task-id", shellArg(input.taskId), "--stage", shellArg(input.stage)]; if (input.resource) parts.push("--resource", shellArg(input.resource)); if (input.taskType) parts.push("--task-type", shellArg(input.taskType)); @@ -510,6 +545,7 @@ export function projectRecordCommand(projectDir: string, input: RecordInput, opt if (input.taskJson) parts.push("--task-json", shellArg(input.taskJson)); if (input.operationId) parts.push("--operation-id", shellArg(input.operationId)); if (opts.root) parts.push("--root", shellArg(opts.root)); + if (opts.workspace) parts.push("--workspace", shellArg(resolvePath(opts.workspace))); return parts.join(" "); } diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index 37a23c0..82d81b1 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -58,7 +58,7 @@ import { } from "./operation-store.js"; import { originOf } from "./config.js"; import { resolveWithinRoot } from "./paths.js"; -import { indexRootFor, projectRecordCommand, recordTask, saveTaskSnapshot, stageFromTaskType, type RecordInput } from "./project-store.js"; +import { assertProjectMetadataPresent, indexRootFor, projectRecordCommand, recordTask, saveTaskSnapshot, stageFromTaskType, type RecordInput } from "./project-store.js"; import { existsSync } from "node:fs"; import { dirname, join, resolve as resolvePath } from "node:path"; @@ -278,7 +278,15 @@ function resolveProjectDir(projectFlag: string, workspace: string | undefined, c /** * --project: snapshot the task (when a full task is known) and record it. * Failures keep the task id in the error result — a bookkeeping problem must - * never read as "no task was created". + * never read as "no task was created". The recovery context (task, journal + * operation, stage, workspace) exists before the project is even looked at, so + * every failure of this phase — the directory resolving outside the workspace, + * a metadata.json that vanished or was damaged after the preflight, a lock, a + * full disk — carries it. Two kinds of failure, two answers: a project that no + * longer lies inside the write boundary gets no command that would cross it; + * anything else gets the one `meshy project record …` invocation (with the + * original `--workspace`) that redoes just the record once the project is + * restored. Nothing is re-submitted. */ function attachToProject( opts: Record, @@ -292,7 +300,8 @@ function attachToProject( ): ProjectAttachment | null { const projectFlag = opts.project as string | undefined; if (!projectFlag) return null; - const projectDir = resolveProjectDir(projectFlag, opened.flags.workspace); + const workspace = opened.flags.workspace ? resolvePath(opened.flags.workspace) : undefined; + const projectDir = resolvePath(projectFlag); const stage = (opts.stage as string | undefined) ?? (typeof extra.payload?.["mode"] === "string" ? (extra.payload["mode"] as string) : descriptor.creativeLab?.stage ?? stageFromTaskType(task?.type, descriptor.id)); const input: RecordInput = { taskId, @@ -306,21 +315,33 @@ function attachToProject( operationId: extra.operationId ?? null, files: extra.files ?? [], }; + // 1. The location. A project that now resolves outside the workspace, or has + // become a symlink, is a boundary problem: the task exists and is journaled, + // but no recovery command may be handed out that writes across that line. + if (workspace) { + try { + resolveWithinRoot(projectDir, workspace, { label: "--project" }); + } catch (err) { + throw projectBoundaryFailure(err, taskId, projectFlag, input); + } + } + // 2. The record itself, including "is this still an initialised project". try { + assertProjectMetadataPresent(projectDir, projectFlag); const snapshot = task && raw ? saveTaskSnapshot(projectDir, taskId, raw) : null; input.taskJson = snapshot?.relative ?? null; - const indexRoot = indexRootFor(projectDir, undefined, opened.flags.workspace); + const indexRoot = indexRootFor(projectDir, undefined, workspace); const rec = recordTask(projectDir, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}; run \`meshy project rebuild-index\``)); if (rec.migrated_from_legacy) warnings.push(warning("metadata_migrated", "legacy metadata.json migrated to schema_version 2 (backup kept beside it)")); return { project_dir: projectDir, snapshot: snapshot?.path ?? null, stage, action: rec.action, index: rec.index }; } catch (err) { // The task exists and the journal is written; only the project entry is - // missing. The recovery redoes that one step — nothing is re-submitted. - const command = projectRecordCommand(projectDir, input); + // missing. The recovery redoes that one step under the original boundary. + const command = projectRecordCommand(projectDir, input, { workspace }); throw new CliError({ code: err instanceof CliError ? err.code : "local_io", - message: `task ${taskId} exists but recording it in ${projectDir} failed: ${err instanceof Error ? err.message : String(err)}`, + message: `task ${taskId} exists${input.operationId ? ` (operation ${input.operationId})` : ""} but recording it in ${projectDir} failed: ${err instanceof Error ? err.message : String(err)}; restore the project, then run: ${command}`, httpStatus: err instanceof CliError ? err.httpStatus : null, retryable: err instanceof CliError ? err.retryable : false, recovery: { action: "record_project", automatic: false, command }, @@ -331,6 +352,22 @@ function attachToProject( } } +/** + * The project directory no longer resolves inside the workspace (or is a + * symlink). The task is known and journaled; the caller learns that, and that + * nothing was recorded — but no `meshy project record` command is offered, + * because the only one that would succeed is one without the boundary. + */ +function projectBoundaryFailure(err: unknown, taskId: string, projectFlag: string, input: RecordInput): CliError { + const reason = err instanceof Error ? err.message : String(err); + return new CliError({ + code: "local_io", + message: `task ${taskId} exists${input.operationId ? ` (operation ${input.operationId})` : ""} but --project ${projectFlag} is no longer a target inside the workspace: ${reason}; nothing was recorded. Restore the project inside the workspace, then record the task with \`meshy project record\` from that workspace`, + details: { project: projectFlag, task_id: taskId, operation_id: input.operationId ?? null, stage: input.stage, recorded: false }, + cause: err, + }); +} + /** --save-json inside the task's context: a full disk or a vanished directory never hides the task id. */ function saveJsonInContext(opts: Record, opened: OpenedCommand, raw: unknown, ctx: TaskContext): SavedJson | null { if (!opts.saveJson) return null; diff --git a/tests/codex-review-round4.test.ts b/tests/codex-review-round4.test.ts index 2905154..0bea98d 100644 --- a/tests/codex-review-round4.test.ts +++ b/tests/codex-review-round4.test.ts @@ -398,8 +398,8 @@ test("D02/R4-F02 download --project: a record failure after the transfer (metada assert.deepEqual(e.result.project.recovery, e.error.recovery); assert.match( e.error.recovery.command, - new RegExp(`^meshy project record --project \\S+ --task-id round4-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED${recordedFiles.map((f) => ` --file ${f.replaceAll(".", "\\.")}`).join("")}$`), - `${label}: the recovery redoes exactly the metadata entry (files that landed inside the project)`, + new RegExp(`^meshy project record --project \\S+ --task-id round4-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED${recordedFiles.map((f) => ` --file ${f.replaceAll(".", "\\.")}`).join("")} --workspace \\S+$`), + `${label}: the recovery redoes exactly the metadata entry (files that landed inside the project) under the original workspace`, ); return e; }; @@ -428,12 +428,14 @@ test("D02/R4-F02 download --project: a record failure after the transfer (metada assert.ok(readFileSync(backup).equals(outsideBefore), "the original metadata is intact"); assert.deepEqual(tmpFiles(proj1), [], "no temp file left in the project"); assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [["GET", "/model.glb"]], "one asset GET, nothing else"); - // Repair the directory and run the recovery command verbatim: it records exactly the downloaded file and makes no request. + // Repair the directory and run the recovery command verbatim — nothing appended: the + // command itself carries the original --workspace (round 5, R5-F01) and records exactly the downloaded file with no request. unlinkSync(meta1); renameSync(backup, meta1); const words = shellSplit(e1.error.recovery.command); assert.equal(words[0], "meshy"); - const rec = await runCli([...words.slice(1), "--workspace", workspace], { env, cwd: dir }); + assert.equal(realpathSync(words[words.indexOf("--workspace") + 1]!), realpathSync(workspace), "the recovery command carries the original workspace"); + const rec = await runCli(words.slice(1), { env, cwd: dir }); assert.equal(rec.code, 0, `${rec.stderr}\n${rec.stdout}`); const meta = JSON.parse(readFileSync(meta1, "utf8")) as { tasks: Array<{ task_id: string; stage: string; files: string[]; status: string | null; resource: string | null }> }; assert.deepEqual(meta.tasks.map((t) => [t.task_id, t.stage, t.files, t.status, t.resource]), [["round4-task", "preview", ["model.glb"], "SUCCEEDED", "text-to-3d"]]); @@ -637,7 +639,8 @@ test("R4-T01 make: two chain steps get different task ids; submission.operation_ if (mode === "503") { assert.deepEqual(result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]], label); } else { - assert.notEqual(result.downloads.state, "completed", label); + assert.equal(result.downloads.state, "failed", `${label}: the interrupted transfer wrote nothing, so the manifest says failed (not not_requested)`); + assert.deepEqual(result.downloads.files.map((f) => [f.key, f.status]), [["model_glb", "failed"]], label); assert.ok(!existsSync(target), `${label}: no final file after the interrupt`); } } finally { diff --git a/tests/codex-review-round5.test.ts b/tests/codex-review-round5.test.ts new file mode 100644 index 0000000..7c968f4 --- /dev/null +++ b/tests/codex-review-round5.test.ts @@ -0,0 +1,522 @@ +/** + * Codex review round 5 (reviews/cli-s1-68690f9: R5-F01–R5-F03) as positive + * regressions with real subprocesses, a loopback API that records every request, + * synthetic credentials and isolated temp directories. The recovery command a + * project-record failure hands out keeps the original `--workspace` and is + * replayed *verbatim* (nothing appended) — a workspace equal to the project must + * still leave the parent's history index alone; one MTL reference used under + * several keys is reconciled across them, an ambiguous key included; and the + * task verbs put the project's location and metadata checks inside the recovery + * context (missing/damaged metadata → record_project with the task's journal + * identity and workspace; a project that left the workspace → no command that + * would cross the boundary). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import sharp from "sharp"; +import { relinkMaterials } from "../src/internal/material-links.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; +const ENVELOPE_KEYS = ["schema_version", "command", "ok", "result", "error", "warnings"].sort(); + +function taskBody(fields: Record = {}): Record { + return { id: "round5-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function listing(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).sort() : []; +} + +interface JournalRecord { + operation_id: string; + state: string; + task_id: string | null; +} + +function journal(configDir: string): JournalRecord[] { + const ops = join(configDir, "operations"); + if (!existsSync(ops)) return []; + return readdirSync(ops) + .filter((f) => f.endsWith(".json")) + .map((f) => JSON.parse(readFileSync(join(ops, f), "utf8")) as JournalRecord); +} + +/** Split a recovery command the way a POSIX shell would (bare words, single quotes with the '\'' escape). */ +function shellSplit(command: string): string[] { + const words: string[] = []; + let cur = ""; + let pending = false; + let inQuote = false; + let escape = false; + for (const ch of command) { + if (escape) { + cur += ch; + escape = false; + pending = true; + continue; + } + if (inQuote) { + if (ch === "'") inQuote = false; + else cur += ch; + pending = true; + continue; + } + if (ch === "'") { + inQuote = true; + pending = true; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (/\s/.test(ch)) { + if (pending) { + words.push(cur); + cur = ""; + pending = false; + } + continue; + } + cur += ch; + pending = true; + } + if (pending) words.push(cur); + return words; +} + +function optionValue(words: string[], flag: string): string | undefined { + const i = words.indexOf(flag); + return i === -1 ? undefined : words[i + 1]; +} + +interface RecordReplay { + code: number; + stdout: string; + stderr: string; +} + +/** + * Replay a `meshy project record …` recovery command exactly as handed out + * (only the launcher word is replaced by the test's dist entry) and check that + * it redid the record under the original boundary: the record lands, the + * parent's history index is untouched and reported as skipped, no lock or temp + * file appears outside, and no request is made. + */ +async function replayRecord( + command: string, + env: Record, + cwd: string, + expect: { projectDir: string; workspace: string; taskId: string; requestsBefore: number; requests: () => number }, +): Promise<{ replay: RecordReplay; words: string[]; entry: Record }> { + const words = shellSplit(command); + assert.equal(words[0], "meshy"); + assert.equal(words[1], "project"); + assert.equal(words[2], "record"); + assert.equal(realpathSync(optionValue(words, "--project")!), realpathSync(expect.projectDir), "the command names the same project"); + assert.equal(realpathSync(optionValue(words, "--workspace")!), realpathSync(expect.workspace), "the command carries the original --workspace, resolved"); + assert.equal(optionValue(words, "--task-id"), expect.taskId); + const parent = dirname(expect.projectDir); + const historyPath = join(parent, "history.json"); + const historyBefore = readFileSync(historyPath); + const parentBefore = listing(parent); + const replay = await runCli(words.slice(1), { env, cwd }); + assert.equal(replay.code, 0, `verbatim replay: ${replay.stderr}\n${replay.stdout}`); + const out = parseSingleJson(replay.stdout) as { ok: boolean; result: { action: string; entry: Record; index: { updated: boolean; error: string | null } }; warnings: Array<{ code: string }> }; + assert.equal(out.ok, true); + assert.equal(out.result.action, "added"); + assert.equal(out.result.index.updated, false, "workspace == project: the parent's index is not touched"); + assert.match(out.result.index.error ?? "", /outside --workspace/); + assert.ok(out.warnings.some((w) => w.code === "index_dirty"), "index_dirty says exactly that: metadata committed, history not updated"); + assert.ok(readFileSync(historyPath).equals(historyBefore), "the parent's history.json bytes are unchanged"); + assert.deepEqual(listing(parent).filter((n) => n.includes(".lock") || n.includes(".tmp-")), [], "no lock or temp file outside the boundary"); + assert.deepEqual(listing(parent), parentBefore, "nothing appeared in the parent directory"); + assert.equal(expect.requests(), expect.requestsBefore, "the recovery made no request"); + const meta = JSON.parse(readFileSync(join(expect.projectDir, "metadata.json"), "utf8")) as { tasks: Array> }; + assert.equal(meta.tasks.length, 1, "exactly one task recorded"); + assert.equal(meta.tasks[0]!["task_id"], expect.taskId); + return { replay, words, entry: meta.tasks[0]! }; +} + +// --------------------------------------------------------------------------- +// R5-F01 / E01 — the recovery command keeps the original write boundary +// --------------------------------------------------------------------------- + +test("E01/R5-F01 download --project P --workspace P (path with a space and a quote): the record_project command carries --workspace and, replayed verbatim after the repair, records the task with 0 requests, leaves the parent history.json bytes unchanged and reports index_dirty; without --workspace the command has none and the index is updated as before", async () => { + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/model.glb") { + plant?.(); + plant = null; + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const env = api.env({ MESHY_API_KEY: undefined }); + const fixture = join(dir, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/model.glb` } }))); + + // (1) workspace == project, and the path needs shell quoting. + const awkward = join(dir, "work space's"); + mkdirSync(awkward); + const init = await runCli(["project", "init", "--root", join(awkward, "projects"), "--name", "recovery scope"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const parent = dirname(proj); + assert.ok(existsSync(join(parent, "history.json")), "the parent holds a history index the recovery must not touch"); + const meta = join(proj, "metadata.json"); + const outside = join(dir, "outside-metadata.json"); + writeFileSync(outside, readFileSync(meta)); + const outsideBefore = readFileSync(outside); + plant = () => { + renameSync(meta, `${meta}.backup`); + symlinkSync(outside, meta); + }; + const r = await runCli(["download", "--task-json", fixture, "--all", "--project", proj, "--workspace", proj], { env, cwd: dir }); + assert.equal(r.code, 11, `${r.stderr}\n${r.stdout}`); + const e = parseSingleJson(r.stdout) as { error: { code: string; recovery: { action: string; command: string }; hint: string }; result: { project: { action: string; recovery: { command: string } }; downloads: { files: Array<{ path: string; sha256: string }> } } }; + assert.equal(e.error.code, "local_io"); + assert.equal(e.error.recovery.action, "record_project"); + assert.equal(e.error.hint, e.error.recovery.command); + assert.equal(e.result.project.action, "failed"); + assert.equal(e.result.project.recovery.command, e.error.recovery.command); + assert.match(e.error.recovery.command, /--workspace '.*work space'\\''s\/projects\/[^']+'$/, "the workspace is quoted for a shell and carries the quote character"); + assert.match(e.error.recovery.command, /--project '.*work space'\\''s\/projects\/[^']+' --task-id round5-task/, "the project path is quoted the same way"); + assert.ok(readFileSync(outside).equals(outsideBefore), "the outside metadata is untouched"); + assert.equal(sha(e.result.downloads.files[0]!.path), e.result.downloads.files[0]!.sha256); + const requestsAfterDownload = api.requests.length; + assert.equal(requestsAfterDownload, 1); + // Repair, then replay exactly the command that was handed out. + unlinkSync(meta); + renameSync(`${meta}.backup`, meta); + const { entry } = await replayRecord(e.error.recovery.command, env, dir, { projectDir: proj, workspace: proj, taskId: "round5-task", requestsBefore: requestsAfterDownload, requests: () => api.requests.length }); + assert.deepEqual([entry["stage"], entry["files"], entry["status"], entry["resource"]], ["preview", ["model.glb"], "SUCCEEDED", "text-to-3d"]); + + // (2) no explicit workspace: the command has none and the parent's index is refreshed, as it always was. + const init2 = await runCli(["project", "init", "--root", join(dir, "plain-projects"), "--name", "plain"], { env, cwd: dir }); + assert.equal(init2.code, 0, init2.stderr); + const proj2 = (parseSingleJson(init2.stdout) as { result: { project_dir: string } }).result.project_dir; + const meta2 = join(proj2, "metadata.json"); + plant = () => { + renameSync(meta2, `${meta2}.backup`); + symlinkSync(outside, meta2); + }; + const r2 = await runCli(["download", "--task-json", fixture, "--all", "--project", proj2], { env, cwd: dir }); + assert.equal(r2.code, 11, `${r2.stderr}\n${r2.stdout}`); + const e2 = parseSingleJson(r2.stdout) as { error: { recovery: { command: string } } }; + const words2 = shellSplit(e2.error.recovery.command); + assert.equal(words2.includes("--workspace"), false, "no workspace was given, none is invented"); + unlinkSync(meta2); + renameSync(`${meta2}.backup`, meta2); + const historyBefore = readFileSync(join(dirname(proj2), "history.json")); + const rec2 = await runCli(words2.slice(1), { env, cwd: dir }); + assert.equal(rec2.code, 0, `${rec2.stderr}\n${rec2.stdout}`); + const o2 = parseSingleJson(rec2.stdout) as { result: { index: { updated: boolean } }; warnings: Array<{ code: string }> }; + assert.equal(o2.result.index.updated, true, "without a workspace the parent index is refreshed as before"); + assert.ok(!o2.warnings.some((w) => w.code === "index_dirty")); + assert.ok(!readFileSync(join(dirname(proj2), "history.json")).equals(historyBefore), "the parent history now lists the task"); + assert.equal(api.requests.length, 2, "one GET per download, nothing during either replay"); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R5-F02 / E02 — one reference used under several keys is reconciled across them +// --------------------------------------------------------------------------- + +interface TextureMap { + material: string | null; + reference: string; + resolved_to: string | null; + method: string; + candidates?: string[]; + note?: string; +} + +test("E02/R5-F02 map_Kd shared.png + map_Bump shared.png with one base color and two normals: neither line is rewritten in either order (MTL byte-identical), both are ambiguous with their own candidates and one cross-key note; digests match disk", async () => { + const red = await sharp({ create: { width: 2, height: 2, channels: 3, background: "#ff0000" } }).png().toBuffer(); + const lines = ["map_Kd shared.png", "map_Bump shared.png"] as const; + for (const order of [ + [0, 1], + [1, 0], + ] as const) { + const mtl = `newmtl a\n${lines[order[0]]}\n${lines[order[1]]}\n`; + const label = `${lines[order[0]]} first`; + const host = await startMockApi((req, res) => { + if (req.path === "/model.obj") { + res.writeHead(200, { "content-type": "model/obj" }); + return void res.end("mtllib original.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 1\nf 1 2 3\n"); + } + if (req.path === "/original.mtl") { + res.writeHead(200, { "content-type": "text/plain" }); + return void res.end(mtl); + } + if (/\.png$/.test(req.path)) { + res.writeHead(200, { "content-type": "image/png" }); + return void res.end(red); + } + return jsonReply(res, 404, {}); + }); + try { + const dir = tmpDir(); + const fixture = join(dir, "mixed.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { obj: `${host.url}/model.obj`, mtl: `${host.url}/original.mtl` }, texture_urls: [{ base_color: `${host.url}/a.png`, normal: `${host.url}/n1.png` }, { normal: `${host.url}/n2.png` }] }))); + const out = join(dir, "mixed"); + const r = await runCli(["download", "--task-json", fixture, "--model-format", "obj", "--output-dir", out], { env: host.env({ MESHY_API_KEY: undefined }), cwd: dir }); + assert.equal(r.code, 0, `${label}: ${r.stderr}\n${r.stdout}`); + assert.equal(readFileSync(join(out, "model.mtl"), "utf8"), mtl, `${label}: the MTL is byte-identical`); + const env = parseSingleJson(r.stdout) as { result: { downloads: { state: string; files: Array<{ key: string; path: string; sha256: string; bytes: number; relinked: boolean }>; material_links: { status: string; rewritten: string[]; texture_maps: TextureMap[] } } }; warnings: Array<{ code: string; message: string }> }; + const dl = env.result.downloads; + assert.equal(dl.state, "completed"); + assert.equal(dl.material_links.status, "incomplete"); + const byKey = new Map(dl.material_links.texture_maps.map((l) => [l.reference + "/" + (l.candidates ?? []).join(","), l])); + assert.equal(dl.material_links.texture_maps.length, 2); + for (const l of dl.material_links.texture_maps) { + assert.equal(l.reference, "shared.png"); + assert.equal(l.resolved_to, null, `${label}: nothing rewritten`); + assert.equal(l.method, "ambiguous"); + assert.match(l.note ?? "", /'shared\.png' is used by map_Kd and map_Bump|'shared\.png' is used by map_Bump and map_Kd/, `${label}: the note names both keys`); + assert.match(l.note ?? "", /map_Kd would make it texture_0_base_color\.png \(channel_of_key\)/); + assert.match(l.note ?? "", /map_Bump could only make it texture_0_normal\.png or texture_1_normal\.png/); + assert.match(l.note ?? "", /one reference names one file/); + } + assert.ok(byKey.has("shared.png/texture_0_base_color.png"), `${label}: the map_Kd line lists its own candidate`); + assert.ok(byKey.has("shared.png/texture_0_normal.png,texture_1_normal.png"), `${label}: the map_Bump line lists its own candidates`); + assert.deepEqual(dl.material_links.rewritten.map((p) => basename(p)), ["model.obj"], `${label}: only the OBJ's mtllib changed`); + for (const f of dl.files) { + assert.equal(sha(f.path), f.sha256, `${label}: ${f.key} digest matches disk`); + assert.equal(readFileSync(f.path).length, f.bytes); + } + assert.equal(dl.files.find((f) => f.key === "model.mtl")!.relinked, false); + const ambiguous = env.warnings.filter((w) => w.code === "material_reference_ambiguous"); + assert.equal(ambiguous.length, 1, `${label}: one warning, said once`); + assert.equal(ambiguous[0]!.message.split("one reference names one file").length, 2); + assert.deepEqual(host.requests.map((q) => q.method), ["GET", "GET", "GET", "GET", "GET"]); + } finally { + await host.close(); + } + } +}); + +test("R5-F02 arbitration matrix: eight key/reference combinations in both orders — identity keeps its texture, ambiguous rivals block heuristics, one reference is reconciled across keys (two hits, hit + ambiguity, same identity, same fallback), distinct channels stay independent", async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const B = { key: "texture.0.base_color", name: "texture_0_base_color.png", sourceName: "a.png" }; + const N = { key: "texture.0.normal", name: "texture_0_normal.png", sourceName: "n1.png" }; + const N2 = { key: "texture.1.normal", name: "texture_1_normal.png", sourceName: "n2.png" }; + type Tex = { key: string; name: string; sourceName: string }; + const cases: Array<{ id: string; lines: [string, string]; textures: Tex[]; expected: [Array, Array]; note?: RegExp }> = [ + { id: "identity-plus-heuristic", lines: ["map_Kd body.png", "map_Kd eyes_diffuse.png"], textures: [{ ...B, sourceName: "body.png" }], expected: [["texture_0_base_color.png", "source_name"], [null, "ambiguous"]], note: /compete for texture_0_base_color\.png/ }, + { id: "ambiguous-plus-heuristic", lines: ["map_Kd texture_0_base_color.png", "map_Kd eyes_diffuse.png"], textures: [B], expected: [[null, "ambiguous"], [null, "ambiguous"]] }, + { id: "same-ref-two-hits", lines: ["map_Kd shared.png", "map_Bump shared.png"], textures: [B, N], expected: [[null, "ambiguous"], [null, "ambiguous"]], note: /one reference names one file/ }, + { id: "same-ref-hit-and-ambiguous", lines: ["map_Kd shared.png", "map_Bump shared.png"], textures: [B, N, N2], expected: [[null, "ambiguous"], [null, "ambiguous"]], note: /one reference names one file/ }, + { id: "same-ref-same-identity", lines: ["map_Kd a.png", "map_Bump a.png"], textures: [B, N], expected: [["texture_0_base_color.png", "source_name"], ["texture_0_base_color.png", "source_name"]] }, + { id: "same-ref-same-fallback", lines: ["map_Bump unknown_normal.png", "norm unknown_normal.png"], textures: [B, N], expected: [["texture_0_normal.png", "channel_in_name"], ["texture_0_normal.png", "channel_in_name"]] }, + { id: "distinct-channel-fallbacks", lines: ["map_Kd skin.png", "map_Bump other_normal.png"], textures: [B, N], expected: [["texture_0_base_color.png", "channel_of_key"], ["texture_0_normal.png", "channel_in_name"]] }, + { id: "identity-with-ambiguous-source-rival", lines: ["map_Kd a.png", "map_Bump shared.png"], textures: [B, N, N2], expected: [["texture_0_base_color.png", "source_name"], [null, "ambiguous"]] }, + ]; + for (const c of cases) { + for (const reversed of [false, true]) { + const label = `${c.id}${reversed ? " (reversed)" : ""}`; + const order = reversed ? [1, 0] : [0, 1]; + const dir = tmpDir(); + const mtl = `newmtl fixture\n${order.map((i) => c.lines[i]!).join("\n")}\n`; + writeFileSync(join(dir, "model.obj"), "mtllib old.mtl\nv 0 0 0\n"); + writeFileSync(join(dir, "model.mtl"), mtl); + for (const t of c.textures) writeFileSync(join(dir, t.name), png); + const report = (await relinkMaterials([ + { key: "model.obj", path: join(dir, "model.obj"), sourceName: "source.obj" }, + { key: "model.mtl", path: join(dir, "model.mtl"), sourceName: "old.mtl" }, + ...c.textures.map((t) => ({ key: t.key, path: join(dir, t.name), sourceName: t.sourceName })), + ]))!; + const expected = order.map((i) => c.expected[i]!); + assert.deepEqual(report.texture_maps.map((l) => [l.resolved_to, l.method]), expected, label); + if (expected.every((x) => x[0] === null)) assert.equal(readFileSync(join(dir, "model.mtl"), "utf8"), mtl, `${label}: nothing rewritten`); + if (c.note) for (const l of report.texture_maps.filter((l) => l.method === "ambiguous")) assert.match(l.note ?? "", c.note, label); + assert.equal(report.status, expected.some((x) => x[0] === null) ? "incomplete" : "complete", label); + if (c.id === "same-ref-hit-and-ambiguous") { + const sets = report.texture_maps.map((l) => (l.candidates ?? []).join(",")).sort(); + assert.deepEqual(sets, ["texture_0_base_color.png", "texture_0_normal.png,texture_1_normal.png"], `${label}: each line keeps its own candidate set`); + assert.equal(report.warnings.length, 1, `${label}: the cross-key reason is one warning`); + } + } + } +}); + +// --------------------------------------------------------------------------- +// R5-F03 / E03 — project location and metadata checks inside the recovery context +// --------------------------------------------------------------------------- + +type Verb = "get" | "wait" | "stream" | "create-async" | "create-sync"; +type Fault = "missing" | "damaged"; + +test("E03/R5-F03 legacy/v1 × get/wait/stream/create-async/create-sync × metadata missing/damaged during the request (--project P --workspace P): exit 11 with the task, journal operation and a record_project command that carries --workspace and --operation-id; single POST for create; verbatim replay after the repair records the task with 0 requests and index_dirty", async () => { + for (const schema of ["legacy", "v1"] as const) { + for (const verb of ["get", "wait", "stream", "create-async", "create-sync"] as Verb[]) { + for (const fault of ["missing", "damaged"] as Fault[]) { + const id = `${schema}-${verb}-${fault}`; + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + plant?.(); + plant = null; + if (req.method === "POST") return jsonReply(res, 200, { result: id }); + if (req.method === "DELETE") return jsonReply(res, 500, { message: "never" }); + const body = taskBody({ id }); + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + return void res.end(`data: ${JSON.stringify(body)}\n\n`); + } + return jsonReply(res, 200, body); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const init = await runCli(["project", "init", "--root", join(dir, "projects"), "--name", id], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const meta = join(proj, "metadata.json"); + const original = readFileSync(meta); + plant = fault === "missing" ? () => renameSync(meta, `${meta}.backup`) : () => writeFileSync(meta, "{ invalid json"); + const args = verb.startsWith("create") ? ["text-to-3d", "create", "--mode", "preview", "--prompt", "fixture", ...(verb === "create-async" ? ["--async"] : [])] : ["text-to-3d", verb, id]; + const r = await runCli([...args, ...(schema === "v1" ? V1 : []), "--project", proj, "--workspace", proj], { env, cwd: dir }); + assert.equal(r.code, 11, `${id}: ${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as Record; + const result = out["result"] as { task_id: string; submission: { state: string; operation_id: string | null; task_id?: string } }; + const isCreate = verb.startsWith("create"); + const records = journal(configDir); + let command: string; + if (schema === "v1") { + assert.deepEqual(Object.keys(out).sort(), ENVELOPE_KEYS, id); + const error = out["error"] as { code: string; message: string; recovery: { action: string; automatic: boolean; command: string } | null; hint?: string }; + assert.equal(error.code, "local_io", id); + assert.ok(error.recovery, `${id}: a recovery is offered`); + assert.equal(error.recovery!.action, "record_project", id); + assert.equal(error.hint, error.recovery!.command, id); + assert.match(error.message, fault === "missing" ? /has no metadata\.json any more/ : /not valid JSON/, id); + command = error.recovery!.command; + } else { + assert.equal(out["code"], "local_io", id); + assert.equal(out["task_id"], id, `${id}: the legacy payload names the task`); + assert.ok(String(out["hint"]).startsWith("meshy project record "), `${id}: the legacy hint is the record command: ${String(out["hint"])}`); + command = String(out["hint"]); + } + assert.equal(result.task_id, id, `${id}: the task is kept`); + assert.equal(result.submission.state, "accepted"); + const words = shellSplit(command); + assert.equal(optionValue(words, "--stage"), "preview", id); + assert.equal(optionValue(words, "--resource"), "text-to-3d", id); + if (isCreate) { + assert.equal(records.length, 1, `${id}: exactly one journal record — nothing re-submitted`); + assert.equal(records[0]!.state, "accepted"); + assert.equal(records[0]!.task_id, id); + assert.equal(result.submission.operation_id, records[0]!.operation_id, `${id}: submission names the journal record`); + if (schema === "legacy") assert.equal(out["operation_id"], records[0]!.operation_id, id); + assert.equal(optionValue(words, "--operation-id"), records[0]!.operation_id, `${id}: the recovery keeps the operation id`); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, id); + } else { + assert.equal(records.length, 0, id); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 0, id); + } + if (verb === "get" || verb === "stream" || verb === "create-async") assert.equal(api.requests.length, 1, `${id}: one request only`); + assert.equal(api.requests.filter((q) => q.method === "DELETE").length, 0); + // A damaged metadata still lets the snapshot land; a missing one does not — and the command says which. + const taskJson = optionValue(words, "--task-json"); + if (fault === "missing" || verb === "create-async") assert.equal(taskJson, undefined, `${id}: no snapshot was written`); + else { + assert.equal(taskJson, `task_${id}.json`, id); + assert.ok(existsSync(join(proj, taskJson!)), `${id}: the snapshot exists in the project`); + } + // Repair the project, then replay exactly the command handed out. + if (fault === "missing") renameSync(`${meta}.backup`, meta); + else writeFileSync(meta, original); + const before = api.requests.length; + const { entry } = await replayRecord(command, env, dir, { projectDir: proj, workspace: proj, taskId: id, requestsBefore: before, requests: () => api.requests.length }); + assert.equal(entry["stage"], "preview", id); + assert.equal(entry["resource"], "text-to-3d", id); + if (isCreate) assert.equal(entry["operation_id"], records[0]!.operation_id, `${id}: the journal operation is recorded`); + if (taskJson) assert.equal(entry["task_json"], taskJson, id); + } finally { + await api.close(); + } + } + } + } +}); + +test("R5-F03 a project that leaves the workspace during the request (replaced by a symlink to an outside directory) is refused without any record command: exit 11, task and journal kept, nothing written outside, recovery null", async () => { + let plant: (() => void) | null = null; + let taskId = ""; + const api = await startMockApi((req, res) => { + plant?.(); + plant = null; + if (req.method === "POST") return jsonReply(res, 200, { result: taskId }); + return jsonReply(res, 200, taskBody({ id: taskId })); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const workspace = join(dir, "workspace"); + mkdirSync(workspace); + for (const schema of ["legacy", "v1"] as const) { + taskId = `escaped-${schema}`; + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", `escape-${schema}`], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const outside = join(dir, `outside-${schema}`); + // The project passes the preflight, then leaves the workspace while the POST is in flight. + plant = () => { + renameSync(proj, outside); + symlinkSync(outside, proj); + }; + api.requests.length = 0; + const r = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "fixture", "--async", ...(schema === "v1" ? V1 : []), "--project", proj, "--workspace", workspace], { env, cwd: dir }); + assert.equal(r.code, 11, `${schema}: ${r.stderr}\n${r.stdout}`); + assert.equal(api.requests.filter((q) => q.method === "POST").length, 1, `${schema}: the POST happened (the preflight had passed)`); + const out = parseSingleJson(r.stdout) as Record; + const result = out["result"] as { task_id: string; submission: { operation_id: string | null } }; + assert.equal(result.task_id, taskId, `${schema}: the accepted task is named`); + const records = journal(configDir).filter((j) => j.task_id === taskId); + assert.equal(records.length, 1, `${schema}: journaled exactly once`); + assert.equal(records[0]!.state, "accepted"); + assert.equal(result.submission.operation_id, records[0]!.operation_id); + const message = schema === "v1" ? (out["error"] as { message: string }).message : String(out["message"]); + assert.match(message, /no longer a target inside the workspace/, schema); + assert.match(message, /symbolic link|outside the authorised root/, schema); + assert.match(message, /nothing was recorded/, schema); + assert.match(message, new RegExp(`operation ${records[0]!.operation_id}`), `${schema}: the journal operation is named`); + const hint = schema === "v1" ? (out["error"] as { hint?: string }).hint : (out["hint"] as string | undefined); + assert.ok(!(hint ?? "").startsWith("meshy project record"), `${schema}: no record command that would cross the boundary`); + if (schema === "v1") assert.equal((out["error"] as { recovery: unknown }).recovery, null, "no recovery that drops the workspace"); + else assert.equal(out["task_id"], taskId); + assert.deepEqual(listing(outside), ["metadata.json"], `${schema}: nothing was written through the symlink (no snapshot, no lock, no temp)`); + assert.equal((JSON.parse(readFileSync(join(outside, "metadata.json"), "utf8")) as { tasks: unknown[] }).tasks.length, 0, `${schema}: the escaped metadata was not written`); + } + } finally { + await api.close(); + } +}); From 166492ba9691f093b302c1c114e782bceee14a1d Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 11:10:10 +0800 Subject: [PATCH 21/26] docs(skill-parity): round-6 handoff after Codex review round 5 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verification.json, capability-matrix.json and REVIEW_HANDOFF.md bound to code HEAD 7b7c24c (fix R5-F01–R5-F03): pnpm test 552/552, typecheck, diff --check on fd94490 and the tree, poll ×12, round-1 ×8, rounds 2–5 ×3, reviewer script copies (round1 0/12, round2 0/8, round3 0/6, round4 0/3 with D03 positive, round5 E01/E02×2/E03 fixed, material matrix 16/16, project-entry matrix 20/20 record_project, verify-original-regressions 20/20, verify-round3-4 8/8, context checks 5/5, stream check), tarball smoke 29/29. Live account / Windows / Linux / slicer / paid checks stay not_run. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/REVIEW_HANDOFF.md | 628 ++++++++--------------- docs/skill-parity/capability-matrix.json | 172 +++++-- docs/skill-parity/verification.json | 609 ++++++++++++++++------ 3 files changed, 814 insertions(+), 595 deletions(-) diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md index 05ebe42..0e2bc61 100644 --- a/docs/skill-parity/REVIEW_HANDOFF.md +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -1,28 +1,28 @@ # Meshy CLI S1 Review Handoff -Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 5**(Codex review 第 4 轮修复后的复审交接)。历次 review:第 1 轮 `…/reviews/cli-s1-6273d9a/`,第 2 轮 `…/reviews/cli-s1-730132b/`,第 3 轮 `…/reviews/cli-s1-cf8905d/`,第 4 轮 `…/reviews/cli-s1-235d6de/`;四处 reviewer 证据目录均未被改动,所有复现/核对脚本逐字节复制到独立证据目录后运行(副本 sha256 与原件一致)。 +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 6**(Codex review 第 5 轮修复后的复审交接)。历次 review:第 1 轮 `…/reviews/cli-s1-6273d9a/`,第 2 轮 `…/reviews/cli-s1-730132b/`,第 3 轮 `…/reviews/cli-s1-cf8905d/`,第 4 轮 `…/reviews/cli-s1-235d6de/`,第 5 轮 `…/reviews/cli-s1-68690f9/`;五处 reviewer 证据目录均未被改动,所有复现/核对脚本逐字节复制到独立证据目录后运行(11 个副本 sha256 与原件一致)。 -## 0. 第 4 轮 review 结论与本轮修复 +## 0. 第 5 轮 review 结论与本轮修复 -- 被 review 的 HEAD:`9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4`(代码 `235d6de`);结论 **changes_requested**:2 项 P2 代码缺陷(R4-F01、R4-F02)+ 1 项 P3 测试缺口(R4-T01);reviewer 全量 542/542,R3 六项正向 6/6,前两轮 20 项正向 20/20,5 项上下文检查 5/5,未发现新的 P1。 -- 修复 commit:`93e55bc`(`fix(review): address Codex review round 4 findings R4-F01, R4-F02 and test gap R4-T01`);测试稳定性 commit:`68690f9`(`test(wait)`:round-1 R03 “expiry during the sleep” 子进程检查原本把 poll 数上限设为 2,而 D-044 已记录“提前唤醒可再发一次 deadline-bound GET”,该检查在本机约 1/6 次误报——改为断言真实时钟能证明的不变量:服务端看到的每次 GET 都在预算内开始、第二次等满间隔、第三次只能出现在 deadline 唤醒、计入的 polls 与服务端看到的 GET 一致(最多一个被 deadline 截断);不放宽语义、不跳过)。`11 files changed, 1032 insertions(+), 121 deletions(-)`(相对 9e43a77,含 docs)。 -- 复现脚本重跑(副本,`68690f9`):`round4-probes.mjs` D01/D02 **不再呈现旧行为**(reproduced=false 不是验收依据,正向断言见 `tests/codex-review-round4.test.ts`),D03 正向中断探针 signal_sent=true / exit 130;`round3-probes.mjs` **0/6 复现**(原 fs.watch C03 本轮副本运行**触发成功**:signal_sent=true / exit 130、reproduced=false(上一轮 reviewer 运行中它曾未触发;触发与否取决于 fs.watch 时机,故中断证据仍以 D03、`round4-context-checks` 的 standalone-relink-interrupt 与仓库 C03/12 MB 轮询用例为主));`round2-probes.mjs` **0/8**;`round1-probes.mjs` **0/12**(三者均 exit 1 = 并非全部复现);reviewer 的 `verify-original-regressions.py` 对重跑结果 **20/20 通过**;`round4-context-checks.mjs` **5/5**;`stream-finalization-check.mjs` 通过。 -- 正向回归:`tests/codex-review-round4.test.ts`(5 项:D01 两种顺序 + 仲裁单测 4 例;D02 三种记账失败 + 恢复命令回放;预检 4 例 + 健康项目;R4-T01 legacy/v1 × 503/SIGINT);三轮旧回归全部保留并通过(C06 的 make 部分迁入 R4-T01 并按 reviewer 要求改为不同 step id)。 -- 全量:`pnpm typecheck` 通过;`pnpm test` **547/547**(0 失败、0 跳过);`git diff --check fd94490` 与工作树 exit 0;`poll.test.ts` 连续 12 次 12/12;`codex-review-round1.test.ts` 连续 8 次 8/8;round2+round3+round4 套件连续 3 次 3/3。 +- 被 review 的 HEAD:`e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59`(代码 `68690f9`);结论 **changes_requested**:3 项 P2 代码缺陷(R5-F01–R5-F03),未发现新的 P1;reviewer 全量 547/547,前两轮 20/20、R3+R4 正向 8/8、上下文 5/5,材质矩阵 14/16,项目入口矩阵 缺失分支 0/10 有恢复。 +- 修复 commit:`7b7c24c`(`fix(review): address Codex review round 5 findings R5-F01–R5-F03`)。本轮**没有**测试稳定性提交;68690f9 的 R03 改动按 reviewer 结论接受,未再改动。该 commit 顺带删去上一轮 `REVIEW_HANDOFF.md` 第 462 行样例中继承下来的一个尾随空格(`git diff --check fd94490` 在代码 HEAD 上因此为 exit 0;不改变任何 src/tests 语义)。`11 files changed, 757 insertions(+), 52 deletions(-)`(相对 e7c0bbc,含 docs)。 +- 复现脚本重跑(副本,`7b7c24c`):`round5-probes.mjs` E01 / E02×2 / E03 **均不再呈现旧行为**(reproduced=false 不是验收依据,正向断言见 `tests/codex-review-round5.test.ts`);`material-matrix-check.mjs` **16/16**;`project-entry-matrix-check.mjs` **20/20 有 record_project 恢复**(全部 exit 11、task_id 保留);`round4-probes.mjs` 0/3(D03 signal_sent=true / exit 130);`round3-probes.mjs` 0/6(fs.watch C03 signal_sent=true / exit 130,本轮触发成功);`round2-probes.mjs` 0/8;`round1-probes.mjs` 0/12;`verify-original-regressions.py` **20/20**;`verify-round3-4-regressions.py` **8/8**;`round4-context-checks.mjs` **5/5**;`stream-finalization-check.mjs` 通过。 +- 正向回归:`tests/codex-review-round5.test.ts`(5 项:E01 逐字回放含带空格与引号的 workspace、无 workspace 语义不变;E02 两种顺序;8×2 仲裁矩阵;E03 20 组入口矩阵 + 逐字回放;项目越界边界用例);`tests/codex-review-round4.test.ts` D02 改为逐字回放(不再补 `--workspace`),R4-T01 SIGINT 分支精确断言 `downloads.state=failed` 与 `[model_glb, failed]`;前四轮回归全部保留并通过。 +- 全量:`pnpm typecheck` 通过;`pnpm test` **552/552**(0 失败、0 跳过);`git diff --check fd94490` 与工作树 exit 0;`poll.test.ts` 连续 12 次 12/12;`codex-review-round1.test.ts` 连续 8 次 8/8;round2–5 套件连续 3 次 3/3。 | ID | 优先级 | 问题 | 修复 | 决策 | 回归测试 | 复现脚本重跑 | | --- | --- | --- | --- | --- | --- | --- | -| R4-F01 | P2 | 通道回退按“声明的通道”计竞争,实际回退落到同一贴图的两条不同引用被合并并报告 complete | `material-links.ts` 两遍解析:先按各自证据解析每个 distinct (key, reference)(`source_name`/`exact`/`source_stem` 为身份证据,通道与唯一贴图规则为启发式),再由 `arbitrate` 在**实际落到的贴图**上做全局竞争——任何其它 distinct 引用(命中或 ambiguous 候选)争用同一贴图时启发式命中一律否决;同一引用在不同 key 下落到不同贴图也不重写;身份命中永不否决。被否决的引用保持原文、`method: ambiguous`、`candidates: [贴图]`、组内共享一条 `note`;`status=incomplete`;warning 每个原因只说一次并列出材质组 | D-051 | D01(两种顺序:MTL 字节不变、两条引用 ambiguous、note 同时点名 channel_of_key 与 channel_in_name、rewritten 仅 OBJ、bytes/sha 与磁盘一致、贴图字节为源图、3 GET 0 POST);仲裁单测:source_name 命中 + 启发式竞争者(身份保留、竞争者原文)、无法解析的竞争者(C05 生成名冲突)同样阻止、同一引用两 key 两贴图、单引用回退与不同通道回退仍解析;C05/N04/round-1 材质测试不变 | D01: exit 0, `status=incomplete`, MTL 未改写 | -| R4-F02 | P2 | `download --project` 的 realpath/indexRootFor/recordTask 阶段在下载异常包装之外,记账失败返回 `result=null` | 进入项目阶段前固化 `outcome`(source/selection/downloads 含磁盘摘要/unknown_urls/saved_json);整段包进 `projectRecordFailure`:保留原分类(code/exit/http),`result` 完整 + `project: { action: "failed", stage, recorded_files: [], error, recovery }`,`error.recovery = { action: "record_project", command }`(同为 `hint`),command 为精确的 `meshy project record …`(task id、stage、resource、type、status、落在项目内的文件,`projectRecordCommand`);不回滚、不重下、不重提;`index_dirty` 语义不变。能预检的先预检(`preflightProject`:metadata.json 存在、是常规文件、可解析;`--stage` 非空)→ “nothing was downloaded”、0 请求。任务动词的 `--project` 记账失败同样给出 `record_project` recovery/hint | D-052 | D02(GET 期间 metadata.json 换成指向工作区外有效 metadata 的符号链接:exit 11、完整 v1 形状、result.source/selection/downloads(written, sha=磁盘)/saved_json、project.action=failed + error + recovery、hint=命令、外部与备份 metadata 字节不变、符号链接未被替换、无临时文件、恰 1 次 GET;随后修复目录并**逐字回放恢复命令**:exit 0、metadata.tasks 恰好记录 model.glb、0 请求);GET 期间 metadata 损坏为非 JSON(同上,损坏文件不被覆盖);GET 期间项目目录变为不可写(锁文件无法创建的原生 errno → local_io + 完整 result);预检:符号链接/损坏 metadata → exit 11 且 0 请求、无 id 任务 → exit 2、空 `--stage` → exit 2;健康项目照常记录 | D02: exit 11, result 含 downloads/project/recovery | -| R4-T01 | P3 | make 两步 mock 同一 task_id,只断言 operation_id 非空 | 新增 R4-T01:两步返回不同 id,对账 `result.task_id`、`submission.operation_id`、`executed[-1].operation_id`、legacy 顶层 `operation_id` = 最后一条 accepted journal,`executed[0].operation_id` = 第一步记录,`preview_task_id` = 第一步,请求序列 POST GET POST GET GET,恰 2 条 journal;legacy/v1 × 资产 503/SIGINT 四组;C06 保留 create/wait/get/sidecar/SIGINT | D-053 | R4-T01(4 组);reviewer 的 `round4-context-checks.mjs` 副本 5/5 | context checks 5/5 | +| R5-F01 | P2 | `record_project` 恢复命令不带原 `--workspace`,回放后更新了边界外的父目录 history.json 并在边界外建锁 | `projectRecordCommand(projectDir, input, { root?, workspace? })` 追加 `--workspace <解析后的绝对路径>`(与其它参数同样 shell 引用);`download` 的 `projectRecordFailure` 与任务动词的 `attachToProject` 两处调用均传入;不通过省略约束让恢复成功;无显式 workspace 时不追加、父索引照常刷新 | D-054 | E01(workspace==project 且路径含空格与 `'`:命令被正确引用;修复后**逐字回放**——测试不补任何参数——exit 0、metadata 记录 model.glb、`index.updated=false` 且 `index_dirty`、父 history.json 字节不变、父目录列表不变(无锁/temp)、0 请求;无 workspace 对照:命令无 `--workspace`,父索引刷新);D02 回放改为逐字并断言命令含原 workspace | E01: history_changed=false, index_dirty | +| R5-F02 | P2 | 同一引用在不同 key 下一处命中、另一处 ambiguous 时,只比较 hit,命中行被局部改写 | `arbitrate` 分两步:**先按 reference 汇总所有 key**——身份证据与 key 无关直接保留;否则每个 key 的通道规则给出候选集(命中=单元素、ambiguous=其候选、该通道无贴图=不提供证据),全部相同且为单一贴图才成立,否则该 reference 所有行保持原文、`method: ambiguous`、各自候选、共享一条跨 key note("one reference names one file");**再**做 round-4 的跨引用竞争否决 | D-055 | E02(两种顺序:MTL 字节不变、两行 ambiguous、各行候选 `[base]` / `[n0, n1]`、note 点名两个 key 与各自结果、rewritten 仅 OBJ、bytes/sha 与磁盘一致、1 条 warning);8×2 仲裁矩阵纳入仓库测试(身份+启发式、ambiguous 竞争者、同 ref 两命中 / 命中+ambiguous / 同身份 / 同回退、不同通道、身份+ambiguous 竞争者);D01/C05/N04/round-1 不变 | E02×2: MTL 未改写;矩阵 16/16 | +| R5-F03 | P2 | `resolveProjectDir` 在 `attachToProject` 的 try 之外:metadata 在请求期间消失时 recovery=null、hint 退化为 wait(legacy/v1 × 5 入口共 10 组) | 先建立恢复上下文(task id、journal operation、stage、解析后的 workspace),再在阶段内做两类检查:项目不再位于 workspace 内(或变成符号链接)→ **边界失败**:说明任务与 operation、未记录任何东西、如何处理,但**不给出**会跨越边界的命令;其它(`assertProjectMetadataPresent`:metadata 消失或非常规文件、损坏、锁、磁盘满)→ `record_project` 命令含 `--operation-id` 与 `--workspace`(同为 hint,legacy payload 亦携带);不重提、不建议 `project init`;`index_dirty` 语义不变;`download` 在记账前应用同一断言(消失 → local_io 而非 not_found) | D-056 | E03 矩阵(legacy/v1 × get/wait/stream/create --async/sync create × missing/damaged = 20 组:exit 11、task_id、create 恰 1 POST 且 journal 恰 1 条 accepted、`submission.operation_id` 与 legacy 顶层 `operation_id` = journal、命令含 `--operation-id`/`--workspace`/`--stage preview`、damaged 分支含快照 `--task-json`;修复后逐字回放:0 请求、正确记录、index_dirty、父 history 不变);越界用例(项目在 POST 期间被换成指向外部目录的符号链接:exit 11、task 与 operation 被点名、recovery null、无 record 命令、外部目录未被写入) | E03: recovery 存在;入口矩阵 20/20 | ## 1. 代码定位 - 仓库路径 / remote:`/Users/ark/Dev/meshy-cli`(fresh clone) / `https://github.com/meshy-dev/meshy-cli.git` - 分支:`feat/skill-parity-s1`(本地分支,未推送) - base SHA:`fd94490916376e691efcea51324ac4326b459e1f`(0.2.0,= 计划基线 = 开工时的 remote main) -- head SHA(代码):`68690f9273ff20bbf95e6ac586766e30d977b6b4`(= 修复 `93e55bcc717ceb9a368ee43aeeeac69a3fb52197` + 测试稳定性提交 `68690f9`);本文件与 verification.json/capability-matrix.json 在其后的 **docs-only commit** 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`、`pnpm-lock.yaml`) -- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe8`);round 3 `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905d`);round 4 `9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4`(代码 `235d6de`) +- head SHA(代码):`7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`(= 修复 `7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`);本文件与 verification.json/capability-matrix.json 在其后的 **docs-only commit** 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`、`pnpm-lock.yaml`) +- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe8`);round 3 `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905d`);round 4 `9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4`(代码 `235d6de`);round 5 `e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59`(代码 `68690f9`) - 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) - 实施包版本:2026-09-07 / v1 - 实际源码与计划基线的差异:无(见 `docs/skill-parity/baseline-delta.md`) @@ -31,18 +31,20 @@ Filled from the 2026-09-07 / v1 implementation package. `not_run` means not veri ## 2. 完成状态 -- G1-code / review-ready:**第 4 轮 2 项代码 finding 已修复并有正向回归,1 项测试缺口已补;前三轮 23 项在新 HEAD 复核未回退(reviewer 正向脚本 20/20、R3 探针 0/6 复现且 D03/上下文检查通过);自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) -- G1-release / ready-for-S2:**not_run**(无复审结论、无真实账号/多 OS/切片器验证、未发布) -- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;5 项带 `review_round_4`,10 项 `review_round_3`,13 项 `review_round_2`,21 项 `review_round_1`,`review_status` 均为 "re-review pending") -- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **547 通过 / 0 失败 / 0 跳过**(547 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) -- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 68690f9 重跑:exit 0)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open -- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(四个 reviewer 目录未被改动;未调用付费接口;未发布) +- G1-code / review-ready:**第 5 轮 3 项 finding 已修复并有正向回归;前四轮 26 项在新 HEAD 复核未回退(reviewer 正向脚本 20/20 与 8/8、四轮探针 0 复现、上下文 5/5);自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) +- G1-release / ready-for-S2:**not_run**(无复审结论、无真实账号/多 OS/切片器验证、未发布;按用户要求不在本轮范围) +- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;8 项带 `review_round_5`,5 项 `review_round_4`,10 项 `review_round_3`,13 项 `review_round_2`,21 项 `review_round_1`,`review_status` 均为 "re-review pending") +- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **552 通过 / 0 失败 / 0 跳过**(552 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) +- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 7b7c24c 重跑:exit 0)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open +- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(五个 reviewer 目录未被改动;未调用付费接口;未发布) ## 3. 本次具体改动 `git log --oneline fd94490..HEAD`(最早在下): ``` +7b7c24c fix(review): address Codex review round 5 findings R5-F01–R5-F03 +e7c0bbc docs(skill-parity): round-5 handoff after Codex review round 4 fixes 68690f9 test(wait): allow the deadline wake-up's one extra GET that D-044 permits 93e55bc fix(review): address Codex review round 4 findings R4-F01, R4-F02 and test gap R4-T01 9e43a77 docs(skill-parity): round-4 handoff after Codex review round 3 fixes @@ -66,348 +68,241 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt 修复 commit: ``` -93e55bc fix(review): address Codex review round 4 findings R4-F01, R4-F02 and test gap R4-T01 +7b7c24c fix(review): address Codex review round 5 findings R5-F01–R5-F03 - README.md | 18 +- - docs/skill-parity/decisions.md | 58 ++++ + README.md | 5 +- + docs/skill-parity/REVIEW_HANDOFF.md | 2 +- + docs/skill-parity/decisions.md | 60 ++++ docs/skill-parity/migration-notes.md | 9 + - skills/meshy-cli/SKILL.md | 7 +- - src/cmd/download.ts | 140 ++++++-- - src/internal/material-links.ts | 163 ++++++--- - src/internal/project-store.ts | 23 ++ - src/internal/task-command.ts | 36 +- - tests/codex-review-round3.test.ts | 28 +- - tests/codex-review-round4.test.ts | 648 +++++++++++++++++++++++++++++++++++ - 10 files changed, 1013 insertions(+), 117 deletions(-) -``` - -测试稳定性 commit(仅测试与 decisions.md 的 D-044 备注): - -``` -68690f9 test(wait): allow the deadline wake-up's one extra GET that D-044 permits - - docs/skill-parity/decisions.md | 7 +++++++ - tests/codex-review-round1.test.ts | 16 ++++++++++++---- - 2 files changed, 19 insertions(+), 4 deletions(-) + skills/meshy-cli/SKILL.md | 6 +- + src/cmd/download.ts | 13 +- + src/internal/material-links.ts | 86 ++++-- + src/internal/project-store.ts | 42 ++- + src/internal/task-command.ts | 51 +++- + tests/codex-review-round4.test.ts | 13 +- + tests/codex-review-round5.test.ts | 522 +++++++++++++++++++++++++++++++++++ + 11 files changed, 757 insertions(+), 52 deletions(-) ``` 要点: -- `src/internal/material-links.ts`:`Resolution` 增加 `identity`;`resolveTextureReference` 不再接收 competing 集合;新增 `MapPair`/`pairId`/`arbitrate`(全局竞争);MTL 处理改为“先解析全部 distinct 对,再仲裁,再重写”;ambiguous warning 按原因去重并列出材质组;模块注释描述身份/启发式两类规则。 -- `src/cmd/download.ts`:`preflightProject`(lstat 常规文件、`readProject` 可解析、`--stage` 非空);项目阶段前固化 `outcome`;`projectRecordFailure` 包装 realpath/indexRootFor/recordTask;成功输出形状不变(`project: { project_dir, action, stage, recorded_files }`)。 -- `src/internal/project-store.ts`:`projectRecordCommand(projectDir, input, { root? })` + `shellArg`(仅在需要时加引号)。 -- `src/internal/task-command.ts`:`attachToProject` 失败时附加 `recovery: { action: "record_project", command }`、`hint`、原 http/retryable/details。 -- `tests/codex-review-round4.test.ts`(新,5 项);`tests/codex-review-round3.test.ts` C06 移除 make 段(迁入 R4-T01);`tests/codex-review-round1.test.ts` R03 “expiry during the sleep” 改为 D-044 不变量断言。 -- 文档:`decisions.md` D-051–D-053 + D-044 备注;`migration-notes.md` §3.4;README 材质段与 `--project` 记账失败段;SKILL.md 新增 `project.action: "failed"` 处置与材质 note 说明。 +- `src/internal/project-store.ts`:`projectRecordCommand` 增加 `workspace` 选项(`--workspace `,`shellArg` 引用);新增 `assertProjectMetadataPresent`(metadata.json 消失 → `local_io` "has no metadata.json any more (it was an initialised project when this command started)";非常规文件 → `local_io`)。 +- `src/internal/task-command.ts`:`attachToProject` 先构造 `input`/`workspace`,再 (1) `resolveWithinRoot` 边界检查 → `projectBoundaryFailure`(无命令,details 含 task_id/operation_id/stage/recorded=false),(2) try 内 `assertProjectMetadataPresent` → 快照 → `indexRootFor` → `recordTask`,catch → `record_project` 命令(含 `--operation-id`、`--workspace`)与 hint;`resolveProjectDir` 仅保留给 POST 前的 `preflightLocalTargets`("nothing was submitted" 语义不变)。 +- `src/cmd/download.ts`:项目阶段先算落在项目内的文件列表,再 `assertProjectMetadataPresent`,再记账;`projectRecordFailure` 接收并传递 `workspace`。 +- `src/internal/material-links.ts`:`arbitrate` 两步(按 reference 跨 key 汇总 → 跨引用竞争);模块注释更新。 +- 测试:`tests/codex-review-round5.test.ts`(新,5 项);`tests/codex-review-round4.test.ts` D02 逐字回放、R4-T01 SIGINT 精确 manifest。 +- 文档:`decisions.md` D-054–D-056;`migration-notes.md` §3.5;README `--project` 恢复段;SKILL.md 恢复命令处置。 ## 4. 能力与接口证据 -### 4.1 第 4 轮 finding 修复后的实际输出(`round4-probes.mjs` 副本重跑,`68690f9`,脱敏) +### 4.1 第 5 轮 finding 修复后的实际输出(`round5-probes.mjs` 副本重跑,`7b7c24c`,脱敏) -**D01**(`download --task-json /channel-fallback.json --model-format obj --output-dir /workspace/channel-fallback` → exit 0)。保存的 MTL 与服务端原文逐字节相同: +**E01**(`download --task-json /scope-task.json --all --project /projects/_recovery-scope_4f87 --workspace /projects/_recovery-scope_4f87` → exit 11;模型 sha256 `1390b094bb61759b…`,外部 metadata 未变 = true)。返回的恢复命令(探针只替换启动器,**不补参数**): ```text -newmtl body -map_Kd body_normal.png -newmtl eyes -map_Kd eyes_diffuse.png - +meshy project record --project /projects/_recovery-scope_4f87 --task-id round2-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --file model.glb --workspace /projects/_recovery-scope_4f87 ``` -`result.downloads.material_links`(贴图、mtllib、texture_maps、状态、warnings): +修复 metadata 后逐字回放 → exit 0,父目录 history.json 变化 = **false**: ```json { - "status": "incomplete", - "textures": [ - { - "key": "texture.0.base_color", - "name": "texture_0_base_color.png", - "source_name": "a.png", - "set": 0, - "channel": "basecolor" - } - ], - "mtllib": [ - { - "line": 1, - "material": null, - "reference": "original.mtl", - "resolved_to": "model.mtl", - "method": "downloaded_mtl" - } - ], - "texture_maps": [ - { - "line": 2, - "material": "body", - "reference": "body_normal.png", - "resolved_to": null, - "method": "ambiguous", - "candidates": [ - "texture_0_base_color.png" - ], - "note": "'body_normal.png' (channel_of_key) and 'eyes_diffuse.png' (channel_in_name) compete for texture_0_base_color.png; different references cannot share one texture without evidence that they name the same image" + "ok": true, + "result": { + "action": "added", + "index": { + "updated": false, + "error": "history root /projects resolves outside --workspace /projects/_recovery-scope_4f87; metadata.json was recorded but history.json was not touched (history root /projects resolves outside the authorised root /projects/_recovery-scope_4f87) — run `meshy project rebuild-index --root /projects` from a workspace that contains it" }, + "task_count": 1 + }, + "warnings": [ { - "line": 4, - "material": "eyes", - "reference": "eyes_diffuse.png", - "resolved_to": null, - "method": "ambiguous", - "candidates": [ - "texture_0_base_color.png" - ], - "note": "'body_normal.png' (channel_of_key) and 'eyes_diffuse.png' (channel_in_name) compete for texture_0_base_color.png; different references cannot share one texture without evidence that they name the same image" + "code": "index_dirty", + "message": "metadata.json committed but history.json was not updated: history root /projects resolves outside --workspace /projects/_recovery-scope_4f87; metadata.json was recorded but history.json was not touched (history root /projects resolves outside the authorised root /projects/_recovery-scope_4f87) — run `meshy project rebuild-index --root /projects` from a workspace that contains it; run `meshy project rebuild-index`" } - ], - "rewritten": [ - "/workspace/channel-fallback/model.obj" ] } ``` +**E02**(两种顺序,exit 0,保存的 MTL 与服务端原文逐字节相同): + +```text +newmtl a +map_Kd shared.png +map_Bump shared.png + +``` + ```json [ { - "code": "material_reference_ambiguous", - "message": "model.mtl: 'body_normal.png' (channel_of_key) and 'eyes_diffuse.png' (channel_in_name) compete for texture_0_base_color.png; different references cannot share one texture without evidence that they name the same image (body, eyes); the references stay as written — the CLI does not guess between material groups or sources" + "line": 2, + "reference": "shared.png", + "resolved_to": null, + "method": "ambiguous", + "candidates": [ + "texture_0_base_color.png" + ], + "note": "'shared.png' is used by map_Kd and map_Bump but resolves differently per key: map_Kd would make it texture_0_base_color.png (channel_of_key) while map_Bump could only make it texture_0_normal.png or texture_1_normal.png; one reference names one file, so none of its lines is rewritten" + }, + { + "line": 3, + "reference": "shared.png", + "resolved_to": null, + "method": "ambiguous", + "candidates": [ + "texture_0_normal.png", + "texture_1_normal.png" + ], + "note": "'shared.png' is used by map_Kd and map_Bump but resolves differently per key: map_Kd would make it texture_0_base_color.png (channel_of_key) while map_Bump could only make it texture_0_normal.png or texture_1_normal.png; one reference names one file, so none of its lines is rewritten" } ] ``` +```text +newmtl a +map_Bump shared.png +map_Kd shared.png -**D02**(`download --task-json /project-failure-task.json --all --project /workspace/projects/_record-failure_958d --workspace /workspace` → exit 11;model.glb 已落盘 = true,工作区外 metadata 字节未变 = true;请求:GET /model.glb): +``` + +```json +[ + { + "line": 2, + "reference": "shared.png", + "resolved_to": null, + "method": "ambiguous", + "candidates": [ + "texture_0_normal.png", + "texture_1_normal.png" + ], + "note": "'shared.png' is used by map_Bump and map_Kd but resolves differently per key: map_Bump could only make it texture_0_normal.png or texture_1_normal.png while map_Kd would make it texture_0_base_color.png (channel_of_key); one reference names one file, so none of its lines is rewritten" + }, + { + "line": 3, + "reference": "shared.png", + "resolved_to": null, + "method": "ambiguous", + "candidates": [ + "texture_0_base_color.png" + ], + "note": "'shared.png' is used by map_Bump and map_Kd but resolves differently per key: map_Bump could only make it texture_0_normal.png or texture_1_normal.png while map_Kd would make it texture_0_base_color.png (channel_of_key); one reference names one file, so none of its lines is rewritten" + } +] +``` + +**E03**(`text-to-3d create --mode preview --prompt fixture --async --output-schema v1 --project /task-projects/_vanished-metadata_70a9 --workspace /task-projects/_vanished-metadata_70a9` → exit 11;请求:POST /openapi/v2/text-to-3d): ```json { - "schema_version": "meshy.cli/v1", - "command": "download", "ok": false, "result": { - "source": { - "kind": "task-json", - "path": "/project-failure-task.json", - "shape": "api", - "resource": "text-to-3d", - "task_id": "round2-task" - }, - "selection": { - "selected": [ - "model.glb" - ], - "dependencies": [] - }, - "downloads": { - "state": "completed", - "files": [ - { - "key": "model.glb", - "path": "/workspace/projects/_record-failure_958d/model.glb", - "relative_path": "projects/_record-failure_958d/model.glb", - "bytes": 49, - "sha256": "1390b094bb61759b837fcb096cf53f499d6a5724524a0b55f9700bd22bb87651", - "content_type": "model/gltf-binary", - "format": "glb", - "container_format": null, - "extracted": null, - "status": "written", - "error": null, - "publish_method": "link", - "relinked": false - } - ], - "metadata_path": null, - "material_links": null + "task_id": "accepted-project-recovery-task", + "submission": { + "state": "accepted", + "operation_id": "c261d382-aee7-41dd-b6ff-6c93c5103dae", + "task_id": "accepted-project-recovery-task", + "request_id": null }, - "unknown_urls": [], - "saved_json": null, - "project": { - "project_dir": "/workspace/projects/_record-failure_958d", - "action": "failed", - "stage": "preview", - "recorded_files": [], - "error": { - "code": "local_io", - "message": "refusing to replace /workspace/projects/_record-failure_958d/metadata.json: it is not a regular file" - }, - "recovery": { - "action": "record_project", - "automatic": false, - "command": "meshy project record --project /workspace/projects/_record-failure_958d --task-id round2-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --file model.glb" - } + "next": { + "get": "meshy text-to-3d get accepted-project-recovery-task --output-schema v1", + "wait": "meshy text-to-3d wait accepted-project-recovery-task --output-schema v1", + "stream": "meshy text-to-3d stream accepted-project-recovery-task --format ndjson --output-schema v1" } }, "error": { "code": "local_io", - "message": "1 file(s) were downloaded to /workspace/projects/_record-failure_958d but recording task round2-task in project /workspace/projects/_record-failure_958d failed: refusing to replace /workspace/projects/_record-failure_958d/metadata.json: it is not a regular file", + "message": "task accepted-project-recovery-task exists (operation c261d382-aee7-41dd-b6ff-6c93c5103dae) but recording it in /task-projects/_vanished-metadata_70a9 failed: --project /task-projects/_vanished-metadata_70a9 has no metadata.json any more (it was an initialised project when this command started); restore the project, then run: meshy project record --project /task-projects/_vanished-metadata_70a9 --task-id accepted-project-recovery-task --stage preview --resource text-to-3d --operation-id c261d382-aee7-41dd-b6ff-6c93c5103dae --workspace /task-projects/_vanished-metadata_70a9", "http_status": null, "retryable": false, "recovery": { "action": "record_project", "automatic": false, - "command": "meshy project record --project /workspace/projects/_record-failure_958d --task-id round2-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --file model.glb" + "command": "meshy project record --project /task-projects/_vanished-metadata_70a9 --task-id accepted-project-recovery-task --stage preview --resource text-to-3d --operation-id c261d382-aee7-41dd-b6ff-6c93c5103dae --workspace /task-projects/_vanished-metadata_70a9" }, - "hint": "meshy project record --project /workspace/projects/_record-failure_958d --task-id round2-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --file model.glb" - }, - "warnings": [] + "hint": "meshy project record --project /task-projects/_vanished-metadata_70a9 --task-id accepted-project-recovery-task --stage preview --resource text-to-3d --operation-id c261d382-aee7-41dd-b6ff-6c93c5103dae --workspace /task-projects/_vanished-metadata_70a9" + } } ``` -**D03**(reviewer 的稳定中断探针,signal_sent=true,OBJ 完整 = true,磁盘文件 = ['model.mtl', 'model.obj'])→ exit 130,`error.code=interrupted`,`downloads.failed_step=relink`,manifest: +### 4.2 入口矩阵与材质矩阵(reviewer 脚本副本,`7b7c24c`) + +`project-entry-matrix-check.mjs`:20/20 组有 `record_project` 恢复,全部 exit 11、task_id 保留。 + +| schema | verb | fault | exit | task_id | record_project | +| --- | --- | --- | --- | --- | --- | +| legacy | get | damaged | 11 | legacy-get-damaged | yes | +| legacy | get | missing | 11 | legacy-get-missing | yes | +| legacy | wait | damaged | 11 | legacy-wait-damaged | yes | +| legacy | wait | missing | 11 | legacy-wait-missing | yes | +| legacy | stream | damaged | 11 | legacy-stream-damaged | yes | +| legacy | stream | missing | 11 | legacy-stream-missing | yes | +| legacy | create-async | damaged | 11 | legacy-create-async-damaged | yes | +| legacy | create-async | missing | 11 | legacy-create-async-missing | yes | +| legacy | create-sync | damaged | 11 | legacy-create-sync-damaged | yes | +| legacy | create-sync | missing | 11 | legacy-create-sync-missing | yes | +| v1 | get | damaged | 11 | v1-get-damaged | yes | +| v1 | get | missing | 11 | v1-get-missing | yes | +| v1 | wait | damaged | 11 | v1-wait-damaged | yes | +| v1 | wait | missing | 11 | v1-wait-missing | yes | +| v1 | stream | damaged | 11 | v1-stream-damaged | yes | +| v1 | stream | missing | 11 | v1-stream-missing | yes | +| v1 | create-async | damaged | 11 | v1-create-async-damaged | yes | +| v1 | create-async | missing | 11 | v1-create-async-missing | yes | +| v1 | create-sync | damaged | 11 | v1-create-sync-damaged | yes | +| v1 | create-sync | missing | 11 | v1-create-sync-missing | yes | + +legacy `get` / metadata 消失 的实际 payload(hint 即恢复命令,含 `--workspace`): ```json { - "state": "partial", - "files": [ - { - "key": "model_obj", - "path": "/workspace/relink-interrupt/model.obj", - "bytes": 11600028, - "sha256": "91bd24a25a223efabd173c0ab2a9bd6125a984d8a0bed601a09ca4c593c6acec", - "content_type": "model/obj", - "status": "written", - "error": null, - "relinked": false - }, - { - "key": "model_mtl", - "path": "/workspace/relink-interrupt/model.mtl", - "bytes": 18, - "sha256": "997fd7d9f6b03ab40a1e252c6319d76c351d6f5ea2a431859077faee004243d6", - "content_type": "text/plain", - "status": "written", - "error": null, - "relinked": false - } - ], - "metadata_path": null, - "failed_step": "relink" + "code": "local_io", + "task_id": "legacy-get-missing", + "hint": "meshy project record --project /projects/_legacy-get-missing_0eb7 --task-id legacy-get-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --workspace /projects/_legacy-get-missing_0eb7", + "message": "task legacy-get-missing exists but recording it in /projects/_legacy-get-missing_0eb7 failed: --project /projects/_legacy-get-missing_0eb7 has no metadata.json any more (it was an initialised project when this command started); restore the project, then run: meshy project record --project /projects/_legacy-get-missing_0eb7 --task-id legacy-get-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --workspace /projects/_legacy-get-missing_0eb7" } ``` -### 4.2 `round4-context-checks.mjs` 副本(`68690f9`):make 最后一步身份 - -legacy(exit 7,`task_id`/`operation_id` 顶层字段与 `result.submission`、`executed[-1]`、journal 一致): - -```json -{ - "name": "CliError", - "code": "network", - "status": 503, - "task_id": "legacy-step-2", - "operation_id": "e421a751-67a3-409c-b27e-8279e35693f8", - "hint": "meshy download --resource text-to-3d --task-id legacy-step-2 --all --output-dir " -} -``` +v1 同步 `create` / metadata 消失 的 error(`--operation-id` 与 journal 一致): ```json { - "route": "text", - "task_id": "legacy-step-2", - "submission": { - "state": "accepted", - "operation_id": "e421a751-67a3-409c-b27e-8279e35693f8", - "task_id": "legacy-step-2" + "code": "local_io", + "message": "task v1-create-sync-missing exists (operation 176a6426-84f7-4885-9d69-35652b7b371d) but recording it in /projects/_v1-create-sync-missing_e63c failed: --project /projects/_v1-create-sync-missing_e63c has no metadata.json any more (it was an initialised project when this command started); restore the project, then run: meshy project record --project /projects/_v1-create-sync-missing_e63c --task-id v1-create-sync-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --operation-id 176a6426-84f7-4885-9d69-35652b7b371d --workspace /projects/_v1-create-sync-missing_e63c", + "http_status": null, + "retryable": false, + "recovery": { + "action": "record_project", + "automatic": false, + "command": "meshy project record --project /projects/_v1-create-sync-missing_e63c --task-id v1-create-sync-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --operation-id 176a6426-84f7-4885-9d69-35652b7b371d --workspace /projects/_v1-create-sync-missing_e63c" }, - "executed": [ - { - "step": 1, - "resource": "text-to-3d", - "action": "preview", - "task_id": "legacy-step-1", - "status": "SUCCEEDED", - "operation_id": "4e862c11-4ce8-438e-ad68-d3e2fe51cfb9" - }, - { - "step": 2, - "resource": "text-to-3d", - "action": "refine", - "task_id": "legacy-step-2", - "status": "SUCCEEDED", - "operation_id": "e421a751-67a3-409c-b27e-8279e35693f8" - } - ], - "next": { - "get": "meshy text-to-3d get legacy-step-2 --output-schema v1", - "wait": "meshy text-to-3d wait legacy-step-2 --output-schema v1", - "stream": "meshy text-to-3d stream legacy-step-2 --format ndjson --output-schema v1" - } -} -``` - -v1(exit 7): - -```json -{ - "schema_version": "meshy.cli/v1", - "command": "make", - "ok": false, - "error": { - "code": "network", - "message": "make finished (task v1-step-2) but downloading its assets failed: download failed for model_glb: download failed for http://127.0.0.1:/asset.glb (HTTP 503 Service Unavailable)", - "http_status": 503, - "retryable": false, - "recovery": { - "action": "download", - "automatic": false, - "command": "meshy download --resource text-to-3d --task-id v1-step-2 --all --output-dir " - }, - "details": { - "expired_or_denied": false - } - }, - "result": { - "route": "text", - "task_id": "v1-step-2", - "submission": { - "state": "accepted", - "operation_id": "d51c6fe0-f66b-4ee7-8c76-ae677e20f796", - "task_id": "v1-step-2" - }, - "executed": [ - { - "step": 1, - "resource": "text-to-3d", - "action": "preview", - "task_id": "v1-step-1", - "status": "SUCCEEDED", - "operation_id": "7fd4af5a-ac51-4dfc-b55c-e963e43282bf" - }, - { - "step": 2, - "resource": "text-to-3d", - "action": "refine", - "task_id": "v1-step-2", - "status": "SUCCEEDED", - "operation_id": "d51c6fe0-f66b-4ee7-8c76-ae677e20f796" - } - ], - "next": { - "get": "meshy text-to-3d get v1-step-2 --output-schema v1", - "wait": "meshy text-to-3d wait v1-step-2 --output-schema v1", - "stream": "meshy text-to-3d stream v1-step-2 --format ndjson --output-schema v1" - }, - "downloads": { - "state": "failed", - "files": [ - { - "key": "model_glb", - "path": "/make-v1/model.glb", - "bytes": 0, - "sha256": "", - "content_type": null, - "status": "failed", - "error": "download failed for http://127.0.0.1:/asset.glb (HTTP 503 Service Unavailable)", - "relinked": false - } - ], - "metadata_path": null - } - } + "hint": "meshy project record --project /projects/_v1-create-sync-missing_e63c --task-id v1-create-sync-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --operation-id 176a6426-84f7-4885-9d69-35652b7b371d --workspace /projects/_v1-create-sync-missing_e63c" } ``` -### 4.3 tarball 安装 smoke(`68690f9`,`meshy-cli-0.3.0.tgz`;`` 为临时 npm prefix) +`material-matrix-check.mjs`:16/16。 + +| 组合 | 顺序 | 结果 | 实际 [resolved_to, method] | +| --- | --- | --- | --- | +| identity-plus-heuristic | as listed | pass | [["texture_0_base_color.png", "source_name"], [null, "ambiguous"]] | +| identity-plus-heuristic | reversed | pass | [[null, "ambiguous"], ["texture_0_base_color.png", "source_name"]] | +| ambiguous-plus-heuristic | as listed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | +| ambiguous-plus-heuristic | reversed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | +| same-ref-two-hits | as listed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | +| same-ref-two-hits | reversed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | +| same-ref-hit-and-ambiguous | as listed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | +| same-ref-hit-and-ambiguous | reversed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | +| same-ref-same-identity | as listed | pass | [["texture_0_base_color.png", "source_name"], ["texture_0_base_color.png", "source_name"]] | +| same-ref-same-identity | reversed | pass | [["texture_0_base_color.png", "source_name"], ["texture_0_base_color.png", "source_name"]] | +| same-ref-same-fallback | as listed | pass | [["texture_0_normal.png", "channel_in_name"], ["texture_0_normal.png", "channel_in_name"]] | +| same-ref-same-fallback | reversed | pass | [["texture_0_normal.png", "channel_in_name"], ["texture_0_normal.png", "channel_in_name"]] | +| distinct-channel-fallbacks | as listed | pass | [["texture_0_base_color.png", "channel_of_key"], ["texture_0_normal.png", "channel_in_name"]] | +| distinct-channel-fallbacks | reversed | pass | [["texture_0_normal.png", "channel_in_name"], ["texture_0_base_color.png", "channel_of_key"]] | +| identity-with-ambiguous-source-rival | as listed | pass | [["texture_0_base_color.png", "source_name"], [null, "ambiguous"]] | +| identity-with-ambiguous-source-rival | reversed | pass | [[null, "ambiguous"], ["texture_0_base_color.png", "source_name"]] | + +### 4.3 tarball 安装 smoke(`7b7c24c`,`meshy-cli-0.3.0.tgz`;`` 为临时 npm prefix) `/prefix/bin/meshy resources --output-schema v1` → exit 0 @@ -517,96 +412,6 @@ v1(exit 7): … (truncated) ``` -`/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1` → exit 0 - -```json -{ - "schema_version": "meshy.cli/v1", - "command": "download", - "ok": true, - "result": { - "source": { - "kind": "task-json", - "path": "/work/task-rigging.synthetic.json", - "shape": "api", - "resource": null, - "task_id": "fixture-rig-1" - }, - "assets": [ - { - "key": "result.rigged_character_glb_url", - "kind": "rig", - "format": "glb", - "model_format": "glb", - "container_format": null, - "filename": "rigged_character.glb", - "dependencies": [], - "has_url": true - }, - { - "key": "result.basic_animations.walking_glb_url", - "kind": "animation", - "format": "glb", - "model_format": "glb", - "container_format": null, - "filename": "walking_glb.glb", - "dependencies": [], - "has_url": true - }, - { - "key": "result.basic_animations.running_glb_url", - "kind": "animation", - "format": "glb", - "model_format": "glb", - "container_format": null, - "filename": "running_glb.glb", - "dependencies": [], - "has_url": true - } - ], - "unknown_urls": [], - "product": null, - "saved_json": null - }, - "error": null, - "warnings": [] -} -``` - -`/prefix/bin/meshy make a red sports car --dry-run --output-schema v1` → exit 0 - -```json -{ - "schema_version": "meshy.cli/v1", - "command": "make", - "ok": true, - "result": { - "command": "make", - "route": "text", - "steps": [ - { - "step": 1, - "resource": "text-to-3d", - "action": "preview", - "estimated_credits": 20 - }, - { - "step": 2, - "resource": "text-to-3d", - "action": "refine", - "estimated_credits": 10 - } - ], - "estimated_credits": 30, - "note": "Estimates only — confirm prices at https://docs.meshy.ai/en/api/pricing and your balance with `meshy balance`.", - "dry_run": true, - "requests_made": 0 - }, - "error": null, - "warnings": [] -} -``` - `/prefix/bin/meshy project record --project /work/meshy_output/_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1` → exit 0 ```json @@ -630,8 +435,8 @@ v1(exit 7): ], "task_json": null, "operation_id": null, - "created_at": "2026-09-08T02:24:23.911Z", - "updated_at": "2026-09-08T02:24:23.911Z" + "created_at": "2026-09-08T03:09:36.915Z", + "updated_at": "2026-09-08T03:09:36.915Z" }, "task_count": 1, "index": { @@ -688,27 +493,31 @@ v1(exit 7): ## 5. 实际验证记录 -全部绑定代码 HEAD `68690f9273ff20bbf95e6ac586766e30d977b6b4`(Node v24.20.0,pnpm 11.24.0);机器记录见 `docs/skill-parity/verification.json`。 +全部绑定代码 HEAD `7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`(Node v24.20.0,pnpm 11.24.0);机器记录见 `docs/skill-parity/verification.json`。 | 检查 | 结果 | 说明 | | --- | --- | --- | | `pnpm install --frozen-lockfile` | exit 0 | | | `pnpm typecheck` | exit 0 | src + tests | | `pnpm build` | exit 0 | | -| `pnpm test` | **547 pass / 0 fail / 0 skip**(547 项,41 s) | 含 5 项 round-4 新回归 | +| `pnpm test` | **552 pass / 0 fail / 0 skip**(552 项,42 s) | 含 5 项 round-5 新回归 | | `dist/index.js --version` = package.json | exit 0 | | | `git diff --check fd94490` / 工作树 | exit 0 / 0 | | | `poll.test.ts` × 12 | 12/12 | | -| `codex-review-round1.test.ts` × 8 | 8/8 | R03 稳定性提交后;提交前在 `93e55bc` 上观察到 1/6 失败(polls=3) | -| round2+round3+round4 套件 × 3 | 3/3 | 含 SIGINT/竞争用例 | -| `round4-probes.mjs` 副本 | D01/D02 不再复现,D03 signal_sent=true exit 130 | 正向验收在仓库测试 | +| `codex-review-round1.test.ts` × 8 | 8/8 | R03 保持 68690f9 的 D-044 不变量断言 | +| round2–5 套件 × 3 | 3/3 | 含 SIGINT/竞争/20 组入口矩阵 | +| `round5-probes.mjs` 副本 | E01/E02×2/E03 不再复现 | 正向验收在仓库测试 | +| `material-matrix-check.mjs` 副本 | **16/16** | 两种顺序 | +| `project-entry-matrix-check.mjs` 副本 | **20/20** 有恢复,exit [11] | task_id 保留 20/20 | +| `round4-probes.mjs` 副本 | 0/3 复现;D03 signal_sent=true exit 130 | | | `round3-probes.mjs` 副本 | 0/6 复现(exit 1) | fs.watch C03 signal_sent=true, exit 130(本轮触发成功) | | `round2-probes.mjs` 副本 | 0/8 复现(exit 1) | | | `round1-probes.mjs` 副本 | 0/12 复现(exit 1) | | -| `verify-original-regressions.py` | **20/20** | reviewer 的正向核对脚本 | +| `verify-original-regressions.py` | **20/20** | reviewer 的两轮正向核对 | +| `verify-round3-4-regressions.py` | **8/8** | reviewer 的 R3+R4 正向核对(C03 以 D03 补证) | | `round4-context-checks.mjs` 副本 | **5/5** | make legacy/v1、stream legacy/v1 sidecar、standalone relink SIGINT | | `stream-finalization-check.mjs` 副本 | passed | 唯一 outcome、连续序号 | -| tarball smoke | **29/29** | `meshy-cli-0.3.0.tgz` sha256 `37c279550179ab1998278b92d828148858f1f2bf3dcc0247306813720e26f35c`,398 files,bins ['meshy', 'meshy-cli'] | +| tarball smoke | **29/29** | `meshy-cli-0.3.0.tgz` sha256 `f51d69236d36bec798c9b77004d4457cc69e0e47abe5a5dd31d4209d7a408c4d`,398 files,bins ['meshy', 'meshy-cli'] | ## 6. 真实环境验证 @@ -718,31 +527,32 @@ v1(exit 7): ## 7. 打包证据 -- `npm pack` → `meshy-cli-0.3.0.tgz`,sha256 `37c279550179ab1998278b92d828148858f1f2bf3dcc0247306813720e26f35c`,398 个文件;必需文件(package.json、dist/index.js、README.md、LICENSE、.env.example、skills/meshy-cli/SKILL.md)齐全 = true;禁止内容(src/、tests/、docs/、node_modules/、.env、pnpm-lock.yaml)不存在 = true。 +- `npm pack` → `meshy-cli-0.3.0.tgz`,sha256 `f51d69236d36bec798c9b77004d4457cc69e0e47abe5a5dd31d4209d7a408c4d`,398 个文件;必需文件齐全 = true;禁止内容不存在 = true。 - `npm install -g --prefix ` → bins ['meshy', 'meshy-cli'];无 Python 依赖。 - 未 `npm publish`,未推送。 ## 8. 需要 Codex 复审的事项 -1. R4-F01 的仲裁语义:身份证据(source_name / 落盘名无来源冲突 / source_stem)永不被否决;启发式命中只要有其它 distinct 引用(命中或 ambiguous 候选)争用同一贴图即否决;同一引用在不同 key 下落到不同贴图不重写。请确认这与 §8.3 的意图一致,特别是“身份命中 + 启发式竞争者”时保留身份命中、竞争者原文的选择。 -2. R4-F02 的 `project` 失败形状(`action: "failed"` + `error` + `recovery`,`error.recovery.action = "record_project"`)与预检范围;`index_dirty` 未被用于表达记账失败。 -3. R4-T01 覆盖是否满足长期保护要求;C06 的 make 段迁出(而非并存)是否可接受。 -4. 测试稳定性提交 `68690f9`(R03 “expiry during the sleep”):把 poll 数上限 2 改为 D-044 不变量断言的理由与证据(提交前 1/6 失败,提交后 8/8);产品逻辑未改。 -5. 旧 `round3-probes.mjs` 的 fs.watch C03 依赖 fs.watch 时机:本轮副本运行 signal_sent=true / exit 130(触发成功,与 D03 结论一致);稳定的中断证据为 D03、context 的 standalone-relink-interrupt 与仓库 C03(12 MB OBJ + 1 ms 轮询)。 +1. R5-F01:恢复命令追加解析后的绝对 `--workspace`(不是用户原始写法);无显式 workspace 时不追加。请确认这与“原写入边界”一致。 +2. R5-F02:跨 key 汇总中,某 key 的通道没有任何贴图(候选集为空)被视为“不提供证据”而不是“不一致”——例如 `map_Kd X` 命中 base color 而 `map_Ks X` 的 specular 通道无贴图时,map_Kd 仍改写、map_Ks 保持 unresolved。请确认该取舍(D-055)。 +3. R5-F03:越界(项目在请求期间离开 workspace 或变成符号链接)时 `recovery: null` 且不给命令,hint 回落到 `wait`;缺失/损坏/锁失败给 `record_project`。请确认边界情形不给任何命令的做法可接受。 +4. C06 的 make 段仍在 round-4 R4-T01(本轮按建议把 SIGINT 分支改为精确 `failed` manifest 断言)。 +5. 旧 `round3-probes.mjs` 的 fs.watch C03 本轮副本 signal_sent=true(触发);稳定中断证据为 D03、context 的 standalone-relink-interrupt 与仓库 C03。 ## 9. 最短复现步骤 ```bash -cd /Users/ark/Dev/meshy-cli && git checkout 68690f9 +cd /Users/ark/Dev/meshy-cli && git checkout 7b7c24c export FNM_DIR="$HOME/.local/share/fnm"; eval "$(fnm env --shell bash)"; fnm use 24 -pnpm install --frozen-lockfile && pnpm typecheck && pnpm test # 547/547 -node --import tsx --test tests/codex-review-round4.test.ts # D01, arbitration, D02, preflight, R4-T01 -mkdir -p /tmp/r4 && cp /Users/ark/Dev/meshy-agent-integrations-research/reviews/cli-s1-235d6de/{round4-probes.mjs,round4-context-checks.mjs} /tmp/r4/ -node /tmp/r4/round4-probes.mjs /Users/ark/Dev/meshy-cli # D01/D02 reproduced=false, D03 signal_sent=true exit 130 -node /tmp/r4/round4-context-checks.mjs /Users/ark/Dev/meshy-cli # 5/5 +pnpm install --frozen-lockfile && pnpm typecheck && pnpm test # 552/552 +node --import tsx --test tests/codex-review-round5.test.ts # E01, E02, matrix, E03, boundary +mkdir -p /tmp/r5 && cp /Users/ark/Dev/meshy-agent-integrations-research/reviews/cli-s1-68690f9/{round5-probes.mjs,material-matrix-check.mjs,project-entry-matrix-check.mjs} /tmp/r5/ +node /tmp/r5/round5-probes.mjs /Users/ark/Dev/meshy-cli # E01/E02/E03 reproduced=false +node /tmp/r5/material-matrix-check.mjs # 16/16 +node /tmp/r5/project-entry-matrix-check.mjs /Users/ark/Dev/meshy-cli # 20/20 record_recovery git diff --check fd94490 ``` ## 10. 交给 Codex 的复审提示词 -> 请对 `/Users/ark/Dev/meshy-cli` 分支 `feat/skill-parity-s1` 做 Meshy CLI S1 第 5 轮 review。规范:2026-09-07 v1 实施包。代码 HEAD `68690f9273ff20bbf95e6ac586766e30d977b6b4`(= 修复 `93e55bcc717ceb9a368ee43aeeeac69a3fb52197` + 测试稳定性提交);docs HEAD 为其后的 docs-only commit(`git log -1`)。上一轮(`…/reviews/cli-s1-235d6de/`)结论 changes_requested:R4-F01、R4-F02(P2)与 R4-T01(P3 测试缺口)。请核对:(1) `tests/codex-review-round4.test.ts` 是否按每项 acceptance 做了正向断言(D01 两种顺序、MTL 字节、method/note、bytes/hash、请求次数;D02 完整 v1 形状、exit 11、source/task_id、manifest 摘要、project 失败与 `record_project` 恢复、外部字节不变、恢复命令回放;预检 0 请求;R4-T01 与 journal 对账、POST GET POST GET GET);(2) `src/internal/material-links.ts` 的两遍解析/仲裁是否覆盖 §8.3 的其它组合(身份 + 启发式、ambiguous 竞争者、同一引用多 key、顺序互换),且 C05/N04/round-1 材质用例未回退;(3) `src/cmd/download.ts` 的 `preflightProject`/`projectRecordFailure` 与 `task-command.ts` `attachToProject` 的恢复信息是否在所有入口一致,`index_dirty` 语义未被滥用;(4) 测试稳定性提交 `68690f9` 是否只改测试且理由成立(D-044);(5) 前四轮 26 项 finding 是否保持通过(可从副本重跑 round1–4 探针、`verify-original-regressions.py`、`round4-context-checks.mjs`、`stream-finalization-check.mjs`;旧 fs.watch C03 未触发时不得计为通过)。请勿修改 reviewer 证据目录;禁止付费调用、发布、Skill/MCP 迁移。真实账号、Windows/Linux、真实切片器、UV/Creative Lab/showcases 仍为 not_run;G1-release 与 S2 不在本轮范围。 +> 请对 `/Users/ark/Dev/meshy-cli` 分支 `feat/skill-parity-s1` 做 Meshy CLI S1 第 6 轮 review。规范:2026-09-07 v1 实施包。代码 HEAD `7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`(= 修复 `7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`);docs HEAD 为其后的 docs-only commit(`git log -1`)。上一轮(`…/reviews/cli-s1-68690f9/`)结论 changes_requested:R5-F01–R5-F03(P2)。请核对:(1) `tests/codex-review-round5.test.ts` 是否按每项 acceptance 做了正向断言(E01 逐字回放不补参数、父 history 字节不变、index_dirty、边界外无锁/temp、含空格/引号 workspace;E02 两顺序 MTL 字节不变、各行候选与跨 key note、bytes/hash;8×2 矩阵;E03 20 组入口的 task_id/operation_id/请求次数对账与逐字回放;越界用例无命令);(2) `src/internal/material-links.ts` 两步仲裁是否覆盖 §8.3 的其它组合且 D01/C05/N04/round-1 未回退;(3) `src/internal/task-command.ts` `attachToProject` 与 `src/cmd/download.ts` 的项目阶段是否在所有入口一致,边界失败不给跨界命令、`index_dirty` 语义未被滥用;(4) 前五轮 29 项 finding 是否保持通过(从副本重跑 round1–5 探针、两个 verify 脚本、两个矩阵脚本、`round4-context-checks.mjs`、`stream-finalization-check.mjs`;旧 fs.watch C03 未触发时不得计为通过)。请勿修改 reviewer 证据目录;禁止付费调用、发布、Skill/MCP 迁移。真实账号、Windows/Linux、真实切片器、UV/Creative Lab/showcases 仍为 not_run;G1-release 与 S2 不在本轮范围。 diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index 0b81c64..37ac063 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -2,7 +2,7 @@ "schema_version": 1, "title": "Meshy CLI S1 capability matrix — implementation tracking", "date": "2026-09-08", - "status": "review round 4 (Codex, reviewed 9e43a77/235d6de): changes_requested → R4-F01, R4-F02 fixed and R4-T01 covered in 93e55bc (+ test stabilisation 68690f9); rounds 1–3 (23 findings) re-verified; G1-code re-review pending; G1-release not_run", + "status": "review round 5 (Codex, reviewed e7c0bbc/68690f9): changes_requested → R5-F01–R5-F03 fixed in 7b7c24c; rounds 1–4 (26 findings) re-verified; G1-code re-review pending; G1-release not_run", "baseline": { "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" @@ -20,7 +20,7 @@ "target": "meshy create --async", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "rounds 1–5 findings fixed; re-review pending", "test_ids": [ "T-040", "T-043", @@ -30,7 +30,8 @@ "evidence": [ "src/internal/task-command.ts submitOnce: journal → single POST → journal; tests/task-lifecycle.test.ts T-040/T-043/T-044", "tests/codex-review-round1.test.ts R05/F01, R06/F01", - "tests/codex-review-round3.test.ts C06 (legacy sync create keeps the accepted task)" + "tests/codex-review-round3.test.ts C06 (legacy sync create keeps the accepted task)", + "tests/codex-review-round5.test.ts E03 (create --async / sync create × missing/damaged metadata → record_project with --operation-id/--workspace; escaped project → boundary, no command)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -53,6 +54,16 @@ "tests": [ "tests/codex-review-round3.test.ts C06 (legacy sync create keeps the accepted task)" ] + }, + "review_round_5": { + "findings": [ + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E03 (create --async / sync create × missing/damaged metadata → record_project with --operation-id/--workspace; escaped project → boundary, no command)" + ] } }, { @@ -66,7 +77,7 @@ "target": "meshy get --include-raw --save-json", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "rounds 1–5 findings fixed; re-review pending", "test_ids": [ "T-006", "T-008", @@ -76,7 +87,8 @@ "task-view.ts + task-command get: any status exit 0, --include-raw, --save-json; tests T-006/T-008/T-009/T-010", "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)", "tests/codex-review-round2.test.ts N01 (report-only get -o), N06 (partial manifest), N08 (SIGINT)", - "tests/codex-review-round3.test.ts C01+C02 (get -o sidecar race / finalisation manifest), C03 (relink interrupt), C06 (legacy get)" + "tests/codex-review-round3.test.ts C01+C02 (get -o sidecar race / finalisation manifest), C03 (relink interrupt), C06 (legacy get)", + "tests/codex-review-round5.test.ts E03 (get)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -115,6 +127,16 @@ "tests": [ "tests/codex-review-round3.test.ts C01+C02 (get -o sidecar race / finalisation manifest), C03 (relink interrupt), C06 (legacy get)" ] + }, + "review_round_5": { + "findings": [ + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E03 (get)" + ] } }, { @@ -128,7 +150,7 @@ "target": "meshy wait", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "rounds 1–5 findings fixed; re-review pending", "test_ids": [ "T-007", "T-047", @@ -140,7 +162,8 @@ "tests/poll.test.ts deadline tests", "tests/codex-review-round2.test.ts N01/N06/N08 (wait -o)", "tests/poll.test.ts deterministic deadline tests", - "tests/codex-review-round3.test.ts C06 (legacy wait -o)" + "tests/codex-review-round3.test.ts C06 (legacy wait -o)", + "tests/codex-review-round5.test.ts E03 (wait)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -179,6 +202,16 @@ "tests": [ "tests/codex-review-round3.test.ts C06 (legacy wait -o)" ] + }, + "review_round_5": { + "findings": [ + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E03 (wait)" + ] } }, { @@ -192,7 +225,7 @@ "target": "meshy stream", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "rounds 1–5 findings fixed; re-review pending", "test_ids": [ "T-049", "T-050", @@ -205,7 +238,8 @@ "stream.ts SSE parser + streamTask; task-command stream (ndjson events + outcome, json envelope); tests/sse.test.ts, T-053", "tests/codex-review-round1.test.ts R10/F10", "tests/codex-review-round2.test.ts N01 (stream -o), N05 (single outcome on post-stream failures)", - "tests/codex-review-round3.test.ts C06 (legacy stream path shares emitLegacyOutcome wrapping)" + "tests/codex-review-round3.test.ts C06 (legacy stream path shares emitLegacyOutcome wrapping)", + "tests/codex-review-round5.test.ts E03 (stream)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -241,6 +275,16 @@ "tests": [ "tests/codex-review-round3.test.ts C06 (legacy stream path shares emitLegacyOutcome wrapping)" ] + }, + "review_round_5": { + "findings": [ + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E03 (stream)" + ] } }, { @@ -280,7 +324,7 @@ "target": "existing commands with explicit supported payload", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-028", "T-033", @@ -314,7 +358,7 @@ "target": "typed media flags and final payload normalization", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-029", "T-030", @@ -399,7 +443,7 @@ "target": "meshy creative-lab lamp prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-022", "T-023", @@ -438,7 +482,7 @@ "target": "meshy creative-lab keychain prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-022", "T-023", @@ -475,7 +519,7 @@ "target": "meshy creative-lab fridge-magnet prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-022", "T-023", @@ -561,7 +605,7 @@ "target": "download asset registry with role and dependencies", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–4 findings fixed; re-review pending", + "review_status": "rounds 1–5 findings fixed; re-review pending", "test_ids": [ "T-060", "T-062", @@ -572,7 +616,8 @@ "tests/codex-review-round1.test.ts R07/F09", "tests/codex-review-round2.test.ts N04 (source-name mapping, ambiguity)", "tests/codex-review-round3.test.ts C05 (source identity vs generated names)", - "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases", + "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -615,6 +660,16 @@ "tests": [ "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" ] + }, + "review_round_5": { + "findings": [ + "R5-F02" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" + ] } }, { @@ -628,7 +683,7 @@ "target": "meshy download --asset/--kind/--model-format", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–4 findings fixed; re-review pending", + "review_status": "rounds 1–5 findings fixed; re-review pending", "test_ids": [ "T-061", "T-063" @@ -638,7 +693,8 @@ "tests/codex-review-round1.test.ts R07/F09 (--geometry-only keeps the reference, warns)", "tests/codex-review-round2.test.ts N04", "tests/codex-review-round3.test.ts C05", - "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" + "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases", + "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" ], "intentional_differences": [ "download requires an explicit selector when a task exposes several assets (legacy defaulted to glb); legacy -o keeps downloading everything" @@ -683,6 +739,16 @@ "tests": [ "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases" ] + }, + "review_round_5": { + "findings": [ + "R5-F02" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" + ] } }, { @@ -696,7 +762,7 @@ "target": "safe download with actual final-path manifest", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–4 findings fixed; re-review pending", + "review_status": "rounds 1–5 findings fixed; re-review pending", "test_ids": [ "T-064", "T-065", @@ -711,7 +777,8 @@ "tests/codex-review-round1.test.ts R07/F09, R09/F03", "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08", "tests/codex-review-round3.test.ts C01+C02, C03, C05", - "tests/codex-review-round4.test.ts D01, D02 (symlink/damaged/unwritable + recovery replay), preflight" + "tests/codex-review-round4.test.ts D01, D02 (symlink/damaged/unwritable + recovery replay), preflight", + "tests/codex-review-round5.test.ts E01 (verbatim replay keeps --workspace), E02" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -762,6 +829,17 @@ "tests": [ "tests/codex-review-round4.test.ts D01, D02 (symlink/damaged/unwritable + recovery replay), preflight" ] + }, + "review_round_5": { + "findings": [ + "R5-F01", + "R5-F02" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E01 (verbatim replay keeps --workspace), E02" + ] } }, { @@ -775,7 +853,7 @@ "target": "--save-json and project task snapshots", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-010", "T-063" @@ -819,7 +897,7 @@ "target": "meshy project init", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-072", "T-077" @@ -852,7 +930,7 @@ "target": "meshy project record/show", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "rounds 1–4 findings fixed; re-review pending", + "review_status": "rounds 1–5 findings fixed; re-review pending", "test_ids": [ "T-073", "T-074", @@ -863,7 +941,8 @@ "tests/codex-review-round1.test.ts R08/F03", "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)", "tests/codex-review-round3.test.ts C04 (aliased project records files)", - "tests/codex-review-round4.test.ts D02 + preflight; project.action=failed with record_project recovery" + "tests/codex-review-round4.test.ts D02 + preflight; project.action=failed with record_project recovery", + "tests/codex-review-round5.test.ts E01, E03, boundary case; codex-review-round4 D02 verbatim replay" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -906,6 +985,17 @@ "tests": [ "tests/codex-review-round4.test.ts D02 + preflight; project.action=failed with record_project recovery" ] + }, + "review_round_5": { + "findings": [ + "R5-F01", + "R5-F03" + ], + "status": "fixed", + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "tests": [ + "tests/codex-review-round5.test.ts E01, E03, boundary case; codex-review-round4 D02 verbatim replay" + ] } }, { @@ -919,7 +1009,7 @@ "target": "meshy project list/rebuild-index", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-073", "T-075", @@ -991,7 +1081,7 @@ "target": "meshy mesh prepare-print", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-083", "T-084", @@ -1101,7 +1191,7 @@ "target": "--env-file without automatic dotenv/shell scanning", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-100", "T-101" @@ -1136,7 +1226,7 @@ "target": "preserve existing auth commands and token protocol", "baseline_status": "existing_needs_regression", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-104", "T-105" @@ -1182,7 +1272,7 @@ "target": "--output-schema v1; legacy compatibility", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-001", "T-002", @@ -1232,7 +1322,7 @@ "target": "make --async / --stop-after-first", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–4 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-041", "T-042" @@ -1302,7 +1392,7 @@ "target": "operation journal; never claim server idempotency", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 68690f9); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", "test_ids": [ "T-043", "T-044", @@ -1453,8 +1543,8 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-08T02:25:00.312725Z", - "code_head": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "updated_at": "2026-09-08T03:10:09.966960Z", + "code_head": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "review_rounds": [ { @@ -1532,6 +1622,22 @@ "code_head": "68690f9273ff20bbf95e6ac586766e30d977b6b4", "regression_tests": "tests/codex-review-round4.test.ts (D01 both orders + arbitration cases, D02 three failure modes + recovery replay, preflight, R4-T01 make identity); C06 keeps create/wait/get; R03 asserts D-044 invariants", "probe_rerun": "reviews/cli-s1-235d6de/round4-probes.mjs copy on 68690f9: D01/D02 no longer reproduce, D03 positive (signal_sent=true, exit 130); round3 0/6 (fs.watch C03 trigger missed, not counted), round2 0/8, round1 0/12; verify-original-regressions.py 20/20; round4-context-checks 5/5; stream check passed" + }, + { + "round": 5, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-68690f9", + "reviewed_head": "e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59", + "verdict": "changes_requested", + "findings": [ + "R5-F01", + "R5-F02", + "R5-F03" + ], + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "code_head": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "regression_tests": "tests/codex-review-round5.test.ts (E01 verbatim replay with --workspace, E02 both orders + 8x2 matrix, E03 20-entry matrix + verbatim replay, escaped-project boundary); round4 D02 replay verbatim; R4-T01 SIGINT manifest exact", + "probe_rerun": "reviews/cli-s1-68690f9 copies on 7b7c24c: round5 E01/E02x2/E03 no longer reproduce; material matrix 16/16; project entries 20/20 record_recovery; round4 0/3 (D03 positive), round3 0/6, round2 0/8, round1 0/12; verify-original-regressions 20/20; verify-round3-4 8/8; context 5/5; stream passed" } ] } diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json index 93757a3..7a2bc2b 100644 --- a/docs/skill-parity/verification.json +++ b/docs/skill-parity/verification.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "status": "review round 4 fixes applied (Codex reviewed 9e43a77/235d6de: changes_requested, 2 P2 code findings + 1 P3 test gap); re-review pending; live checks not_run", + "status": "review round 5 fixes applied (Codex reviewed e7c0bbc/68690f9: changes_requested, 3 P2 code findings); re-review pending; live checks not_run", "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", "environment": { "os": "macOS 26.6 (Darwin 25.6.0) arm64", @@ -43,108 +43,108 @@ "final_runs": [ { "command": "node --version", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm --version", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 0, + "duration_s": 1, "result": "passed" }, { "command": "pnpm install --frozen-lockfile", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { "command": "pnpm typecheck", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm build", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 0, + "duration_s": 1, "result": "passed" }, { "command": "pnpm test", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 41, + "duration_s": 42, "result": "passed", - "tests": 547, - "pass": 547, + "tests": 552, + "pass": 552, "fail": 0, "skipped": 0 }, { "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 0, + "duration_s": 1, "result": "passed" }, { "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 6, + "duration_s": 7, "result": "passed" }, { "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/meshy-cli-0.3.0.tgz", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 6, + "duration_s": 7, "result": "passed" }, { "command": "git diff --check fd94490", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "git diff --check", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "for i in 1..12: node --import tsx --test tests/poll.test.ts", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 15, + "duration_s": 14, "result": "passed", "detail": "12/12 runs" }, { "command": "for i in 1..8: node --import tsx --test tests/codex-review-round1.test.ts", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 49, + "duration_s": 50, "result": "passed", - "detail": "8/8 runs; the R03 'expiry during the sleep' check now asserts the D-044 invariants (it failed 1/6 runs on 93e55bc before the test-only commit 68690f9)" + "detail": "8/8 runs; R03 asserts the D-044 invariants since 68690f9" }, { - "command": "for i in 1..3: node --import tsx --test tests/codex-review-round4.test.ts tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "command": "for i in 1..3: node --import tsx --test tests/codex-review-round5.test.ts tests/codex-review-round4.test.ts tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, - "duration_s": 17, + "duration_s": 23, "result": "passed", - "detail": "3/3 runs (SIGINT-during-relink, sidecar race, project-record race, make SIGINT and other timing-sensitive cases)" + "detail": "3/3 runs (SIGINT-during-relink, sidecar race, project-record races incl. the 20-entry recovery matrix, make SIGINT and other timing-sensitive cases)" } ], "behavior_tests": { @@ -153,7 +153,7 @@ "files": [ "tests/cli-contract.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-002": { "result": "passed", @@ -161,49 +161,49 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-003": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-004": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-005": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-006": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-007": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-008": { "result": "passed", "files": [ "tests/task-view.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-009": { "result": "passed", @@ -212,13 +212,15 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", - "tests/codex-review-round4.test.ts" + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F10: R10 stream -o in ndjson/json/pretty; one outcome on download failure", "review_round_2": "R2-F01 N01 report-only get/wait/stream -o confined; R2-F04 N06 partial manifest; R2-F05 N08 SIGINT", "review_round_3": "R3-F02 C06: legacy get/wait/create/make -o failures name the task (task_id, operation_id, next, manifest, hint)", - "review_round_4": "R4-T01: make's task_id, submission.operation_id, executed[-1].operation_id and the legacy top-level operation_id are the last accepted journal record's (distinct step ids; legacy/v1 × asset 503/SIGINT)" + "review_round_4": "R4-T01: make's task_id, submission.operation_id, executed[-1].operation_id and the legacy top-level operation_id are the last accepted journal record's (distinct step ids; legacy/v1 × asset 503/SIGINT)", + "review_round_5": "R5-F03 E03: legacy/v1 × get/wait/stream/create --async/sync × metadata missing/damaged → exit 11 with task_id, submission.operation_id = the single accepted journal record, record_project command with --operation-id and --workspace (legacy hint too)" }, "T-010": { "result": "passed", @@ -228,7 +230,7 @@ "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F01: R05 save-json preflight + post-POST failure keeps the id", "review_round_2": "R2-F04 N06: task -o failure keeps HTTP class/status and the files already written", "review_round_3": "R3-F01/R3-F04 C01+C02: sidecar planted after the preflight refused; committed model kept in the manifest with failed_step" @@ -239,28 +241,28 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-012": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-020": { "result": "passed", "files": [ "tests/resource-registry.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-021": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-022": { "result": "passed", @@ -268,7 +270,7 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-023": { "result": "passed", @@ -276,14 +278,14 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-024": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-025": { "result": "passed", @@ -291,7 +293,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F08: R04 three-layer options merge on the real POST body" }, "T-026": { @@ -299,14 +301,14 @@ "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-027": { "result": "passed", "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-028": { "result": "passed", @@ -314,7 +316,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F08: R04" }, "T-029": { @@ -323,7 +325,7 @@ "tests/file-input.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F06: R02 equal-length images conflict, re-wrapped base64 replays, no base64 in journal" }, "T-030": { @@ -331,28 +333,28 @@ "files": [ "tests/file-input.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-031": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-032": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-040": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-041": { "result": "passed", @@ -360,7 +362,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F02: R11 make journal failure after acceptance is local_io with the id (text + image routes)" }, "T-042": { @@ -369,7 +371,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F02: R11" }, "T-043": { @@ -378,14 +380,14 @@ "tests/operation-store.test.ts", "tests/task-lifecycle.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-044": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-045": { "result": "passed", @@ -393,12 +395,14 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts", "tests/codex-review-round3.test.ts", - "tests/codex-review-round4.test.ts" + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F01: R06 polling 5xx keeps id/submission/next; make polling failure keeps executed steps", "review_round_3": "R3-F02 C06: legacy sync create keeps the accepted task on an asset 503 with exactly one POST", - "review_round_4": "R4-T01: two journal records, no re-submission; POST GET POST GET GET; refine.preview_task_id = step 1" + "review_round_4": "R4-T01: two journal records, no re-submission; POST GET POST GET GET; refine.preview_task_id = step 1", + "review_round_5": "R5-F03 E03: exactly one POST / one journal record per create; the recovery never re-submits; a project that escaped the workspace → boundary message, task + operation named, recovery null" }, "T-046": { "result": "passed", @@ -407,7 +411,7 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "barrier-released three-process race; F05/F06 fingerprint unit tests" }, "T-047": { @@ -417,7 +421,7 @@ "tests/codex-review-round1.test.ts", "tests/poll.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F07: deadline bounds the sleep; --timeout 0 request cap", "review_round_2": "R2-F07: deterministic fake-clock deadline tests; real-timer smoke asserts only no GET after the deadline", "review_round_4": "R03 'expiry during the sleep' asserts the D-044 invariants (test-only commit 68690f9); 8/8 standalone runs" @@ -431,7 +435,7 @@ "tests/codex-review-round3.test.ts", "tests/codex-review-round4.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F01: task context on every post-acceptance failure", "review_round_2": "R2-F05 N08: SIGINT before headers / mid-body / on the second asset → 130 with manifest, no leftovers", "review_round_3": "R3-F05 C03: SIGINT during the OBJ rewrite → 130, digests re-taken, no sidecar, no temp file; make interrupt", @@ -442,28 +446,28 @@ "files": [ "tests/sse.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-050": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-051": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-052": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-053": { "result": "passed", @@ -473,7 +477,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F10: R10", "review_round_2": "R2-F03 N05: save-json/project/download failures after the stream end in one outcome with the next sequence" }, @@ -482,7 +486,7 @@ "files": [ "tests/artifacts.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-061": { "result": "passed", @@ -490,7 +494,7 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-062": { "result": "passed", @@ -500,20 +504,22 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", - "tests/codex-review-round4.test.ts" + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F09: R07 rewritten mtllib/map_* references verified on disk; unresolved map reported", "review_round_2": "R2-F02 N04: multi-material textures mapped by source name; ambiguity kept + warned (v1 and legacy -o)", "review_round_3": "R3-F03 C05: source name beats a colliding generated name (verified by image bytes); generated-name reference with another source is ambiguous", - "review_round_4": "R4-F01 D01: two references reaching the sole texture only through channel fallbacks stay as written in both orders (MTL byte-identical, one shared note, digests match disk); arbitration unit cases (identity vs heuristic, ambiguous rival, one reference/two keys, lone and distinct-channel fallbacks)" + "review_round_4": "R4-F01 D01: two references reaching the sole texture only through channel fallbacks stay as written in both orders (MTL byte-identical, one shared note, digests match disk); arbitration unit cases (identity vs heuristic, ambiguous rival, one reference/two keys, lone and distinct-channel fallbacks)", + "review_round_5": "R5-F02 E02: one reference under two keys (hit + ambiguous) stays as written in both orders; 8x2 arbitration matrix (identity, ambiguous rival, same-ref two hits / hit+ambiguity / same identity / same fallback, distinct channels) as a repository test" }, "T-063": { "result": "passed", "files": [ "tests/artifacts.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-064": { "result": "passed", @@ -521,26 +527,28 @@ "tests/download-command.test.ts", "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", - "tests/codex-review-round4.test.ts" + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_2": "R2-F01 N03: refused download creates no directory; zero requests", "review_round_3": "R3-F01 C01: sidecar publication re-proves the root and refuses symlink/file/directory targets", - "review_round_4": "R4-F02 D02: project record failure after the transfer keeps the full result and a record_project recovery; preflight refuses symlink/damaged metadata.json and blank --stage before any request" + "review_round_4": "R4-F02 D02: project record failure after the transfer keeps the full result and a record_project recovery; preflight refuses symlink/damaged metadata.json and blank --stage before any request", + "review_round_5": "R5-F01 E01: the record_project command carries the original --workspace (quoted path with a space and a quote); replayed verbatim it records with 0 requests, parent history.json bytes unchanged, index_dirty; no workspace → none appended" }, "T-066": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-067": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-068": { "result": "passed", @@ -549,27 +557,29 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", - "tests/codex-review-round4.test.ts" + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F03/F04: R08 project roots, R09 task -o, R12 dependency symlink escapes", "review_round_2": "R2-F01 N01/N03: outside/symlinked targets refused before mkdir for report tasks and downloads", "review_round_3": "R3-F01 C01: outside file bytes unchanged across the sidecar race, single-file and legacy variants", - "review_round_4": "R4-F02 D02: outside metadata bytes and the original metadata stay unchanged; planted symlink not replaced; the recovery command records exactly the downloaded file with no request" + "review_round_4": "R4-F02 D02: outside metadata bytes and the original metadata stay unchanged; planted symlink not replaced; the recovery command records exactly the downloaded file with no request", + "review_round_5": "R5-F01 E01: nothing appears in the parent directory during the replay (no lock, no temp); outside metadata untouched" }, "T-069": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-070": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-071": { "result": "passed", @@ -577,7 +587,7 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-072": { "result": "passed", @@ -586,34 +596,36 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", - "tests/codex-review-round4.test.ts" + "tests/codex-review-round4.test.ts", + "tests/codex-review-round5.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F03: R08 project init/record/rebuild-index confined to --workspace", "review_round_2": "R2-F01 N02: implicit history root confined; metadata recorded, parent untouched", "review_round_3": "R3-F06 C04: aliased project directory records its files; truly outside files still not recorded", - "review_round_4": "R4-F02 D02: project.action=failed with error + recovery; damaged metadata.json is left for repair; unwritable project directory (lock cannot be created) is local_io with the result; healthy project still records" + "review_round_4": "R4-F02 D02: project.action=failed with error + recovery; damaged metadata.json is left for repair; unwritable project directory (lock cannot be created) is local_io with the result; healthy project still records", + "review_round_5": "R5-F01/R5-F03: index_dirty only ever means metadata committed + history untouched; verbatim replay across download and the 20 task-verb entries; a project that left the workspace gets no command" }, "T-073": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-074": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-075": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-076": { "result": "passed", @@ -621,7 +633,7 @@ "tests/project-store.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_2": "R2-F01 N02: index skipped with index.updated=false + reason when the root is outside --workspace" }, "T-077": { @@ -629,49 +641,49 @@ "files": [ "tests/project-store.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-080": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-081": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-082": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-083": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-084": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-085": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-086": { "result": "passed", @@ -679,7 +691,7 @@ "tests/obj-transform.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F04: R12 symlinked materials/ and texture dir refused; real dirs copied" }, "T-087": { @@ -687,35 +699,35 @@ "files": [ "tests/obj-transform.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-088": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-089": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-090": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-091": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-100": { "result": "passed", @@ -725,7 +737,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F05: R01 API key digest binding", "review_round_2": "R2-F06 N07: OAuth without user_id/login_id refused a replay; login_id binds; refresh keeps it" }, @@ -734,7 +746,7 @@ "files": [ "tests/env-file.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-102": { "result": "passed", @@ -742,14 +754,14 @@ "tests/cli-contract.test.ts", "tests/doctor.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-103": { "result": "passed", "files": [ "tests/doctor.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" }, "T-105": { "result": "passed", @@ -758,7 +770,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_1": "F05: R01 OAuth subject binding (token rotation keeps identity)", "review_round_2": "R2-F06 N07" }, @@ -767,7 +779,7 @@ "files": [ "tests/auth-headless.test.ts" ], - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "review_round_2": "R2-F06: a device-flow login mints login_id (offline E2E stub)" } }, @@ -782,13 +794,13 @@ "id": "T-109", "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "partial", - "detail": "macOS 26.6 arm64, Node v24.20.0: tarball install + 29 smoke checks passed on 68690f9 (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" + "detail": "macOS 26.6 arm64, Node v24.20.0: tarball install + 29 smoke checks passed on 7b7c24c (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" }, { "id": "T-110", "scope": "real free queries, existing task get, asset download", "result": "partial", - "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions, free) on 68690f9: exit 0; public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157. Authenticated get/download: not_run (no credential)" + "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions, free) on 7b7c24c: exit 0; public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157. Authenticated get/download: not_run (no credential)" }, { "id": "T-111", @@ -803,12 +815,12 @@ "reason": "no slicer application installed on this host (slicer detect returned an empty list)" } ], - "code_head": "68690f9273ff20bbf95e6ac586766e30d977b6b4", - "recorded_at": "2026-09-08T02:25:00.312725Z", - "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1/2/3/4 notes name the Codex finding and probe the file now covers.", + "code_head": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "recorded_at": "2026-09-08T03:10:09.966960Z", + "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1/2/3/4/5 notes name the Codex finding and probe the file now covers.", "package_smoke": { "tarball": "meshy-cli-0.3.0.tgz", - "sha256": "37c279550179ab1998278b92d828148858f1f2bf3dcc0247306813720e26f35c", + "sha256": "f51d69236d36bec798c9b77004d4457cc69e0e47abe5a5dd31d4209d7a408c4d", "file_count": 398, "required_present": true, "forbidden_absent": true, @@ -822,7 +834,7 @@ { "id": "version", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --version", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -831,7 +843,7 @@ { "id": "version_cli", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --version", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -840,7 +852,7 @@ { "id": "help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --help", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -849,7 +861,7 @@ { "id": "help_cli", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --help", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -858,7 +870,7 @@ { "id": "balance_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --help", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -867,7 +879,7 @@ { "id": "balance_nokey_json", "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -876,7 +888,7 @@ { "id": "balance_nokey_v1", "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -885,7 +897,7 @@ { "id": "resources_v1", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy resources --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -894,7 +906,7 @@ { "id": "doctor", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -903,7 +915,7 @@ { "id": "doctor_check_slicers", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-slicers --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -912,7 +924,7 @@ { "id": "doctor_check_api_nokey", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-api --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -921,7 +933,7 @@ { "id": "slicer_detect", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy slicer detect --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -930,7 +942,7 @@ { "id": "inspect_pass", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -939,7 +951,7 @@ { "id": "inspect_fail", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 12, "expected_exit": 12, "result": "passed", @@ -948,7 +960,7 @@ { "id": "inspect_unknown", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-unknown-faces.json --max-faces 300000 --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 13, "expected_exit": 13, "result": "passed", @@ -957,7 +969,7 @@ { "id": "mesh_prepare", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -966,7 +978,7 @@ { "id": "mesh_refuse_overwrite", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 11, "expected_exit": 11, "result": "passed", @@ -975,7 +987,7 @@ { "id": "project_init", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -983,8 +995,8 @@ }, { "id": "project_record", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_102423_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_110936_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -992,8 +1004,8 @@ }, { "id": "project_show", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_102423_smoke-demo_fixture- --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_110936_smoke-demo_fixture- --output-schema v1", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1002,7 +1014,7 @@ { "id": "project_list", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1011,7 +1023,7 @@ { "id": "download_list", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1020,7 +1032,7 @@ { "id": "download_no_selector", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -1029,7 +1041,7 @@ { "id": "make_dry_run", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1038,7 +1050,7 @@ { "id": "usage_unknown_flag", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --bogus --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -1047,7 +1059,7 @@ { "id": "uv_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy uv-unwrap create --help", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1056,7 +1068,7 @@ { "id": "creative_lab_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy creative-lab lamp build create --help", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1065,7 +1077,7 @@ { "id": "stream_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy text-to-3d stream --help", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1074,7 +1086,7 @@ { "id": "catalog_live", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", - "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1577,6 +1589,297 @@ "passed": true, "sha": "68690f9273ff20bbf95e6ac586766e30d977b6b4" } + }, + { + "round": 5, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-68690f9", + "reviewed_head": "e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59", + "reviewed_code_head": "68690f9273ff20bbf95e6ac586766e30d977b6b4", + "verdict": "changes_requested (G1-code not_accepted; G1-release and S2 out of scope)", + "findings": 3, + "priorities": { + "P2": 3 + }, + "fixed_in": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "regression_tests": [ + "tests/codex-review-round5.test.ts (E01 verbatim replay with --workspace incl. a quoted path; E02 both orders + 8x2 arbitration matrix; E03 20-entry matrix with verbatim replay; escaped-project boundary case)", + "tests/codex-review-round4.test.ts (D02 replay now verbatim; R4-T01 SIGINT manifest asserted as failed + [model_glb failed])" + ], + "reviewer_test_result_before_fix": "pnpm test 547/547; 20/20 round 1+2 positive; 8/8 round 3+4 positive; 5/5 context checks; material matrix 14/16; project entries damaged 10/10 / missing 0/10 recovery; E01/E02x2/E03 reproduced", + "probe_rerun": { + "script": "reviews/cli-s1-68690f9/round5-probes.mjs (byte-identical copy in a scratch directory; the reviewer's evidence directories are untouched)", + "node": "v24.20.0", + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "reproduced_after_fix": 0, + "total": 4, + "note": "E01: history unchanged + index_dirty on verbatim replay; E02 both orders: MTL unchanged; E03: record_project present. reproduced=false is not the acceptance — positive assertions are in tests/codex-review-round5.test.ts", + "cases": [ + { + "id": "E01-recovery-drops-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "E02-mixed-key-hit-and-ambiguity-kd-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "E02-mixed-key-hit-and-ambiguity-bump-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "E03-task-project-resolution-outside-recovery-wrapper", + "reproduced": false, + "exit_code": 11 + } + ] + }, + "material_matrix": { + "script": "material-matrix-check.mjs copy", + "passed": 16, + "total": 16, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + }, + "project_entry_matrix": { + "script": "project-entry-matrix-check.mjs copy", + "record_recovery": 20, + "total": 20, + "exit_codes": [ + 11 + ], + "task_ids_preserved": 20, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + }, + "older_probe_reruns": { + "round4": { + "script": "round4-probes.mjs copy", + "reproduced": 0, + "total": 3, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "cases": [ + { + "id": "D01-channel-fallback-collapses-different-references", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "D02-project-finalization-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "D03-stable-relink-interrupt-positive", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + } + ] + }, + "round3": { + "script": "round3-probes.mjs copy", + "reproduced": 0, + "total": 6, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "note": "original fs.watch C03: signal_sent=True, exit 130 — its trigger fired in this run; the stable D03 probe (signal_sent=True, exit 130), round4-context standalone-relink-interrupt and the repo C03 test are the primary evidence", + "cases": [ + { + "id": "C01-sidecar-follows-new-symlink-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C02-sidecar-failure-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C03-sigint-during-relink-still-succeeds", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + }, + { + "id": "C04-project-alias-drops-asset-record", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C05-saved-name-match-overrides-source-identity", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C06-legacy-create-download-error-loses-task-id", + "reproduced": false, + "exit_code": 7 + } + ] + }, + "round2": { + "script": "round2-probes.mjs copy", + "reproduced": 0, + "total": 8, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "cases": [ + { + "id": "N01-report-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N02-project-index-outside-workspace", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N03-rejected-download-creates-outside-directory", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N04-multiple-textures-collapse-to-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N05-stream-save-error-has-no-outcome-event", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N06-task-download-partial-files-lost", + "reproduced": false, + "exit_code": 7 + }, + { + "id": "N07-oauth-without-subject-replays-other-token", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "N08-task-download-ignores-sigint", + "reproduced": false, + "exit_code": 130 + } + ] + }, + "round1": { + "script": "round1-probes.mjs copy", + "reproduced": 0, + "total": 12, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "cases": [ + { + "id": "R01-credential-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R02-media-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R03-wait-deadline", + "reproduced": false, + "exit_code": 8 + }, + { + "id": "R04-options-shallow-merge", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R05-post-save-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R06-post-poll-lost-id", + "reproduced": false, + "exit_code": 1 + }, + { + "id": "R07-obj-broken-material", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R08-project-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R09-task-output-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R10-stream-output-ignored", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R11-make-journal-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R12-obj-dependency-symlink", + "reproduced": false, + "exit_code": 11 + } + ] + } + }, + "reviewer_positive_checkers": { + "verify_original_regressions": { + "passed": 20, + "total": 20, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + }, + "verify_round3_4_regressions": { + "passed": 8, + "total": 8, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + } + }, + "context_checks": { + "script": "round4-context-checks.mjs copy", + "passed": 5, + "total": 5, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "checks": [ + { + "id": "make-legacy-last-operation-identity", + "passed": true + }, + { + "id": "make-v1-last-operation-identity", + "passed": true + }, + { + "id": "stream-legacy-sidecar-failure-context", + "passed": true + }, + { + "id": "stream-v1-sidecar-failure-context", + "passed": true + }, + { + "id": "standalone-relink-interrupt", + "passed": true + } + ] + }, + "stream_finalization_check": { + "script": "stream-finalization-check.mjs copy", + "passed": true, + "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + } } ] } From e567646d875e5f5a658b78e60b8cdfaed8b233e9 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 11:42:29 +0800 Subject: [PATCH 22/26] fix(review): address Codex review round 6 findings R6-F01, R6-F02 and test gap R6-T01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6-F02 paths / runtime / every write path: the write boundary is an AuthorisedRoot frozen when the global flags are read — the real path plus the device/inode identity of the directory behind --workspace — and resolveWithinRoot given a frozen root never resolves it again: it first proves that the same directory is still at that path (a symlink or another directory there is refused: "changed since the command started"), then that the target's real path lies inside it. The frozen root flows through --save-json, -o downloads (downloadArtifacts / downloadAssets, sidecar, report-only), make, mesh prepare-print, the project commands and the task verbs' project attachment, which freezes the project directory itself before the first request when no workspace is given and writes through the proven real path. A workspace (or the alias it was given through) redirected while a request is in flight is a boundary failure: exit 11, task and accepted journal kept, single POST, nothing written outside, recovery null. Stable aliases (/var, symlinked parents, a workspace given through a symlink that stays put) pass. R6-F01 download --project: before any project lock, snapshot or metadata write the project is resolved against the frozen boundary; a project (or a parent of it) replaced by a symlink to an outside tree during the transfer is a boundary failure — exit 11 / local_io, the completed manifest and assets kept, project.action = "failed" with the reason, recovery null, nothing disguised as index_dirty — distinct from a repairable metadata problem, which keeps the record_project recovery. Records are written through the real path. R6-T01 tests: E03 asserts the exact method/path sequence for all 20 entries; E02 binds each line's candidate set to its key. New codex-review-round6: download × task-json/API × project leaf/parent swapped for an outside symlink (outside tree byte-identical, exact requests, healthy control); legacy/v1 × get/wait/stream/create-async/create-sync × workspace directory swapped or alias re-pointed (exact requests, journal, outside and original trees byte-identical, no record command); stable-alias control. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/skill-parity/decisions.md | 45 ++++ docs/skill-parity/migration-notes.md | 8 + skills/meshy-cli/SKILL.md | 6 +- src/cmd/animation-catalog.ts | 2 +- src/cmd/api.ts | 2 +- src/cmd/balance.ts | 2 +- src/cmd/doctor.ts | 2 +- src/cmd/download.ts | 72 +++++- src/cmd/inspect.ts | 2 +- src/cmd/make.ts | 4 +- src/cmd/mesh.ts | 2 +- src/cmd/project.ts | 14 +- src/cmd/showcases.ts | 2 +- src/internal/command-helpers.ts | 6 +- src/internal/download.ts | 56 ++--- src/internal/obj-transform.ts | 12 +- src/internal/paths.ts | 99 ++++++++- src/internal/project-store.ts | 7 +- src/internal/runtime.ts | 4 + src/internal/task-command.ts | 86 +++++--- tests/codex-review-round5.test.ts | 22 +- tests/codex-review-round6.test.ts | 313 +++++++++++++++++++++++++++ 23 files changed, 665 insertions(+), 105 deletions(-) create mode 100644 tests/codex-review-round6.test.ts diff --git a/README.md b/README.md index 646fb52..bd530e4 100644 --- a/README.md +++ b/README.md @@ -467,7 +467,7 @@ message before any task is created. GLB-only fields (`uv-unwrap`, `rigging` | `--output-schema legacy\|v1` | Stdout data model (existing commands default to `legacy`; new commands are `v1`) | | `--format json\|pretty\|ndjson` | Stdout rendering (default `json`) | | `-o, --output ` | Download artifacts to a file/directory (task commands); output file for `mesh prepare-print` | -| `--workspace ` | Confine every written file to this directory: `download`, `-o` on task verbs and `make` (report-only tasks included), `--save-json`, `--project`/project folders and the history index (skipped with `index_dirty` when its root would fall outside), `mesh prepare-print` outputs and their copied materials — checked on real paths before anything, even a directory, is created | +| `--workspace ` | Confine every written file to this directory: `download`, `-o` on task verbs and `make` (report-only tasks included), `--save-json`, `--project`/project folders and the history index (skipped with `index_dirty` when its root would fall outside), `mesh prepare-print` outputs and their copied materials — checked on real paths before anything, even a directory, is created. The boundary is frozen when the command starts (real path and directory identity): a workspace or project replaced by a symlink while a request is in flight is refused, never followed | | `--no-update-check` | Skip the background npm version check in this process | | `-v, --verbose` | Debug logging to stderr | | `--log-level ` | `debug \| info \| warn \| error \| silent` | diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index 857fd5e..43e2b50 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -652,3 +652,48 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu record step, so a metadata.json that disappears during the transfer is `local_io` with the record_project recovery rather than an API-style `not_found`. + +## D-057 The write boundary is frozen before the first request + +- R6-F02: every containment check re-resolved the root (`--workspace`) with + realpath at check time. A workspace directory swapped for a symlink to another + tree while a request was in flight — or the alias a workspace was given + through re-pointed — made root and target move together, so the check passed + and the task snapshot, metadata and history landed outside the boundary the + user had authorised; `get` and `create --async` reported success. The root is + now an `AuthorisedRoot` frozen when the global flags are read (before any + request or write): its real path and the identity (device, inode) of the + physical directory behind it (`freezeRoot`). `resolveWithinRoot` given such a + root never resolves it again: it first proves that this very directory is still + at that path (a directory, same identity — a symlink or a different directory + there is refused, D-057 message "changed since the command started"), then that + the target's real path lies inside the frozen real path. The frozen root flows + through every write: `--save-json`, `-o` downloads (`downloadArtifacts`, + `downloadAssets`, sidecar, report-only), `make`, `mesh prepare-print`, + `project` commands, and the task verbs' project attachment, whose project is + written through its proven real path. Without a workspace the project directory + itself is frozen at the start of `get`/`wait`/`stream`/`create` (a project + that is not initialised is refused before any request) and of `download`. + Stable aliases (macOS `/var` → `/private/var`, a symlinked parent, a workspace + given through a symlink that keeps pointing at the same directory) resolve to + the same physical directory and pass as before; the recovery command still + carries the workspace as the user named it. + +## D-058 The download command re-validates the project inside the frozen boundary + +- R6-F01: `download --project` only checked that `metadata.json` behind the + project path was a regular file, so a project directory (or its parent) + replaced by a symlink to an outside project during the asset transfer was + followed: the outside metadata was rewritten and the command exited 0 with a + mere `files_outside_project` warning. Before any project lock, snapshot or + metadata write the project is now resolved against the frozen boundary (the + workspace, or the project directory itself when no workspace is given) and the + record is written through its real path. A failure there is a *boundary* + failure distinct from a repairable metadata problem: exit 11 / `local_io`, the + complete download result (source, selection, manifest with on-disk digests, + saved_json) is kept, `project.action = "failed"` with the reason and + `recovery: null` — no `meshy project record` command is offered, because the + only one that would succeed is one that writes across the boundary — and + nothing is disguised as `index_dirty`. Assets already downloaded stay where + they landed. Missing/damaged metadata after the preflight keeps the + `record_project` recovery of D-052/D-056. diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index cb66a5c..780b6bc 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -171,6 +171,14 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | task verbs `--project` when metadata.json disappears (or the project leaves the workspace) after the preflight | `local_io`, `recovery: null`, hint = `wait` | missing/damaged/locked → `record_project` command with `--operation-id` and `--workspace` (hint too); project outside the workspace → explicit boundary message, task and journal named, no command | R5-F03 | | `download --project` when metadata.json disappears during the transfer | `not_found` (exit 5) from the record step | `local_io` (exit 11) with the full result and the record_project recovery | R5-F03 | +### 3.6 Corrections from Codex review round 6 (visible in both schemas) + +| Behaviour | before the fix | after | Finding | +| --- | --- | --- | --- | +| `--workspace W` directory (or the alias it was given through) replaced by a symlink to an outside tree while a request is in flight; task verbs with `--project` | root re-resolved at check time → snapshot/metadata/history written outside, exit 0 | boundary frozen with the flags (real path + directory identity); exit 11 `local_io`, task/journal kept, single POST, nothing written outside, `recovery: null`, no record command | R6-F02 | +| `download --project P --workspace W --output-dir W/assets` with P or its parent replaced by a symlink to an outside project during the transfer | outside metadata.json rewritten, exit 0 with `files_outside_project` | exit 11 `local_io`, completed manifest kept, `project.action="failed"` with the reason, `recovery: null`, outside tree untouched | R6-F01 | +| `get`/`wait`/`stream` with `--project` that is not an initialised project | refused after the request | refused before any request | R6-F02 (D-057) | + New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, `--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 0f8a034..b0a1e92 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -185,8 +185,10 @@ Exit codes: `0` ok · `1` task FAILED/CANCELED while waiting, or unclassified · `error.recovery.command` (`meshy project record …`, it already carries the original `--workspace` and `--operation-id`) verbatim — do not download or create again. The same command appears after a task verb's `--project` record failure (legacy: `hint`); when the - error says the project is *no longer inside the workspace* there is deliberately no - command — move the project back first. + error says the project is *no longer a target inside the authorised boundary* (the project, + its parent or the workspace itself changed while the request was in flight) there is + deliberately no command and `recovery` is null — restore the directory first, then record + with `meshy project record` from inside the original workspace. - Exit 10 → reconcile with ` list`; never submit the same create again blindly. - A `FAILED` task → relay `result.task.task_error.message` verbatim; do not guess a cause. - Any error may carry `error.recovery.command` — prefer it over improvising. diff --git a/src/cmd/animation-catalog.ts b/src/cmd/animation-catalog.ts index c239c0f..2b017da 100644 --- a/src/cmd/animation-catalog.ts +++ b/src/cmd/animation-catalog.ts @@ -70,7 +70,7 @@ const listCommand = new Command("list") if (total !== null && total !== entries.length) { warnings.push(warning("catalog_total_mismatch", `server reported total=${total} but returned ${entries.length} entries; only the returned batch was searched`)); } - const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspaceRoot }) : null; await emitResult( opened, items, diff --git a/src/cmd/api.ts b/src/cmd/api.ts index 4e55585..70c3c10 100644 --- a/src/cmd/api.ts +++ b/src/cmd/api.ts @@ -61,7 +61,7 @@ export const apiCommand = new Command("api") /* leave as text */ } const saveJson = opts.saveJson as string | undefined; - const saved = saveJson ? saveRawJson(saveJson, parsed, { workspace: opened.flags.workspace }) : null; + const saved = saveJson ? saveRawJson(saveJson, parsed, { workspace: opened.flags.workspaceRoot }) : null; await emitResult( opened, parsed, diff --git a/src/cmd/balance.ts b/src/cmd/balance.ts index aefacad..d6f6f93 100644 --- a/src/cmd/balance.ts +++ b/src/cmd/balance.ts @@ -14,6 +14,6 @@ export const balanceCommand = new Command("balance") rejectOutputFlagForV1(opened, opts.saveJson); const runtime = await buildRuntime(opened.flags); const { balance, raw } = await runtime.client.balance.getWithRaw(); - const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspaceRoot }) : null; await emitResult(opened, balance, { balance: balance.balance, saved_json: saved }, { legacyFile: opened.flags.output }); }); diff --git a/src/cmd/doctor.ts b/src/cmd/doctor.ts index f2ee77e..1e0961e 100644 --- a/src/cmd/doctor.ts +++ b/src/cmd/doctor.ts @@ -60,7 +60,7 @@ export function buildDoctorCommand(): Command { checkApi: Boolean(opts.checkApi), checkSlicers: Boolean(opts.checkSlicers), }); - const saved = opts.saveJson ? saveRawJson(opts.saveJson, report, { workspace: opened.flags.workspace }) : null; + const saved = opts.saveJson ? saveRawJson(opts.saveJson, report, { workspace: opened.flags.workspaceRoot }) : null; const result: Record = { ...report, saved_json: saved }; if (opts.checkApi && report.api_ready !== true) { throw apiCheckFailure(apiFailure ?? { stage: "balance", error: new Error("--check-api did not complete") }, result); diff --git a/src/cmd/download.ts b/src/cmd/download.ts index ee8e895..950939b 100644 --- a/src/cmd/download.ts +++ b/src/cmd/download.ts @@ -22,7 +22,7 @@ import { emitResult, openCommand, saveRawJson, type OpenedCommand } from "../int import { abortSignal } from "../internal/context.js"; import { downloadAssets, type DownloadedFile } from "../internal/download.js"; import { CliError, UsageError, type Warning } from "../internal/errors.js"; -import { realpathLenient, resolveWithinRoot, safeSegment } from "../internal/paths.js"; +import { freezeRoot, realpathLenient, resolveWithinRoot, safeSegment, type AuthorisedRoot } from "../internal/paths.js"; import { warning } from "../internal/result.js"; import { assertProjectMetadataPresent, indexRootFor, projectRecordCommand, readProject, recordTask, stageFromTaskType, type RecordInput } from "../internal/project-store.js"; import { buildLocalRuntime, buildRuntime } from "../internal/runtime.js"; @@ -120,12 +120,16 @@ export const downloadCommand = new Command("download") if (selectors.length > 1) throw new UsageError(`selectors are mutually exclusive (got ${selectors.map((s) => `--${s}`).join(", ")})`); if (opts.withDependencies && opts.geometryOnly) throw new UsageError("--with-dependencies and --geometry-only are mutually exclusive"); if (opened.flags.output && opts.outputDir) throw new UsageError("--output/-o and --output-dir are mutually exclusive"); + // The write boundary was frozen with the flags (--workspace); without one the + // project directory itself is frozen here, before any request. + const workspaceRoot = opened.flags.workspaceRoot; const projectDir = opts.project - ? opened.flags.workspace - ? resolveWithinRoot(resolvePath(opts.project), opened.flags.workspace, { label: "--project" }).path + ? workspaceRoot + ? resolveWithinRoot(resolvePath(opts.project), workspaceRoot, { label: "--project" }).path : resolvePath(opts.project) : null; if (projectDir) preflightProject(projectDir, opts.project!, opts.stage); + const projectRoot: AuthorisedRoot | null = projectDir ? (workspaceRoot ?? freezeRoot(projectDir, { label: "--project" })) : null; if (projectDir && opts.url) throw new UsageError("--project needs a task context; it cannot be combined with --url"); const warnings: Warning[] = []; @@ -163,7 +167,7 @@ export const downloadCommand = new Command("download") return new Map(fresh.assets.filter((a) => a.url).map((a) => [a.key, a.url!] as const)); }; } - const savedJson = opts.saveJson && raw ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + const savedJson = opts.saveJson && raw ? saveRawJson(opts.saveJson, raw, { workspace: workspaceRoot }) : null; // ---- enumerate + select ---- let selected: Asset[]; @@ -226,7 +230,7 @@ export const downloadCommand = new Command("download") throw new UsageError(`${toDownload.length} files would be written (${toDownload.map((a) => a.key).join(", ")}); --output names one file — use --output-dir `); } const dir = outputFile ? dirname(resolvePath(outputFile)) : resolvePath(outputDir!); - const root = opened.flags.workspace ? resolvePath(opened.flags.workspace) : dir; + const root: string | AuthorisedRoot = workspaceRoot ?? dir; const files: DownloadedFile[] = []; let result; @@ -282,21 +286,31 @@ export const downloadCommand = new Command("download") status: typeof task["status"] === "string" ? (task["status"] as string) : null, files: [], }; - const workspace = opened.flags.workspace ? resolvePath(opened.flags.workspace) : undefined; + const workspace = workspaceRoot?.given; + // 1. The location, against the boundary frozen before the transfers: the + // frozen directory must still be there and the project must resolve + // inside it now. A project (or a parent of it) replaced by a symlink to + // somewhere else during the transfer is refused before any lock, + // snapshot or metadata write — and gets no command that would write there. + let located: string; + try { + located = resolveWithinRoot(projectDir, projectRoot!, { label: "--project" }).path; + } catch (err) { + throw projectBoundaryFailure(err, { projectDir, dir, input, written: written.length, outcome, warnings }); + } try { // Compare in one real-path frame: the project may be reached through an // alias (a symlinked parent, macOS /var → /private/var) while the // downloader reports real paths; the recorded name is relative to the real project. // The file list is known before the project is examined, so a recovery // command always names what landed inside the project. - const projectReal = realpathLenient(projectDir); input.files = written - .map((f) => relative(projectReal, realpathLenient(f.path)).split(/[\\/]/).join("/")) + .map((f) => relative(located, realpathLenient(f.path)).split(/[\\/]/).join("/")) .filter((f) => f.length > 0 && !f.startsWith("..") && !f.startsWith("/")); - // The project passed the preflight; it must still be one now. - assertProjectMetadataPresent(projectDir, opts.project!); - const indexRoot = indexRootFor(projectDir, undefined, opened.flags.workspace); - const rec = recordTask(projectDir, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); + // 2. The project passed the preflight; it must still be one now. Writes go through the real path. + assertProjectMetadataPresent(located, opts.project!); + const indexRoot = indexRootFor(located, undefined, workspaceRoot); + const rec = recordTask(located, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}`)); if (input.files.length !== written.length) warnings.push(warning("files_outside_project", "some files were written outside the project directory and were not recorded")); project = { project_dir: projectDir, action: rec.action, stage: rec.entry.stage, recorded_files: input.files }; @@ -337,6 +351,40 @@ function preflightProject(projectDir: string, flag: string, stage: string | unde if (stage !== undefined && stage.trim() === "") throw new UsageError("--stage must not be blank"); } +/** + * The transfers are done and the files are on disk, but the project no longer + * lies inside the boundary frozen when the command started (its directory or a + * parent was replaced by a symlink to somewhere else, or the boundary itself + * was moved). Nothing was locked, snapshotted or written; the complete download + * result is kept and `project.action` is `failed` — and no `meshy project + * record` command is offered, because the only one that would succeed is one + * that writes across the boundary. + */ +function projectBoundaryFailure( + err: unknown, + ctx: { projectDir: string; dir: string; input: RecordInput; written: number; outcome: Record; warnings: Warning[] }, +): CliError { + const reason = err instanceof Error ? err.message : String(err); + return new CliError({ + code: "local_io", + message: `${ctx.written} file(s) were downloaded to ${ctx.dir} but --project ${ctx.projectDir} is no longer a target inside the authorised boundary: ${reason}; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record task ${ctx.input.taskId} with \`meshy project record\` from that workspace`, + warnings: ctx.warnings, + details: { project: ctx.projectDir, task_id: ctx.input.taskId, stage: ctx.input.stage, recorded: false }, + result: { + ...ctx.outcome, + project: { + project_dir: ctx.projectDir, + action: "failed", + stage: ctx.input.stage, + recorded_files: [], + error: { code: "local_io", message: reason }, + recovery: null, + }, + }, + cause: err, + }); +} + /** * The transfers are done and the files are on disk; only the project entry * could not be written. The error keeps its own class (a refused symlink, diff --git a/src/cmd/inspect.ts b/src/cmd/inspect.ts index 70b7958..3ae03da 100644 --- a/src/cmd/inspect.ts +++ b/src/cmd/inspect.ts @@ -191,7 +191,7 @@ export function buildInspectCommand(): Command { const { raw } = await runtime.client.endpointFor(descriptor).retrieveDetailed(source.taskId, { signal: abortSignal() }); const extracted = extractTaskObject(raw); task = extracted?.task ?? (raw as Record); - savedJson = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + savedJson = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspaceRoot }) : null; sourceInfo = { kind: "api", resource: descriptor.id, task_id: source.taskId, endpoint: descriptor.legacyEndpoint, requests_made: 1 }; } diff --git a/src/cmd/make.ts b/src/cmd/make.ts index ce6465a..ab547af 100644 --- a/src/cmd/make.ts +++ b/src/cmd/make.ts @@ -144,7 +144,7 @@ async function runChain(plan: MakePlan, opts: MakeOptions, timeoutSeconds: numbe const payloadFor = await buildPayloads(plan); // -o is checked before the first billable request: an unwritable or // out-of-workspace target must refuse while the run is still free. - if (runtime.flags.output) preflightOutputPath(runtime.flags.output, runtime.flags.workspace); + if (runtime.flags.output) preflightOutputPath(runtime.flags.output, runtime.flags.workspaceRoot); const executed: ExecutedStep[] = []; const warnings: Warning[] = []; @@ -341,7 +341,7 @@ async function finalOutcome( let downloads = base.downloads as Record; if (runtime.flags.output) { try { - const { files, metadataPath, materialLinks } = await downloadArtifacts(task, runtime.flags.output, step.resource, { root: runtime.flags.workspace, signal: abortSignal() }); + const { files, metadataPath, materialLinks } = await downloadArtifacts(task, runtime.flags.output, step.resource, { root: runtime.flags.workspaceRoot, signal: abortSignal() }); if (materialLinks) warnings.push(...materialLinks.warnings); downloads = { state: "completed", files: files.map((f) => ({ key: f.key, path: f.path, status: f.status, bytes: f.bytes, sha256: f.sha256, error: f.error })), metadata_path: metadataPath, material_links: materialLinks }; } catch (err) { diff --git a/src/cmd/mesh.ts b/src/cmd/mesh.ts index ca3c8a8..7eb4ac4 100644 --- a/src/cmd/mesh.ts +++ b/src/cmd/mesh.ts @@ -42,7 +42,7 @@ const prepareCommand = new Command("prepare-print") throw new UsageError("--output/-o and --in-place are mutually exclusive: pick a new file or replace the input, not both"); } const cwd = process.cwd(); - const workspace = opened.flags.workspace; + const workspace = opened.flags.workspaceRoot; // Reads may follow a symlinked input; an in-place replacement may not // (renaming over the link would drop the bytes somewhere else). diff --git a/src/cmd/project.ts b/src/cmd/project.ts index 1ef7f59..bd158f4 100644 --- a/src/cmd/project.ts +++ b/src/cmd/project.ts @@ -18,7 +18,7 @@ import { join, resolve as resolvePath } from "node:path"; import { emitResult, openCommand } from "../internal/command-helpers.js"; import { UsageError } from "../internal/errors.js"; import { collect } from "../internal/flags.js"; -import { resolveWithinRoot } from "../internal/paths.js"; +import { resolveWithinRoot, type AuthorisedRoot } from "../internal/paths.js"; import { indexRootFor, initProject, listProjects, readProject, rebuildIndex, recordTask } from "../internal/project-store.js"; import { warning, type Warning } from "../internal/result.js"; import { buildLocalRuntime } from "../internal/runtime.js"; @@ -30,7 +30,7 @@ function rootFrom(opts: { root?: string }, cwd = process.cwd()): string { } /** With --workspace, a directory this command writes must resolve inside it (no symlink leaf, real paths). */ -function confine(path: string, workspace: string | undefined, label: string): string { +function confine(path: string, workspace: AuthorisedRoot | undefined, label: string): string { if (!workspace) return path; return resolveWithinRoot(path, workspace, { label }).path; } @@ -44,7 +44,7 @@ const initCommand = new Command("init") .action(async (opts: { root?: string; name?: string; taskId?: string; taskType?: string }, thisCmd: Command) => { const opened = openCommand(thisCmd, "project.init", "v1"); buildLocalRuntime(opened.flags); - const root = confine(rootFrom(opts), opened.flags.workspace, "--root"); + const root = confine(rootFrom(opts), opened.flags.workspaceRoot, "--root"); const res = initProject(root, { name: opts.name, taskId: opts.taskId ?? null, taskType: opts.taskType ?? null }); const warnings: Warning[] = []; if (!res.index.updated) warnings.push(warning("index_dirty", `metadata.json written but history.json was not updated: ${res.index.error}; run \`meshy project rebuild-index --root ${res.root}\``)); @@ -71,15 +71,15 @@ const recordCommand = new Command("record") ) => { const opened = openCommand(thisCmd, "project.record", "v1"); buildLocalRuntime(opened.flags); - const projectDir = confine(resolvePath(opts.project), opened.flags.workspace, "--project"); + const projectDir = confine(resolvePath(opts.project), opened.flags.workspaceRoot, "--project"); if (!existsSync(join(projectDir, "metadata.json"))) { throw new UsageError(`${projectDir} has no metadata.json; run \`meshy project init\` first`); } // An explicit --root outside the workspace is refused up front; the // implicit root (the project's parent) is checked the same way and, when // it lies outside, the index is skipped rather than written across the boundary. - if (opts.root) confine(resolvePath(opts.root), opened.flags.workspace, "--root"); - const indexRoot = indexRootFor(projectDir, opts.root, opened.flags.workspace); + if (opts.root) confine(resolvePath(opts.root), opened.flags.workspaceRoot, "--root"); + const indexRoot = indexRootFor(projectDir, opts.root, opened.flags.workspaceRoot); const res = recordTask( projectDir, { @@ -133,7 +133,7 @@ const rebuildCommand = new Command("rebuild-index") .action(async (opts: { root?: string }, thisCmd: Command) => { const opened = openCommand(thisCmd, "project.rebuild-index", "v1"); buildLocalRuntime(opened.flags); - const res = rebuildIndex(confine(rootFrom(opts), opened.flags.workspace, "--root")); + const res = rebuildIndex(confine(rootFrom(opts), opened.flags.workspaceRoot, "--root")); const warnings: Warning[] = res.skipped.map((s) => warning("project_skipped", `${s.folder}: ${s.reason}`)); await emitResult(opened, res, res, { warnings }); }); diff --git a/src/cmd/showcases.ts b/src/cmd/showcases.ts index 92f2f1a..b253e7a 100644 --- a/src/cmd/showcases.ts +++ b/src/cmd/showcases.ts @@ -59,7 +59,7 @@ const listCommand = new Command("list") }; const runtime = await buildRuntime(opened.flags); const { items, raw } = await runtime.client.showcases.list(request, { signal: abortSignal() }); - const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspace }) : null; + const saved = opts.saveJson ? saveRawJson(opts.saveJson, raw, { workspace: opened.flags.workspaceRoot }) : null; await emitResult( opened, items, diff --git a/src/internal/command-helpers.ts b/src/internal/command-helpers.ts index f24aaf7..7917612 100644 --- a/src/internal/command-helpers.ts +++ b/src/internal/command-helpers.ts @@ -14,7 +14,7 @@ import { emit, emitEnvelope, type OutputFormat } from "./output.js"; import { okEnvelope, type OutputSchema } from "./result.js"; import { readGlobalFlags, resolveSchema, type GlobalFlags } from "./runtime.js"; import { writeJsonFile } from "./atomic-file.js"; -import { resolveWithinRoot } from "./paths.js"; +import { resolveWithinRoot, type AuthorisedRoot } from "./paths.js"; export interface OpenedCommand extends CommandContext { flags: GlobalFlags; @@ -71,11 +71,11 @@ export interface SavedJson { export function saveRawJson( target: string, raw: unknown, - opts: { workspace?: string | undefined; overwrite?: boolean; cwd?: string } = {}, + opts: { workspace?: string | AuthorisedRoot | undefined; overwrite?: boolean; cwd?: string } = {}, ): SavedJson { const cwd = opts.cwd ?? process.cwd(); const abs = resolvePath(cwd, target); - const root = opts.workspace ?? dirname(abs); + const root: string | AuthorisedRoot = opts.workspace ?? dirname(abs); const resolved = resolveWithinRoot(abs, root, { cwd, label: "--save-json target" }); const text = `${JSON.stringify(raw, null, 2)}\n`; writeJsonFile(resolved.path, raw, { overwrite: opts.overwrite ?? false }); diff --git a/src/internal/download.ts b/src/internal/download.ts index 28ed013..2e6b29f 100644 --- a/src/internal/download.ts +++ b/src/internal/download.ts @@ -31,7 +31,7 @@ import type { Task } from "../client/types.js"; import { publishTempFile, tempPathFor, writeJsonFile } from "./atomic-file.js"; import { CliError, UsageError } from "./errors.js"; import { logger } from "./logger.js"; -import { isInside, realpathLenient, resolveWithinRoot, safeExtension, safeSegment } from "./paths.js"; +import { freezeRoot, isInside, realpathLenient, resolveWithinRoot, safeExtension, safeSegment, type AuthorisedRoot } from "./paths.js"; import { USER_AGENT } from "./user-agent.js"; import type { Asset } from "./artifacts.js"; import { fileDigest, relinkMaterials, type MaterialLinkReport } from "./material-links.js"; @@ -315,8 +315,8 @@ export interface DownloadAssetsOptions extends FetchOptions { /** Single-file mode (exactly one asset). */ targetFile?: string; overwrite?: boolean; - /** Authorised root every final path must stay inside. */ - root: string; + /** Authorised root every final path must stay inside — frozen before the first transfer (a string is frozen here). */ + root: string | AuthorisedRoot; /** Validate magic/content-type for models (the legacy wrapper turns this off). */ validateContent?: boolean; /** Bounded URL refresh hook (API source): returns fresh URLs by key or null. */ @@ -351,13 +351,16 @@ export async function downloadAssets(assets: readonly Asset[], opts: DownloadAss const files: DownloadedFile[] = []; const warnings: Array<{ code: string; message: string }> = []; const dir = opts.targetFile ? dirname(resolvePath(opts.targetFile)) : resolvePath(opts.targetDir!); - const rootReal = realpathLenient(opts.root); + // The root is frozen once: its real path and directory identity now, re-proven + // at every later check, so a boundary replaced during a transfer is refused. + const root: AuthorisedRoot = typeof opts.root === "string" ? freezeRoot(opts.root, { label: "output root" }) : opts.root; + const rootReal = root.real; // The directory and every planned leaf must be inside the root (and no // symlink) before anything at all is created — a refused target must not // leave a directory behind; the check repeats after any MIME-driven rename. - resolveWithinRoot(dir, rootReal, { label: "output directory" }); + resolveWithinRoot(dir, root, { label: "output directory" }); for (const asset of assets) { - resolveWithinRoot(join(dir, plannedName(asset, opts.targetFile)), rootReal, { label: "planned download path" }); + resolveWithinRoot(join(dir, plannedName(asset, opts.targetFile)), root, { label: "planned download path" }); } mkdirSync(dir, { recursive: true }); @@ -367,7 +370,7 @@ export async function downloadAssets(assets: readonly Asset[], opts: DownloadAss let entry: DownloadedFile; try { if (asset.kind === "report") { - const target = resolveWithinRoot(planned.endsWith(".json") ? planned : `${planned}.json`, rootReal, { label: "report path" }).path; + const target = resolveWithinRoot(planned.endsWith(".json") ? planned : `${planned}.json`, root, { label: "report path" }).path; const res = writeJsonFile(target, asset.report, { overwrite: opts.overwrite ?? false }); const bytes = statSync(target).size; entry = { key: asset.key, path: target, relative_path: rel(rootReal, target), bytes, sha256: createHash("sha256").update(readFileSync(target)).digest("hex"), content_type: "application/json", format: "json", container_format: null, extracted: null, status: "written", error: null, publish_method: res.method, relinked: false }; @@ -389,7 +392,7 @@ export async function downloadAssets(assets: readonly Asset[], opts: DownloadAss } else throw err; } else throw err; } - entry = await placeFetched(asset, fetched, planned, rootReal, opts, warnings); + entry = await placeFetched(asset, fetched, planned, root, opts, warnings); } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -488,10 +491,11 @@ async function placeFetched( asset: Asset, fetched: FetchedFile, planned: string, - rootReal: string, + root: AuthorisedRoot, opts: DownloadAssetsOptions, warnings: Array<{ code: string; message: string }>, ): Promise { + const rootReal = root.real; const actualExt = extFromContentType(fetched.contentType); const requestedExt = safeExtension(extname(planned)); let finalPath = planned; @@ -533,7 +537,7 @@ async function placeFetched( // must not be a symlink or an existing file (unless --overwrite). let resolved: string; try { - resolved = resolveWithinRoot(finalPath, rootReal, { label: "final download path" }).path; + resolved = resolveWithinRoot(finalPath, root, { label: "final download path" }).path; } catch (err) { removeQuietly(tmp); throw err; @@ -694,8 +698,8 @@ export interface DownloadResult { } export interface DownloadArtifactsOptions { - /** Authorised root (the --workspace); every directory and file must resolve inside it. Default: the output directory itself. */ - root?: string; + /** Authorised root (the --workspace, frozen when the flags were read); every directory and file must resolve inside it. Default: the output directory itself. A string is frozen here. */ + root?: string | AuthorisedRoot; /** Cooperative cancellation (SIGINT): aborts the in-flight transfer and stops every later download, relink and publish. */ signal?: AbortSignal; } @@ -768,13 +772,13 @@ export async function downloadArtifacts( const artifacts = enumerateArtifacts(task); // An explicit workspace is the root for everything written here — the // directory, every planned file and the sidecar — checked before mkdir. - const workspaceReal = opts.root !== undefined ? realpathLenient(resolvePath(opts.root)) : null; + const workspaceRoot: AuthorisedRoot | null = opts.root === undefined ? null : typeof opts.root === "string" ? freezeRoot(opts.root, { label: "--workspace" }) : opts.root; if (artifacts.length === 0) { // Report-only tasks (analyze-printability) carry their result in a // structured field instead of downloadable files. Persist the full task // JSON so `-o` still means "give me the result on disk". if (task.printability != null) { - return saveReportOnly(task, outputPath, resource, workspaceReal); + return saveReportOnly(task, outputPath, resource, workspaceRoot); } throw new Error(`task ${task.id} has no downloadable artifacts`); } @@ -814,13 +818,13 @@ export async function downloadArtifacts( ); } - if (workspaceReal) { - resolveWithinRoot(targetDir, workspaceReal, { label: "output directory" }); - for (const p of [...plan.map((x) => x.target), metadataPath]) resolveWithinRoot(p, workspaceReal, { label: "planned download path" }); + if (workspaceRoot) { + resolveWithinRoot(targetDir, workspaceRoot, { label: "output directory" }); + for (const p of [...plan.map((x) => x.target), metadataPath]) resolveWithinRoot(p, workspaceRoot, { label: "planned download path" }); } mkdirSync(targetDir, { recursive: true }); - const root = workspaceReal ?? realpathLenient(targetDir); + const root: AuthorisedRoot = workspaceRoot ?? freezeRoot(targetDir, { label: "output directory" }); const files: LegacyDownloadedFile[] = []; const saved: string[] = []; for (const { artifact, target } of plan) { @@ -892,7 +896,7 @@ interface PlacedArtifact { } /** Fetch one artifact into place. Errors keep their class: a CliError from the fetch/publish core is rethrown untouched. */ -async function downloadArtifact(artifact: Artifact, targetPath: string, root: string, signal: AbortSignal | undefined): Promise { +async function downloadArtifact(artifact: Artifact, targetPath: string, root: AuthorisedRoot, signal: AbortSignal | undefined): Promise { logger.debug(`GET ${redact(artifact.url)}`); const fetched = await fetchToTemp(artifact.url, targetPath, { signal }); const actualExt = extFromContentType(fetched.contentType); @@ -949,7 +953,7 @@ async function downloadArtifact(artifact: Artifact, targetPath: string, root: st * the extension would be worse than a clear error. Every path is proven inside * the workspace (when given) before any directory or file is created. */ -function saveReportOnly(task: Task, outputPath: string, resource: string, workspaceReal: string | null): DownloadResult { +function saveReportOnly(task: Task, outputPath: string, resource: string, workspaceRoot: AuthorisedRoot | null): DownloadResult { const singleFileMode = looksLikeFile(outputPath); const abs = resolvePath(outputPath); if (singleFileMode) { @@ -965,7 +969,7 @@ function saveReportOnly(task: Task, outputPath: string, resource: string, worksp `(delete it or choose a different --output path to rerun)`, ); } - if (workspaceReal) resolveWithinRoot(abs, workspaceReal, { label: "report path" }); + if (workspaceRoot) resolveWithinRoot(abs, workspaceRoot, { label: "report path" }); writeJsonFile(abs, { resource, task, downloaded_at: new Date().toISOString() }, { overwrite: false, mode: 0o644 }); return { savedFiles: [], metadataPath: abs, materialLinks: null, files: [] }; } @@ -976,12 +980,12 @@ function saveReportOnly(task: Task, outputPath: string, resource: string, worksp `(delete it or choose a different --output path to rerun)`, ); } - if (workspaceReal) { - resolveWithinRoot(abs, workspaceReal, { label: "output directory" }); - resolveWithinRoot(metadataPath, workspaceReal, { label: "planned download path" }); + if (workspaceRoot) { + resolveWithinRoot(abs, workspaceRoot, { label: "output directory" }); + resolveWithinRoot(metadataPath, workspaceRoot, { label: "planned download path" }); } mkdirSync(abs, { recursive: true }); - writeMeta(task, resource, metadataPath, [], workspaceReal ?? realpathLenient(abs)); + writeMeta(task, resource, metadataPath, [], workspaceRoot ?? freezeRoot(abs, { label: "output directory" })); return { savedFiles: [], metadataPath, materialLinks: null, files: [] }; } @@ -991,7 +995,7 @@ function saveReportOnly(task: Task, outputPath: string, resource: string, worksp * publication time (a symlink or file that appeared since the preflight is * refused, never followed or truncated) and the write is exclusive and atomic. */ -function writeMeta(task: Task, resource: string, path: string, savedFiles: string[], root: string): void { +function writeMeta(task: Task, resource: string, path: string, savedFiles: string[], root: AuthorisedRoot): void { const meta = { resource, task, diff --git a/src/internal/obj-transform.ts b/src/internal/obj-transform.ts index 3fa387a..2cc7be7 100644 --- a/src/internal/obj-transform.ts +++ b/src/internal/obj-transform.ts @@ -53,7 +53,7 @@ import { finished } from "node:stream/promises"; import { StringDecoder } from "node:string_decoder"; import { copyFilePublished, publishTempFile, tempPathFor } from "./atomic-file.js"; import { CliError, UsageError, type Warning } from "./errors.js"; -import { isInside, realpathLenient, resolveWithinRoot } from "./paths.js"; +import { freezeRoot, isInside, realpathLenient, resolveWithinRoot, type AuthorisedRoot } from "./paths.js"; import { warning } from "./result.js"; export type Vec3 = [number, number, number]; @@ -123,10 +123,12 @@ export interface PrepareObjOptions { maxBytes?: number; /** * Authorised root for every write (the output and each copied dependency). - * Defaults to the output's directory; pass the --workspace to confine writes - * to it. Checked on real paths, so symlinked parents cannot escape it. + * Defaults to the output's directory; pass the --workspace (frozen with the + * flags) to confine writes to it. Checked on real paths against the frozen + * directory, so symlinked parents cannot escape it and a root replaced + * mid-run is refused. */ - root?: string; + root?: string | AuthorisedRoot; } export function rotateYUpToZUp(v: Vec3): Vec3 { @@ -661,7 +663,7 @@ export async function prepareObjForPrint(inputPath: string, opts: PrepareObjOpti // Every path written by this run must resolve inside the write root: the // output itself and each dependency copy, checked on real paths before any // directory is created. A `materials/` symlink pointing elsewhere fails here. - const writeRoot = realpathLenient(opts.root !== undefined ? resolvePath(opts.root) : outputDir); + const writeRoot: AuthorisedRoot = opts.root === undefined ? freezeRoot(outputDir, { label: "output directory" }) : typeof opts.root === "string" ? freezeRoot(opts.root, { label: "--workspace" }) : opts.root; if (!inPlace) resolveWithinRoot(output, writeRoot, { label: "output" }); for (const copy of plan.copies) { copy.real = resolveWithinRoot(copy.target, writeRoot, { label: `material dependency target for '${copy.ref}'` }).path; diff --git a/src/internal/paths.ts b/src/internal/paths.ts index 5f4d4dd..de19daf 100644 --- a/src/internal/paths.ts +++ b/src/internal/paths.ts @@ -7,9 +7,20 @@ * Containment is decided on real paths — the deepest existing ancestor is * resolved with realpath so a symlinked directory cannot redirect a write — * never with a string prefix test. + * + * The root itself is *frozen* when the command starts (`freezeRoot`): its real + * path and the identity (device, inode) of the physical directory behind it are + * captured before the first request or write, and every later check + * (`resolveWithinRoot` with an `AuthorisedRoot`) first proves that this very + * directory is still there — not a symlink that appeared at its path, not a + * different directory — and then proves the target's real path inside it. A + * root that is re-resolved at check time would move with whatever now sits at + * its path; a frozen root cannot. Stable aliases (macOS `/var` → `/private/var`, + * a symlinked parent, a workspace given through a symlink that keeps pointing + * at the same directory) resolve to the same physical directory and pass. */ -import { lstatSync, realpathSync } from "node:fs"; +import { lstatSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute, relative, resolve as resolvePath, sep, basename } from "node:path"; import { CliError } from "./errors.js"; @@ -50,19 +61,101 @@ export function isInside(root: string, candidate: string): boolean { return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } +/** + * A write boundary fixed at the start of a command. `real` is where writes may + * land; `anchor` is the deepest part of it that existed when frozen (the whole + * root when it exists) and `dev`/`ino` identify that directory. A later check + * re-proves the identity, so replacing the directory at that path — a symlink + * to somewhere else, a different directory swapped in — is refused rather than + * silently becoming the new root. + */ +export interface AuthorisedRoot { + /** The root as authorised, absolute (what the user named). */ + given: string; + /** Real path of the root when frozen. */ + real: string; + /** Real path of the deepest existing ancestor when frozen (equals `real` when the root existed). */ + anchor: string; + dev: number; + ino: number; + /** Whether `anchor` was a directory when frozen (a file is refused at the first write, not earlier). */ + directory: boolean; + /** Name used in messages: "--workspace", "--project", "output directory" … */ + label: string; +} + +function errnoOf(err: unknown): string | undefined { + return (err as NodeJS.ErrnoException | undefined)?.code; +} + +/** Freeze a write boundary: resolve it once and remember which directory it is. */ +export function freezeRoot(path: string, opts: { cwd?: string; label?: string } = {}): AuthorisedRoot { + const given = resolvePath(opts.cwd ?? process.cwd(), path); + const real = realpathLenient(given); + let anchor = real; + for (;;) { + try { + const st = statSync(anchor); + return { given, real, anchor, dev: st.dev, ino: st.ino, directory: st.isDirectory(), label: opts.label ?? "root" }; + } catch (err) { + if (errnoOf(err) !== "ENOENT" && errnoOf(err) !== "ENOTDIR") throw err; + const parent = dirname(anchor); + if (parent === anchor) throw err; + anchor = parent; + } + } +} + +/** + * The directory a root was frozen on must still be the one at its path: a + * directory (never a symlink), with the same device and inode. Anything else + * means the authorised boundary was moved or replaced since the command started. + */ +export function assertRootIntact(root: AuthorisedRoot): void { + if (!root.directory) { + throw new CliError({ code: "local_io", message: `${root.label} ${root.given} is not a directory; refusing to write` }); + } + let st: ReturnType; + try { + st = lstatSync(root.anchor); + } catch (err) { + throw new CliError({ + code: "local_io", + message: `${root.label} ${root.given} changed since the command started: ${root.anchor} is gone (${errnoOf(err) ?? "stat failed"}); refusing to write outside the authorised boundary`, + cause: err, + }); + } + if (st.isSymbolicLink() || !st.isDirectory() || st.dev !== root.dev || st.ino !== root.ino) { + throw new CliError({ + code: "local_io", + message: `${root.label} ${root.given} changed since the command started: ${root.anchor} is now ${st.isSymbolicLink() ? "a symbolic link" : st.isDirectory() ? "a different directory" : "not a directory"}; refusing to write outside the authorised boundary`, + }); + } +} + /** * Resolve `target` (file or directory) against cwd and prove it lies inside * `root`. A symlink at the leaf is rejected for write targets: replacing or * following it could land the bytes outside the root. + * + * With an `AuthorisedRoot` the root is not resolved again: its frozen real path + * is the boundary and its identity is re-proven first. A plain string root is + * resolved here (for one-shot local checks with no request in between). */ export function resolveWithinRoot( target: string, - root: string, + root: string | AuthorisedRoot, opts: { cwd?: string; allowSymlinkLeaf?: boolean; label?: string } = {}, ): ResolvedTarget { const cwd = opts.cwd ?? process.cwd(); const label = opts.label ?? "path"; - const rootReal = realpathLenient(resolvePath(cwd, root)); + let rootReal: string; + if (typeof root === "string") { + rootReal = realpathLenient(resolvePath(cwd, root)); + } else { + assertRootIntact(root); + rootReal = root.real; + } const absTarget = resolvePath(cwd, target); let leaf: ReturnType | null = null; try { diff --git a/src/internal/project-store.ts b/src/internal/project-store.ts index 32277c3..b712a28 100644 --- a/src/internal/project-store.ts +++ b/src/internal/project-store.ts @@ -25,7 +25,7 @@ import { randomBytes } from "node:crypto"; import { writeJsonFile } from "./atomic-file.js"; import { CliError, UsageError } from "./errors.js"; import { withFileLock } from "./lock.js"; -import { realpathLenient, resolveWithinRoot, safeSegment } from "./paths.js"; +import { realpathLenient, resolveWithinRoot, safeSegment, type AuthorisedRoot } from "./paths.js"; export const METADATA_SCHEMA_VERSION = 2; export const HISTORY_VERSION = 1; @@ -100,14 +100,15 @@ export interface StoreOptions { * the index is left alone with an explicit reason — nothing is ever written, * locked or temp-filed outside the workspace. */ -export function indexRootFor(projectDir: string, explicitRoot: string | undefined, workspace: string | undefined): { root: string; skipIndex?: string } { +export function indexRootFor(projectDir: string, explicitRoot: string | undefined, workspace: string | AuthorisedRoot | undefined): { root: string; skipIndex?: string } { const root = explicitRoot !== undefined ? resolvePath(explicitRoot) : resolvePath(projectDir, ".."); if (!workspace) return { root }; + const workspacePath = typeof workspace === "string" ? resolvePath(workspace) : workspace.given; try { resolveWithinRoot(root, workspace, { label: "history root" }); return { root }; } catch (err) { - return { root, skipIndex: `history root ${root} resolves outside --workspace ${resolvePath(workspace)}; metadata.json was recorded but history.json was not touched (${err instanceof Error ? err.message : String(err)}) — run \`meshy project rebuild-index --root ${root}\` from a workspace that contains it` }; + return { root, skipIndex: `history root ${root} resolves outside --workspace ${workspacePath}; metadata.json was recorded but history.json was not touched (${err instanceof Error ? err.message : String(err)}) — run \`meshy project rebuild-index --root ${root}\` from a workspace that contains it` }; } } diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index fb3af3a..e7822b2 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -12,6 +12,7 @@ */ import { Command } from "commander"; +import { freezeRoot, type AuthorisedRoot } from "./paths.js"; import { MeshyClient } from "../client/index.js"; import { loadConfig, type ConfigOverrides, type MeshyConfig } from "./config.js"; import { credentialsPath, readCredentials, saveProfile } from "./credentials.js"; @@ -34,6 +35,8 @@ export interface GlobalFlags { /** Path given to --api-key-file (only MESHY_API_KEY is read from it). */ envFile?: string; workspace?: string; + /** The --workspace frozen when the flags were read (real path + directory identity); every write is confined to it. */ + workspaceRoot?: AuthorisedRoot; /** false when --no-update-check was given. */ updateCheck: boolean; verbose: boolean; @@ -234,6 +237,7 @@ export function readGlobalFlags(cmd: Command): GlobalFlags { output: opts.output, envFile: opts.apiKeyFile, workspace: opts.workspace, + workspaceRoot: opts.workspace ? freezeRoot(opts.workspace, { label: "--workspace" }) : undefined, updateCheck: opts.updateCheck !== false, verbose: Boolean(opts.verbose), logLevel: normalizeLogLevel(opts.logLevel), diff --git a/src/internal/task-command.ts b/src/internal/task-command.ts index 82d81b1..b4eecec 100644 --- a/src/internal/task-command.ts +++ b/src/internal/task-command.ts @@ -57,7 +57,7 @@ import { type OperationRecord, } from "./operation-store.js"; import { originOf } from "./config.js"; -import { resolveWithinRoot } from "./paths.js"; +import { resolveWithinRoot, freezeRoot, type AuthorisedRoot } from "./paths.js"; import { assertProjectMetadataPresent, indexRootFor, projectRecordCommand, recordTask, saveTaskSnapshot, stageFromTaskType, type RecordInput } from "./project-store.js"; import { existsSync } from "node:fs"; import { dirname, join, resolve as resolvePath } from "node:path"; @@ -263,7 +263,7 @@ function parentTaskIdFromPayload(payload: Record | null): strin } /** Resolve --project: an initialised project directory, inside the workspace when one is set. */ -function resolveProjectDir(projectFlag: string, workspace: string | undefined, cwd = process.cwd()): string { +function resolveProjectDir(projectFlag: string, workspace: string | AuthorisedRoot | undefined, cwd = process.cwd()): string { const projectDir = resolvePath(cwd, projectFlag); if (!existsSync(join(projectDir, "metadata.json"))) { throw new CliError({ @@ -300,8 +300,12 @@ function attachToProject( ): ProjectAttachment | null { const projectFlag = opts.project as string | undefined; if (!projectFlag) return null; - const workspace = opened.flags.workspace ? resolvePath(opened.flags.workspace) : undefined; const projectDir = resolvePath(projectFlag); + // The boundary was frozen before the first request (the workspace, or the + // project directory itself when no workspace is given); it is never resolved + // again from a path that may have been replaced since. + const root: AuthorisedRoot = opened.flags.workspaceRoot ?? (opts.__projectRoot as AuthorisedRoot | undefined) ?? freezeRoot(projectDir, { label: "--project" }); + const workspace = opened.flags.workspaceRoot?.given; const stage = (opts.stage as string | undefined) ?? (typeof extra.payload?.["mode"] === "string" ? (extra.payload["mode"] as string) : descriptor.creativeLab?.stage ?? stageFromTaskType(task?.type, descriptor.id)); const input: RecordInput = { taskId, @@ -315,23 +319,28 @@ function attachToProject( operationId: extra.operationId ?? null, files: extra.files ?? [], }; - // 1. The location. A project that now resolves outside the workspace, or has - // become a symlink, is a boundary problem: the task exists and is journaled, - // but no recovery command may be handed out that writes across that line. - if (workspace) { - try { - resolveWithinRoot(projectDir, workspace, { label: "--project" }); - } catch (err) { - throw projectBoundaryFailure(err, taskId, projectFlag, input); - } + // 1. The location. The frozen boundary must still be the directory it was, + // and the project must resolve inside it now — a workspace or project + // replaced by a symlink while the request was in flight, or a project that + // now resolves elsewhere, is a boundary problem: the task exists and is + // journaled, but no recovery command may be handed out that writes across + // that line. What passes is written through its real path. + let located: string; + try { + located = resolveWithinRoot(projectDir, root, { label: "--project" }).path; + } catch (err) { + throw projectBoundaryFailure(err, taskId, projectFlag, input); } - // 2. The record itself, including "is this still an initialised project". + // 2. The record itself, including "is this (still) an initialised project". try { - assertProjectMetadataPresent(projectDir, projectFlag); - const snapshot = task && raw ? saveTaskSnapshot(projectDir, taskId, raw) : null; + if (opts.__projectInitialised === false && !existsSync(join(located, "metadata.json"))) { + throw new CliError({ code: "local_io", message: `--project ${projectFlag} is not an initialised project (no metadata.json); run \`meshy project init\` first` }); + } + assertProjectMetadataPresent(located, projectFlag); + const snapshot = task && raw ? saveTaskSnapshot(located, taskId, raw) : null; input.taskJson = snapshot?.relative ?? null; - const indexRoot = indexRootFor(projectDir, undefined, workspace); - const rec = recordTask(projectDir, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); + const indexRoot = indexRootFor(located, undefined, opened.flags.workspaceRoot); + const rec = recordTask(located, input, { root: indexRoot.root, skipIndex: indexRoot.skipIndex }); if (!rec.index.updated) warnings.push(warning("index_dirty", `metadata.json committed but history.json was not updated: ${rec.index.error}; run \`meshy project rebuild-index\``)); if (rec.migrated_from_legacy) warnings.push(warning("metadata_migrated", "legacy metadata.json migrated to schema_version 2 (backup kept beside it)")); return { project_dir: projectDir, snapshot: snapshot?.path ?? null, stage, action: rec.action, index: rec.index }; @@ -362,7 +371,7 @@ function projectBoundaryFailure(err: unknown, taskId: string, projectFlag: strin const reason = err instanceof Error ? err.message : String(err); return new CliError({ code: "local_io", - message: `task ${taskId} exists${input.operationId ? ` (operation ${input.operationId})` : ""} but --project ${projectFlag} is no longer a target inside the workspace: ${reason}; nothing was recorded. Restore the project inside the workspace, then record the task with \`meshy project record\` from that workspace`, + message: `task ${taskId} exists${input.operationId ? ` (operation ${input.operationId})` : ""} but --project ${projectFlag} is no longer a target inside the authorised boundary: ${reason}; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record the task with \`meshy project record\` from that workspace`, details: { project: projectFlag, task_id: taskId, operation_id: input.operationId ?? null, stage: input.stage, recorded: false }, cause: err, }); @@ -372,7 +381,7 @@ function projectBoundaryFailure(err: unknown, taskId: string, projectFlag: strin function saveJsonInContext(opts: Record, opened: OpenedCommand, raw: unknown, ctx: TaskContext): SavedJson | null { if (!opts.saveJson) return null; try { - return saveRawJson(String(opts.saveJson), raw, { workspace: opened.flags.workspace }); + return saveRawJson(String(opts.saveJson), raw, { workspace: opened.flags.workspaceRoot }); } catch (err) { throw withTaskContext(err, ctx); } @@ -402,7 +411,7 @@ function attachInContext( // --------------------------------------------------------------------------- /** `-o` target: inside the workspace (or its own directory), no symlink leaf, not an existing file. */ -export function preflightOutputPath(output: string, workspace: string | undefined, cwd = process.cwd()): void { +export function preflightOutputPath(output: string, workspace: string | AuthorisedRoot | undefined, cwd = process.cwd()): void { const abs = resolvePath(cwd, output); resolveWithinRoot(abs, workspace ?? dirname(abs), { cwd, label: "--output" }); if (looksLikeFile(abs) && existsSync(abs)) { @@ -415,7 +424,7 @@ export function preflightOutputPath(output: string, workspace: string | undefine } /** `--save-json` target: inside the workspace (or its own directory), no symlink leaf, not an existing file. */ -export function preflightSaveJsonPath(target: string, workspace: string | undefined, cwd = process.cwd()): void { +export function preflightSaveJsonPath(target: string, workspace: string | AuthorisedRoot | undefined, cwd = process.cwd()): void { const abs = resolvePath(cwd, target); const resolved = resolveWithinRoot(abs, workspace ?? dirname(abs), { cwd, label: "--save-json target" }); if (existsSync(resolved.path)) { @@ -427,12 +436,30 @@ export function preflightSaveJsonPath(target: string, workspace: string | undefi } } +/** + * Freeze the project's write boundary before the first request: with a + * workspace it is the workspace (already frozen with the flags); without one it + * is the project directory as it is right now. Whether the project was + * initialised at this moment is remembered too, so a later "no metadata.json" + * can say which it was. Nothing is refused here for get/wait/stream — a + * bookkeeping problem stays a bookkeeping problem after the request (one + * outcome); create's preflight refuses an uninitialised project before its POST. + */ +function beginProjectContext(opts: Record, opened: OpenedCommand): void { + const projectFlag = opts.project as string | undefined; + if (!projectFlag || opts.__projectRoot) return; + const projectDir = resolvePath(projectFlag); + opts.__projectInitialised = existsSync(join(projectDir, "metadata.json")); + opts.__projectRoot = opened.flags.workspaceRoot ?? freezeRoot(projectDir, { label: "--project" }); +} + function preflightLocalTargets(opts: Record, opened: OpenedCommand): void { - if (opts.saveJson) preflightSaveJsonPath(String(opts.saveJson), opened.flags.workspace); - if (opened.flags.output) preflightOutputPath(opened.flags.output, opened.flags.workspace); + if (opts.saveJson) preflightSaveJsonPath(String(opts.saveJson), opened.flags.workspaceRoot); + if (opened.flags.output) preflightOutputPath(opened.flags.output, opened.flags.workspaceRoot); if (opts.project) { try { - resolveProjectDir(String(opts.project), opened.flags.workspace); + resolveProjectDir(String(opts.project), opened.flags.workspaceRoot); + beginProjectContext(opts, opened); } catch (err) { if (err instanceof CliError) { throw new CliError({ code: err.code, message: `${err.message} (nothing was submitted)`, recovery: err.recovery, cause: err }); @@ -542,6 +569,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { TASK_JSON_OPTIONS(cmd.command("get ").description("Retrieve a single task by id (any status is a successful query)")).action( async (taskId: string, opts: Record, thisCmd: Command) => { const opened = openCommand(thisCmd, `${prefix}.get`, defaultSchema); + beginProjectContext(opts, opened); const runtime = await buildRuntime(opened.flags); const { task, raw } = await endpointOf(runtime.client).retrieveDetailed(taskId, { signal: abortSignal() }); const submission: SubmissionInfo = { state: "accepted", operation_id: null }; @@ -575,6 +603,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { .option("--timeout ", "max seconds to wait (0 = a single query)", "600"), ).action(async (taskId: string, opts: Record, thisCmd: Command) => { const opened = openCommand(thisCmd, `${prefix}.wait`, defaultSchema); + beginProjectContext(opts, opened); const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); const runtime = await buildRuntime(opened.flags); const endpoint = endpointOf(runtime.client); @@ -590,6 +619,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { .option("--idle-timeout ", "abort when no bytes arrive for this long (keep-alives count)", "60"), ).action(async (taskId: string, opts: Record, thisCmd: Command) => { const opened = openCommand(thisCmd, `${prefix}.stream`, defaultSchema); + beginProjectContext(opts, opened); if (!descriptor.supports.stream) throw new UsageError(`${spec.name} does not support stream`); const timeoutSeconds = parseTimeoutSeconds(opts.timeout ?? "600"); const idleSeconds = parseTimeoutSeconds(opts.idleTimeout ?? "60", "--idle-timeout"); @@ -634,7 +664,7 @@ export function buildResourceCommand(spec: ResourceCommandSpec): Command { if (!Number.isInteger(page.page_num) || page.page_num < 1) throw new UsageError("--page must be a positive integer"); if (!Number.isInteger(page.page_size) || page.page_size < 1) throw new UsageError("--page-size must be a positive integer"); const { tasks, raw } = await endpointOf(runtime.client).listDetailed(page, { signal: abortSignal() }); - const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspace }) : null; + const savedJson = opts.saveJson ? saveRawJson(opts.saveJson as string, raw, { workspace: opened.flags.workspaceRoot }) : null; const rawItems = Array.isArray(raw) ? raw : []; await emitResult( opened, @@ -872,7 +902,7 @@ async function maybeDownloadV1( if (!output) return NOT_REQUESTED(); if (task.status !== "SUCCEEDED") return { state: "not_ready", files: [], metadata_path: null }; try { - const { files, metadataPath, materialLinks } = await downloadArtifacts(task, output, descriptor.id, { root: opened.flags.workspace, signal: abortSignal() }); + const { files, metadataPath, materialLinks } = await downloadArtifacts(task, output, descriptor.id, { root: opened.flags.workspaceRoot, signal: abortSignal() }); if (materialLinks) warnings.push(...materialLinks.warnings); return { state: "completed", @@ -1059,7 +1089,7 @@ function interruptedError( let saved = savedJson; if (!saved && opts.saveJson && last) { try { - saved = saveRawJson(opts.saveJson as string, last.raw, { workspace: opened.flags.workspace }); + saved = saveRawJson(opts.saveJson as string, last.raw, { workspace: opened.flags.workspaceRoot }); } catch { saved = null; } @@ -1234,7 +1264,7 @@ async function emitLegacyOutcome( if (output) { if (succeeded) { - const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName, { root: runtime.flags.workspace, signal: abortSignal() }); + const { savedFiles, metadataPath } = await downloadArtifacts(task, output, resourceName, { root: runtime.flags.workspaceRoot, signal: abortSignal() }); const successReport: Parameters[0] = { status: "SUCCESS", taskId: task.id, diff --git a/tests/codex-review-round5.test.ts b/tests/codex-review-round5.test.ts index 7c968f4..07d2c88 100644 --- a/tests/codex-review-round5.test.ts +++ b/tests/codex-review-round5.test.ts @@ -249,6 +249,7 @@ test("E01/R5-F01 download --project P --workspace P (path with a space and a quo // --------------------------------------------------------------------------- interface TextureMap { + line: number; material: string | null; reference: string; resolved_to: string | null; @@ -293,7 +294,15 @@ test("E02/R5-F02 map_Kd shared.png + map_Bump shared.png with one base color and const dl = env.result.downloads; assert.equal(dl.state, "completed"); assert.equal(dl.material_links.status, "incomplete"); - const byKey = new Map(dl.material_links.texture_maps.map((l) => [l.reference + "/" + (l.candidates ?? []).join(","), l])); + const candidatesByLine = { "map_Kd shared.png": ["texture_0_base_color.png"], "map_Bump shared.png": ["texture_0_normal.png", "texture_1_normal.png"] } as const; + assert.deepEqual( + dl.material_links.texture_maps.map((l) => [l.line, l.candidates]), + [ + [2, [...candidatesByLine[lines[order[0]]]]], + [3, [...candidatesByLine[lines[order[1]]]]], + ], + `${label}: each line carries the candidate set of its own key (line 2 = ${lines[order[0]]}, line 3 = ${lines[order[1]]})`, + ); assert.equal(dl.material_links.texture_maps.length, 2); for (const l of dl.material_links.texture_maps) { assert.equal(l.reference, "shared.png"); @@ -304,8 +313,6 @@ test("E02/R5-F02 map_Kd shared.png + map_Bump shared.png with one base color and assert.match(l.note ?? "", /map_Bump could only make it texture_0_normal\.png or texture_1_normal\.png/); assert.match(l.note ?? "", /one reference names one file/); } - assert.ok(byKey.has("shared.png/texture_0_base_color.png"), `${label}: the map_Kd line lists its own candidate`); - assert.ok(byKey.has("shared.png/texture_0_normal.png,texture_1_normal.png"), `${label}: the map_Bump line lists its own candidates`); assert.deepEqual(dl.material_links.rewritten.map((p) => basename(p)), ["model.obj"], `${label}: only the OBJ's mtllib changed`); for (const f of dl.files) { assert.equal(sha(f.path), f.sha256, `${label}: ${f.key} digest matches disk`); @@ -441,8 +448,11 @@ test("E03/R5-F03 legacy/v1 × get/wait/stream/create-async/create-sync × metada assert.equal(records.length, 0, id); assert.equal(api.requests.filter((q) => q.method === "POST").length, 0, id); } - if (verb === "get" || verb === "stream" || verb === "create-async") assert.equal(api.requests.length, 1, `${id}: one request only`); - assert.equal(api.requests.filter((q) => q.method === "DELETE").length, 0); + // Exact request sequence for every entry: the fixture is SUCCEEDED on the first GET, so no extra poll is tolerated. + const base = "/openapi/v2/text-to-3d"; + const expectedRequests: Array<[string, string]> = + verb === "get" || verb === "wait" ? [["GET", `${base}/${id}`]] : verb === "stream" ? [["GET", `${base}/${id}/stream`]] : verb === "create-async" ? [["POST", base]] : [["POST", base], ["GET", `${base}/${id}`]]; + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), expectedRequests, `${id}: exact method/path sequence`); // A damaged metadata still lets the snapshot land; a missing one does not — and the command says which. const taskJson = optionValue(words, "--task-json"); if (fault === "missing" || verb === "create-async") assert.equal(taskJson, undefined, `${id}: no snapshot was written`); @@ -505,7 +515,7 @@ test("R5-F03 a project that leaves the workspace during the request (replaced by assert.equal(records[0]!.state, "accepted"); assert.equal(result.submission.operation_id, records[0]!.operation_id); const message = schema === "v1" ? (out["error"] as { message: string }).message : String(out["message"]); - assert.match(message, /no longer a target inside the workspace/, schema); + assert.match(message, /no longer a target inside the authorised boundary/, schema); assert.match(message, /symbolic link|outside the authorised root/, schema); assert.match(message, /nothing was recorded/, schema); assert.match(message, new RegExp(`operation ${records[0]!.operation_id}`), `${schema}: the journal operation is named`); diff --git a/tests/codex-review-round6.test.ts b/tests/codex-review-round6.test.ts new file mode 100644 index 0000000..49066c4 --- /dev/null +++ b/tests/codex-review-round6.test.ts @@ -0,0 +1,313 @@ +/** + * Codex review round 6 (reviews/cli-s1-7b7c24c: R6-F01, R6-F02) as positive + * regressions with real subprocesses, a loopback API that records every request, + * synthetic credentials and isolated temp directories. The write boundary is + * frozen when a command starts — the real path and the directory identity of + * the --workspace (or of the project directory when no workspace is given) — + * and every later check proves that this very directory is still there and + * that the target resolves inside it. While a request is in flight the project + * directory, its parent, the workspace itself or the alias the workspace was + * given through is replaced by a symlink to an outside directory holding a + * valid project: the download command and every task verb refuse to record, + * keep the completed manifest / accepted task, write nothing outside (bytes of + * the outside metadata.json and history.json compared whole), and offer no + * command that would write across the boundary. Stable aliases keep working. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { basename, join, relative } from "node:path"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; + +const V1 = ["--output-schema", "v1"]; +const ENVELOPE_KEYS = ["schema_version", "command", "ok", "result", "error", "warnings"].sort(); + +function taskBody(fields: Record = {}): Record { + return { id: "round6-task", status: "SUCCEEDED", type: "text-to-3d-preview", progress: 100, ...fields }; +} + +function glb(payload = "x"): Buffer { + const chunk = Buffer.from(`{"asset":{"version":"2.0"},"x":"${payload}"} `); + const head = Buffer.alloc(20); + head.write("glTF", 0, "ascii"); + head.writeUInt32LE(2, 4); + head.writeUInt32LE(20 + chunk.length, 8); + head.writeUInt32LE(chunk.length, 12); + head.writeUInt32LE(0x4e4f534a, 16); + return Buffer.concat([head, chunk]); +} + +function sha(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function listing(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).sort() : []; +} + +/** Every file under `dir` (relative POSIX paths → sha256), so "nothing changed outside" is a whole-tree statement. */ +function treeDigest(dir: string): Record { + const out: Record = {}; + const walk = (d: string): void => { + for (const name of readdirSync(d, { withFileTypes: true })) { + const p = join(d, name.name); + if (name.isDirectory()) walk(p); + else if (name.isFile()) out[relative(dir, p).split("\\").join("/")] = sha(p); + else out[relative(dir, p).split("\\").join("/")] = `<${name.isSymbolicLink() ? "symlink" : "special"}>`; + } + }; + walk(dir); + return out; +} + +interface JournalRecord { + operation_id: string; + state: string; + task_id: string | null; +} + +function journal(configDir: string): JournalRecord[] { + const ops = join(configDir, "operations"); + if (!existsSync(ops)) return []; + return readdirSync(ops) + .filter((f) => f.endsWith(".json")) + .map((f) => JSON.parse(readFileSync(join(ops, f), "utf8")) as JournalRecord); +} + +interface ProjectFailure { + project_dir: string; + action: string; + stage: string; + recorded_files: string[]; + error: { code: string; message: string }; + recovery: unknown; +} + +// --------------------------------------------------------------------------- +// R6-F01 — the download command re-validates the project against the frozen boundary +// --------------------------------------------------------------------------- + +test("R6-F01 download --project P --workspace W --output-dir W/assets: when P (leaf) or W/projects (parent) is replaced by a symlink to an outside project during the asset GET — task-json and API sources — the record is refused: exit 11, completed manifest kept, project.failed with recovery null, outside tree byte-identical, exact requests; a healthy run still records", async () => { + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + if (req.path === "/model.glb") { + plant?.(); + plant = null; + res.writeHead(200, { "content-type": "model/gltf-binary" }); + return void res.end(glb()); + } + if (req.path.startsWith("/openapi/")) return jsonReply(res, 200, taskBody({ model_urls: { glb: `${api.url}/model.glb` } })); + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const fixture = join(dir, "task.json"); + writeFileSync(fixture, JSON.stringify(taskBody({ model_urls: { glb: `${api.url}/model.glb` } }))); + for (const source of ["task-json", "api"] as const) { + for (const target of ["leaf", "parent"] as const) { + const label = `${source}/${target}`; + const workspace = join(dir, `ws-${source}-${target}`); + mkdirSync(workspace); + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", "escape"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + // A valid project tree outside the workspace, mirroring what the symlink will point at. + const outside = join(dir, `outside-${source}-${target}`); + cpSync(target === "leaf" ? proj : join(workspace, "projects"), outside, { recursive: true }); + const outsideBefore = treeDigest(outside); + const replaced = target === "leaf" ? proj : join(workspace, "projects"); + plant = () => { + renameSync(replaced, `${replaced}.moved`); + symlinkSync(outside, replaced); + }; + const assets = join(workspace, "assets"); + api.requests.length = 0; + const args = source === "task-json" ? ["download", "--task-json", fixture] : ["download", "--resource", "text-to-3d", "--task-id", "round6-task"]; + const r = await runCli([...args, "--all", "--project", proj, "--workspace", workspace, "--output-dir", assets], { env, cwd: dir }); + assert.equal(r.code, 11, `${label}: ${r.stderr}\n${r.stdout}`); + const e = parseSingleJson(r.stdout) as { ok: boolean; result: { source: { task_id: string }; downloads: { state: string; files: Array<{ key: string; path: string; status: string; bytes: number; sha256: string }> }; project: ProjectFailure }; error: { code: string; message: string; recovery: unknown; hint?: string }; warnings: Array<{ code: string }> }; + assert.deepEqual(Object.keys(e).sort(), ENVELOPE_KEYS, label); + assert.equal(e.ok, false); + assert.equal(e.error.code, "local_io", label); + assert.match(e.error.message, /no longer a target inside the authorised boundary/, label); + assert.match(e.error.message, target === "leaf" ? /symbolic link/ : /outside the authorised root/, label); + assert.match(e.error.message, /nothing was recorded/, label); + assert.equal(e.error.recovery, null, `${label}: no command that would write across the boundary`); + assert.ok(!(e.error.hint ?? "").startsWith("meshy project record"), label); + assert.equal(e.result.source.task_id, "round6-task"); + assert.equal(e.result.downloads.state, "completed", `${label}: the transfer itself completed`); + assert.deepEqual(e.result.downloads.files.map((f) => [f.key, f.status]), [["model.glb", "written"]]); + const model = e.result.downloads.files[0]!; + assert.ok(existsSync(model.path) && readFileSync(model.path).equals(glb()), `${label}: the asset is on disk and not rolled back`); + assert.equal(sha(model.path), model.sha256); + assert.equal(readFileSync(model.path).length, model.bytes); + assert.equal(realpathSync(model.path), realpathSync(join(assets, "model.glb"))); + assert.equal(e.result.project.action, "failed", label); + assert.deepEqual(e.result.project.recorded_files, []); + assert.equal(e.result.project.error.code, "local_io"); + assert.equal(e.result.project.recovery, null, label); + assert.ok(!e.warnings.some((w) => w.code === "index_dirty"), `${label}: a boundary refusal is not disguised as index_dirty`); + assert.deepEqual(treeDigest(outside), outsideBefore, `${label}: the outside tree is byte-identical (metadata.json, history.json), no snapshot/lock/temp`); + assert.deepEqual( + api.requests.map((q) => [q.method, q.path]), + source === "task-json" ? [["GET", "/model.glb"]] : [["GET", "/openapi/v2/text-to-3d/round6-task"], ["GET", "/model.glb"]], + `${label}: exact requests, no retry, no POST`, + ); + // The moved original project was not written either. + const original = target === "leaf" ? `${proj}.moved` : join(`${replaced}.moved`, basename(proj)); + assert.equal((JSON.parse(readFileSync(join(original, "metadata.json"), "utf8")) as { tasks: unknown[] }).tasks.length, 0, `${label}: the original project is untouched`); + } + } + // Healthy control: nothing planted — the record lands, assets outside the project are reported as such. + const workspace = join(dir, "ws-healthy"); + mkdirSync(workspace); + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", "healthy"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + api.requests.length = 0; + const ok = await runCli(["download", "--task-json", fixture, "--all", "--project", proj, "--workspace", workspace, "--output-dir", join(workspace, "assets")], { env, cwd: dir }); + assert.equal(ok.code, 0, `${ok.stderr}\n${ok.stdout}`); + const o = parseSingleJson(ok.stdout) as { result: { project: { action: string; recorded_files: string[] } }; warnings: Array<{ code: string }> }; + assert.equal(o.result.project.action, "added"); + assert.deepEqual(o.result.project.recorded_files, []); + assert.ok(o.warnings.some((w) => w.code === "files_outside_project")); + assert.equal((JSON.parse(readFileSync(join(proj, "metadata.json"), "utf8")) as { tasks: Array<{ task_id: string }> }).tasks[0]!.task_id, "round6-task"); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [["GET", "/model.glb"]]); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// R6-F02 — the authorised root is frozen before the first request +// --------------------------------------------------------------------------- + +type Verb = "get" | "wait" | "stream" | "create-async" | "create-sync"; +type Swap = "directory" | "alias"; + +test("R6-F02 legacy/v1 × get/wait/stream/create-async/create-sync × workspace replaced (the directory itself swapped for a symlink to an outside copy, or the alias the workspace was given through re-pointed) while the request is in flight: exit 11, task and accepted journal kept, single POST, exact method/path, outside tree byte-identical, no snapshot/lock/temp, no cross-boundary record command", async () => { + for (const schema of ["legacy", "v1"] as const) { + for (const verb of ["get", "wait", "stream", "create-async", "create-sync"] as Verb[]) { + for (const swap of ["directory", "alias"] as Swap[]) { + const id = `${schema}-${verb}-${swap}`; + let plant: (() => void) | null = null; + const api = await startMockApi((req, res) => { + plant?.(); + plant = null; + if (req.method === "POST") return jsonReply(res, 200, { result: id }); + if (req.method === "DELETE") return jsonReply(res, 500, { message: "never" }); + const body = taskBody({ id }); + if (req.path.endsWith("/stream")) { + res.writeHead(200, { "content-type": "text/event-stream" }); + return void res.end(`data: ${JSON.stringify(body)}\n\n`); + } + return jsonReply(res, 200, body); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const configDir = String(env["MESHY_CONFIG_DIR"]); + const real = join(dir, "real-workspace"); + mkdirSync(real); + // "directory": the workspace is a plain directory that gets swapped for a symlink. + // "alias": the workspace is given through a symlink that keeps pointing at `real` … until it is re-pointed. + const workspace = swap === "directory" ? real : join(dir, "workspace-alias"); + if (swap === "alias") symlinkSync(real, workspace); + const init = await runCli(["project", "init", "--root", join(workspace, "projects"), "--name", id], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + const outside = join(dir, "outside"); + cpSync(real, outside, { recursive: true }); + const outsideBefore = treeDigest(outside); + const realBefore = treeDigest(real); + plant = + swap === "directory" + ? () => { + renameSync(real, `${real}.moved`); + symlinkSync(outside, real); + } + : () => { + unlinkSync(workspace); + symlinkSync(outside, workspace); + }; + const args = verb.startsWith("create") ? ["text-to-3d", "create", "--mode", "preview", "--prompt", "fixture", ...(verb === "create-async" ? ["--async"] : [])] : ["text-to-3d", verb, id]; + const r = await runCli([...args, ...(schema === "v1" ? V1 : []), "--project", proj, "--workspace", workspace], { env, cwd: dir }); + assert.equal(r.code, 11, `${id}: ${r.stderr}\n${r.stdout}`); + const out = parseSingleJson(r.stdout) as Record; + const result = out["result"] as { task_id: string; submission: { state: string; operation_id: string | null } }; + assert.equal(result.task_id, id, `${id}: the task is kept`); + assert.equal(result.submission.state, "accepted"); + const message = schema === "v1" ? (out["error"] as { message: string }).message : String(out["message"]); + assert.match(message, /no longer a target inside the authorised boundary/, id); + assert.match(message, /changed since the command started|outside the authorised root|symbolic link/, id); + assert.match(message, /nothing was recorded/, id); + const hint = schema === "v1" ? (out["error"] as { hint?: string }).hint : (out["hint"] as string | undefined); + assert.ok(!(hint ?? "").startsWith("meshy project record"), `${id}: no record command that would cross the boundary`); + if (schema === "v1") { + assert.deepEqual(Object.keys(out).sort(), ENVELOPE_KEYS, id); + assert.equal((out["error"] as { code: string }).code, "local_io", id); + assert.equal((out["error"] as { recovery: unknown }).recovery, null, `${id}: recovery null`); + } else { + assert.equal(out["code"], "local_io", id); + assert.equal(out["task_id"], id, id); + } + const records = journal(configDir); + const isCreate = verb.startsWith("create"); + if (isCreate) { + assert.equal(records.length, 1, `${id}: exactly one accepted journal record — nothing re-submitted`); + assert.equal(records[0]!.state, "accepted"); + assert.equal(records[0]!.task_id, id); + assert.equal(result.submission.operation_id, records[0]!.operation_id, id); + if (schema === "legacy") assert.equal(out["operation_id"], records[0]!.operation_id, id); + } else { + assert.equal(records.length, 0, id); + } + const base = "/openapi/v2/text-to-3d"; + const expected: Array<[string, string]> = + verb === "get" || verb === "wait" ? [["GET", `${base}/${id}`]] : verb === "stream" ? [["GET", `${base}/${id}/stream`]] : verb === "create-async" ? [["POST", base]] : [["POST", base], ["GET", `${base}/${id}`]]; + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), expected, `${id}: exact request sequence`); + assert.deepEqual(treeDigest(outside), outsideBefore, `${id}: the outside tree is byte-identical (no metadata/history change, no snapshot/lock/temp)`); + const original = swap === "directory" ? `${real}.moved` : real; + assert.deepEqual(treeDigest(original), realBefore, `${id}: the original workspace tree is untouched too`); + } finally { + await api.close(); + } + } + } + } +}); + +test("R6-F02 stable aliases keep working: a workspace given through a symlink that stays put, and a project inside a symlinked parent, record normally (snapshot in the real project, index refreshed)", async () => { + const api = await startMockApi((req, res) => { + if (req.method === "POST") return jsonReply(res, 200, { result: "alias-ok" }); + return jsonReply(res, 200, taskBody({ id: "alias-ok" })); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const real = join(dir, "real"); + mkdirSync(real); + const alias = join(dir, "alias"); + symlinkSync(real, alias); + const init = await runCli(["project", "init", "--root", join(alias, "projects"), "--name", "alias-ok"], { env, cwd: dir }); + assert.equal(init.code, 0, init.stderr); + const proj = (parseSingleJson(init.stdout) as { result: { project_dir: string } }).result.project_dir; + assert.ok(proj.startsWith(alias)); + const r = await runCli(["text-to-3d", "get", "alias-ok", ...V1, "--project", proj, "--workspace", alias], { env, cwd: dir }); + assert.equal(r.code, 0, `${r.stderr}\n${r.stdout}`); + const o = parseSingleJson(r.stdout) as { result: { project: { action: string; snapshot: string | null; index: { updated: boolean } } } }; + assert.equal(o.result.project.action, "added"); + assert.equal(o.result.project.index.updated, true); + assert.ok(o.result.project.snapshot && existsSync(o.result.project.snapshot)); + assert.equal(realpathSync(o.result.project.snapshot!), join(realpathSync(real), "projects", basename(proj), "task_alias-ok.json")); + const meta = JSON.parse(readFileSync(join(real, "projects", basename(proj), "metadata.json"), "utf8")) as { tasks: Array<{ task_id: string; task_json: string | null }> }; + assert.deepEqual(meta.tasks.map((t) => [t.task_id, t.task_json]), [["alias-ok", "task_alias-ok.json"]]); + assert.deepEqual(listing(join(real, "projects")).includes("history.json"), true); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [["GET", "/openapi/v2/text-to-3d/alias-ok"]]); + } finally { + await api.close(); + } +}); From 4da216d3568fbd997bf85f8047ce3932672a25de Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 11:47:45 +0800 Subject: [PATCH 23/26] docs(skill-parity): round-7 handoff after Codex review round 6 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verification.json, capability-matrix.json and REVIEW_HANDOFF.md bound to code HEAD e567646 (fix R6-F01/R6-F02/R6-T01): pnpm test 555/555, typecheck, diff --check on fd94490, 166492b and the tree, poll ×12, round-1 ×8, rounds 2–6 ×3, reviewer script copies (round1 0/12, round2 0/8, round3 0/6, round4 0/3 with D03 positive, round5 0/4, round6 0/4 with outside bytes unchanged, verify-original 20/20, verify-round3-4 8/8, verify-round5-positive 24/24, material matrix 16/16, round6 material 10/10, project entries 20/20, context 5/5, stream check), tarball smoke 29/29. Live account / Windows / Linux / slicer / paid checks stay not_run. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/REVIEW_HANDOFF.md | 415 +++++++------- docs/skill-parity/capability-matrix.json | 159 ++++-- docs/skill-parity/verification.json | 657 +++++++++++++++++------ 3 files changed, 826 insertions(+), 405 deletions(-) diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md index 0e2bc61..fcf0f0e 100644 --- a/docs/skill-parity/REVIEW_HANDOFF.md +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -1,28 +1,28 @@ # Meshy CLI S1 Review Handoff -Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 6**(Codex review 第 5 轮修复后的复审交接)。历次 review:第 1 轮 `…/reviews/cli-s1-6273d9a/`,第 2 轮 `…/reviews/cli-s1-730132b/`,第 3 轮 `…/reviews/cli-s1-cf8905d/`,第 4 轮 `…/reviews/cli-s1-235d6de/`,第 5 轮 `…/reviews/cli-s1-68690f9/`;五处 reviewer 证据目录均未被改动,所有复现/核对脚本逐字节复制到独立证据目录后运行(11 个副本 sha256 与原件一致)。 +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 7**(Codex review 第 6 轮修复后的复审交接)。历次 review:第 1 轮 `…/reviews/cli-s1-6273d9a/`,第 2 轮 `…/reviews/cli-s1-730132b/`,第 3 轮 `…/reviews/cli-s1-cf8905d/`,第 4 轮 `…/reviews/cli-s1-235d6de/`,第 5 轮 `…/reviews/cli-s1-68690f9/`,第 6 轮 `…/reviews/cli-s1-7b7c24c/`;六处 reviewer 证据目录均未被改动,所有复现/核对脚本(14 个)逐字节复制到独立证据目录后运行(副本 sha256 与原件一致)。 -## 0. 第 5 轮 review 结论与本轮修复 +## 0. 第 6 轮 review 结论与本轮修复 -- 被 review 的 HEAD:`e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59`(代码 `68690f9`);结论 **changes_requested**:3 项 P2 代码缺陷(R5-F01–R5-F03),未发现新的 P1;reviewer 全量 547/547,前两轮 20/20、R3+R4 正向 8/8、上下文 5/5,材质矩阵 14/16,项目入口矩阵 缺失分支 0/10 有恢复。 -- 修复 commit:`7b7c24c`(`fix(review): address Codex review round 5 findings R5-F01–R5-F03`)。本轮**没有**测试稳定性提交;68690f9 的 R03 改动按 reviewer 结论接受,未再改动。该 commit 顺带删去上一轮 `REVIEW_HANDOFF.md` 第 462 行样例中继承下来的一个尾随空格(`git diff --check fd94490` 在代码 HEAD 上因此为 exit 0;不改变任何 src/tests 语义)。`11 files changed, 757 insertions(+), 52 deletions(-)`(相对 e7c0bbc,含 docs)。 -- 复现脚本重跑(副本,`7b7c24c`):`round5-probes.mjs` E01 / E02×2 / E03 **均不再呈现旧行为**(reproduced=false 不是验收依据,正向断言见 `tests/codex-review-round5.test.ts`);`material-matrix-check.mjs` **16/16**;`project-entry-matrix-check.mjs` **20/20 有 record_project 恢复**(全部 exit 11、task_id 保留);`round4-probes.mjs` 0/3(D03 signal_sent=true / exit 130);`round3-probes.mjs` 0/6(fs.watch C03 signal_sent=true / exit 130,本轮触发成功);`round2-probes.mjs` 0/8;`round1-probes.mjs` 0/12;`verify-original-regressions.py` **20/20**;`verify-round3-4-regressions.py` **8/8**;`round4-context-checks.mjs` **5/5**;`stream-finalization-check.mjs` 通过。 -- 正向回归:`tests/codex-review-round5.test.ts`(5 项:E01 逐字回放含带空格与引号的 workspace、无 workspace 语义不变;E02 两种顺序;8×2 仲裁矩阵;E03 20 组入口矩阵 + 逐字回放;项目越界边界用例);`tests/codex-review-round4.test.ts` D02 改为逐字回放(不再补 `--workspace`),R4-T01 SIGINT 分支精确断言 `downloads.state=failed` 与 `[model_glb, failed]`;前四轮回归全部保留并通过。 -- 全量:`pnpm typecheck` 通过;`pnpm test` **552/552**(0 失败、0 跳过);`git diff --check fd94490` 与工作树 exit 0;`poll.test.ts` 连续 12 次 12/12;`codex-review-round1.test.ts` 连续 8 次 8/8;round2–5 套件连续 3 次 3/3。 +- 被 review 的 HEAD:`166492ba9691f093b302c1c114e782bceee14a1d`(代码 `7b7c24c`);结论 **changes_requested**:2 项 P1 代码缺陷(R6-F01、R6-F02)+ 1 项 P3 测试缺口(R6-T01);reviewer 全量 552/552,前两轮 20/20、R3+R4 正向 8/8、R5 正向 24/24、材质矩阵 16/16 + 补充 10/10、项目入口矩阵 20/20、上下文 5/5;新探针 4/4 复现。 +- 修复 commit:`e567646`(`fix(review): address Codex review round 6 findings R6-F01, R6-F02 and test gap R6-T01`)。本轮没有测试稳定性提交。`23 files changed, 665 insertions(+), 105 deletions(-)`(相对 166492b,含 docs)。 +- 复现脚本重跑(副本,`e567646`):`round6-probes.mjs` F01 task-json/api、F02 get/create-async **全部 exit 11、外部 metadata/history 字节不变、recovery null**(reproduced=false 不是验收依据,正向断言见 `tests/codex-review-round6.test.ts`);`round5-probes.mjs` 0/4;`round4-probes.mjs` 0/3(D03 signal_sent=true / exit 130);`round3-probes.mjs` 0/6(fs.watch C03 signal_sent=true / exit 130,本轮触发成功);`round2-probes.mjs` 0/8;`round1-probes.mjs` 0/12;`verify-original-regressions.py` **20/20**;`verify-round3-4-regressions.py` **8/8**;`verify-round5-positive.py` **24/24**;`material-matrix-check.mjs` **16/16**;`round6-material-check.mjs` **10/10**;`project-entry-matrix-check.mjs` **20/20**;`round4-context-checks.mjs` **5/5**;`stream-finalization-check.mjs` 通过。 +- 正向回归:`tests/codex-review-round6.test.ts`(3 项:F01 download × task-json/API × 项目叶子/父目录换成外部链接 + 健康对照;F02 legacy/v1 × get/wait/stream/create-async/create-sync × workspace 目录被换/别名被重指向 = 20 组;稳定别名对照);`tests/codex-review-round5.test.ts` E03 全部 20 组精确 method/path 序列、E02 候选按行与 key 绑定(R6-T01);前五轮回归全部保留并通过。 +- 全量:`pnpm typecheck` 通过;`pnpm test` **555/555**(0 失败、0 跳过);`git diff --check` 在 `fd94490..HEAD`、`166492b..HEAD` 与工作树均 exit 0;`poll.test.ts` 连续 12 次 12/12;`codex-review-round1.test.ts` 连续 8 次 8/8;round2–6 套件连续 3 次 3/3。 | ID | 优先级 | 问题 | 修复 | 决策 | 回归测试 | 复现脚本重跑 | | --- | --- | --- | --- | --- | --- | --- | -| R5-F01 | P2 | `record_project` 恢复命令不带原 `--workspace`,回放后更新了边界外的父目录 history.json 并在边界外建锁 | `projectRecordCommand(projectDir, input, { root?, workspace? })` 追加 `--workspace <解析后的绝对路径>`(与其它参数同样 shell 引用);`download` 的 `projectRecordFailure` 与任务动词的 `attachToProject` 两处调用均传入;不通过省略约束让恢复成功;无显式 workspace 时不追加、父索引照常刷新 | D-054 | E01(workspace==project 且路径含空格与 `'`:命令被正确引用;修复后**逐字回放**——测试不补任何参数——exit 0、metadata 记录 model.glb、`index.updated=false` 且 `index_dirty`、父 history.json 字节不变、父目录列表不变(无锁/temp)、0 请求;无 workspace 对照:命令无 `--workspace`,父索引刷新);D02 回放改为逐字并断言命令含原 workspace | E01: history_changed=false, index_dirty | -| R5-F02 | P2 | 同一引用在不同 key 下一处命中、另一处 ambiguous 时,只比较 hit,命中行被局部改写 | `arbitrate` 分两步:**先按 reference 汇总所有 key**——身份证据与 key 无关直接保留;否则每个 key 的通道规则给出候选集(命中=单元素、ambiguous=其候选、该通道无贴图=不提供证据),全部相同且为单一贴图才成立,否则该 reference 所有行保持原文、`method: ambiguous`、各自候选、共享一条跨 key note("one reference names one file");**再**做 round-4 的跨引用竞争否决 | D-055 | E02(两种顺序:MTL 字节不变、两行 ambiguous、各行候选 `[base]` / `[n0, n1]`、note 点名两个 key 与各自结果、rewritten 仅 OBJ、bytes/sha 与磁盘一致、1 条 warning);8×2 仲裁矩阵纳入仓库测试(身份+启发式、ambiguous 竞争者、同 ref 两命中 / 命中+ambiguous / 同身份 / 同回退、不同通道、身份+ambiguous 竞争者);D01/C05/N04/round-1 不变 | E02×2: MTL 未改写;矩阵 16/16 | -| R5-F03 | P2 | `resolveProjectDir` 在 `attachToProject` 的 try 之外:metadata 在请求期间消失时 recovery=null、hint 退化为 wait(legacy/v1 × 5 入口共 10 组) | 先建立恢复上下文(task id、journal operation、stage、解析后的 workspace),再在阶段内做两类检查:项目不再位于 workspace 内(或变成符号链接)→ **边界失败**:说明任务与 operation、未记录任何东西、如何处理,但**不给出**会跨越边界的命令;其它(`assertProjectMetadataPresent`:metadata 消失或非常规文件、损坏、锁、磁盘满)→ `record_project` 命令含 `--operation-id` 与 `--workspace`(同为 hint,legacy payload 亦携带);不重提、不建议 `project init`;`index_dirty` 语义不变;`download` 在记账前应用同一断言(消失 → local_io 而非 not_found) | D-056 | E03 矩阵(legacy/v1 × get/wait/stream/create --async/sync create × missing/damaged = 20 组:exit 11、task_id、create 恰 1 POST 且 journal 恰 1 条 accepted、`submission.operation_id` 与 legacy 顶层 `operation_id` = journal、命令含 `--operation-id`/`--workspace`/`--stage preview`、damaged 分支含快照 `--task-json`;修复后逐字回放:0 请求、正确记录、index_dirty、父 history 不变);越界用例(项目在 POST 期间被换成指向外部目录的符号链接:exit 11、task 与 operation 被点名、recovery null、无 record 命令、外部目录未被写入) | E03: recovery 存在;入口矩阵 20/20 | +| R6-F02 | P1 | 授权根在每次检查时重新 realpath:请求期间把 workspace(或其别名)换成指向外部树的符号链接,root 与目标一起移动,检查通过,snapshot/metadata/history 写到外部,exit 0 | 授权根改为 `AuthorisedRoot`,在读取全局 flags 时冻结(`freezeRoot`:真实路径 + 目录 dev/inode);`resolveWithinRoot` 收到冻结根时不再解析根,先证明同一目录仍在原路径(出现符号链接或不同目录 → "changed since the command started"),再证明目标真实路径在冻结的真实根内。冻结根贯穿 `--save-json`、`-o` 下载(downloadArtifacts/downloadAssets/sidecar/report-only)、`make`、`mesh prepare-print`、`project` 命令与任务动词的项目记账;无 workspace 时项目目录本身在首次请求前冻结(`beginProjectContext`),记账通过证明过的真实路径写入。越界为边界失败:exit 11、任务与 accepted journal 保留、单 POST、外部零写入、recovery null、无跨界命令。稳定别名照常通过 | D-057 | F02 矩阵(legacy/v1 × 5 动词 × 目录被换/别名重指向 = 20 组:exit 11、task_id、create 恰 1 POST 且 journal 恰 1 条 accepted、`submission.operation_id`/legacy 顶层 `operation_id` 对账、精确 method/path、外部树与原树整树摘要不变、recovery null、hint 非 record 命令);稳定别名对照(snapshot 落在真实项目、index 刷新) | F02 get/create-async: exit 11, metadata/history 不变 | +| R6-F01 | P1 | 独立 `download --project` 记账阶段只 lstat 了 `P/metadata.json`:P 或其父目录在资产 GET 期间换成指向外部项目的符号链接时被跟随,外部 metadata 被改写,exit 0 | 项目阶段在任何项目锁/快照/metadata 写入之前用冻结边界重新定位项目(`resolveWithinRoot(projectDir, projectRoot)`;无 workspace 时项目目录本身在预检后冻结),记账通过真实路径写入;边界失败(`projectBoundaryFailure`)与可修复的 metadata 内容问题分开:exit 11 / local_io、完整下载 outcome(source/selection/manifest 磁盘摘要/saved_json)、`project.action="failed"` + error、`recovery: null`、不生成跨界 record 命令、不伪装成 index_dirty;已下载资产不回滚。metadata 缺失/损坏仍走 D-052/D-056 的 record_project | D-058 | F01(task-json/API × 叶子/父目录:exit 11、完整 v1 形状、downloads.completed 与 bytes/sha、project.failed + recovery null、外部整树摘要不变、原项目 metadata 不变、精确请求 task-json `GET /model.glb` / API `GET task, GET /model.glb`);健康对照 exit 0 记账、`files_outside_project` | F01 task-json/api: exit 11, 外部 metadata 不变 | +| R6-T01 | P3 | E03 的 wait/create-sync 8 组没有精确 GET 数量/顺序断言;E02 候选按集合查找 | E03 20 组统一 deepEqual `[method, path]`(get/wait/stream 各 1 GET;create-async 单 POST;create-sync POST GET,路径含 task_id);E02 按 line 2/3 与原 key 绑定候选集 | — | `tests/codex-review-round5.test.ts`(R6-T01);新 round-6 矩阵同样精确断言序列 | verify-round5-positive 24/24 | ## 1. 代码定位 - 仓库路径 / remote:`/Users/ark/Dev/meshy-cli`(fresh clone) / `https://github.com/meshy-dev/meshy-cli.git` - 分支:`feat/skill-parity-s1`(本地分支,未推送) - base SHA:`fd94490916376e691efcea51324ac4326b459e1f`(0.2.0,= 计划基线 = 开工时的 remote main) -- head SHA(代码):`7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`(= 修复 `7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`);本文件与 verification.json/capability-matrix.json 在其后的 **docs-only commit** 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`、`pnpm-lock.yaml`) -- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe8`);round 3 `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905d`);round 4 `9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4`(代码 `235d6de`);round 5 `e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59`(代码 `68690f9`) +- head SHA(代码):`e567646d875e5f5a658b78e60b8cdfaed8b233e9`(= 修复 `e567646d875e5f5a658b78e60b8cdfaed8b233e9`);本文件与 verification.json/capability-matrix.json 在其后的 **docs-only commit** 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`、`pnpm-lock.yaml`) +- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe8`);round 3 `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905d`);round 4 `9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4`(代码 `235d6de`);round 5 `e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59`(代码 `68690f9`);round 6 `166492ba9691f093b302c1c114e782bceee14a1d`(代码 `7b7c24c`) - 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) - 实施包版本:2026-09-07 / v1 - 实际源码与计划基线的差异:无(见 `docs/skill-parity/baseline-delta.md`) @@ -31,18 +31,20 @@ Filled from the 2026-09-07 / v1 implementation package. `not_run` means not veri ## 2. 完成状态 -- G1-code / review-ready:**第 5 轮 3 项 finding 已修复并有正向回归;前四轮 26 项在新 HEAD 复核未回退(reviewer 正向脚本 20/20 与 8/8、四轮探针 0 复现、上下文 5/5);自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) +- G1-code / review-ready:**第 6 轮 2 项 P1 已修复并有正向回归,P3 测试缺口已补;前五轮 29 项在新 HEAD 复核未回退(reviewer 正向脚本 20/20、8/8、24/24,六轮探针 0 复现,矩阵 16/16、10/10、20/20,上下文 5/5);自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) - G1-release / ready-for-S2:**not_run**(无复审结论、无真实账号/多 OS/切片器验证、未发布;按用户要求不在本轮范围) -- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;8 项带 `review_round_5`,5 项 `review_round_4`,10 项 `review_round_3`,13 项 `review_round_2`,21 项 `review_round_1`,`review_status` 均为 "re-review pending") -- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **552 通过 / 0 失败 / 0 跳过**(552 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) -- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 7b7c24c 重跑:exit 0)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open -- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(五个 reviewer 目录未被改动;未调用付费接口;未发布) +- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;7 项带 `review_round_6`,8 项 `review_round_5`,5 项 `review_round_4`,10 项 `review_round_3`,13 项 `review_round_2`,21 项 `review_round_1`,`review_status` 均为 "re-review pending") +- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **555 通过 / 0 失败 / 0 跳过**(555 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) +- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 e567646 重跑:exit 0)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open +- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(六个 reviewer 目录未被改动;未调用付费接口;未发布) ## 3. 本次具体改动 `git log --oneline fd94490..HEAD`(最早在下): ``` +e567646 fix(review): address Codex review round 6 findings R6-F01, R6-F02 and test gap R6-T01 +166492b docs(skill-parity): round-6 handoff after Codex review round 5 fixes 7b7c24c fix(review): address Codex review round 5 findings R5-F01–R5-F03 e7c0bbc docs(skill-parity): round-5 handoff after Codex review round 4 fixes 68690f9 test(wait): allow the deadline wake-up's one extra GET that D-044 permits @@ -68,167 +70,181 @@ c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixt 修复 commit: ``` -7b7c24c fix(review): address Codex review round 5 findings R5-F01–R5-F03 +e567646 fix(review): address Codex review round 6 findings R6-F01, R6-F02 and test gap R6-T01 - README.md | 5 +- - docs/skill-parity/REVIEW_HANDOFF.md | 2 +- - docs/skill-parity/decisions.md | 60 ++++ - docs/skill-parity/migration-notes.md | 9 + + README.md | 2 +- + docs/skill-parity/decisions.md | 45 +++++ + docs/skill-parity/migration-notes.md | 8 + skills/meshy-cli/SKILL.md | 6 +- - src/cmd/download.ts | 13 +- - src/internal/material-links.ts | 86 ++++-- - src/internal/project-store.ts | 42 ++- - src/internal/task-command.ts | 51 +++- - tests/codex-review-round4.test.ts | 13 +- - tests/codex-review-round5.test.ts | 522 +++++++++++++++++++++++++++++++++++ - 11 files changed, 757 insertions(+), 52 deletions(-) + src/cmd/animation-catalog.ts | 2 +- + src/cmd/api.ts | 2 +- + src/cmd/balance.ts | 2 +- + src/cmd/doctor.ts | 2 +- + src/cmd/download.ts | 72 ++++++-- + src/cmd/inspect.ts | 2 +- + src/cmd/make.ts | 4 +- + src/cmd/mesh.ts | 2 +- + src/cmd/project.ts | 14 +- + src/cmd/showcases.ts | 2 +- + src/internal/command-helpers.ts | 6 +- + src/internal/download.ts | 56 ++++--- + src/internal/obj-transform.ts | 12 +- + src/internal/paths.ts | 99 ++++++++++- + src/internal/project-store.ts | 7 +- + src/internal/runtime.ts | 4 + + src/internal/task-command.ts | 86 ++++++---- + tests/codex-review-round5.test.ts | 22 ++- + tests/codex-review-round6.test.ts | 313 +++++++++++++++++++++++++++++++++++ + 23 files changed, 665 insertions(+), 105 deletions(-) ``` 要点: -- `src/internal/project-store.ts`:`projectRecordCommand` 增加 `workspace` 选项(`--workspace `,`shellArg` 引用);新增 `assertProjectMetadataPresent`(metadata.json 消失 → `local_io` "has no metadata.json any more (it was an initialised project when this command started)";非常规文件 → `local_io`)。 -- `src/internal/task-command.ts`:`attachToProject` 先构造 `input`/`workspace`,再 (1) `resolveWithinRoot` 边界检查 → `projectBoundaryFailure`(无命令,details 含 task_id/operation_id/stage/recorded=false),(2) try 内 `assertProjectMetadataPresent` → 快照 → `indexRootFor` → `recordTask`,catch → `record_project` 命令(含 `--operation-id`、`--workspace`)与 hint;`resolveProjectDir` 仅保留给 POST 前的 `preflightLocalTargets`("nothing was submitted" 语义不变)。 -- `src/cmd/download.ts`:项目阶段先算落在项目内的文件列表,再 `assertProjectMetadataPresent`,再记账;`projectRecordFailure` 接收并传递 `workspace`。 -- `src/internal/material-links.ts`:`arbitrate` 两步(按 reference 跨 key 汇总 → 跨引用竞争);模块注释更新。 -- 测试:`tests/codex-review-round5.test.ts`(新,5 项);`tests/codex-review-round4.test.ts` D02 逐字回放、R4-T01 SIGINT 精确 manifest。 -- 文档:`decisions.md` D-054–D-056;`migration-notes.md` §3.5;README `--project` 恢复段;SKILL.md 恢复命令处置。 +- `src/internal/paths.ts`:新增 `AuthorisedRoot`(given/real/anchor/dev/ino/directory/label)、`freezeRoot`(真实路径 + 最深已存在祖先的目录身份)、`assertRootIntact`(lstat 同一目录、非符号链接、dev/inode 相同);`resolveWithinRoot(target, root: string | AuthorisedRoot)`:冻结根不再 realpath,先 `assertRootIntact` 再判定目标真实路径在 `root.real` 内。 +- `src/internal/runtime.ts`:`GlobalFlags.workspaceRoot` 在 `readGlobalFlags` 中由 `--workspace` 冻结(首次 I/O 前)。 +- `src/internal/command-helpers.ts`(`saveRawJson`)、`src/internal/project-store.ts`(`indexRootFor`)、`src/internal/download.ts`(`downloadAssets`/`downloadArtifacts`/`placeFetched`/`downloadArtifact`/`saveReportOnly`/`writeMeta`)、`src/internal/obj-transform.ts`(写根)、`src/cmd/project.ts`、`src/cmd/mesh.ts`、`src/cmd/make.ts`、`balance/doctor/api/inspect/animation-catalog/showcases` 的 `--save-json`:全部改为接收并使用冻结根。 +- `src/internal/task-command.ts`:`beginProjectContext`(get/wait/stream 在 openCommand 后、create 在预检中冻结项目根并记住是否已初始化;不在此拒绝,未初始化的项目仍作为请求后的记账失败,保持 R2-F03 的单 outcome);`attachToProject` 用冻结根定位项目(`located` 真实路径),边界失败 → `projectBoundaryFailure`(无命令),其余 → record_project;记账通过 `located` 写入。 +- `src/cmd/download.ts`:`projectRoot`(workspace 或预检后冻结的项目目录);项目阶段先 `resolveWithinRoot(projectDir, projectRoot)` → 失败走新 `projectBoundaryFailure`(recovery null、project.failed、完整 outcome);通过后按真实路径算文件列表、断言 metadata、记账。 +- 测试:`tests/codex-review-round6.test.ts`(新,3 项);`tests/codex-review-round5.test.ts` R6-T01 精确请求序列与按行候选;`R5-F03` 边界用例的消息断言随文案更新("authorised boundary")。 +- 文档:`decisions.md` D-057–D-058;`migration-notes.md` §3.6;README `--workspace` 行;SKILL.md 边界失败处置。 ## 4. 能力与接口证据 -### 4.1 第 5 轮 finding 修复后的实际输出(`round5-probes.mjs` 副本重跑,`7b7c24c`,脱敏) - -**E01**(`download --task-json /scope-task.json --all --project /projects/_recovery-scope_4f87 --workspace /projects/_recovery-scope_4f87` → exit 11;模型 sha256 `1390b094bb61759b…`,外部 metadata 未变 = true)。返回的恢复命令(探针只替换启动器,**不补参数**): - -```text -meshy project record --project /projects/_recovery-scope_4f87 --task-id round2-task --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --file model.glb --workspace /projects/_recovery-scope_4f87 -``` +### 4.1 第 6 轮 finding 修复后的实际输出(`round6-probes.mjs` 副本重跑,`e567646`,脱敏) -修复 metadata 后逐字回放 → exit 0,父目录 history.json 变化 = **false**: +**F01-download-project-boundary-task-json** ```json { - "ok": true, - "result": { - "action": "added", - "index": { - "updated": false, - "error": "history root /projects resolves outside --workspace /projects/_recovery-scope_4f87; metadata.json was recorded but history.json was not touched (history root /projects resolves outside the authorised root /projects/_recovery-scope_4f87) — run `meshy project rebuild-index --root /projects` from a workspace that contains it" + "args": "download --task-json /boundary-task-json.json --all --project /ws-task-json/projects/_escaped-project_0d2d --workspace /ws-task-json --output-dir /ws-task-json/assets", + "exit": 11, + "error": { + "code": "local_io", + "message": "1 file(s) were downloaded to /ws-task-json/assets but --project /ws-task-json/projects/_escaped-project_0d2d is no longer a target inside the authorised boundary: --project /ws-task-json/projects/_escaped-project_0d2d is a symbolic link; refusing to write through it; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record task round2-task with `meshy project record` from that workspace", + "recovery": null, + "hint": null + }, + "result.project": { + "project_dir": "/ws-task-json/projects/_escaped-project_0d2d", + "action": "failed", + "stage": "preview", + "recorded_files": [], + "error": { + "code": "local_io", + "message": "--project /ws-task-json/projects/_escaped-project_0d2d is a symbolic link; refusing to write through it" }, - "task_count": 1 + "recovery": null }, - "warnings": [ - { - "code": "index_dirty", - "message": "metadata.json committed but history.json was not updated: history root /projects resolves outside --workspace /projects/_recovery-scope_4f87; metadata.json was recorded but history.json was not touched (history root /projects resolves outside the authorised root /projects/_recovery-scope_4f87) — run `meshy project rebuild-index --root /projects` from a workspace that contains it; run `meshy project rebuild-index`" - } + "result.task_id": "round2-task", + "downloads": { + "state": "completed" + }, + "external_metadata_changed": false, + "external_history_changed": null, + "external_listing": [ + "metadata.json" + ], + "requests": [ + "GET /model.glb" ] } ``` - -**E02**(两种顺序,exit 0,保存的 MTL 与服务端原文逐字节相同): - -```text -newmtl a -map_Kd shared.png -map_Bump shared.png - -``` +**F01-download-project-boundary-api** ```json -[ - { - "line": 2, - "reference": "shared.png", - "resolved_to": null, - "method": "ambiguous", - "candidates": [ - "texture_0_base_color.png" - ], - "note": "'shared.png' is used by map_Kd and map_Bump but resolves differently per key: map_Kd would make it texture_0_base_color.png (channel_of_key) while map_Bump could only make it texture_0_normal.png or texture_1_normal.png; one reference names one file, so none of its lines is rewritten" +{ + "args": "download --resource text-to-3d --task-id round2-task --all --project /ws-api/projects/_escaped-project_944d --workspace /ws-api --output-dir /ws-api/assets", + "exit": 11, + "error": { + "code": "local_io", + "message": "1 file(s) were downloaded to /ws-api/assets but --project /ws-api/projects/_escaped-project_944d is no longer a target inside the authorised boundary: --project /ws-api/projects/_escaped-project_944d is a symbolic link; refusing to write through it; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record task round2-task with `meshy project record` from that workspace", + "recovery": null, + "hint": null }, - { - "line": 3, - "reference": "shared.png", - "resolved_to": null, - "method": "ambiguous", - "candidates": [ - "texture_0_normal.png", - "texture_1_normal.png" - ], - "note": "'shared.png' is used by map_Kd and map_Bump but resolves differently per key: map_Kd would make it texture_0_base_color.png (channel_of_key) while map_Bump could only make it texture_0_normal.png or texture_1_normal.png; one reference names one file, so none of its lines is rewritten" - } -] -``` -```text -newmtl a -map_Bump shared.png -map_Kd shared.png - + "result.project": { + "project_dir": "/ws-api/projects/_escaped-project_944d", + "action": "failed", + "stage": "preview", + "recorded_files": [], + "error": { + "code": "local_io", + "message": "--project /ws-api/projects/_escaped-project_944d is a symbolic link; refusing to write through it" + }, + "recovery": null + }, + "result.task_id": "round2-task", + "downloads": { + "state": "completed" + }, + "external_metadata_changed": false, + "external_history_changed": null, + "external_listing": [ + "metadata.json" + ], + "requests": [ + "GET /openapi/v2/text-to-3d/round2-task", + "GET /model.glb" + ] +} ``` +**F02-mutable-workspace-boundary-get** ```json -[ - { - "line": 2, - "reference": "shared.png", - "resolved_to": null, - "method": "ambiguous", - "candidates": [ - "texture_0_normal.png", - "texture_1_normal.png" - ], - "note": "'shared.png' is used by map_Bump and map_Kd but resolves differently per key: map_Bump could only make it texture_0_normal.png or texture_1_normal.png while map_Kd would make it texture_0_base_color.png (channel_of_key); one reference names one file, so none of its lines is rewritten" +{ + "args": "text-to-3d get mutable-root-task --output-schema v1 --project /mutable-ws-get/projects/_mutable-root_cef3 --workspace /mutable-ws-get", + "exit": 11, + "error": { + "code": "local_io", + "message": "task mutable-root-task exists but --project /mutable-ws-get/projects/_mutable-root_cef3 is no longer a target inside the authorised boundary: --workspace /mutable-ws-get changed since the command started: /mutable-ws-get is now a symbolic link; refusing to write outside the authorised boundary; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record the task with `meshy project record` from that workspace", + "recovery": null, + "hint": "meshy text-to-3d wait mutable-root-task --output-schema v1" }, - { - "line": 3, - "reference": "shared.png", - "resolved_to": null, - "method": "ambiguous", - "candidates": [ - "texture_0_base_color.png" - ], - "note": "'shared.png' is used by map_Bump and map_Kd but resolves differently per key: map_Bump could only make it texture_0_normal.png or texture_1_normal.png while map_Kd would make it texture_0_base_color.png (channel_of_key); one reference names one file, so none of its lines is rewritten" - } -] + "result.project": null, + "result.task_id": "mutable-root-task", + "downloads": { + "state": "not_requested" + }, + "external_metadata_changed": false, + "external_history_changed": false, + "external_listing": [ + "metadata.json" + ], + "requests": [ + "GET /openapi/v2/text-to-3d/mutable-root-task" + ] +} ``` - -**E03**(`text-to-3d create --mode preview --prompt fixture --async --output-schema v1 --project /task-projects/_vanished-metadata_70a9 --workspace /task-projects/_vanished-metadata_70a9` → exit 11;请求:POST /openapi/v2/text-to-3d): +**F02-mutable-workspace-boundary-create-async** ```json { - "ok": false, - "result": { - "task_id": "accepted-project-recovery-task", - "submission": { - "state": "accepted", - "operation_id": "c261d382-aee7-41dd-b6ff-6c93c5103dae", - "task_id": "accepted-project-recovery-task", - "request_id": null - }, - "next": { - "get": "meshy text-to-3d get accepted-project-recovery-task --output-schema v1", - "wait": "meshy text-to-3d wait accepted-project-recovery-task --output-schema v1", - "stream": "meshy text-to-3d stream accepted-project-recovery-task --format ndjson --output-schema v1" - } - }, + "args": "text-to-3d create --mode preview --prompt fixture --async --output-schema v1 --project /mutable-ws-create-async/projects/_mutable-root_66f8 --workspace /mutable-ws-create-async", + "exit": 11, "error": { "code": "local_io", - "message": "task accepted-project-recovery-task exists (operation c261d382-aee7-41dd-b6ff-6c93c5103dae) but recording it in /task-projects/_vanished-metadata_70a9 failed: --project /task-projects/_vanished-metadata_70a9 has no metadata.json any more (it was an initialised project when this command started); restore the project, then run: meshy project record --project /task-projects/_vanished-metadata_70a9 --task-id accepted-project-recovery-task --stage preview --resource text-to-3d --operation-id c261d382-aee7-41dd-b6ff-6c93c5103dae --workspace /task-projects/_vanished-metadata_70a9", - "http_status": null, - "retryable": false, - "recovery": { - "action": "record_project", - "automatic": false, - "command": "meshy project record --project /task-projects/_vanished-metadata_70a9 --task-id accepted-project-recovery-task --stage preview --resource text-to-3d --operation-id c261d382-aee7-41dd-b6ff-6c93c5103dae --workspace /task-projects/_vanished-metadata_70a9" - }, - "hint": "meshy project record --project /task-projects/_vanished-metadata_70a9 --task-id accepted-project-recovery-task --stage preview --resource text-to-3d --operation-id c261d382-aee7-41dd-b6ff-6c93c5103dae --workspace /task-projects/_vanished-metadata_70a9" - } + "message": "task mutable-root-task exists (operation 2ac86063-8cad-4294-8728-20fccc09a615) but --project /mutable-ws-create-async/projects/_mutable-root_66f8 is no longer a target inside the authorised boundary: --workspace /mutable-ws-create-async changed since the command started: /mutable-ws-create-async is now a symbolic link; refusing to write outside the authorised boundary; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record the task with `meshy project record` from that workspace", + "recovery": null, + "hint": "meshy text-to-3d wait mutable-root-task --output-schema v1" + }, + "result.project": null, + "result.task_id": "mutable-root-task", + "downloads": { + "state": "not_requested" + }, + "external_metadata_changed": false, + "external_history_changed": false, + "external_listing": [ + "metadata.json" + ], + "requests": [ + "POST /openapi/v2/text-to-3d" + ] } ``` -### 4.2 入口矩阵与材质矩阵(reviewer 脚本副本,`7b7c24c`) +### 4.2 矩阵(reviewer 脚本副本,`e567646`) -`project-entry-matrix-check.mjs`:20/20 组有 `record_project` 恢复,全部 exit 11、task_id 保留。 +`project-entry-matrix-check.mjs`:20/20 组有 `record_project` 恢复(metadata 缺失/损坏 = 可修复内容问题),全部 exit 11、task_id 保留。 | schema | verb | fault | exit | task_id | record_project | | --- | --- | --- | --- | --- | --- | @@ -253,56 +269,9 @@ map_Kd shared.png | v1 | create-sync | damaged | 11 | v1-create-sync-damaged | yes | | v1 | create-sync | missing | 11 | v1-create-sync-missing | yes | -legacy `get` / metadata 消失 的实际 payload(hint 即恢复命令,含 `--workspace`): - -```json -{ - "code": "local_io", - "task_id": "legacy-get-missing", - "hint": "meshy project record --project /projects/_legacy-get-missing_0eb7 --task-id legacy-get-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --workspace /projects/_legacy-get-missing_0eb7", - "message": "task legacy-get-missing exists but recording it in /projects/_legacy-get-missing_0eb7 failed: --project /projects/_legacy-get-missing_0eb7 has no metadata.json any more (it was an initialised project when this command started); restore the project, then run: meshy project record --project /projects/_legacy-get-missing_0eb7 --task-id legacy-get-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --workspace /projects/_legacy-get-missing_0eb7" -} -``` - -v1 同步 `create` / metadata 消失 的 error(`--operation-id` 与 journal 一致): - -```json -{ - "code": "local_io", - "message": "task v1-create-sync-missing exists (operation 176a6426-84f7-4885-9d69-35652b7b371d) but recording it in /projects/_v1-create-sync-missing_e63c failed: --project /projects/_v1-create-sync-missing_e63c has no metadata.json any more (it was an initialised project when this command started); restore the project, then run: meshy project record --project /projects/_v1-create-sync-missing_e63c --task-id v1-create-sync-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --operation-id 176a6426-84f7-4885-9d69-35652b7b371d --workspace /projects/_v1-create-sync-missing_e63c", - "http_status": null, - "retryable": false, - "recovery": { - "action": "record_project", - "automatic": false, - "command": "meshy project record --project /projects/_v1-create-sync-missing_e63c --task-id v1-create-sync-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --operation-id 176a6426-84f7-4885-9d69-35652b7b371d --workspace /projects/_v1-create-sync-missing_e63c" - }, - "hint": "meshy project record --project /projects/_v1-create-sync-missing_e63c --task-id v1-create-sync-missing --stage preview --resource text-to-3d --task-type text-to-3d-preview --status SUCCEEDED --operation-id 176a6426-84f7-4885-9d69-35652b7b371d --workspace /projects/_v1-create-sync-missing_e63c" -} -``` +`material-matrix-check.mjs` 16/16;`round6-material-check.mjs` 10/10(无候选通道不提供证据、相同多候选集、source_name/source_stem + 无通道、map key 大小写)。 -`material-matrix-check.mjs`:16/16。 - -| 组合 | 顺序 | 结果 | 实际 [resolved_to, method] | -| --- | --- | --- | --- | -| identity-plus-heuristic | as listed | pass | [["texture_0_base_color.png", "source_name"], [null, "ambiguous"]] | -| identity-plus-heuristic | reversed | pass | [[null, "ambiguous"], ["texture_0_base_color.png", "source_name"]] | -| ambiguous-plus-heuristic | as listed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | -| ambiguous-plus-heuristic | reversed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | -| same-ref-two-hits | as listed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | -| same-ref-two-hits | reversed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | -| same-ref-hit-and-ambiguous | as listed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | -| same-ref-hit-and-ambiguous | reversed | pass | [[null, "ambiguous"], [null, "ambiguous"]] | -| same-ref-same-identity | as listed | pass | [["texture_0_base_color.png", "source_name"], ["texture_0_base_color.png", "source_name"]] | -| same-ref-same-identity | reversed | pass | [["texture_0_base_color.png", "source_name"], ["texture_0_base_color.png", "source_name"]] | -| same-ref-same-fallback | as listed | pass | [["texture_0_normal.png", "channel_in_name"], ["texture_0_normal.png", "channel_in_name"]] | -| same-ref-same-fallback | reversed | pass | [["texture_0_normal.png", "channel_in_name"], ["texture_0_normal.png", "channel_in_name"]] | -| distinct-channel-fallbacks | as listed | pass | [["texture_0_base_color.png", "channel_of_key"], ["texture_0_normal.png", "channel_in_name"]] | -| distinct-channel-fallbacks | reversed | pass | [["texture_0_normal.png", "channel_in_name"], ["texture_0_base_color.png", "channel_of_key"]] | -| identity-with-ambiguous-source-rival | as listed | pass | [["texture_0_base_color.png", "source_name"], [null, "ambiguous"]] | -| identity-with-ambiguous-source-rival | reversed | pass | [[null, "ambiguous"], ["texture_0_base_color.png", "source_name"]] | - -### 4.3 tarball 安装 smoke(`7b7c24c`,`meshy-cli-0.3.0.tgz`;`` 为临时 npm prefix) +### 4.3 tarball 安装 smoke(`e567646`,`meshy-cli-0.3.0.tgz`;`` 为临时 npm prefix) `/prefix/bin/meshy resources --output-schema v1` → exit 0 @@ -435,8 +404,8 @@ v1 同步 `create` / metadata 消失 的 error(`--operation-id` 与 journal ], "task_json": null, "operation_id": null, - "created_at": "2026-09-08T03:09:36.915Z", - "updated_at": "2026-09-08T03:09:36.915Z" + "created_at": "2026-09-08T03:45:25.184Z", + "updated_at": "2026-09-08T03:45:25.184Z" }, "task_count": 1, "index": { @@ -493,31 +462,33 @@ v1 同步 `create` / metadata 消失 的 error(`--operation-id` 与 journal ## 5. 实际验证记录 -全部绑定代码 HEAD `7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`(Node v24.20.0,pnpm 11.24.0);机器记录见 `docs/skill-parity/verification.json`。 +全部绑定代码 HEAD `e567646d875e5f5a658b78e60b8cdfaed8b233e9`(Node v24.20.0,pnpm 11.24.0);机器记录见 `docs/skill-parity/verification.json`。 | 检查 | 结果 | 说明 | | --- | --- | --- | | `pnpm install --frozen-lockfile` | exit 0 | | | `pnpm typecheck` | exit 0 | src + tests | | `pnpm build` | exit 0 | | -| `pnpm test` | **552 pass / 0 fail / 0 skip**(552 项,42 s) | 含 5 项 round-5 新回归 | +| `pnpm test` | **555 pass / 0 fail / 0 skip**(555 项,45 s) | 含 3 项 round-6 新回归 | | `dist/index.js --version` = package.json | exit 0 | | -| `git diff --check fd94490` / 工作树 | exit 0 / 0 | | +| `git diff --check fd94490` / `166492b` / 工作树 | exit 0 / 0 / 0 | 完整基线、本轮 diff、工作树 | | `poll.test.ts` × 12 | 12/12 | | -| `codex-review-round1.test.ts` × 8 | 8/8 | R03 保持 68690f9 的 D-044 不变量断言 | -| round2–5 套件 × 3 | 3/3 | 含 SIGINT/竞争/20 组入口矩阵 | -| `round5-probes.mjs` 副本 | E01/E02×2/E03 不再复现 | 正向验收在仓库测试 | -| `material-matrix-check.mjs` 副本 | **16/16** | 两种顺序 | -| `project-entry-matrix-check.mjs` 副本 | **20/20** 有恢复,exit [11] | task_id 保留 20/20 | +| `codex-review-round1.test.ts` × 8 | 8/8 | | +| round2–6 套件 × 3 | 3/3 | 含 SIGINT/竞争/两个 20 组矩阵 | +| `round6-probes.mjs` 副本 | F01 ×2、F02 ×2 不再复现(exit 11,外部字节不变) | 正向验收在仓库测试 | +| `round5-probes.mjs` 副本 | 0/4 复现 | | | `round4-probes.mjs` 副本 | 0/3 复现;D03 signal_sent=true exit 130 | | -| `round3-probes.mjs` 副本 | 0/6 复现(exit 1) | fs.watch C03 signal_sent=true, exit 130(本轮触发成功) | +| `round3-probes.mjs` 副本 | 0/6 复现(exit 1) | fs.watch C03 signal_sent=true, exit 130(触发成功) | | `round2-probes.mjs` 副本 | 0/8 复现(exit 1) | | | `round1-probes.mjs` 副本 | 0/12 复现(exit 1) | | -| `verify-original-regressions.py` | **20/20** | reviewer 的两轮正向核对 | -| `verify-round3-4-regressions.py` | **8/8** | reviewer 的 R3+R4 正向核对(C03 以 D03 补证) | -| `round4-context-checks.mjs` 副本 | **5/5** | make legacy/v1、stream legacy/v1 sidecar、standalone relink SIGINT | -| `stream-finalization-check.mjs` 副本 | passed | 唯一 outcome、连续序号 | -| tarball smoke | **29/29** | `meshy-cli-0.3.0.tgz` sha256 `f51d69236d36bec798c9b77004d4457cc69e0e47abe5a5dd31d4209d7a408c4d`,398 files,bins ['meshy', 'meshy-cli'] | +| `verify-original-regressions.py` | **20/20** | | +| `verify-round3-4-regressions.py` | **8/8** | C03 以 D03 补证 | +| `verify-round5-positive.py` | **24/24** | E01–E03 + 20 入口 | +| `material-matrix-check.mjs` / `round6-material-check.mjs` | **16/16** / **10/10** | | +| `project-entry-matrix-check.mjs` | **20/20** | | +| `round4-context-checks.mjs` 副本 | **5/5** | | +| `stream-finalization-check.mjs` 副本 | passed | | +| tarball smoke | **29/29** | `meshy-cli-0.3.0.tgz` sha256 `bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98`,398 files,bins ['meshy', 'meshy-cli'] | ## 6. 真实环境验证 @@ -527,32 +498,30 @@ v1 同步 `create` / metadata 消失 的 error(`--operation-id` 与 journal ## 7. 打包证据 -- `npm pack` → `meshy-cli-0.3.0.tgz`,sha256 `f51d69236d36bec798c9b77004d4457cc69e0e47abe5a5dd31d4209d7a408c4d`,398 个文件;必需文件齐全 = true;禁止内容不存在 = true。 +- `npm pack` → `meshy-cli-0.3.0.tgz`,sha256 `bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98`,398 个文件;必需文件齐全 = true;禁止内容不存在 = true。 - `npm install -g --prefix ` → bins ['meshy', 'meshy-cli'];无 Python 依赖。 - 未 `npm publish`,未推送。 ## 8. 需要 Codex 复审的事项 -1. R5-F01:恢复命令追加解析后的绝对 `--workspace`(不是用户原始写法);无显式 workspace 时不追加。请确认这与“原写入边界”一致。 -2. R5-F02:跨 key 汇总中,某 key 的通道没有任何贴图(候选集为空)被视为“不提供证据”而不是“不一致”——例如 `map_Kd X` 命中 base color 而 `map_Ks X` 的 specular 通道无贴图时,map_Kd 仍改写、map_Ks 保持 unresolved。请确认该取舍(D-055)。 -3. R5-F03:越界(项目在请求期间离开 workspace 或变成符号链接)时 `recovery: null` 且不给命令,hint 回落到 `wait`;缺失/损坏/锁失败给 `record_project`。请确认边界情形不给任何命令的做法可接受。 -4. C06 的 make 段仍在 round-4 R4-T01(本轮按建议把 SIGINT 分支改为精确 `failed` manifest 断言)。 -5. 旧 `round3-probes.mjs` 的 fs.watch C03 本轮副本 signal_sent=true(触发);稳定中断证据为 D03、context 的 standalone-relink-interrupt 与仓库 C03。 +1. R6-F02 的冻结语义:根身份 = 冻结时最深已存在祖先的 (dev, ino)(workspace 尚不存在时为其祖先);`assertRootIntact` 要求该路径仍是同一目录且非符号链接。请确认"稳定 `/var` 别名 / 父目录别名 / 指向不变的 workspace 符号链接均通过,目录被换或别名被重指向均拒绝"符合预期。 +2. 无 workspace 时项目目录本身作为冻结根(get/wait/stream 在首次请求前、create 在预检中、download 在预检后);未初始化的项目**不**在请求前拒绝(保持 R2-F03 的"请求后单 outcome"语义与既有 project-store 测试)。 +3. R6-F01 的边界失败形状:`project.action="failed"` + `error` + `recovery: null`,`error.recovery` 为 null,hint 不是 record 命令;与 metadata 缺失/损坏的 `record_project` 分支明确区分。 +4. 任务动词的边界失败文案由 "inside the workspace" 改为 "inside the authorised boundary"(round-5 测试断言随之更新);hint 回落为只读 `wait`。 +5. 旧 `round3-probes.mjs` 的 fs.watch C03 本轮副本 signal_sent=true(触发);稳定证据仍为 D03、context 的 standalone-relink-interrupt 与仓库 C03。 ## 9. 最短复现步骤 ```bash -cd /Users/ark/Dev/meshy-cli && git checkout 7b7c24c +cd /Users/ark/Dev/meshy-cli && git checkout e567646 export FNM_DIR="$HOME/.local/share/fnm"; eval "$(fnm env --shell bash)"; fnm use 24 -pnpm install --frozen-lockfile && pnpm typecheck && pnpm test # 552/552 -node --import tsx --test tests/codex-review-round5.test.ts # E01, E02, matrix, E03, boundary -mkdir -p /tmp/r5 && cp /Users/ark/Dev/meshy-agent-integrations-research/reviews/cli-s1-68690f9/{round5-probes.mjs,material-matrix-check.mjs,project-entry-matrix-check.mjs} /tmp/r5/ -node /tmp/r5/round5-probes.mjs /Users/ark/Dev/meshy-cli # E01/E02/E03 reproduced=false -node /tmp/r5/material-matrix-check.mjs # 16/16 -node /tmp/r5/project-entry-matrix-check.mjs /Users/ark/Dev/meshy-cli # 20/20 record_recovery -git diff --check fd94490 +pnpm install --frozen-lockfile && pnpm typecheck && pnpm test # 555/555 +node --import tsx --test tests/codex-review-round6.test.ts # F01 ×4 + control, F02 ×20, stable alias +mkdir -p /tmp/r6 && cp /Users/ark/Dev/meshy-agent-integrations-research/reviews/cli-s1-7b7c24c/round6-probes.mjs /tmp/r6/ +node /tmp/r6/round6-probes.mjs /Users/ark/Dev/meshy-cli # F01/F02 reproduced=false, exit 11, external bytes unchanged +git diff --check fd94490 && git diff --check 166492b ``` ## 10. 交给 Codex 的复审提示词 -> 请对 `/Users/ark/Dev/meshy-cli` 分支 `feat/skill-parity-s1` 做 Meshy CLI S1 第 6 轮 review。规范:2026-09-07 v1 实施包。代码 HEAD `7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`(= 修复 `7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7`);docs HEAD 为其后的 docs-only commit(`git log -1`)。上一轮(`…/reviews/cli-s1-68690f9/`)结论 changes_requested:R5-F01–R5-F03(P2)。请核对:(1) `tests/codex-review-round5.test.ts` 是否按每项 acceptance 做了正向断言(E01 逐字回放不补参数、父 history 字节不变、index_dirty、边界外无锁/temp、含空格/引号 workspace;E02 两顺序 MTL 字节不变、各行候选与跨 key note、bytes/hash;8×2 矩阵;E03 20 组入口的 task_id/operation_id/请求次数对账与逐字回放;越界用例无命令);(2) `src/internal/material-links.ts` 两步仲裁是否覆盖 §8.3 的其它组合且 D01/C05/N04/round-1 未回退;(3) `src/internal/task-command.ts` `attachToProject` 与 `src/cmd/download.ts` 的项目阶段是否在所有入口一致,边界失败不给跨界命令、`index_dirty` 语义未被滥用;(4) 前五轮 29 项 finding 是否保持通过(从副本重跑 round1–5 探针、两个 verify 脚本、两个矩阵脚本、`round4-context-checks.mjs`、`stream-finalization-check.mjs`;旧 fs.watch C03 未触发时不得计为通过)。请勿修改 reviewer 证据目录;禁止付费调用、发布、Skill/MCP 迁移。真实账号、Windows/Linux、真实切片器、UV/Creative Lab/showcases 仍为 not_run;G1-release 与 S2 不在本轮范围。 +> 请对 `/Users/ark/Dev/meshy-cli` 分支 `feat/skill-parity-s1` 做 Meshy CLI S1 第 7 轮 review。规范:2026-09-07 v1 实施包。代码 HEAD `e567646d875e5f5a658b78e60b8cdfaed8b233e9`(= 修复 `e567646d875e5f5a658b78e60b8cdfaed8b233e9`);docs HEAD 为其后的 docs-only commit(`git log -1`)。上一轮(`…/reviews/cli-s1-7b7c24c/`)结论 changes_requested:R6-F01、R6-F02(P1)与 R6-T01(P3)。请核对:(1) `src/internal/paths.ts` 的 `AuthorisedRoot`/`freezeRoot`/`assertRootIntact` 与 `resolveWithinRoot` 冻结根分支是否在首次 I/O 前建立、且所有写入/恢复路径(`--save-json`、`-o` 下载与 sidecar、`make`、`mesh prepare-print`、`project`、任务动词与 `download` 的项目记账)都不再重新解析可变根;(2) `tests/codex-review-round6.test.ts` 是否按 acceptance 做了正向断言(task-json/API × 叶子/父目录;legacy/v1 × 5 动词 × 目录被换/别名重指向;exit 11、task/submission/operation_id、单 POST、精确 method/path、外部与原树整树摘要、无 snapshot/lock/temp、recovery null、无跨界命令;稳定别名与健康对照);(3) `tests/codex-review-round5.test.ts` E03 20 组精确序列与 E02 按行候选(R6-T01);(4) 前六轮 32 项 finding 是否保持通过(副本重跑 round1–6 探针、三个 verify 脚本、三个矩阵脚本、`round4-context-checks.mjs`、`stream-finalization-check.mjs`;旧 fs.watch C03 未触发时不得计为通过)。请勿修改 reviewer 证据目录;禁止付费调用、发布、Skill/MCP 迁移。真实账号、Windows/Linux、真实切片器、UV/Creative Lab/showcases 仍为 not_run;G1-release 与 S2 不在本轮范围。 diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index 37ac063..bf8afa8 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -2,7 +2,7 @@ "schema_version": 1, "title": "Meshy CLI S1 capability matrix — implementation tracking", "date": "2026-09-08", - "status": "review round 5 (Codex, reviewed e7c0bbc/68690f9): changes_requested → R5-F01–R5-F03 fixed in 7b7c24c; rounds 1–4 (26 findings) re-verified; G1-code re-review pending; G1-release not_run", + "status": "review round 6 (Codex, reviewed 166492b/7b7c24c): changes_requested → R6-F01, R6-F02 fixed and R6-T01 covered in e567646; rounds 1–5 (29 findings) re-verified; G1-code re-review pending; G1-release not_run", "baseline": { "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" @@ -20,7 +20,7 @@ "target": "meshy create --async", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–5 findings fixed; re-review pending", + "review_status": "rounds 1–6 findings fixed; re-review pending", "test_ids": [ "T-040", "T-043", @@ -31,7 +31,8 @@ "src/internal/task-command.ts submitOnce: journal → single POST → journal; tests/task-lifecycle.test.ts T-040/T-043/T-044", "tests/codex-review-round1.test.ts R05/F01, R06/F01", "tests/codex-review-round3.test.ts C06 (legacy sync create keeps the accepted task)", - "tests/codex-review-round5.test.ts E03 (create --async / sync create × missing/damaged metadata → record_project with --operation-id/--workspace; escaped project → boundary, no command)" + "tests/codex-review-round5.test.ts E03 (create --async / sync create × missing/damaged metadata → record_project with --operation-id/--workspace; escaped project → boundary, no command)", + "tests/codex-review-round6.test.ts (create --async / sync create × workspace swapped / alias re-pointed → boundary refusal, single POST, journal kept)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -64,6 +65,16 @@ "tests": [ "tests/codex-review-round5.test.ts E03 (create --async / sync create × missing/damaged metadata → record_project with --operation-id/--workspace; escaped project → boundary, no command)" ] + }, + "review_round_6": { + "findings": [ + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (create --async / sync create × workspace swapped / alias re-pointed → boundary refusal, single POST, journal kept)" + ] } }, { @@ -77,7 +88,7 @@ "target": "meshy get --include-raw --save-json", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–5 findings fixed; re-review pending", + "review_status": "rounds 1–6 findings fixed; re-review pending", "test_ids": [ "T-006", "T-008", @@ -88,7 +99,8 @@ "tests/codex-review-round1.test.ts R06/F01 (wait), R09/F03 (get -o)", "tests/codex-review-round2.test.ts N01 (report-only get -o), N06 (partial manifest), N08 (SIGINT)", "tests/codex-review-round3.test.ts C01+C02 (get -o sidecar race / finalisation manifest), C03 (relink interrupt), C06 (legacy get)", - "tests/codex-review-round5.test.ts E03 (get)" + "tests/codex-review-round5.test.ts E03 (get)", + "tests/codex-review-round6.test.ts (get; stable-alias control records normally)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -137,6 +149,16 @@ "tests": [ "tests/codex-review-round5.test.ts E03 (get)" ] + }, + "review_round_6": { + "findings": [ + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (get; stable-alias control records normally)" + ] } }, { @@ -150,7 +172,7 @@ "target": "meshy wait", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–5 findings fixed; re-review pending", + "review_status": "rounds 1–6 findings fixed; re-review pending", "test_ids": [ "T-007", "T-047", @@ -163,7 +185,8 @@ "tests/codex-review-round2.test.ts N01/N06/N08 (wait -o)", "tests/poll.test.ts deterministic deadline tests", "tests/codex-review-round3.test.ts C06 (legacy wait -o)", - "tests/codex-review-round5.test.ts E03 (wait)" + "tests/codex-review-round5.test.ts E03 (wait)", + "tests/codex-review-round6.test.ts (wait)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -212,6 +235,16 @@ "tests": [ "tests/codex-review-round5.test.ts E03 (wait)" ] + }, + "review_round_6": { + "findings": [ + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (wait)" + ] } }, { @@ -225,7 +258,7 @@ "target": "meshy stream", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "rounds 1–5 findings fixed; re-review pending", + "review_status": "rounds 1–6 findings fixed; re-review pending", "test_ids": [ "T-049", "T-050", @@ -239,7 +272,8 @@ "tests/codex-review-round1.test.ts R10/F10", "tests/codex-review-round2.test.ts N01 (stream -o), N05 (single outcome on post-stream failures)", "tests/codex-review-round3.test.ts C06 (legacy stream path shares emitLegacyOutcome wrapping)", - "tests/codex-review-round5.test.ts E03 (stream)" + "tests/codex-review-round5.test.ts E03 (stream)", + "tests/codex-review-round6.test.ts (stream)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -285,6 +319,16 @@ "tests": [ "tests/codex-review-round5.test.ts E03 (stream)" ] + }, + "review_round_6": { + "findings": [ + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (stream)" + ] } }, { @@ -324,7 +368,7 @@ "target": "existing commands with explicit supported payload", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-028", "T-033", @@ -358,7 +402,7 @@ "target": "typed media flags and final payload normalization", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-029", "T-030", @@ -443,7 +487,7 @@ "target": "meshy creative-lab lamp prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-022", "T-023", @@ -482,7 +526,7 @@ "target": "meshy creative-lab keychain prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-022", "T-023", @@ -519,7 +563,7 @@ "target": "meshy creative-lab fridge-magnet prototype|build ", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-022", "T-023", @@ -605,7 +649,7 @@ "target": "download asset registry with role and dependencies", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–5 findings fixed; re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-060", "T-062", @@ -683,7 +727,7 @@ "target": "meshy download --asset/--kind/--model-format", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–5 findings fixed; re-review pending", + "review_status": "rounds 1–6 findings fixed; re-review pending", "test_ids": [ "T-061", "T-063" @@ -694,7 +738,8 @@ "tests/codex-review-round2.test.ts N04", "tests/codex-review-round3.test.ts C05", "tests/codex-review-round4.test.ts D01 (both orders) + arbitration unit cases", - "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" + "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix", + "tests/codex-review-round6.test.ts (download × task-json/API × leaf/parent symlink → exit 11, manifest kept, project.failed, outside tree byte-identical)" ], "intentional_differences": [ "download requires an explicit selector when a task exposes several assets (legacy defaulted to glb); legacy -o keeps downloading everything" @@ -749,6 +794,16 @@ "tests": [ "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" ] + }, + "review_round_6": { + "findings": [ + "R6-F01" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (download × task-json/API × leaf/parent symlink → exit 11, manifest kept, project.failed, outside tree byte-identical)" + ] } }, { @@ -762,7 +817,7 @@ "target": "safe download with actual final-path manifest", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "rounds 1–5 findings fixed; re-review pending", + "review_status": "rounds 1–6 findings fixed; re-review pending", "test_ids": [ "T-064", "T-065", @@ -778,7 +833,8 @@ "tests/codex-review-round2.test.ts N03 (no mkdir before refusal), N04, N06, N08", "tests/codex-review-round3.test.ts C01+C02, C03, C05", "tests/codex-review-round4.test.ts D01, D02 (symlink/damaged/unwritable + recovery replay), preflight", - "tests/codex-review-round5.test.ts E01 (verbatim replay keeps --workspace), E02" + "tests/codex-review-round5.test.ts E01 (verbatim replay keeps --workspace), E02", + "tests/codex-review-round6.test.ts (frozen boundary for downloads and project records); round5 E03 exact request sequences (R6-T01)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -840,6 +896,17 @@ "tests": [ "tests/codex-review-round5.test.ts E01 (verbatim replay keeps --workspace), E02" ] + }, + "review_round_6": { + "findings": [ + "R6-F01", + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (frozen boundary for downloads and project records); round5 E03 exact request sequences (R6-T01)" + ] } }, { @@ -853,7 +920,7 @@ "target": "--save-json and project task snapshots", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-010", "T-063" @@ -897,7 +964,7 @@ "target": "meshy project init", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-072", "T-077" @@ -930,7 +997,7 @@ "target": "meshy project record/show", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "rounds 1–5 findings fixed; re-review pending", + "review_status": "rounds 1–6 findings fixed; re-review pending", "test_ids": [ "T-073", "T-074", @@ -942,7 +1009,8 @@ "tests/codex-review-round2.test.ts N02 (implicit history root confined; metadata recorded)", "tests/codex-review-round3.test.ts C04 (aliased project records files)", "tests/codex-review-round4.test.ts D02 + preflight; project.action=failed with record_project recovery", - "tests/codex-review-round5.test.ts E01, E03, boundary case; codex-review-round4 D02 verbatim replay" + "tests/codex-review-round5.test.ts E01, E03, boundary case; codex-review-round4 D02 verbatim replay", + "tests/codex-review-round6.test.ts (project record refused across the boundary at every entry; recovery null; no index_dirty disguise)" ], "intentional_differences": [], "external_verification": "offline_primary", @@ -996,6 +1064,17 @@ "tests": [ "tests/codex-review-round5.test.ts E01, E03, boundary case; codex-review-round4 D02 verbatim replay" ] + }, + "review_round_6": { + "findings": [ + "R6-F01", + "R6-F02" + ], + "status": "fixed", + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "tests": [ + "tests/codex-review-round6.test.ts (project record refused across the boundary at every entry; recovery null; no index_dirty disguise)" + ] } }, { @@ -1009,7 +1088,7 @@ "target": "meshy project list/rebuild-index", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-073", "T-075", @@ -1081,7 +1160,7 @@ "target": "meshy mesh prepare-print", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-083", "T-084", @@ -1191,7 +1270,7 @@ "target": "--env-file without automatic dotenv/shell scanning", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-100", "T-101" @@ -1226,7 +1305,7 @@ "target": "preserve existing auth commands and token protocol", "baseline_status": "existing_needs_regression", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-104", "T-105" @@ -1272,7 +1351,7 @@ "target": "--output-schema v1; legacy compatibility", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-001", "T-002", @@ -1322,7 +1401,7 @@ "target": "make --async / --stop-after-first", "baseline_status": "partial", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-041", "T-042" @@ -1392,7 +1471,7 @@ "target": "operation journal; never claim server idempotency", "baseline_status": "missing", "implementation_status": "implemented", - "review_status": "earlier-round findings fixed (re-verified on 7b7c24c); re-review pending", + "review_status": "earlier-round findings fixed (re-verified on e567646); re-review pending", "test_ids": [ "T-043", "T-044", @@ -1543,8 +1622,8 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-08T03:10:09.966960Z", - "code_head": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "updated_at": "2026-09-08T03:47:45.510877Z", + "code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "review_rounds": [ { @@ -1638,6 +1717,22 @@ "code_head": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", "regression_tests": "tests/codex-review-round5.test.ts (E01 verbatim replay with --workspace, E02 both orders + 8x2 matrix, E03 20-entry matrix + verbatim replay, escaped-project boundary); round4 D02 replay verbatim; R4-T01 SIGINT manifest exact", "probe_rerun": "reviews/cli-s1-68690f9 copies on 7b7c24c: round5 E01/E02x2/E03 no longer reproduce; material matrix 16/16; project entries 20/20 record_recovery; round4 0/3 (D03 positive), round3 0/6, round2 0/8, round1 0/12; verify-original-regressions 20/20; verify-round3-4 8/8; context 5/5; stream passed" + }, + { + "round": 6, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-7b7c24c", + "reviewed_head": "166492ba9691f093b302c1c114e782bceee14a1d", + "verdict": "changes_requested", + "findings": [ + "R6-F01", + "R6-F02", + "R6-T01 (test gap)" + ], + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "regression_tests": "tests/codex-review-round6.test.ts (R6-F01 4 download scenarios + control; R6-F02 20-entry mutable-workspace matrix + stable-alias control); codex-review-round5 E03 exact sequences and E02 line-bound candidates (R6-T01)", + "probe_rerun": "reviews/cli-s1-7b7c24c copies on e567646: round6 0/4 reproduce (all exit 11, outside bytes unchanged); round5 0/4; round4 0/3 (D03 positive); round3 0/6; round2 0/8; round1 0/12; verify-original 20/20; verify-round3-4 8/8; verify-round5-positive 24/24; material matrix 16/16; round6 material 10/10; project entries 20/20; context 5/5; stream passed" } ] } diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json index 7a2bc2b..14d24d4 100644 --- a/docs/skill-parity/verification.json +++ b/docs/skill-parity/verification.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "status": "review round 5 fixes applied (Codex reviewed e7c0bbc/68690f9: changes_requested, 3 P2 code findings); re-review pending; live checks not_run", + "status": "review round 6 fixes applied (Codex reviewed 166492b/7b7c24c: changes_requested, 2 P1 code findings + 1 P3 test gap); re-review pending; live checks not_run", "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", "environment": { "os": "macOS 26.6 (Darwin 25.6.0) arm64", @@ -43,108 +43,115 @@ "final_runs": [ { "command": "node --version", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm --version", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { "command": "pnpm install --frozen-lockfile", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, - "duration_s": 0, + "duration_s": 1, "result": "passed" }, { "command": "pnpm typecheck", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm build", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { "command": "pnpm test", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, - "duration_s": 42, + "duration_s": 45, "result": "passed", - "tests": 552, - "pass": 552, + "tests": 555, + "pass": 555, "fail": 0, "skipped": 0 }, { "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, - "duration_s": 1, + "duration_s": 0, "result": "passed" }, { "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "duration_s": 7, "result": "passed" }, { "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/meshy-cli-0.3.0.tgz", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "duration_s": 7, "result": "passed" }, { "command": "git diff --check fd94490", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "exit_code": 0, + "duration_s": 0, + "result": "passed" + }, + { + "command": "git diff --check 166492b", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "git diff --check", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "for i in 1..12: node --import tsx --test tests/poll.test.ts", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, - "duration_s": 14, + "duration_s": 15, "result": "passed", - "detail": "12/12 runs" + "detail": "12/12 runs; deterministic + real-timer smoke" }, { "command": "for i in 1..8: node --import tsx --test tests/codex-review-round1.test.ts", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "duration_s": 50, "result": "passed", "detail": "8/8 runs; R03 asserts the D-044 invariants since 68690f9" }, { - "command": "for i in 1..3: node --import tsx --test tests/codex-review-round5.test.ts tests/codex-review-round4.test.ts tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "command": "for i in 1..3: node --import tsx --test tests/codex-review-round6.test.ts tests/codex-review-round5.test.ts tests/codex-review-round4.test.ts tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, - "duration_s": 23, + "duration_s": 24, "result": "passed", - "detail": "3/3 runs (SIGINT-during-relink, sidecar race, project-record races incl. the 20-entry recovery matrix, make SIGINT and other timing-sensitive cases)" + "detail": "3/3 runs; SIGINT-during-relink, sidecar race, project-record races, the 20-entry recovery matrix, the 20-entry mutable-workspace matrix, make SIGINT and other timing-sensitive cases" } ], "behavior_tests": { @@ -153,7 +160,7 @@ "files": [ "tests/cli-contract.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-002": { "result": "passed", @@ -161,49 +168,49 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-003": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-004": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-005": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-006": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-007": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-008": { "result": "passed", "files": [ "tests/task-view.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-009": { "result": "passed", @@ -213,14 +220,16 @@ "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", "tests/codex-review-round4.test.ts", - "tests/codex-review-round5.test.ts" + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F10: R10 stream -o in ndjson/json/pretty; one outcome on download failure", "review_round_2": "R2-F01 N01 report-only get/wait/stream -o confined; R2-F04 N06 partial manifest; R2-F05 N08 SIGINT", "review_round_3": "R3-F02 C06: legacy get/wait/create/make -o failures name the task (task_id, operation_id, next, manifest, hint)", "review_round_4": "R4-T01: make's task_id, submission.operation_id, executed[-1].operation_id and the legacy top-level operation_id are the last accepted journal record's (distinct step ids; legacy/v1 × asset 503/SIGINT)", - "review_round_5": "R5-F03 E03: legacy/v1 × get/wait/stream/create --async/sync × metadata missing/damaged → exit 11 with task_id, submission.operation_id = the single accepted journal record, record_project command with --operation-id and --workspace (legacy hint too)" + "review_round_5": "R5-F03 E03: legacy/v1 × get/wait/stream/create --async/sync × metadata missing/damaged → exit 11 with task_id, submission.operation_id = the single accepted journal record, record_project command with --operation-id and --workspace (legacy hint too)", + "review_round_6": "R6-F02: the accepted task, its single POST and journal record survive the boundary refusal; legacy payload names task_id/operation_id; recovery null, no record command" }, "T-010": { "result": "passed", @@ -230,7 +239,7 @@ "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F01: R05 save-json preflight + post-POST failure keeps the id", "review_round_2": "R2-F04 N06: task -o failure keeps HTTP class/status and the files already written", "review_round_3": "R3-F01/R3-F04 C01+C02: sidecar planted after the preflight refused; committed model kept in the manifest with failed_step" @@ -241,28 +250,28 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-012": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-020": { "result": "passed", "files": [ "tests/resource-registry.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-021": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-022": { "result": "passed", @@ -270,7 +279,7 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-023": { "result": "passed", @@ -278,14 +287,14 @@ "tests/resource-registry.test.ts", "tests/uv-creative-lab.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-024": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-025": { "result": "passed", @@ -293,7 +302,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F08: R04 three-layer options merge on the real POST body" }, "T-026": { @@ -301,14 +310,14 @@ "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-027": { "result": "passed", "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-028": { "result": "passed", @@ -316,7 +325,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F08: R04" }, "T-029": { @@ -325,7 +334,7 @@ "tests/file-input.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F06: R02 equal-length images conflict, re-wrapped base64 replays, no base64 in journal" }, "T-030": { @@ -333,28 +342,28 @@ "files": [ "tests/file-input.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-031": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-032": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-040": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-041": { "result": "passed", @@ -362,7 +371,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F02: R11 make journal failure after acceptance is local_io with the id (text + image routes)" }, "T-042": { @@ -371,7 +380,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F02: R11" }, "T-043": { @@ -380,14 +389,14 @@ "tests/operation-store.test.ts", "tests/task-lifecycle.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-044": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-045": { "result": "passed", @@ -396,13 +405,15 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round3.test.ts", "tests/codex-review-round4.test.ts", - "tests/codex-review-round5.test.ts" + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F01: R06 polling 5xx keeps id/submission/next; make polling failure keeps executed steps", "review_round_3": "R3-F02 C06: legacy sync create keeps the accepted task on an asset 503 with exactly one POST", "review_round_4": "R4-T01: two journal records, no re-submission; POST GET POST GET GET; refine.preview_task_id = step 1", - "review_round_5": "R5-F03 E03: exactly one POST / one journal record per create; the recovery never re-submits; a project that escaped the workspace → boundary message, task + operation named, recovery null" + "review_round_5": "R5-F03 E03: exactly one POST / one journal record per create; the recovery never re-submits; a project that escaped the workspace → boundary message, task + operation named, recovery null", + "review_round_6": "R6-F02: exactly one POST per create in the 20-entry mutable-workspace matrix; exact method/path sequences (R6-T01 also applied to the round-5 E03 matrix)" }, "T-046": { "result": "passed", @@ -411,7 +422,7 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "barrier-released three-process race; F05/F06 fingerprint unit tests" }, "T-047": { @@ -421,7 +432,7 @@ "tests/codex-review-round1.test.ts", "tests/poll.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F07: deadline bounds the sleep; --timeout 0 request cap", "review_round_2": "R2-F07: deterministic fake-clock deadline tests; real-timer smoke asserts only no GET after the deadline", "review_round_4": "R03 'expiry during the sleep' asserts the D-044 invariants (test-only commit 68690f9); 8/8 standalone runs" @@ -435,7 +446,7 @@ "tests/codex-review-round3.test.ts", "tests/codex-review-round4.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F01: task context on every post-acceptance failure", "review_round_2": "R2-F05 N08: SIGINT before headers / mid-body / on the second asset → 130 with manifest, no leftovers", "review_round_3": "R3-F05 C03: SIGINT during the OBJ rewrite → 130, digests re-taken, no sidecar, no temp file; make interrupt", @@ -446,28 +457,28 @@ "files": [ "tests/sse.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-050": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-051": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-052": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-053": { "result": "passed", @@ -477,7 +488,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F10: R10", "review_round_2": "R2-F03 N05: save-json/project/download failures after the stream end in one outcome with the next sequence" }, @@ -486,7 +497,7 @@ "files": [ "tests/artifacts.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-061": { "result": "passed", @@ -494,7 +505,7 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-062": { "result": "passed", @@ -505,21 +516,23 @@ "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", "tests/codex-review-round4.test.ts", - "tests/codex-review-round5.test.ts" + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F09: R07 rewritten mtllib/map_* references verified on disk; unresolved map reported", "review_round_2": "R2-F02 N04: multi-material textures mapped by source name; ambiguity kept + warned (v1 and legacy -o)", "review_round_3": "R3-F03 C05: source name beats a colliding generated name (verified by image bytes); generated-name reference with another source is ambiguous", "review_round_4": "R4-F01 D01: two references reaching the sole texture only through channel fallbacks stay as written in both orders (MTL byte-identical, one shared note, digests match disk); arbitration unit cases (identity vs heuristic, ambiguous rival, one reference/two keys, lone and distinct-channel fallbacks)", - "review_round_5": "R5-F02 E02: one reference under two keys (hit + ambiguous) stays as written in both orders; 8x2 arbitration matrix (identity, ambiguous rival, same-ref two hits / hit+ambiguity / same identity / same fallback, distinct channels) as a repository test" + "review_round_5": "R5-F02 E02: one reference under two keys (hit + ambiguous) stays as written in both orders; 8x2 arbitration matrix (identity, ambiguous rival, same-ref two hits / hit+ambiguity / same identity / same fallback, distinct channels) as a repository test", + "review_round_6": "R6-T01: E02 binds each line's candidate set to its key (line 2 / line 3 in both orders)" }, "T-063": { "result": "passed", "files": [ "tests/artifacts.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-064": { "result": "passed", @@ -528,27 +541,29 @@ "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", "tests/codex-review-round4.test.ts", - "tests/codex-review-round5.test.ts" + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_2": "R2-F01 N03: refused download creates no directory; zero requests", "review_round_3": "R3-F01 C01: sidecar publication re-proves the root and refuses symlink/file/directory targets", "review_round_4": "R4-F02 D02: project record failure after the transfer keeps the full result and a record_project recovery; preflight refuses symlink/damaged metadata.json and blank --stage before any request", - "review_round_5": "R5-F01 E01: the record_project command carries the original --workspace (quoted path with a space and a quote); replayed verbatim it records with 0 requests, parent history.json bytes unchanged, index_dirty; no workspace → none appended" + "review_round_5": "R5-F01 E01: the record_project command carries the original --workspace (quoted path with a space and a quote); replayed verbatim it records with 0 requests, parent history.json bytes unchanged, index_dirty; no workspace → none appended", + "review_round_6": "R6-F01: download --project with the project leaf or parent swapped for a symlink to an outside project during the asset GET (task-json and API sources) → exit 11, completed manifest kept, project.failed with recovery null, outside tree byte-identical, exact requests; healthy control still records" }, "T-066": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-067": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-068": { "result": "passed", @@ -558,28 +573,30 @@ "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", "tests/codex-review-round4.test.ts", - "tests/codex-review-round5.test.ts" + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F03/F04: R08 project roots, R09 task -o, R12 dependency symlink escapes", "review_round_2": "R2-F01 N01/N03: outside/symlinked targets refused before mkdir for report tasks and downloads", "review_round_3": "R3-F01 C01: outside file bytes unchanged across the sidecar race, single-file and legacy variants", "review_round_4": "R4-F02 D02: outside metadata bytes and the original metadata stay unchanged; planted symlink not replaced; the recovery command records exactly the downloaded file with no request", - "review_round_5": "R5-F01 E01: nothing appears in the parent directory during the replay (no lock, no temp); outside metadata untouched" + "review_round_5": "R5-F01 E01: nothing appears in the parent directory during the replay (no lock, no temp); outside metadata untouched", + "review_round_6": "R6-F01/R6-F02: whole outside trees (metadata.json, history.json, snapshots, locks, temps) compared by digest before/after — nothing written across the boundary; the original workspace/project trees untouched too" }, "T-069": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-070": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-071": { "result": "passed", @@ -587,7 +604,7 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-072": { "result": "passed", @@ -597,35 +614,37 @@ "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts", "tests/codex-review-round4.test.ts", - "tests/codex-review-round5.test.ts" + "tests/codex-review-round5.test.ts", + "tests/codex-review-round6.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F03: R08 project init/record/rebuild-index confined to --workspace", "review_round_2": "R2-F01 N02: implicit history root confined; metadata recorded, parent untouched", "review_round_3": "R3-F06 C04: aliased project directory records its files; truly outside files still not recorded", "review_round_4": "R4-F02 D02: project.action=failed with error + recovery; damaged metadata.json is left for repair; unwritable project directory (lock cannot be created) is local_io with the result; healthy project still records", - "review_round_5": "R5-F01/R5-F03: index_dirty only ever means metadata committed + history untouched; verbatim replay across download and the 20 task-verb entries; a project that left the workspace gets no command" + "review_round_5": "R5-F01/R5-F03: index_dirty only ever means metadata committed + history untouched; verbatim replay across download and the 20 task-verb entries; a project that left the workspace gets no command", + "review_round_6": "R6-F02: the boundary is frozen with the flags (real path + directory identity); a workspace directory swapped for a symlink or a workspace alias re-pointed while the request is in flight is refused across legacy/v1 × get/wait/stream/create --async/sync create; stable aliases keep recording (snapshot in the real project, index refreshed)" }, "T-073": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-074": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-075": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-076": { "result": "passed", @@ -633,7 +652,7 @@ "tests/project-store.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_2": "R2-F01 N02: index skipped with index.updated=false + reason when the root is outside --workspace" }, "T-077": { @@ -641,49 +660,49 @@ "files": [ "tests/project-store.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-080": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-081": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-082": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-083": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-084": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-085": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-086": { "result": "passed", @@ -691,7 +710,7 @@ "tests/obj-transform.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F04: R12 symlinked materials/ and texture dir refused; real dirs copied" }, "T-087": { @@ -699,35 +718,35 @@ "files": [ "tests/obj-transform.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-088": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-089": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-090": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-091": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-100": { "result": "passed", @@ -737,7 +756,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F05: R01 API key digest binding", "review_round_2": "R2-F06 N07: OAuth without user_id/login_id refused a replay; login_id binds; refresh keeps it" }, @@ -746,7 +765,7 @@ "files": [ "tests/env-file.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-102": { "result": "passed", @@ -754,14 +773,14 @@ "tests/cli-contract.test.ts", "tests/doctor.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-103": { "result": "passed", "files": [ "tests/doctor.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" }, "T-105": { "result": "passed", @@ -770,7 +789,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_1": "F05: R01 OAuth subject binding (token rotation keeps identity)", "review_round_2": "R2-F06 N07" }, @@ -779,7 +798,7 @@ "files": [ "tests/auth-headless.test.ts" ], - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "review_round_2": "R2-F06: a device-flow login mints login_id (offline E2E stub)" } }, @@ -794,13 +813,13 @@ "id": "T-109", "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "partial", - "detail": "macOS 26.6 arm64, Node v24.20.0: tarball install + 29 smoke checks passed on 7b7c24c (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" + "detail": "macOS 26.6 arm64, Node v24.20.0: tarball install + 29 smoke checks passed on e567646 (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" }, { "id": "T-110", "scope": "real free queries, existing task get, asset download", "result": "partial", - "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions, free) on 7b7c24c: exit 0; public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157. Authenticated get/download: not_run (no credential)" + "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions, free) on e567646: exit 0; public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157. Authenticated get/download: not_run (no credential)" }, { "id": "T-111", @@ -815,12 +834,12 @@ "reason": "no slicer application installed on this host (slicer detect returned an empty list)" } ], - "code_head": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", - "recorded_at": "2026-09-08T03:10:09.966960Z", - "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1/2/3/4/5 notes name the Codex finding and probe the file now covers.", + "code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "recorded_at": "2026-09-08T03:47:45.510877Z", + "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1…6 notes name the Codex finding and probe the file now covers.", "package_smoke": { "tarball": "meshy-cli-0.3.0.tgz", - "sha256": "f51d69236d36bec798c9b77004d4457cc69e0e47abe5a5dd31d4209d7a408c4d", + "sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", "file_count": 398, "required_present": true, "forbidden_absent": true, @@ -834,7 +853,7 @@ { "id": "version", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --version", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -843,7 +862,7 @@ { "id": "version_cli", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --version", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -852,7 +871,7 @@ { "id": "help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --help", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -861,7 +880,7 @@ { "id": "help_cli", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --help", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -870,7 +889,7 @@ { "id": "balance_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --help", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -879,7 +898,7 @@ { "id": "balance_nokey_json", "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -888,7 +907,7 @@ { "id": "balance_nokey_v1", "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -897,7 +916,7 @@ { "id": "resources_v1", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy resources --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -906,7 +925,7 @@ { "id": "doctor", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -915,7 +934,7 @@ { "id": "doctor_check_slicers", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-slicers --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -924,7 +943,7 @@ { "id": "doctor_check_api_nokey", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-api --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -933,7 +952,7 @@ { "id": "slicer_detect", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy slicer detect --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -942,7 +961,7 @@ { "id": "inspect_pass", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -951,7 +970,7 @@ { "id": "inspect_fail", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 12, "expected_exit": 12, "result": "passed", @@ -960,7 +979,7 @@ { "id": "inspect_unknown", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-unknown-faces.json --max-faces 300000 --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 13, "expected_exit": 13, "result": "passed", @@ -969,7 +988,7 @@ { "id": "mesh_prepare", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -978,7 +997,7 @@ { "id": "mesh_refuse_overwrite", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 11, "expected_exit": 11, "result": "passed", @@ -987,7 +1006,7 @@ { "id": "project_init", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -995,8 +1014,8 @@ }, { "id": "project_record", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_110936_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_114525_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1004,8 +1023,8 @@ }, { "id": "project_show", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_110936_smoke-demo_fixture- --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_114525_smoke-demo_fixture- --output-schema v1", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1014,7 +1033,7 @@ { "id": "project_list", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1023,7 +1042,7 @@ { "id": "download_list", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1032,7 +1051,7 @@ { "id": "download_no_selector", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -1041,7 +1060,7 @@ { "id": "make_dry_run", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1050,7 +1069,7 @@ { "id": "usage_unknown_flag", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --bogus --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -1059,7 +1078,7 @@ { "id": "uv_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy uv-unwrap create --help", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1068,7 +1087,7 @@ { "id": "creative_lab_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy creative-lab lamp build create --help", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1077,7 +1096,7 @@ { "id": "stream_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy text-to-3d stream --help", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1086,7 +1105,7 @@ { "id": "catalog_live", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", - "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1880,6 +1899,344 @@ "passed": true, "sha": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7" } + }, + { + "round": 6, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-7b7c24c", + "reviewed_head": "166492ba9691f093b302c1c114e782bceee14a1d", + "reviewed_code_head": "7b7c24c0357fb9a351438da2c1bc6a5cb1ebbae7", + "verdict": "changes_requested (G1-code not_accepted; G1-release and S2 out of scope)", + "findings": 2, + "test_gaps": 1, + "priorities": { + "P1": 2, + "P3": 1 + }, + "fixed_in": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "regression_tests": [ + "tests/codex-review-round6.test.ts (R6-F01 download × task-json/API × leaf/parent symlink + healthy control; R6-F02 legacy/v1 × 5 verbs × directory-swap/alias-repoint = 20 entries; stable-alias control)", + "tests/codex-review-round5.test.ts (R6-T01: exact method/path for all 20 E03 entries; E02 candidates bound to line/key)" + ], + "reviewer_test_result_before_fix": "pnpm test 552/552; round1+2 positive 20/20; round3+4 positive 8/8; round5 positive 24/24; material matrix 16/16 + round6 material 10/10; project entries 20/20; context 5/5; round6-probes 4/4 reproduced (F01 task-json/api, F02 get/create-async)", + "probe_rerun": { + "script": "reviews/cli-s1-7b7c24c/round6-probes.mjs (byte-identical copy in a scratch directory; the reviewer's evidence directories are untouched)", + "node": "v24.20.0", + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "reproduced_after_fix": 0, + "total": 4, + "note": "all four now exit 11 with external metadata/history unchanged and recovery null; reproduced=false is not the acceptance — positive assertions are in tests/codex-review-round6.test.ts", + "cases": [ + { + "id": "F01-download-project-boundary-task-json", + "reproduced": false, + "exit_code": 11, + "external_metadata_changed": false + }, + { + "id": "F01-download-project-boundary-api", + "reproduced": false, + "exit_code": 11, + "external_metadata_changed": false + }, + { + "id": "F02-mutable-workspace-boundary-get", + "reproduced": false, + "exit_code": 11, + "external_metadata_changed": false, + "external_history_changed": false + }, + { + "id": "F02-mutable-workspace-boundary-create-async", + "reproduced": false, + "exit_code": 11, + "external_metadata_changed": false, + "external_history_changed": false + } + ] + }, + "material_matrix": { + "script": "material-matrix-check.mjs copy", + "passed": 16, + "total": 16, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "round6_material_check": { + "script": "round6-material-check.mjs copy", + "passed": 10, + "total": 10, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "project_entry_matrix": { + "script": "project-entry-matrix-check.mjs copy", + "record_recovery": 20, + "total": 20, + "exit_codes": [ + 11 + ], + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "older_probe_reruns": { + "round5": { + "script": "round5-probes.mjs copy", + "reproduced": 0, + "total": 4, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "cases": [ + { + "id": "E01-recovery-drops-workspace", + "reproduced": false, + "exit_code": 11, + "history_changed": false + }, + { + "id": "E02-mixed-key-hit-and-ambiguity-kd-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "E02-mixed-key-hit-and-ambiguity-bump-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "E03-task-project-resolution-outside-recovery-wrapper", + "reproduced": false, + "exit_code": 11 + } + ] + }, + "round4": { + "script": "round4-probes.mjs copy", + "reproduced": 0, + "total": 3, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "cases": [ + { + "id": "D01-channel-fallback-collapses-different-references", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "D02-project-finalization-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "D03-stable-relink-interrupt-positive", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + } + ] + }, + "round3": { + "script": "round3-probes.mjs copy", + "reproduced": 0, + "total": 6, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "note": "original fs.watch C03: signal_sent=True, exit 130 — its trigger fired in this run; the stable D03 probe (signal_sent=True, exit 130), round4-context standalone-relink-interrupt and the repo C03 test are the primary evidence", + "cases": [ + { + "id": "C01-sidecar-follows-new-symlink-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C02-sidecar-failure-loses-written-manifest", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "C03-sigint-during-relink-still-succeeds", + "reproduced": false, + "exit_code": 130, + "signal_sent": true + }, + { + "id": "C04-project-alias-drops-asset-record", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C05-saved-name-match-overrides-source-identity", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "C06-legacy-create-download-error-loses-task-id", + "reproduced": false, + "exit_code": 7 + } + ] + }, + "round2": { + "script": "round2-probes.mjs copy", + "reproduced": 0, + "total": 8, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "cases": [ + { + "id": "N01-report-outside-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N02-project-index-outside-workspace", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N03-rejected-download-creates-outside-directory", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N04-multiple-textures-collapse-to-first", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "N05-stream-save-error-has-no-outcome-event", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "N06-task-download-partial-files-lost", + "reproduced": false, + "exit_code": 7 + }, + { + "id": "N07-oauth-without-subject-replays-other-token", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "N08-task-download-ignores-sigint", + "reproduced": false, + "exit_code": 130 + } + ] + }, + "round1": { + "script": "round1-probes.mjs copy", + "reproduced": 0, + "total": 12, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "cases": [ + { + "id": "R01-credential-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R02-media-collision", + "reproduced": false, + "exit_code": 2 + }, + { + "id": "R03-wait-deadline", + "reproduced": false, + "exit_code": 8 + }, + { + "id": "R04-options-shallow-merge", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R05-post-save-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R06-post-poll-lost-id", + "reproduced": false, + "exit_code": 1 + }, + { + "id": "R07-obj-broken-material", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R08-project-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R09-task-output-workspace", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R10-stream-output-ignored", + "reproduced": false, + "exit_code": 0 + }, + { + "id": "R11-make-journal-lost-id", + "reproduced": false, + "exit_code": 11 + }, + { + "id": "R12-obj-dependency-symlink", + "reproduced": false, + "exit_code": 11 + } + ] + } + }, + "reviewer_positive_checkers": { + "verify_original_regressions": { + "passed": 20, + "total": 20, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "verify_round3_4_regressions": { + "passed": 8, + "total": 8, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + }, + "verify_round5_positive": { + "passed": 24, + "total": 24, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + } + }, + "context_checks": { + "script": "round4-context-checks.mjs copy", + "passed": 5, + "total": 5, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "checks": [ + { + "id": "make-legacy-last-operation-identity", + "passed": true + }, + { + "id": "make-v1-last-operation-identity", + "passed": true + }, + { + "id": "stream-legacy-sidecar-failure-context", + "passed": true + }, + { + "id": "stream-v1-sidecar-failure-context", + "passed": true + }, + { + "id": "standalone-relink-interrupt", + "passed": true + } + ] + }, + "stream_finalization_check": { + "script": "stream-finalization-check.mjs copy", + "passed": true, + "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + } } ] } From 1d9f10976b5f754502c81591c64c712e492188d1 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 13:56:59 +0800 Subject: [PATCH 24/26] fix(live): parse Creative Lab null timestamps; name Creative Lab parts and bundles in the legacy -o layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found during the real-account verification (2026-09-08) and re-verified live. L01 client/types: the Creative Lab endpoints return finished_at: null (and may return null for the other not-yet fields) while a task is IN_PROGRESS, where the v2 endpoints return 0. The task schema accepted only a number with a default for absence, so creative-lab … get/wait failed with "unexpected task shape" (error.code server, HTTP 200) on every poll until completion. progress, preceding_tasks, created_at, started_at, finished_at and expires_at now accept null and absence alike and read as 0; the v1 view still shows a not-yet timestamp as null. The live body is the fixture; the test replays it through the schema and through get/wait. L02 internal/download + artifacts: the legacy -o downloader named every model_urls entry model., so lamp builds landed as model.lamp_stl / model.base_stl and the keychain OBJ ZIP bundle as model.obj. The legacy enumerator now asks the product-aware modelAsset mapping (shared with meshy download) for the file name and format: lamp.stl, base.stl, bundle.zip, model.obj.zip; slot keys and relink rules are unchanged. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/decisions.md | 37 +++++ docs/skill-parity/migration-notes.md | 7 + src/client/types.ts | 24 +++- src/internal/artifacts.ts | 8 +- src/internal/download.ts | 14 +- ...eative-lab-lamp-prototype.in-progress.json | 17 +++ tests/live-verification.test.ts | 128 ++++++++++++++++++ 7 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json create mode 100644 tests/live-verification.test.ts diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index 43e2b50..9b7c993 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -697,3 +697,40 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu nothing is disguised as `index_dirty`. Assets already downloaded stay where they landed. Missing/damaged metadata after the preflight keeps the `record_project` recovery of D-052/D-056. + +## D-059 Timestamps and counts a server sends as null read as 0 + +- Live verification (real account, 2026-09-08): the Creative Lab endpoints + return `finished_at: null` (and may return null for the other "not yet" + fields) while a task is IN_PROGRESS, whereas the v2 endpoints return 0. The + task schema accepted only a number with a default for *absence*, so every + `creative-lab … get`/`wait` on a running task failed with "unexpected task + shape" (v1 `error.code: "server"`, HTTP 200) until the task had finished — a + `wait` could never poll through. `progress`, `preceding_tasks`, `created_at`, + `started_at`, `finished_at` and `expires_at` now accept null and absence alike + and normalise both to 0, the value the v2 endpoints already use; the v1 task + view keeps showing a timestamp that has not happened as `null`. The body + captured live is `tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json` + and `tests/live-verification.test.ts` (L01) replays it through the schema and + through `creative-lab lamp prototype get`/`wait`. Verified live afterwards: a + keychain prototype created and waited on immediately polled through three + IN_PROGRESS states to SUCCEEDED; the lamp prototype's `get`/`wait` and the lamp + build completed. + +## D-060 The legacy `-o` layout names Creative Lab parts and bundles like `meshy download` + +- Live verification (real account, 2026-09-08): the task verbs' `-o` downloader + (`downloadArtifacts`, the 0.2.0 layout) derived every `model_urls` file name + as `model.`, which is right for format keys (`glb`, `obj`) but wrong for + Creative Lab builds: the lamp parts landed as `model.lamp_stl` / + `model.base_stl` and the keychain build's OBJ — which the server delivers as + a ZIP bundle (model.obj + model.mtl + texture.png) — as `model.obj`, an + extension no slicer or loader accepts. `meshy download` already named them + `lamp.stl`, `base.stl`, `model.obj.zip` through the product-aware + `modelAsset` mapping in artifacts.ts; that mapping is now shared: the legacy + enumerator asks it for the file name and expected format (product taken from + the task type), keeps its own slot keys (`model_lamp_stl`, `model_obj`) in + `saved_files`/the manifest, and a ZIP bundle is neither relinked nor renamed + by content type. `tests/live-verification.test.ts` (L02) covers both products; + the same tasks re-downloaded live produce `lamp.stl`/`base.stl` (byte-identical + to the mis-named files) and `model.obj.zip`. diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index 780b6bc..074bd0c 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -179,6 +179,13 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | `download --project P --workspace W --output-dir W/assets` with P or its parent replaced by a symlink to an outside project during the transfer | outside metadata.json rewritten, exit 0 with `files_outside_project` | exit 11 `local_io`, completed manifest kept, `project.action="failed"` with the reason, `recovery: null`, outside tree untouched | R6-F01 | | `get`/`wait`/`stream` with `--project` that is not an initialised project | refused after the request | refused before any request | R6-F02 (D-057) | +### 3.7 Corrections from the live (real-account) verification (visible in both schemas) + +| Behaviour | before the fix | after | Decision | +| --- | --- | --- | --- | +| `creative-lab … get`/`wait` while the task is IN_PROGRESS (the endpoint returns `finished_at: null`) | `server` error "unexpected task shape", HTTP 200, exit 1 on every poll until completion | parses; null timestamps/counts read as 0 (v1 view shows `null` as before); `wait` polls through | D-059 | +| task verbs `-o` on a Creative Lab lamp build / keychain OBJ build | `model.lamp_stl`, `model.base_stl`; the OBJ ZIP bundle saved as `model.obj` | `lamp.stl`, `base.stl`, `model.obj.zip` — the same names `meshy download` uses; slot keys unchanged | D-060 | + New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, `--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), diff --git a/src/client/types.ts b/src/client/types.ts index 06b8905..c487ea0 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -59,19 +59,29 @@ export const PrintabilitySchema = z .passthrough(); export type Printability = z.infer; +/** A count or epoch-millisecond field that a server may omit or send as null: absent and null both read as 0. */ +const nullableNumberOr0 = z + .number() + .nullable() + .optional() + .transform((v) => v ?? 0); + export const TaskSchema = z .object({ id: z.string(), type: z.string().default(""), name: z.string().nullable().optional(), status: z.string().default(""), - progress: z.number().default(0), - preceding_tasks: z.number().default(0), - - created_at: z.number().default(0), - started_at: z.number().default(0), - finished_at: z.number().default(0), - expires_at: z.number().default(0), + // The v2 endpoints report 0 for a timestamp that has not happened yet; the + // Creative Lab endpoints report null (observed live: finished_at: null while + // IN_PROGRESS). Both mean "not yet" and normalise to 0. + progress: nullableNumberOr0, + preceding_tasks: nullableNumberOr0, + + created_at: nullableNumberOr0, + started_at: nullableNumberOr0, + finished_at: nullableNumberOr0, + expires_at: nullableNumberOr0, task_error: TaskErrorSchema.nullable().optional(), diff --git a/src/internal/artifacts.ts b/src/internal/artifacts.ts index ac05d16..1ebf821 100644 --- a/src/internal/artifacts.ts +++ b/src/internal/artifacts.ts @@ -62,7 +62,13 @@ export function productFromTaskType(type: unknown): { product: string; stage: st return m ? { product: m[1]!, stage: m[2]! } : null; } -function modelAsset(fmtKey: string, url: string, product: string | null): Asset { +/** + * The asset a `model_urls` entry stands for. Shared with the legacy `-o` + * downloader so both paths name Creative Lab parts the same way (`lamp.stl`, + * `base.stl`, `bundle.zip`, the keychain/fridge-magnet OBJ bundle as + * `model.obj.zip`) instead of turning the key into an extension. + */ +export function modelAsset(fmtKey: string, url: string, product: string | null): Asset { const key = `model.${fmtKey}`; const sourcePath = `model_urls.${fmtKey}`; switch (fmtKey) { diff --git a/src/internal/download.ts b/src/internal/download.ts index 2e6b29f..bee8699 100644 --- a/src/internal/download.ts +++ b/src/internal/download.ts @@ -33,7 +33,7 @@ import { CliError, UsageError } from "./errors.js"; import { logger } from "./logger.js"; import { freezeRoot, isInside, realpathLenient, resolveWithinRoot, safeExtension, safeSegment, type AuthorisedRoot } from "./paths.js"; import { USER_AGENT } from "./user-agent.js"; -import type { Asset } from "./artifacts.js"; +import { modelAsset, productFromTaskType, type Asset } from "./artifacts.js"; import { fileDigest, relinkMaterials, type MaterialLinkReport } from "./material-links.js"; /** Extensions sharp can transcode between. */ @@ -604,15 +604,24 @@ export interface Artifact { url: string; /** Expected extension inferred from the slot name (no dot, may be empty). */ preferredExt: string; + /** File name to save under when the slot name is not `.` (Creative Lab parts, bundles). */ + filename?: string; } export function enumerateArtifacts(task: Task): Artifact[] { const out: Artifact[] = []; if (task.model_urls) { + // `model_urls` keys are usually formats (glb, obj) but Creative Lab builds + // use part names (`lamp_stl`, `base_stl`, `bundle_zip`) and deliver the + // keychain / fridge-magnet OBJ as a ZIP bundle. The selective downloader + // already knows this (artifacts.ts); the legacy layout names the files the + // same way while keeping its slot keys (`model_lamp_stl`). + const product = productFromTaskType(task.type)?.product ?? null; for (const [ext, url] of Object.entries(task.model_urls)) { if (typeof url === "string" && url) { - out.push({ key: `model_${ext}`, url, preferredExt: ext.toLowerCase() }); + const asset = modelAsset(ext, url, product); + out.push({ key: `model_${ext}`, url, preferredExt: asset.format ?? ext.toLowerCase(), filename: asset.filename }); } } } @@ -872,6 +881,7 @@ export async function downloadArtifacts( } function deriveFilename(artifact: Artifact): string { + if (artifact.filename) return artifact.filename; if (artifact.key.startsWith("model_")) { const ext = artifact.key.slice("model_".length); return `model.${ext}`; diff --git a/tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json b/tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json new file mode 100644 index 0000000..87bbeec --- /dev/null +++ b/tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json @@ -0,0 +1,17 @@ +{ + "id": "creative-lab-live-in-progress", + "type": "creative-lab-lamp-prototype", + "name": "live lamp", + "status": "IN_PROGRESS", + "progress": 5, + "created_at": 1788845091914, + "started_at": 1788845091918, + "finished_at": null, + "expires_at": 1789104291914, + "task_error": null, + "preceding_tasks": 0, + "consumed_credits": 30, + "model_urls": {}, + "thumbnail_url": "", + "image_urls": [] +} diff --git a/tests/live-verification.test.ts b/tests/live-verification.test.ts new file mode 100644 index 0000000..c382e90 --- /dev/null +++ b/tests/live-verification.test.ts @@ -0,0 +1,128 @@ +/** + * Regressions for defects found during the live (real-account) verification. + * + * L01 — Creative Lab endpoints report `finished_at: null` while a task is + * IN_PROGRESS (the v2 endpoints report 0). The task schema required a number, + * so `creative-lab … get`/`wait` failed with "unexpected task shape" (code + * `server`, HTTP 200) on every poll until the task finished. The body captured + * live is the fixture; null timestamps and counts now read as 0. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { TaskSchema } from "../src/client/types.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi } from "./helpers/cli.js"; + +const FIXTURE = join(import.meta.dirname, "fixtures", "skill-parity", "creative-lab-lamp-prototype.in-progress.json"); +const inProgress = JSON.parse(readFileSync(FIXTURE, "utf8")) as Record; + +test("L01 a Creative Lab task body with finished_at: null (captured live while IN_PROGRESS) parses; null timestamps and counts read as 0, the v2 zero form is unchanged", () => { + assert.equal(inProgress["finished_at"], null, "the fixture really carries null"); + const parsed = TaskSchema.parse(inProgress); + assert.equal(parsed.status, "IN_PROGRESS"); + assert.equal(parsed.progress, 5); + assert.equal(parsed.finished_at, 0, "null → 0, the same value the v2 endpoints send before completion"); + assert.equal(parsed.started_at, 1788845091918); + assert.equal(parsed.consumed_credits, 30); + // Every "not yet" field tolerates null and absence alike. + const sparse = TaskSchema.parse({ id: "x", status: "PENDING", progress: null, preceding_tasks: null, created_at: null, started_at: null, finished_at: null, expires_at: null }); + assert.deepEqual([sparse.progress, sparse.preceding_tasks, sparse.created_at, sparse.started_at, sparse.finished_at, sparse.expires_at], [0, 0, 0, 0, 0, 0]); + const absent = TaskSchema.parse({ id: "y", status: "PENDING" }); + assert.deepEqual([absent.progress, absent.finished_at], [0, 0]); + const v2 = TaskSchema.parse({ id: "z", status: "IN_PROGRESS", progress: 40, created_at: 1, started_at: 2, finished_at: 0, expires_at: 3 }); + assert.equal(v2.finished_at, 0); +}); + +test("L01 creative-lab lamp prototype get/wait on a task that is still IN_PROGRESS: get reports the status (exit 0), wait polls through to SUCCEEDED (2 GETs) instead of failing on the first poll", async () => { + let gets = 0; + const api = await startMockApi((req, res) => { + if (req.method === "GET" && /\/openapi\/creative-lab\/lamp\/v1\/prototype\/cl-live$/.test(req.path)) { + gets += 1; + if (gets <= 2) return jsonReply(res, 200, { ...inProgress, id: "cl-live" }); + return jsonReply(res, 200, { ...inProgress, id: "cl-live", status: "SUCCEEDED", progress: 100, finished_at: 1788845191918, thumbnail_url: `${api.url}/thumb.png`, model_urls: { glb: `${api.url}/model.glb` } }); + } + return jsonReply(res, 404, { message: "unexpected" }); + }); + try { + const env = api.env({ MESHY_POLL_INTERVAL_MS: "20" }); + const got = await runCli(["creative-lab", "lamp", "prototype", "get", "cl-live", "--output-schema", "v1"], { env }); + assert.equal(got.code, 0, `${got.stderr}\n${got.stdout}`); + const g = parseSingleJson(got.stdout) as { ok: boolean; result: { task: { status: string; progress: number; finished_at: number } } }; + assert.equal(g.ok, true); + assert.equal(g.result.task.status, "IN_PROGRESS"); + assert.equal(g.result.task.progress, 5); + assert.equal(g.result.task.finished_at, null, "the v1 view shows a timestamp that has not happened as null — for the Creative Lab null and the v2 zero alike"); + + const waited = await runCli(["creative-lab", "lamp", "prototype", "wait", "cl-live", "--output-schema", "v1"], { env }); + assert.equal(waited.code, 0, `${waited.stderr}\n${waited.stdout}`); + const w = parseSingleJson(waited.stdout) as { ok: boolean; result: { task: { status: string; finished_at: number }; wait: { polls: number; timed_out: boolean } } }; + assert.equal(w.ok, true); + assert.equal(w.result.task.status, "SUCCEEDED"); + assert.equal(w.result.task.finished_at, 1788845191918); + assert.equal(w.result.wait.timed_out, false); + assert.equal(w.result.wait.polls, 2, "the first poll saw IN_PROGRESS and was accepted, the second saw SUCCEEDED"); + assert.deepEqual(api.requests.map((q) => [q.method, q.path]), [ + ["GET", "/openapi/creative-lab/lamp/v1/prototype/cl-live"], + ["GET", "/openapi/creative-lab/lamp/v1/prototype/cl-live"], + ["GET", "/openapi/creative-lab/lamp/v1/prototype/cl-live"], + ]); + } finally { + await api.close(); + } +}); + +// --------------------------------------------------------------------------- +// L02 — the legacy `-o` layout names Creative Lab parts and bundles like `meshy download` +// --------------------------------------------------------------------------- + +test("L02 -o on a Creative Lab lamp build saves lamp.stl / base.stl (not model.lamp_stl), and a keychain OBJ bundle saves as model.obj.zip; slot keys and digests unchanged", async () => { + const { mkdirSync, statSync } = await import("node:fs"); + const { tmpDir } = await import("./helpers/cli.js"); + const stl = Buffer.concat([Buffer.alloc(80, 0), Buffer.from([1, 0, 0, 0]), Buffer.alloc(50, 7)]); + const zip = Buffer.from("504b0304140000000800" + "00".repeat(20) + "504b0506" + "00".repeat(18), "hex"); + const api = await startMockApi((req, res) => { + if (req.path === "/lamp.stl" || req.path === "/base.stl") { + res.writeHead(200, { "content-type": "application/octet-stream" }); + return void res.end(stl); + } + if (req.path === "/bundle") { + res.writeHead(200, { "content-type": "application/zip" }); + return void res.end(zip); + } + if (/\/openapi\/creative-lab\/lamp\/v1\/build\/lamp-1$/.test(req.path)) { + return jsonReply(res, 200, { id: "lamp-1", type: "creative-lab-lamp-build", status: "SUCCEEDED", progress: 100, finished_at: 1, model_urls: { lamp_stl: `${api.url}/lamp.stl`, base_stl: `${api.url}/base.stl` } }); + } + if (/\/openapi\/creative-lab\/keychain\/v1\/build\/key-1$/.test(req.path)) { + return jsonReply(res, 200, { id: "key-1", type: "creative-lab-keychain-build", status: "SUCCEEDED", progress: 100, finished_at: 1, model_urls: { obj: `${api.url}/bundle` } }); + } + return jsonReply(res, 404, { message: "unexpected" }); + }); + try { + const dir = tmpDir(); + const env = api.env(); + const lampOut = join(dir, "lamp"); + mkdirSync(lampOut); + const lamp = await runCli(["creative-lab", "lamp", "build", "get", "lamp-1", "--output-schema", "v1", "-o", lampOut], { env, cwd: dir }); + assert.equal(lamp.code, 0, `${lamp.stderr}\n${lamp.stdout}`); + const l = parseSingleJson(lamp.stdout) as { result: { downloads: { files: Array<{ key: string; path: string; bytes: number; status: string }> } } }; + assert.deepEqual(l.result.downloads.files.map((f) => [f.key, f.path.split("/").at(-1), f.status]), [ + ["model_lamp_stl", "lamp.stl", "written"], + ["model_base_stl", "base.stl", "written"], + ]); + for (const f of l.result.downloads.files) assert.equal(statSync(f.path).size, stl.length); + assert.deepEqual(Object.keys((JSON.parse(readFileSync(join(lampOut, "meta.json"), "utf8")) as { task: { model_urls: Record } }).task.model_urls).sort(), ["base_stl", "lamp_stl"]); + + const keyOut = join(dir, "keychain"); + mkdirSync(keyOut); + const key = await runCli(["creative-lab", "keychain", "build", "get", "key-1", "--output-schema", "v1", "-o", keyOut], { env, cwd: dir }); + assert.equal(key.code, 0, `${key.stderr}\n${key.stdout}`); + const k = parseSingleJson(key.stdout) as { result: { downloads: { files: Array<{ key: string; path: string; status: string }>; material_links: unknown } } }; + assert.deepEqual(k.result.downloads.files.map((f) => [f.key, f.path.split("/").at(-1), f.status]), [["model_obj", "model.obj.zip", "written"]]); + assert.ok(readFileSync(k.result.downloads.files[0]!.path).equals(zip), "the bundle bytes are saved untouched"); + assert.equal(k.result.downloads.material_links, null, "a ZIP bundle is not relinked"); + } finally { + await api.close(); + } +}); From c70b06cc3509b6e36b18a51783b368d816c964b4 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 14:09:54 +0800 Subject: [PATCH 25/26] =?UTF-8?q?docs(skill-parity):=20round=208=20handoff?= =?UTF-8?q?=20=E2=80=94=20live=20(real-account)=20verification=20bound=20t?= =?UTF-8?q?o=201d9f109?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the 2026-09-08 live verification run as the account owner: T-104 passed (API key + OAuth, refresh observed, journal replay/conflict), T-110 passed, T-111 passed (all Creative Lab products; showcases 403 enterprise-only recorded), T-112 passed (Bambu Studio), T-109 partial (macOS + Linux arm64/x64 29/29; Windows not_run). 327 credits consumed. Two live defects (L01 null timestamps, L02 legacy -o naming) fixed in 1d9f109 and re-verified live. New docs/skill-parity/live-verification.json holds the sanitised step-by-step record; verification.json and capability-matrix.json carry the final runs (558/558), reviewer-script reruns and per-capability live status. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/REVIEW_HANDOFF.md | 706 +++++------- docs/skill-parity/capability-matrix.json | 493 ++++++++- docs/skill-parity/live-verification.json | 1263 ++++++++++++++++++++++ docs/skill-parity/verification.json | 481 +++++--- 4 files changed, 2313 insertions(+), 630 deletions(-) create mode 100644 docs/skill-parity/live-verification.json diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md index fcf0f0e..076d50f 100644 --- a/docs/skill-parity/REVIEW_HANDOFF.md +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -1,48 +1,44 @@ # Meshy CLI S1 Review Handoff -Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 7**(Codex review 第 6 轮修复后的复审交接)。历次 review:第 1 轮 `…/reviews/cli-s1-6273d9a/`,第 2 轮 `…/reviews/cli-s1-730132b/`,第 3 轮 `…/reviews/cli-s1-cf8905d/`,第 4 轮 `…/reviews/cli-s1-235d6de/`,第 5 轮 `…/reviews/cli-s1-68690f9/`,第 6 轮 `…/reviews/cli-s1-7b7c24c/`;六处 reviewer 证据目录均未被改动,所有复现/核对脚本(14 个)逐字节复制到独立证据目录后运行(副本 sha256 与原件一致)。 +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 8**:第 7 轮 Codex review 已 **accepted**(G1-code,代码 `e567646`,docs `4da216d`,证据 `…/reviews/cli-s1-e567646/`);本轮是 **真实账号 live verification** 及其发现的 2 个缺陷的修复,交给 Codex 复审这两个修复并核对 live 证据。七处 reviewer 证据目录均未被改动;第 7 轮的 15 个脚本副本在新 HEAD 上重跑。 -## 0. 第 6 轮 review 结论与本轮修复 +## 0. Live verification 结论与本轮修复 -- 被 review 的 HEAD:`166492ba9691f093b302c1c114e782bceee14a1d`(代码 `7b7c24c`);结论 **changes_requested**:2 项 P1 代码缺陷(R6-F01、R6-F02)+ 1 项 P3 测试缺口(R6-T01);reviewer 全量 552/552,前两轮 20/20、R3+R4 正向 8/8、R5 正向 24/24、材质矩阵 16/16 + 补充 10/10、项目入口矩阵 20/20、上下文 5/5;新探针 4/4 复现。 -- 修复 commit:`e567646`(`fix(review): address Codex review round 6 findings R6-F01, R6-F02 and test gap R6-T01`)。本轮没有测试稳定性提交。`23 files changed, 665 insertions(+), 105 deletions(-)`(相对 166492b,含 docs)。 -- 复现脚本重跑(副本,`e567646`):`round6-probes.mjs` F01 task-json/api、F02 get/create-async **全部 exit 11、外部 metadata/history 字节不变、recovery null**(reproduced=false 不是验收依据,正向断言见 `tests/codex-review-round6.test.ts`);`round5-probes.mjs` 0/4;`round4-probes.mjs` 0/3(D03 signal_sent=true / exit 130);`round3-probes.mjs` 0/6(fs.watch C03 signal_sent=true / exit 130,本轮触发成功);`round2-probes.mjs` 0/8;`round1-probes.mjs` 0/12;`verify-original-regressions.py` **20/20**;`verify-round3-4-regressions.py` **8/8**;`verify-round5-positive.py` **24/24**;`material-matrix-check.mjs` **16/16**;`round6-material-check.mjs` **10/10**;`project-entry-matrix-check.mjs` **20/20**;`round4-context-checks.mjs` **5/5**;`stream-finalization-check.mjs` 通过。 -- 正向回归:`tests/codex-review-round6.test.ts`(3 项:F01 download × task-json/API × 项目叶子/父目录换成外部链接 + 健康对照;F02 legacy/v1 × get/wait/stream/create-async/create-sync × workspace 目录被换/别名被重指向 = 20 组;稳定别名对照);`tests/codex-review-round5.test.ts` E03 全部 20 组精确 method/path 序列、E02 候选按行与 key 绑定(R6-T01);前五轮回归全部保留并通过。 -- 全量:`pnpm typecheck` 通过;`pnpm test` **555/555**(0 失败、0 跳过);`git diff --check` 在 `fd94490..HEAD`、`166492b..HEAD` 与工作树均 exit 0;`poll.test.ts` 连续 12 次 12/12;`codex-review-round1.test.ts` 连续 8 次 8/8;round2–6 套件连续 3 次 3/3。 +- 执行方式:账号所有者在自己的终端完成 `meshy auth login --with-key`(profile `default`,API key)与 `meshy auth login`(profile `oauth`,浏览器 PKCE),凭据落在默认的 `~/.config/meshy/credentials.json`;CLI 通过 `npm install -g ~/Downloads/meshy-cli-0.3.0.tgz` 全局安装(与真实用户一致,每次修复后重新安装);工作目录 `/meshy-live`;所有者授权计费且不设上限。凭据从未经过 agent;记录中凭据一律脱敏(`auth status` 自带遮蔽;`credentials.json` 只读取键名与哈希)。 +- 结果:**T-104 passed**(两种凭据、真实 token 端点无 `user_id` → login_id 身份、静默 refresh 观测到期后 expires_at 前移且 login_id 不变、journal 复用/凭据冲突/媒体指纹冲突真实复现);**T-110 passed**;**T-111 passed**(UV / 全部 Creative Lab 产品 / showcases 为 403 enterprise-only 记录);**T-112 passed**(真实 Bambu Studio 02.08.02.61 两次拉起);**T-109 partial**(macOS arm64 + Linux arm64/x64 通过,Windows x64 not_run)。 +- credits:开始 2806 → 结束 2479,共消耗 **327**(每步见 §4 表)。 +- 发现并修复 2 个真实缺陷(commit `1d9f109`,`tests/live-verification.test.ts`,修复后重新安装并在真实任务上复验): -| ID | 优先级 | 问题 | 修复 | 决策 | 回归测试 | 复现脚本重跑 | -| --- | --- | --- | --- | --- | --- | --- | -| R6-F02 | P1 | 授权根在每次检查时重新 realpath:请求期间把 workspace(或其别名)换成指向外部树的符号链接,root 与目标一起移动,检查通过,snapshot/metadata/history 写到外部,exit 0 | 授权根改为 `AuthorisedRoot`,在读取全局 flags 时冻结(`freezeRoot`:真实路径 + 目录 dev/inode);`resolveWithinRoot` 收到冻结根时不再解析根,先证明同一目录仍在原路径(出现符号链接或不同目录 → "changed since the command started"),再证明目标真实路径在冻结的真实根内。冻结根贯穿 `--save-json`、`-o` 下载(downloadArtifacts/downloadAssets/sidecar/report-only)、`make`、`mesh prepare-print`、`project` 命令与任务动词的项目记账;无 workspace 时项目目录本身在首次请求前冻结(`beginProjectContext`),记账通过证明过的真实路径写入。越界为边界失败:exit 11、任务与 accepted journal 保留、单 POST、外部零写入、recovery null、无跨界命令。稳定别名照常通过 | D-057 | F02 矩阵(legacy/v1 × 5 动词 × 目录被换/别名重指向 = 20 组:exit 11、task_id、create 恰 1 POST 且 journal 恰 1 条 accepted、`submission.operation_id`/legacy 顶层 `operation_id` 对账、精确 method/path、外部树与原树整树摘要不变、recovery null、hint 非 record 命令);稳定别名对照(snapshot 落在真实项目、index 刷新) | F02 get/create-async: exit 11, metadata/history 不变 | -| R6-F01 | P1 | 独立 `download --project` 记账阶段只 lstat 了 `P/metadata.json`:P 或其父目录在资产 GET 期间换成指向外部项目的符号链接时被跟随,外部 metadata 被改写,exit 0 | 项目阶段在任何项目锁/快照/metadata 写入之前用冻结边界重新定位项目(`resolveWithinRoot(projectDir, projectRoot)`;无 workspace 时项目目录本身在预检后冻结),记账通过真实路径写入;边界失败(`projectBoundaryFailure`)与可修复的 metadata 内容问题分开:exit 11 / local_io、完整下载 outcome(source/selection/manifest 磁盘摘要/saved_json)、`project.action="failed"` + error、`recovery: null`、不生成跨界 record 命令、不伪装成 index_dirty;已下载资产不回滚。metadata 缺失/损坏仍走 D-052/D-056 的 record_project | D-058 | F01(task-json/API × 叶子/父目录:exit 11、完整 v1 形状、downloads.completed 与 bytes/sha、project.failed + recovery null、外部整树摘要不变、原项目 metadata 不变、精确请求 task-json `GET /model.glb` / API `GET task, GET /model.glb`);健康对照 exit 0 记账、`files_outside_project` | F01 task-json/api: exit 11, 外部 metadata 不变 | -| R6-T01 | P3 | E03 的 wait/create-sync 8 组没有精确 GET 数量/顺序断言;E02 候选按集合查找 | E03 20 组统一 deepEqual `[method, path]`(get/wait/stream 各 1 GET;create-async 单 POST;create-sync POST GET,路径含 task_id);E02 按 line 2/3 与原 key 绑定候选集 | — | `tests/codex-review-round5.test.ts`(R6-T01);新 round-6 矩阵同样精确断言序列 | verify-round5-positive 24/24 | +| ID | 严重度 | 现象(真实账号) | 修复 | 决策 | 复验 | +| --- | --- | --- | --- | --- | --- | +| L01 | P1(Creative Lab 全流程阻断) | Creative Lab 端点在 IN_PROGRESS 时返回 `finished_at: null`(v2 端点返回 0);schema 只接受 number → `creative-lab … get/wait` 每次轮询都以 "unexpected task shape"(`error.code=server`,HTTP 200,exit 1)失败,`wait` 永远轮询不到完成 | `progress/preceding_tasks/created_at/started_at/finished_at/expires_at` 接受 null 与缺省并归一为 0;v1 视图仍以 `null` 表示未发生 | D-059 | 真实捕获体作为 fixture;keychain prototype 创建后立即 `wait` 经 3 次 IN_PROGRESS 到 SUCCEEDED;lamp prototype get/wait 与 lamp build 完成 | +| L02 | P2(输出文件名错误) | 任务动词的 `-o`(legacy 布局)把 `model_urls` 键当扩展名:lamp build 落盘为 `model.lamp_stl`/`model.base_stl`;keychain 的 OBJ 实为 ZIP bundle 却存成 `model.obj` | legacy 枚举复用 artifacts.ts 的产品感知 `modelAsset` 映射得到文件名与格式(`lamp.stl`、`base.stl`、`bundle.zip`、`model.obj.zip`),slot key 与 relink 规则不变 | D-060 | 同一任务重新 `-o`:`lamp.stl`/`base.stl`(字节与误名文件一致)、`model.obj.zip`;fridge-magnet build 亦按映射命名 | + +- 未修复的观察(P3,供 Codex 判断是否立项):见 §6。 +- 回归:`pnpm test` **558/558**;typecheck 通过;`git diff --check` 在 `fd94490..HEAD`、`4da216d..HEAD`、工作树均 exit 0;poll ×12 12/12、round1 ×8 8/8、round2–6 ×3 3/3;第 7 轮 15 个 reviewer 脚本副本在 `1d9f109` 上:round1–6 探针 0 复现(C03/D03 signal_sent=true exit 130),verify 20/20、8/8、24/24、5/5,矩阵 16/16、10/10、20/20,context 5/5,stream 通过;tarball smoke 29/29(sha256 `0d22647fd0568a7c6b44aba80ceb32a5de74781b6697d44986745ee78ddf9c8e`)。 ## 1. 代码定位 -- 仓库路径 / remote:`/Users/ark/Dev/meshy-cli`(fresh clone) / `https://github.com/meshy-dev/meshy-cli.git` -- 分支:`feat/skill-parity-s1`(本地分支,未推送) -- base SHA:`fd94490916376e691efcea51324ac4326b459e1f`(0.2.0,= 计划基线 = 开工时的 remote main) -- head SHA(代码):`e567646d875e5f5a658b78e60b8cdfaed8b233e9`(= 修复 `e567646d875e5f5a658b78e60b8cdfaed8b233e9`);本文件与 verification.json/capability-matrix.json 在其后的 **docs-only commit** 中(见 `git log`,不改变任何 `src/`、`tests/`、`package.json`、`pnpm-lock.yaml`) -- 历次被 review 的 HEAD:round 1 `6273d9aa6ef396cf1cc26838e2c0d09ab176f595`(代码 `e7c26fc`);round 2 `730132bd99a471ed41d6bbf6b200219f13f569ba`(代码 `0388fe8`);round 3 `566f3bdbcdd85d1139e48e3a87d4c6f5e844e43a`(代码 `cf8905d`);round 4 `9e43a77ced4f84e1ab03c96ddc9a61ce349d44d4`(代码 `235d6de`);round 5 `e7c0bbc4c39b26e77d48ab0979328ae8fa4a5b59`(代码 `68690f9`);round 6 `166492ba9691f093b302c1c114e782bceee14a1d`(代码 `7b7c24c`) +- 仓库路径 / remote:`/Users/ark/Dev/meshy-cli` / `https://github.com/meshy-dev/meshy-cli.git`;分支 `feat/skill-parity-s1`(本地,未推送) +- base SHA:`fd94490916376e691efcea51324ac4326b459e1f` +- 上一轮(accepted)代码 HEAD:`e567646d875e5f5a658b78e60b8cdfaed8b233e9`;docs HEAD:`4da216d3568fbd997bf85f8047ce3932672a25de` +- 本轮代码 HEAD:`1d9f10976b5f754502c81591c64c712e492188d1`(= live 修复 commit);本文件与 verification.json / capability-matrix.json / live-verification.json 在其后的 **docs-only commit** 中 - 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) -- 实施包版本:2026-09-07 / v1 -- 实际源码与计划基线的差异:无(见 `docs/skill-parity/baseline-delta.md`) -- PR URL:未创建(未获创建 PR / 推送授权) -- Node / pnpm / OS / arch:Node v24.20.0(fnm)/ pnpm 11.24.0(corepack,`packageManager`)/ macOS 26.6 (Darwin 25.6.0) / arm64 +- Node / pnpm / OS:Node v24.20.0 / pnpm 11.24.0 / macOS 26.6.2 arm64 +- PR / 发布:未创建、未发布(需授权) ## 2. 完成状态 -- G1-code / review-ready:**第 6 轮 2 项 P1 已修复并有正向回归,P3 测试缺口已补;前五轮 29 项在新 HEAD 复核未回退(reviewer 正向脚本 20/20、8/8、24/24,六轮探针 0 复现,矩阵 16/16、10/10、20/20,上下文 5/5);自评 passed,等待 Codex 复审确认**(上一轮结论 not_accepted) -- G1-release / ready-for-S2:**not_run**(无复审结论、无真实账号/多 OS/切片器验证、未发布;按用户要求不在本轮范围) -- mandatory 能力实现数 / 总数:**35 / 35**(`docs/skill-parity/capability-matrix.json`;7 项带 `review_round_6`,8 项 `review_round_5`,5 项 `review_round_4`,10 项 `review_round_3`,13 项 `review_round_2`,21 项 `review_round_1`,`review_status` 均为 "re-review pending") -- mandatory 离线测试通过 / 失败 / 未执行数:`pnpm test` **555 通过 / 0 失败 / 0 跳过**(555 项,含基线原有 363 项;覆盖 74 个 T-id 的离线部分,见 verification.json `behavior_tests`) -- 真实 API / OS / GUI 验证通过 / 未执行项:通过 1 项部分(真实公开动画目录 GET,免费无鉴权,在 e567646 重跑:exit 0)+ macOS arm64 tarball 安装 smoke 29 项;未执行:T-104(真实账号 OAuth/Key 回归,含真实 token 端点是否返回 user_id)、T-109 Windows/Linux、T-110 鉴权 get/下载、T-111 UV/Creative Lab/showcases、T-112 真实切片器 open -- 是否修改独立 Skills、MCP 或内部服务仓库:**没有**(六个 reviewer 目录未被改动;未调用付费接口;未发布) +- G1-code:第 7 轮 accepted;本轮新增 2 个 live 修复待 Codex 复审(范围小:`src/client/types.ts` 6 个字段的 null 容忍;`src/internal/download.ts`+`artifacts.ts` 的文件名映射共享)。 +- G1-release:**尚未满足**。已完成:Codex review(第 7 轮)、真实账号/UV/Creative Lab/showcases/切片器/macOS+Linux 验证;未完成:Windows x64(无主机,not_run)、正式发布(未授权)、本轮两个修复的复审。 +- mandatory 能力 35/35 已实现;`capability-matrix.json` 每项新增 `live_verification`(33 项 live passed / passed_after_live_fixes,CAP-014 showcases 为账号门控 403 记录,CAP-034 打包为 partial(Windows not_run))。 +- 离线测试:`pnpm test` 558/558,0 跳过。 ## 3. 本次具体改动 -`git log --oneline fd94490..HEAD`(最早在下): - ``` +1d9f109 fix(live): parse Creative Lab null timestamps; name Creative Lab parts and bundles in the legacy -o layout +4da216d docs(skill-parity): round-7 handoff after Codex review round 6 fixes e567646 fix(review): address Codex review round 6 findings R6-F01, R6-F02 and test gap R6-T01 166492b docs(skill-parity): round-6 handoff after Codex review round 5 fixes 7b7c24c fix(review): address Codex review round 5 findings R5-F01–R5-F03 @@ -67,461 +63,283 @@ e7ea577 feat(cli): B01 v1 envelope, exit codes, local runtime, --api-key-file an c75588e docs(skill-parity): B00 baseline, endpoint contracts, decisions and fixtures ``` -修复 commit: - ``` -e567646 fix(review): address Codex review round 6 findings R6-F01, R6-F02 and test gap R6-T01 - - README.md | 2 +- - docs/skill-parity/decisions.md | 45 +++++ - docs/skill-parity/migration-notes.md | 8 + - skills/meshy-cli/SKILL.md | 6 +- - src/cmd/animation-catalog.ts | 2 +- - src/cmd/api.ts | 2 +- - src/cmd/balance.ts | 2 +- - src/cmd/doctor.ts | 2 +- - src/cmd/download.ts | 72 ++++++-- - src/cmd/inspect.ts | 2 +- - src/cmd/make.ts | 4 +- - src/cmd/mesh.ts | 2 +- - src/cmd/project.ts | 14 +- - src/cmd/showcases.ts | 2 +- - src/internal/command-helpers.ts | 6 +- - src/internal/download.ts | 56 ++++--- - src/internal/obj-transform.ts | 12 +- - src/internal/paths.ts | 99 ++++++++++- - src/internal/project-store.ts | 7 +- - src/internal/runtime.ts | 4 + - src/internal/task-command.ts | 86 ++++++---- - tests/codex-review-round5.test.ts | 22 ++- - tests/codex-review-round6.test.ts | 313 +++++++++++++++++++++++++++++++++++ - 23 files changed, 665 insertions(+), 105 deletions(-) +1d9f109 fix(live): parse Creative Lab null timestamps; name Creative Lab parts and bundles in the legacy -o layout + + docs/skill-parity/decisions.md | 37 ++++++ + docs/skill-parity/migration-notes.md | 7 ++ + src/client/types.ts | 24 ++-- + src/internal/artifacts.ts | 8 +- + src/internal/download.ts | 14 ++- + .../creative-lab-lamp-prototype.in-progress.json | 17 +++ + tests/live-verification.test.ts | 128 +++++++++++++++++++++ + 7 files changed, 225 insertions(+), 10 deletions(-) ``` -要点: - -- `src/internal/paths.ts`:新增 `AuthorisedRoot`(given/real/anchor/dev/ino/directory/label)、`freezeRoot`(真实路径 + 最深已存在祖先的目录身份)、`assertRootIntact`(lstat 同一目录、非符号链接、dev/inode 相同);`resolveWithinRoot(target, root: string | AuthorisedRoot)`:冻结根不再 realpath,先 `assertRootIntact` 再判定目标真实路径在 `root.real` 内。 -- `src/internal/runtime.ts`:`GlobalFlags.workspaceRoot` 在 `readGlobalFlags` 中由 `--workspace` 冻结(首次 I/O 前)。 -- `src/internal/command-helpers.ts`(`saveRawJson`)、`src/internal/project-store.ts`(`indexRootFor`)、`src/internal/download.ts`(`downloadAssets`/`downloadArtifacts`/`placeFetched`/`downloadArtifact`/`saveReportOnly`/`writeMeta`)、`src/internal/obj-transform.ts`(写根)、`src/cmd/project.ts`、`src/cmd/mesh.ts`、`src/cmd/make.ts`、`balance/doctor/api/inspect/animation-catalog/showcases` 的 `--save-json`:全部改为接收并使用冻结根。 -- `src/internal/task-command.ts`:`beginProjectContext`(get/wait/stream 在 openCommand 后、create 在预检中冻结项目根并记住是否已初始化;不在此拒绝,未初始化的项目仍作为请求后的记账失败,保持 R2-F03 的单 outcome);`attachToProject` 用冻结根定位项目(`located` 真实路径),边界失败 → `projectBoundaryFailure`(无命令),其余 → record_project;记账通过 `located` 写入。 -- `src/cmd/download.ts`:`projectRoot`(workspace 或预检后冻结的项目目录);项目阶段先 `resolveWithinRoot(projectDir, projectRoot)` → 失败走新 `projectBoundaryFailure`(recovery null、project.failed、完整 outcome);通过后按真实路径算文件列表、断言 metadata、记账。 -- 测试:`tests/codex-review-round6.test.ts`(新,3 项);`tests/codex-review-round5.test.ts` R6-T01 精确请求序列与按行候选;`R5-F03` 边界用例的消息断言随文案更新("authorised boundary")。 -- 文档:`decisions.md` D-057–D-058;`migration-notes.md` §3.6;README `--workspace` 行;SKILL.md 边界失败处置。 - -## 4. 能力与接口证据 - -### 4.1 第 6 轮 finding 修复后的实际输出(`round6-probes.mjs` 副本重跑,`e567646`,脱敏) - -**F01-download-project-boundary-task-json** +- `src/client/types.ts`:`nullableNumberOr0`(null/缺省 → 0)用于 6 个时间戳/计数字段。 +- `src/internal/artifacts.ts`:导出 `modelAsset`;`src/internal/download.ts`:legacy `enumerateArtifacts` 用产品感知映射得到 `filename`/`preferredExt`,`deriveFilename` 优先使用它。 +- `tests/live-verification.test.ts`:L01(fixture 解析 + get/wait 轮询)、L02(lamp/keychain build `-o` 命名);fixture `tests/fixtures/skill-parity/creative-lab-lamp-prototype.in-progress.json`(真实捕获体,id/name 已替换)。 +- 文档:D-059、D-060;migration-notes §3.7;`docs/skill-parity/live-verification.json`(脱敏 live 记录:49 步 + Linux 双架构)。 + +## 4. Live verification 逐步记录(脱敏;完整字段见 `docs/skill-parity/live-verification.json`) + +| 步骤 | 凭据 | 内容 | credits | +| --- | --- | --- | --- | +| T104-01 | oauth | auth status (OAuth active) — masked credential, verified balance | | +| T104-02 | oauth | auth list — two profiles (api_key default, oauth) | | +| T104-03 | oauth | credentials.json shape: oauth profile has access/refresh tokens and login_id, no user_id (the real token endpoint did not return one) | | +| T104-04 | oauth | silent refresh observed: expires_at advanced, login_id kept, tokens rotated; refreshed token works | | +| T104-05 | api_key | journal replay with the same key: operation_replayed, no new task, balance unchanged | | +| T104-06 | oauth | same operation id under the OAuth profile → operation_conflict (credential), exit 2, nothing submitted | | +| T104-07 | api_key | same operation id with a different image → operation_conflict (payload); same bytes under another file name → replayed | | +| T104-08 | oauth | OAuth bearer on v2 get and v1 asset download — same bytes as the API-key download | | +| T110-01 | api_key | project init (real workspace ~/meshy-live) | | +| T110-02 | api_key | text-to-3d create --mode preview --async --project --save-json | 20 | +| T110-03 | api_key | stream (ndjson) on the finished preview: task + outcome, sequence contiguous | | +| T110-04 | api_key | wait -o preview into the project (model.glb + thumbnail, meta.json, snapshot merged) | | +| T110-05 | api_key | download --list on the preview | | +| T110-06 | api_key | text-to-3d create --mode refine (glb,obj,fbx) --async | 10 | +| T110-07 | api_key | stream (ndjson) during the refine: 30 progress events + 1 outcome, contiguous sequence | | +| T110-08 | api_key | wait -o refine: 9 files incl. OBJ+MTL+4 textures; real MTL map_Kd texture_0.png → texture_0_base_color.png by source_name; material_links complete | | +| T110-09 | api_key | download --model-format obj (selective, dependencies) — material_links complete | | +| T110-10 | api_key | download --asset thumbnail.primary --output file | | +| T110-11 | api_key | image-to-3d create from a local synthetic PNG (data URI) — server-side FAILED, 0 credits, task_failed relayed, FAILED recorded in the project | 0 | +| T110-12 | api_key | download --list / get on the FAILED task (not_ready + task_not_ready warning; task_error relayed) | | +| T110-13 | api_key | image-to-3d retry with the real refine thumbnail: SUCCEEDED, OBJ/MTL relinked | 30 | +| T110-14 | api_key | text-to-3d list --page-size 3 | | +| T110-15 | oauth | image-to-3d delete (the FAILED task) then get → not_found 404 exit 5 | | +| T111-01 | api_key | showcases list → 403 enterprise-only (account-gated): error.code server, http 403, exit 1 | | +| T111-02 | api_key | animation-catalog list (public, free) | | +| T111-03 | api_key | uv-unwrap on the 1.9M-face refine → API 400 (44k limit) mapped to validation exit 4; on the remeshed model SUCCEEDED | 5 | +| T111-04 | api_key | remesh (8000 faces, glb,obj) SUCCEEDED; OBJ set relinked | 5 | +| T111-05 | api_key | retexture (text style prompt, PBR) SUCCEEDED | 10 | +| T111-06 | api_key | analyze-printability SUCCEEDED (0 credits): report-only, meta.json written, status warning (degenerate faces) | 0 | +| T111-07 | api_key | rigging on the teapot → API 400 (face limit) then 422 (pose estimation failed) → validation exit 4 | | +| T111-08 | api_key | text-to-motion: CLI requires --duration (2–10 s); with --duration 4 SUCCEEDED, motion.fbx | 10 | +| T111-09 | api_key | humanoid text-to-3d preview for the rig chain | 20 | +| T111-10 | oauth | remesh the humanoid to 30k faces (rigging refused 1.95M faces with 400) | 5 | +| T111-11 | oauth | rigging SUCCEEDED: rigged glb/fbx + walking/running clips (8 files) | 5 | +| T111-12 | oauth | animate create --action-id 28 (Big Wave Hello) on the rig: stream 10 events + outcome; wait -o glb+fbx | 3 | +| T111-13 | oauth | make 'a low-poly cactus…' -o: preview → refine, two journal records, downloads | 30 | +| T111-14 | oauth | creative-lab lamp prototype create; get/wait on the IN_PROGRESS task FAILED with 'unexpected task shape' (finished_at: null) → live finding L01 | 30 | +| T111-15 | oauth | after the L01 fix (reinstalled CLI): lamp prototype get/wait SUCCEEDED (lampshade glb + concept image) | | +| T111-16 | oauth | keychain prototype create + immediate wait polled through 3 IN_PROGRESS states to SUCCEEDED (L01 verified live) | 6 | +| T111-17 | oauth | lamp build SUCCEEDED — legacy -o named the parts model.lamp_stl/model.base_stl → live finding L02 | 6 | +| T111-18 | oauth | meshy download --list/--all on the lamp build names lamp.stl / base.stl (selective path was right) | | +| T111-19 | oauth | keychain builds: default (glb) and --model-format obj (ZIP bundle); legacy -o saved the bundle as model.obj → L02; selective path: model.obj.zip | 60 | +| T111-20 | oauth | after the L02 fix (reinstalled CLI): -o on the same builds → lamp.stl/base.stl (byte-identical) and model.obj.zip | | +| T111-21 | oauth | figure prototype SUCCEEDED; first figure build FAILED server-side (0 credits, task_failed relayed); retry SUCCEEDED with OBJ/MTL relinked | 36 | +| T111-22 | oauth | fridge-magnet prototype + build (exposed product) SUCCEEDED with the fixed CLI: bundle named by the shared mapping | 36 | +| T112-01 | none | slicer detect finds Bambu Studio 02.08.02.61; slicer open on a prepared print OBJ launches it (macOS open -a, pid observed) | | +| T112-02 | none | mesh prepare-print on the real remeshed OBJ (60 mm) → print OBJ + copied MTL/texture; slicer open again on that file | | +| LOCAL-01 | none | project show/list on the real project: 11+ task entries with snapshots and operation ids; history index clean | | +| LOCAL-02 | none | inspect faces on real task snapshots → check_unknown (13): the API task JSON carries no face_count | | + +Linux(OrbStack,`node:24-bookworm`):linux/arm64 Debian GNU/Linux 12 (bookworm) node v24.20.0 → 29/29,sharp 0.35.4 (libvips 8.18.6); linux/x64 Debian GNU/Linux 12 (bookworm) node v24.20.0 → 29/29,sharp 0.35.4 (libvips 8.18.6)。 + +### 4.1 关键实际输出(脱敏) + +L01 修复前(lamp prototype `wait`,IN_PROGRESS): ```json { - "args": "download --task-json /boundary-task-json.json --all --project /ws-task-json/projects/_escaped-project_0d2d --workspace /ws-task-json --output-dir /ws-task-json/assets", - "exit": 11, - "error": { - "code": "local_io", - "message": "1 file(s) were downloaded to /ws-task-json/assets but --project /ws-task-json/projects/_escaped-project_0d2d is no longer a target inside the authorised boundary: --project /ws-task-json/projects/_escaped-project_0d2d is a symbolic link; refusing to write through it; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record task round2-task with `meshy project record` from that workspace", - "recovery": null, - "hint": null - }, - "result.project": { - "project_dir": "/ws-task-json/projects/_escaped-project_0d2d", - "action": "failed", - "stage": "preview", - "recorded_files": [], - "error": { - "code": "local_io", - "message": "--project /ws-task-json/projects/_escaped-project_0d2d is a symbolic link; refusing to write through it" - }, - "recovery": null - }, - "result.task_id": "round2-task", - "downloads": { - "state": "completed" - }, - "external_metadata_changed": false, - "external_history_changed": null, - "external_listing": [ - "metadata.json" - ], - "requests": [ - "GET /model.glb" - ] + "ok": false, + "error.code": "server", + "error.http_status": 200, + "message_head": "unexpected task shape from GET /lamp/v1/prototype/01a07f79-e9f7-7347-89c7-c46fb7a11d06: [\n {\n \"expected\": \"number\",\n" } ``` -**F01-download-project-boundary-api** -```json -{ - "args": "download --resource text-to-3d --task-id round2-task --all --project /ws-api/projects/_escaped-project_944d --workspace /ws-api --output-dir /ws-api/assets", - "exit": 11, - "error": { - "code": "local_io", - "message": "1 file(s) were downloaded to /ws-api/assets but --project /ws-api/projects/_escaped-project_944d is no longer a target inside the authorised boundary: --project /ws-api/projects/_escaped-project_944d is a symbolic link; refusing to write through it; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record task round2-task with `meshy project record` from that workspace", - "recovery": null, - "hint": null - }, - "result.project": { - "project_dir": "/ws-api/projects/_escaped-project_944d", - "action": "failed", - "stage": "preview", - "recorded_files": [], - "error": { - "code": "local_io", - "message": "--project /ws-api/projects/_escaped-project_944d is a symbolic link; refusing to write through it" - }, - "recovery": null - }, - "result.task_id": "round2-task", - "downloads": { - "state": "completed" - }, - "external_metadata_changed": false, - "external_history_changed": null, - "external_listing": [ - "metadata.json" - ], - "requests": [ - "GET /openapi/v2/text-to-3d/round2-task", - "GET /model.glb" - ] -} -``` -**F02-mutable-workspace-boundary-get** +L01 修复后(keychain prototype 创建后立即 `wait`): ```json { - "args": "text-to-3d get mutable-root-task --output-schema v1 --project /mutable-ws-get/projects/_mutable-root_cef3 --workspace /mutable-ws-get", - "exit": 11, - "error": { - "code": "local_io", - "message": "task mutable-root-task exists but --project /mutable-ws-get/projects/_mutable-root_cef3 is no longer a target inside the authorised boundary: --workspace /mutable-ws-get changed since the command started: /mutable-ws-get is now a symbolic link; refusing to write outside the authorised boundary; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record the task with `meshy project record` from that workspace", - "recovery": null, - "hint": "meshy text-to-3d wait mutable-root-task --output-schema v1" - }, - "result.project": null, - "result.task_id": "mutable-root-task", - "downloads": { - "state": "not_requested" - }, - "external_metadata_changed": false, - "external_history_changed": false, - "external_listing": [ - "metadata.json" - ], - "requests": [ - "GET /openapi/v2/text-to-3d/mutable-root-task" + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 6, + "result.wait.polls": 4, + "files": [ + { + "key": "image_0", + "bytes": 477978, + "status": "written", + "sha256_12": "e4eab2f32244" + } ] } ``` -**F02-mutable-workspace-boundary-create-async** + +L02 修复前/后(lamp build 与 keychain obj build 的 `-o` 文件名): ```json { - "args": "text-to-3d create --mode preview --prompt fixture --async --output-schema v1 --project /mutable-ws-create-async/projects/_mutable-root_66f8 --workspace /mutable-ws-create-async", - "exit": 11, - "error": { - "code": "local_io", - "message": "task mutable-root-task exists (operation 2ac86063-8cad-4294-8728-20fccc09a615) but --project /mutable-ws-create-async/projects/_mutable-root_66f8 is no longer a target inside the authorised boundary: --workspace /mutable-ws-create-async changed since the command started: /mutable-ws-create-async is now a symbolic link; refusing to write outside the authorised boundary; nothing was recorded (no project lock, snapshot or metadata was written). Restore the project inside the workspace, then record the task with `meshy project record` from that workspace", - "recovery": null, - "hint": "meshy text-to-3d wait mutable-root-task --output-schema v1" - }, - "result.project": null, - "result.task_id": "mutable-root-task", - "downloads": { - "state": "not_requested" - }, - "external_metadata_changed": false, - "external_history_changed": false, - "external_listing": [ - "metadata.json" + "before_lamp": [ + { + "key": "model_base_stl", + "bytes": 360284, + "status": "written", + "sha256_12": "5cbf2af2866c" + }, + { + "key": "model_lamp_stl", + "bytes": 15162184, + "status": "written", + "sha256_12": "94f5809a85df" + } ], - "requests": [ - "POST /openapi/v2/text-to-3d" - ] + "before_keychain_legacy": [ + { + "key": "model_obj", + "bytes": 46871892, + "status": "written", + "sha256_12": "bf665456914a" + } + ], + "selective_download_was_right": [ + { + "key": "model.obj", + "relative_path": "keychain-build-selective/model.obj.zip", + "format": "zip", + "container_format": "zip" + } + ], + "after_fix": { + "lamp": [ + [ + "model_base_stl", + "base.stl" + ], + [ + "model_lamp_stl", + "lamp.stl" + ] + ], + "keychain": [ + [ + "model_obj", + "model.obj.zip" + ] + ] + } } ``` -### 4.2 矩阵(reviewer 脚本副本,`e567646`) - -`project-entry-matrix-check.mjs`:20/20 组有 `record_project` 恢复(metadata 缺失/损坏 = 可修复内容问题),全部 exit 11、task_id 保留。 - -| schema | verb | fault | exit | task_id | record_project | -| --- | --- | --- | --- | --- | --- | -| legacy | get | damaged | 11 | legacy-get-damaged | yes | -| legacy | get | missing | 11 | legacy-get-missing | yes | -| legacy | wait | damaged | 11 | legacy-wait-damaged | yes | -| legacy | wait | missing | 11 | legacy-wait-missing | yes | -| legacy | stream | damaged | 11 | legacy-stream-damaged | yes | -| legacy | stream | missing | 11 | legacy-stream-missing | yes | -| legacy | create-async | damaged | 11 | legacy-create-async-damaged | yes | -| legacy | create-async | missing | 11 | legacy-create-async-missing | yes | -| legacy | create-sync | damaged | 11 | legacy-create-sync-damaged | yes | -| legacy | create-sync | missing | 11 | legacy-create-sync-missing | yes | -| v1 | get | damaged | 11 | v1-get-damaged | yes | -| v1 | get | missing | 11 | v1-get-missing | yes | -| v1 | wait | damaged | 11 | v1-wait-damaged | yes | -| v1 | wait | missing | 11 | v1-wait-missing | yes | -| v1 | stream | damaged | 11 | v1-stream-damaged | yes | -| v1 | stream | missing | 11 | v1-stream-missing | yes | -| v1 | create-async | damaged | 11 | v1-create-async-damaged | yes | -| v1 | create-async | missing | 11 | v1-create-async-missing | yes | -| v1 | create-sync | damaged | 11 | v1-create-sync-damaged | yes | -| v1 | create-sync | missing | 11 | v1-create-sync-missing | yes | - -`material-matrix-check.mjs` 16/16;`round6-material-check.mjs` 10/10(无候选通道不提供证据、相同多候选集、source_name/source_stem + 无通道、map key 大小写)。 - -### 4.3 tarball 安装 smoke(`e567646`,`meshy-cli-0.3.0.tgz`;`` 为临时 npm prefix) - -`/prefix/bin/meshy resources --output-schema v1` → exit 0 +OAuth refresh 观测(T-104): ```json { - "schema_version": "meshy.cli/v1", - "command": "resources", - "ok": true, - "result": { - "items": [ - { - "name": "text-to-3d", - "kind": "task", - "command": "meshy text-to-3d", - "summary": "two-stage 3D generation from text (preview → refine)", - "endpoint": "/openapi/v2/text-to-3d", - "verbs": [ - "create", - "get", - "list", - "wait", - "stream", - "delete" - ] - }, - { - "name": "image-to-3d", - "kind": "task", - "command": "meshy image-to-3d", - "summary": "3D from a single image (standard or smart-topology low-poly)", - "endpoint": "/openapi/v1/image-to-3d", - "verbs": [ - "create", - "get", - "list", - "wait", - "stream", - "delete" - ] - }, - { - "name": "multi-image-to-3d", - "kind": "task", - "command": "meshy multi-image-to-3d", - "summary": "3D from multiple views (beta; prefer image-to-3d)", - "endpoint": "/openapi/v1/multi-image-to-3d", - "verbs": [ - "create", - "get", - "list", - "wait", - -… (truncated) + "original_expires_at_iso": "2026-09-08T05:52:10.945000+00:00", + "expires_at_now_iso": "2026-09-08T06:51:13.984000+00:00", + "refreshed": true, + "login_id_present": true, + "login_id_sha": "ee55f2134c19", + "created_at_unchanged": true, + "balance_after": 2515 +} ``` -`/prefix/bin/meshy doctor --output-schema v1` → exit 0 +真实 refine 的材质重链接(T-110): ```json { - "schema_version": "meshy.cli/v1", - "command": "doctor", - "ok": true, - "result": { - "cli": { - "version": "0.3.0", - "node": "v24.20.0", - "platform": "darwin", - "arch": "arm64" + "result.downloads.material_links.status": "complete", + "result.downloads.material_links.texture_maps": [ + { + "line": 12, + "material": "Material.005", + "reference": "texture_0.png", + "resolved_to": "texture_0_base_color.png", + "method": "source_name" + } + ], + "files": [ + { + "key": "model_glb", + "bytes": 83900100, + "status": "written", + "sha256_12": "51d4b36507bc" }, - "local_ready": true, - "api_ready": null, - "checks": [ - { - "id": "cli", - "status": "ok", - "detail": "meshy-cli 0.3.0 on node v24.20.0 (darwin arm64)" - }, - { - "id": "node", - "status": "ok", - "detail": "node v24.20.0 satisfies the required >=24" - }, - { - "id": "base_urls", - "status": "ok", - "detail": "v1 https://api.meshy.ai/openapi/v1; v2 https://api.meshy.ai/openapi/v2; creative-lab https://api.meshy.ai/openapi/creative-lab; public-web https://api.meshy.ai/web/public" - }, - { - "id": "api_key_file", - "status": "skipped", - "detail": "--api-key-file not given" - }, - { - "id": "credentials", - "status": "warn", - "detail": "sources present: --api-key=no, MESHY_API_KEY=no, --api-key-file=none, stored profile=absent (/config/credentials.json); values are never read by doctor. API commands need --api-key, MESHY_API_KEY, --api-key-file or `meshy auth login`" - }, - { - "id": "workspace", - "status": "skipped", - "detail": "--workspace not given (files land next to their targets)" - }, - { - "id": "cwd_env_files", - "status": "ok", - "detail": "no .env or .env.local in /pri -… (truncated) -``` - -`/prefix/bin/meshy project record --project /work/meshy_output/_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1` → exit 0 - -```json -{ - "schema_version": "meshy.cli/v1", - "command": "project.record", - "ok": true, - "result": { - "project_dir": "/work/meshy_output/_smoke-demo_fixture-", - "action": "added", - "entry": { - "task_id": "fixture-rig-1", - "task_type": "rigging", - "resource": "rigging", - "endpoint": null, - "stage": "rigged", - "parent_task_id": null, - "status": null, - "files": [ - "rigged.glb" - ], - "task_json": null, - "operation_id": null, - "created_at": "2026-09-08T03:45:25.184Z", - "updated_at": "2026-09-08T03:45:25.184Z" + { + "key": "model_fbx", + "bytes": 99468604, + "status": "written", + "sha256_12": "915454648561" }, - "task_count": 1, - "index": { - "updated": true, - "error": null + { + "key": "model_obj", + "bytes": 195271847, + "status": "written", + "sha256_12": "d1e6f5bb707e" }, - "migrated_from_legacy": false - }, - "error": null, - "warnings": [ { - "code": "recorded_file_missing", - "message": "recorded file(s) not present in the project yet: rigged.glb" + "key": "model_mtl", + "bytes": 239, + "status": "written", + "sha256_12": "c9c73e2a3ec9" + }, + { + "key": "thumbnail", + "bytes": 61083, + "status": "written", + "sha256_12": "e80a33787756" + }, + { + "key": "texture_0_base_color", + "bytes": 19421816, + "status": "written", + "sha256_12": "5e0411a618eb" + }, + { + "key": "texture_0_metallic", + "bytes": 19397, + "status": "written", + "sha256_12": "4fd4696a84f8" + }, + { + "key": "texture_0_normal", + "bytes": 11884487, + "status": "written", + "sha256_12": "964426c0fc2e" + }, + { + "key": "texture_0_roughness", + "bytes": 967530, + "status": "written", + "sha256_12": "60836dd75819" } ] } ``` -`/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1` → exit 0 +## 5. 实际验证记录(绑定 `1d9f10976b5f754502c81591c64c712e492188d1`) -```json -{ - "schema_version": "meshy.cli/v1", - "command": "animation-catalog.list", - "ok": true, - "result": { - "items": [ - { - "action_id": 28, - "name": "Big Wave Hello", - "key": "Big_Wave_Hello", - "category": "DailyActions", - "sub_category": "Interacting", - "preview_url": "https://cdn.meshy.ai/webapp-assets/feature-demo/animation/preview/biped/Big_Wave_Hello.gif", - "rig_type": "style_02", - "is_default": false, - "is_free": false - }, - { - "action_id": 290, - "name": "Wave One Hand", - "key": "Wave_One_Hand", - "category": "DailyActions", - "sub_category": "Interacting", - "preview_url": "https://cdn.meshy.ai/webapp-assets/feature-demo/animation/preview/biped/Wave_One_Hand.gif", - "rig_type": "style_02", - "is_default": false, - "is_free": false - }, - { - -… (truncated) -``` +| 检查 | 结果 | +| --- | --- | +| `pnpm install --frozen-lockfile` / `pnpm typecheck` / `pnpm build` | exit 0 / 0 / 0 | +| `pnpm test` | **558/558**(45 s) | +| `git diff --check` fd94490 / 4da216d / 工作树 | 0 / 0 / 0 | +| poll ×12 / round1 ×8 / round2–6 ×3 | 12/12 / 8/8 / 3/3 | +| 第 7 轮 reviewer 脚本副本(15 个) | round1–6 探针复现 0/0/0/0/0/0;verify 20/20、8/8、24/24、5/5;矩阵 16/16、10/10、20/20;context 5/5;stream passed | +| tarball smoke(macOS) | **29/29**,`meshy-cli-0.3.0.tgz` sha256 `0d22647fd0568a7c6b44aba80ceb32a5de74781b6697d44986745ee78ddf9c8e`,398 files | -## 5. 实际验证记录 - -全部绑定代码 HEAD `e567646d875e5f5a658b78e60b8cdfaed8b233e9`(Node v24.20.0,pnpm 11.24.0);机器记录见 `docs/skill-parity/verification.json`。 - -| 检查 | 结果 | 说明 | -| --- | --- | --- | -| `pnpm install --frozen-lockfile` | exit 0 | | -| `pnpm typecheck` | exit 0 | src + tests | -| `pnpm build` | exit 0 | | -| `pnpm test` | **555 pass / 0 fail / 0 skip**(555 项,45 s) | 含 3 项 round-6 新回归 | -| `dist/index.js --version` = package.json | exit 0 | | -| `git diff --check fd94490` / `166492b` / 工作树 | exit 0 / 0 / 0 | 完整基线、本轮 diff、工作树 | -| `poll.test.ts` × 12 | 12/12 | | -| `codex-review-round1.test.ts` × 8 | 8/8 | | -| round2–6 套件 × 3 | 3/3 | 含 SIGINT/竞争/两个 20 组矩阵 | -| `round6-probes.mjs` 副本 | F01 ×2、F02 ×2 不再复现(exit 11,外部字节不变) | 正向验收在仓库测试 | -| `round5-probes.mjs` 副本 | 0/4 复现 | | -| `round4-probes.mjs` 副本 | 0/3 复现;D03 signal_sent=true exit 130 | | -| `round3-probes.mjs` 副本 | 0/6 复现(exit 1) | fs.watch C03 signal_sent=true, exit 130(触发成功) | -| `round2-probes.mjs` 副本 | 0/8 复现(exit 1) | | -| `round1-probes.mjs` 副本 | 0/12 复现(exit 1) | | -| `verify-original-regressions.py` | **20/20** | | -| `verify-round3-4-regressions.py` | **8/8** | C03 以 D03 补证 | -| `verify-round5-positive.py` | **24/24** | E01–E03 + 20 入口 | -| `material-matrix-check.mjs` / `round6-material-check.mjs` | **16/16** / **10/10** | | -| `project-entry-matrix-check.mjs` | **20/20** | | -| `round4-context-checks.mjs` 副本 | **5/5** | | -| `stream-finalization-check.mjs` 副本 | passed | | -| tarball smoke | **29/29** | `meshy-cli-0.3.0.tgz` sha256 `bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98`,398 files,bins ['meshy', 'meshy-cli'] | - -## 6. 真实环境验证 - -- 已执行:真实公开动画目录 GET(免费、无鉴权;`/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1` → exit 0;public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157);macOS arm64 tarball 安装 + 29 项本地命令 smoke。 -- **not_run**(保持):T-104 真实 API Key/OAuth 登录、重新登录、真实 token 端点 `user_id`;T-109 Windows x64 / Linux x64;T-110 鉴权 get / 已有任务资产下载;T-111 UV Unwrap / Creative Lab / showcases(付费或权限门控);T-112 真实切片器 GUI;正式发布(未授权、未执行)。 -- 不把离线通过写成 G1-release passed;不进入 S2。 - -## 7. 打包证据 - -- `npm pack` → `meshy-cli-0.3.0.tgz`,sha256 `bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98`,398 个文件;必需文件齐全 = true;禁止内容不存在 = true。 -- `npm install -g --prefix ` → bins ['meshy', 'meshy-cli'];无 Python 依赖。 -- 未 `npm publish`,未推送。 - -## 8. 需要 Codex 复审的事项 - -1. R6-F02 的冻结语义:根身份 = 冻结时最深已存在祖先的 (dev, ino)(workspace 尚不存在时为其祖先);`assertRootIntact` 要求该路径仍是同一目录且非符号链接。请确认"稳定 `/var` 别名 / 父目录别名 / 指向不变的 workspace 符号链接均通过,目录被换或别名被重指向均拒绝"符合预期。 -2. 无 workspace 时项目目录本身作为冻结根(get/wait/stream 在首次请求前、create 在预检中、download 在预检后);未初始化的项目**不**在请求前拒绝(保持 R2-F03 的"请求后单 outcome"语义与既有 project-store 测试)。 -3. R6-F01 的边界失败形状:`project.action="failed"` + `error` + `recovery: null`,`error.recovery` 为 null,hint 不是 record 命令;与 metadata 缺失/损坏的 `record_project` 分支明确区分。 -4. 任务动词的边界失败文案由 "inside the workspace" 改为 "inside the authorised boundary"(round-5 测试断言随之更新);hint 回落为只读 `wait`。 -5. 旧 `round3-probes.mjs` 的 fs.watch C03 本轮副本 signal_sent=true(触发);稳定证据仍为 D03、context 的 standalone-relink-interrupt 与仓库 C03。 - -## 9. 最短复现步骤 - -```bash -cd /Users/ark/Dev/meshy-cli && git checkout e567646 -export FNM_DIR="$HOME/.local/share/fnm"; eval "$(fnm env --shell bash)"; fnm use 24 -pnpm install --frozen-lockfile && pnpm typecheck && pnpm test # 555/555 -node --import tsx --test tests/codex-review-round6.test.ts # F01 ×4 + control, F02 ×20, stable alias -mkdir -p /tmp/r6 && cp /Users/ark/Dev/meshy-agent-integrations-research/reviews/cli-s1-7b7c24c/round6-probes.mjs /tmp/r6/ -node /tmp/r6/round6-probes.mjs /Users/ark/Dev/meshy-cli # F01/F02 reproduced=false, exit 11, external bytes unchanged -git diff --check fd94490 && git diff --check 166492b -``` +## 6. 未修复的观察(供复审判断) + +- **OBS-1**(P3):403 'enterprise only' from showcases maps to error.code server / exit 1; a dedicated permission code (or auth) may be clearer +- **OBS-2**(P3):downloaded asset files are published mode 0600 while rewritten MTL and JSON sidecars are 0644 +- **OBS-3**(P3):task verbs' -o downloads are not recorded as files in the project entry (recorded only by meshy download --project); attachToProject.extra.files has no caller +- **OBS-4**(P3):download --list on a FAILED task reports downloads.state not_ready with ok:true (a terminal failure reads as 'not yet') +- **OBS-5**(P3):auth status/list/use ignore --output-schema v1 (legacy shape only) +- **OBS-6**(P3):real task JSON carries no face_count, so inspect faces from a task JSON always ends in check_unknown (13) — by design, but worth stating in docs +- **OBS-7**(P3):text-to-motion requires --duration client-side (2–10 s, 0.5 steps); confirm against the API default + +## 7. 尚未完成 / not_run + +- T-109 Windows x64:无主机。 +- `MESHY_API_KEY` 环境变量 / `--api-key-file` 对真实 API 的路径:仅离线测试覆盖(live 使用了存储的 API key profile)。 +- showcases 内容:账号非 enterprise(403 已记录)。 +- `auth logout/revoke`:留给所有者。 +- 正式发布:未授权、未执行。 -## 10. 交给 Codex 的复审提示词 +## 8. 交给 Codex 的复审提示词 -> 请对 `/Users/ark/Dev/meshy-cli` 分支 `feat/skill-parity-s1` 做 Meshy CLI S1 第 7 轮 review。规范:2026-09-07 v1 实施包。代码 HEAD `e567646d875e5f5a658b78e60b8cdfaed8b233e9`(= 修复 `e567646d875e5f5a658b78e60b8cdfaed8b233e9`);docs HEAD 为其后的 docs-only commit(`git log -1`)。上一轮(`…/reviews/cli-s1-7b7c24c/`)结论 changes_requested:R6-F01、R6-F02(P1)与 R6-T01(P3)。请核对:(1) `src/internal/paths.ts` 的 `AuthorisedRoot`/`freezeRoot`/`assertRootIntact` 与 `resolveWithinRoot` 冻结根分支是否在首次 I/O 前建立、且所有写入/恢复路径(`--save-json`、`-o` 下载与 sidecar、`make`、`mesh prepare-print`、`project`、任务动词与 `download` 的项目记账)都不再重新解析可变根;(2) `tests/codex-review-round6.test.ts` 是否按 acceptance 做了正向断言(task-json/API × 叶子/父目录;legacy/v1 × 5 动词 × 目录被换/别名重指向;exit 11、task/submission/operation_id、单 POST、精确 method/path、外部与原树整树摘要、无 snapshot/lock/temp、recovery null、无跨界命令;稳定别名与健康对照);(3) `tests/codex-review-round5.test.ts` E03 20 组精确序列与 E02 按行候选(R6-T01);(4) 前六轮 32 项 finding 是否保持通过(副本重跑 round1–6 探针、三个 verify 脚本、三个矩阵脚本、`round4-context-checks.mjs`、`stream-finalization-check.mjs`;旧 fs.watch C03 未触发时不得计为通过)。请勿修改 reviewer 证据目录;禁止付费调用、发布、Skill/MCP 迁移。真实账号、Windows/Linux、真实切片器、UV/Creative Lab/showcases 仍为 not_run;G1-release 与 S2 不在本轮范围。 +> 请对 `/Users/ark/Dev/meshy-cli` 分支 `feat/skill-parity-s1` 做 Meshy CLI S1 第 8 轮 review:复审 live verification 发现的两个修复。代码 HEAD `1d9f10976b5f754502c81591c64c712e492188d1`(上一轮 accepted 代码 `e567646`);docs HEAD 为其后的 docs-only commit。请核对:(1) `src/client/types.ts` 的 null 容忍是否只影响 6 个"未发生"字段、v1 视图输出不变,`tests/live-verification.test.ts` L01 是否用真实捕获体(fixture)覆盖 schema 与 get/wait 轮询;(2) `src/internal/download.ts` + `artifacts.ts` 的命名共享是否保持 legacy slot key、relink 规则与既有测试(round1 R07、N04、C05、D01、E02)不变,L02 是否覆盖 lamp 与 keychain bundle;(3) `docs/skill-parity/live-verification.json` 与 verification.json/capability-matrix.json 的 live 结论是否与证据一致且无凭据/签名 URL 泄露;(4) 前七轮 32 项 finding 在 `1d9f109` 上仍保持通过(副本重跑记录见 §5)。禁止付费调用、发布、Skill/MCP 迁移;Windows 与发布仍为 not_run。 diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index bf8afa8..5e0a821 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -2,7 +2,7 @@ "schema_version": 1, "title": "Meshy CLI S1 capability matrix — implementation tracking", "date": "2026-09-08", - "status": "review round 6 (Codex, reviewed 166492b/7b7c24c): changes_requested → R6-F01, R6-F02 fixed and R6-T01 covered in e567646; rounds 1–5 (29 findings) re-verified; G1-code re-review pending; G1-release not_run", + "status": "G1-code accepted (Codex round 7, e567646); live verification on the owner's real account done 2026-09-08 — every mandatory capability exercised live except Windows install (not_run) and enterprise showcases content (account-gated 403 recorded); two live defects fixed in 1d9f109 (L01/L02) awaiting Codex review; release not performed → G1-release not yet satisfied", "baseline": { "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" @@ -35,7 +35,7 @@ "tests/codex-review-round6.test.ts (create --async / sync create × workspace swapped / alias re-pointed → boundary refusal, single POST, journal kept)" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F01" @@ -75,6 +75,18 @@ "tests": [ "tests/codex-review-round6.test.ts (create --async / sync create × workspace swapped / alias re-pointed → boundary refusal, single POST, journal kept)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-02", + "T110-06", + "T110-13", + "T111-09", + "T111-13" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -103,7 +115,7 @@ "tests/codex-review-round6.test.ts (get; stable-alias control records normally)" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F01", @@ -159,6 +171,16 @@ "tests": [ "tests/codex-review-round6.test.ts (get; stable-alias control records normally)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-04", + "T110-12", + "T104-08" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -189,7 +211,7 @@ "tests/codex-review-round6.test.ts (wait)" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F01", @@ -245,6 +267,20 @@ "tests": [ "tests/codex-review-round6.test.ts (wait)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-04", + "T110-08", + "T111-03", + "T111-04", + "T111-05", + "T111-11", + "T111-12" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -276,7 +312,7 @@ "tests/codex-review-round6.test.ts (stream)" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F10" @@ -329,6 +365,16 @@ "tests": [ "tests/codex-review-round6.test.ts (stream)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-03", + "T110-07", + "T111-12" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -355,7 +401,17 @@ "intentional_differences": [ "rigging list enabled (0.2.0 marked it unsupported; official docs and server routes provide it)" ], - "external_verification": "offline_primary" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T104-01", + "T110-14", + "T110-15" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-006", @@ -379,7 +435,7 @@ "tests/codex-review-round1.test.ts R04/F08" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F08" @@ -389,6 +445,17 @@ "tests": [ "tests/codex-review-round1.test.ts R04/F08" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-06", + "T111-04", + "T111-05", + "T111-08" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -414,7 +481,7 @@ "tests/operation-store.test.ts F06" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F06" @@ -425,6 +492,16 @@ "tests/codex-review-round1.test.ts R02/F06", "tests/operation-store.test.ts F06" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-11", + "T110-13", + "T104-07" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -449,7 +526,15 @@ "src/cmd/uv-unwrap.ts: exclusive source, GLB-only, 404 reported as not_found; tests T-021" ], "intentional_differences": [], - "external_verification": "required_when_applicable_not_run" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-03" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-009", @@ -474,7 +559,15 @@ "src/cmd/creative-lab.ts figure prototype/build; tests T-022..T-025" ], "intentional_differences": [], - "external_verification": "required_when_applicable_not_run" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-21" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-010", @@ -503,7 +596,7 @@ "intentional_differences": [ "deprecated lamp `text` prototype input is rejected before submission; official lamp page documents image_url" ], - "external_verification": "required_when_applicable_not_run", + "external_verification": "passed_after_live_fixes", "review_round_1": { "findings": [ "F08" @@ -513,6 +606,17 @@ "tests": [ "tests/codex-review-round1.test.ts R04/F08 (lamp)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed_after_live_fixes", + "evidence": [ + "T111-14", + "T111-15", + "T111-17", + "T111-20" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -540,7 +644,7 @@ "tests/codex-review-round1.test.ts R04/F08 (keychain)" ], "intentional_differences": [], - "external_verification": "required_when_applicable_not_run", + "external_verification": "passed_after_live_fixes", "review_round_1": { "findings": [ "F08" @@ -550,6 +654,16 @@ "tests": [ "tests/codex-review-round1.test.ts R04/F08 (keychain)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed_after_live_fixes", + "evidence": [ + "T111-16", + "T111-19", + "T111-20" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -577,7 +691,7 @@ "tests/codex-review-round1.test.ts R04/F08 (relief options shared with keychain)" ], "intentional_differences": [], - "external_verification": "required_when_applicable_not_run", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F08" @@ -587,6 +701,14 @@ "tests": [ "tests/codex-review-round1.test.ts R04/F08 (relief options shared with keychain)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-22" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -610,7 +732,15 @@ "src/cmd/animation-catalog.ts: public transport, no credential, local search; tests T-026" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-02" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-014", @@ -636,7 +766,15 @@ "intentional_differences": [ "showcase_type accepts docs spelling \"animated\" and sends the server enum \"animate\" with a warning (decisions D-007)" ], - "external_verification": "required_when_applicable_not_run" + "external_verification": "live_account_gated_403", + "live_verification": { + "date": "2026-09-08", + "status": "account_gated_403_recorded", + "evidence": [ + "T111-01" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-015", @@ -664,7 +802,7 @@ "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F09" @@ -714,6 +852,16 @@ "tests": [ "tests/codex-review-round5.test.ts E02 + 8x2 arbitration matrix" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-05", + "T111-18", + "T111-19" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -744,7 +892,7 @@ "intentional_differences": [ "download requires an explicit selector when a task exposes several assets (legacy defaulted to glb); legacy -o keeps downloading everything" ], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F09" @@ -804,6 +952,16 @@ "tests": [ "tests/codex-review-round6.test.ts (download × task-json/API × leaf/parent symlink → exit 11, manifest kept, project.failed, outside tree byte-identical)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-09", + "T110-10", + "T111-18" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -837,7 +995,7 @@ "tests/codex-review-round6.test.ts (frozen boundary for downloads and project records); round5 E03 exact request sequences (R6-T01)" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F03", @@ -907,6 +1065,16 @@ "tests": [ "tests/codex-review-round6.test.ts (frozen boundary for downloads and project records); round5 E03 exact request sequences (R6-T01)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-08", + "T110-09", + "T111-20" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -931,7 +1099,7 @@ "tests/codex-review-round2.test.ts N05 (save-json conflict after the stream → outcome)" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F01" @@ -951,6 +1119,16 @@ "tests": [ "tests/codex-review-round2.test.ts N05 (save-json conflict after the stream → outcome)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-02", + "T104-08", + "LOCAL-01" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -974,7 +1152,7 @@ "tests/codex-review-round1.test.ts R08/F03" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F03" @@ -984,6 +1162,14 @@ "tests": [ "tests/codex-review-round1.test.ts R08/F03" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-01" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -1013,7 +1199,7 @@ "tests/codex-review-round6.test.ts (project record refused across the boundary at every entry; recovery null; no index_dirty disguise)" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F03" @@ -1075,6 +1261,15 @@ "tests": [ "tests/codex-review-round6.test.ts (project record refused across the boundary at every entry; recovery null; no index_dirty disguise)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T110-04", + "LOCAL-01" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -1101,7 +1296,7 @@ "tests/codex-review-round2.test.ts N02 (index skipped with index.updated=false + reason)" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F03" @@ -1121,6 +1316,14 @@ "tests": [ "tests/codex-review-round2.test.ts N02 (index skipped with index.updated=false + reason)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "LOCAL-01" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -1147,7 +1350,15 @@ "intentional_differences": [ "missing face_count is unknown (exit 13), never 0 (legacy check-faces passed unmeasured models); --max-faces has no default" ], - "external_verification": "offline_primary" + "external_verification": "passed_check_unknown_on_real_tasks", + "live_verification": { + "date": "2026-09-08", + "status": "passed_check_unknown_on_real_tasks", + "evidence": [ + "LOCAL-02" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-023", @@ -1176,7 +1387,7 @@ "intentional_differences": [ "default output is .print.obj, in-place only with --in-place; NaN/Infinity/degenerate input is a validation error instead of silent scale 1.0 output" ], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F04" @@ -1187,6 +1398,14 @@ "tests/codex-review-round1.test.ts R12/F04", "tests/obj-transform.test.ts T-086 (planned paths reported)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T112-02" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -1209,7 +1428,15 @@ "src/internal/slicers.ts detectSlicers (darwin/win32/linux rules, seven registered, multicolor flag) + src/cmd/slicer.ts detect; tests/slicers.test.ts T-088" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T112-01" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-025", @@ -1235,7 +1462,16 @@ "intentional_differences": [ "no default-application fallback (legacy used os.startfile / xdg-open); Windows launches the detected absolute exe, not a PATH lookup" ], - "external_verification": "required_when_applicable_not_run" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T112-01", + "T112-02" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-026", @@ -1257,7 +1493,15 @@ "src/internal/doctor.ts runDoctorDetailed + src/cmd/doctor.ts (local default; --check-api one GET /balance; --check-slicers); tests/doctor.test.ts T-102/T-103" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "smoke doctor checks (macOS + Linux)" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-027", @@ -1282,7 +1526,7 @@ "intentional_differences": [ "flag is --api-key-file, not --env-file: Node.js intercepts --env-file anywhere in argv (decisions D-025)" ], - "external_verification": "offline_primary", + "external_verification": "passed_stored_profiles", "review_round_1": { "findings": [ "F05" @@ -1292,6 +1536,15 @@ "tests": [ "tests/codex-review-round1.test.ts R01/F05 (API key digest)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed_stored_profiles", + "evidence": [ + "T104-01", + "T104-02" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -1317,7 +1570,7 @@ "tests/auth-headless.test.ts (device login mints login_id)" ], "intentional_differences": [], - "external_verification": "required_when_applicable_not_run", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F05" @@ -1338,6 +1591,17 @@ "tests/codex-review-round2.test.ts N07 (login_id binding, refresh keeps it, unverified refused)", "tests/auth-headless.test.ts (device login mints login_id)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T104-01", + "T104-03", + "T104-04", + "T104-08" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -1366,7 +1630,7 @@ "tests/codex-review-round3.test.ts C06 (legacy error payload additive task_id/operation_id), C03 (exit 130 after relink interrupt)" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_2": { "findings": [ "R2-F03", @@ -1388,6 +1652,17 @@ "tests": [ "tests/codex-review-round3.test.ts C06 (legacy error payload additive task_id/operation_id), C03 (exit 130 after relink interrupt)" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-01", + "T110-11", + "T110-15", + "T104-08" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -1414,7 +1689,7 @@ "tests/codex-review-round4.test.ts R4-T01 make identity (legacy/v1 × 503/SIGINT) reconciled with the journal" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F01", @@ -1458,6 +1733,14 @@ "tests": [ "tests/codex-review-round4.test.ts R4-T01 make identity (legacy/v1 × 503/SIGINT) reconciled with the journal" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T111-13" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -1486,7 +1769,7 @@ "tests/codex-review-round2.test.ts N07, credentialBinding unit test" ], "intentional_differences": [], - "external_verification": "offline_primary", + "external_verification": "live_passed", "review_round_1": { "findings": [ "F05", @@ -1508,6 +1791,16 @@ "tests": [ "tests/codex-review-round2.test.ts N07, credentialBinding unit test" ] + }, + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T104-05", + "T104-06", + "T104-07" + ], + "record": "docs/skill-parity/live-verification.json" } }, { @@ -1532,7 +1825,16 @@ "transport.ts path validation, redirect refusal, public transport; config.ts origin policy; tests transport.test.ts" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T104-08", + "T111-02" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-033", @@ -1555,7 +1857,17 @@ "runtime.ts buildLocalRuntime; index.ts update-check policy; tests T-102, T-012" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "T112-01", + "LOCAL-01", + "LOCAL-02" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-034", @@ -1578,7 +1890,16 @@ "npm pack → meshy-cli-0.3.0.tgz sha256 8e4e4d86faf18d5b5539264fdac5377059d033e07e4defc902e872f470e66367; installed into a temp prefix; both bins run; no Python; see verification.json package_smoke" ], "intentional_differences": [], - "external_verification": "required_when_applicable_not_run" + "external_verification": "live_partial_windows_not_run", + "live_verification": { + "date": "2026-09-08", + "status": "partial_windows_not_run", + "evidence": [ + "T-109 linux arm64/x64 29/29", + "macOS 29/29" + ], + "record": "docs/skill-parity/live-verification.json" + } }, { "id": "CAP-035", @@ -1600,7 +1921,15 @@ "resources index generated from resource-registry.ts (kind task|query|local, verbs, endpoints); root/verb help updated; skills/meshy-cli/SKILL.md rewritten; tests/resource-registry.test.ts, smoke resources_v1" ], "intentional_differences": [], - "external_verification": "offline_primary" + "external_verification": "live_passed", + "live_verification": { + "date": "2026-09-08", + "status": "passed", + "evidence": [ + "smoke help/resources checks" + ], + "record": "docs/skill-parity/live-verification.json" + } } ], "knowledge_only": [ @@ -1622,8 +1951,8 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-08T03:47:45.510877Z", - "code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "updated_at": "2026-09-08T06:09:04.640614Z", + "code_head": "1d9f10976b5f754502c81591c64c712e492188d1" }, "review_rounds": [ { @@ -1734,5 +2063,91 @@ "regression_tests": "tests/codex-review-round6.test.ts (R6-F01 4 download scenarios + control; R6-F02 20-entry mutable-workspace matrix + stable-alias control); codex-review-round5 E03 exact sequences and E02 line-bound candidates (R6-T01)", "probe_rerun": "reviews/cli-s1-7b7c24c copies on e567646: round6 0/4 reproduce (all exit 11, outside bytes unchanged); round5 0/4; round4 0/3 (D03 positive); round3 0/6; round2 0/8; round1 0/12; verify-original 20/20; verify-round3-4 8/8; verify-round5-positive 24/24; material matrix 16/16; round6 material 10/10; project entries 20/20; context 5/5; stream passed" } - ] + ], + "live_verification_run": { + "date": "2026-09-08", + "code_head_under_test": { + "before_live_fixes": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "live_fixes": "1d9f10976b5f754502c81591c64c712e492188d1", + "final": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "credits": { + "start": 2806, + "end": 2479, + "after_oauth_refresh_check": 2515, + "source": "meshy balance --output-schema v1 before the first live step (auth status shown by the owner) and after the last one (live/balance-final.json)", + "consumed": 327 + }, + "record": "docs/skill-parity/live-verification.json", + "fixes": [ + { + "id": "L01", + "decision": "D-059", + "commit": "1d9f10976b5f754502c81591c64c712e492188d1", + "test": "tests/live-verification.test.ts", + "summary": "Creative Lab in-progress bodies carry finished_at: null; schema now reads null timestamps/counts as 0; verified live afterwards" + }, + { + "id": "L02", + "decision": "D-060", + "commit": "1d9f10976b5f754502c81591c64c712e492188d1", + "test": "tests/live-verification.test.ts", + "summary": "legacy -o names Creative Lab parts/bundles via the shared modelAsset mapping (lamp.stl, base.stl, model.obj.zip); verified live afterwards" + } + ], + "observations_not_fixed": [ + { + "id": "OBS-1", + "severity": "P3", + "text": "403 'enterprise only' from showcases maps to error.code server / exit 1; a dedicated permission code (or auth) may be clearer" + }, + { + "id": "OBS-2", + "severity": "P3", + "text": "downloaded asset files are published mode 0600 while rewritten MTL and JSON sidecars are 0644" + }, + { + "id": "OBS-3", + "severity": "P3", + "text": "task verbs' -o downloads are not recorded as files in the project entry (recorded only by meshy download --project); attachToProject.extra.files has no caller" + }, + { + "id": "OBS-4", + "severity": "P3", + "text": "download --list on a FAILED task reports downloads.state not_ready with ok:true (a terminal failure reads as 'not yet')" + }, + { + "id": "OBS-5", + "severity": "P3", + "text": "auth status/list/use ignore --output-schema v1 (legacy shape only)" + }, + { + "id": "OBS-6", + "severity": "P3", + "text": "real task JSON carries no face_count, so inspect faces from a task JSON always ends in check_unknown (13) — by design, but worth stating in docs" + }, + { + "id": "OBS-7", + "severity": "P3", + "text": "text-to-motion requires --duration client-side (2–10 s, 0.5 steps); confirm against the API default" + } + ], + "reviewer_script_reruns_on_this_head": { + "round1": 0, + "round2": 0, + "round3": 0, + "round4": 0, + "round5": 0, + "round6": 0, + "verify_original": "20/20", + "verify_round3_4": "8/8", + "verify_round5": "24/24", + "verify_round6": "5/5", + "material_matrix": "16/16", + "round6_material": "10/10", + "project_entries": "20/20", + "context": "5/5", + "stream": true + } + } } diff --git a/docs/skill-parity/live-verification.json b/docs/skill-parity/live-verification.json new file mode 100644 index 0000000..126cb66 --- /dev/null +++ b/docs/skill-parity/live-verification.json @@ -0,0 +1,1263 @@ +{ + "schema_version": 1, + "recorded_at": "2026-09-08T06:09:04.615660Z", + "code_head_under_test": { + "before_live_fixes": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "live_fixes": "1d9f10976b5f754502c81591c64c712e492188d1", + "final": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "environment": { + "host": "macOS 26.6.2 (Darwin 25.6.0) arm64", + "node": "v24.20.0", + "install": "npm install -g ~/Downloads/meshy-cli-0.3.0.tgz (real global install; reinstalled after each live fix)", + "config": "~/.config/meshy/credentials.json (default location; profiles: default = API key, oauth = OAuth)", + "workspace": "/meshy-live (real user directory)" + }, + "scope": "real account, real API, billable tasks authorised by the account owner without a cap; credentials entered by the owner in their own terminal and never handled by the agent; no publish", + "credits": { + "start": 2806, + "end": 2479, + "after_oauth_refresh_check": 2515, + "source": "meshy balance --output-schema v1 before the first live step (auth status shown by the owner) and after the last one (live/balance-final.json)", + "consumed": 327 + }, + "steps": [ + { + "id": "T104-01", + "credential": "oauth", + "title": "auth status (OAuth active) — masked credential, verified balance", + "file": "live/t104/status-oauth.json", + "authenticated": true, + "profile": "oauth", + "profiles": [ + "default", + "oauth" + ], + "active_profile": "oauth", + "verified": true, + "balance.balance": 2806 + }, + { + "id": "T104-02", + "credential": "oauth", + "title": "auth list — two profiles (api_key default, oauth)", + "file": "live/t104/list.json", + "active_profile": "oauth", + "profiles": [ + { + "name": "default", + "kind": "api_key", + "active": false + }, + { + "name": "oauth", + "kind": "oauth", + "active": true + } + ] + }, + { + "id": "T104-03", + "credential": "oauth", + "title": "credentials.json shape: oauth profile has access/refresh tokens and login_id, no user_id (the real token endpoint did not return one)", + "file": "inspected with jq keys only", + "oauth_profile_keys": [ + "access_token", + "created_at", + "expires_at", + "kind", + "login_id", + "refresh_token" + ], + "has_user_id": false, + "has_login_id": true, + "api_key_profile_keys": [ + "api_key", + "created_at", + "kind" + ] + }, + { + "id": "T104-04", + "credential": "oauth", + "title": "silent refresh observed: expires_at advanced, login_id kept, tokens rotated; refreshed token works", + "file": "live/t104/oauth-refresh-observed.json", + "original_expires_at_iso": "2026-09-08T05:52:10.945000+00:00", + "expires_at_now_iso": "2026-09-08T06:51:13.984000+00:00", + "refreshed": true, + "login_id_present": true, + "login_id_sha": "ee55f2134c19", + "created_at_unchanged": true, + "observed_at": "2026-09-08T05:52:56.108115+00:00", + "balance_after": 2515 + }, + { + "id": "T104-05", + "credential": "api_key", + "title": "journal replay with the same key: operation_replayed, no new task, balance unchanged", + "file": "live/t110/09-replay-same-key.json", + "ok": true, + "result.task_id": "01a07f5e-81c7-7111-86f2-f6d2f80b2093", + "result.submission.operation_id": "2a9a9e91-da6f-4d14-a560-d1b16608fba2", + "warnings": [ + "operation_replayed" + ] + }, + { + "id": "T104-06", + "credential": "oauth", + "title": "same operation id under the OAuth profile → operation_conflict (credential), exit 2, nothing submitted", + "file": "live/t110/10-replay-other-credential.json", + "ok": false, + "error.code": "operation_conflict", + "result.conflict": [ + "credential" + ] + }, + { + "id": "T104-07", + "credential": "api_key", + "title": "same operation id with a different image → operation_conflict (payload); same bytes under another file name → replayed", + "file": "live/t110/19-image-replay-other-image.json", + "ok": false, + "error.code": "operation_conflict", + "result.conflict": [ + "payload" + ], + "same_bytes_replay": [ + { + "code": "operation_replayed", + "message": "operation d9342f08-41b0-4338-a862-a659bdc64269 was already accepted as task 01a07f66-9cc1-7408-ad91-eec669ea0bf5 on 2026-09-08T05:03:46.613Z; no new request was sent" + } + ] + }, + { + "id": "T104-08", + "credential": "oauth", + "title": "OAuth bearer on v2 get and v1 asset download — same bytes as the API-key download", + "file": "live/t104/download-oauth.json", + "ok": true, + "files": [ + { + "key": "model.glb", + "bytes": 83900100, + "status": "written", + "sha256_12": "51d4b36507bc" + } + ], + "get_ok": true, + "legacy_get_keys": [ + "created_at", + "finished_at", + "id", + "model_urls", + "progress", + "resource", + "status", + "texture_urls", + "thumbnail_url", + "type" + ] + }, + { + "id": "T110-01", + "credential": "api_key", + "title": "project init (real workspace ~/meshy-live)", + "file": "live/t110/01-project-init.json", + "ok": true, + "result.project_dir": "/meshy-live/meshy_output/20260908_125453_live-teapot_d344", + "result.index.updated": true + }, + { + "id": "T110-02", + "credential": "api_key", + "title": "text-to-3d create --mode preview --async --project --save-json", + "file": "live/t110/04-preview-create.json", + "ok": true, + "result.task_id": "01a07f5e-81c7-7111-86f2-f6d2f80b2093", + "result.submission.state": "accepted", + "result.project.action": "added", + "result.saved_json.bytes": 55, + "credits": 20 + }, + { + "id": "T110-03", + "credential": "api_key", + "title": "stream (ndjson) on the finished preview: task + outcome, sequence contiguous", + "file": "live/t110/05-preview-stream.ndjson", + "events": 2, + "last": { + "events": 1, + "ended": "terminal", + "elapsed_seconds": 0.78 + } + }, + { + "id": "T110-04", + "credential": "api_key", + "title": "wait -o preview into the project (model.glb + thumbnail, meta.json, snapshot merged)", + "file": "live/t110/07-preview-wait-download.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.wait": { + "timed_out": false, + "elapsed_seconds": 0.85, + "polls": 1 + }, + "result.downloads.state": "completed", + "result.downloads.metadata_path": "/meshy-live/preview/meta.json", + "result.project.action": "merged", + "files": [ + { + "key": "model_glb", + "bytes": 34393124, + "status": "written", + "sha256_12": "38a2e495b5d2" + }, + { + "key": "thumbnail", + "bytes": 59226, + "status": "written", + "sha256_12": "6d0e766f6a49" + } + ] + }, + { + "id": "T110-05", + "credential": "api_key", + "title": "download --list on the preview", + "file": "live/t110/08-download-list.json", + "ok": true, + "assets": [ + [ + "model.glb", + "glb" + ], + [ + "thumbnail.primary", + "png" + ] + ] + }, + { + "id": "T110-06", + "credential": "api_key", + "title": "text-to-3d create --mode refine (glb,obj,fbx) --async", + "file": "live/t110/11-refine-create.json", + "ok": true, + "result.task_id": "01a07f61-6956-7630-a9a5-e0dcfd344683", + "result.submission.operation_id": "5e1b7a4d-d41a-4fdf-800a-7863a91cdb0d", + "result.project.action": "added", + "credits": 10 + }, + { + "id": "T110-07", + "credential": "api_key", + "title": "stream (ndjson) during the refine: 30 progress events + 1 outcome, contiguous sequence", + "file": "live/t110/12-refine-stream.ndjson", + "events": 31, + "last": { + "events": 30, + "ended": "terminal", + "elapsed_seconds": 213.25 + } + }, + { + "id": "T110-08", + "credential": "api_key", + "title": "wait -o refine: 9 files incl. OBJ+MTL+4 textures; real MTL map_Kd texture_0.png → texture_0_base_color.png by source_name; material_links complete", + "file": "live/t110/13-refine-wait-download.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 10, + "result.downloads.state": "completed", + "result.downloads.material_links.status": "complete", + "result.downloads.material_links.texture_maps": [ + { + "line": 12, + "material": "Material.005", + "reference": "texture_0.png", + "resolved_to": "texture_0_base_color.png", + "method": "source_name" + } + ], + "files": [ + { + "key": "model_glb", + "bytes": 83900100, + "status": "written", + "sha256_12": "51d4b36507bc" + }, + { + "key": "model_fbx", + "bytes": 99468604, + "status": "written", + "sha256_12": "915454648561" + }, + { + "key": "model_obj", + "bytes": 195271847, + "status": "written", + "sha256_12": "d1e6f5bb707e" + }, + { + "key": "model_mtl", + "bytes": 239, + "status": "written", + "sha256_12": "c9c73e2a3ec9" + }, + { + "key": "thumbnail", + "bytes": 61083, + "status": "written", + "sha256_12": "e80a33787756" + }, + { + "key": "texture_0_base_color", + "bytes": 19421816, + "status": "written", + "sha256_12": "5e0411a618eb" + }, + { + "key": "texture_0_metallic", + "bytes": 19397, + "status": "written", + "sha256_12": "4fd4696a84f8" + }, + { + "key": "texture_0_normal", + "bytes": 11884487, + "status": "written", + "sha256_12": "964426c0fc2e" + }, + { + "key": "texture_0_roughness", + "bytes": 967530, + "status": "written", + "sha256_12": "60836dd75819" + } + ] + }, + { + "id": "T110-09", + "credential": "api_key", + "title": "download --model-format obj (selective, dependencies) — material_links complete", + "file": "live/t110/17-download-obj.json", + "ok": true, + "result.selection": { + "selected": [ + "model.obj" + ], + "dependencies": [ + "model.mtl", + "texture.0.base_color", + "texture.0.metallic", + "texture.0.roughness", + "texture.0.normal" + ] + }, + "result.downloads.state": "completed", + "result.downloads.material_links.status": "complete", + "files": [ + { + "key": "model.obj", + "bytes": 195271847, + "status": "written", + "sha256_12": "d1e6f5bb707e" + }, + { + "key": "model.mtl", + "bytes": 239, + "status": "written", + "sha256_12": "c9c73e2a3ec9" + }, + { + "key": "texture.0.base_color", + "bytes": 19421816, + "status": "written", + "sha256_12": "5e0411a618eb" + }, + { + "key": "texture.0.metallic", + "bytes": 19397, + "status": "written", + "sha256_12": "4fd4696a84f8" + }, + { + "key": "texture.0.roughness", + "bytes": 967530, + "status": "written", + "sha256_12": "60836dd75819" + }, + { + "key": "texture.0.normal", + "bytes": 11884487, + "status": "written", + "sha256_12": "964426c0fc2e" + } + ] + }, + { + "id": "T110-10", + "credential": "api_key", + "title": "download --asset thumbnail.primary --output file", + "file": "live/t110/18-download-thumb.json", + "ok": true, + "files": [ + { + "key": "thumbnail.primary", + "bytes": 61083, + "status": "written", + "sha256_12": "e80a33787756" + } + ] + }, + { + "id": "T110-11", + "credential": "api_key", + "title": "image-to-3d create from a local synthetic PNG (data URI) — server-side FAILED, 0 credits, task_failed relayed, FAILED recorded in the project", + "file": "live/t110/16-image-wait-download.json", + "ok": false, + "error.code": "task_failed", + "result.task.status": "FAILED", + "result.task.consumed_credits": 0, + "result.task.task_error": { + "type": "server_error", + "message": "Failed to generate preview images. Please retry." + }, + "result.project.action": "merged", + "credits": 0 + }, + { + "id": "T110-12", + "credential": "api_key", + "title": "download --list / get on the FAILED task (not_ready + task_not_ready warning; task_error relayed)", + "file": "live/t110/21-failed-download-list.json", + "ok": true, + "result.downloads.state": "not_ready", + "warnings": [ + "task_not_ready" + ], + "get_task_error": { + "type": "server_error", + "message": "Failed to generate preview images. Please retry." + } + }, + { + "id": "T110-13", + "credential": "api_key", + "title": "image-to-3d retry with the real refine thumbnail: SUCCEEDED, OBJ/MTL relinked", + "file": "live/t110/23-image-create-retry.json", + "result.task_id": "01a07f6d-d56b-711b-8ebc-3fe0c1dd8541", + "wait": {}, + "status": "SUCCEEDED", + "consumed": 30, + "material_links": "complete", + "files": [ + { + "key": "model_glb", + "bytes": 80427848, + "status": "written", + "sha256_12": "31e43eba14c2" + }, + { + "key": "model_obj", + "bytes": 195493200, + "status": "written", + "sha256_12": "aa13ba15d5e3" + }, + { + "key": "model_mtl", + "bytes": 235, + "status": "written", + "sha256_12": "552ae8602502" + }, + { + "key": "thumbnail", + "bytes": 47266, + "status": "written", + "sha256_12": "2c4a3a83bd4c" + }, + { + "key": "texture_0_base_color", + "bytes": 17044752, + "status": "written", + "sha256_12": "7af338cc630c" + }, + { + "key": "texture_0_metallic", + "bytes": 19055, + "status": "written", + "sha256_12": "6caf8476a1aa" + }, + { + "key": "texture_0_normal", + "bytes": 8612949, + "status": "written", + "sha256_12": "3ef26cf887cf" + }, + { + "key": "texture_0_roughness", + "bytes": 788016, + "status": "written", + "sha256_12": "67fc11c6adf9" + } + ], + "credits": 30 + }, + { + "id": "T110-14", + "credential": "api_key", + "title": "text-to-3d list --page-size 3", + "file": "live/t110/14-list.json", + "ok": true, + "result.count": 2 + }, + { + "id": "T110-15", + "credential": "oauth", + "title": "image-to-3d delete (the FAILED task) then get → not_found 404 exit 5", + "file": "live/t110/24-delete-failed.json", + "ok": true, + "result.deleted": true, + "get_after": { + "code": "not_found", + "http_status": 404 + } + }, + { + "id": "T111-01", + "credential": "api_key", + "title": "showcases list → 403 enterprise-only (account-gated): error.code server, http 403, exit 1", + "file": "live/t111/01-showcases-list.json", + "ok": false, + "error.code": "server", + "error.http_status": 403 + }, + { + "id": "T111-02", + "credential": "api_key", + "title": "animation-catalog list (public, free)", + "file": "live/t111/02-animation-catalog.json", + "ok": true, + "result.count": 8, + "result.total": 157 + }, + { + "id": "T111-03", + "credential": "api_key", + "title": "uv-unwrap on the 1.9M-face refine → API 400 (44k limit) mapped to validation exit 4; on the remeshed model SUCCEEDED", + "file": "live/t111/wait-uv-unwrap.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 5, + "files": [ + { + "key": "model_glb", + "bytes": 212528, + "status": "written", + "sha256_12": "37651e05e3fa" + }, + { + "key": "thumbnail", + "bytes": 60098, + "status": "written", + "sha256_12": "73eafd0c48f2" + } + ], + "credits": 5 + }, + { + "id": "T111-04", + "credential": "api_key", + "title": "remesh (8000 faces, glb,obj) SUCCEEDED; OBJ set relinked", + "file": "live/t111/wait-remesh.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 5, + "result.downloads.material_links.status": "complete", + "files": [ + { + "key": "model_glb", + "bytes": 24213896, + "status": "written", + "sha256_12": "bb2bdc29e55f" + }, + { + "key": "model_obj", + "bytes": 757517, + "status": "written", + "sha256_12": "767bf14f8c46" + }, + { + "key": "model_mtl", + "bytes": 235, + "status": "written", + "sha256_12": "552ae8602502" + }, + { + "key": "thumbnail", + "bytes": 61545, + "status": "written", + "sha256_12": "bd911dba6dfe" + }, + { + "key": "texture_0_base_color", + "bytes": 18108173, + "status": "written", + "sha256_12": "7ed2e4f3b659" + }, + { + "key": "texture_0_metallic", + "bytes": 900041, + "status": "written", + "sha256_12": "b7b51aa43b77" + }, + { + "key": "texture_0_normal", + "bytes": 8656095, + "status": "written", + "sha256_12": "681e3f286a1f" + }, + { + "key": "texture_0_roughness", + "bytes": 2289526, + "status": "written", + "sha256_12": "ab7395fccc69" + }, + { + "key": "texture_0_metallic_roughness", + "bytes": 5830658, + "status": "written", + "sha256_12": "262cc61894df" + } + ], + "credits": 5 + }, + { + "id": "T111-05", + "credential": "api_key", + "title": "retexture (text style prompt, PBR) SUCCEEDED", + "file": "live/t111/wait-retexture.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 10, + "files": [ + { + "key": "model_glb", + "bytes": 83810660, + "status": "written", + "sha256_12": "d7d18c8cb3de" + }, + { + "key": "thumbnail", + "bytes": 93635, + "status": "written", + "sha256_12": "d8804aba5986" + }, + { + "key": "texture_0_base_color", + "bytes": 19358023, + "status": "written", + "sha256_12": "0a467f028d73" + }, + { + "key": "texture_0_metallic", + "bytes": 237086, + "status": "written", + "sha256_12": "ef3aaeab62f0" + }, + { + "key": "texture_0_normal", + "bytes": 11468546, + "status": "written", + "sha256_12": "376b83e5b532" + }, + { + "key": "texture_0_roughness", + "bytes": 1027166, + "status": "written", + "sha256_12": "b474c3dfa34d" + } + ], + "credits": 10 + }, + { + "id": "T111-06", + "credential": "api_key", + "title": "analyze-printability SUCCEEDED (0 credits): report-only, meta.json written, status warning (degenerate faces)", + "file": "live/t111/wait-printability.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 0, + "result.task.printability.status": "warning", + "result.task.printability.metrics": { + "is_watertight": true, + "volume": 0.7804952440067714, + "non_manifold_edges": 0, + "degenerate_faces": 64868, + "holes": 0 + }, + "result.downloads.metadata_path": "/meshy-live/printability/meta.json", + "credits": 0 + }, + { + "id": "T111-07", + "credential": "api_key", + "title": "rigging on the teapot → API 400 (face limit) then 422 (pose estimation failed) → validation exit 4", + "file": "live/t111/sub-rigging.json", + "error.code": null, + "error.message": null, + "note": "the teapot is not a humanoid; see T111-11 for the successful rig" + }, + { + "id": "T111-08", + "credential": "api_key", + "title": "text-to-motion: CLI requires --duration (2–10 s); with --duration 4 SUCCEEDED, motion.fbx", + "file": "live/t111/wait-motion.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 10, + "files": [ + { + "key": "motion_url", + "bytes": 16825904, + "status": "written", + "sha256_12": "f34b68ced2fe" + } + ], + "credits": 10 + }, + { + "id": "T111-09", + "credential": "api_key", + "title": "humanoid text-to-3d preview for the rig chain", + "file": "live/t111/sub-humanoid.json", + "result.task_id": "01a07f72-0239-759f-850b-54081f7e12a0", + "result.submission.operation_id": "587d078c-7823-4a71-a795-e03a7a00259b", + "credits": 20 + }, + { + "id": "T111-10", + "credential": "oauth", + "title": "remesh the humanoid to 30k faces (rigging refused 1.95M faces with 400)", + "file": "live/t111/wait-humanoid-remesh.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 5, + "files": [ + { + "key": "model_glb", + "bytes": 1300572, + "status": "written", + "sha256_12": "21400643996f" + }, + { + "key": "thumbnail", + "bytes": 108534, + "status": "written", + "sha256_12": "95fb5c2d7771" + }, + { + "key": "texture_0_normal", + "bytes": 4570614, + "status": "written", + "sha256_12": "3217a4b1b18f" + } + ], + "credits": 5 + }, + { + "id": "T111-11", + "credential": "oauth", + "title": "rigging SUCCEEDED: rigged glb/fbx + walking/running clips (8 files)", + "file": "live/t111/wait-rigging.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 5, + "files": [ + { + "key": "rigged_character_fbx_url", + "bytes": 2033532, + "status": "written", + "sha256_12": "1ef48318a45d" + }, + { + "key": "rigged_character_glb_url", + "bytes": 2020516, + "status": "written", + "sha256_12": "59f350b4c8ec" + }, + { + "key": "basic_animations_walking_glb_url", + "bytes": 2033292, + "status": "written", + "sha256_12": "235da1bf00dd" + }, + { + "key": "basic_animations_walking_fbx_url", + "bytes": 2058316, + "status": "written", + "sha256_12": "f20d67b431b6" + }, + { + "key": "basic_animations_walking_armature_glb_url", + "bytes": 65392, + "status": "written", + "sha256_12": "20e066e440b3" + }, + { + "key": "basic_animations_running_glb_url", + "bytes": 2028688, + "status": "written", + "sha256_12": "9cd1dcc464a3" + }, + { + "key": "basic_animations_running_fbx_url", + "bytes": 2049660, + "status": "written", + "sha256_12": "04f1027412a8" + }, + { + "key": "basic_animations_running_armature_glb_url", + "bytes": 60788, + "status": "written", + "sha256_12": "3dc15c9cbe60" + } + ], + "credits": 5 + }, + { + "id": "T111-12", + "credential": "oauth", + "title": "animate create --action-id 28 (Big Wave Hello) on the rig: stream 10 events + outcome; wait -o glb+fbx", + "file": "live/t111/wait-animation.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 3, + "files": [ + { + "key": "animation_glb_url", + "bytes": 2085424, + "status": "written", + "sha256_12": "71cacbd652db" + }, + { + "key": "animation_fbx_url", + "bytes": 2133068, + "status": "written", + "sha256_12": "6516e604028a" + } + ], + "stream_events": 11, + "credits": 3 + }, + { + "id": "T111-13", + "credential": "oauth", + "title": "make 'a low-poly cactus…' -o: preview → refine, two journal records, downloads", + "file": "live/make/make.json", + "ok": true, + "result.route": "text", + "result.executed": [ + { + "step": 1, + "resource": "text-to-3d", + "action": "preview", + "task_id": "01a07f75-c6f0-7219-8a2e-65577ed29838", + "status": "SUCCEEDED", + "operation_id": "4ce4ad82-a125-49e1-a198-868b0b41bad4" + }, + { + "step": 2, + "resource": "text-to-3d", + "action": "refine", + "task_id": "01a07f76-c8a8-7731-bdbd-464304055493", + "status": "SUCCEEDED", + "operation_id": "a8227912-f16b-48b5-a155-1b3a429445e4" + } + ], + "result.submission": { + "state": "accepted", + "operation_id": "a8227912-f16b-48b5-a155-1b3a429445e4", + "task_id": "01a07f76-c8a8-7731-bdbd-464304055493" + }, + "result.task.status": "SUCCEEDED", + "result.downloads.state": "completed", + "files": [ + { + "key": "model_glb", + "bytes": 91624388, + "status": "written", + "sha256_12": "8de9537dfff7" + }, + { + "key": "thumbnail", + "bytes": 116190, + "status": "written", + "sha256_12": "7b1e09871729" + }, + { + "key": "texture_0_base_color", + "bytes": 22237173, + "status": "written", + "sha256_12": "4fffa8996db1" + }, + { + "key": "texture_0_metallic", + "bytes": 52871, + "status": "written", + "sha256_12": "dbe5bb6c6ecc" + }, + { + "key": "texture_0_normal", + "bytes": 15698341, + "status": "written", + "sha256_12": "68716ce5b0b5" + }, + { + "key": "texture_0_roughness", + "bytes": 1287400, + "status": "written", + "sha256_12": "fbe3aaeab1fa" + } + ], + "credits": 30 + }, + { + "id": "T111-14", + "credential": "oauth", + "title": "creative-lab lamp prototype create; get/wait on the IN_PROGRESS task FAILED with 'unexpected task shape' (finished_at: null) → live finding L01", + "file": "live/creative/lamp-proto-wait.json", + "ok": false, + "error.code": "server", + "error.http_status": 200, + "message_head": "unexpected task shape from GET /lamp/v1/prototype/01a07f79-e9f7-7347-89c7-c46fb7a11d06: [\n {\n \"expected\": \"number\",\n", + "credits": 30 + }, + { + "id": "T111-15", + "credential": "oauth", + "title": "after the L01 fix (reinstalled CLI): lamp prototype get/wait SUCCEEDED (lampshade glb + concept image)", + "file": "live/creative/lamp-proto-wait3.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.wait.polls": 1, + "files": [ + { + "key": "model_glb", + "bytes": 4483108, + "status": "written", + "sha256_12": "62b8179c70b4" + }, + { + "key": "thumbnail", + "bytes": 69479, + "status": "written", + "sha256_12": "2fe40b2143fa" + }, + { + "key": "image_0", + "bytes": 678713, + "status": "written", + "sha256_12": "121c950c3694" + } + ] + }, + { + "id": "T111-16", + "credential": "oauth", + "title": "keychain prototype create + immediate wait polled through 3 IN_PROGRESS states to SUCCEEDED (L01 verified live)", + "file": "live/creative/keychain-proto-wait.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 6, + "result.wait.polls": 4, + "files": [ + { + "key": "image_0", + "bytes": 477978, + "status": "written", + "sha256_12": "e4eab2f32244" + } + ], + "credits": 6 + }, + { + "id": "T111-17", + "credential": "oauth", + "title": "lamp build SUCCEEDED — legacy -o named the parts model.lamp_stl/model.base_stl → live finding L02", + "file": "live/creative/lamp-build-wait.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 6, + "files": [ + { + "key": "model_base_stl", + "bytes": 360284, + "status": "written", + "sha256_12": "5cbf2af2866c" + }, + { + "key": "model_lamp_stl", + "bytes": 15162184, + "status": "written", + "sha256_12": "94f5809a85df" + } + ], + "credits": 6 + }, + { + "id": "T111-18", + "credential": "oauth", + "title": "meshy download --list/--all on the lamp build names lamp.stl / base.stl (selective path was right)", + "file": "live/creative/lamp-build-selective.json", + "ok": true, + "files": [ + { + "key": "model.base_stl", + "relative_path": "lamp-build-selective/base.stl", + "format": "stl" + }, + { + "key": "model.lamp_stl", + "relative_path": "lamp-build-selective/lamp.stl", + "format": "stl" + } + ] + }, + { + "id": "T111-19", + "credential": "oauth", + "title": "keychain builds: default (glb) and --model-format obj (ZIP bundle); legacy -o saved the bundle as model.obj → L02; selective path: model.obj.zip", + "file": "live/creative/keychain-build-obj-wait.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 30, + "legacy_files": [ + { + "key": "model_obj", + "bytes": 46871892, + "status": "written", + "sha256_12": "bf665456914a" + } + ], + "selective": [ + { + "key": "model.obj", + "relative_path": "keychain-build-selective/model.obj.zip", + "format": "zip", + "container_format": "zip" + } + ], + "credits": 60 + }, + { + "id": "T111-20", + "credential": "oauth", + "title": "after the L02 fix (reinstalled CLI): -o on the same builds → lamp.stl/base.stl (byte-identical) and model.obj.zip", + "file": "live/creative/lamp-build-fixed.json", + "ok": true, + "lamp_files": [ + [ + "model_base_stl", + "base.stl" + ], + [ + "model_lamp_stl", + "lamp.stl" + ] + ], + "keychain_files": [ + [ + "model_obj", + "model.obj.zip" + ] + ] + }, + { + "id": "T111-21", + "credential": "oauth", + "title": "figure prototype SUCCEEDED; first figure build FAILED server-side (0 credits, task_failed relayed); retry SUCCEEDED with OBJ/MTL relinked", + "file": "live/creative/figure-build-wait2.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 30, + "result.downloads.material_links.status": "complete", + "first_attempt": { + "status": "FAILED", + "consumed_credits": 0, + "task_error": { + "type": "server_error", + "message": "Failed to generate preview images. Please retry." + } + }, + "files": [ + { + "key": "model_glb", + "bytes": 78663044, + "status": "written", + "sha256_12": "24c8443380ed" + }, + { + "key": "model_obj", + "bytes": 199049675, + "status": "written", + "sha256_12": "653af29a67bd" + }, + { + "key": "model_mtl", + "bytes": 235, + "status": "written", + "sha256_12": "552ae8602502" + }, + { + "key": "thumbnail", + "bytes": 55346, + "status": "written", + "sha256_12": "24e69b643114" + }, + { + "key": "texture_0_base_color", + "bytes": 18874922, + "status": "written", + "sha256_12": "09bd7ed10caa" + } + ], + "credits": 36 + }, + { + "id": "T111-22", + "credential": "oauth", + "title": "fridge-magnet prototype + build (exposed product) SUCCEEDED with the fixed CLI: bundle named by the shared mapping", + "file": "live/creative/magnet-build-wait.json", + "ok": true, + "result.task.status": "SUCCEEDED", + "result.task.consumed_credits": 30, + "prototype": { + "status": "SUCCEEDED", + "consumed_credits": 6 + }, + "files": [ + [ + "model_glb", + "model.glb", + 17185876 + ] + ], + "credits": 36 + }, + { + "id": "T112-01", + "credential": "none", + "title": "slicer detect finds Bambu Studio 02.08.02.61; slicer open on a prepared print OBJ launches it (macOS open -a, pid observed)", + "file": "live/t112/open.json", + "ok": true, + "result.launch_requested": true, + "result.slicer.name": "Bambu Studio", + "result.slicer.path": "/Applications/BambuStudio.app", + "result.launcher": { + "command": "open", + "exit_code": 0 + } + }, + { + "id": "T112-02", + "credential": "none", + "title": "mesh prepare-print on the real remeshed OBJ (60 mm) → print OBJ + copied MTL/texture; slicer open again on that file", + "file": "live/t112/prepare-real.json", + "ok": true, + "result.output": "/meshy-live/print/teapot.print.obj", + "open": { + "launch_requested": true, + "pid": 17811 + } + }, + { + "id": "LOCAL-01", + "credential": "none", + "title": "project show/list on the real project: 11+ task entries with snapshots and operation ids; history index clean", + "file": "live/local/project-show.json", + "ok": true, + "tasks": 11, + "index_dirty": false + }, + { + "id": "LOCAL-02", + "credential": "none", + "title": "inspect faces on real task snapshots → check_unknown (13): the API task JSON carries no face_count", + "file": "live/local/inspect-remesh.json", + "ok": false, + "error.code": "check_unknown", + "result.verdict": "unknown", + "result.reason": "face_count missing" + } + ], + "t109_linux": [ + { + "platform": "linux/arm64", + "os": "Debian GNU/Linux 12 (bookworm)", + "node": "v24.20.0", + "npm": "11.19.0", + "tarball_sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", + "passed": 29, + "total": 29, + "sharp": "0.35.4 (libvips 8.18.6)", + "container": "OrbStack docker, node:24-bookworm, native" + }, + { + "platform": "linux/x64", + "os": "Debian GNU/Linux 12 (bookworm)", + "node": "v24.20.0", + "npm": "11.19.0", + "tarball_sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", + "passed": 29, + "total": 29, + "sharp": "0.35.4 (libvips 8.18.6)", + "container": "OrbStack docker, node:24-bookworm, linux/amd64 emulated" + } + ], + "not_run": [ + "T-109 Windows x64 (no host)", + "MESHY_API_KEY env / --api-key-file variants against the real API (offline tests only; the stored API-key profile was used live)", + "showcases content (account is not enterprise: 403 recorded)", + "auth logout/revoke on the owner's real profiles (left to the owner)" + ], + "observations_not_fixed": [ + { + "id": "OBS-1", + "severity": "P3", + "text": "403 'enterprise only' from showcases maps to error.code server / exit 1; a dedicated permission code (or auth) may be clearer" + }, + { + "id": "OBS-2", + "severity": "P3", + "text": "downloaded asset files are published mode 0600 while rewritten MTL and JSON sidecars are 0644" + }, + { + "id": "OBS-3", + "severity": "P3", + "text": "task verbs' -o downloads are not recorded as files in the project entry (recorded only by meshy download --project); attachToProject.extra.files has no caller" + }, + { + "id": "OBS-4", + "severity": "P3", + "text": "download --list on a FAILED task reports downloads.state not_ready with ok:true (a terminal failure reads as 'not yet')" + }, + { + "id": "OBS-5", + "severity": "P3", + "text": "auth status/list/use ignore --output-schema v1 (legacy shape only)" + }, + { + "id": "OBS-6", + "severity": "P3", + "text": "real task JSON carries no face_count, so inspect faces from a task JSON always ends in check_unknown (13) — by design, but worth stating in docs" + }, + { + "id": "OBS-7", + "severity": "P3", + "text": "text-to-motion requires --duration client-side (2–10 s, 0.5 steps); confirm against the API default" + } + ] +} diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json index 14d24d4..46bbb97 100644 --- a/docs/skill-parity/verification.json +++ b/docs/skill-parity/verification.json @@ -1,9 +1,9 @@ { "schema_version": 1, - "status": "review round 6 fixes applied (Codex reviewed 166492b/7b7c24c: changes_requested, 2 P1 code findings + 1 P3 test gap); re-review pending; live checks not_run", + "status": "G1-code accepted by Codex round 7 (e567646); live verification against the owner's real account completed on 2026-09-08 with two defects found and fixed (L01, L02, commit 1d9f109); Windows x64 not_run; release not performed; live fixes await Codex review", "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", "environment": { - "os": "macOS 26.6 (Darwin 25.6.0) arm64", + "os": "macOS 26.6.2 (Darwin 25.6.0) arm64", "node": "v24.20.0", "pnpm": "11.24.0" }, @@ -43,95 +43,95 @@ "final_runs": [ { "command": "node --version", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm --version", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm install --frozen-lockfile", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 1, "result": "passed" }, { "command": "pnpm typecheck", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm build", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "pnpm test", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 45, "result": "passed", - "tests": 555, - "pass": 555, + "tests": 558, + "pass": 558, "fail": 0, "skipped": 0 }, { "command": "bash -c test \"$(node dist/index.js --version)\" = \"$(node -p \"require(\\\"./package.json\\\").version\")\"", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "npm pack --json --pack-destination /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 7, "result": "passed" }, { "command": "npm install -g --prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/meshy-cli-0.3.0.tgz", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 7, "result": "passed" }, { "command": "git diff --check fd94490", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 0, "result": "passed" }, { - "command": "git diff --check 166492b", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "command": "git diff --check 4da216d", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "git diff --check", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 0, "result": "passed" }, { "command": "for i in 1..12: node --import tsx --test tests/poll.test.ts", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 15, "result": "passed", @@ -139,19 +139,19 @@ }, { "command": "for i in 1..8: node --import tsx --test tests/codex-review-round1.test.ts", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, - "duration_s": 50, + "duration_s": 49, "result": "passed", - "detail": "8/8 runs; R03 asserts the D-044 invariants since 68690f9" + "detail": "8/8 runs; R03 asserts the D-044 invariants" }, { "command": "for i in 1..3: node --import tsx --test tests/codex-review-round6.test.ts tests/codex-review-round5.test.ts tests/codex-review-round4.test.ts tests/codex-review-round3.test.ts tests/codex-review-round2.test.ts", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "duration_s": 24, "result": "passed", - "detail": "3/3 runs; SIGINT-during-relink, sidecar race, project-record races, the 20-entry recovery matrix, the 20-entry mutable-workspace matrix, make SIGINT and other timing-sensitive cases" + "detail": "3/3 runs; timing-sensitive review regressions" } ], "behavior_tests": { @@ -160,7 +160,7 @@ "files": [ "tests/cli-contract.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-002": { "result": "passed", @@ -168,49 +168,49 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-003": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-004": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-005": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-006": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-007": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-008": { "result": "passed", "files": [ "tests/task-view.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-009": { "result": "passed", @@ -223,7 +223,7 @@ "tests/codex-review-round5.test.ts", "tests/codex-review-round6.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F10: R10 stream -o in ndjson/json/pretty; one outcome on download failure", "review_round_2": "R2-F01 N01 report-only get/wait/stream -o confined; R2-F04 N06 partial manifest; R2-F05 N08 SIGINT", "review_round_3": "R3-F02 C06: legacy get/wait/create/make -o failures name the task (task_id, operation_id, next, manifest, hint)", @@ -239,7 +239,7 @@ "tests/codex-review-round2.test.ts", "tests/codex-review-round3.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F01: R05 save-json preflight + post-POST failure keeps the id", "review_round_2": "R2-F04 N06: task -o failure keeps HTTP class/status and the files already written", "review_round_3": "R3-F01/R3-F04 C01+C02: sidecar planted after the preflight refused; committed model kept in the manifest with failed_step" @@ -250,74 +250,82 @@ "tests/cli-contract.test.ts", "tests/result.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-012": { "result": "passed", "files": [ "tests/cli-contract.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-020": { "result": "passed", "files": [ "tests/resource-registry.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-021": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-022": { "result": "passed", "files": [ "tests/resource-registry.test.ts", - "tests/uv-creative-lab.test.ts" + "tests/uv-creative-lab.test.ts", + "tests/live-verification.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "live_fix": "L01: Creative Lab in-progress body with finished_at: null parses (fixture captured live); get/wait poll through" }, "T-023": { "result": "passed", "files": [ "tests/resource-registry.test.ts", - "tests/uv-creative-lab.test.ts" + "tests/uv-creative-lab.test.ts", + "tests/live-verification.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "live_fix": "L02: legacy -o names lamp parts lamp.stl/base.stl and the keychain OBJ bundle model.obj.zip" }, "T-024": { "result": "passed", "files": [ - "tests/uv-creative-lab.test.ts" + "tests/uv-creative-lab.test.ts", + "tests/live-verification.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "live_fix": "L01/L02 via creative-lab lamp/keychain build get -o" }, "T-025": { "result": "passed", "files": [ "tests/uv-creative-lab.test.ts", - "tests/codex-review-round1.test.ts" + "tests/codex-review-round1.test.ts", + "tests/live-verification.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", - "review_round_1": "F08: R04 three-layer options merge on the real POST body" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "review_round_1": "F08: R04 three-layer options merge on the real POST body", + "live_fix": "L01: creative-lab lamp prototype wait polls through IN_PROGRESS (null timestamps) to SUCCEEDED" }, "T-026": { "result": "passed", "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-027": { "result": "passed", "files": [ "tests/catalog-showcases.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-028": { "result": "passed", @@ -325,7 +333,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F08: R04" }, "T-029": { @@ -334,7 +342,7 @@ "tests/file-input.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F06: R02 equal-length images conflict, re-wrapped base64 replays, no base64 in journal" }, "T-030": { @@ -342,28 +350,28 @@ "files": [ "tests/file-input.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-031": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-032": { "result": "passed", "files": [ "tests/transport.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-040": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-041": { "result": "passed", @@ -371,7 +379,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F02: R11 make journal failure after acceptance is local_io with the id (text + image routes)" }, "T-042": { @@ -380,7 +388,7 @@ "tests/uv-creative-lab.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F02: R11" }, "T-043": { @@ -389,14 +397,14 @@ "tests/operation-store.test.ts", "tests/task-lifecycle.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-044": { "result": "passed", "files": [ "tests/task-lifecycle.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-045": { "result": "passed", @@ -408,7 +416,7 @@ "tests/codex-review-round5.test.ts", "tests/codex-review-round6.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F01: R06 polling 5xx keeps id/submission/next; make polling failure keeps executed steps", "review_round_3": "R3-F02 C06: legacy sync create keeps the accepted task on an asset 503 with exactly one POST", "review_round_4": "R4-T01: two journal records, no re-submission; POST GET POST GET GET; refine.preview_task_id = step 1", @@ -422,7 +430,7 @@ "tests/task-lifecycle.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "barrier-released three-process race; F05/F06 fingerprint unit tests" }, "T-047": { @@ -432,7 +440,7 @@ "tests/codex-review-round1.test.ts", "tests/poll.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F07: deadline bounds the sleep; --timeout 0 request cap", "review_round_2": "R2-F07: deterministic fake-clock deadline tests; real-timer smoke asserts only no GET after the deadline", "review_round_4": "R03 'expiry during the sleep' asserts the D-044 invariants (test-only commit 68690f9); 8/8 standalone runs" @@ -446,7 +454,7 @@ "tests/codex-review-round3.test.ts", "tests/codex-review-round4.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F01: task context on every post-acceptance failure", "review_round_2": "R2-F05 N08: SIGINT before headers / mid-body / on the second asset → 130 with manifest, no leftovers", "review_round_3": "R3-F05 C03: SIGINT during the OBJ rewrite → 130, digests re-taken, no sidecar, no temp file; make interrupt", @@ -457,28 +465,28 @@ "files": [ "tests/sse.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-050": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-051": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-052": { "result": "passed", "files": [ "tests/sse.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-053": { "result": "passed", @@ -488,7 +496,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F10: R10", "review_round_2": "R2-F03 N05: save-json/project/download failures after the stream end in one outcome with the next sequence" }, @@ -497,7 +505,7 @@ "files": [ "tests/artifacts.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-061": { "result": "passed", @@ -505,7 +513,7 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-062": { "result": "passed", @@ -519,7 +527,7 @@ "tests/codex-review-round5.test.ts", "tests/codex-review-round6.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F09: R07 rewritten mtllib/map_* references verified on disk; unresolved map reported", "review_round_2": "R2-F02 N04: multi-material textures mapped by source name; ambiguity kept + warned (v1 and legacy -o)", "review_round_3": "R3-F03 C05: source name beats a colliding generated name (verified by image bytes); generated-name reference with another source is ambiguous", @@ -532,7 +540,7 @@ "files": [ "tests/artifacts.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-064": { "result": "passed", @@ -544,7 +552,7 @@ "tests/codex-review-round5.test.ts", "tests/codex-review-round6.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_2": "R2-F01 N03: refused download creates no directory; zero requests", "review_round_3": "R3-F01 C01: sidecar publication re-proves the root and refuses symlink/file/directory targets", "review_round_4": "R4-F02 D02: project record failure after the transfer keeps the full result and a record_project recovery; preflight refuses symlink/damaged metadata.json and blank --stage before any request", @@ -556,14 +564,14 @@ "files": [ "tests/download-command.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-067": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-068": { "result": "passed", @@ -576,7 +584,7 @@ "tests/codex-review-round5.test.ts", "tests/codex-review-round6.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F03/F04: R08 project roots, R09 task -o, R12 dependency symlink escapes", "review_round_2": "R2-F01 N01/N03: outside/symlinked targets refused before mkdir for report tasks and downloads", "review_round_3": "R3-F01 C01: outside file bytes unchanged across the sidecar race, single-file and legacy variants", @@ -589,14 +597,14 @@ "files": [ "tests/download-command.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-070": { "result": "passed", "files": [ "tests/download-command.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-071": { "result": "passed", @@ -604,7 +612,7 @@ "tests/artifacts.test.ts", "tests/download-command.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-072": { "result": "passed", @@ -617,7 +625,7 @@ "tests/codex-review-round5.test.ts", "tests/codex-review-round6.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F03: R08 project init/record/rebuild-index confined to --workspace", "review_round_2": "R2-F01 N02: implicit history root confined; metadata recorded, parent untouched", "review_round_3": "R3-F06 C04: aliased project directory records its files; truly outside files still not recorded", @@ -630,21 +638,21 @@ "files": [ "tests/project-store.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-074": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-075": { "result": "passed", "files": [ "tests/project-store.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-076": { "result": "passed", @@ -652,7 +660,7 @@ "tests/project-store.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_2": "R2-F01 N02: index skipped with index.updated=false + reason when the root is outside --workspace" }, "T-077": { @@ -660,49 +668,49 @@ "files": [ "tests/project-store.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-080": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-081": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-082": { "result": "passed", "files": [ "tests/inspect.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-083": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-084": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-085": { "result": "passed", "files": [ "tests/obj-transform.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-086": { "result": "passed", @@ -710,7 +718,7 @@ "tests/obj-transform.test.ts", "tests/codex-review-round1.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F04: R12 symlinked materials/ and texture dir refused; real dirs copied" }, "T-087": { @@ -718,35 +726,35 @@ "files": [ "tests/obj-transform.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-088": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-089": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-090": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-091": { "result": "passed", "files": [ "tests/slicers.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-100": { "result": "passed", @@ -756,7 +764,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F05: R01 API key digest binding", "review_round_2": "R2-F06 N07: OAuth without user_id/login_id refused a replay; login_id binds; refresh keeps it" }, @@ -765,7 +773,7 @@ "files": [ "tests/env-file.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-102": { "result": "passed", @@ -773,14 +781,14 @@ "tests/cli-contract.test.ts", "tests/doctor.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-103": { "result": "passed", "files": [ "tests/doctor.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1" }, "T-105": { "result": "passed", @@ -789,7 +797,7 @@ "tests/codex-review-round1.test.ts", "tests/codex-review-round2.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_1": "F05: R01 OAuth subject binding (token rotation keeps identity)", "review_round_2": "R2-F06 N07" }, @@ -798,7 +806,7 @@ "files": [ "tests/auth-headless.test.ts" ], - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "review_round_2": "R2-F06: a device-flow login mints login_id (offline E2E stub)" } }, @@ -806,40 +814,133 @@ { "id": "T-104", "scope": "API Key and OAuth profile regression against a real account", - "result": "not_run", - "reason": "no credential configured in this session; offline regression suites (auth-headless, oauth, device, credentials, runtime) pass. Round 2 adds login_id minting at auth login — whether the real token endpoint returns user_id, and a real re-login, remain not_run" + "result": "passed", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "real account, credentials entered by the owner (meshy auth login --with-key → profile 'default'; meshy auth login → profile 'oauth', browser PKCE); both used live for creates/gets/downloads; the real token endpoint returned no user_id — identity falls back to the minted login_id (present); silent refresh observed at expiry (expires_at advanced, login_id kept, tokens rotated, refreshed token verified by balance); journal replay with the same key → operation_replayed, same operation id under the other credential → operation_conflict (credential), different image bytes → operation_conflict (payload), same bytes under another name → replayed", + "steps": [ + "T104-01", + "T104-02", + "T104-03", + "T104-04", + "T104-05", + "T104-06", + "T104-07", + "T104-08" + ], + "record": "docs/skill-parity/live-verification.json", + "not_run": [ + "MESHY_API_KEY env and --api-key-file against the real API (offline tests only)", + "auth logout/revoke of the owner's real profiles" + ] }, { "id": "T-109", "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "partial", - "detail": "macOS 26.6 arm64, Node v24.20.0: tarball install + 29 smoke checks passed on e567646 (see package_smoke). Windows x64 and Linux x64: not_run (no host); slicer detection rules for win32/linux are covered by platform fixtures only" + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "macOS 26.6.2 arm64 Node v24.20.0: tarball install + 29 smoke checks passed; Linux (OrbStack docker node:24-bookworm): linux/arm64 Debian GNU/Linux 12 (bookworm) node v24.20.0 29/29 sharp 0.35.4 (libvips 8.18.6) (OrbStack docker, node:24-bookworm, native); linux/x64 Debian GNU/Linux 12 (bookworm) node v24.20.0 29/29 sharp 0.35.4 (libvips 8.18.6) (OrbStack docker, node:24-bookworm, linux/amd64 emulated); the owner also installed the same tarball globally (npm install -g ~/Downloads/meshy-cli-0.3.0.tgz) and ran every live command through that install. Windows x64: not_run (no host)", + "linux": [ + { + "platform": "linux/arm64", + "os": "Debian GNU/Linux 12 (bookworm)", + "node": "v24.20.0", + "npm": "11.19.0", + "tarball_sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", + "passed": 29, + "total": 29, + "sharp": "0.35.4 (libvips 8.18.6)", + "container": "OrbStack docker, node:24-bookworm, native" + }, + { + "platform": "linux/x64", + "os": "Debian GNU/Linux 12 (bookworm)", + "node": "v24.20.0", + "npm": "11.19.0", + "tarball_sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", + "passed": 29, + "total": 29, + "sharp": "0.35.4 (libvips 8.18.6)", + "container": "OrbStack docker, node:24-bookworm, linux/amd64 emulated" + } + ] }, { "id": "T-110", "scope": "real free queries, existing task get, asset download", - "result": "partial", - "detail": "one real unauthenticated GET of the public animation catalog (api.meshy.ai/web/public/animations/resources?category=DailyActions, free) on e567646: exit 0; public unauthenticated GET of the animation catalog; result keys: items,count,fetched,total,filters,search_scope,source,authenticated,saved_json; matched=8; total=157. Authenticated get/download: not_run (no credential)" + "result": "passed", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "new tasks created and fetched under both credentials: text-to-3d preview (20) → refine (10, glb/obj/fbx + 4 textures; real MTL relinked by source name, material_links complete), stream over real SSE (30 progress events + outcome, contiguous), wait -o into a project, selective download (--model-format obj with dependencies, --asset thumbnail --output), --list, list, delete → not_found; image-to-3d from a local PNG (data URI): first task FAILED server-side (0 credits, task_failed relayed, FAILED recorded in the project), retry with a real thumbnail SUCCEEDED (30); public animation catalog GET", + "steps": [ + "T110-01", + "T110-02", + "T110-03", + "T110-04", + "T110-05", + "T110-06", + "T110-07", + "T110-08", + "T110-09", + "T110-10", + "T110-11", + "T110-12", + "T110-13", + "T110-14", + "T110-15" + ] }, { "id": "T-111", "scope": "UV Unwrap / Creative Lab / showcases smoke", - "result": "not_run", - "reason": "billable or account-gated; no budget or account authorised" + "result": "passed", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "uv-unwrap: 400 on a 1.9M-face model mapped to validation/4, SUCCEEDED on the remeshed model (5); remesh (5), retexture (10), analyze-printability (0, report), text-to-motion (10), rigging (400/422 on the teapot → validation/4; SUCCEEDED on a remeshed humanoid, 5), animate action 28 (3), make two-step chain (30); Creative Lab: lamp prototype (30) + build (6), keychain prototype (6) + glb build (30) + obj-bundle build (30), figure prototype (6) + build (first attempt FAILED server-side with 0 credits, retry 30), fridge-magnet prototype + build (36). Two defects found and fixed live: L01 (null finished_at rejected → get/wait failed while IN_PROGRESS) and L02 (legacy -o mis-named lamp parts and the keychain bundle). showcases: 403 'only available for enterprise users' for this account — recorded, not enterprise", + "steps": [ + "T111-01", + "T111-02", + "T111-03", + "T111-04", + "T111-05", + "T111-06", + "T111-07", + "T111-08", + "T111-09", + "T111-10", + "T111-11", + "T111-12", + "T111-13", + "T111-14", + "T111-15", + "T111-16", + "T111-17", + "T111-18", + "T111-19", + "T111-20", + "T111-21", + "T111-22" + ], + "fixes": [ + "L01 (D-059)", + "L02 (D-060)" + ] }, { "id": "T-112", "scope": "real slicer open", - "result": "not_run", - "reason": "no slicer application installed on this host (slicer detect returned an empty list)" + "result": "passed", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", + "detail": "Bambu Studio 02.08.02.61 on macOS 26.6.2 detected at /Applications/BambuStudio.app; slicer open launched it (open -a, pid observed) on a fixture print OBJ and again on mesh prepare-print output of the real remeshed teapot (60 mm) with its copied MTL/texture; no print job sent", + "steps": [ + "T112-01", + "T112-02" + ] } ], - "code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", - "recorded_at": "2026-09-08T03:47:45.510877Z", - "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1…6 notes name the Codex finding and probe the file now covers.", + "code_head": "1d9f10976b5f754502c81591c64c712e492188d1", + "recorded_at": "2026-09-08T06:09:04.640614Z", + "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1…6 notes name the Codex finding and probe the file now covers; live_fix notes name the defect found in the real-account verification.", "package_smoke": { "tarball": "meshy-cli-0.3.0.tgz", - "sha256": "bd3d39e71cfd41f7b19a5fe0b4e71805ed1634e8e5e3afb88ca5aa3242f7cb98", + "sha256": "0d22647fd0568a7c6b44aba80ceb32a5de74781b6697d44986745ee78ddf9c8e", "file_count": 398, "required_present": true, "forbidden_absent": true, @@ -853,7 +954,7 @@ { "id": "version", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --version", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -862,7 +963,7 @@ { "id": "version_cli", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --version", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -871,7 +972,7 @@ { "id": "help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy --help", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -880,7 +981,7 @@ { "id": "help_cli", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy-cli --help", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -889,7 +990,7 @@ { "id": "balance_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --help", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -898,7 +999,7 @@ { "id": "balance_nokey_json", "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -907,7 +1008,7 @@ { "id": "balance_nokey_v1", "command": "env MESHY_API_KEY= /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -916,7 +1017,7 @@ { "id": "resources_v1", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy resources --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -925,7 +1026,7 @@ { "id": "doctor", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -934,7 +1035,7 @@ { "id": "doctor_check_slicers", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-slicers --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -943,7 +1044,7 @@ { "id": "doctor_check_api_nokey", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy doctor --check-api --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 3, "expected_exit": 3, "result": "passed", @@ -952,7 +1053,7 @@ { "id": "slicer_detect", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy slicer detect --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -961,7 +1062,7 @@ { "id": "inspect_pass", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 300000 --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -970,7 +1071,7 @@ { "id": "inspect_fail", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-rigging.synthetic.json --max-faces 249999 --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 12, "expected_exit": 12, "result": "passed", @@ -979,7 +1080,7 @@ { "id": "inspect_unknown", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy inspect faces --task-json task-unknown-faces.json --max-faces 300000 --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 13, "expected_exit": 13, "result": "passed", @@ -988,7 +1089,7 @@ { "id": "mesh_prepare", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -997,7 +1098,7 @@ { "id": "mesh_refuse_overwrite", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy mesh prepare-print box-y-up.obj --height-mm 80 --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 11, "expected_exit": 11, "result": "passed", @@ -1006,7 +1107,7 @@ { "id": "project_init", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project init --root ./meshy_output --name smoke demo --task-id fixture-rig-1 --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1014,8 +1115,8 @@ }, { "id": "project_record", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_114525_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project record --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_140008_smoke-demo_fixture- --task-id fixture-rig-1 --resource rigging --stage rigged --file rigged.glb --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1023,8 +1124,8 @@ }, { "id": "project_show", - "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_114525_smoke-demo_fixture- --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project show --project /private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/work/meshy_output/20260908_140008_smoke-demo_fixture- --output-schema v1", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1033,7 +1134,7 @@ { "id": "project_list", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy project list --root ./meshy_output --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1042,7 +1143,7 @@ { "id": "download_list", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --list --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1051,7 +1152,7 @@ { "id": "download_no_selector", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy download --task-json task-rigging.synthetic.json --output-dir ./dl --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -1060,7 +1161,7 @@ { "id": "make_dry_run", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy make a red sports car --dry-run --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1069,7 +1170,7 @@ { "id": "usage_unknown_flag", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy balance --bogus --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 2, "expected_exit": 2, "result": "passed", @@ -1078,7 +1179,7 @@ { "id": "uv_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy uv-unwrap create --help", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1087,7 +1188,7 @@ { "id": "creative_lab_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy creative-lab lamp build create --help", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1096,7 +1197,7 @@ { "id": "stream_help", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy text-to-3d stream --help", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -1105,7 +1206,7 @@ { "id": "catalog_live", "command": "/private/tmp/claude-502/-Users-ark-Dev/005e8b84-02bd-4644-8c71-c3b60ab1d28e/scratchpad/verify-r6/prefix/bin/meshy animation-catalog list --category DailyActions --search wave --output-schema v1", - "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "sha": "1d9f10976b5f754502c81591c64c712e492188d1", "exit_code": 0, "expected_exit": 0, "result": "passed", @@ -2238,5 +2339,91 @@ "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" } } - ] + ], + "live_verification_run": { + "date": "2026-09-08", + "code_head_under_test": { + "before_live_fixes": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "live_fixes": "1d9f10976b5f754502c81591c64c712e492188d1", + "final": "1d9f10976b5f754502c81591c64c712e492188d1" + }, + "credits": { + "start": 2806, + "end": 2479, + "after_oauth_refresh_check": 2515, + "source": "meshy balance --output-schema v1 before the first live step (auth status shown by the owner) and after the last one (live/balance-final.json)", + "consumed": 327 + }, + "record": "docs/skill-parity/live-verification.json", + "fixes": [ + { + "id": "L01", + "decision": "D-059", + "commit": "1d9f10976b5f754502c81591c64c712e492188d1", + "test": "tests/live-verification.test.ts", + "summary": "Creative Lab in-progress bodies carry finished_at: null; schema now reads null timestamps/counts as 0; verified live afterwards" + }, + { + "id": "L02", + "decision": "D-060", + "commit": "1d9f10976b5f754502c81591c64c712e492188d1", + "test": "tests/live-verification.test.ts", + "summary": "legacy -o names Creative Lab parts/bundles via the shared modelAsset mapping (lamp.stl, base.stl, model.obj.zip); verified live afterwards" + } + ], + "observations_not_fixed": [ + { + "id": "OBS-1", + "severity": "P3", + "text": "403 'enterprise only' from showcases maps to error.code server / exit 1; a dedicated permission code (or auth) may be clearer" + }, + { + "id": "OBS-2", + "severity": "P3", + "text": "downloaded asset files are published mode 0600 while rewritten MTL and JSON sidecars are 0644" + }, + { + "id": "OBS-3", + "severity": "P3", + "text": "task verbs' -o downloads are not recorded as files in the project entry (recorded only by meshy download --project); attachToProject.extra.files has no caller" + }, + { + "id": "OBS-4", + "severity": "P3", + "text": "download --list on a FAILED task reports downloads.state not_ready with ok:true (a terminal failure reads as 'not yet')" + }, + { + "id": "OBS-5", + "severity": "P3", + "text": "auth status/list/use ignore --output-schema v1 (legacy shape only)" + }, + { + "id": "OBS-6", + "severity": "P3", + "text": "real task JSON carries no face_count, so inspect faces from a task JSON always ends in check_unknown (13) — by design, but worth stating in docs" + }, + { + "id": "OBS-7", + "severity": "P3", + "text": "text-to-motion requires --duration client-side (2–10 s, 0.5 steps); confirm against the API default" + } + ], + "reviewer_script_reruns_on_this_head": { + "round1": 0, + "round2": 0, + "round3": 0, + "round4": 0, + "round5": 0, + "round6": 0, + "verify_original": "20/20", + "verify_round3_4": "8/8", + "verify_round5": "24/24", + "verify_round6": "5/5", + "material_matrix": "16/16", + "round6_material": "10/10", + "project_entries": "20/20", + "context": "5/5", + "stream": true + } + } } From 8b812797e7bc2b78485eea6e241cdde72667f830 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 8 Sep 2026 16:34:23 +0800 Subject: [PATCH 26/26] docs(skill-parity): record Codex round 8 acceptance and the authorised release path Codex round 8 (reviews/cli-s1-1d9f109) accepted the live fixes L01/L02 with no findings; rounds 7 and 8 are now listed in review_rounds. The owner authorised the release on 2026-09-08: the branch goes to main through a pull request, publishing runs through .github/workflows/release.yml, and Windows x64 stays not_run by their decision. G1-release remains pending until the publish is verified and recorded. Co-Authored-By: Claude Fable 5.1 --- docs/skill-parity/REVIEW_HANDOFF.md | 8 +++--- docs/skill-parity/capability-matrix.json | 34 +++++++++++++++++++++--- docs/skill-parity/verification.json | 33 ++++++++++++++++++++--- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/docs/skill-parity/REVIEW_HANDOFF.md b/docs/skill-parity/REVIEW_HANDOFF.md index 076d50f..b1fd88c 100644 --- a/docs/skill-parity/REVIEW_HANDOFF.md +++ b/docs/skill-parity/REVIEW_HANDOFF.md @@ -1,6 +1,6 @@ # Meshy CLI S1 Review Handoff -Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. 本文件为 **Round 8**:第 7 轮 Codex review 已 **accepted**(G1-code,代码 `e567646`,docs `4da216d`,证据 `…/reviews/cli-s1-e567646/`);本轮是 **真实账号 live verification** 及其发现的 2 个缺陷的修复,交给 Codex 复审这两个修复并核对 live 证据。七处 reviewer 证据目录均未被改动;第 7 轮的 15 个脚本副本在新 HEAD 上重跑。 +Filled from the 2026-09-07 / v1 implementation package. `not_run` means not verified. **第 8 轮 Codex 结论(2026-09-08):accepted** — L01/L02 关闭,前七轮 32 项 finding 在 `1d9f109` 未回退,G1-code 继续接受;证据 `…/reviews/cli-s1-1d9f109/`(0 findings)。随后所有者授权:分支经 PR 合并、通过 `release.yml` 发布 0.3.0,Windows x64 本次不验证;发布结果在发布后的 docs commit 中记录。本文件为 **Round 8**:第 7 轮 Codex review 已 **accepted**(G1-code,代码 `e567646`,docs `4da216d`,证据 `…/reviews/cli-s1-e567646/`);本轮是 **真实账号 live verification** 及其发现的 2 个缺陷的修复,交给 Codex 复审这两个修复并核对 live 证据。七处 reviewer 证据目录均未被改动;第 7 轮的 15 个脚本副本在新 HEAD 上重跑。 ## 0. Live verification 结论与本轮修复 @@ -25,12 +25,12 @@ Filled from the 2026-09-07 / v1 implementation package. `not_run` means not veri - 本轮代码 HEAD:`1d9f10976b5f754502c81591c64c712e492188d1`(= live 修复 commit);本文件与 verification.json / capability-matrix.json / live-verification.json 在其后的 **docs-only commit** 中 - 工作区是否还有未提交修改:无(docs commit 之后 `git status` 干净) - Node / pnpm / OS:Node v24.20.0 / pnpm 11.24.0 / macOS 26.6.2 arm64 -- PR / 发布:未创建、未发布(需授权) +- PR / 发布:所有者 2026-09-08 授权后,本 commit 之后推送分支并创建 PR 合入 `main`;发布经 `.github/workflows/release.yml`(workflow_dispatch,main)执行,不做本地 `npm publish`;结果见发布后的 docs commit ## 2. 完成状态 -- G1-code:第 7 轮 accepted;本轮新增 2 个 live 修复待 Codex 复审(范围小:`src/client/types.ts` 6 个字段的 null 容忍;`src/internal/download.ts`+`artifacts.ts` 的文件名映射共享)。 -- G1-release:**尚未满足**。已完成:Codex review(第 7 轮)、真实账号/UV/Creative Lab/showcases/切片器/macOS+Linux 验证;未完成:Windows x64(无主机,not_run)、正式发布(未授权)、本轮两个修复的复审。 +- G1-code:第 7 轮 accepted;第 8 轮 accepted(本轮 2 个 live 修复 L01/L02 已关闭,范围小:`src/client/types.ts` 6 个字段的 null 容忍;`src/internal/download.ts`+`artifacts.ts` 的文件名映射共享)。 +- G1-release:**尚未满足(发布进行中)**。已完成:Codex review(第 7、8 轮 accepted)、真实账号/UV/Creative Lab/showcases/切片器/macOS+Linux 验证、所有者发布授权(2026-09-08);未完成:正式发布与可追溯记录(PR 合并后经 release.yml 发布 0.3.0,随后记录);Windows x64 由所有者决定本次不验证,保持 not_run。 - mandatory 能力 35/35 已实现;`capability-matrix.json` 每项新增 `live_verification`(33 项 live passed / passed_after_live_fixes,CAP-014 showcases 为账号门控 403 记录,CAP-034 打包为 partial(Windows not_run))。 - 离线测试:`pnpm test` 558/558,0 跳过。 diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index 5e0a821..b19a037 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -2,7 +2,7 @@ "schema_version": 1, "title": "Meshy CLI S1 capability matrix — implementation tracking", "date": "2026-09-08", - "status": "G1-code accepted (Codex round 7, e567646); live verification on the owner's real account done 2026-09-08 — every mandatory capability exercised live except Windows install (not_run) and enterprise showcases content (account-gated 403 recorded); two live defects fixed in 1d9f109 (L01/L02) awaiting Codex review; release not performed → G1-release not yet satisfied", + "status": "G1-code accepted (Codex round 7 on e567646, round 8 on 1d9f109 — live fixes L01/L02 closed, 0 findings); live verification on the owner's real account done 2026-09-08 — every mandatory capability exercised live except Windows install (not_run, skipped by the owner's decision) and enterprise showcases content (account-gated 403 recorded); release authorised 2026-09-08, publish via release.yml after the PR merge → G1-release recorded once the publish is verified", "baseline": { "cli_sha": "fd94490916376e691efcea51324ac4326b459e1f", "skills_sha": "b9db44b5663e6e92d89828bf2e4fe1dc1b3f6610" @@ -1898,7 +1898,8 @@ "T-109 linux arm64/x64 29/29", "macOS 29/29" ], - "record": "docs/skill-parity/live-verification.json" + "record": "docs/skill-parity/live-verification.json", + "note": "Windows x64 not_run: skipped for this release by the owner's decision (2026-09-08); macOS arm64 and Linux arm64/x64 passed" } }, { @@ -1951,7 +1952,7 @@ "branch": "feat/skill-parity-s1", "head_at_start": "fd94490916376e691efcea51324ac4326b459e1f", "package_version_at_start": "0.2.0", - "updated_at": "2026-09-08T06:09:04.640614Z", + "updated_at": "2026-09-08T08:34:23.922875Z", "code_head": "1d9f10976b5f754502c81591c64c712e492188d1" }, "review_rounds": [ @@ -2062,6 +2063,26 @@ "code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", "regression_tests": "tests/codex-review-round6.test.ts (R6-F01 4 download scenarios + control; R6-F02 20-entry mutable-workspace matrix + stable-alias control); codex-review-round5 E03 exact sequences and E02 line-bound candidates (R6-T01)", "probe_rerun": "reviews/cli-s1-7b7c24c copies on e567646: round6 0/4 reproduce (all exit 11, outside bytes unchanged); round5 0/4; round4 0/3 (D03 positive); round3 0/6; round2 0/8; round1 0/12; verify-original 20/20; verify-round3-4 8/8; verify-round5-positive 24/24; material matrix 16/16; round6 material 10/10; project entries 20/20; context 5/5; stream passed" + }, + { + "round": 7, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-e567646", + "reviewed_head": "4da216d3568fbd997bf85f8047ce3932672a25de", + "reviewed_code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "verdict": "accepted", + "findings": [], + "note": "R6-F01/R6-F02/R6-T01 closed; 32 findings from rounds 1-6 not regressed; G1-code accepted" + }, + { + "round": 8, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-1d9f109", + "reviewed_head": "c70b06cc3509b6e36b18a51783b368d816c964b4", + "reviewed_code_head": "1d9f10976b5f754502c81591c64c712e492188d1", + "verdict": "accepted", + "findings": [], + "note": "live fixes L01/L02 closed; 32 prior findings not regressed on 1d9f109; 255 prior evidence files unchanged; OBS-1..7 judged non-blocking P3 observations; G1-code continues accepted" } ], "live_verification_run": { @@ -2149,5 +2170,12 @@ "context": "5/5", "stream": true } + }, + "release": { + "authorised_by_owner_on": "2026-09-08", + "integration": "feat/skill-parity-s1 → main via pull request (owner's decision); merge commit, as for the previous releases", + "mechanism": ".github/workflows/release.yml (workflow_dispatch, run on main after the merge) publishes meshy-cli and the scoped alias @meshy-ai/cli at package.json version 0.3.0 with the repository's NPM_TOKEN; no local npm publish", + "windows_x64": "not_run — the owner decided on 2026-09-08 to skip it for this release (no host)", + "status": "pending — recorded with the published version, tag and registry digest in a docs commit after the publish is verified" } } diff --git a/docs/skill-parity/verification.json b/docs/skill-parity/verification.json index 46bbb97..9e0691f 100644 --- a/docs/skill-parity/verification.json +++ b/docs/skill-parity/verification.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "status": "G1-code accepted by Codex round 7 (e567646); live verification against the owner's real account completed on 2026-09-08 with two defects found and fixed (L01, L02, commit 1d9f109); Windows x64 not_run; release not performed; live fixes await Codex review", + "status": "G1-code accepted by Codex rounds 7 (e567646) and 8 (1d9f109: live fixes L01/L02 closed); live verification against the owner's real account completed 2026-09-08; Windows x64 not_run (skipped by the owner's decision); release authorised 2026-09-08 and in progress via release.yml — G1-release is recorded only after the publish is verified", "rules": "Every entry binds a command or test id to the code SHA it ran against. not_run is not passed. Entries are replaced when re-run on a newer HEAD.", "environment": { "os": "macOS 26.6.2 (Darwin 25.6.0) arm64", @@ -838,7 +838,7 @@ "scope": "macOS arm64 / Windows x64 / Linux x64 install and local commands", "result": "partial", "sha": "1d9f10976b5f754502c81591c64c712e492188d1", - "detail": "macOS 26.6.2 arm64 Node v24.20.0: tarball install + 29 smoke checks passed; Linux (OrbStack docker node:24-bookworm): linux/arm64 Debian GNU/Linux 12 (bookworm) node v24.20.0 29/29 sharp 0.35.4 (libvips 8.18.6) (OrbStack docker, node:24-bookworm, native); linux/x64 Debian GNU/Linux 12 (bookworm) node v24.20.0 29/29 sharp 0.35.4 (libvips 8.18.6) (OrbStack docker, node:24-bookworm, linux/amd64 emulated); the owner also installed the same tarball globally (npm install -g ~/Downloads/meshy-cli-0.3.0.tgz) and ran every live command through that install. Windows x64: not_run (no host)", + "detail": "macOS 26.6.2 arm64 Node v24.20.0: tarball install + 29 smoke checks passed; Linux (OrbStack docker node:24-bookworm): linux/arm64 Debian GNU/Linux 12 (bookworm) node v24.20.0 29/29 sharp 0.35.4 (libvips 8.18.6) (OrbStack docker, node:24-bookworm, native); linux/x64 Debian GNU/Linux 12 (bookworm) node v24.20.0 29/29 sharp 0.35.4 (libvips 8.18.6) (OrbStack docker, node:24-bookworm, linux/amd64 emulated); the owner also installed the same tarball globally (npm install -g ~/Downloads/meshy-cli-0.3.0.tgz) and ran every live command through that install. Windows x64: not_run (no host; the owner decided on 2026-09-08 to skip it for this release)", "linux": [ { "platform": "linux/arm64", @@ -936,7 +936,7 @@ } ], "code_head": "1d9f10976b5f754502c81591c64c712e492188d1", - "recorded_at": "2026-09-08T06:09:04.640614Z", + "recorded_at": "2026-09-08T08:34:23.922875Z", "behavior_test_note": "Every T-id below is asserted by node:test files that run offline (loopback HTTP mocks, temp dirs). covered_offline_only marks ids whose live/OS/GUI part remains not_run. review_round_1…6 notes name the Codex finding and probe the file now covers; live_fix notes name the defect found in the real-account verification.", "package_smoke": { "tarball": "meshy-cli-0.3.0.tgz", @@ -2338,6 +2338,26 @@ "passed": true, "sha": "e567646d875e5f5a658b78e60b8cdfaed8b233e9" } + }, + { + "round": 7, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-e567646", + "reviewed_head": "4da216d3568fbd997bf85f8047ce3932672a25de", + "reviewed_code_head": "e567646d875e5f5a658b78e60b8cdfaed8b233e9", + "verdict": "accepted", + "findings": [], + "note": "R6-F01/R6-F02/R6-T01 closed; 32 findings from rounds 1-6 not regressed; G1-code accepted" + }, + { + "round": 8, + "reviewer": "Codex", + "review_dir": "meshy-agent-integrations-research/reviews/cli-s1-1d9f109", + "reviewed_head": "c70b06cc3509b6e36b18a51783b368d816c964b4", + "reviewed_code_head": "1d9f10976b5f754502c81591c64c712e492188d1", + "verdict": "accepted", + "findings": [], + "note": "live fixes L01/L02 closed; 32 prior findings not regressed on 1d9f109; 255 prior evidence files unchanged; OBS-1..7 judged non-blocking P3 observations; G1-code continues accepted" } ], "live_verification_run": { @@ -2425,5 +2445,12 @@ "context": "5/5", "stream": true } + }, + "release": { + "authorised_by_owner_on": "2026-09-08", + "integration": "feat/skill-parity-s1 → main via pull request (owner's decision); merge commit, as for the previous releases", + "mechanism": ".github/workflows/release.yml (workflow_dispatch, run on main after the merge) publishes meshy-cli and the scoped alias @meshy-ai/cli at package.json version 0.3.0 with the repository's NPM_TOKEN; no local npm publish", + "windows_x64": "not_run — the owner decided on 2026-09-08 to skip it for this release (no host)", + "status": "pending — recorded with the published version, tag and registry digest in a docs commit after the publish is verified" } }