Merge branch 'master' into feat/artagent-artifact-jump-to-focus
This commit is contained in:
@@ -436,6 +436,28 @@ async function runConfigWizardRegressionChecks() {
|
||||
path.join(os.tmpdir(), 'genarrative-agc-config-check-'),
|
||||
);
|
||||
try {
|
||||
const canonicalTestRoot = fs.realpathSync.native(testRoot);
|
||||
const realConfigAncestor = path.join(testRoot, 'real-config-ancestor');
|
||||
const linkedConfigAncestor = path.join(testRoot, 'linked-config-ancestor');
|
||||
fs.mkdirSync(realConfigAncestor);
|
||||
fs.symlinkSync(
|
||||
realConfigAncestor,
|
||||
linkedConfigAncestor,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
const missingLinkedConfigDir = path.join(
|
||||
linkedConfigAncestor,
|
||||
'missing-appdata',
|
||||
);
|
||||
assert.equal(fs.existsSync(missingLinkedConfigDir), false);
|
||||
assert.equal(
|
||||
await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir),
|
||||
path.join(
|
||||
fs.realpathSync.native(realConfigAncestor),
|
||||
'missing-appdata',
|
||||
),
|
||||
);
|
||||
|
||||
const gitRoot = path.join(testRoot, 'tracked-repository');
|
||||
const trackedConfigDir = path.join(gitRoot, 'runtime-config');
|
||||
fs.mkdirSync(trackedConfigDir, { recursive: true });
|
||||
@@ -463,7 +485,7 @@ async function runConfigWizardRegressionChecks() {
|
||||
const outsideConfigDir = path.join(testRoot, 'outside-appdata');
|
||||
assert.equal(
|
||||
await assertSafeGameCreatorConfigDestination(outsideConfigDir),
|
||||
outsideConfigDir,
|
||||
path.join(canonicalTestRoot, 'outside-appdata'),
|
||||
);
|
||||
await assert.rejects(
|
||||
assertSafeGameCreatorConfigDestination(outsideConfigDir, {
|
||||
@@ -476,7 +498,7 @@ async function runConfigWizardRegressionChecks() {
|
||||
await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, {
|
||||
requireDedicatedLeaf: true,
|
||||
}),
|
||||
dedicatedConfigDir,
|
||||
path.join(canonicalTestRoot, appIdentifier),
|
||||
);
|
||||
|
||||
const injectedNonGitConfigDir = path.join(testRoot, 'injected-non-git');
|
||||
@@ -489,7 +511,7 @@ async function runConfigWizardRegressionChecks() {
|
||||
'fatal: not a git repository (or any of the parent directories): .git\n',
|
||||
}),
|
||||
}),
|
||||
injectedNonGitConfigDir,
|
||||
path.join(canonicalTestRoot, 'injected-non-git'),
|
||||
);
|
||||
await assert.rejects(
|
||||
assertSafeGameCreatorConfigDestination(
|
||||
|
||||
@@ -4231,7 +4231,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
([command]) => command === 'get_local_game_preview_status',
|
||||
).length,
|
||||
).toBeGreaterThan(previewStatusReads);
|
||||
});
|
||||
}, { timeout: 3000 });
|
||||
expect(driver.readAuthorization()?.authorizationId).toBe(
|
||||
steerAuthorizationId,
|
||||
);
|
||||
|
||||
@@ -3737,6 +3737,7 @@
|
||||
- 处理:GUI 与 `npm run agc:config` 共用系统 AppData `game-creator.config.json`,终端隐藏输入 API Key 并禁止 `--api-key`;更新时保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置,POSIX 权限维持目录 `0700` / 文件 `0600` 并原子替换。显式 `--config-dir` 必须以 `world.genarrative.ai-game-creator` 为独立叶目录,不能让向导对 `/tmp`、AppData 根或共享目录整体 chmod / 重建 DACL。隐藏输入调用 `stdin.resume()` 后必须记住原 pause 状态,在成功、取消、异常和 `SIGINT / SIGTERM / SIGHUP` 路径恢复 raw mode 并 `pause()`,信号恢复后重发;只移除 `data` listener 会让 `--configure-only`、配置检查失败或 Ctrl+C 保持活动 stdin。缺配置时仅 TTY 人工会话可询问进入向导,非 TTY 立即失败并提示配置命令。
|
||||
- Windows 密钥复制:`mode: 0o600` 和 POSIX `chmod` 在 Windows 上不能代替 DACL。隔离 AppData 目录必须先设置仅当前用户、禁止继承的 DACL;目标配置文件先以空文件创建并收紧 DACL,之后才允许把 API Key 字节写入。先 `copyFile` 再依赖 Rust 只读检查或事后收紧会留下密钥暴露窗口,也可能因继承 ACL 不满足 Runtime 合同而在首次 `--llm-status` 失败。
|
||||
- Windows PowerShell 参数:不要把 DACL 目标路径和目录标记直接追加在 `powershell.exe -Command <script>` 后;Windows Node `spawn` 会让 PowerShell 5.1 把这些值拼入命令文本,带空格的临时路径会被拆分并使 `GetFullPath($args[0])` 失败。当前实现只通过子进程私有环境变量传入路径和布尔值,并由真实 Windows `npm run agc:typecheck` 覆盖 DACL 回归。
|
||||
- 跨平台临时路径比较:macOS 的 `os.tmpdir()` 可返回 `/var/folders/...`,而 `realpath` 会返回同一目录的 `/private/var/folders/...`;Windows 也可存在驱动器号大小写、junction 或链接解析差异。测试安全路径函数时,fixture 期望值必须基于平台原生 `realpath` 后的根目录构造,不能直接与 `mkdtemp` 的逻辑路径字符串严格比较。不能只依赖开发机自带的路径别名;CI 应创建真实父目录、指向它的符号链接(Windows 使用 junction)和不存在的叶目录,断言安全函数返回真实父目录下的叶路径。`realpath` 和全部断言都应放在 `try/finally` 内,确保失败也能清理 fixture。
|
||||
- 超时与进程树:`setTimeout` 后只对直接 Cargo PID 调一次 `kill()` 不是硬超时;Cargo 启动的 CLI / Runner 仍可能持有 stdio,使 `close` 永远不返回,清理阶段也可能无界等待。POSIX 必须创建独立进程组并按负 PID 终止,Windows 必须使用 `taskkill /T`;宽限期后升级强杀,Runner shutdown 和清理另设短硬超时。重复 Ctrl+C 也必须升级,不能一直被自定义 signal handler 吞掉。
|
||||
- 模式拆分:自动化使用默认的 `agc:test:chat`,由脚本投递固定植物塔防需求;正式产物、最新 revision 静态检查和 Runtime 浏览器验收通过后立即收束并清理,不启动持续 preview。`agc:test:chat:manual` 不注入 `--task`,用于多轮 stdin 手工聊天,并保留 preview 直到显式退出;不能用 EOF 或是否存在 `game/index.html` 猜测当前模式。
|
||||
- 验证:覆盖 `--api-key` 拒绝、各平台 AppData 路径、Provider 预设、旧配置节点保留、URL 安全校验、原子写入、POSIX 权限、隐藏输入恢复 pause、Windows 写密钥前 DACL、整棵进程树超时终止、缺配置时 TTY / 非 TTY 分支,以及 `--task` 成功后不进入长期 preview、手工模式仍可持续试玩。测试和错误输出只验证“密钥已配置”状态,不读取或打印密钥本体;源码字符串断言和 Linux 上的 Windows mock 不能替代真实 Windows ACL / `taskkill` 复验。
|
||||
@@ -3921,6 +3922,7 @@
|
||||
- 根因:旧实现调用 `start_local_game_preview` 前就把“项目 + parent run”的授权加入 attempted 集合并清空;首版完成投影与后续专业任务仍在写项目时,启动恰逢项目写锁竞争,catch 只显示错误却无法重试。
|
||||
- 约束:一次性语义应按“成功或确定性终态”消费,不按“函数调用次数”消费。项目写锁竞争保留同一授权并轮询重试;成功、显式 deny 与非瞬时失败才清除。授权需持久化项目路径和 accepted runId,重启恢复时仍必须逐项匹配,切换项目不得继承。
|
||||
- 回归:AppSurface 模拟第一次 `start_local_game_preview` 返回 `项目正在被其他写操作占用`、第二次成功,断言最终渲染游戏区域且启动调用恰为两次;完整 AppSurface 仍需覆盖显式 deny、停止隐藏与项目切换隔离。
|
||||
- CI 时序:生产预览状态每 `1000ms` 轮询一次,回归若也使用 `waitFor` 默认 `1000ms` 上限,会在 CI 负载下于首次 interval 回调附近竞争超时。验证“授权保留期间仍继续轮询”应使用明确 `3000ms` 上限,不改生产轮询周期。合并长测试文件后还要运行全量 ESLint;单纯 autofix 只会排序、不会消除两个分支同时引入的重复 import。
|
||||
|
||||
## 跨窗口 CAS 锁不能用 mtime stale 删除模拟系统互斥(2026-07-30)
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。
|
||||
- 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。
|
||||
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。
|
||||
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。
|
||||
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。OSS 直传确认成功后,即时占位素材、即时画布图层和补充图片尺寸探测必须使用本次换签得到的 signed URL,不得把私有对象的裸 `/generated-*` 兼容路径交给浏览器读取;账号素材持久化仍只写 `legacyPublicPath`、`objectKey` 和 `assetObjectId`,禁止把会过期的 signed URL 写入资源或素材记录。
|
||||
- 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `<video controls preload="metadata" playsInline>` 播放,外层图层仍承接选择和拖拽语义;音频图层播放前继续通过 `/api/assets/read-url` 换签。画布素材导出按 `mediaType` 保留真实媒体格式:图片进入 `images/`,音频 / 视频进入 `media/`,文件扩展名从响应 MIME、`objectKey` 或源 URL 推断,不得把音频 / 视频导出成 PNG。
|
||||
- 画布素材 ZIP 的 `metadata.json` 只保存前端信息弹窗和导出文件列表可见的展示快照:项目标题、导出时间、图层标题、文件路径、类型、生成输入、模型显示名、Task 短 ID、Object 显示值、Resolution / 时长和导出错误。导出的生成输入只保留用户实际填写或选择的内容;系统默认兜底提示词、固定工作流提示词、内置图标描述、UI 提取素材固定提示词等内置提示词即使存在于历史 `generationInputs`,也不得写入导出元数据。不得把 `projectId`、`layerId`、`resourceId`、`sourceAssetId`、`sourceResourceId`、原始 `prompt` / `actualPrompt` / `provider` 或画布坐标、锁定、隐藏等布局状态写入导出元数据;Object 字段仅沿用信息弹窗当前可见值。
|
||||
- `生成角色动作` 的完成结果按序列帧素材处理:图层主 `src` 使用 `frames[0].imageSrc`,`mediaType` 固定为 `image-sequence`,`assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。下载和 ZIP 导出必须因此得到序列帧 ZIP / frames 目录,不能回退为预览视频或首帧 PNG;后端抽帧后逐帧保存带绿幕源图,上传时消费并释放原帧字节,再用该对象的 600 秒 OSS URL 完成透明化并落盘透明帧素材。
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- 固定试玩契约:`generic-v1` 初始状态必须为 `ready` 且 `level > 0`;点击 start 后 sequence 必须推进、phase 必须进入 `playing`,并先持续观察 2 秒、取得至少 8 个实际样本,期间保持 `playing`,以确认玩家获得正常操作机会。随后必须点击唯一可见、启用且真实可交互的 `data-playtest-id="primary-action"` 控件;该控件必须映射游戏的真实主要玩法操作,并以 sequence 相对点击前严格推进证明操作已被接受。玩家获得这次正常操作机会之前进入 `won | lost` 属于过早结束并失败;操作被接受后的单次 `lost` 是合法游戏结局,但不能成为所有受控尝试的唯一结果;若主要操作后仍为 `playing`,则继续观察 3 秒并取得至少 12 个实际样本,`won` 可提前证明非失败推进。点击 restart 后 sequence 必须再次推进并恢复到 `ready | playing`,随后持续观察 3 秒且取得至少 12 个实际样本。若首轮结果为 `lost`,重开稳定后必须再执行一次必要的 start、2 秒 / 8 样本操作机会和真实 primary-action;第二次必须进入或保持 `playing`(再观察 3 秒 / 12 样本且不得转为 `lost`)或进入 `won`,两次都固定 `lost` 代表无法正常推进的恶性 bug,必须失败。各观察窗口内 sequence 不得回退,restart 窗口只能保持 `ready | playing`;样本数门槛不能替代时长门槛,窗口末端必须强制再读取一次有效状态,不能只在前段快速取得足够样本后提前通过。控件 selector、观察时长、最少样本数、终态边界、非失败推进、末端覆盖、sequence 单调 / 严格推进规则及完整 required assertions 都进入 scenario fingerprint。读取旧 fingerprint 回执和检查 plan liveness 时,把合同升级造成的 fingerprint 不匹配视为 stale missing,允许同一 run 重新执行 `preview.validate` 自愈;身份、路径、digest 或内容完整性篡改仍失败关闭。最终完成门每次按当前合同重算 fingerprint,并严格拒绝旧 fingerprint、旧 assertion 集或仅保存历史 `passed=true` 的证据。
|
||||
- 试玩证据展示:game-chat 的进度卡、可玩 revision 和自动预览只接受结构化 `preview.validate` detail 同时满足 `passed=true` 与 `playtestPassed=true`;工具 summary 中的 `:ok` 不能作为兜底。`image.inspect` 的 `status=ok` 只代表工具执行成功,不代表视觉验收通过;结构化 `passed=null` 或缺少布尔结论时,UI 必须以中性“截图分析完成”展示,只有显式 `passed=true` 才能显示“截图检查通过”。
|
||||
- 一次性自动预览授权:用户在该入口成功提交本轮自主生成需求,即视为对“当前项目 + 当前 Supervisor 父 run”的一次 `preview.start` 授权。授权以仅含项目路径与 accepted parent runId 的客户端本地记录持久化,App / WebView 重启后仍可恢复,但项目或 run 身份不匹配时不得使用。只有当前 accepted parent run 成功完成 `preview.validate` 且给出有效 revision 后,客户端才可消费授权,由 Tauri 首次启动并自动展示该 revision 的用户可见预览;一次授权最多成功启动一个 Tauri preview server,并必须继续走现有权限、项目写锁、审计和客户端 `PreviewRegistry` 链路。项目或 Agent 策略的显式 deny 始终优先,不得被此授权绕过。启动成功、显式 deny、非瞬时失败、父 run 在首版验证前终止或切换项目后授权失效;`preview.start` 恰逢项目写锁竞争属于瞬时失败,不消费授权,释放写锁后由同一轮询链路重试。
|
||||
- 增量预览刷新:Tauri 客户端记录当前 iframe 已展示的 validated revision;同一当前 run 后续成功 `preview.validate` 的 revision 严格高于已展示 revision 时,只在原 Tauri preview server 和原 loopback origin 上刷新 iframe,不得再次调用 `preview.start`、新增 server 或切换到 Runner registry。相同或更低 revision 不触发刷新。preview HTTP server 对 HTML、脚本、样式、资源和错误响应统一发送 `Cache-Control: no-store`,iframe 刷新必须读取新 revision,不能继续命中 WebView 缓存中的旧版本。
|
||||
- 增量预览刷新:Tauri 客户端记录当前 iframe 已展示的 validated revision;同一当前 run 后续成功 `preview.validate` 的 revision 严格高于已展示 revision 时,只在原 Tauri preview server 和原 loopback origin 上刷新 iframe,不得再次调用 `preview.start`、新增 server 或切换到 Runner registry。相同或更低 revision 不触发刷新。preview HTTP server 对 HTML、脚本、样式、资源和错误响应统一发送 `Cache-Control: no-store`,iframe 刷新必须读取新 revision,不能继续命中 WebView 缓存中的旧版本。自动预览轮询回归的等待上限必须严格大于生产 `1000ms` 轮询间隔,不得使用同为 `1000ms` 的默认上限制造 CI 边界竞争。
|
||||
- 退出与 Runner:独立 game-chat release 正常退出 Tauri 事件循环时调用仅供该 flavor 使用的认证 `runner.shutdown_for_client_exit`。Runner 先进入 draining、拒绝新的 Runtime 写请求,再无条件请求结束本 boot;已有 durable task / handoff / retry / reconciliation 状态不得伪装为 completed 或被删除,下次启动按既有 reconciliation 合同恢复。Windows game-chat 客户端以 `CREATE_SUSPENDED` 创建 Runner,在其执行用户代码前立即加入由客户端持有的 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` Job,严格复核唯一主线程后再恢复运行;分配或恢复失败必须 kill + wait 并令客户端启动失败。Runner 的 command、MCP、Git、PTY 及其它未 breakaway 后代随客户端异常退出或 Job handle 关闭一起终止;Runner 正常退出前仍先执行既有进程会话收束。普通 dev / release 与 CLI 继续使用 `runner.shutdown_if_idle`,不改变共享 Runner 的原生命周期。
|
||||
- Windows 后台进程可见性:所有不需要交互控制台的 `command.exec / project.verify`、STDIO MCP、Repository Context Git、`git.inspect / project.git_commit` 和清理用 `taskkill` 必须使用 `CREATE_NO_WINDOW`;需要独立终止边界的命令可同时使用 `CREATE_NEW_PROCESS_GROUP`,不得使用会增加孤儿风险的 `DETACHED_PROCESS`。game-chat release 禁止再打开 launcher、workspace 或其它平行 Tauri 页面。
|
||||
- 真实浏览器回归:显式运行 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml real_chrome_generic_playtest_ --features game-chat-release -- --ignored --nocapture --test-threads=1`,同时用真实 Chrome / Chromium / Edge 证明尚未接受真实 `primary-action` 就瞬时进入 `lost`、以及两次受控尝试都固定 `lost` 的页面必须判失败;首轮合法 `lost`、restart 恢复后第二轮进入 `playing | won` 的页面可以判通过。
|
||||
@@ -777,6 +777,7 @@ game-project/
|
||||
- 验证:`npm run agc:test` 已通过确定性 loopback Provider、真实 Runtime、项目写入和浏览器链路验收:同一父 Run 下 16 个 manifest task 均只有一个 logical run、一次 start、一次 completed 和一次 manifest projection,且无 failed / cancelled;父 run 与全部子 run 完成,最终 revision 为 `11`,基础正式产物、静态 smoke、桌面 / 移动 `37/37` 试玩通过,pending、reconciliation、Provider 失败、重复和泄漏计数均为 `0`。该结果不替代独立外部 Provider 验收。
|
||||
- 2026-07-26 本轮已验证 `npm run agc:config` 的终端配置链路。向导与 GUI 使用同一 Tauri identifier 对应的系统 AppData 和同名 `game-creator.config.json` / 可选 local overlay;读取已有配置时只更新有效 LLM 层,保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置。API Key 只从隐藏输入读取,拒绝 `--api-key`、仓库内目录、Git 已跟踪配置、符号链接,以及不是以 `world.genarrative.ai-game-creator` 为独立叶目录的 `--config-dir`,防止把任意父目录整体改成私有权限。保存使用同目录 `0600` 临时文件原子替换,POSIX AppData 目录保持 `0700`,Windows 使用当前用户独占 DACL,写后复用真实 `--llm-status` 检查;隐藏输入收到 `SIGINT / SIGTERM / SIGHUP` 时先恢复 raw mode 和 pause 状态再重发原信号,向导启动的 Cargo / npm 使用独立进程组并在信号路径有界收束整棵子进程树。
|
||||
- 2026-07-27 Windows DACL 启动回归修正:`powershell.exe -Command` 后追加的位置参数会被 PowerShell 5.1 拼接进命令文本,不能用 `$args` 安全接收包含空格的 AppData / 临时目录。DACL 脚本改为从仅传给该子进程的环境变量读取目标绝对路径和目录标记;`npm run agc:typecheck` 必须在真实 Windows 上执行配置回归,保证 `npm run agc` 的 `beforeDevCommand` 不因路径解析失败退出。
|
||||
- 2026-07-31 macOS 临时路径回归修正:配置目的地安全检查返回解析过现存父目录的真实路径,回归 fixture 的期望值也必须先使用平台原生 `realpath` 规范化临时根目录。macOS 下 `/var/folders/...` 与 `/private/var/folders/...` 是同一目录身份,不得用未规范化字符串阻断 `agc:typecheck`。CI 还必须使用“真实目录 + 符号链接父目录 + 不存在叶目录”确定性复现该语义;Windows 使用 junction 覆盖驱动器号、大小写与链接路径差异。fixture 的规范化与断言必须位于同一 `try/finally` 清理边界内。
|
||||
- 2026-07-27 项目总控右栏空态与持久状态水合修正:项目尚未产生 Runtime 时仍显示“尚未开始”状态块和创作入口,不把消息列表的弹性剩余空间裸露为空白;若 active Session 索引缺失但项目内已有 `project-supervisor` Runtime,工作台必须从 `read_game_creator_agent_runtimes` 的权威项目列表恢复总控 Session 与状态。`needs-reconciliation` 统一显示为“失败 / 待核对”,不能因对话索引缺失隐藏已落盘的失败事实。
|
||||
- 2026-07-28 Windows `tool-plan` 成功响应交接修正:相对目录句柄下安装 handoff 账本改用 `NtSetInformationFile(FileRenameInformation)`;`SetFileInformationByHandle(FileRenameInfo)` 不接受当前实现所需的非空 `RootDirectory`,会稳定返回 `ERROR_INVALID_PARAMETER (87)` 并让总控首轮进入 `needs-reconciliation`。实现继续绑定已验证的父目录句柄和相对 hash 文件名,不退化为绝对路径 rename;“按句柄安装”归入 `tool-plan-storage`。总控对 reconciliation 提供“已核对,结束旧任务”,取消后有 pending task 时只等待 Runner 续跑,队列为空时才允许显式 retry;自主构建 Supervisor 的 retry source 从原 Run Profile 绑定恢复并重新验证为可信 GUI / CLI 根入口,不降级成普通后台任务来源。
|
||||
- 2026-07-28 `tool-plan` 格式修复协议与 steer 审计修正:OpenAI Responses 请求按角色映射内容块,system / user 文本为 `input_text`,assistant 计划预览为 `output_text`,assistant `input_image` 在本地校验阶段拒绝,避免 repair 请求因非法 `input_text` 被上游以 HTTP 400 拒绝。tool-plan protocol / repair 审计增加 `appliedSteerCursor`,幂等键也纳入该 cursor;等待 Provider retry 期间 steer 后,同一 run / loop 可在新 cursor 下合法重用 `repair-0` 逻辑槽。旧记录缺少 cursor 时仅视为 `0`,保持升级后 replay 幂等。回归必须覆盖 Responses 多角色序列化、user 多模态,以及“无效计划 → repair retry 等待 → steer → 新 cursor repair”链路。
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type {
|
||||
@@ -195,7 +195,7 @@ function UploadWorkflowHarness({
|
||||
{assets
|
||||
.map(
|
||||
(asset) =>
|
||||
`${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}:${asset.width}x${asset.height}`,
|
||||
`${asset.id}:${asset.label}:${asset.folderId}:${asset.uploadStatus ?? 'ready'}:${asset.uploadMessage ?? '-'}:${asset.width}x${asset.height}:${asset.src}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
@@ -211,7 +211,7 @@ function UploadWorkflowHarness({
|
||||
{layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}:${layer.width}x${layer.height}:${layer.objectKey ?? '-'}`,
|
||||
`${layer.id}:${layer.title}:${layer.sourceAssetId}:${layer.x}:${layer.y}:${layer.width}x${layer.height}:${layer.objectKey ?? '-'}:${layer.src}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
@@ -380,6 +380,10 @@ function UploadWorkflowHarness({
|
||||
}
|
||||
|
||||
describe('useImageCanvasUploadWorkflow', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createEditorAssetMock.mockImplementation(async (input) => ({
|
||||
@@ -531,6 +535,19 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
});
|
||||
|
||||
it('creates an uploading asset card, adds a canvas layer, keeps the sidebar, and patches the layer with the persisted asset id', async () => {
|
||||
const measuredImageSources: string[] = [];
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class {
|
||||
naturalWidth = 0;
|
||||
naturalHeight = 0;
|
||||
onload: (() => void) | null = null;
|
||||
|
||||
set src(value: string) {
|
||||
measuredImageSources.push(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
const deferredAsset = createDeferred<{
|
||||
assetId: string;
|
||||
folderId: string;
|
||||
@@ -549,10 +566,10 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('assets').textContent).toContain(
|
||||
'upload-1:画布素材.png:folder-uploaded:uploading:上传中',
|
||||
'upload-1:画布素材.png:folder-uploaded:uploading:上传中:420x315:https://signed.example.com/editor/image/画布素材.png',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-upload-1:画布素材.png:upload-1:-160:-107.5:420x315:generated-character-drafts/editor/asset-library/image/画布素材.png',
|
||||
'layer-upload-1:画布素材.png:upload-1:-160:-107.5:420x315:generated-character-drafts/editor/asset-library/image/画布素材.png:https://signed.example.com/editor/image/画布素材.png',
|
||||
);
|
||||
});
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
@@ -568,6 +585,12 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
assetObjectId: 'assetobj-editor-image',
|
||||
}),
|
||||
);
|
||||
expect(measuredImageSources).toContain(
|
||||
'https://signed.example.com/editor/image/画布素材.png',
|
||||
);
|
||||
expect(measuredImageSources).not.toContain(
|
||||
'/generated-character-drafts/editor/asset-library/image/画布素材.png',
|
||||
);
|
||||
expect(screen.getByTestId('sidebar').textContent).toBe('assets');
|
||||
expect(screen.getByTestId('selected-layer').textContent).toBe(
|
||||
'layer-upload-1',
|
||||
@@ -577,7 +600,8 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
assetId: 'asset-persisted-canvas',
|
||||
folderId: 'folder-uploaded',
|
||||
label: '画布素材.png',
|
||||
imageSrc: 'data:image/png;base64,Y2FudmFz',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/asset-library/image/画布素材.png',
|
||||
width: 420,
|
||||
height: 315,
|
||||
sourceType: 'uploaded',
|
||||
@@ -587,10 +611,10 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('assets').textContent).toContain(
|
||||
'asset-persisted-canvas:画布素材.png:folder-uploaded:ready:-',
|
||||
'asset-persisted-canvas:画布素材.png:folder-uploaded:ready:-:420x315:https://signed.example.com/editor/image/画布素材.png',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5',
|
||||
'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5:420x315:object-key-canvas:https://signed.example.com/editor/image/画布素材.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -976,7 +976,8 @@ export function useImageCanvasUploadWorkflow({
|
||||
}),
|
||||
);
|
||||
|
||||
let imageSrc = '';
|
||||
let previewSrc = '';
|
||||
let persistedImageSrc = '';
|
||||
let objectKey: string | null = null;
|
||||
let assetObjectId: string | null = null;
|
||||
let durationSeconds: number | undefined;
|
||||
@@ -989,14 +990,15 @@ export function useImageCanvasUploadWorkflow({
|
||||
));
|
||||
}
|
||||
const uploadedMedia = await uploadEditorMediaAssetFile(file, mediaType);
|
||||
imageSrc = uploadedMedia.legacyPublicPath;
|
||||
previewSrc = uploadedMedia.src;
|
||||
persistedImageSrc = uploadedMedia.legacyPublicPath;
|
||||
objectKey = uploadedMedia.objectKey;
|
||||
assetObjectId = uploadedMedia.assetObjectId;
|
||||
setAssets((currentAssets) =>
|
||||
applyUploadAssetReadSuccess({
|
||||
assets: currentAssets,
|
||||
uploadAssetId: uploadedAsset.id,
|
||||
imageSrc,
|
||||
imageSrc: previewSrc,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
@@ -1012,7 +1014,7 @@ export function useImageCanvasUploadWorkflow({
|
||||
const nextLayer = createUploadCanvasLayer({
|
||||
uploadIndex,
|
||||
fileName: file.name,
|
||||
imageSrc,
|
||||
imageSrc: previewSrc,
|
||||
objectKey,
|
||||
assetObjectId,
|
||||
mediaType,
|
||||
@@ -1038,7 +1040,7 @@ export function useImageCanvasUploadWorkflow({
|
||||
createEditorAsset({
|
||||
folderId: uploadFolderId,
|
||||
label: uploadedAsset.label,
|
||||
imageSrc,
|
||||
imageSrc: persistedImageSrc,
|
||||
...(objectKey ? { objectKey } : {}),
|
||||
...(assetObjectId ? { assetObjectId } : {}),
|
||||
width: uploadedAsset.width,
|
||||
@@ -1047,11 +1049,15 @@ export function useImageCanvasUploadWorkflow({
|
||||
...(durationSeconds ? { durationSeconds } : {}),
|
||||
})
|
||||
.then((asset) => {
|
||||
const persistedAssetWithPreview = {
|
||||
...asset,
|
||||
imageSrc: previewSrc,
|
||||
};
|
||||
setAssets((currentAssets) =>
|
||||
applyPersistedUploadAsset({
|
||||
assets: currentAssets,
|
||||
uploadAssetId: uploadedAsset.id,
|
||||
persistedAsset: asset,
|
||||
persistedAsset: persistedAssetWithPreview,
|
||||
}),
|
||||
);
|
||||
if (options.addToCanvas) {
|
||||
@@ -1059,7 +1065,7 @@ export function useImageCanvasUploadWorkflow({
|
||||
bindUploadLayerToPersistedAsset({
|
||||
layers: currentLayers,
|
||||
layerId: nextLayer.id,
|
||||
persistedAsset: asset,
|
||||
persistedAsset: persistedAssetWithPreview,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1078,7 +1084,7 @@ export function useImageCanvasUploadWorkflow({
|
||||
);
|
||||
});
|
||||
|
||||
if (mediaType === 'image' && imageSrc) {
|
||||
if (mediaType === 'image' && previewSrc) {
|
||||
const uploadedImage = new Image();
|
||||
uploadedImage.onload = () => {
|
||||
const originalWidth =
|
||||
@@ -1110,7 +1116,7 @@ export function useImageCanvasUploadWorkflow({
|
||||
}),
|
||||
);
|
||||
};
|
||||
uploadedImage.src = imageSrc;
|
||||
uploadedImage.src = previewSrc;
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user