merge: require cover for first project submission

This commit is contained in:
2026-08-17 22:08:00 +08:00
12 changed files with 279 additions and 129 deletions

View File

@@ -10,7 +10,7 @@
- Electron 双视口预检必须检查与最终 `built_archive` 相同的内存文件字节;预检失败不得上传,预检成功不得被表述为可信审核凭据。
- 上传协议必须同时携带源码归档、构建归档和严格版本化 artifact contract服务端独立重算摘要、校验合同并固化不可变 Release。
- Renderer 不得获得发布凭据、归档、临时目录、构建 origin 或任意本地路径;旧客户端和旧 sandbox/browser 任务必须提示升级后重新构建提交。
- Works Project 在没有 metadata revision/条件写与 cover 回收合同的阶段,首次 create 只能原子保存文字资料且不上传封面;已有项目只能 version-only。客户端不得通过无条件 PATCH、先上传封面或吞掉并发冲突来模拟原子性
- Works Project 首次 create 必须用单一 multipart 合同原子提交文字资料与必选的 PNG/JPEG/WebP 封面;创建失败或冲突不得继续上传版本。已有项目保持 version-only;在没有 metadata revision/条件写前,客户端不得通过无条件 PATCH 修改已有资料
- Learning 课程目录、生成、下载、Agent、ASR 与 classroom runtime 必须保持 Main-ownedRenderer 不得获得 Works Token、Provider/模型配置、本地 archive 路径或任意网络代理能力。
- Learning 课程包必须匹配声明大小与 SHA-256 后原子安装并在播放前复验;媒体必须是 MIME/扩展匹配的被动图片、音视频或字体,可执行同源文档在安装和服务两层拒绝。大课模块必须绑定同一 aggregate 课程身份,课堂联网只允许固定 capability、方法和有界请求/响应。
- Learning 本地库、下载、播放器注册和 IPC 结果必须绑定 Electron Main 从当前登录身份派生的不透明账号分区;切换账号、注销或会话 epoch 变化后,旧账号文件、播放器 URL 和迟到结果不得继续可见。

View File

@@ -4,6 +4,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
## Integrated Through
- Project-cover source commit `145a6ce571d646325092d1e722282babea503954` from feature task `20260817-project-cover-upload-a6a98e56`, integrated by task `20260813-sync-push-main-9c2f71`. First submission now requires a bounded PNG/JPEG/WebP cover, shows preview/file/reselect feedback, and sends metadata plus cover through Main-owned `POST /api/projects/with-cover`; conflicts stop before version upload and existing draft/published projects remain version-only. The matching Works Square server source is `407c883` (local merge `0cedfc4`). No client package, production deployment, or real-account smoke occurred.
- `3b37ac3` / `55e61b7`: macOS Robot hotspot discovery performs one bounded worker-thread rescan after an empty or SSID-redacted CoreWLAN result; persistent SSID redaction maps to the existing permission error instead of a misleading empty-device state, while firmware and the open `Xiaozhi-*` contract remain unchanged.
- `f5d47c8` / `b6148a5`: AI Programming voice capture is available after an Agent is selected but before the lazy first OpenCode session exists; transcription fills the composer draft without creating an empty session, while runtime, loading, transcribing, busy, and recording guards remain unchanged.
- `4013edc` / `3b799af`: integrated per-user Code/Canvas/Learning/Robot entry policy from Works Square, projected by Electron Main as four booleans and enforced before disabled module routes initialize.
@@ -49,9 +50,10 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
## Recently Completed
- 2026-08-17: Replaced the temporary coverless-first-create fallback with a required PNG/JPEG/WebP picker, preview, file name, reselect action, Renderer/Main signature and size validation, and one Main-owned multipart metadata-plus-cover create request. Create conflicts fail before version upload; existing project metadata and covers remain unchanged.
- 2026-08-17: Corrected macOS Robot hotspot discovery after a system-visible `Xiaozhi-*` report. CoreWLAN now gets one bounded retry when its first result is empty or all SSIDs are unavailable; a persistent non-empty redacted result becomes the existing safe permission state. Open-only filtering, firmware, Host/Renderer contracts, exact-current-SSID verification, and the system-Wi-Fi fallback are unchanged; signed-package physical smoke remains pending.
- 2026-08-17: Created merge commit `4013edc` for the reviewed per-user module-entry policy source tip `3b799af`. Main exposes only four booleans from `/api/auth/me`; missing fields remain enabled, `design` maps to `painting`, disabled root/deep/alias routes stop before module initialization, Code provider startup waits for policy hydration, terminal `401` clears both session layers, and global settings remains reachable.
- 2026-08-17: Integrated remote `01bee31`: Learning is enabled with course browsing, strict bounded generation materials, verified atomic course installation, multi-module playback, Main-owned Agent/ASR/runtime bridges, and a manifest-verified external OpenMAIC player artifact. Merge review added account-isolated generation/library/player state, fixed-binding token/fetch/401 guards, passive-only course media with hardened responses, pre-existing active-registration checks before side-effect-free identity resolution, nonce-protected single-document player sessions, and a recoverable deep-link profile error gate. Publishing now reflects the actual Works contract: first create is coverless, existing draft/published are version-only, and races fail closed without cover/PATCH side effects. The transient `game-engine` Skill was removed and `planning-with-files` writes its files to the project root. Production Works/player-artifact/signed-package acceptance remains pending.
- 2026-08-17: Integrated remote `01bee31`: Learning is enabled with course browsing, strict bounded generation materials, verified atomic course installation, multi-module playback, Main-owned Agent/ASR/runtime bridges, and a manifest-verified external OpenMAIC player artifact. Merge review added account-isolated generation/library/player state, fixed-binding token/fetch/401 guards, passive-only course media with hardened responses, pre-existing active-registration checks before side-effect-free identity resolution, nonce-protected single-document player sessions, and a recoverable deep-link profile error gate. At that integration checkpoint, publishing used a coverless first create, existing draft/published were version-only, and races failed closed without cover/PATCH side effects; project-cover source `145a6ce` and matching server merge `0cedfc4` above supersede only the coverless-first-create limitation. The transient `game-engine` Skill was removed and `planning-with-files` writes its files to the project root. Production Works/player-artifact/signed-package acceptance remains pending.
- 2026-08-16: Integrated remote `26b52d7`: Canvas now has server-backed Prompt Museum navigation, editable server-repriced generation Quotes, task result details/downloads, guarded project deletion, cloud-default development, and Chinese-only UI. That tip briefly bundled `game-engine`; authoritative successor `01bee31` removed it. Client integration is verified separately from production Prompt Museum data/backend deployment.
- 2026-08-16: Integrated Windows/macOS in-page Robot hotspot discovery, explicit selection, connection, and exact-current-SSID verification behind the existing default-on guided capability. Candidate IDs are bounded and short-lived, native diagnostics stay in Main, system settings remain fallback, and firmware/Portal/Binding contracts are unchanged.
- 2026-08-16: Enabled the existing Guided Hotspot Binding journey by default after explicit product confirmation. Exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` remains the operational rollback; fixed portal ownership, direct-code fallback, security warnings, firmware-zero-change, and Binding-without-online semantics are preserved.
@@ -91,7 +93,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
- 四模块权限只控制 Makelore 客户端入口和初始化,不是 API 授权边界。不得因卡片置灰或路由阻断而放宽 Works/模块服务端的身份与权限校验;旧服务端缺少对象/字段时默认开启是显式兼容策略。
- Learning 的课程目录、生成、Agent、ASR 与 runtime 都依赖真实 Works 权益和服务端契约;本地课程归档与播放器 artifact 必须在信任前完成边界、大小与摘要校验。账号分区/epoch、fixed-binding token+fetch guards、同源重定向、512 MiB 上限、player nonce、exact source/origin 与单文档 bridge 边界不可放宽;不得把模块/场景自报身份当成 aggregate 课程权益也不得把上游错误、Token、内部 URL 或本地归档路径投影到 Renderer。
- Works Project 服务当前没有 metadata revision/ETagdraft-only 条件写或封面删除/原子绑定合同;首次发布暂不上传封面,已有 draft/published 只允许 version-only。恢复封面或已有资料编辑前必须先扩展并真实验证服务端原子合同,客户端不得以无条件 PATCH 或孤立上传替代。
- Works Project 首次封面已由服务端源 `407c883`(本地 merge `0cedfc4`)提供单请求原子绑定与失败补偿,客户端源 `145a6ce` 因此要求首次发布上传 PNG/JPEG/WebP 封面;部署、安装包和真实账号/对象存储 smoke 仍未完成。服务端仍没有已有 metadata revision/ETagdraft-only 条件写,因此已有 draft/published 继续只允许 version-only客户端不得以无条件 PATCH 替代。
- Guided Hotspot Binding 默认开启并提供未经认证的热点扫描/显式连接,但当前 Hotspot/portal 仍是开放 SoftAP + 明文 HTTP且精确出货镜像、激活码发行契约、签名 macOS、Windows 真机与完整整链尚未验证。界面必须保留环境警告,异常发布可用精确环境值 `0` 回滚;不得把 SSID 前缀宣称为可信设备发现、自动认领或在线证明。
- 一键提交已成功但本机 submission binding 落盘失败时必须保持提交成功、显示固定 `binding_warning` 并继续轮询,避免用户误判上传失败。
- 公共 `play_url` 必须满足 Works Square 同源 HTTPS、无 userinfo/loopback、精确 `/apps/{app_id}/` 路径、无 query/fragment、版本非空且上游标记可播放。

View File

@@ -309,6 +309,10 @@ Gate result:
## Outcome
- Prepared a normal no-ff merge of project-cover source `145a6ce571d646325092d1e722282babea503954` onto local `main` `da376b0bf37cdd0b5eabe7efc9f1141ae5645411` with no textual conflicts; the source feature task record remains only on the source branch.
- Reconciled the obsolete coverless-first-create rule into required first-cover atomic submission while retaining existing-project version-only behavior and the unfulfilled conditional metadata-editing commitment.
- Final project-cover merge commit is pending independent read-only review.
- Created local main merge commit `3b37ac318782de5ee6f3338c9744ec66df14f674` with exact parents `7e8d9e38114158992c03e589de32535274796d04` and reviewed source `55e61b7d8f9caccdad54dc9a42092fc7960fb394`. The source is now a `main` ancestor, and its task record remains absent from the integrated tree.
- On 2026-08-17, staged a normal no-ff merge of reviewed macOS Robot hotspot source `55e61b7` onto clean local `main` at `7e8d9e3`; Git reported no textual conflicts. The source task record remains reachable on its source commit/branch and is excluded from the integrated tree.
- The staged tree performs exactly one 250 ms worker-thread rescan when the first macOS CoreWLAN result is empty or every returned SSID is unavailable. A recovered candidate continues through the unchanged open/printable `Xiaozhi-*` Module filter; two empty scans remain an empty result; a second non-empty fully redacted result becomes the existing fixed permission error.
@@ -326,7 +330,7 @@ Gate result:
- Added a real Electron Learning navigation smoke and corrected three OpenCode slash-command E2E setup races by entering Code through the module chooser after setup. Production behavior was unchanged by the OpenCode test correction.
- Fixed the repository E2E script to invoke the declared `@playwright/test@1.59.0` CLI directly. The prior `pnpm playwright test` path selected a transitive alpha `playwright` CLI from `@playwright/mcp`, producing a false version-mismatch failure.
- The first independent final Learning merge review returned `FAIL` on four P1, three P2 and one P3 findings: Renderer-self-asserted course identity, account-switch token TOCTOU, old player/progress/form reuse, same-origin navigation bridge recovery, overlay-only profile gating, non-authoritative published status, premature documentation claims, and direct Renderer event IPC. The merge now revalidates installed/registered course identity in Main, threads captured binding guards through token/fetch/401 boundaries, closes and rotates player sessions, partitions/clears Renderer state, permanently disables a navigated iframe bridge, blocks Learning Outlet execution until profile readiness, re-reads strict project status after 409, corrects canonical nonce wording, and routes events through an allowlisted API-client seam.
- The second independent Learning merge review returned `FAIL` on two P1 and four P2 findings: Agent/runtime could register the requested course before checking active-player identity; executable same-origin course assets could message before a second iframe load; profile sync errors could leave an empty deep link; Works cover/metadata lacked atomic concurrency; material-generation IPC was not fully strict; and canonical evidence overclaimed closure. The final code separates side-effect-free `resolveClassroom` from explicit player registration, validates pre-existing active registration before Agent/runtime resolution, restricts course media to exact passive MIME/extension pairs with nosniff/sandbox CSP, renders a retryable profile error gate, strictly projects generation upload DTOs before auth/network, and adopts an honest coverless-first-create / existing-version-only Works workflow whose races fail closed without cover or PATCH side effects.
- The second independent Learning merge review returned `FAIL` on two P1 and four P2 findings: Agent/runtime could register the requested course before checking active-player identity; executable same-origin course assets could message before a second iframe load; profile sync errors could leave an empty deep link; Works cover/metadata lacked atomic concurrency; material-generation IPC was not fully strict; and canonical evidence overclaimed closure. The final code separates side-effect-free `resolveClassroom` from explicit player registration, validates pre-existing active registration before Agent/runtime resolution, restricts course media to exact passive MIME/extension pairs with nosniff/sandbox CSP, renders a retryable profile error gate, strictly projects generation upload DTOs before auth/network, and at that checkpoint adopted a coverless-first-create / existing-version-only Works workflow whose races failed closed without cover or PATCH side effects. The current project-cover resumption supersedes only that coverless-first-create checkpoint.
- The third independent Learning merge review returned `FAIL` on one P2 integration mismatch: the package consumer admitted `fonts/*`, while the player server only routed `audio|media`, so a declared playable font would install and then 404. Consumer and server now share the exact root/module `audio|media|fonts` directory contract and passive extension/MIME set; unsupported directories fail installation/HTTP lookup, and real registered ZIP-to-HTTP tests verify root WOFF and module WOFF2 with the expected MIME and security headers.
- The fourth independent Learning merge review returned `FAIL` on one remaining P2 composition bug: allowing a manifest-relative `modules/<id>/...` path caused the authoritative module root to be prepended twice. Manifest media paths now begin only with relative `audio|media|fonts`; the module prefix comes solely from `location.root`. A consumer-to-classroom-URL-to-registered-ZIP-to-real-HTTP test proves exactly one module prefix, correct WOFF2 bytes/MIME/security headers, and rejection of manifest-supplied `modules/` or other directories.
- The fifth and final independent Learning merge review returned `PASS` on Standards and Spec with no P0-P3 findings. It confirmed the consumer-generated modular font URL traverses the registered ZIP and real player HTTP response with one module prefix, and found no regression in registration, media safety, profile recovery, Works version-only behavior, generation DTOs, identity guards, canonical documents, packaging, Robot/Canvas, or the lock graph.
@@ -442,6 +446,10 @@ Gate result:
## Verification
- Project-cover source verification: 3 focused Vitest files / 76 tests, typecheck, scoped ESLint, Vite production build, focused Electron E2E 1/1, and cross-repository Sol review all passed.
- Project-cover merged tree: 3 focused Vitest files / 76 tests, `tsc --noEmit`, scoped ESLint, Renderer/Main/Preload Vite production build, and focused Electron E2E 1/1 all passed; build emitted only the existing dynamic-import and chunk-size warnings.
- `check_project_docs.py`, task-aware `check_doc_drift.py`, and `git diff --cached --check` passed for the prepared project-cover merge.
- Post-commit topology confirms merge `3b37ac3` has exact parents `7e8d9e3` and `55e61b7`, the source tip is a `main` ancestor, the source task record is absent from `main`, and the product worktree was clean before this evidence-only documentation update.
- 2026-08-17 independent final macOS Robot hotspot staged-merge review — Standards `PASS`, Spec `PASS`, overall `PASS`, with no P0-P3 findings. It confirmed exact staged topology/scope, source-record exclusion, bounded worker-only retry, retained 8s abort/termination, unchanged filtering/connect contracts, and honest signed-macOS physical-smoke residual risk.
- 2026-08-17 macOS Robot hotspot staged-merge regression — `tests/unit/robot-hotspot-module.test.ts` passed 14/14, covering first-scan recovery, persistent SSID redaction, bounded double-empty behavior, security filtering, RSSI/current-SSID projection, worker termination, candidate expiry, exclusivity, and exact-current-SSID verification.
@@ -589,7 +597,7 @@ Gate result:
- Before releasing the per-user module-entry policy, deploy the Works `module_access` migration and `/api/auth/me` contract, build and install a new Makelore package, then use a real account to disable Code, Canvas, Learning and Robot one at a time and smoke chooser/root/deep/alias behavior, global settings, terminal `401`, and independent server-side API authorization.
- Before releasing Learning, run a real Works account through catalog, generation/material/cancel-resume, bounded download, offline multi-module playback, progress, Agent, ASR and PBL/scoring using the exact production Stage artifact. Validate packaged loopback cookie/nonce behavior on Windows and a signed macOS build; current automation is not that acceptance.
- Before restoring project cover upload or editing metadata on an existing draft/published project, add and verify a server-owned revision/ETag plus draft-only conditional write and atomic cover attachment or cleanup. Until then the client intentionally creates new projects without a cover and treats existing projects as version-only.
- First-project cover upload is implemented by client source `145a6ce` and server source `407c883` / local merge `0cedfc4`; before release, deploy the server, package the client, and verify a real account/object-store flow including create conflict, cover failure, and version-upload blocking. Editing metadata on an existing draft/published project still requires a server-owned revision/ETag plus draft-only conditional write; until then existing projects remain version-only.
- Complete the visible Git Credential Manager/PowerShell authentication prompt, then fetch and verify `origin/main` equals the local tip before recording push completion and releasing this Integration task.
- Packaging audit follow-ups outside this merge remain: verify macOS/Linux OpenCode multi-architecture staging, remove any unsupported Windows ARM64 advertising, pin the uv installer by digest, and replace unauthenticated curl-style installer paths before those release lanes are trusted.
- Before releasing Prompt Museum and the expanded Canvas deletion/repricing workflow, use a real Works account to validate Museum list/detail/pagination/attribution/CDN content, latest Quote pricing/confirmation, project soft-delete visibility, queued reservation release, and running-task settlement. Client tests do not prove the content backend or production billing/deletion semantics are deployed.

View File

@@ -13,7 +13,7 @@
- 客户端预检是可绕过的 UX fail-fast不上传可信 receipt也不声称具备生产 opaque-origin parity。服务端把源码、构建归档和 contract 当作不可信字节,独立重算、校验并固化不可变 Release人工审核仍是不可绕过发布门禁。未来若要求 runtime 强门禁,必须由可信 verifier 绑定精确构建产物。
- 发布安全边界由 Electron Main 持有Renderer 不接触账号 Token、ZIP、幂等键和本地绝对路径。
- 发布 Host API 必须在读取凭据、查询项目和打包前校验 Renderer capability仅持有 Host token/base 的非 UI 调用方不得发起发布。
- 当前 Works Project 契约没有可验证的 metadata revision/ETag、draft-only 条件写或封面删除/原子绑定能力。首次发布只允许在 project create 中原子保存文字资料并暂不上传封面;已有 draft/published 只提交新版本并沿用平台资料与封面。404→create 409 等状态竞态必须固定失败并要求重新确认,不得上传孤立封面、无条件 PATCH 或静默丢弃表单资料
- 首次 Works Project 发布必须选择 PNG/JPEG/WebP 封面(不超过 10 MiB并由 Electron Main 将项目资料与封面通过服务端单一 multipart create 合同原子绑定Renderer 仅传有界封面 DTO不接触 Works Token、项目路径、归档或幂等身份。服务端创建冲突或失败时客户端必须停止版本上传不能回退到先传封面再 JSON create。已有 draft/published 只提交新版本并沿用平台资料与封面;在具备 metadata revision/ETag 与 draft-only 条件写前,不得用无条件 PATCH 模拟已有资料编辑
- 客户端只持久化服务端已接受的 submission binding v2。旧 `submitted` 绑定必须保留;旧 `armed``waiting_for_package``waiting_for_login``uploading``failed` 必须迁移为可理解的 `legacy_retired`,不得恢复后台任务。
- 云端上传成功但本机 submission binding 保存失败时,提交仍视为成功;客户端显示固定、无本地路径的告警并继续轮询服务端校验与 Release 固化状态,避免诱导重复提交。
- 旧客户端缺少 source+built+contract 新协议,或服务端仍存在旧 sandbox/browser 任务时,必须提示升级客户端并重新构建提交;不得把它们伪装为新版瞬时故障。新版合同校验后的 `BUILD_STALE`、归档存储或 ReleaseStore 瞬时失败由运营在“构建异常”中重试。

View File

@@ -4,8 +4,9 @@ Use this index for searchable, traceable evidence records.
| Date | Topic | Status | Source | Detail |
|---|---|---|---|---|
| 2026-08-17 | 首次项目封面原子提交 | 客户端与匹配服务端本地集成;发布/部署待验收 | 客户端源 `145a6ce`、服务端源 `407c883` / merge `0cedfc4`、任务 `20260817-project-cover-upload-a6a98e56` | 首次弹窗必选 PNG/JPEG/WebP ≤10 MiB提供预览/文件名/重选Main 用 `metadata` + `cover` multipart 创建409 不上传版本,已有项目 version-only。源验证通过 76 单测、typecheck、scoped ESLint、Vite build、Electron E2E 1/1 和跨仓库 Sol review未执行真实账号、生产对象存储或安装包 smoke。 |
| 2026-08-17 | Makelore 用户级四模块入口策略 | 客户端实现与独立复审通过;服务端部署、新包与真实账号 smoke 待验收 | 源任务 `20260817-makelore-module-access-6f2a91c4`、源 tip `3b799af` | 4 files / 69 focused tests、175 files / 2047 full tests、TypeScript、scoped ESLint、Renderer/Main/Preload production build 与最终独立 Sol review PASS。覆盖 Main 四布尔安全投影、缺失默认开启、`design``painting`、卡片置灰不可点、根/深层/别名路由初始化前阻断、Code policy hydration、terminal `401` 双层清会话和全局 `/settings`;不据此宣称 API 授权或生产发布已验收 |
| 2026-08-17 | 远程 `01bee31` Learning 主线集成与安全收口 | 合并树自动化与独立双轴复审通过;真实 Works、固定生产播放器 artifact 与签名安装包待验收 | 远程提交 `01bee31`、集成任务 `20260813-sync-push-main-9c2f71` | pnpm 10.33.4 frozen install、26 files / 418 pre-review focused、175 files / 1944 pre-review full四轮审查修复后统一 9 files / 161、最终 175 files / 2028 full、typecheck、lint0 errors / 7 existing warnings、Renderer/Main/Preload build 与最终 Electron E2E 4/4 通过(更早跨模块选择 9/9 亦通过);第五轮 Standards/Spec 最终复审 PASS、无 P0-P3。最终树严格投影 generation IPC分离显式 player registration 与无副作用 identity resolvemanifest 只接受相对 `audio|media|fonts`,权威 root 注入单一 module 前缀consumer 生成 URL 已穿过 registered ZIP 的真实 HTTP/Woff2/MIME/security-header 测试;保持 fixed-binding 账号/Token/fetch/401 guard、512 MiB/ZIP/同源 5 跳下载、nonce player、exact-source/origin bridge 与可恢复 profile gate。Works 发布因缺少 revision/cover cleanup 合同采用 coverless first-create、existing version-only、竞态 fail-closed不据此宣称生产服务或签名包已验收 |
| 2026-08-17 | 远程 `01bee31` Learning 主线集成与安全收口 | 合并树自动化与独立双轴复审通过;真实 Works、固定生产播放器 artifact 与签名安装包待验收 | 远程提交 `01bee31`、集成任务 `20260813-sync-push-main-9c2f71` | pnpm 10.33.4 frozen install、26 files / 418 pre-review focused、175 files / 1944 pre-review full四轮审查修复后统一 9 files / 161、最终 175 files / 2028 full、typecheck、lint0 errors / 7 existing warnings、Renderer/Main/Preload build 与最终 Electron E2E 4/4 通过(更早跨模块选择 9/9 亦通过);第五轮 Standards/Spec 最终复审 PASS、无 P0-P3。最终树严格投影 generation IPC分离显式 player registration 与无副作用 identity resolvemanifest 只接受相对 `audio|media|fonts`,权威 root 注入单一 module 前缀consumer 生成 URL 已穿过 registered ZIP 的真实 HTTP/Woff2/MIME/security-header 测试;保持 fixed-binding 账号/Token/fetch/401 guard、512 MiB/ZIP/同源 5 跳下载、nonce player、exact-source/origin bridge 与可恢复 profile gate。该 checkpoint 因当时缺少 revision/cover cleanup 合同采用 coverless first-create、existing version-only、竞态 fail-closed当前客户端 `145a6ce` 与服务端 `407c883` / `0cedfc4` 已仅替换首次 coverless 限制,不据此宣称生产服务或签名包已验收 |
| 2026-08-16 | 远程 `26b52d7` Canvas/Prompt Museum 主线集成 | 合并树自动化验证通过;真实服务端内容、计费与删除结算待验收 | 远程提交 `26b52d7`、集成任务 `20260813-sync-push-main-9c2f71` | 主工作区及独立临时目录 clean frozen install955 packages、16 files / 284 focused、Prompt Museum 3 files / 13、161 files / 1850 full、typecheck、lint、Renderer/Main/Preload build、Electron E2E 6/6 与文档门禁通过。合并额外修复 lock override 实际签名依赖图、Prompt Museum 未知错误脱敏/严格 DTO+HTTPS 投影/401 refresh以及过期 E2E 断言;不据此宣称 Museum 后台审核数据、Quote 真实计费或 Workspace 删除结算已部署 |
| 2026-08-16 | Robot Windows/macOS 配网页内热点连接 | 实现与本地自动化验证通过;双平台实机发布证据待完成 | 源提交 `c1326a2`、ADR-003、集成任务 `20260813-sync-push-main-9c2f71` | 4 files / 132 focused tests、157 files / 1796 full tests、typecheck、lint、Renderer/Main/Preload build 与独立 Standards/Spec review 通过Electron 40.10.6 加载 Koffi/wlanapi 成功Windows 权限拒绝安全投影通过。签名 macOS x64/arm64、Windows Robot 真机和真实 Host/native Electron E2E 未完成,不得据此宣称双平台硬件验收 |
| 2026-08-11 | AI 设计多会话客户端集成 | 本地功能验证通过;仓库基线仍有既有失败 | `30-worklog/tasks/20260811-merge-all-code-a7c91e.md``4980894``03dae62` | 8 files / 116 focused tests、typecheck、changed-file ESLint、production build 与新增 Electron E2E 通过;生产 migration 0033/API 尚待验收,全量 lint/unit/E2E 的既有失败已单独记录 |

View File

@@ -6,7 +6,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks,
|---|---|---|---|---|---|
| 2026-08-17 | 验收用户级四模块入口策略的真实发布链 | 发布包含源 tip `3b799af` 的 Makelore 安装包前 | Works 服务端/客户端/发布集成 | Pending | 部署 `module_access` migration 和 `/api/auth/me` API重新打包并安装 Makelore使用真实账号依次关闭 Code、Canvas、Learning、Robot重启后验证卡片置灰/不可点、根/深层/别名路由阻断、`/settings` 仍可访问和 terminal `401` 退出;另行以直接 API 请求确认服务端授权,不以 UI 置灰代替 |
| 2026-08-16 | 验收 AI Learning 的真实 Works、课程包与 production Stage 发布链 | 发布包含 `01bee31` Learning 行为的安装包前 | 客户端/服务端/发布集成 | Pending | 使用真实账号核对课程广场、单课生成(无材料/5 文件边界)、取消/恢复/finalize、同源最多 5 跳下载、512 MiB archive 上限、大小/SHA-256/原子安装、账号切换隔离、离线多模块播放、进度、Agent、ASR、PBL/评分权益;以固定 URL/SHA-256 的 player artifact 构建并完成 Windows 与签名 macOS 安装包 smoke验证 packaged Chromium 的 loopback cookie/nonce不以客户端单测替代生产验收 |
| 2026-08-17 | 恢复 Works 项目封面已有资料编辑的原子合同 | 再启用首次封面上传或 draft/published metadata 编辑前 | Works 服务端/客户端发布集成 | Pending | 服务端提供可验证的 metadata revision/ETag 与 draft-only 条件写,以及原子 cover 绑定失败清理/回收;客户端用真实竞态验证 404→create 409、draft→published 与 cover 失败均不产生孤立对象、不覆盖已发布资料。合同落地前保持首次 coverless、已有项目 version-only |
| 2026-08-17 | 验收首次项目封面并补齐已有资料条件写 | 发布包含源 `145a6ce` 的 Makelore 前,或启用 draft/published metadata 编辑前 | Works 服务端/客户端发布集成 | Partial / Pending | 首次封面已由服务端源 `407c883` 与客户端源 `145a6ce` 通过单请求绑定失败补偿落地;仍需先部署服务端、重新打包客户端,并以真实对象存储/账号验证 404→create 409、cover 失败和版本阻断。已有资料编辑继续等待 metadata revision/ETag 与 draft-only 条件写;在此之前保持 existing version-only |
| 2026-08-16 | 验收 Prompt Museum 与 Canvas 删除/重报价的真实服务端链路 | 发布包含 `26b52d7` Canvas 行为的安装包前 | 客户端/服务端集成 | Pending | 使用真实 Works 账号核对 Museum 列表/详情/分页/筛选/署名/CDN/Prompt 回填;核对最终 Prompt/options 重报价和确认设计点;删除 Workspace 后确认软删除可见性、未提交任务取消/预留积分释放、已运行任务结算。保留 Main 错误脱敏和严格 DTO/HTTPS 投影,不以客户端回归替代服务端验收 |
| 2026-08-16 | 验收 default-on Robot Guided Hotspot Binding 的 Windows/macOS 真实设备链路 | 下一份包含页面内热点连接行为的安装包发布前 | 客户端/硬件/服务端集成 | Pending | 核对精确出货固件与固定 Portal、六位码发行/消费语义;执行 Windows Robot 真机扫描/连接、签名 macOS x64/arm64 CoreLocation/CoreWLAN/worker/ASAR/Koffi smoke以及真实 Host/native Electron 端到端配网+Binding保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚并记录支持矩阵 |
| 2026-08-10 | 完成客户端提交到 App `play_url` 播放的真实生产整链验收 | source+built+contract 服务端协议、OSS immutable Release、CDN/Edge 与 App 消费链成组集成后 | 客户端/服务端集成 | Pending | 使用真实账号执行小游戏和小程序创建、客户端本地构建与同字节预检、双归档提交、服务端逐字节校验/不可变 Release 固化、运营批准、CDN 发布、App 播放与监控核对;如需不可绕过 runtime gate另行设计可信 verifier |

View File

@@ -11,7 +11,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览未登录用户点击已开通模块时进入浏览器授权授权成功后回到入口选择页。已登录时Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”小游戏和小程序会创建完整的平台发布模板项目配置底部提供“一键提交审核”Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。当前 Works 项目接口没有可验证的元数据 revision/条件写或封面回收合同,因此首次创建会原子保存文字资料但暂不上传封面已有 draft/published 只提交新版本并沿用平台现有资料与封面;恢复封面或已有资料编辑前必须先补齐服务端原子合同。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”小游戏和小程序会创建完整的平台发布模板项目配置底部提供“一键提交审核”Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
## 当前产品状态
@@ -92,7 +92,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode
- Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。
- Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成。
- Works Square 登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复授权,连续 7 天未使用才清除会话并要求重新登录。刷新凭据只由 Electron Main 持有并在系统提供受保护凭据存储时加密落盘Renderer 仅保存短效公开会话状态旧版升级迁移时仅暂存既有刷新凭据Main 成功接管后立即删除)。
- AI 编程发布只经过 Main-owned Host APIRenderer 仅提交本地项目标识非敏感作品资料Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。首次项目 create 原子写入文字资料且不上传封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。
- AI 编程发布只经过 Main-owned Host APIRenderer 仅提交本地项目标识非敏感作品资料和有界封面 DTOMain 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。首次项目 create 使用 `/api/projects/with-cover` multipart 原子写入资料与封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH 或封面替换。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。
- AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。
- AI 绘画 Renderer 只调用 Main-owned Host APIMain 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、双向命令/事件帧、断点续传与契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。
- AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。

View File

@@ -1020,9 +1020,22 @@ function readProjectCoverUpload(value: unknown): ProjectCoverUpload | null {
if (dataBase64.length > Math.ceil(MAX_PROJECT_COVER_BYTES * 4 / 3) + 4) return null;
const bytes = Buffer.from(dataBase64, 'base64');
if (bytes.length === 0 || bytes.length > MAX_PROJECT_COVER_BYTES) return null;
const validSignature = mimeType === 'image/png'
? bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
: mimeType === 'image/jpeg'
? bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
: bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
if (!validSignature) return null;
return { fileName, mimeType, bytes };
}
function createProjectWithCoverForm(metadata: Record<string, unknown>, cover: ProjectCoverUpload): FormData {
const form = new FormData();
form.set('metadata', JSON.stringify(metadata));
form.set('cover', new Blob([new Uint8Array(cover.bytes)], { type: cover.mimeType }), cover.fileName);
return form;
}
async function handlePublishProjectSource(
req: IncomingMessage,
res: ServerResponse,
@@ -1125,6 +1138,15 @@ async function handlePublishProjectSource(
);
return;
}
if (!projectCover) {
sendPublishSourceFailure(
res,
400,
'PROJECT_COVER_REQUIRED',
'首次提交必须选择有效的 PNG、JPEG 或 WebP 项目封面。',
);
return;
}
} else {
await sendPublishSourceUpstreamError(
res,
@@ -1135,25 +1157,12 @@ async function handlePublishProjectSource(
return;
}
if (projectCover && !existingMetadataPreserved) {
// The cover contract has no delete or atomic project attachment, so uploading
// before a conflicting create could leave an unreferenced private object.
sendPublishSourceFailure(
res,
503,
'WORKS_SQUARE_UNAVAILABLE',
'发布服务暂时无法安全保存封面,请稍后重试。',
);
return;
}
const createResponse = existingMetadataPreserved ? null : await proxyAwareFetch(createWorksUrl('/api/projects').toString(), {
const createResponse = existingMetadataPreserved ? null : await proxyAwareFetch(createWorksUrl('/api/projects/with-cover').toString(), {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(projectMetadata),
body: createProjectWithCoverForm(projectMetadata, projectCover!),
});
if (createResponse && !createResponse.ok && createResponse.status !== 409) {
await sendPublishSourceUpstreamError(
@@ -1167,32 +1176,6 @@ async function handlePublishProjectSource(
await createResponse?.body?.cancel().catch(() => undefined);
if (createResponse?.status === 409) {
const ownershipResponse = await proxyAwareFetch(
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(),
{
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
},
);
if (!ownershipResponse.ok) {
await sendPublishSourceUpstreamError(
res,
ownershipResponse,
'PROJECT_OWNERSHIP_UNCONFIRMED',
'这个作品 ID 已被占用,请更换后重试。',
);
return;
}
const ownershipPayload = projectSafeStatusPayload(await readResponsePayload(ownershipResponse));
if (!ownershipPayload) {
sendPublishSourceFailure(
res,
502,
'PROJECT_OWNERSHIP_UNCONFIRMED',
'这个作品的归属暂时无法确认,请稍后重试。',
);
return;
}
sendPublishSourceFailure(
res,
409,

View File

@@ -23,6 +23,7 @@ import {
import {
fetchCurrentWorksProjectStatus,
publishWorksProjectSource,
type WorksProjectCoverUpload,
type WorksProjectMetadataInput,
} from '@/lib/works-square';
import type { OpencodeProject } from '@/types/opencode';
@@ -55,6 +56,30 @@ type ProjectPublishActionProps = {
const BUILD_POLL_INTERVAL_MS = 2_000;
const BUILD_POLL_ATTEMPTS = 300;
const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024;
const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function matchesCoverSignature(bytes: Uint8Array, mimeType: string): boolean {
if (mimeType === 'image/png') {
return bytes.length >= 8
&& [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((value, index) => bytes[index] === value);
}
if (mimeType === 'image/jpeg') {
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}
return bytes.length >= 12
&& String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF'
&& String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP';
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return window.btoa(binary);
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => {
@@ -106,6 +131,8 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
const [difficulty, setDifficulty] = useState('');
const [checkingProjectStatus, setCheckingProjectStatus] = useState(false);
const [existingProject, setExistingProject] = useState<ExistingProjectMetadata | null>(null);
const [cover, setCover] = useState<WorksProjectCoverUpload | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const pollGeneration = useRef(0);
const authUser = useAuthStore((state) => state.user);
@@ -164,7 +191,10 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
}
}
async function submitProject(metadata: WorksProjectMetadataInput): Promise<void> {
async function submitProject(
metadata: WorksProjectMetadataInput,
projectCover?: WorksProjectCoverUpload,
): Promise<void> {
pollGeneration.current += 1;
const generation = pollGeneration.current;
setFailure(null);
@@ -175,6 +205,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
const result = await publishWorksProjectSource({
projectId: project.id,
project: metadata,
...(projectCover ? { cover: projectCover } : {}),
});
if (pollGeneration.current !== generation) return;
setBindingWarning(result.bindingWarning?.message ?? null);
@@ -191,6 +222,34 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
}
}
async function handleCoverChange(file: File | undefined): Promise<void> {
setCover(null);
setCoverPreview(null);
if (!file) return;
const mimeType = file.type.toLowerCase();
if (!PROJECT_COVER_MIME_TYPES.has(mimeType)) {
setFormError('项目封面仅支持 PNG、JPEG 或 WebP 图片。');
return;
}
if (file.size === 0) {
setFormError('项目封面不能为空,请重新选择图片。');
return;
}
if (file.size > MAX_PROJECT_COVER_BYTES) {
setFormError('项目封面不能超过 10 MiB请选择更小的图片。');
return;
}
const bytes = new Uint8Array(await file.arrayBuffer());
if (!matchesCoverSignature(bytes, mimeType)) {
setFormError('项目封面内容与图片格式不符,请重新选择 PNG、JPEG 或 WebP 图片。');
return;
}
const dataBase64 = bytesToBase64(bytes);
setCover({ fileName: file.name, mimeType, dataBase64 });
setCoverPreview(`data:${mimeType};base64,${dataBase64}`);
setFormError(null);
}
async function openPublishDialog(): Promise<void> {
setFailure(null);
setCheckingProjectStatus(true);
@@ -249,6 +308,10 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
setFormError('请填写项目简介。');
return;
}
if (!cover) {
setFormError('请选择项目封面。首次提交必须上传 PNG、JPEG 或 WebP 图片。');
return;
}
if (!trimmedCreatorName) {
setFormError('请填写发布者姓名。');
return;
@@ -271,7 +334,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
difficulty: difficulty.trim() || null,
};
setDialogOpen(false);
await submitProject(metadata);
await submitProject(metadata, cover);
}
const locked = checkingProjectStatus
@@ -326,7 +389,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
<DialogDescription>
{existingProject
? '当前发布契约不支持安全修改已有作品资料。本次只会提交新的构建版本,现有名称、简介、作者信息和封面都会保持不变。'
: '首次创建会保存这些作品信息并提交构建结果。当前发布契约暂不支持上传封面,因此本次作品将不设置封面。发布者年龄指作者本人年龄。'}
: '首次创建会原子保存作品信息与项目封面,再提交构建结果。发布者年龄指作者本人年龄。'}
</DialogDescription>
</DialogHeader>
@@ -400,6 +463,40 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
/>
</div>
<div className="grid gap-2">
<Label htmlFor="project-publish-cover"> <span className="text-destructive">*</span></Label>
<Input
id="project-publish-cover"
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={(event) => void handleCoverChange(event.target.files?.[0])}
/>
<div className="flex min-w-0 items-center gap-3 rounded-xl border border-border/80 bg-muted/30 p-3">
{coverPreview ? (
<img
src={coverPreview}
alt="项目封面预览"
className="h-20 w-20 shrink-0 rounded-xl object-cover outline outline-1 -outline-offset-1 outline-black/10"
/>
) : (
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground [box-shadow:inset_0_0_0_1px_rgba(0,0,0,0.1)]">
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{cover?.fileName ?? '请选择一张图片'}</p>
<p className="mt-1 text-xs text-muted-foreground">PNGJPEG WebP 10 MiB</p>
<label
htmlFor="project-publish-cover"
className="mt-2 inline-flex min-h-10 cursor-pointer items-center rounded-md border border-input bg-background px-4 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
>
{cover ? '重新选择' : '选择封面'}
</label>
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="project-publish-age-band"></Label>
@@ -424,7 +521,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
</div>
<p className="rounded-xl bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
{projectType}
{projectType}
</p>
</>}
@@ -451,14 +548,14 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
{phase === 'submitting'
? existingProject
? '作品资料与封面保持不变Makelore 正在生成并提交新版本。'
: 'Makelore 正在创建无封面作品、生成本次构建结果并提交,不需要准备 ZIP。'
: 'Makelore 正在创建作品并上传封面、生成本次构建结果,不需要准备 ZIP。'
: phase === 'polling'
? existingProject
? '新版本已上传,现有作品资料与封面保持不变,正在等待平台校验。'
: '无封面作品与本次构建结果已提交,正在等待平台校验。'
: '作品封面与本次构建结果已提交,正在等待平台校验。'
: existingProject
? '新版本已提交,现有作品资料与封面保持不变。'
: '无封面作品已提交,等待运营审核。审核通过后会直接发布。'}
: '作品与封面已提交,等待运营审核。审核通过后会直接发布。'}
</p>
) : null}

View File

@@ -119,6 +119,44 @@ test.describe('Project configuration skills', () => {
await expect(page.getByTestId('resource-card-publish')).toHaveCount(0);
await expect(page.getByRole('button', { name: '一键提交审核' })).toBeVisible();
await app.evaluate(({ ipcMain }) => {
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => {
if (/^\/api\/works\/projects\/mine\/[^/]+\/status$/.test(request.path ?? '')) {
return {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: false,
status: 404,
code: 'PROJECT_NOT_FOUND',
error: 'Project not found',
},
},
};
}
return { ok: false, error: { message: `Unexpected E2E Host API request: ${request.path ?? ''}` } };
});
});
await page.getByRole('button', { name: '一键提交审核' }).click();
const publishDialog = page.getByRole('dialog', { name: '完善作品信息后提交' });
await expect(publishDialog).toBeVisible();
await expect(publishDialog.getByLabel(/项目封面/)).toBeVisible();
await publishDialog.getByLabel(/发布者姓名/).fill('E2E 作者');
await publishDialog.getByLabel(/发布者年龄/).fill('12');
await publishDialog.getByLabel(/项目简介/).fill('验证首次提审必须选择项目封面。');
await publishDialog.getByRole('button', { name: '提交审核' }).click();
await expect(publishDialog.getByRole('alert')).toContainText('首次提交必须上传');
const coverPath = path.join(process.cwd(), 'src/assets/agent-avatars/avatar-01.png');
await publishDialog.getByLabel(/项目封面/).setInputFiles(coverPath);
await expect(publishDialog.getByAltText('项目封面预览')).toBeVisible();
await expect(publishDialog.getByText('avatar-01.png')).toBeVisible();
await expect(publishDialog.getByText('重新选择')).toBeVisible();
await publishDialog.getByRole('button', { name: '取消' }).click();
const agentCard = page.getByTestId('project-agent-e2e-partner');
await expect(agentCard).toContainText('E2E伙伴');
await agentCard.click();

View File

@@ -63,7 +63,9 @@ async function advancePoll() {
});
}
async function submitProjectMetadata() {
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]);
async function submitProjectMetadata({ includeCover = true }: { includeCover?: boolean } = {}) {
fetchCurrentWorksProjectStatusMock.mockRejectedValueOnce(Object.assign(
new Error('project missing'),
{ statusCode: 404 },
@@ -75,6 +77,17 @@ async function submitProjectMetadata() {
fireEvent.change(screen.getByLabelText(/发布者姓名/), { target: { value: '小明' } });
fireEvent.change(screen.getByLabelText(/发布者年龄/), { target: { value: '12' } });
fireEvent.change(screen.getByLabelText(/项目简介/), { target: { value: '这是一个太空清洁小游戏。' } });
if (includeCover) {
fireEvent.change(screen.getByLabelText(/项目封面/), {
target: { files: [new File([pngBytes], 'space-cover.png', { type: 'image/png' })] },
});
await act(async () => {
await Promise.resolve();
});
expect(screen.getByText('space-cover.png')).toBeVisible();
expect(screen.getByAltText('项目封面预览')).toBeVisible();
expect(screen.getByText('重新选择')).toBeVisible();
}
fireEvent.click(screen.getByRole('button', { name: '提交审核' }));
await act(async () => {
await Promise.resolve();
@@ -95,7 +108,7 @@ describe('ProjectPublishAction', () => {
vi.useRealTimers();
});
it('creates a new project without a cover and locks after Builder succeeds', async () => {
it('creates a new project with a cover DTO and locks after Builder succeeds', async () => {
fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded'));
render(<ProjectPublishAction project={project} projectType="mini_game" />);
@@ -104,7 +117,7 @@ describe('ProjectPublishAction', () => {
await flushSubmission();
expect(screen.getByTestId('project-publish-status')).toHaveTextContent(
'无封面作品与本次构建结果已提交,正在等待平台校验。',
'作品封面与本次构建结果已提交,正在等待平台校验。',
);
expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({
@@ -121,9 +134,12 @@ describe('ProjectPublishAction', () => {
age_band: null,
difficulty: null,
},
cover: {
fileName: 'space-cover.png',
mimeType: 'image/png',
dataBase64: 'iVBORw0KGgoB',
},
});
expect(screen.queryByLabelText(/项目封面/)).not.toBeInTheDocument();
expect(publishWorksProjectSourceMock.mock.calls[0]?.[0]).not.toHaveProperty('cover');
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce();
await advancePoll();
@@ -133,7 +149,36 @@ describe('ProjectPublishAction', () => {
);
expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledTimes(2);
expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled();
expect(screen.getByTestId('project-publish-status')).toHaveTextContent('无封面作品已提交');
expect(screen.getByTestId('project-publish-status')).toHaveTextContent('作品与封面已提交');
});
it('blocks first submission until a cover is selected', async () => {
render(<ProjectPublishAction project={project} projectType="mini_game" />);
await submitProjectMetadata({ includeCover: false });
expect(screen.getByRole('alert')).toHaveTextContent('首次提交必须上传');
expect(publishWorksProjectSourceMock).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toBeVisible();
});
it('rejects unsupported and oversized cover files before confirmation', async () => {
fetchCurrentWorksProjectStatusMock.mockRejectedValue(Object.assign(new Error('missing'), { statusCode: 404 }));
render(<ProjectPublishAction project={project} projectType="mini_game" />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
await Promise.resolve();
});
const input = screen.getByLabelText(/项目封面/);
fireEvent.change(input, { target: { files: [new File(['text'], 'cover.gif', { type: 'image/gif' })] } });
expect(screen.getByRole('alert')).toHaveTextContent('仅支持 PNG、JPEG 或 WebP');
fireEvent.change(input, {
target: { files: [new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'huge.png', { type: 'image/png' })] },
});
expect(screen.getByRole('alert')).toHaveTextContent('不能超过 10 MiB');
expect(publishWorksProjectSourceMock).not.toHaveBeenCalled();
});
it('shows a friendly Builder failure without raw details', async () => {

View File

@@ -79,6 +79,14 @@ function createRendererRequest(method: string, body?: unknown): IncomingMessage
});
}
function validProjectCover(fileName = 'cover.png') {
return {
fileName,
mimeType: 'image/png',
dataBase64: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]).toString('base64'),
};
}
async function writePublishableProject(projectPath: string): Promise<void> {
await mkdir(join(projectPath, 'src'), { recursive: true });
await mkdir(join(projectPath, '.niancode'), { recursive: true });
@@ -1320,7 +1328,7 @@ describe('works square host api routes', () => {
prepareProjectReleaseMock.mockResolvedValueOnce(release);
const handled = await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover: validProjectCover() }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
@@ -1360,17 +1368,16 @@ describe('works square host api routes', () => {
expect(response.json().package).not.toHaveProperty('archivePath');
expect(getValidWorksSquareAccessTokenMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[1]).toEqual([
'https://square.nianxx.cn/api/projects',
{
method: 'POST',
headers: {
Authorization: 'Bearer main-owned-access-token',
'Content-Type': 'application/json',
},
body: JSON.stringify(projectMetadata),
},
]);
const [createUrl, createInit] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(createUrl).toBe('https://square.nianxx.cn/api/projects/with-cover');
expect(createInit.method).toBe('POST');
expect(createInit.headers).toEqual({ Authorization: 'Bearer main-owned-access-token' });
const createForm = createInit.body as FormData;
expect(JSON.parse(String(createForm.get('metadata')))).toEqual(projectMetadata);
const submittedCover = createForm.get('cover');
expect(submittedCover).toBeInstanceOf(File);
expect((submittedCover as File).name).toBe('cover.png');
expect((submittedCover as File).type).toBe('image/png');
const [, uploadInit] = fetchMock.mock.calls[2] as [string, RequestInit];
expect(uploadInit.headers).toMatchObject({
Authorization: 'Bearer main-owned-access-token',
@@ -1404,7 +1411,7 @@ describe('works square host api routes', () => {
expect(JSON.stringify(response.json())).not.toContain('private-source.zip');
});
it('fails before upload or create when a 404 preflight cannot atomically attach the submitted cover', async () => {
it('requires a cover after a 404 ownership preflight and stops before atomic create or version upload', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-cover-publish-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
@@ -1420,18 +1427,13 @@ describe('works square host api routes', () => {
age_band: '6-12岁',
difficulty: '入门',
};
const cover = {
fileName: 'cover.png',
mimeType: 'image/png',
dataBase64: Buffer.from('cover bytes').toString('base64'),
};
const fetchMock = vi.fn().mockResolvedValueOnce(new Response('{}', { status: 404 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover }),
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
@@ -1443,13 +1445,13 @@ describe('works square host api routes', () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 503,
code: 'WORKS_SQUARE_UNAVAILABLE',
error: '发布服务暂时无法安全保存封面,请稍后重试。',
status: 400,
code: 'PROJECT_COVER_REQUIRED',
error: '首次提交必须选择有效的 PNG、JPEG 或 WebP 项目封面。',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects'))).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/with-cover'))).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
});
@@ -1465,11 +1467,7 @@ describe('works square host api routes', () => {
creator_age: 12,
status: 'published',
};
const cover = {
fileName: 'replacement.png',
mimeType: 'image/png',
dataBase64: Buffer.from('unused replacement cover').toString('base64'),
};
const cover = validProjectCover('replacement.png');
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
project: {
@@ -1533,11 +1531,7 @@ describe('works square host api routes', () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-unsafe-ownership-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const cover = {
fileName: 'replacement.png',
mimeType: 'image/png',
dataBase64: Buffer.from('must not upload').toString('base64'),
};
const cover = validProjectCover('replacement.png');
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
project: {
app_id: 'space-cleaner',
@@ -1621,7 +1615,7 @@ describe('works square host api routes', () => {
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
});
it('fails closed when a 404 create conflict reveals published metadata that the form did not show', async () => {
it('maps an atomic create race to metadata conflict without version upload', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-publish-race-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
@@ -1630,26 +1624,15 @@ describe('works square host api routes', () => {
title: 'Must not replace published title',
summary: 'Must not replace published summary',
};
const ownedStatus = (status: 'published') => ({
project: {
app_id: 'space-cleaner',
title: 'Existing title',
summary: 'Existing summary',
status,
},
latest_version: null,
versions: [],
});
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
.mockResolvedValueOnce(new Response('{}', { status: 409 }))
.mockResolvedValueOnce(new Response(JSON.stringify(ownedStatus('published')), { status: 200 }));
.mockResolvedValueOnce(new Response('{}', { status: 409 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata }),
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata, cover: validProjectCover() }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
@@ -1667,14 +1650,13 @@ describe('works square host api routes', () => {
});
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
'https://square.nianxx.cn/api/projects',
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
'https://square.nianxx.cn/api/projects/with-cover',
]);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
});
it('fails closed without PATCH or version upload when a create conflict reconfirms draft ownership', async () => {
it('fails closed without PATCH or version upload when atomic create returns a conflict', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-draft-conflict-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
@@ -1683,26 +1665,15 @@ describe('works square host api routes', () => {
title: 'Updated title',
summary: 'Updated summary',
};
const draftStatus = {
project: {
app_id: 'space-cleaner',
title: 'Existing title',
summary: 'Existing summary',
status: 'draft',
},
latest_version: null,
versions: [],
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
.mockResolvedValueOnce(new Response('{}', { status: 409 }))
.mockResolvedValueOnce(new Response(JSON.stringify(draftStatus), { status: 200 }));
.mockResolvedValueOnce(new Response('{}', { status: 409 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata }),
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata, cover: validProjectCover() }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
@@ -1718,7 +1689,7 @@ describe('works square host api routes', () => {
code: 'PROJECT_METADATA_CONFLICT',
error: '作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
});
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
@@ -1800,6 +1771,7 @@ describe('works square host api routes', () => {
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
},
cover: validProjectCover(),
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
@@ -1866,6 +1838,7 @@ describe('works square host api routes', () => {
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
},
cover: validProjectCover(),
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
@@ -1914,6 +1887,7 @@ describe('works square host api routes', () => {
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
},
cover: validProjectCover(),
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
@@ -1958,7 +1932,7 @@ describe('works square host api routes', () => {
for (let index = 0; index < 2; index += 1) {
const response = createResponse();
await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover: validProjectCover() }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
ctx,
@@ -2005,6 +1979,7 @@ describe('works square host api routes', () => {
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
},
cover: validProjectCover(),
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
@@ -2046,6 +2021,7 @@ describe('works square host api routes', () => {
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
},
cover: validProjectCover(),
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),