Compare commits

...
11 Commits
156 changed files with 8970 additions and 3136 deletions

No files matched your search

+3
View File
@@ -6,6 +6,9 @@ OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
OPENAI_MODEL=gpt-6-luna OPENAI_MODEL=gpt-6-luna
# 单次模型 HTTP 请求超时(秒) # 单次模型 HTTP 请求超时(秒)
OPENAI_TIMEOUT_SECONDS=300 OPENAI_TIMEOUT_SECONDS=300
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
OPENAI_USE_SYSTEM_PROXY=false
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。 # 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120 ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。 # 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
+12 -2
View File
@@ -21,7 +21,7 @@
| 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) | | 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) |
| 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md | | 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md |
| 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 | | 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 |
| 继续历史开发、确认版本或发布 | [迁入时的开发快照](docs/development-handoff.md)、[2.1.0 发行说明](docs/releases/2.1.0.md),再核对实时 Git 状态;`docs/plans/` 保留各自阶段的状态 | | 继续历史开发、确认版本或发布 | [README 当前发行入口](README.md)、对应发行说明及实时 Git 状态;[开发快照](docs/development-handoff.md)与 `docs/plans/` 仅作为历史线索,不替代当前源码与发布包核验 |
## Quick Commands ## Quick Commands
@@ -60,7 +60,7 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
## Conventions ## Conventions
- Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。 - Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档;默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。 - 新增可调参数集中进入配置层,按作用域更新环境示例、网页/MCP 入口和文档;明确仅供环境配置的参数不增加前端入口。JSON 默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。 - MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
- 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。 - 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。 - Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
@@ -75,6 +75,16 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
- 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。 - 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。
- 提交前检查 `git diff --check` 和暂存范围,只暂存本次具体路径;报告实际执行的验证及未完成项,不将未运行的检查写成通过。 - 提交前检查 `git diff --check` 和暂存范围,只暂存本次具体路径;报告实际执行的验证及未完成项,不将未运行的检查写成通过。
## Release Gates
- 主程序、SDK、插件和第三方依赖独立版本化。主程序升版只修改 `pyproject.toml` 的项目版本、`uv.lock` 的 artemis 条目及前端应用的根版本字段,发行文档另行更新。禁止对仓库或整个锁文件全局替换版本字符串。
- 单独审查锁文件差异。第三方条目的版本、下载地址、完整性校验和依赖声明必须相互匹配;主程序升版不应改变这些条目。真正升级依赖时由包管理器生成并核对上游声明;发生 EINTEGRITY 先核验来源,禁止关闭校验或只照错误提示改哈希。
- 发布前必须在隔离目录解压最终 ZIP,使用独立空 npm 缓存完成 `npm ci` 和生产构建,覆盖本次发布的官方/国内安装路径。包内安装或构建输入变化后必须重跑;不得清空用户全局缓存或改动用户运行目录来制造干净环境。
- 记录实际 Node/npm 版本、镜像、源码提交、ZIP SHA256 和验证范围。已有 node_modules、构建指纹、缓存命中或大量单元测试通过,均不能证明全新安装成功;某个 Node 版本通过也不能冒充其他版本实测。测试和构建命令见前端局部指南。
- 发布顺序:固定提交并打包 → 验证最终包 → 推送标签 → 创建草稿并上传包及校验文件 → 回读校验 → 正式发布 → 无认证下载核验。标签必须指向打包提交;推送标签后检查 Release 状态,本仓库曾出现 Gitea 将已有草稿自动发布的情况。失败时修复并重新验证,不提前宣布完成。
- 候选包与正式包目录明确区分。候选包失效后移出可分发路径并标明替代版本,不能留下同名旧 ZIP 供用户误取;对外只给已核验的正式发布链接或准确文件路径。正式版本不覆盖原标签或附件,后续修复发补丁版。
- 安装或市场版本不符时,先核对用户实际 ZIP 的 SHA256、运行源码提交及生效市场地址;不凭界面版本号判断,也不把未经验证的缓存假设当成原因。主程序与插件版本、索引兼容性分别验证。
## Boundaries ## Boundaries
- 始终保护用户任务、历史库、环境配置和凭据。不要把个人设备序列号、绝对目录或 API Key 写入源码。 - 始终保护用户任务、历史库、环境配置和凭据。不要把个人设备序列号、绝对目录或 API Key 写入源码。
+15
View File
@@ -64,8 +64,14 @@ ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 | | 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 | | 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
插件运行辅助接口见 [SDK 与市场材料](docs/plugin-sdk.md):已批准文件用不可变快照绑定实际消费字节,受管外部工具在返回前回收后代。宿主负责 MCP 总预算,插件负责内部超时和取消。开发检查保持轻量;市场材料使用独立内容指纹与验收记录校验,不代替源码、权利来源或真实业务审查。
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。 请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。
运行时准备阶段失败记录 `initialization_failed`,批次保留详细原因并暂停未开始项,修复环境后由用户继续,已失败项不自动重跑。模型请求导致的批次暂停单独标记;模型暂停结束后恢复既定调度及失败即停止策略。用户暂停、停止全部任务和服务重启后的恢复暂停均需用户继续,不被模型恢复覆盖。Flash 与 Pro/探索 Operator 的生成失败复用统一备用模型与重试预算;不完整输出不会成为动作,已有动作不因模型切换而重放。
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。 最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
Responses 消息交给工具、下游解析器或正式裁定前,必须具有服务端 `completed` 状态;工具参数的原始字符串必须是完整 JSON 对象,不能使用库自动补全的半截参数。流结束或库补出的 `last` 标记不能替代完成状态。缺少终止事件、明确未完成或失败的流丢弃预览,进入现有无效输出恢复策略,最多重新请求一次,配置为零次重试时不重试;恢复保留已有工具证据,不重新提交业务任务。非流式、结构化输出和串接解析器遵守同一检查。 Responses 消息交给工具、下游解析器或正式裁定前,必须具有服务端 `completed` 状态;工具参数的原始字符串必须是完整 JSON 对象,不能使用库自动补全的半截参数。流结束或库补出的 `last` 标记不能替代完成状态。缺少终止事件、明确未完成或失败的流丢弃预览,进入现有无效输出恢复策略,最多重新请求一次,配置为零次重试时不重试;恢复保留已有工具证据,不重新提交业务任务。非流式、结构化输出和串接解析器遵守同一检查。
@@ -84,6 +90,10 @@ Responses 消息交给工具、下游解析器或正式裁定前,必须具有
执行平台与证据来源分别记录。报告和证据索引从执行记录汇总来源及实际 step_id/call_id/source_id;界面、接口、文件和终端各自只能支持其观察范围,业务后台名称或网址不能推断平台。最终复核明确事实、矛盾和未验证边界;来源清单不生成新结论,不覆盖 AI 原判、有效结论或人工复验。 执行平台与证据来源分别记录。报告和证据索引从执行记录汇总来源及实际 step_id/call_id/source_id;界面、接口、文件和终端各自只能支持其观察范围,业务后台名称或网址不能推断平台。最终复核明确事实、矛盾和未验证边界;来源清单不生成新结论,不覆盖 AI 原判、有效结论或人工复验。
原生安卓动作在派发时记录所选设备来源。安卓截图来源也可从已保存的成功截图回执读取设备、采集时间、文件路径和校验值,并引用实际 trace_id;兼容 Flash 与 Pro 的回执封装,不从模型参数补造来源。空步骤、笔记和结束声明不计入缺失动作来源;窗口枚举等无单一目标的操作保留未关联状态,该计数不等于截图丢失。未生成独立输出正文时,报告展示当前有效判定及依据,保留人工复验优先级,不把正文缺失写成验收结论缺失。
规划器的笔记工具与执行器的设备、插件工具分别绑定。规划器依据任务的实际授权插件清单制定计划,由执行器加载并验证能力;规划阶段没有绑定动作工具,不表示任务缺少这些能力,也不构成重新申请授权的依据。
MCP 与单任务 HTTP 入参拒绝未知字段,校验失败不调度任务。MCP JSON 结果保留兼容文本并提供 structuredContent;任务摘要分页明确总数与 next_offset,引用实际存储编号。跨端阶段先完整初始化,再按既有阶段 id 更新状态与依据;补丁保留其他阶段、范围、资源和顺序,合并后统一校验并保存,失败不更新内存状态。 MCP 与单任务 HTTP 入参拒绝未知字段,校验失败不调度任务。MCP JSON 结果保留兼容文本并提供 structuredContent;任务摘要分页明确总数与 next_offset,引用实际存储编号。跨端阶段先完整初始化,再按既有阶段 id 更新状态与依据;补丁保留其他阶段、范围、资源和顺序,合并后统一校验并保存,失败不更新内存状态。
Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。 Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。
@@ -147,6 +157,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
- 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。 - 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。
- 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。 - 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。
- 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。 - 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。
- 结束任务的错误与阻塞说明默认只占一行,点击展开安全 Markdown 正文与已有依据,长正文限高滚动。摘要优先采用已保存的有效结论原因,否则取执行错误首行;不新增 AI 复盘请求,不改写原始记录。展开说明不触发任务切换,刷新保留展开状态。
## Elevation ## Elevation
@@ -191,3 +202,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。 回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。
评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。 评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。
执行详情中的 Payload 默认折叠;其他超长字段也默认折叠,展开后保留完整可复制内容并限制显示高度。原始用例面板仅由启用的工作区插件声明,额外导入列不影响未启用插件的用户。评审支持所有执行模式和目标,用户修改评审后自动保存:结果选择立即保存,备注短暂防抖;切换任务前保存,失败时保留输入和当前任务。仅用户编辑触发写入,AI 不推断人工结论。
模型网络代理策略仅由环境项 `OPENAI_USE_SYSTEM_PROXY` 控制,默认 false。同步、异步模型客户端和模型凭据检查统一遵循;不出现在前端配置中,不改变 TLS 验证与自定义 CA 设置。
+1 -1
View File
@@ -2,7 +2,7 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
**2.3.0**:新增工作区插件接口,支持步骤回放左右侧栏、Markdown 初始需求、人工评审及报告字段扩展;配套「任务人工评审 1.0.0」从插件市场独立安装。新增 gpt-6.1-terra,探索默认 high。见 [2.3.0 发行说明](docs/releases/2.3.0.md);上一版本见 [2.2.0](docs/releases/2.2.0.md)。 **2.4.5**:补齐插件开发辅助接口与市场材料检查,提供已批准文件快照、受管外部工具、故障自测和可运行模板。新 SDK 需 ARTEMIS 2.4.5;现有插件继续按自身清单兼容旧版本。见 [2.4.5 发行说明](docs/releases/2.4.5.md)与 [SDK 使用说明](docs/plugin-sdk.md)。人工评审插件保持 1.1.0;任务说明折叠见 [2.4.4](docs/releases/2.4.4.md),批次异常隔离见 [2.4.3](docs/releases/2.4.3.md)。
## 主要功能 ## 主要功能
@@ -15,6 +15,7 @@
import json import json
import logging import logging
import os import os
import re
import sqlite3 import sqlite3
import time import time
from typing import Any from typing import Any
@@ -235,6 +236,47 @@ class SessionRepository:
rows = cursor.fetchall() rows = cursor.fetchall()
return [r[0] for r in rows if r and r[0]] return [r[0] for r in rows if r and r[0]]
def get_recorded_model_info_map(self, session_ids: list[str]) -> dict[str, dict[str, str]]:
"""Read each session's own requested model from its earliest diagnostics.
Prefer operator/Flash requests over auxiliary roles. Older sessions may
have no model diagnostics; do not replace their missing data with the
current global configuration.
"""
result: dict[str, dict[str, str]] = {}
safe_payload = "CASE WHEN json_valid(t.payload) THEN t.payload ELSE '{}' END"
valid_model = f"json_type({safe_payload}, '$.model') = 'text'"
role = f"lower(json_extract({safe_payload}, '$.role'))"
with db_session(self.db_path) as conn:
for offset in range(0, len(session_ids), 500):
batch = session_ids[offset : offset + 500]
placeholders = ",".join("?" for _ in batch)
rows = conn.execute(
"SELECT s.session_id, COALESCE("
" (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id"
f" AND t.type = 'llm_diagnostic' AND {valid_model}"
f" AND {role} IN ('operator', 'flashrunner')"
" ORDER BY t.timestamp ASC LIMIT 1),"
" (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id"
f" AND t.type = 'llm_diagnostic' AND {valid_model}"
" ORDER BY t.timestamp ASC LIMIT 1)"
") AS model_payload FROM sessions s"
f" WHERE s.session_id IN ({placeholders})",
batch,
).fetchall()
for row in rows:
if not row[1]:
continue
payload = json.loads(row[1])
model = payload.get("model")
if isinstance(model, str) and model.strip():
provider = payload.get("provider")
result[str(row[0])] = {
"id": model,
"provider": provider if isinstance(provider, str) else "",
}
return result
def get_llm_traces_for_profiles_map( def get_llm_traces_for_profiles_map(
self, session_ids: list[str] | None = None, limit: int = 3 self, session_ids: list[str] | None = None, limit: int = 3
) -> dict[str, list[str]]: ) -> dict[str, list[str]]:
@@ -349,7 +391,22 @@ class SessionRepository:
total += t total += t
cached += _int(payload.get("cached_tokens")) cached += _int(payload.get("cached_tokens"))
node = str(payload.get("node") or "").strip().lower() node = str(payload.get("node") or "").strip().lower()
model_name = str(payload.get("model") or "unknown") model_name = payload.get("model")
if not model_name:
# Older gateway traces carry the model in their own endpoint key.
source = str(payload.get("source") or "")
source_parts = source.split(":", 2)
if len(source_parts) == 3 and source_parts[0] == "openai":
# Model aliases can contain colons. The HTTP(S) base URL
# marks the boundary; an ambiguous key stays unknown.
match = re.match(r"^openai:(.+?):https?://", source)
model_name = match[1] if match else None
elif len(source_parts) == 3 and source_parts[:2] in (
["lens", "visual_transition"],
["lens", "step_capsule"],
):
model_name = source_parts[2] if source_parts[2] != "fallback" else None
model_name = str(model_name or "unknown")
group = by_node_model.setdefault( group = by_node_model.setdefault(
(node, model_name), (node, model_name),
{ {
@@ -220,6 +220,41 @@ class StepRepository:
# recovering, waiting, completed, and terminal outcomes. Other tools # recovering, waiting, completed, and terminal outcomes. Other tools
# retain the historical compact payload to avoid sending large blobs. # retain the historical compact payload to avoid sending large blobs.
res = payload_obj.get("result") res = payload_obj.get("result")
if trace_type == "tool" and trace_name == "android_file":
# Preserve host-written file receipts, not file contents. A file's
# checksum is not a screenshot-store image ID.
if isinstance(res, str):
try:
res = json.loads(res)
except ValueError:
res = None
if isinstance(res, dict) and "outcome" in res:
res = res["outcome"]
if isinstance(res, str):
try:
res = json.loads(res)
except ValueError:
res = None
if isinstance(res, dict):
result_payload["result"] = {
key: self._clean_value(res[key])
for key in (
"ok",
"action",
"path",
"trace_path",
"device_path",
"bytes",
"sha256",
"verified",
"width",
"height",
"captured_at",
"device_serial",
)
if key in res
}
return result_payload
is_video_analysis = str(trace_name or "").lower() in { is_video_analysis = str(trace_name or "").lower() in {
"video_analysis", "video_analysis",
"video_analyzer", "video_analyzer",
+3 -1
View File
@@ -12,7 +12,7 @@ from apps.admin_console.services.exploration_listing import EXPLORATION_RUNS_CTE
from apps.admin_console.services.automation_store import identifier, store from apps.admin_console.services.automation_store import identifier, store
from apps.admin_console.services import automation_service as service from apps.admin_console.services import automation_service as service
from apps.admin_console.services import batch_archive as archive_service from apps.admin_console.services import batch_archive as archive_service
from apps.admin_console.services import report_export, task_import from apps.admin_console.services import report_export, task_extensions, task_import
from artemis.config.settings import settings from artemis.config.settings import settings
from apps.admin_console.services.case_review import read_reviewed_result from apps.admin_console.services.case_review import read_reviewed_result
@@ -131,7 +131,9 @@ def batch_by_request(request_id: str):
@router.get("/batches/{batch_id}") @router.get("/batches/{batch_id}")
def batch_detail(batch_id: str): def batch_detail(batch_id: str):
jobs = store.jobs(batch_id) jobs = store.jobs(batch_id)
reviewed = task_extensions.reviewed_batch_sessions(batch_id, store)
for job in jobs: for job in jobs:
job["human_reviewed"] = job["session_id"] in reviewed
job["test_result"] = read_reviewed_result( job["test_result"] = read_reviewed_result(
job["session_id"], job["status"], job.get("error") or "", storage=store job["session_id"], job["status"], job.get("error") or "", storage=store
) )
+10
View File
@@ -84,6 +84,9 @@ def _list_sessions_sync():
with store.connection() as connection: with store.connection() as connection:
exploration_runs = exploration_metadata(connection) exploration_runs = exploration_metadata(connection)
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows]) jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
recorded_models = session_repo.get_recorded_model_info_map(
[str(row.get("session_id")) for row in rows]
)
video_rec_map = session_repo.get_video_recordings_map() video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map() latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map() agent_names_by_session = session_repo.get_agent_trace_names_map()
@@ -217,6 +220,13 @@ def _list_sessions_sync():
print(f"[list_sessions] Failed to update orphaned sessions: {e}") print(f"[list_sessions] Failed to update orphaned sessions: {e}")
for row in result: for row in result:
# Session history must not inherit today's global model configuration.
row["model_info"] = {
**row["model_info"],
"id": "",
"provider": "",
**recorded_models.get(str(row.get("session_id")), {}),
}
if row.get("exploration_kind") == "validation" and row.get("exploration_case_name"): if row.get("exploration_kind") == "validation" and row.get("exploration_case_name"):
row["display_name"] = row["exploration_case_name"] row["display_name"] = row["exploration_case_name"]
model = row.pop("exploration_model", None) model = row.pop("exploration_model", None)
+24
View File
@@ -441,6 +441,24 @@ async def get_status():
) )
model_info = model_service.get_active_model_info(active_profile) model_info = model_service.get_active_model_info(active_profile)
model_sid = running_sid if is_running else latest_session_id
if model_info and model_sid:
recorded_model = session_repo.get_recorded_model_info_map([str(model_sid)]).get(
str(model_sid)
)
if not isinstance(recorded_model, dict):
matching_task = next(
(
item
for item in state.queue_items
if isinstance(item, dict) and str(item.get("session_id")) == str(model_sid)
),
None,
)
override = (matching_task or {}).get("runtime_overrides") or {}
model = override.get("model")
recorded_model = {"id": model, "provider": "openai"} if model else {}
model_info = {**model_info, "id": "", "provider": "", **recorded_model}
# Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets # Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets
global_queued = DeviceExecutionLock.get_queued_tasks() global_queued = DeviceExecutionLock.get_queued_tasks()
@@ -508,6 +526,12 @@ async def get_status():
is_paused = state.is_paused is_paused = state.is_paused
conn_profile = conn_info.get("profile") or active_profile conn_profile = conn_info.get("profile") or active_profile
conn_model_info = model_service.get_active_model_info(conn_profile) conn_model_info = model_service.get_active_model_info(conn_profile)
if conn_model_info and model_info:
conn_model_info = {
**conn_model_info,
"id": model_info["id"],
"provider": model_info["provider"],
}
return { return {
"status": "paused" if is_paused else "running", "status": "paused" if is_paused else "running",
"paused_error": state.paused_error if is_paused else None, "paused_error": state.paused_error if is_paused else None,
@@ -15,6 +15,7 @@ from artemis.runtime.task_limits import task_timeout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"} TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
MODEL_PAUSE_REASON = "模型请求已暂停,请先恢复模型连接再继续批次"
background_tasks: set[asyncio.Task] = set() background_tasks: set[asyncio.Task] = set()
@@ -123,7 +124,9 @@ def recover_batches():
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])): if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
continue continue
batch.update( batch.update(
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务" status="paused",
reason="服务已重启;请检查中断任务,点击继续执行未开始任务",
pause_source="recovery",
) )
for job in store.jobs(batch["id"]): for job in store.jobs(batch["id"]):
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}: if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
@@ -164,9 +167,29 @@ async def on_terminal(item: dict, status: str):
} }
): ):
job["status"] = "failed" job["status"] = "failed"
job["error"] = job.get("error") or "任务执行结束,但业务检查未通过" job["error"] = (
job.get("error")
or report.get("error")
or (report.get("test_result") or {}).get("reason")
or "任务未完成或验证未通过,请查看报告中的具体原因"
)
elif job["status"] == "failed" and report.get("error") and not job.get("error"): elif job["status"] == "failed" and report.get("error") and not job.get("error"):
job["error"] = report["error"] job["error"] = report["error"]
if job["status"] == "failed" and report["outcome"].get("code") == "initialization_failed":
job["failure_code"] = "initialization_failed"
batch = store.get("batch", job.get("batch_id")) if job.get("batch_id") else None
if (
batch
and (batch["status"] == "running" or batch.get("pause_source") == "model")
and any(j["status"] == "pending" for j in store.jobs(batch["id"]))
):
batch.update(
status="paused",
pause_source="initialization",
reason="任务初始化失败;请处理环境问题后继续剩余用例:"
+ (job.get("error") or "请查看任务报告"),
)
store.put("batch", batch["id"], batch)
# Persist terminal classification before export, but the coordinator waits for finalization. # Persist terminal classification before export, but the coordinator waits for finalization.
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]}) store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
await asyncio.to_thread(export_session, sid, job["formats"]) await asyncio.to_thread(export_session, sid, job["formats"])
@@ -217,10 +240,18 @@ async def tick_batches():
if batch["status"] not in {"running", "paused"}: if batch["status"] not in {"running", "paused"}:
continue continue
jobs = store.jobs(batch["id"]) jobs = store.jobs(batch["id"])
if (
batch["status"] == "paused"
and batch.get("pause_source") == "model"
and not state.is_paused
):
batch.update(status="running", reason="")
batch.pop("pause_source", None)
store.put("batch", batch["id"], batch)
active = [j for j in jobs if j["status"] in {"running", "queued", "finalizing"}] active = [j for j in jobs if j["status"] in {"running", "queued", "finalizing"}]
if active: if active:
if state.is_paused: if state.is_paused and batch["status"] == "running":
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次") batch.update(status="paused", reason=MODEL_PAUSE_REASON, pause_source="model")
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
for job in active: for job in active:
# The queue records started_at at submission, then replaces it # The queue records started_at at submission, then replaces it
@@ -284,7 +315,7 @@ async def tick_batches():
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
continue continue
if state.is_paused: if state.is_paused:
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次") batch.update(status="paused", reason=MODEL_PAUSE_REASON, pause_source="model")
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
continue continue
job = pending[0] job = pending[0]
@@ -438,11 +469,14 @@ def batch_action(batch_id: str, action: str) -> dict:
if action == "pause": if action == "pause":
if batch["status"] not in {"running", "paused"}: if batch["status"] not in {"running", "paused"}:
raise ValueError("只能暂停正在执行的批次") raise ValueError("只能暂停正在执行的批次")
batch.update(status="paused", reason="用户暂停;当前任务继续,后续任务等待") batch.update(
status="paused", reason="用户暂停;当前任务继续,后续任务等待", pause_source="user"
)
elif action == "resume": elif action == "resume":
if batch["status"] not in {"draft", "paused"}: if batch["status"] not in {"draft", "paused"}:
raise ValueError("只能开始草稿或继续已暂停批次") raise ValueError("只能开始草稿或继续已暂停批次")
batch.update(status="running", reason="") batch.update(status="running", reason="")
batch.pop("pause_source", None)
elif action == "retry_failed": elif action == "retry_failed":
if batch["status"] == "running": if batch["status"] == "running":
raise ValueError("请先暂停批次") raise ValueError("请先暂停批次")
+1 -1
View File
@@ -149,7 +149,7 @@ class MediaService:
return p return p
LIVE_RECORDING_GRACE_SECONDS = 10.0 LIVE_RECORDING_GRACE_SECONDS = 10.0
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_") TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_", "_BLOCKED_", "_UNVERIFIED_")
@classmethod @classmethod
def is_live_recording(cls, p: Path) -> bool: def is_live_recording(cls, p: Path) -> bool:
+30 -11
View File
@@ -36,7 +36,7 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
from apps.admin_console.services.case_review import reviewed_result from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2" BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-3"
def batch_archive_signature(batch_id: str | None = None) -> str: def batch_archive_signature(batch_id: str | None = None) -> str:
@@ -234,11 +234,14 @@ def session_report(session_id: str, db_path=None) -> dict:
if status in {"failed", "interrupted"} and verification.startswith("未验证"): if status in {"failed", "interrupted"} and verification.startswith("未验证"):
verification = "未验证(任务异常终止)" verification = "未验证(任务异常终止)"
if not content: if not content:
content = ( lines = ["本任务未保存独立输出正文。", f"当前有效判定:{verification}"]
"本任务未生成最终结论。请查看异常原因和已记录的执行步骤。" if test_result:
if status in {"failed", "interrupted"} if test_result.get("reason"):
else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。" lines.append(f"判定依据:{test_result['reason']}")
) lines.extend(f"- {item}" for item in test_result.get("evidence") or [])
elif error:
lines.append(f"异常原因:{error}")
content = "\n\n".join(lines)
return { return {
"id": session_id, "id": session_id,
**guidance_details(session_id, db_path=repo.db_path), **guidance_details(session_id, db_path=repo.db_path),
@@ -264,6 +267,7 @@ def session_report(session_id: str, db_path=None) -> dict:
"category": case.get("category", ""), "category": case.get("category", ""),
"subcategory": case.get("subcategory", ""), "subcategory": case.get("subcategory", ""),
"preconditions": case.get("preconditions", ""), "preconditions": case.get("preconditions", ""),
"original_case": case.get("original_case"),
"task_config": case.get("task_config") or {}, "task_config": case.get("task_config") or {},
"task_extensions": task_extensions.report_sections(session_id, store), "task_extensions": task_extensions.report_sections(session_id, store),
} }
@@ -330,17 +334,24 @@ def source_text(value: dict) -> str:
for item in value.get("sources", []): for item in value.get("sources", []):
references = ", ".join( references = ", ".join(
" / ".join( " / ".join(
f"{key}={ref[key]}" for key in ("step_id", "call_id", "source_id") if ref.get(key) f"{key}={ref[key]}"
for key in ("step_id", "call_id", "source_id", "trace_id")
if ref.get(key)
) )
or "编号未记录" or "编号未记录"
for ref in item.get("references", []) for ref in item.get("references", [])
) )
variation = "(来源标签随步骤变化,详见原始步骤)" if item.get("label_varies") else ""
lines.append( lines.append(
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']} · {references}" f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']}"
f"{variation} · {references}"
) )
if value.get("sources_omitted"): if value.get("sources_omitted"):
lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。") lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。")
lines.append(f"来源未完整记录的步骤:{value.get('unattributed_step_count', '未知')}。") lines.append(
f"未关联到单一来源的步骤:{value.get('unattributed_step_count', '未知')}。"
"窗口枚举等操作可能没有单一目标;此计数不表示截图丢失。"
)
lines.append(value.get("note") or "来源清单不代表断言已验证。") lines.append(value.get("note") or "来源清单不代表断言已验证。")
return "\n".join(lines) return "\n".join(lines)
@@ -367,6 +378,15 @@ def metadata(report: dict) -> list[tuple]:
("检测项目", report.get("category") or "—"), ("检测项目", report.get("category") or "—"),
("测试子项", report.get("subcategory") or "—"), ("测试子项", report.get("subcategory") or "—"),
("前置约束提取", report.get("preconditions") or "—"), ("前置约束提取", report.get("preconditions") or "—"),
*(
[
("原始用例 · 预置条件", report["original_case"].get("preconditions") or "未填写"),
("原始用例 · 操作步骤", report["original_case"].get("steps") or "未填写"),
("原始用例 · 预期结果", report["original_case"].get("expected") or "未填写"),
]
if report.get("original_case") is not None
else []
),
("任务配置", config_text(report.get("task_config"))), ("任务配置", config_text(report.get("task_config"))),
("任务目标", report["goal"]), ("任务目标", report["goal"]),
("预期结果", report["expected"] or "未指定"), ("预期结果", report["expected"] or "未指定"),
@@ -949,8 +969,7 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
return [] return []
source = Path(str(candidate)).resolve() source = Path(str(candidate)).resolve()
trace_root = TRACES_PATH.resolve() trace_root = TRACES_PATH.resolve()
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL # The recording row already ties the finalized trace folder to this session.
# folder. The recording row already ties that path to this session.
if not source.is_relative_to(trace_root): if not source.is_relative_to(trace_root):
return [] return []
# A resumed recording lives under resume_NNNN; the initial recording is # A resumed recording lives under resume_NNNN; the initial recording is
@@ -9,6 +9,7 @@ from apps.admin_console.services.automation_store import AutomationStore, identi
from apps.admin_console.database.repositories.session_repository import SessionRepository from apps.admin_console.database.repositories.session_repository import SessionRepository
from artemis.plugins.models import PluginManifest from artemis.plugins.models import PluginManifest
from artemis.plugins.store import PluginStore from artemis.plugins.store import PluginStore
from apps.admin_console.services.task_import import case_title
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"} TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
@@ -35,16 +36,58 @@ def installation_signature() -> str:
) )
def reviewed_batch_sessions(batch_id: str, store: AutomationStore) -> set[str]:
"""Read current human review values once per batch, without loading history."""
manifest = next((item for item in active_manifests() if item.id == "task-review"), None)
if manifest is None or manifest.workspace is None:
return set()
with store.connection() as conn:
rows = conn.execute(
"""SELECT j.session_id, json_extract(r.payload, '$."task-review".values') AS review_values
FROM automation_jobs j JOIN automation_records r
ON r.kind='task_extensions' AND r.id=j.session_id
WHERE j.batch_id=?""",
(batch_id,),
).fetchall()
reviewed = set()
for row in rows:
values = json.loads(row["review_values"] or "{}")
if any(
isinstance(value := values.get(field.id), str)
and value.strip()
and (field.kind == "text" or value in field.options)
for field in manifest.workspace.fields
):
reviewed.add(row["session_id"])
return reviewed
def task_context(session_id: str, store: AutomationStore) -> dict: def task_context(session_id: str, store: AutomationStore) -> dict:
identifier(session_id) identifier(session_id)
session = SessionRepository(store.path).get_session_by_id(session_id) or {} session = SessionRepository(store.path).get_session_by_id(session_id) or {}
job = store.job(session_id) or {} job = store.job(session_id) or {}
if not session and not job: if not session and not job:
raise ValueError("任务记录不存在") raise ValueError("任务记录不存在")
case = job.get("case") or {}
navigation = None
if batch_id := job.get("batch_id"):
jobs = [item for item in store.jobs(batch_id) if item.get("status") in TERMINAL]
ids = [item["session_id"] for item in jobs]
index = ids.index(session_id) if session_id in ids else -1
navigation = {
"scope": "batch",
"position": index + 1,
"total": len(ids),
"previous": ids[index - 1] if index > 0 else None,
"next": ids[index + 1] if 0 <= index < len(ids) - 1 else None,
}
return { return {
"session_id": session_id, "session_id": session_id,
"initial_prompt": session.get("initial_goal") or (job.get("case") or {}).get("goal", ""), "initial_prompt": session.get("initial_goal") or (job.get("case") or {}).get("goal", ""),
"editable": (job.get("status") or session.get("status")) in TERMINAL, "editable": (job.get("status") or session.get("status")) in TERMINAL,
"case_title": case_title(case),
"original_case": case.get("original_case"),
"navigation": navigation,
} }
+63 -6
View File
@@ -21,6 +21,7 @@ HEADERS = (
"Artemis提示词", "Artemis提示词",
"任务配置(JSON)", "任务配置(JSON)",
) )
CASE_HEADERS = ("预置条件", "操作步骤", "预期结果")
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000")) MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760")) MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
@@ -61,6 +62,15 @@ class TaskOptions(BaseModel):
return value.model_dump(exclude_unset=True) return value.model_dump(exclude_unset=True)
class OriginalCase(BaseModel):
"""Imported review material, never appended to the execution prompt."""
model_config = ConfigDict(extra="forbid")
preconditions: str = Field(default="", max_length=10000)
steps: str = Field(default="", max_length=10000)
expected: str = Field(default="", max_length=10000)
class TaskCase(BaseModel): class TaskCase(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
case_id: str = Field(default="", max_length=200) case_id: str = Field(default="", max_length=200)
@@ -70,16 +80,29 @@ class TaskCase(BaseModel):
preconditions: str = Field(default="", max_length=10000) preconditions: str = Field(default="", max_length=10000)
goal: str = Field(min_length=1, max_length=10000) goal: str = Field(min_length=1, max_length=10000)
task_config: TaskOptions = Field(default_factory=TaskOptions) task_config: TaskOptions = Field(default_factory=TaskOptions)
original_case: OriginalCase | None = None
def original_case_enabled() -> bool:
from apps.admin_console.services.task_extensions import active_manifests
return any(
panel.kind == "original_case"
for manifest in active_manifests()
if manifest.workspace is not None
for panel in manifest.workspace.panels
)
def template() -> bytes: def template() -> bytes:
headers = (*HEADERS, *CASE_HEADERS) if original_case_enabled() else HEADERS
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
assert ws is not None assert ws is not None
ws.title = "任务" ws.title = "任务"
ws.append(HEADERS) ws.append(headers)
ws.freeze_panes = "A2" ws.freeze_panes = "A2"
ws.auto_filter.ref = "A1:G1" ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}1"
for cell in ws[1]: for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF") cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="245C78") cell.fill = PatternFill("solid", fgColor="245C78")
@@ -93,7 +116,7 @@ def template() -> bytes:
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"]) help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"]) help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"]) help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
help_sheet.append(HEADERS) help_sheet.append(headers)
help_sheet.append( help_sheet.append(
[ [
"示例-001", "示例-001",
@@ -130,7 +153,18 @@ def template() -> bytes:
json.dumps({"profile": "flash", "execution_target": "computer"}, ensure_ascii=False), json.dumps({"profile": "flash", "execution_target": "computer"}, ensure_ascii=False),
] ]
) )
for column in range(1, 8): if len(headers) > len(HEADERS):
help_sheet.append(
[
"人工评审插件附加列:预置条件、操作步骤、预期结果。仅保存原始用例,均不追加到 AI 提示词。"
]
)
for row_number in (6, 7):
for column, value in enumerate(
("填写原始用例的预置条件", "填写原始用例的操作步骤", "填写原始用例的预期结果"), 8
):
help_sheet.cell(row_number, column, value)
for column in range(1, len(headers) + 1):
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26 help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
for row in help_sheet: for row in help_sheet:
for cell in row: for cell in row:
@@ -164,7 +198,10 @@ def parse_workbook(data: bytes) -> dict:
raise ValueError("缺少“任务”工作表,请使用下载的模板") raise ValueError("缺少“任务”工作表,请使用下载的模板")
rows = iter(wb["任务"].iter_rows()) rows = iter(wb["任务"].iter_rows())
headers = [str(cell.value or "").strip() for cell in next(rows, [])] headers = [str(cell.value or "").strip() for cell in next(rows, [])]
if headers != list(HEADERS): supported_headers = [list(HEADERS)]
if original_case_enabled():
supported_headers.append([*HEADERS, *CASE_HEADERS])
if headers not in supported_headers:
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板") raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
tasks, errors = [], [] tasks, errors = [], []
for number, row in enumerate(rows, 2): for number, row in enumerate(rows, 2):
@@ -190,9 +227,29 @@ def parse_workbook(data: bytes) -> dict:
"preconditions": values[4], "preconditions": values[4],
"goal": values[5], "goal": values[5],
"task_config": config, "task_config": config,
"original_case": (
dict(
zip(
("preconditions", "steps", "expected"),
(
str(cell.value if cell.value is not None else "").strip()
for cell in row[7:10]
),
)
)
if len(headers) == 10
else None
),
}
)
tasks.append(
{
"row": number,
**case.model_dump(
exclude={"original_case"} if case.original_case is None else None
),
} }
) )
tasks.append({"row": number, **case.model_dump()})
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
errors.append({"row": number, "message": str(exc)[:500]}) errors.append({"row": number, "message": str(exc)[:500]})
if not tasks and not errors: if not tasks and not errors:
@@ -1522,8 +1522,10 @@ class TaskQueueService:
from artemis.plugins.store import PluginStore from artemis.plugins.store import PluginStore
for batch in store.records("batch"): for batch in store.records("batch"):
if batch["status"] == "running": if batch["status"] in {"running", "paused"}:
batch.update(status="paused", reason="已停止全部任务,等待手动继续") batch.update(
status="paused", reason="已停止全部任务,等待手动继续", pause_source="user"
)
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
# 1. Cancel local queue reservations # 1. Cancel local queue reservations
for item in state.queue_items: for item in state.queue_items:
+3
View File
@@ -30,6 +30,9 @@ npm run build
## Local Conventions ## Local Conventions
- 发版遵守根指南的 Release Gates。前端应用版本只改 package.json 顶层 `version` 和 package-lock.json 顶层及 `packages[""].version`;禁止把相同数字的第三方依赖一并替换。
- Node 支持范围以锁定的 Angular CLI/构建依赖实际 `engines` 为准,核对启动脚本的主、次、补丁版本边界和提示;不要只判断“Node 22/24/26”。
- 安装或依赖变更需在隔离目录、独立空 npm 缓存下运行 `npm ci`,再运行上述测试和构建;国内入口同时核验镜像安装。不得以关闭完整性校验、强制安装或删除锁文件掩盖不一致,也不得用已装依赖的构建结果宣称全新安装通过。
- 页面组件在 src/app/components,服务在 src/app/services;先复用已有组件、样式及 API 服务。 - 页面组件在 src/app/components,服务在 src/app/services;先复用已有组件、样式及 API 服务。
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。 - 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。 - 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
+1859 -2314
View File
File diff suppressed because it is too large. Load diff
+17 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.3.0", "version": "2.4.5",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -9,24 +9,29 @@
"test": "ng test" "test": "ng test"
}, },
"private": true, "private": true,
"overrides": {
"@angular/build": {
"vite": "8.3.5"
}
},
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.2.4",
"@angular/common": "^22.1.4", "@angular/common": "^22.2.3",
"@angular/compiler": "^22.1.4", "@angular/compiler": "^22.2.3",
"@angular/core": "^22.1.4", "@angular/core": "^22.2.3",
"@angular/forms": "^22.1.4", "@angular/forms": "^22.2.3",
"@angular/platform-browser": "^22.1.4", "@angular/platform-browser": "^22.2.3",
"@angular/platform-browser-dynamic": "^22.1.4", "@angular/platform-browser-dynamic": "^22.2.3",
"@angular/router": "^22.1.4", "@angular/router": "^22.2.3",
"marked": "18.1.1", "marked": "18.1.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^22.1.6", "@angular/build": "^22.2.4",
"@angular/cli": "^22.1.6", "@angular/cli": "^22.2.4",
"@angular/compiler-cli": "^22.1.4", "@angular/compiler-cli": "^22.2.3",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"istanbul-lib-instrument": "^6.0.3", "istanbul-lib-instrument": "^6.0.3",
"jasmine-core": "~5.6.0", "jasmine-core": "~5.6.0",
@@ -129,9 +129,7 @@ limitations under the License.
<div class="item-goal" [title]="session.initial_goal || ''"> <div class="item-goal" [title]="session.initial_goal || ''">
{{ session.display_name || session.initial_goal || '未命名任务' }} {{ session.display_name || session.initial_goal || '未命名任务' }}
</div> </div>
@if (session.error && getTaskStatus(session) === 'failed') { <app-task-outcome [error]="session.error" [result]="session.test_result" [status]="getTaskStatus(session)" />
<div class="item-error" [title]="session.error">{{session.error}}</div>
}
<div class="item-footer"> <div class="item-footer">
<app-task-id-copy [taskId]="session.session_id" /> <app-task-id-copy [taskId]="session.session_id" />
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span> <span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
@@ -695,13 +693,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) { @if (getActionTargetText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">目标:</span> <span class="detail-label">目标:</span>
<span class="detail-value">"{{ getActionTargetText(item.data) }}"</span> <app-expandable-value class="detail-value" label="目标" [value]="getActionTargetText(item.data)" />
</div> </div>
} }
@if (getActionInputText(item.data)) { @if (getActionInputText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">{{ getActionInputLabel(item.data) }}:</span> <span class="detail-label">{{ getActionInputLabel(item.data) }}:</span>
<span class="detail-value">"{{ getActionInputText(item.data) }}"</span> <app-expandable-value class="detail-value" label="输入" [value]="getActionInputText(item.data)" />
</div> </div>
} }
@if (getActionCoords(item.data)) { @if (getActionCoords(item.data)) {
@@ -719,13 +717,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) { @if (getActionTargetText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">目标</span> <span class="grid-label">目标</span>
<span class="grid-value">"{{ getActionTargetText(item.data) }}"</span> <app-expandable-value label="目标" [value]="getActionTargetText(item.data)" />
</div> </div>
} }
@if (getActionInputText(item.data)) { @if (getActionInputText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">{{ getActionInputLabel(item.data) }}</span> <span class="grid-label">{{ getActionInputLabel(item.data) }}</span>
<span class="grid-value">"{{ getActionInputText(item.data) }}"</span> <app-expandable-value label="输入" [value]="getActionInputText(item.data)" />
</div> </div>
} }
@if (getActionCoords(item.data)) { @if (getActionCoords(item.data)) {
@@ -749,19 +747,7 @@ limitations under the License.
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) { @for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item" [class.full-width]="param.key === 'Result'"> <div class="grid-item" [class.full-width]="param.key === 'Result'">
<span class="grid-label">{{ param.key }}</span> <span class="grid-label">{{ param.key }}</span>
@if (param.key === 'Result') { <app-expandable-value [label]="param.key" [value]="param.value" />
<div class="action-result-preview">{{ param.value | slice:0:180 }}{{ param.value.length > 180 ? '…' : '' }}</div>
<details class="action-result-detail">
<summary>
<span class="result-expand-label">展开完整结果</span>
<span class="result-collapse-label">收起结果</span>
<span class="result-length">{{ param.value.length }} 字符</span>
</summary>
<pre tabindex="0" aria-label="完整操作结果,可选择复制">{{ param.value }}</pre>
</details>
} @else {
<span class="grid-value code-pill">{{ param.value }}</span>
}
</div> </div>
} }
<!-- Fallback item if all detail fields are empty --> <!-- Fallback item if all detail fields are empty -->
@@ -1008,7 +994,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) { @if (getAdbCommandLine(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">命令:</span> <span class="detail-label">命令:</span>
<span class="detail-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span> <app-expandable-value class="detail-value" label="命令" [value]="getAdbCommandLine(item.data)" />
</div> </div>
} }
@if (getAdbCwd(item.data)) { @if (getAdbCwd(item.data)) {
@@ -1029,13 +1015,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) { @if (getToolTargetText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">目标:</span> <span class="detail-label">目标:</span>
<span class="detail-value">"{{ getToolTargetText(item.data) }}"</span> <app-expandable-value class="detail-value" label="目标" [value]="getToolTargetText(item.data)" />
</div> </div>
} }
@if (getToolInputText(item.data)) { @if (getToolInputText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">{{ getToolInputLabel(item.data) }}:</span> <span class="detail-label">{{ getToolInputLabel(item.data) }}:</span>
<span class="detail-value">"{{ getToolInputText(item.data) }}"</span> <app-expandable-value class="detail-value" label="输入" [value]="getToolInputText(item.data)" />
</div> </div>
} }
@if (getToolCoords(item.data)) { @if (getToolCoords(item.data)) {
@@ -1047,13 +1033,13 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) { @if (getToolAnalysisText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">分析:</span> <span class="detail-label">分析:</span>
<span class="detail-value">"{{ getToolAnalysisText(item.data) }}"</span> <app-expandable-value class="detail-value" label="分析" [value]="getToolAnalysisText(item.data)" />
</div> </div>
} }
@if (getToolGenericDetails(item.data)) { @if (getToolGenericDetails(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">详情:</span> <span class="detail-label">详情:</span>
<span class="detail-value">"{{ getToolGenericDetails(item.data) }}"</span> <app-expandable-value class="detail-value" label="详情" [value]="getToolGenericDetails(item.data)" />
</div> </div>
} }
} }
@@ -1067,7 +1053,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) { @if (getAdbCommandLine(item.data)) {
<div class="grid-item full-width"> <div class="grid-item full-width">
<span class="grid-label">命令</span> <span class="grid-label">命令</span>
<span class="grid-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span> <app-expandable-value label="命令" [value]="getAdbCommandLine(item.data)" />
</div> </div>
} }
@if (getAdbCwd(item.data)) { @if (getAdbCwd(item.data)) {
@@ -1087,7 +1073,7 @@ limitations under the License.
@for (param of plugin.args; track param.key) { @for (param of plugin.args; track param.key) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">{{ param.key }}</span> <span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span> <app-expandable-value [label]="param.key" [value]="param.value" />
</div> </div>
} }
@if (plugin.result || plugin.rawResult || plugin.facts.length) { @if (plugin.result || plugin.rawResult || plugin.facts.length) {
@@ -1101,13 +1087,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) { @if (getToolTargetText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">目标</span> <span class="grid-label">目标</span>
<span class="grid-value">"{{ getToolTargetText(item.data) }}"</span> <app-expandable-value label="目标" [value]="getToolTargetText(item.data)" />
</div> </div>
} }
@if (getToolInputText(item.data)) { @if (getToolInputText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">{{ getToolInputLabel(item.data) }}</span> <span class="grid-label">{{ getToolInputLabel(item.data) }}</span>
<span class="grid-value">"{{ getToolInputText(item.data) }}"</span> <app-expandable-value label="输入" [value]="getToolInputText(item.data)" />
</div> </div>
} }
@if (getToolCoords(item.data)) { @if (getToolCoords(item.data)) {
@@ -1119,19 +1105,19 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) { @if (getToolAnalysisText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">分析</span> <span class="grid-label">分析</span>
<span class="grid-value">"{{ getToolAnalysisText(item.data) }}"</span> <app-expandable-value label="分析" [value]="getToolAnalysisText(item.data)" />
</div> </div>
} }
@if (getToolGenericDetails(item.data)) { @if (getToolGenericDetails(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">详情</span> <span class="grid-label">详情</span>
<span class="grid-value">"{{ getToolGenericDetails(item.data) }}"</span> <app-expandable-value label="详情" [value]="getToolGenericDetails(item.data)" />
</div> </div>
} }
@for (param of getToolExtraParams(item.data); track trackParam($index, param)) { @for (param of getToolExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">{{ param.key }}</span> <span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span> <app-expandable-value [label]="param.key" [value]="param.value" />
</div> </div>
} }
} }
@@ -3694,7 +3694,6 @@ $stream-row-height: 25px;
&.failed { border-left: 3.5px solid #dc2626; background: var(--app-error-soft, #fff7f7); } &.failed { border-left: 3.5px solid #dc2626; background: var(--app-error-soft, #fff7f7); }
&.completed { border-left: 3.5px solid #16a34a; } &.completed { border-left: 3.5px solid #16a34a; }
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; } &.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
.item-error { color: var(--app-error-text, #b91c1c); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
.item-header { .item-header {
display: flex; display: flex;
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { ExplorationMessage, Demonstration } from '../../services/exploration.service'; import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
import { mergeConversation } from '../../utils/exploration-timeline.util'; import { mergeConversation } from '../../utils/exploration-timeline.util';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
@@ -6,6 +7,7 @@ import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-sta
import { TaskLifecycleComponent } from '../task-lifecycle/task-lifecycle.component'; import { TaskLifecycleComponent } from '../task-lifecycle/task-lifecycle.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { PluginToolResultComponent } from '../plugin-tool-result/plugin-tool-result.component'; import { PluginToolResultComponent } from '../plugin-tool-result/plugin-tool-result.component';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component'; import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe'; import { UiLabelPipe } from '../../utils/ui-label.pipe';
/** /**
@@ -250,7 +252,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({ @Component({
selector: 'app-agent-stream', selector: 'app-agent-stream',
standalone: true, standalone: true,
imports: [PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent], imports: [TaskOutcomeComponent,ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html', templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss', styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -17,7 +17,7 @@
<div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div> <div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div> <div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />} @if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> } @if (r.error) { <app-task-outcome [error]="r.error" [status]="r.status" /> }
</div> </div>
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div> <div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
<app-evidence-sources [value]="r.evidence_sources || null" /> <app-evidence-sources [value]="r.evidence_sources || null" />
@@ -108,6 +108,19 @@
</div> </div>
} }
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>} @if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><app-favorite-button [sourceId]="j.session_id" /><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>} @for(j of b.jobs; track j.session_id){
<div class="job" [class.selected]="agent.currentSessionId() === j.session_id">
<div class="job-heading">
<app-favorite-button [sourceId]="j.session_id" />
<button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button>
</div>
<div class="job-statuses">
<span>{{label(j.status)}}</span>
<app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />
@if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>}
</div>
<app-task-outcome [error]="j.error" [result]="j.test_result" [status]="j.status" />
</div>
}
</section>} </section>}
} }
@@ -37,14 +37,21 @@ input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text,
@keyframes archive-spin{to{transform:rotate(360deg);}} @keyframes archive-spin{to{transform:rotate(360deg);}}
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:var(--app-surface-soft, #f8fafc);}.table-wrap{max-height:260px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid var(--app-border, #e2e8f0);text-align:left;overflow-wrap:anywhere;}th{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #475569);font-weight:500;} .code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:var(--app-surface-soft, #f8fafc);}.table-wrap{max-height:260px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid var(--app-border, #e2e8f0);text-align:left;overflow-wrap:anywhere;}th{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #475569);font-weight:500;}
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:var(--app-text-control, #334155);background:var(--app-surface-soft, #f8fafc);border-radius:8px;padding:12px;border:1px solid var(--app-border, #e2e8f0);} .preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:var(--app-text-control, #334155);background:var(--app-surface-soft, #f8fafc);border-radius:8px;padding:12px;border:1px solid var(--app-border, #e2e8f0);}
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid var(--app-border, #f1f5f9);}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:pointer;padding:8px 0;} .job{display:flex;flex-direction:column;gap:8px;padding:12px 0;border-bottom:1px solid var(--app-border, #f1f5f9);}
.job:last-child{border-bottom:0;}
.job-heading{position:relative;min-width:0;}
.job-heading>app-favorite-button{position:absolute;left:6px;top:4px;z-index:1;}
.job-heading>button{box-sizing:border-box;width:100%;min-width:0;padding-left:40px;justify-content:flex-start;text-align:left;overflow-wrap:anywhere;}
.job-statuses{display:flex;flex-wrap:wrap;align-items:center;gap:8px;}
.job-statuses>span{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;padding:0 6px;border:1px solid transparent;border-radius:5px;font-size:11px;line-height:14px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);white-space:nowrap;}
.job small{overflow-wrap:anywhere;}
details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:pointer;padding:8px 0;}
.task-guidance{margin-top:16px;padding:14px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);} .task-guidance{margin-top:16px;padding:14px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
.task-guidance label{margin:8px 0 4px;} .task-guidance label{margin:8px 0 4px;}
.report-overview{padding:14px;border:1px solid var(--app-primary-border, #dbeafe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);} .report-overview{padding:14px;border:1px solid var(--app-primary-border, #dbeafe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;} .report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
.report-title{display:block;font-size:14px;line-height:1.5;color:var(--app-text-strong, #0f172a);overflow-wrap:anywhere;} .report-title{display:block;font-size:14px;line-height:1.5;color:var(--app-text-strong, #0f172a);overflow-wrap:anywhere;}
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:20px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e40af);font-size:12px;&.failed{background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fecdd3);color:var(--app-error-text, #be123c);}}} .report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:20px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e40af);font-size:12px;&.failed{background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fecdd3);color:var(--app-error-text, #be123c);}}}
.report-error{margin-top:11px;padding:10px 12px;background:var(--app-error-soft, #fff1f2);border-left:3px solid #e11d48;border-radius:6px;color:var(--app-error-text, #9f1239);strong{font-size:12px;}p{margin:4px 0 0;color:var(--app-error-text, #9f1239);white-space:pre-wrap;}}
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}} .report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}}
.report-content{margin-top:14px;padding:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}} .report-content{margin-top:14px;padding:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}}
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b);line-height:1.75;} .report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b);line-height:1.75;}
@@ -58,3 +65,5 @@ input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text,
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid var(--app-border, #e2e8f0);vertical-align:top;}th{background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e3a8a);} table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid var(--app-border, #e2e8f0);vertical-align:top;}th{background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e3a8a);}
.md-para-gap{height:8px;} .md-para-gap{height:8px;}
} }
.job .human-review-badge{white-space:nowrap;border:1px solid var(--app-primary-border, #bfdbfe);background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1d4ed8);font-weight:500;}
@@ -90,6 +90,30 @@ describe('AutomationPanelComponent batch deletion', () => {
expect(http.post).not.toHaveBeenCalled(); expect(http.post).not.toHaveBeenCalled();
}); });
it('shows only saved human review badges and removes them on a batch refresh', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
const component = fixture.componentInstance;
await new Promise<void>(resolve => setTimeout(resolve, 0));
component.tab.set('batches');
const batch = { id: 'review-batch', name: '评审批次', status: 'completed', jobs: [
{ session_id: 'reviewed', status: 'completed', human_reviewed: true, case: { name: '已评用例' } },
{ session_id: 'empty', status: 'completed', human_reviewed: false, case: { name: '空白用例' } },
{ session_id: 'legacy', status: 'completed', case: { name: '旧版任务' } }
] };
component.batch.set(batch);
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.job');
expect(rows[0].querySelector('.human-review-badge').textContent).toBe('人工已审');
expect(rows[1].querySelector('.human-review-badge')).toBeNull();
expect(rows[2].querySelector('.human-review-badge')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('人工未审');
(http.get.and as any).callFake((url: string) => of(url.endsWith('/review-batch')
? { ...batch, jobs: batch.jobs.map(job => ({ ...job, human_reviewed: false })) } : []));
await component.refresh();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.human-review-badge')).toBeNull();
});
it('confirms deletion and removes the batch from the visible list', async () => { it('confirms deletion and removes the batch from the visible list', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent); const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches'); fixture.componentInstance.tab.set('batches');
@@ -129,7 +153,7 @@ describe('AutomationPanelComponent batch deletion', () => {
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}} {session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
]}); ]});
fixture.detectChanges(); fixture.detectChanges();
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job > button')]; const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job-heading > button')];
jobs[0].click(); jobs[1].click(); jobs[0].click(); jobs[1].click();
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]); expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
expect(component.tab()).toBe('batches'); expect(component.tab()).toBe('batches');
@@ -152,7 +176,12 @@ describe('AutomationPanelComponent batch deletion', () => {
}); });
fixture.detectChanges(); fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement; const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次'); const explanation = root.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
expect(explanation.open).toBeFalse();
expect(explanation.querySelector('summary')?.textContent).toContain('连续 3 次');
explanation.querySelector('summary')!.click();
expect(explanation.open).toBeTrue();
expect(explanation.querySelector('.body')?.textContent).toContain('连续 3 次');
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果'); expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
expect(root.querySelector('.report-body')?.textContent).toContain('第一行'); expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
expect(root.querySelector('.report-body')?.textContent).toContain('第二行'); expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core'; import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
@@ -24,7 +25,7 @@ interface ArchiveProgress {
} }
@Component({ @Component({
selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent], selector: 'app-automation-panel', standalone: true, imports: [TaskOutcomeComponent,FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
}) })
export class AutomationPanelComponent { export class AutomationPanelComponent {
@@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { ExpandableValueComponent } from './expandable-value.component';
describe('执行字段折叠', () => {
for (const [label, value] of [['Payload', '{"x":1}'], ['输入', '完整内容'.repeat(100)], ['输出', '行\n'.repeat(6)]]) {
it(`默认折叠 ${label} 并保留完整内容`, () => {
const fixture = TestBed.createComponent(ExpandableValueComponent);
fixture.componentRef.setInput('label', label);
fixture.componentRef.setInput('value', value);
fixture.detectChanges();
const details: HTMLDetailsElement = fixture.nativeElement.querySelector('details');
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(details.querySelector('pre')!.textContent).toBe(value);
fixture.detectChanges();
expect(details.open).toBeTrue();
});
}
it('直接显示普通短字段', () => {
const fixture = TestBed.createComponent(ExpandableValueComponent);
fixture.componentRef.setInput('value', '点击设置'); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('点击设置');
});
});
@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
/** Visual folding only: the full receipt remains selectable and unchanged. */
@Component({
selector: 'app-expandable-value', standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (foldable()) {
<details>
<summary><span>{{ label() }} · {{ value().length }} 字符</span><span class="hint">展开查看</span></summary>
<pre tabindex="0" [attr.aria-label]="label() + '完整内容,可选择复制'">{{ value() }}</pre>
</details>
} @else { <span class="short-value">{{ value() }}</span> }
`,
styles: [`
:host { display:block; min-width:0; max-width:100%; }
details { border:1px solid var(--app-border, #e2e8f0); border-radius:8px; background:var(--app-surface-soft, #f8fafc); }
summary { cursor:pointer; padding:7px 10px; font-size:12px; color:var(--app-text-secondary, #475569); overflow-wrap:anywhere; }
.hint { margin-left:8px; color:var(--app-primary-text, #1a73e8); }
details[open] .hint { display:none; }
pre { margin:0; padding:10px; max-height:24rem; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; user-select:text; }
.short-value { white-space:pre-wrap; overflow-wrap:anywhere; }
summary:focus-visible, pre:focus-visible { outline:2px solid var(--app-focus, #a5b4fc); outline-offset:2px; }
`]
})
export class ExpandableValueComponent {
value = input('');
label = input('详情');
foldable = computed(() => /payload|原始|^result$/i.test(this.label())
|| this.value().length > 240 || this.value().split('\n').length > 4);
}
@@ -122,7 +122,8 @@ limitations under the License.
} }
<!-- Video Main Content Area --> <!-- Video Main Content Area -->
@if (!agentService.isVideoMinimized()) { @if (!agentService.isVideoMinimized()) {
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="agentService.playerMode() === 'steps'" <app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="true"
[sessionIds]="reviewSessionIds()" (navigate)="agentService.selectSession($event, true)"
[taskStatus]="agentService.currentSession()?.status || ''" (expandedWidth)="extensionWidth.set($event)"> [taskStatus]="agentService.currentSession()?.status || ''" (expandedWidth)="extensionWidth.set($event)">
<div class="video-body"> <div class="video-body">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) --> <!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@@ -1,5 +1,5 @@
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service'; import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service';
@@ -41,6 +41,8 @@ describe('FloatingVideoPlayerComponent video markers', () => {
currentSessionStepFrames: signal([frame]), currentSessionStepFrames: signal([frame]),
currentSession: signal({ session_id: 'test', start_time: start }), currentSession: signal({ session_id: 'test', start_time: start }),
currentSessionId: signal('test'), currentSessionId: signal('test'),
sessions: signal([{ session_id: 'test', status: 'completed' }]),
selectSession: jasmine.createSpy('selectSession'),
videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null), videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null),
stepSeekRequest: signal(null), stepSeekRequest: signal(null),
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false) consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
@@ -66,6 +68,9 @@ describe('FloatingVideoPlayerComponent video markers', () => {
fixture = TestBed.createComponent(FloatingVideoPlayerComponent); fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
fixture.detectChanges(); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: []
});
component = fixture.componentInstance; component = fixture.componentInstance;
video = component.videoRef!.nativeElement; video = component.videoRef!.nativeElement;
paused = true; paused = true;
@@ -100,6 +105,23 @@ describe('FloatingVideoPlayerComponent video markers', () => {
fixture.detectChanges(); fixture.detectChanges();
} }
for (const mode of ['video', 'steps']) {
it(`shows review controls in ${mode} mode even without a recording`, () => {
agentService.playerMode.set(mode);
agentService.recordingPlaybackStatus.set('unavailable');
component.extensionPanels()!.refresh(); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: [{
plugin_id: 'review', name: '原始用例', version: '1.1.0', revision: 0, values: {}, history: [],
workspace: { fields: [], panels: [{ id: 'case', title: '原始用例', side: 'left', kind: 'original_case', width: {minimum: 240, preferred: 340, maximum: 480} }] }
}]
}); fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button[aria-label="展开原始用例"]');
expect(button).not.toBeNull(); button.click(); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('aside').textContent).toContain('未记录原始用例');
});
}
it('removes the live image while hidden and reconnects it on return', () => { it('removes the live image while hidden and reconnects it on return', () => {
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false); const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
agentService.recordingPlaybackStatus.set('live'); agentService.recordingPlaybackStatus.set('live');
@@ -51,6 +51,9 @@ import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-m
export class FloatingVideoPlayerComponent implements OnDestroy { export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService); public agentService = inject(AgentService);
public extensionWidth = signal(0); public extensionWidth = signal(0);
public reviewSessionIds = computed(() => this.agentService.sessions()
.filter(session => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(session.status || ''))
.map(session => session.session_id));
public extensionPanels = viewChild(TaskExtensionPanelsComponent); public extensionPanels = viewChild(TaskExtensionPanelsComponent);
public windowLeft(): number { public windowLeft(): number {
const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth(); const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth();
@@ -1,15 +1,17 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
import { PluginToolView } from '../../utils/plugin-tool-view.util'; import { PluginToolView } from '../../utils/plugin-tool-view.util';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
/** Readable presentation only: the full receipt and model context stay intact. */ /** Readable presentation only: the full receipt and model context stay intact. */
@Component({ @Component({
selector: 'app-plugin-tool-result', selector: 'app-plugin-tool-result',
standalone: true, standalone: true,
imports: [ExpandableValueComponent],
template: ` template: `
@if (view().facts.length) { @if (view().facts.length) {
<dl class="receipt-facts"> <dl class="receipt-facts">
@for (fact of view().facts; track fact.key) { @for (fact of view().facts; track fact.key) {
<div><dt>{{ fact.key }}</dt><dd>{{ fact.value }}</dd></div> <div><dt>{{ fact.key }}</dt><dd><app-expandable-value [label]="fact.key" [value]="fact.value" /></dd></div>
} }
</dl> </dl>
} }
@@ -44,14 +44,21 @@
<ng-template #panelContent let-plugin="plugin" let-panel="panel"> <ng-template #panelContent let-plugin="plugin" let-panel="panel">
<section [id]="'extension-' + key(plugin, panel)" [attr.aria-label]="panel.title"> <section [id]="'extension-' + key(plugin, panel)" [attr.aria-label]="panel.title">
<header><h3>{{ panel.title }}</h3><span class="panel-kind">{{ panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}</span></header> <header><h3>{{ panel.kind === 'original_case' ? (data()?.case_title || panel.title) : panel.title }}</h3><span class="panel-kind">{{ panel.kind === 'original_case' ? '原始用例' : panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}</span></header>
@if (panel.kind === 'initial_prompt') { @if (panel.kind === 'original_case') {
@if (!data()?.original_case) {
<p class="muted">此任务未记录原始用例内容;旧任务不会从 AI 提示词推测补写。</p>
}
<div class="case-section"><h4>预置条件</h4><app-report-markdown [content]="data()?.original_case?.preconditions || '未填写'" /></div>
<div class="case-section"><h4>操作步骤</h4><app-report-markdown [content]="data()?.original_case?.steps || '未填写'" /></div>
<div class="case-section"><h4>预期结果</h4><app-report-markdown [content]="data()?.original_case?.expected || '未填写'" /></div>
} @else if (panel.kind === 'initial_prompt') {
@if (data()?.initial_prompt) { @if (data()?.initial_prompt) {
<app-report-markdown [content]="data()!.initial_prompt" /> <app-report-markdown [content]="data()!.initial_prompt" />
} @else { <p class="muted">该任务未记录初始需求提示词。</p> } } @else { <p class="muted">该任务未记录初始需求提示词。</p> }
} @else { } @else {
@if (!data()?.editable) { <p class="muted">任务结束后可填写评审。</p> } @if (!data()?.editable) { <p class="muted">任务结束后可填写评审。</p> }
<fieldset [disabled]="!data()?.editable || !!saving()"> <fieldset [disabled]="!data()?.editable">
@for (field of plugin.workspace.fields; track field.id) { @for (field of plugin.workspace.fields; track field.id) {
<div class="field" [class.choice-field]="field.kind === 'choice'" [class.note-field]="field.kind === 'text'"> <div class="field" [class.choice-field]="field.kind === 'choice'" [class.note-field]="field.kind === 'text'">
<span class="field-label">{{ field.label }}</span> <span class="field-label">{{ field.label }}</span>
@@ -73,17 +80,26 @@
} }
</div> </div>
} @else { } @else {
<textarea rows="2" [attr.aria-label]="field.label" [maxLength]="field.max_length" placeholder="添加备注(选填)" <textarea rows="1" [attr.aria-label]="field.label" [maxLength]="field.max_length" placeholder="添加备注(选填)"
[ngModel]="drafts[plugin.plugin_id].values[field.id] || ''" (ngModelChange)="edit(plugin.plugin_id, field.id, $event)"></textarea> [ngModel]="drafts[plugin.plugin_id].values[field.id] || ''" (ngModelChange)="edit(plugin.plugin_id, field.id, $event)"></textarea>
} }
</div> </div>
} }
<div class="form-actions"> <div class="form-actions">
<button type="button" class="save" (click)="save(plugin)">{{ saving() === plugin.plugin_id ? '正在保存…' : '保存评审' }}</button> <button type="button" class="save" [disabled]="!!saving()" (click)="save(plugin)">{{ saving() === plugin.plugin_id ? '正在保存…' : '保存评审' }}</button>
<button type="button" class="reload" [disabled]="!!saving()" (click)="reload(plugin)" title="放弃未保存内容,读取最新记录">重新加载</button> <button type="button" class="reload" [disabled]="!!saving()" (click)="reload(plugin)" title="放弃未保存内容,读取最新记录">重新加载</button>
@if (drafts[plugin.plugin_id].dirty) { <span class="muted unsaved">有未保存内容</span> } <span class="muted unsaved">{{ saving() === plugin.plugin_id ? '正在保存…' : drafts[plugin.plugin_id].dirty ? '等待自动保存…' : '修改后自动保存' }}</span>
</div> </div>
</fieldset> </fieldset>
<nav class="review-navigation" aria-label="切换评审任务">
<div class="navigation-buttons">
<button type="button" [disabled]="!navigation().previous || !!saving() || moving()" (click)="move('previous')"><span aria-hidden="true">‹</span>上一条</button>
<span class="review-position">{{ navigation().position }} / {{ navigation().total }}</span>
<button type="button" [disabled]="!navigation().next || !!saving() || moving()" (click)="move('next')">下一条<span aria-hidden="true">›</span></button>
</div>
<span class="muted">{{ navigation().scope === 'batch' ? '同批次已结束任务 · 按执行顺序' : '当前历史列表' }}</span>
@if (drafts[plugin.plugin_id].dirty) { <span class="muted">有待保存的修改,切换前会先保存。</span> }
</nav>
@if (notice()) { <p class="notice" role="status">{{ notice() }}</p> } @if (notice()) { <p class="notice" role="status">{{ notice() }}</p> }
@if (plugin.history.length) { @if (plugin.history.length) {
<details class="history"><summary>修改记录({{ plugin.history.length }})</summary> <details class="history"><summary>修改记录({{ plugin.history.length }})</summary>
@@ -19,13 +19,19 @@ button:focus-visible, textarea:focus-visible { outline: 2px solid var(--app-prim
.left-column { border-right: 1px solid var(--app-border, #e2e8f0); } .left-column { border-right: 1px solid var(--app-border, #e2e8f0); }
.right-column { border-left: 1px solid var(--app-border, #e2e8f0); } .right-column { border-left: 1px solid var(--app-border, #e2e8f0); }
section + section { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 12px; margin-top: 12px; } section + section { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 12px; margin-top: 12px; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 20px; } header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 14px; overflow-wrap: anywhere; }
h3 { margin: 0; font-size: 14px; color: var(--app-text-strong, #0f172a); } h3 { margin: 0; font-size: 14px; color: var(--app-text-strong, #0f172a); }
.panel-kind { font-size: 11px; color: var(--app-text-muted, #64748b); } .panel-kind { flex-shrink: 0; font-size: 11px; color: var(--app-text-muted, #64748b); }
.case-section { margin: 18px 0; }
.case-section h4 { margin: 0 0 8px; font-size: 13px; color: var(--app-text-strong, #0f172a); }
.review-navigation { margin: 12px 0 0; padding: 12px 0 0; background: var(--app-surface, #fff); border-top: 1px solid var(--app-border, #e2e8f0); }
.navigation-buttons { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin-bottom: 4px; }
.navigation-buttons button { white-space: nowrap; }
.review-position { font-size: 11px; color: var(--app-text-muted, #64748b); white-space: nowrap; }
.muted { display: block; font-size: 12px; color: var(--app-text-secondary, #64748b); line-height: 1.6; } .muted { display: block; font-size: 12px; color: var(--app-text-secondary, #64748b); line-height: 1.6; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.field { display: grid; gap: 8px; margin: 0 0 14px; } .field { display: grid; gap: 8px; margin: 0 0 14px; }
.choice-field:not(:first-child) { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 22px; margin-top: 22px; } .choice-field:not(:first-child) { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 14px; margin-top: 14px; }
.field-label { font-size: 13px; font-weight: 600; } .field-label { font-size: 13px; font-weight: 600; }
.note-field .field-label { font-size: 11px; font-weight: 400; color: var(--app-text-muted, #64748b); } .note-field .field-label { font-size: 11px; font-weight: 400; color: var(--app-text-muted, #64748b); }
.choice-group { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); } .choice-group { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); }
@@ -36,7 +42,7 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.choice-button.selected { border-color: var(--app-primary-border, #93c5fd); background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); font-weight: 600; } .choice-button.selected { border-color: var(--app-primary-border, #93c5fd); background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); font-weight: 600; }
.choice-button.pass.selected { border-color: var(--app-success-border, #a7f3d0); background: var(--app-success-soft, #ecfdf5); color: var(--app-success-text, #047857); } .choice-button.pass.selected { border-color: var(--app-success-border, #a7f3d0); background: var(--app-success-soft, #ecfdf5); color: var(--app-success-text, #047857); }
.choice-button.failed.selected { border-color: var(--app-error-border, #fecdd3); background: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #be123c); } .choice-button.failed.selected { border-color: var(--app-error-border, #fecdd3); background: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #be123c); }
textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 10px 12px; font: inherit; font-size: 12px; line-height: 1.6; color: inherit; border: 1px solid var(--app-border, #e2e8f0); border-radius: 8px; background: var(--app-surface-soft, #f8fafc); resize: vertical; min-height: 66px; max-height: 200px; } textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 6px 10px; font: inherit; font-size: 12px; line-height: 1.6; color: inherit; border: 1px solid var(--app-border, #e2e8f0); border-radius: 8px; background: var(--app-surface-soft, #f8fafc); resize: vertical; min-height: 34px; max-height: 200px; }
textarea::placeholder { color: var(--app-text-muted, #94a3b8); } textarea::placeholder { color: var(--app-text-muted, #94a3b8); }
.form-actions { display: grid; grid-template-columns: auto auto; justify-content: start; align-items: center; gap: 8px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--app-border, #e2e8f0); } .form-actions { display: grid; grid-template-columns: auto auto; justify-content: start; align-items: center; gap: 8px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--app-border, #e2e8f0); }
.form-actions button { height: 36px; padding: 0 16px; white-space: nowrap; } .form-actions button { height: 36px; padding: 0 16px; white-space: nowrap; }
@@ -1,4 +1,4 @@
import { TestBed, ComponentFixture } from '@angular/core/testing'; import { TestBed, ComponentFixture, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TaskExtensionPanelsComponent } from './task-extension-panels.component'; import { TaskExtensionPanelsComponent } from './task-extension-panels.component';
@@ -50,7 +50,7 @@ describe('步骤回放工作区扩展', () => {
fixture.detectChanges(); fixture.detectChanges();
} }
it('does not request extensions in video mode and shows no controls without a plugin', () => { it('does not request disabled extensions and shows no controls without a plugin', () => {
fixture.componentRef.setInput('sessionId', 'task-one'); fixture.componentRef.setInput('sessionId', 'task-one');
fixture.detectChanges(); fixture.detectChanges();
http.expectNone('/api/task-extensions/task-one'); http.expectNone('/api/task-extensions/task-one');
@@ -87,87 +87,92 @@ describe('步骤回放工作区扩展', () => {
} }
}); });
it('allows partial manual selection and keeps unfilled outcomes blank', () => { it('automatically saves choices and clears while keeping unfilled outcomes blank', fakeAsync(() => {
open(); toggle('right'); open(); toggle('right');
expect(fixture.nativeElement.querySelector('select')).toBeNull(); const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
const groups: NodeListOf<HTMLElement> = fixture.nativeElement.querySelectorAll('.choice-group');
expect(fixture.nativeElement.querySelector('[aria-pressed="true"]')).toBeNull();
const pass = groups[0].querySelector<HTMLButtonElement>('.pass')!;
const failed = groups[0].querySelector<HTMLButtonElement>('.failed')!;
pass.click(); fixture.detectChanges(); pass.click(); fixture.detectChanges();
expect(pass.getAttribute('aria-pressed')).toBe('true'); expect(saved().values).toEqual({ accuracy: 'Pass', note: '', correctness: '' });
pass.click(); fixture.detectChanges(); pass.click(); fixture.detectChanges(); expect(saved(2).values.accuracy).toBe('');
expect(component.drafts['review'].values['accuracy']).toBe(''); expect(fixture.nativeElement.textContent).toContain('人工评审已自动保存');
failed.click(); pass.click(); fixture.detectChanges(); }));
expect(failed.getAttribute('aria-pressed')).toBe('false');
expect(groups[1].querySelector('[aria-pressed="true"]')).toBeNull(); function saved(revision = 1, id = 'task-one') {
const note: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea'); const request = http.expectOne(`/api/task-extensions/${id}/review`);
note.value = '人工核对'; note.dispatchEvent(new Event('input'));
fixture.detectChanges();
fixture.nativeElement.querySelector('button.save').click();
const request = http.expectOne('/api/task-extensions/task-one/review');
expect(request.request.method).toBe('PUT'); expect(request.request.method).toBe('PUT');
expect(request.request.body.values).toEqual({ accuracy: 'Pass', note: '人工核对', correctness: '' }); const body = request.request.body, data = snapshot(id);
expect(request.request.body.expected_revision).toBe(0); data.plugins[0].revision = revision; data.plugins[0].values = body.values;
const data = snapshot(); data.plugins[0].revision = 1; data.plugins[0].values = request.request.body.values; request.flush(data); flushMicrotasks(); fixture.detectChanges(); return body;
request.flush(data); fixture.detectChanges(); }
expect(fixture.nativeElement.textContent).toContain('人工评审已保存');
});
it('preserves draft and request receipt after a failed save, and permits clearing a result', () => { it('debounces notes and never posts just from opening a task', fakeAsync(() => {
open(); toggle('right'); open(); toggle('right'); tick(700); http.expectNone('/api/task-extensions/task-one/review');
const plugin = component.data()!.plugins[0]; component.edit('review', 'note', '第一段'); tick(400);
component.edit('review', 'accuracy', 'Failed'); component.save(plugin); component.edit('review', 'note', '完整备注'); tick(599);
const first = http.expectOne('/api/task-extensions/task-one/review'); http.expectNone('/api/task-extensions/task-one/review'); tick(1);
const requestId = first.request.body.request_id; expect(saved().values.note).toBe('完整备注');
first.flush({}, { status: 503, statusText: 'Unavailable' }); }));
component.save(plugin);
const retry = http.expectOne('/api/task-extensions/task-one/review'); it('does not overwrite an automatic save with an older read response', fakeAsync(() => {
expect(retry.request.body.request_id).toBe(requestId); open(); component.edit('review', 'note', '已保存的新内容'); tick(600); saved(2);
retry.flush({}, { status: 409, statusText: 'Conflict' }); component.refresh(); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('已保存的新内容');
expect(component.drafts['review'].revision).toBe(2);
}));
it('flushes a pending note when the review panel is closed', fakeAsync(() => {
open(); component.edit('review', 'note', '关闭前保存'); fixture.destroy();
const request = http.expectOne('/api/task-extensions/task-one/review');
expect(request.request.body.values.note).toBe('关闭前保存');
const response = snapshot(); response.plugins[0].revision = 1; response.plugins[0].values = request.request.body.values;
request.flush(response); flushMicrotasks();
}));
it('preserves draft and request receipt on failure, and retries only when requested', fakeAsync(() => {
open(); toggle('right'); const plugin = component.data()!.plugins[0];
component.edit('review', 'accuracy', 'Failed'); void component.save(plugin);
const first = http.expectOne('/api/task-extensions/task-one/review'), requestId = first.request.body.request_id;
first.flush({}, { status: 503, statusText: 'Unavailable' }); flushMicrotasks(); tick(700);
http.expectNone('/api/task-extensions/task-one/review'); void component.save(plugin);
const retry = http.expectOne('/api/task-extensions/task-one/review'); expect(retry.request.body.request_id).toBe(requestId);
retry.flush({}, { status: 409, statusText: 'Conflict' }); flushMicrotasks();
expect(component.drafts['review'].values['accuracy']).toBe('Failed'); expect(component.drafts['review'].values['accuracy']).toBe('Failed');
component.edit('review', 'accuracy', ''); component.save(plugin); component.edit('review', 'accuracy', ''); void component.save(plugin);
const clear = http.expectOne('/api/task-extensions/task-one/review'); const clear = saved(); expect(clear.values.accuracy).toBe(''); expect(clear.request_id).not.toBe(requestId);
expect(clear.request.body.values.accuracy).toBe(''); }));
expect(clear.request.body.request_id).not.toBe(requestId);
clear.flush(snapshot());
});
it('renders Chinese outcomes as mutually exclusive buttons and saves their declared values', () => { it('serializes rapid edits and saves the latest choice using the returned revision', fakeAsync(() => {
const data = snapshot(); data.plugins[0].workspace.fields[0].options = ['通过', '失败']; const data = snapshot(); data.plugins[0].workspace.fields[0].options = ['通过', '失败']; open(data); toggle('right');
open(data); toggle('right');
const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass'); const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
const failed: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.failed'); const failed: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.failed');
expect(pass.textContent).toContain('通过'); expect(failed.textContent).toContain('失败');
pass.click(); failed.click(); fixture.detectChanges(); pass.click(); failed.click(); fixture.detectChanges();
expect(component.drafts['review'].values['accuracy']).toBe('失败'); expect(component.drafts['review'].values['accuracy']).toBe('失败'); expect(saved().values.accuracy).toBe('通过');
expect(pass.getAttribute('aria-pressed')).toBe('false'); const second = saved(2); expect(second.values.accuracy).toBe('失败'); expect(second.expected_revision).toBe(1);
expect(failed.getAttribute('aria-pressed')).toBe('true'); expect(component.drafts['review'].dirty).toBeFalse();
}); }));
it('does not apply a delayed save response to the task now displayed', () => { it('does not apply a delayed save response to the task now displayed', fakeAsync(() => {
open(); toggle('right'); open(); toggle('right'); component.edit('review', 'note', '只属于第一个任务'); void component.save(component.data()!.plugins[0]);
component.edit('review', 'note', '只属于第一个任务'); component.save(component.data()!.plugins[0]);
const oldSave = http.expectOne('/api/task-extensions/task-one/review'); const oldSave = http.expectOne('/api/task-extensions/task-one/review');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges(); fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges(); http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
oldSave.flush(snapshot('task-one')); fixture.detectChanges(); oldSave.flush(snapshot('task-one')); flushMicrotasks(); fixture.detectChanges();
expect(component.data()!.session_id).toBe('task-two'); expect(component.data()!.session_id).toBe('task-two'); expect(component.drafts['review'].values['note']).toBe('');
expect(component.drafts['review'].values['note']).toBe(''); }));
});
it('keeps unsaved drafts across task switches and removes panels on plugin disable', () => { it('flushes a pending note on direct task switches and hides disabled plugins', fakeAsync(() => {
open(); toggle('right'); component.edit('review', 'note', '保留草稿'); open(); toggle('right'); component.edit('review', 'note', '自动保存草稿');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges(); fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two'));
const body = saved(1, 'task-one'); expect(body.values.note).toBe('自动保存草稿');
fixture.componentRef.setInput('sessionId', 'task-one'); fixture.detectChanges(); fixture.componentRef.setInput('sessionId', 'task-one'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges(); const first = snapshot(); first.plugins[0].revision = 1; first.plugins[0].values = body.values;
expect(component.drafts['review'].values['note']).toBe('保留草稿'); http.expectOne('/api/task-extensions/task-one').flush(first); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('自动保存草稿');
window.dispatchEvent(new Event('artemis-plugin-change')); fixture.detectChanges(); window.dispatchEvent(new Event('artemis-plugin-change')); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush({ ...snapshot(), plugins: [] }); fixture.detectChanges(); http.expectOne('/api/task-extensions/task-one').flush({ ...first, plugins: [] }); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('nav')).toBeNull();
expect(fixture.nativeElement.querySelector('aside')).toBeNull(); expect(fixture.nativeElement.querySelector('aside')).toBeNull();
}); }));
it('keeps running tasks read-only then enables editing when the task finishes', () => { it('keeps running tasks read-only then enables editing when the task finishes', () => {
open({ ...snapshot(), editable: false }); toggle('right'); open({ ...snapshot(), editable: false }); toggle('right');
@@ -187,4 +192,30 @@ describe('步骤回放工作区扩展', () => {
expect(component.panels('right').length).toBe(1); expect(component.panels('right').length).toBe(1);
} finally { Object.defineProperty(window, 'innerWidth', original); } } finally { Object.defineProperty(window, 'innerWidth', original); }
}); });
it('shows original case sections in order without substituting the AI prompt', () => {
const data = snapshot();
data.plugins[0].workspace.panels[0].kind = 'original_case';
data.case_title = 'CASE-001 检查设置';
data.original_case = { preconditions: '已登录', steps: '1. 打开设置\n2. 核对版本', expected: '版本可见' };
open(data); toggle('left');
const text = fixture.nativeElement.textContent;
expect(text).toContain('CASE-001 检查设置');
expect(text).toMatch(/预置条件[\s\S]*已登录[\s\S]*操作步骤[\s\S]*核对版本[\s\S]*预期结果[\s\S]*版本可见/);
expect(text).not.toContain('原始需求');
});
it('saves before navigation and keeps the current task on save failure', fakeAsync(() => {
const first = snapshot(); first.navigation = { scope: 'batch', position: 1, total: 2, previous: null, next: 'task-two' };
open(first); toggle('right'); const emitted = jasmine.createSpy('navigate'); component.navigate.subscribe(emitted);
const saveButton = fixture.nativeElement.querySelector('button.save'), navigation = fixture.nativeElement.querySelector('.review-navigation');
expect(saveButton.compareDocumentPosition(navigation) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
component.edit('review', 'note', '切换前保存'); void component.move('next'); expect(emitted).not.toHaveBeenCalled();
http.expectOne('/api/task-extensions/task-one/review').flush({}, {status: 409, statusText: 'Conflict'});
flushMicrotasks(); expect(emitted).not.toHaveBeenCalled(); expect(component.drafts['review'].dirty).toBeTrue();
void component.move('next'); saved(); flushMicrotasks(); expect(emitted).toHaveBeenCalledOnceWith('task-two');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
expect(component.panels('right').length).toBe(1);
}));
}); });
@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, effect, inject, input, output, signal, viewChild } from '@angular/core'; import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, DestroyRef, computed, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { DatePipe, NgTemplateOutlet } from '@angular/common'; import { DatePipe, NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { ExtensionDraft, ExtensionPanel, TaskExtension, TaskExtensions, TaskExtensionsService } from '../../services/task-extensions.service'; import { ExtensionDraft, ExtensionPanel, TaskExtension, TaskExtensions, TaskExtensionsService } from '../../services/task-extensions.service';
@@ -16,11 +17,25 @@ export class TaskExtensionPanelsComponent {
sessionId = input<string | null>(null); sessionId = input<string | null>(null);
enabled = input(false); enabled = input(false);
taskStatus = input(''); taskStatus = input('');
sessionIds = input<string[]>([]);
navigate = output<string>();
navigation = computed(() => {
if (this.data()?.navigation) return this.data()!.navigation!;
const ids = this.sessionIds(), index = ids.indexOf(this.sessionId() || '');
return { scope: 'history', position: index + 1, total: ids.length,
previous: index > 0 ? ids[index - 1] : null,
next: index >= 0 && index < ids.length - 1 ? ids[index + 1] : null };
});
expandedWidth = output<number>(); expandedWidth = output<number>();
headerControls = viewChild<TemplateRef<unknown>>('headerControls'); headerControls = viewChild<TemplateRef<unknown>>('headerControls');
data = signal<TaskExtensions | null>(null); data = signal<TaskExtensions | null>(null);
error = signal(''); error = signal('');
saving = signal<string | null>(null); private destroyRef = inject(DestroyRef);
private savingKeys = signal<string[]>([]);
saving = computed(() => this.savingKeys().find(key => key.startsWith(`${this.sessionId()}/`))?.split('/')[1] || null);
moving = signal(false);
private scheduled = new Map<string, { timer: ReturnType<typeof setTimeout>; run: () => Promise<boolean> }>();
private inFlight = new Map<string, Promise<boolean>>();
notice = signal(''); notice = signal('');
opened = signal<Record<string, boolean>>({}); opened = signal<Record<string, boolean>>({});
private refreshToken = signal(0); private refreshToken = signal(0);
@@ -29,6 +44,7 @@ export class TaskExtensionPanelsComponent {
drafts: Record<string, ExtensionDraft> = {}; drafts: Record<string, ExtensionDraft> = {};
constructor() { constructor() {
this.destroyRef.onDestroy(() => this.flushScheduled());
effect(onCleanup => { effect(onCleanup => {
const id = this.sessionId(); const id = this.sessionId();
const enabled = this.enabled(); const enabled = this.enabled();
@@ -37,14 +53,14 @@ export class TaskExtensionPanelsComponent {
this.generation++; this.generation++;
const identity = enabled ? id || '' : ''; const identity = enabled ? id || '' : '';
if (identity !== this.identity) { if (identity !== this.identity) {
this.flushScheduled();
this.identity = identity; this.identity = identity;
this.data.set(null); this.data.set(null);
this.opened.set({}); if (!identity) this.opened.set({});
this.expandedWidth.emit(0); this.expandedWidth.emit(0);
} }
this.error.set(''); this.error.set('');
this.notice.set(''); this.notice.set('');
this.saving.set(null);
if (!id || !enabled) return; if (!id || !enabled) return;
const subscription = this.service.read(id).subscribe({ const subscription = this.service.read(id).subscribe({
next: data => { next: data => {
@@ -61,6 +77,27 @@ export class TaskExtensionPanelsComponent {
@HostListener('window:artemis-plugin-change') @HostListener('window:artemis-plugin-change')
refresh(): void { this.refreshToken.update(value => value + 1); } refresh(): void { this.refreshToken.update(value => value + 1); }
async move(direction: 'previous' | 'next'): Promise<void> {
const destination = this.navigation()[direction], id = this.sessionId();
if (!destination || !id || !this.data() || this.moving()) return;
this.moving.set(true);
try {
for (const plugin of this.data()!.plugins) {
if (this.drafts[plugin.plugin_id]?.dirty && !await this.save(plugin)) return;
}
if (id === this.sessionId() && !this.destroyRef.destroyed) this.navigate.emit(destination);
} finally { this.moving.set(false); }
}
private cancelScheduled(key: string): void {
const pending = this.scheduled.get(key);
if (pending) clearTimeout(pending.timer);
this.scheduled.delete(key);
}
private flushScheduled(): void {
for (const pending of [...this.scheduled.values()]) void pending.run();
}
key(plugin: TaskExtension, panel: ExtensionPanel): string { return `${plugin.plugin_id}/${panel.id}`; } key(plugin: TaskExtension, panel: ExtensionPanel): string { return `${plugin.plugin_id}/${panel.id}`; }
panels(side: 'left' | 'right') { panels(side: 'left' | 'right') {
return (this.data()?.plugins || []).flatMap(plugin => plugin.workspace.panels return (this.data()?.plugins || []).flatMap(plugin => plugin.workspace.panels
@@ -94,41 +131,71 @@ export class TaskExtensionPanelsComponent {
} }
} }
edit(pluginId: string, field: string, value: string): void { edit(pluginId: string, field: string, value: string): void {
const draft = this.drafts[pluginId]; const id = this.sessionId(), draft = this.drafts[pluginId];
if (!id || !draft || !this.data()?.editable || draft.values[field] === value) return;
draft.values[field] = value; draft.values[field] = value;
draft.dirty = true; draft.dirty = true;
draft.requestId = undefined; draft.requestId = undefined;
this.notice.set(''); this.notice.set('');
const key = `${id}/${pluginId}`;
this.cancelScheduled(key);
const run = () => this.saveFor(id, pluginId, draft);
this.scheduled.set(key, { run, timer: setTimeout(() => void run(), 600) });
} }
choose(pluginId: string, field: string, value: string): void { choose(pluginId: string, field: string, value: string): void {
this.edit(pluginId, field, this.drafts[pluginId].values[field] === value ? '' : value); this.edit(pluginId, field, this.drafts[pluginId].values[field] === value ? '' : value);
const plugin = this.data()?.plugins.find(item => item.plugin_id === pluginId);
if (plugin) void this.save(plugin);
} }
reload(plugin: TaskExtension): void { reload(plugin: TaskExtension): void {
const id = this.sessionId(); const id = this.sessionId();
if (!id) return; if (!id) return;
this.cancelScheduled(`${id}/${plugin.plugin_id}`);
this.service.discard(id, plugin.plugin_id); this.service.discard(id, plugin.plugin_id);
this.refresh(); this.refresh();
} }
save(plugin: TaskExtension): void { save(plugin: TaskExtension): Promise<boolean> {
const id = this.sessionId(); const id = this.sessionId();
if (!id || !this.data()?.editable || this.saving()) return; if (!id || !this.data()?.editable) return Promise.resolve(false);
this.saving.set(plugin.plugin_id); return this.saveFor(id, plugin.plugin_id, this.drafts[plugin.plugin_id]);
const generation = this.generation; }
this.notice.set('');
this.service.save(id, plugin.plugin_id, this.drafts[plugin.plugin_id]).subscribe({ private async saveFor(id: string, pluginId: string, draft: ExtensionDraft): Promise<boolean> {
next: data => { const key = `${id}/${pluginId}`;
this.service.discard(id, plugin.plugin_id); this.cancelScheduled(key);
if (id !== this.sessionId() || generation !== this.generation) return; const pending = this.inFlight.get(key);
for (const item of data.plugins) this.drafts[item.plugin_id] = this.service.draft(id, item); if (pending) {
if (!await pending) return false;
return draft.dirty ? this.saveFor(id, pluginId, draft) : true;
}
if (!draft.dirty) return true;
const submitted = { ...draft.values }, generation = this.generation;
const visible = () => !this.destroyRef.destroyed && id === this.sessionId() && generation === this.generation;
this.savingKeys.update(keys => [...keys, key]);
const request = firstValueFrom(this.service.save(id, pluginId, draft)).then(data => {
const saved = data.plugins.find(plugin => plugin.plugin_id === pluginId);
if (!saved) throw new Error('未收到保存回执');
draft.revision = saved.revision;
draft.dirty = JSON.stringify(draft.values) !== JSON.stringify(submitted);
draft.requestId = undefined;
if (!draft.dirty) draft.values = { ...saved.values };
if (visible()) {
this.data.set(data); this.data.set(data);
this.saving.set(null); this.notice.set(draft.dirty ? '正在保存后续修改…' : '人工评审已自动保存');
this.notice.set('人工评审已保存,下次导出将包含最新内容');
},
error: error => {
if (id !== this.sessionId() || generation !== this.generation) return;
this.saving.set(null);
this.notice.set(typeof error.error?.detail === 'string' ? error.error.detail : '保存失败,已保留输入,请重试');
} }
return true;
}).catch(error => {
this.cancelScheduled(key);
if (visible()) this.notice.set(typeof error.error?.detail === 'string'
? `保存失败:${error.error.detail};输入已保留`
: '保存失败,输入已保留,请点击保存评审重试');
return false;
}).finally(() => {
this.inFlight.delete(key);
this.savingKeys.update(keys => keys.filter(item => item !== key));
}); });
this.inFlight.set(key, request);
const succeeded = await request;
return succeeded && draft.dirty ? this.saveFor(id, pluginId, draft) : succeeded;
} }
} }
@@ -18,6 +18,28 @@ describe('历史任务检索', () => {
}); });
afterEach(() => http.verify()); afterEach(() => http.verify());
it('历史错误独立折叠,展开不选择任务且后台刷新保留展开状态', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'blocked', name:'前置条件缺失', status:'failed', start_time:100,
error:'[web-blocked] FlashRunner failed: **目标未绑定**\n\n- 未执行删除'};
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const details = host.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
expect(details.closest('button')).toBeNull();
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(TestBed.inject(AgentService).selectSession).not.toHaveBeenCalled();
fixture.componentInstance.load(true);
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[{...row}]});
fixture.detectChanges();
expect(host.querySelector('app-task-outcome details')).toBe(details);
expect(details.open).toBeTrue();
expect(details.querySelector('strong')!.textContent).toBe('目标未绑定');
fixture.destroy();
});
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => { it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent); const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100}; const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
@@ -268,7 +290,7 @@ describe('历史任务检索', () => {
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类'); expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
expect(row.textContent).toContain('1.1 打开并核对设置'); expect(row.textContent).toContain('1.1 打开并核对设置');
expect(row.textContent).toContain('总耗时 1分5秒'); expect(row.textContent).toContain('总耗时 1分5秒');
expect(row.textContent).toContain('任务执行超时'); expect(fixture.nativeElement.querySelector('app-task-outcome summary').textContent).toContain('任务执行超时');
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败'); expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
fixture.destroy(); fixture.destroy();
}); });
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component'; import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
@@ -9,13 +10,13 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
@Component({ @Component({
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent], selector:'app-task-history', standalone:true, imports:[TaskOutcomeComponent,CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3> template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()"> <input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div> <div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>} @if(error()){<p role="alert">{{error()}}</p>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary> @for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button></div><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>} @for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span></button></div><app-task-outcome [error]="row.error" [result]="row.test_result" [status]="row.status" /><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
</details>} </details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>} @if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div> <div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
@@ -51,7 +52,6 @@ import { AgentService } from '../../services/agent.service';
.status-completed .history-status,.status-success .history-status{background:var(--app-success-soft, #dcfce7);color:var(--app-success-text, #166534)} .status-completed .history-status,.status-success .history-status{background:var(--app-success-soft, #dcfce7);color:var(--app-success-text, #166534)}
.status-failed .history-status{background:var(--app-error-soft, #fee2e2);color:var(--app-error-text, #991b1b)} .status-failed .history-status{background:var(--app-error-soft, #fee2e2);color:var(--app-error-text, #991b1b)}
.status-cancelled .history-status,.status-interrupted .history-status{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e)} .status-cancelled .history-status,.status-interrupted .history-status{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e)}
.history-error{font-size:11px;color:var(--app-error-text, #b91c1c);line-height:1.4}
.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:var(--app-text-muted, #64748b);cursor:pointer;font-size:12px} .delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:var(--app-text-muted, #64748b);cursor:pointer;font-size:12px}
.delete-history .material-symbols-outlined{font-size:16px} .delete-history .material-symbols-outlined{font-size:16px}
.delete-history:hover{color:var(--app-error-text, #b91c1c)} .delete-history:hover{color:var(--app-error-text, #b91c1c)}
@@ -0,0 +1,71 @@
import { TestBed } from '@angular/core/testing';
import { TaskOutcomeComponent } from './task-outcome.component';
describe('任务结束说明', () => {
function create(status = 'failed', error = '[web-example] FlashRunner failed: **前置受阻**\n\n- 未找到目标设备\n- 未执行删除') {
const fixture = TestBed.createComponent(TaskOutcomeComponent);
fixture.componentRef.setInput('status', status);
fixture.componentRef.setInput('error', error);
fixture.detectChanges();
return fixture;
}
it('默认只显示一行摘要,展开后渲染完整 Markdown,不选择外层任务', () => {
const fixture = create();
const host = fixture.nativeElement as HTMLElement;
host.style.width = '280px';
const details = host.querySelector('details')!;
expect(details.open).toBeFalse();
expect(host.querySelector('.summary-text')!.textContent).toBe('前置受阻');
expect(host.textContent).not.toContain('FlashRunner failed');
expect(host.querySelector('button details')).toBeNull();
const select = jasmine.createSpy('外层任务选择');
host.addEventListener('click', select);
host.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(select).not.toHaveBeenCalled();
expect(host.querySelector('.body strong')!.textContent).toBe('前置受阻');
expect(host.querySelectorAll('.body li').length).toBe(2);
fixture.componentRef.setInput('error', '[web-example] FlashRunner failed: **更新说明**\n\n- 已有记录');
fixture.detectChanges();
expect(host.querySelector('details')).toBe(details);
expect(details.open).toBeTrue();
host.querySelector('summary')!.click();
expect(details.open).toBeFalse();
});
it('优先采用已有结论,同时保留不同的执行错误和依据', () => {
const fixture = create();
fixture.componentRef.setInput('result', {status:'blocked', reason:'目标设备未绑定', evidence:['截图中仅有 **添加设备**']});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('.summary-text')!.textContent).toBe('目标设备未绑定');
expect(host.textContent).toContain('未执行删除');
expect(host.textContent).toContain('已记录依据');
expect(host.textContent).toContain('添加设备');
});
it('完成但结论阻塞也展示说明,运行中和无内容时不展示', () => {
const fixture = create('running');
expect(fixture.nativeElement.querySelector('details')).toBeNull();
fixture.componentRef.setInput('status', 'completed');
fixture.componentRef.setInput('error', '');
fixture.componentRef.setInput('result', {status:'blocked',reason:'缺少预置数据',evidence:[]});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.summary-text').textContent).toBe('缺少预置数据');
fixture.componentRef.setInput('result', null);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
});
it('不重复相同说明,不执行错误正文中的 HTML 或危险链接', () => {
const error = '**失败**\n<script>alert(1)</script>\n[打开](javascript:alert(1))';
const fixture = create('failed', error);
fixture.componentRef.setInput('result', {status:'blocked',reason:error,evidence:[]});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelectorAll('app-report-markdown').length).toBe(1);
expect(host.querySelector('script')).toBeNull();
expect(host.querySelector('a[href^="javascript:"]')).toBeNull();
});
});
@@ -0,0 +1,62 @@
import { Component, computed, input } from '@angular/core';
import { CaseTestResult } from '../../core/models/session.model';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
/** Read-only presentation of persisted results; opening it never requests a model. */
@Component({
selector: 'app-task-outcome',
standalone: true,
imports: [ReportMarkdownComponent],
template: `@if (visible()) {
<details (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
<summary aria-label="任务说明,展开或收起">
<span class="chevron" aria-hidden="true">›</span>
<span class="summary-text">{{summary()}}</span>
<span class="hint">详情</span>
</summary>
<div class="body">
@if (reason()) { <app-report-markdown [content]="reason()" /> }
@if (message() && message() !== reason()) {
@if (reason()) { <p class="label">执行记录</p> }
<app-report-markdown [content]="message()" />
}
@if (result()?.evidence?.length) {
<p class="label">已记录依据</p>
<ul>@for (item of result()!.evidence; track $index) {<li><app-report-markdown [content]="item" /></li>}</ul>
}
</div>
</details>
}`,
styles: [`
:host { display: block; min-width: 0; max-width: 100%; }
details { margin: 4px 0; font-size: 12px; color: var(--app-text-secondary, #475569); }
summary { display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 26px; cursor: pointer; list-style: none; border-radius: 4px; }
summary::-webkit-details-marker { display: none; }
summary:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
.summary-text { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chevron { flex: none; font-size: 18px; line-height: 20px; }
details[open] .chevron { transform: rotate(90deg); }
.hint { flex: none; font-size: 11px; color: var(--app-text-muted, #64748b); }
.body { max-height: 280px; overflow: auto; padding: 8px 10px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 6px; background: var(--app-surface, #fff); cursor: text; }
.label { font-size: 11px; color: var(--app-text-muted, #64748b); margin: 10px 0 4px; }
ul { margin: 4px 0; padding-left: 20px; }
`]
})
export class TaskOutcomeComponent {
error = input<string | null>();
result = input<CaseTestResult | null>();
status = input('');
// Strip only the known SDK envelope, preserving the actual explanation and diagnostics.
private explanation(value: string | null | undefined): string {
return (value || '').replace(/^\[[^\]\r\n]+\]\s*FlashRunner failed:\s*/, '').trim();
}
message = computed(() => this.explanation(this.error()));
reason = computed(() => this.explanation(this.result()?.reason));
visible = computed(() => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.status())
&& !!(this.message() || (this.result()?.status !== 'passed' && this.reason())));
summary = computed(() => {
const line = (this.reason() || this.message()).split(/\r?\n/).find(part => part.trim()) || '';
return line.replace(/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|>\s*)/, '')
.replace(/!?\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/(\*\*|__|~~|`)/g, '');
});
}
@@ -6,7 +6,7 @@ export interface ExtensionField {
options: string[]; max_length: number; report: boolean; options: string[]; max_length: number; report: boolean;
} }
export interface ExtensionPanel { export interface ExtensionPanel {
id: string; title: string; side: 'left' | 'right'; kind: 'initial_prompt' | 'form'; id: string; title: string; side: 'left' | 'right'; kind: 'initial_prompt' | 'original_case' | 'form';
width: { preferred: number; minimum: number; maximum: number }; width: { preferred: number; minimum: number; maximum: number };
} }
export interface TaskExtension { export interface TaskExtension {
@@ -17,6 +17,9 @@ export interface TaskExtension {
} }
export interface TaskExtensions { export interface TaskExtensions {
session_id: string; initial_prompt: string; editable: boolean; plugins: TaskExtension[]; session_id: string; initial_prompt: string; editable: boolean; plugins: TaskExtension[];
case_title?: string;
original_case?: { preconditions: string; steps: string; expected: string } | null;
navigation?: { scope: 'batch'; position: number; total: number; previous: string | null; next: string | null } | null;
} }
export interface ExtensionDraft { export interface ExtensionDraft {
values: Record<string, string>; revision: number; dirty: boolean; requestId?: string; values: Record<string, string>; revision: number; dirty: boolean; requestId?: string;
@@ -32,7 +35,7 @@ export class TaskExtensionsService {
draft(sessionId: string, plugin: TaskExtension): ExtensionDraft { draft(sessionId: string, plugin: TaskExtension): ExtensionDraft {
const key = `${sessionId}/${plugin.plugin_id}`; const key = `${sessionId}/${plugin.plugin_id}`;
let draft = this.drafts.get(key); let draft = this.drafts.get(key);
if (!draft?.dirty) { if (!draft || (!draft.dirty && plugin.revision >= draft.revision)) {
draft = { values: { ...plugin.values }, revision: plugin.revision, dirty: false }; draft = { values: { ...plugin.values }, revision: plugin.revision, dirty: false };
this.drafts.set(key, draft); this.drafts.set(key, draft);
} }
+20 -7
View File
@@ -96,6 +96,7 @@ from artemis.services.llm import (
get_openai_llm, get_openai_llm,
get_llm, get_llm,
invoke_llm_with_timeout_message, invoke_llm_with_timeout_message,
with_fallback,
) )
from artemis.tools.history import history_tool_declarations from artemis.tools.history import history_tool_declarations
from artemis.tools.android_files import android_file from artemis.tools.android_files import android_file
@@ -780,7 +781,7 @@ class FlashRunner:
action_dict["normalized_start_coordinates"] = norm_start action_dict["normalized_start_coordinates"] = norm_start
action_dict["normalized_end_coordinates"] = norm_end action_dict["normalized_end_coordinates"] = norm_end
action_dict.update(metadata.get("target_semantics") or {}) action_dict.update(metadata.get("target_semantics") or {})
for key in ("input_verification", "execution", "side_effect"): for key in ("input_verification", "execution", "side_effect", "source"):
if key in metadata: if key in metadata:
action_dict[key] = metadata[key] action_dict[key] = metadata[key]
if metadata.get("plugin_id"): if metadata.get("plugin_id"):
@@ -1506,20 +1507,32 @@ class FlashRunner:
turn_base = len(messages) - 1 turn_base = len(messages) - 1
current_tools = report_only_tools if is_final else self._get_tools() current_tools = report_only_tools if is_final else self._get_tools()
async def invoke_turn():
if using_tool_fallback:
return await self._invoke_model(fallback_llm, current_tools, messages)
async def invoke_fallback():
nonlocal fallback_llm
if fallback_llm is None:
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
return await self._invoke_model(fallback_llm, current_tools, messages)
return await with_fallback(
lambda: self._invoke_model(llm, current_tools, messages),
invoke_fallback,
none_should_fallback=False,
)
if isinstance(control, TaskControl): if isinstance(control, TaskControl):
interrupted, response = await control.run_interruptibly( interrupted, response = await control.run_interruptibly(
self._invoke_model( invoke_turn(),
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
on_pause=getattr(self.ctx, "on_task_pause", None), on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None), on_resume=getattr(self.ctx, "on_task_resume", None),
) )
else: else:
interrupted, response = ( interrupted, response = (
False, False,
await self._invoke_model( await invoke_turn(),
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
) )
if interrupted or ( if interrupted or (
+1
View File
@@ -412,6 +412,7 @@ class VisualStepSummarizer(StepMemoryService):
engine, engine,
response, response,
source=f"lens:visual_transition:{self._model_name}", source=f"lens:visual_transition:{self._model_name}",
model=self._model_name,
update_last_prompt=False, update_last_prompt=False,
) )
+13 -3
View File
@@ -35,7 +35,7 @@ from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
from artemis.llm.usage import usage_from_message from artemis.llm.usage import usage_from_message
from artemis.llm.structured import content_to_text from artemis.llm.structured import content_to_text
from artemis.runtime.task_control import TaskControl, has_pending_instruction from artemis.runtime.task_control import TaskControl, has_pending_instruction
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message, with_fallback
from artemis.tools.command_tool import ( from artemis.tools.command_tool import (
analyze_task_output_wrapper, analyze_task_output_wrapper,
get_adb_task_registry, get_adb_task_registry,
@@ -638,9 +638,19 @@ class OperatorNode:
and t.name not in {"plugin_load", "plugin_read_file", "plugin_read_result"} and t.name not in {"plugin_load", "plugin_read_file", "plugin_read_result"}
] ]
traced_tools.extend(plugins.langchain_tools()) traced_tools.extend(plugins.langchain_tools())
bound_llm = active_llm.bind_tools(tools=traced_tools)
async def invoke(model):
bound = model.bind_tools(tools=traced_tools)
return await invoke_llm_with_timeout_message(acomplete(bound, current_messages))
interrupted, response = await self._await_or_pause( interrupted, response = await self._await_or_pause(
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages)) invoke(active_llm)
if using_tool_fallback
else with_fallback(
lambda: invoke(base_llm),
lambda: invoke(get_llm(ctx=self.ctx, name="operator", use_fallback=True)),
none_should_fallback=False,
)
) )
if interrupted: if interrupted:
return None, None, None, False return None, None, None, False
+13 -2
View File
@@ -303,8 +303,19 @@ class PlannerNode:
screen = await UnifiedMobileController(self.ctx).get_screen_data() screen = await UnifiedMobileController(self.ctx).get_screen_data()
human_message_content.append({"type": "text", "text": screen.observation_text}) human_message_content.append({"type": "text", "text": screen.observation_text})
plugins = getattr(self.ctx, "plugins", None) plugins = getattr(self.ctx, "plugins", None)
if plugins: plugin_catalog = plugins.prompt() if plugins else ""
human_message_content.append({"type": "text", "text": plugins.prompt()}) if plugin_catalog:
human_message_content.append(
{
"type": "text",
"text": "规划与执行的工具分工:规划器只使用本阶段绑定的笔记工具制定计划;"
"下列插件清单描述执行器获授权的能力。执行器可以调用 plugin_load 加载插件。"
"规划器当前工具列表里没有 plugin_load 或设备动作,不表示执行器缺少这些能力。"
"将需要的插件、待观察的目标和验收条件写入计划,由执行器根据实际加载与操作回执"
"确认可用性;未取得回执时保留待确认,不预先记录为插件不可用或要求用户补授权。",
}
)
human_message_content.append({"type": "text", "text": plugin_catalog})
if screenshot_b64: if screenshot_b64:
human_message_content.append( human_message_content.append(
{ {
@@ -92,6 +92,11 @@ async def exec_action(
return False, str(e) return False, str(e)
try: try:
from artemis.data_engine.evidence_sources import android_action_source
source = android_action_source(getattr(getattr(ctx, "device", None), "device_id", None))
if source:
action_item["source"] = source
res = await session.call(tool_name, wire_args) res = await session.call(tool_name, wire_args)
except Exception as e: except Exception as e:
action_item.update(execution="unknown", side_effect="possible") action_item.update(execution="unknown", side_effect="possible")
+3 -1
View File
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
"""Centralized ARTEMIS runtime settings loaded from environment and .env files.""" """Centralized ARTEMIS runtime settings loaded from environment and .env files."""
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = ( ARTEMIS_PLUGIN_MARKETPLACE_URL: str = (
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json" "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v4.json"
) )
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300) ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600) ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
@@ -109,6 +109,8 @@ class Settings(BaseSettings):
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1" OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1) OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0) OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
# Environment only; deliberately absent from the saved UI/task configuration.
OPENAI_USE_SYSTEM_PROXY: bool = False
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10) OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300) OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600) OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
+98 -4
View File
@@ -8,6 +8,58 @@ import json
SOURCE_KINDS = {"android", "computer", "terminal", "api", "file", "other"} SOURCE_KINDS = {"android", "computer", "terminal", "api", "file", "other"}
def android_action_source(device_id: object) -> dict | None:
"""Stamp a native action at dispatch, using its actual selected device."""
if not isinstance(device_id, str) or not device_id or device_id == "computer:local":
return None
return {"kind": "android", "target_id": device_id, "label": "安卓屏幕"}
def _android_screenshot_records(step: dict) -> list[dict]:
"""Project explicit capture receipts; never infer provenance from tool args."""
records = []
seen = set()
for tool in step.get("generic_tools") or []:
if not isinstance(tool, dict) or (
tool.get("type"),
tool.get("name"),
tool.get("status"),
) != ("tool", "android_file", "success"):
continue
payload = tool.get("payload")
receipt = payload.get("result") if isinstance(payload, dict) else None
if not isinstance(receipt, dict) or (
receipt.get("ok") is not True
or receipt.get("verified") is not True
or receipt.get("action") != "screenshot"
):
continue
serial, path, digest = (
receipt.get("device_serial"),
receipt.get("trace_path"),
receipt.get("sha256"),
)
if not all(isinstance(value, str) and value for value in (serial, path, digest)):
continue
captured_at = receipt.get("captured_at")
captured_at = captured_at if isinstance(captured_at, str) else None
key = (serial, path, digest, captured_at)
if key in seen:
# Nested tool decorators can record the same completed capture twice.
continue
seen.add(key)
records.append(
{
"source": {"kind": "android", "target_id": serial, "label": "安卓屏幕"},
"trace_id": tool.get("trace_id"),
"artifact_path": path,
"sha256": digest,
"captured_at": captured_at,
}
)
return records
def _step_reference(step: dict) -> dict: def _step_reference(step: dict) -> dict:
value = step.get("step_id") value = step.get("step_id")
return { return {
@@ -16,6 +68,19 @@ def _step_reference(step: dict) -> dict:
} }
def _represented_capture(action: dict, captures: list[dict]) -> bool:
if action.get("action") != "android_file":
return False
args = action.get("args") or {}
if not isinstance(args, dict) or args.get("action") != "screenshot":
return False
filename = args.get("filename")
return any(
capture["artifact_path"].replace("\\", "/").rsplit("/", 1)[-1] == filename
for capture in captures
)
def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict: def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
sources = {} sources = {}
unattributed = [] unattributed = []
@@ -23,7 +88,23 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
result = step.get("last_execution_result") result = step.get("last_execution_result")
execution = result.get("execution") if isinstance(result, dict) else None execution = result.get("execution") if isinstance(result, dict) else None
records = execution if isinstance(execution, list) else step.get("action_taken") records = execution if isinstance(execution, list) else step.get("action_taken")
records = records if isinstance(records, list) else [records] records = records if isinstance(records, list) else [records] if records else []
captures = _android_screenshot_records(step)
records = [
record
for record in records
if not isinstance(record, dict)
or (
record.get("action")
not in {"save_note", "read_note", "list_notes", "report_task_status"}
and not _represented_capture(record, captures)
)
]
records = [*records, *captures]
if not records:
# A planning or completion turn with no recorded action is not a
# missing action source. Screenshots alone do not supply provenance.
continue
found = False found = False
missing = False missing = False
for record in records: for record in records:
@@ -39,6 +120,7 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
continue continue
found = True found = True
plugin = str(record.get("plugin_id") or "")[:100] plugin = str(record.get("plugin_id") or "")[:100]
label = str(source.get("label") or "")[:500]
key = (plugin, kind, target[:256]) key = (plugin, kind, target[:256])
if key not in sources: if key not in sources:
sources[key] = { sources[key] = {
@@ -46,18 +128,30 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
"kind": kind, "kind": kind,
"plugin_id": plugin, "plugin_id": plugin,
"target_id": target[:256], "target_id": target[:256],
"label": str(source.get("label") or "")[:500], "label": label,
"category": "interface" if kind in {"android", "computer"} else "auxiliary", "category": "interface" if kind in {"android", "computer"} else "auxiliary",
"references": [], "references": [],
"reference_count": 0, "reference_count": 0,
} }
reference = _step_reference(step) reference = _step_reference(step)
for field in ("call_id", "source_id"): for field in (
"call_id",
"source_id",
"trace_id",
"artifact_path",
"sha256",
"captured_at",
):
if isinstance(record.get(field), str) and record[field]: if isinstance(record.get(field), str) and record[field]:
reference[field] = record[field][:256] reference[field] = record[field][: 2048 if field == "artifact_path" else 256]
if isinstance(record.get("read_only"), bool): if isinstance(record.get("read_only"), bool):
reference["read_only"] = record["read_only"] reference["read_only"] = record["read_only"]
entry = sources[key] entry = sources[key]
if not entry.get("label_varies") and entry["label"] != label:
# The same window can navigate between tabs/pages. Its first
# title is not a valid label for all later evidence.
entry["label"] = ""
entry["label_varies"] = True
entry["reference_count"] += 1 entry["reference_count"] += 1
if len(entry["references"]) < 8: if len(entry["references"]) < 8:
entry["references"].append(reference) entry["references"].append(reference)
+8
View File
@@ -164,6 +164,7 @@ async def execute_task(
config.with_graph_config_callbacks(graph_config_callbacks) config.with_graph_config_callbacks(graph_config_callbacks)
agent: Agent | None = None agent: Agent | None = None
task_started = False
try: try:
agent = Agent(config=config.build(), session_id=effective_sid) agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init( await agent.init(
@@ -196,7 +197,14 @@ async def execute_task(
request.plugins = plugins request.plugins = plugins
elif plugin_choices is not None: elif plugin_choices is not None:
request.plugins = TypeAdapter(list[PluginSelection]).validate_json(plugin_choices) request.plugins = TypeAdapter(list[PluginSelection]).validate_json(plugin_choices)
task_started = True
await agent.run_task(request=request) await agent.run_task(request=request)
except Exception as exc:
if not task_started and effective_sid:
from artemis.runtime.task_result import save_failure_result
save_failure_result(str(effective_sid), str(exc), code="initialization_failed")
raise
finally: finally:
if agent is not None: if agent is not None:
await agent.clean() await agent.clean()
+45 -10
View File
@@ -15,6 +15,7 @@
"""UI Launch and Web Console CLI Command (artemis ui).""" """UI Launch and Web Console CLI Command (artemis ui)."""
import logging import logging
import os
from pathlib import Path from pathlib import Path
import re import re
import shutil import shutil
@@ -34,6 +35,11 @@ import typer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _launch_text(english: str, chinese: str) -> str:
"""Honor the language selected by the Windows launch wrapper."""
return chinese if os.environ.get("ARTEMIS_LAUNCH_LANG") == "zh-CN" else english
def running_via_console_script_shim() -> bool: def running_via_console_script_shim() -> bool:
"""Return True when this CLI was launched through a console-script ``.exe`` shim. """Return True when this CLI was launched through a console-script ``.exe`` shim.
@@ -308,7 +314,12 @@ def ui_command(
) )
if restart: if restart:
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]") console.print(
_launch_text(
f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]",
f" [yellow]🔄 已请求重启,正在释放端口 {port}...[/yellow]",
)
)
load_session_reconciler() load_session_reconciler()
stop_server(port=port, timeout=12.0) stop_server(port=port, timeout=12.0)
elif is_port_in_use(port): elif is_port_in_use(port):
@@ -316,19 +327,39 @@ def ui_command(
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else "" pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
if sys.stdin.isatty(): if sys.stdin.isatty():
console.print( console.print(
f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]" _launch_text(
f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]",
f"\n [yellow]⚠ 检测到 ARTEMIS 服务正在运行,占用端口 {port}{pid_str}。[/yellow]",
)
) )
console.print(" [dim]Choose an action:[/dim]") console.print(
console.print(" [bold]1[/bold] Open browser (default)") _launch_text(" [dim]Choose an action:[/dim]", " [dim]请选择操作:[/dim]")
console.print(" [bold]2[/bold] Restart server (stop existing and start here)") )
console.print(" [bold]3[/bold] Cancel") console.print(
_launch_text(
" [bold]1[/bold] Open browser (default)",
" [bold]1[/bold] 打开浏览器(默认)",
)
)
console.print(
_launch_text(
" [bold]2[/bold] Restart server (stop existing and start here)",
" [bold]2[/bold] 重启服务(停止现有服务并在此启动,会中断正在运行的任务)",
)
)
console.print(_launch_text(" [bold]3[/bold] Cancel", " [bold]3[/bold] 取消"))
try: try:
choice = input(" Select [1-3, default 1]: ").strip() choice = input(
_launch_text(" Select [1-3, default 1]: ", " 请输入选项 [1-3,默认 1]:")
).strip()
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
choice = "1" choice = "1"
if choice == "2": if choice == "2":
console.print( console.print(
f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]" _launch_text(
f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]",
f"\n [yellow]🔄 正在停止端口 {port} 上的现有 ARTEMIS 服务...[/yellow]",
)
) )
load_session_reconciler() load_session_reconciler()
stop_server(port=port, timeout=12.0) stop_server(port=port, timeout=12.0)
@@ -340,8 +371,12 @@ def ui_command(
return return
else: else:
console.print( console.print(
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. " _launch_text(
f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n" f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n",
f"\n [yellow]⚠ 检测到 ARTEMIS 服务正在运行,占用端口 {port}{pid_str}。"
"如需重启,请运行 '[bold]artemis restart[/bold]'。[/yellow]\n",
)
) )
if open_browser: if open_browser:
webbrowser.open(local_url) webbrowser.open(local_url)
+53
View File
@@ -0,0 +1,53 @@
"""Bounded fingerprints of received argument deltas, without retaining text."""
import hashlib
PREFIX_BYTES = 4096
MAX_PREFIXES = 16
MAX_TRACKED_TOOLS = 64
class ArgumentFingerprint:
def __init__(self):
self.characters = 0
self.bytes = 0
self.deltas = 0
self.encoding_valid = True
self.digest = hashlib.sha256()
self.prefixes: list[dict] = []
def observe(self, arguments: str) -> None:
self.characters += len(arguments)
if not arguments:
return
self.deltas += 1
if not self.encoding_valid:
return
try:
encoded = arguments.encode("utf-8")
except UnicodeEncodeError:
# Diagnostics must not repair, reject or otherwise alter the stream.
self.encoding_valid = False
return
offset = 0
while offset < len(encoded) and len(self.prefixes) < MAX_PREFIXES:
size = min(PREFIX_BYTES - self.bytes % PREFIX_BYTES, len(encoded) - offset)
self.digest.update(encoded[offset : offset + size])
self.bytes += size
offset += size
if self.bytes % PREFIX_BYTES == 0:
self.prefixes.append({"bytes": self.bytes, "sha256": self.digest.hexdigest()})
if offset < len(encoded):
self.digest.update(encoded[offset:])
self.bytes += len(encoded) - offset
def snapshot(self) -> dict:
return {
"argument_chars": self.characters,
"argument_bytes": self.bytes if self.encoding_valid else None,
"argument_sha256": self.digest.hexdigest() if self.encoding_valid else None,
"argument_deltas": self.deltas,
"argument_prefixes": [dict(prefix) for prefix in self.prefixes],
"argument_encoding_valid": self.encoding_valid,
}
+35 -10
View File
@@ -13,6 +13,7 @@ from uuid import uuid4
from PIL import Image from PIL import Image
from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID
from artemis.llm.argument_fingerprint import ArgumentFingerprint, MAX_TRACKED_TOOLS
from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded
from artemis.llm.reliability import LLMExhaustedError from artemis.llm.reliability import LLMExhaustedError
from artemis.llm.usage import usage_from_message from artemis.llm.usage import usage_from_message
@@ -107,6 +108,7 @@ class RequestAttempt:
self.clock = time.monotonic() self.clock = time.monotonic()
self.last_publish = self.clock self.last_publish = self.clock
self.tools: dict[int | str, dict] = {} self.tools: dict[int | str, dict] = {}
self.tool_fingerprints: dict[int | str, ArgumentFingerprint] = {}
self.review = ACTIVE_FINAL_REVIEW.get() self.review = ACTIVE_FINAL_REVIEW.get()
if self.review: if self.review:
self.review.request_started(request_id, attempt) self.review.request_started(request_id, attempt)
@@ -126,6 +128,9 @@ class RequestAttempt:
"usage": None, "usage": None,
"output_chunks": 0, "output_chunks": 0,
"tool_argument_chars": 0, "tool_argument_chars": 0,
"tool_observation_layer": "langchain_tool_call_chunks",
"argument_fingerprint_version": 1,
"tool_stats_truncated": False,
"tools": [], "tools": [],
"step_id": str(self.step_id) if self.step_id else None, "step_id": str(self.step_id) if self.step_id else None,
"parent_trace_id": str(self.parent_id) if self.parent_id else None, "parent_trace_id": str(self.parent_id) if self.parent_id else None,
@@ -167,9 +172,10 @@ class RequestAttempt:
self.data.update( self.data.update(
headers_at=time.time(), headers_at=time.time(),
http_status=response.status_code, http_status=response.status_code,
server_request_id=_identifier( server_request_id=_identifier(response.headers.get("x-oneapi-request-id"))
response.headers.get("x-request-id") or response.headers.get("request-id") or _identifier(response.headers.get("x-request-id"))
), or _identifier(response.headers.get("request-id")),
response_x_request_id=_identifier(response.headers.get("x-request-id")),
server_trace_id=_identifier(response.headers.get("x-trace-id")), server_trace_id=_identifier(response.headers.get("x-trace-id")),
phase="waiting_output", phase="waiting_output",
) )
@@ -196,18 +202,31 @@ class RequestAttempt:
self.data["usage"] = usage self.data["usage"] = usage
tool_chunks = getattr(chunk, "tool_call_chunks", None) or [] tool_chunks = getattr(chunk, "tool_call_chunks", None) or []
# Responses exposes the same argument delta in content and tool_call_chunks. # Responses exposes the same argument delta in content and tool_call_chunks.
# Count only the latter; retain lengths/names, never argument bodies. # Count only the latter; retain bounded fingerprints, never argument bodies.
for tool in tool_chunks: for tool in tool_chunks:
arguments = tool.get("args")
if isinstance(arguments, str):
self.data["tool_argument_chars"] += len(arguments)
index = tool.get("index") index = tool.get("index")
key = index if index is not None else tool.get("id") or "unknown" call_id = _identifier(tool.get("id"))
stats = self.tools.setdefault(key, {"name": None, "argument_chars": 0}) key = index if index is not None else call_id or "unknown"
if key not in self.tools and len(self.tools) >= MAX_TRACKED_TOOLS:
self.data["tool_stats_truncated"] = True
continue
stats = self.tools.setdefault(key, {"name": None})
if call_id:
stats["call_id"] = call_id
if index is not None:
stats["chunk_index"] = index
name = tool.get("name") name = tool.get("name")
if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name): if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name):
stats["name"] = name stats["name"] = name
arguments = tool.get("args") fingerprint = self.tool_fingerprints.get(key)
if fingerprint is None:
fingerprint = self.tool_fingerprints[key] = ArgumentFingerprint()
if isinstance(arguments, str): if isinstance(arguments, str):
stats["argument_chars"] += len(arguments) fingerprint.observe(arguments)
self.data["tool_argument_chars"] += len(arguments) stats.update(fingerprint.snapshot())
self.data["tools"] = list(self.tools.values()) self.data["tools"] = list(self.tools.values())
content = getattr(chunk, "content", None) content = getattr(chunk, "content", None)
substantive = isinstance(content, str) and bool(content) substantive = isinstance(content, str) and bool(content)
@@ -252,7 +271,9 @@ class RequestAttempt:
if isinstance(status, int): if isinstance(status, int):
self.data["http_status"] = status self.data["http_status"] = status
if request_id := _identifier(getattr(error, "request_id", None)): if request_id := _identifier(getattr(error, "request_id", None)):
self.data["server_request_id"] = request_id self.data["error_request_id"] = request_id
if not self.data.get("server_request_id"):
self.data["server_request_id"] = request_id
if category == "timeout": if category == "timeout":
self.data.setdefault("timeout_scope", "transport") self.data.setdefault("timeout_scope", "transport")
if response is not None: if response is not None:
@@ -301,6 +322,7 @@ async def observe_request_headers(request):
if attempt is None: if attempt is None:
return return
request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"]) request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"])
request.headers.setdefault("X-Client-Attempt-Id", attempt.data["attempt_id"])
routing = {} routing = {}
for name in ("thread-id", "thread_id", "session-id", "session_id"): for name in ("thread-id", "thread_id", "session-id", "session_id"):
value = request.headers.get(name) value = request.headers.get(name)
@@ -309,9 +331,12 @@ async def observe_request_headers(request):
attempt.data.update( attempt.data.update(
request_sent_at=time.time(), request_sent_at=time.time(),
client_request_id=_identifier(request.headers.get("X-Client-Request-Id")), client_request_id=_identifier(request.headers.get("X-Client-Request-Id")),
client_attempt_id=_identifier(request.headers.get("X-Client-Attempt-Id")),
routing_header_fingerprints=routing, routing_header_fingerprints=routing,
client_request_id_matches=request.headers.get("X-Client-Request-Id") client_request_id_matches=request.headers.get("X-Client-Request-Id")
== attempt.data["request_id"], == attempt.data["request_id"],
client_attempt_id_matches=request.headers.get("X-Client-Attempt-Id")
== attempt.data["attempt_id"],
) )
attempt.publish() attempt.publish()
+15
View File
@@ -0,0 +1,15 @@
"""Environment-only network policy shared by model calls and credential checks."""
from typing import Any
import httpx
from artemis.config.settings import settings
def model_http_options() -> dict[str, Any]:
return {
"trust_env": settings.OPENAI_USE_SYSTEM_PROXY,
# Proxy opt-out must not disable configured CA certificates or TLS checks.
"verify": httpx.create_ssl_context(trust_env=True),
}
+7 -1
View File
@@ -30,6 +30,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
import random import random
import re
from functools import lru_cache from functools import lru_cache
import threading import threading
import time import time
@@ -124,6 +125,11 @@ def classify_failure(error: BaseException) -> Failure:
# Programmer/request errors: retrying is useless and falling back only # Programmer/request errors: retrying is useless and falling back only
# hides the bug inside a different model's output. # hides the bug inside a different model's output.
return Failure(FailureCategory.BAD_REQUEST, False, False) return Failure(FailureCategory.BAD_REQUEST, False, False)
# langchain-openai 1.7 raises this ValueError for a response.failed event
# without provider error details, before our completion guard sees it.
# Preserve the bounded invalid-output retry policy for these failed streams.
if isinstance(error, ValueError) and re.fullmatch(r"Response \S+ failed\.", str(error)):
return Failure(FailureCategory.INVALID_OUTPUT, True, True)
return Failure(FailureCategory.UNKNOWN, True, True) return Failure(FailureCategory.UNKNOWN, True, True)
@@ -147,7 +153,7 @@ class LLMPermanentError(LLMCallError):
class LLMExhaustedError(LLMCallError): class LLMExhaustedError(LLMCallError):
"""Retryable failures exhausted every attempt and the pause deadline.""" """Retryable failures exhausted attempts and any applicable pause deadline."""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+9 -1
View File
@@ -62,6 +62,7 @@ class ModelEndpoint(BaseModel):
self.reasoning_effort, self.reasoning_effort,
self.enable_grounding, self.enable_grounding,
self.api_base or settings.OPENAI_BASE_URL, self.api_base or settings.OPENAI_BASE_URL,
settings.OPENAI_USE_SYSTEM_PROXY,
hashlib.sha256(key.encode()).hexdigest(), hashlib.sha256(key.encode()).hexdigest(),
) )
@@ -87,9 +88,10 @@ class ModelFactory:
) )
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from openai import DefaultAsyncHttpxClient from openai import DefaultAsyncHttpxClient, DefaultHttpxClient
from artemis.llm.diagnostics import observe_request_headers, observe_response_headers from artemis.llm.diagnostics import observe_request_headers, observe_response_headers
from artemis.llm.http import model_http_options
api_key = endpoint.api_key or ( api_key = endpoint.api_key or (
settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None
@@ -112,8 +114,14 @@ class ModelFactory:
"use_responses_api": True, "use_responses_api": True,
"output_version": "responses/v1", "output_version": "responses/v1",
"store": False, "store": False,
# The explicit clients own proxy policy, including the synchronous path.
"openai_proxy": "",
"http_client": DefaultHttpxClient(
timeout=endpoint.timeout_seconds, **model_http_options()
),
"http_async_client": DefaultAsyncHttpxClient( "http_async_client": DefaultAsyncHttpxClient(
timeout=endpoint.timeout_seconds, timeout=endpoint.timeout_seconds,
**model_http_options(),
event_hooks={ event_hooks={
"request": [observe_request_headers], "request": [observe_request_headers],
"response": [observe_response_headers], "response": [observe_response_headers],
+1 -1
View File
@@ -23,7 +23,7 @@ class ToolArgumentOverflow(ValueError):
"limit_chars": SMALL_ACTION_ARGUMENT_LIMIT, "limit_chars": SMALL_ACTION_ARGUMENT_LIMIT,
} }
super().__init__( super().__init__(
f"模型生成的 {name} 参数异常膨胀({characters} 字符," f"收到的 {name} 参数流超出安全限制({characters} 字符,"
f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。" f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。"
) )
+9
View File
@@ -280,6 +280,11 @@ class McpActionExecutor:
) -> ToolExecutionResult: ) -> ToolExecutionResult:
span = TraceSpan(name=raw_name, trace_type="action", ctx=self.ctx) span = TraceSpan(name=raw_name, trace_type="action", ctx=self.ctx)
span.payload = {"args": args} span.payload = {"args": args}
from artemis.data_engine.evidence_sources import android_action_source
source = android_action_source(
getattr(getattr(self.ctx, "device", None), "device_id", None)
)
message = "" message = ""
res: ActionResult | None = None res: ActionResult | None = None
@@ -291,6 +296,7 @@ class McpActionExecutor:
observation_settled: bool | None = None observation_settled: bool | None = None
input_verification: str | None = None input_verification: str | None = None
expected_input: str | None = None expected_input: str | None = None
dispatched = False
with span: with span:
try: try:
session = await self._session_or_start() session = await self._session_or_start()
@@ -315,6 +321,7 @@ class McpActionExecutor:
or observation_anchor(state, index_elements) or observation_anchor(state, index_elements)
) )
extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS) extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
dispatched = True
if extension: if extension:
raw_result = await session.call_raw(wire_name, wire_args) raw_result = await session.call_raw(wire_name, wire_args)
message = next( message = next(
@@ -386,6 +393,7 @@ class McpActionExecutor:
"post_image_name": post_image_name, "post_image_name": post_image_name,
"has_xml": bool(xml_list), "has_xml": bool(xml_list),
"status": status, "status": status,
**({"source": source} if source and dispatched else {}),
} }
if status == "error": if status == "error":
span.status = "failed" span.status = "failed"
@@ -400,6 +408,7 @@ class McpActionExecutor:
screenshot_path=shot_path, screenshot_path=shot_path,
ui_elements_text=xml_list, ui_elements_text=xml_list,
metadata={ metadata={
**({"source": source} if source and dispatched else {}),
"code": res.code.value, "code": res.code.value,
"normalized_coordinates": res.normalized_coordinates, "normalized_coordinates": res.normalized_coordinates,
"target_semantics": target_semantics, "target_semantics": target_semantics,
+1
View File
@@ -532,6 +532,7 @@ class StepCapsuleLens(StepLens):
engine, engine,
response, response,
source=f"lens:step_capsule:{model_label}", source=f"lens:step_capsule:{model_label}",
model=model_label if model_label != "fallback" else None,
update_last_prompt=False, update_last_prompt=False,
) )
except Exception as exc: except Exception as exc:
+57
View File
@@ -0,0 +1,57 @@
"""Private launch barrier for sdk.run_process; stdlib-only under Python -I."""
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import threading
import time
def watch_parent(expected: int):
while True:
if os.getppid() != expected:
os.killpg(os.getpid(), signal.SIGKILL)
time.sleep(0.1)
def main():
status = Path(sys.argv[1])
if os.name != "nt":
threading.Thread(target=watch_parent, args=(int(sys.argv[2]),), daemon=True).start()
# Until the parent owns our job/session and sends this line, no business
# process can start. Buffering is local; tool stdin uses a separate pipe.
control = sys.stdin.buffer.readline(1024 * 1024 + 1)
if not control.endswith(b"\n") or len(control) > 1024 * 1024:
return
request = json.loads(control)
size = request["input_size"]
if type(size) is not int or not 0 <= size <= 64 * 1024 * 1024:
return
content = sys.stdin.buffer.read(size) if request["has_input"] else None
if content is not None and len(content) != size:
return
try:
result = subprocess.run(
request["argv"],
shell=False,
stdin=None if request["has_input"] else subprocess.DEVNULL,
input=content,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
check=False,
)
response = {"returncode": result.returncode}
except OSError:
response = {"launch_failed": True}
pending = status.with_suffix(".tmp")
pending.write_text(json.dumps(response), encoding="utf-8")
pending.replace(status)
# Remain the session/group leader until the owner reclaims the whole tree.
while True:
time.sleep(60)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""Synthetic tool used only by the opt-in SDK fault suite. No device/network I/O."""
import json
import os
from pathlib import Path
import subprocess
import sys
import time
def main():
mode = sys.argv[1]
if mode == "sleep":
time.sleep(60)
elif mode == "tree":
child = subprocess.Popen(
[sys.executable, "-I", "-B", __file__, "sleep"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
Path(sys.argv[2]).write_text(json.dumps({"pid": child.pid}), encoding="utf-8")
if sys.argv[3] == "linger":
time.sleep(60)
elif mode == "echo":
data = sys.stdin.buffer.read()
sys.stdout.buffer.write(data)
sys.stderr.buffer.write(b"synthetic-stderr")
elif mode == "overflow":
while True:
os.write(1, b"x" * 65536)
elif mode == "failure":
raise SystemExit(7)
else:
raise SystemExit(2)
if __name__ == "__main__":
main()
+5 -4
View File
@@ -44,10 +44,11 @@ def download(url: str, progress: Callable[[str, int], None]) -> bytes:
def load_catalog() -> PluginCatalog: def load_catalog() -> PluginCatalog:
url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL
# Older hosts reject schema-v3 entries before checking version constraints. # Strict older hosts cannot parse newer panel kinds, even if incompatible.
# Keep their index intact, and route only the exact former official URL. # Preserve their indexes; upgrade only the exact official saved addresses.
if url == "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json": official = "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/"
url = url.removesuffix("catalog.json") + "catalog-v3.json" if url in {official + "catalog.json", official + "catalog-v3.json"}:
url = official + "catalog-v4.json"
data = download(url, lambda *_: None) data = download(url, lambda *_: None)
if len(data) > 2 * 1024 * 1024: if len(data) > 2 * 1024 * 1024:
raise ValueError("市场索引过大") raise ValueError("市场索引过大")
+266
View File
@@ -0,0 +1,266 @@
"""Evidence-bound market materials. Declarations still need source/rights review."""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
import hashlib
import json
from pathlib import Path, PurePosixPath
import stat
import tempfile
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from artemis.config.settings import settings
from artemis.plugins.installer import _extract
from artemis.plugins.models import PluginManifest
REPORT_NAME = "MARKET.json"
CHECKS = (
"installation",
"tool_contract",
"failure_partial",
"timeout_cancel",
"process_tree",
"file_binding",
"resource_conflicts",
"evidence_export",
"history_uninstall",
"secret_redaction",
"real_task",
"source_review",
)
FORBIDDEN_DIRECTORIES = {".git", ".ruff_cache", ".pytest_cache", "__pycache__", ".venv"}
class SubmissionModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
class Component(SubmissionModel):
name: str = Field(min_length=1)
paths: list[str] = Field(min_length=1)
license: str = Field(min_length=1)
permission: Literal["permitted", "local_only", "unknown"]
license_file: str
source_file: str
class CheckRecord(SubmissionModel):
status: Literal["passed", "failed", "unverified", "not_applicable"]
evidence_file: str = ""
evidence_sha256: str = ""
notes: str = ""
class Traits(SubmissionModel):
uses_subprocesses: bool | None
file_inputs_for_writes: bool | None
class MarketSubmission(SubmissionModel):
schema_version: Literal[1]
plugin_id: str
plugin_version: str
content_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
traits: Traits
components: list[Component] = Field(min_length=1)
checks: dict[str, CheckRecord]
@contextmanager
def package_directory(path: Path) -> Iterator[tuple[Path, str | None]]:
"""Inspect a directory or safely unpack a ZIP without installing or hooks."""
path = path.resolve(strict=True)
if path.is_dir():
yield path, None
return
if not path.is_file():
raise ValueError("检查输入必须是普通插件目录或 ZIP 文件")
with path.open("rb") as stream:
data = stream.read(settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES + 1)
if len(data) > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件 ZIP 超过下载大小限制")
with tempfile.TemporaryDirectory(prefix="artemis-plugin-check-") as temporary:
directory = Path(temporary)
_extract(data, directory)
archive_sha256 = hashlib.sha256(data).hexdigest()
yield directory, archive_sha256
# Re-read only to reject changes; never relabel the inspected snapshot.
current_digest = hashlib.sha256()
current_size = 0
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
current_size += len(chunk)
if current_size > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("检查期间 ZIP 发生变化或超过大小限制")
current_digest.update(chunk)
if current_digest.hexdigest() != archive_sha256:
raise ValueError("检查期间 ZIP 发生变化,检查记录失效")
def _relative(value: str, *, root_allowed: bool = False) -> str:
if root_allowed and value == ".":
return value
path = PurePosixPath(value)
if (
not value
or "\\" in value
or ":" in value
or path.is_absolute()
or any(p in {".", ".."} for p in value.split("/"))
or any(ord(c) < 32 for c in value)
or str(path) != value
):
raise ValueError("市场材料必须使用包内普通相对路径")
return value
def package_files(directory: Path) -> dict[str, str]:
"""Hash every distributable file, excluding only the self-referential report."""
directory = directory.resolve(strict=True)
files = {}
seen = set()
size = 0
for path in sorted(directory.rglob("*")):
relative = path.relative_to(directory)
name = relative.as_posix()
if path.is_symlink() or path.is_junction() or not path.resolve().is_relative_to(directory):
raise ValueError("市场包不能包含链接")
if name.casefold() in seen:
raise ValueError("市场包存在大小写冲突的路径")
seen.add(name.casefold())
if len(seen) > 20000:
raise ValueError("市场包超过文件数量限制")
if any(p.casefold() in FORBIDDEN_DIRECTORIES for p in relative.parts):
raise ValueError(f"市场包包含缓存、依赖环境或版本库目录:{name}")
if path.is_dir():
continue
info = path.stat()
if not stat.S_ISREG(info.st_mode):
raise ValueError("市场包只能包含普通文件")
if name == REPORT_NAME:
continue
lower = path.name.casefold()
if (
lower.endswith((".pyc", ".pyo"))
or lower == ".env"
or (
lower.startswith(".env.")
and not lower.endswith((".example", ".sample", ".template"))
)
):
raise ValueError(f"市场包不能包含缓存或个人环境配置:{name}")
_relative(name)
digest = hashlib.sha256()
read = 0
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
read += len(chunk)
size += len(chunk)
if size > settings.ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES:
raise ValueError("市场包超过展开大小或文件数量限制")
digest.update(chunk)
if read != info.st_size:
raise ValueError("文件校验期间大小发生变化")
files[name] = digest.hexdigest()
return files
def content_fingerprint(files: dict[str, str]) -> str:
encoded = json.dumps(files, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def draft_submission(manifest: PluginManifest, files: dict[str, str]) -> dict:
"""Drafts never fabricate passed results or distribution permission."""
return {
"schema_version": 1,
"plugin_id": manifest.id,
"plugin_version": manifest.version,
"content_sha256": content_fingerprint(files),
"traits": {"uses_subprocesses": None, "file_inputs_for_writes": None},
"components": [
{
"name": "请拆分原始代码、适配层与第三方资料",
"paths": ["."],
"license": manifest.license or "待补充",
"permission": "unknown",
"license_file": "LICENSE",
"source_file": "SOURCE.md",
}
],
"checks": {
name: {"status": "unverified", "evidence_file": "", "evidence_sha256": "", "notes": ""}
for name in CHECKS
},
}
def _document(directory: Path, files: dict[str, str], value: str) -> str:
name = _relative(value)
if name not in files or not 0 < (directory / name).stat().st_size <= 1024 * 1024:
raise ValueError("许可、来源与验证记录必须是非空包内文档,且不超过 1 MiB")
return name
def validate_submission(
directory: Path, manifest: PluginManifest, files: dict[str, str]
) -> MarketSubmission:
path = directory / REPORT_NAME
if not path.is_file() or path.stat().st_size > 1024 * 1024:
raise ValueError("市场检查需要包内 MARKET.json;先生成草稿并补充真实记录")
report = MarketSubmission.model_validate_json(path.read_text("utf-8"))
if (report.plugin_id, report.plugin_version) != (manifest.id, manifest.version):
raise ValueError("市场材料的插件 ID/版本与清单不一致")
if report.content_sha256 != content_fingerprint(files):
raise ValueError("包内容已变化,旧的市场验证记录失效")
if not manifest.license.strip():
raise ValueError("市场清单必须声明许可证或具体的 LicenseRef")
if None in (report.traits.uses_subprocesses, report.traits.file_inputs_for_writes):
raise ValueError("必须明确是否使用子进程、是否将文件用于写操作")
covered = set()
for component in report.components:
if component.permission != "permitted" or component.license.strip().casefold() in {
"",
"unknown",
"todo",
"待补充",
}:
raise ValueError(f"组件未明确允许市场分发或未提供许可证:{component.name}")
_document(directory, files, component.license_file)
_document(directory, files, component.source_file)
for scope in component.paths:
scope = _relative(scope, root_allowed=True)
selected = {
name
for name in files
if scope == "." or name == scope or name.startswith(scope + "/")
}
if not selected:
raise ValueError("来源组件声明了不存在的文件范围")
covered.update(selected)
if set(files) - covered:
raise ValueError("市场包有文件未记录来源与分发许可")
if set(report.checks) != set(CHECKS):
raise ValueError("市场验证项目缺失或包含未知项目")
optional = {
"tool_contract": manifest.server is None,
"timeout_cancel": manifest.server is None,
"process_tree": not report.traits.uses_subprocesses,
"file_binding": not report.traits.file_inputs_for_writes,
"resource_conflicts": not manifest.resources,
}
for name, record in report.checks.items():
if not record.notes.strip():
raise ValueError(f"验证项目必须说明实际范围或不适用原因:{name}")
if record.status == "not_applicable" and optional.get(name, False):
continue
if record.status != "passed":
raise ValueError(f"市场验证项目未通过:{name}")
evidence = _document(directory, files, record.evidence_file)
if files[evidence] != record.evidence_sha256:
raise ValueError(f"验证记录 SHA256 不一致:{name}")
return report
+39 -2
View File
@@ -4,8 +4,8 @@ import ctypes
from ctypes import wintypes from ctypes import wintypes
class CaptureProcessJob: class ProcessJob:
"""A non-inheritable job handle: Windows kills captures when its owner exits.""" """A non-inheritable job handle that owns a Windows process tree."""
def __init__(self): def __init__(self):
class BasicLimits(ctypes.Structure): class BasicLimits(ctypes.Structure):
@@ -54,6 +54,12 @@ class CaptureProcessJob:
), ),
("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE), ("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), ("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
(
"QueryInformationJobObject",
[wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, wintypes.LPVOID],
wintypes.BOOL,
),
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL), ("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
): ):
function = getattr(self._api, name) function = getattr(self._api, name)
@@ -84,3 +90,34 @@ class CaptureProcessJob:
if self._handle: if self._handle:
self._api.CloseHandle(self._handle) self._api.CloseHandle(self._handle)
self._handle = None self._handle = None
def terminate(self):
if self._handle and not self._api.TerminateJobObject(self._handle, 1):
raise ctypes.WinError(ctypes.get_last_error())
def active_processes(self) -> int:
if not self._handle:
return 0
class Accounting(ctypes.Structure):
_fields_ = [
("user_time", ctypes.c_int64),
("kernel_time", ctypes.c_int64),
("period_user_time", ctypes.c_int64),
("period_kernel_time", ctypes.c_int64),
("page_faults", wintypes.DWORD),
("total", wintypes.DWORD),
("active", wintypes.DWORD),
("terminated", wintypes.DWORD),
]
accounting = Accounting()
if not self._api.QueryInformationJobObject(
self._handle, 1, ctypes.byref(accounting), ctypes.sizeof(accounting), None
):
raise ctypes.WinError(ctypes.get_last_error())
return accounting.active
# Keep the recording implementation and its callers on the same ownership primitive.
CaptureProcessJob = ProcessJob
+347
View File
@@ -0,0 +1,347 @@
"""Plugin author helpers; no scheduler, device actions or implicit retries.
Use snapshot_file before approving a write and consume its immutable bytes, not the
original pathname. run_process owns the tool and its descendants on every exit.
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass, field
import hashlib
import json
import math
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import tempfile
from artemis.plugins.recording_process import ProcessJob
MAX_INPUT_BYTES = 64 * 1024 * 1024
DEFAULT_OUTPUT_BYTES = 4 * 1024 * 1024
class FileVerificationError(ValueError):
"""The input cannot be bound to its explicitly approved digest."""
@dataclass(frozen=True)
class VerifiedFile:
"""Bounded, immutable input. No original path is exposed for reopening."""
data: bytes = field(repr=False)
sha256: str = field(init=False)
def __post_init__(self):
if not isinstance(self.data, bytes):
raise TypeError("快照内容必须是不可变 bytes")
object.__setattr__(self, "sha256", hashlib.sha256(self.data).hexdigest())
@property
def size(self) -> int:
return len(self.data)
def chunks(self, size: int = 256 * 1024) -> Iterator[memoryview]:
if type(size) is not int or size <= 0:
raise ValueError("分块大小必须是正整数")
content = memoryview(self.data)
for offset in range(0, len(content), size):
yield content[offset : offset + size]
def snapshot_file(
path: Path | str, expected_sha256: str, *, max_bytes: int = MAX_INPUT_BYTES
) -> VerifiedFile:
"""Copy once, then verify the exact bytes that will be consumed by the caller.
The source must already be authorized by the plugin. This is not a path or
permission sandbox. Memory use is bounded by max_bytes plus one read byte.
"""
if type(max_bytes) is not int or max_bytes <= 0:
raise ValueError("文件大小上限必须是正整数")
if not isinstance(expected_sha256, str) or not re.fullmatch(
r"[a-fA-F0-9]{64}", expected_sha256
):
raise FileVerificationError("必须明确提供已批准的 SHA256")
source = Path(path)
if source.is_symlink() or not stat.S_ISREG(source.lstat().st_mode):
raise FileVerificationError("输入必须是普通文件,不能是链接或设备")
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
descriptor = os.open(source, flags)
with os.fdopen(descriptor, "rb") as handle:
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size > max_bytes:
raise FileVerificationError("输入不是普通文件或超过大小上限")
data = handle.read(max_bytes + 1)
if len(data) > max_bytes:
raise FileVerificationError("输入超过大小上限")
snapshot = VerifiedFile(data)
if snapshot.sha256 != expected_sha256.lower():
raise FileVerificationError("输入 SHA256 与批准值不一致;不得开始写操作")
return snapshot
class ManagedProcessError(RuntimeError):
"""Stable failure metadata. Do not infer a business verdict or retry a write."""
def __init__(self, code: str, *, may_have_started: bool = True):
super().__init__(code)
self.error_code = code
self.may_have_started = may_have_started
class ProcessTimedOut(ManagedProcessError, TimeoutError):
pass
@dataclass(frozen=True)
class ProcessResult:
returncode: int
stdout: bytes = field(repr=False)
stderr: bytes = field(repr=False)
async def _cleanup(process: asyncio.subprocess.Process, job: ProcessJob | None) -> None:
error = None
try:
if job is not None:
job.terminate()
async with asyncio.timeout(5):
while job.active_processes():
await asyncio.sleep(0.02)
elif os.name != "nt":
# The bootstrap stays alive until cleanup, pinning the owned PGID.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
except (OSError, TimeoutError) as exc:
error = exc
finally:
if job is not None:
job.close()
if process.returncode is None:
try:
process.kill()
except ProcessLookupError:
pass
# Process.wait also waits for pipe EOF. A paused, full output reader must be
# drained separately; it cannot serve as evidence that the OS process lives.
try:
async with asyncio.timeout(5):
while process.returncode is None:
await asyncio.sleep(0.02)
except TimeoutError as exc:
error = exc
if error is not None:
raise ManagedProcessError("process_cleanup_unconfirmed") from error
async def _finish_cleanup(task: asyncio.Task[None]) -> None:
cancelled = False
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
cancelled = True
task.result()
if cancelled:
raise asyncio.CancelledError
async def _read_output(stream: asyncio.StreamReader, consumed: list[int], limit: int) -> bytes:
chunks = []
while chunk := await stream.read(65536):
consumed[0] += len(chunk)
if consumed[0] > limit:
raise ManagedProcessError("process_output_limit")
chunks.append(chunk)
return b"".join(chunks)
async def _drain(stream: asyncio.StreamReader | None) -> None:
if stream is not None:
while await stream.read(65536):
pass
async def _wait_result(process: asyncio.subprocess.Process, path: Path) -> int:
while not path.exists():
if process.returncode is not None:
raise ManagedProcessError("process_worker_interrupted")
await asyncio.sleep(0.02)
if path.stat().st_size > 4096:
raise ManagedProcessError("process_invalid_status")
try:
result = json.loads(path.read_text("utf-8"))
if not isinstance(result, dict):
raise ValueError
except (OSError, ValueError) as exc:
raise ManagedProcessError("process_invalid_status") from exc
if result.get("launch_failed") is True:
raise ManagedProcessError("process_launch_failed", may_have_started=False)
if type(result.get("returncode")) is not int:
raise ManagedProcessError("process_invalid_status")
return result["returncode"]
async def run_process(
argv: Sequence[str],
*,
cwd: Path | str,
timeout: float,
env: Mapping[str, str] | None = None,
input_data: bytes | None = None,
max_output_bytes: int = DEFAULT_OUTPUT_BYTES,
) -> ProcessResult:
"""Run one fixed argv without a shell; reclaim descendants even on normal exit.
Windows assigns a waiting bootstrap to a Job Object *before* it can start the
tool. POSIX uses a dedicated session. Cancellation completes cleanup before
propagating. Inputs are at most 64 MiB; stdout+stderr share a bounded budget.
Return codes/output are tool results, never a test verdict. No retries occur.
"""
if (
not isinstance(argv, Sequence)
or isinstance(argv, (str, bytes))
or not argv
or any(not isinstance(arg, str) or "\0" in arg for arg in argv)
):
raise ValueError("必须提供固定的 argv 字符串列表,不能使用 shell 命令文本")
if not argv[0] or isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
raise ValueError("启动程序和有限正数超时是必填项")
if type(max_output_bytes) is not int or max_output_bytes <= 0:
raise ValueError("输出上限必须是正整数")
if input_data is not None and (
not isinstance(input_data, bytes) or len(input_data) > MAX_INPUT_BYTES
):
raise ValueError("标准输入必须是至多 64 MiB 的 bytes")
directory = Path(cwd).resolve(strict=True)
if not directory.is_dir():
raise ValueError("工作目录必须是已存在的目录")
control = (
json.dumps(
{
"argv": list(argv),
"has_input": input_data is not None,
"input_size": len(input_data or b""),
}
).encode()
+ b"\n"
)
if len(control) > 1024 * 1024:
raise ValueError("启动参数超过大小上限")
job = None
process: asyncio.subprocess.Process | None = None
cleanup: asyncio.Task[None] | None = None
tasks: list[asyncio.Task] = []
async def reclaim():
nonlocal cleanup
if process is None:
if job is not None:
job.close()
return
if cleanup is None:
cleanup = asyncio.create_task(_cleanup(process, job))
await _finish_cleanup(cleanup)
with tempfile.TemporaryDirectory(prefix="artemis-plugin-process-") as temporary:
status = Path(temporary) / "status.json"
try:
job = ProcessJob() if os.name == "nt" else None
except OSError as exc:
raise ManagedProcessError("process_ownership_failed", may_have_started=False) from exc
launch = asyncio.create_task(
asyncio.create_subprocess_exec(
sys.executable,
"-I",
"-B",
str(Path(__file__).with_name("_process_worker.py")),
str(status),
str(os.getpid()),
cwd=str(directory),
env=dict(env) if env is not None else None,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
start_new_session=os.name != "nt",
)
)
try:
cancelled = False
while process is None:
try:
process = await asyncio.shield(launch)
except asyncio.CancelledError:
cancelled = True
except OSError as exc:
raise ManagedProcessError(
"process_launch_failed", may_have_started=False
) from exc
if cancelled:
raise asyncio.CancelledError
if job is not None:
try:
job.assign(process.pid)
except OSError as exc:
raise ManagedProcessError(
"process_ownership_failed", may_have_started=False
) from exc
stdin, stdout, stderr = process.stdin, process.stdout, process.stderr
assert stdin is not None and stdout is not None and stderr is not None
consumed = [0]
async def feed():
try:
stdin.write(control)
await stdin.drain()
if input_data:
stdin.write(input_data)
await stdin.drain()
except (BrokenPipeError, ConnectionResetError):
pass
finally:
stdin.close()
async def wait_tool():
code = await _wait_result(process, status)
await reclaim()
return code
tasks = [
asyncio.create_task(feed()),
asyncio.create_task(_read_output(stdout, consumed, max_output_bytes)),
asyncio.create_task(_read_output(stderr, consumed, max_output_bytes)),
asyncio.create_task(wait_tool()),
]
try:
_, stdout, stderr, code = await asyncio.wait_for(asyncio.gather(*tasks), timeout)
except TimeoutError as exc:
raise ProcessTimedOut("process_timeout") from exc
return ProcessResult(code, stdout, stderr)
finally:
for task in tasks:
task.cancel()
async def finalize():
try:
await reclaim()
finally:
await asyncio.gather(*tasks, return_exceptions=True)
if process is not None:
try:
async with asyncio.timeout(5):
await asyncio.gather(_drain(process.stdout), _drain(process.stderr))
await process.wait()
except TimeoutError as exc:
raise ManagedProcessError("process_cleanup_unconfirmed") from exc
await _finish_cleanup(asyncio.create_task(finalize()))
+212
View File
@@ -0,0 +1,212 @@
"""Run opt-in, offline SDK fault regressions in temporary directories only."""
import argparse
import asyncio
from contextlib import suppress
import hashlib
import json
from pathlib import Path
import sys
import tempfile
import time
import psutil
from artemis.plugins.evidence import decode_receipt, persist_attachments
from artemis.plugins.models import PluginCallResult
from artemis.plugins.sdk import (
FileVerificationError,
ManagedProcessError,
ProcessTimedOut,
run_process,
snapshot_file,
)
PROBE = Path(__file__).with_name("_sdk_probe.py").resolve()
def _require(condition, message):
if not condition:
raise AssertionError(message)
def _owned_process(marker: Path, started: float):
if not marker.exists():
return None
pid = json.loads(marker.read_text("utf-8"))["pid"]
try:
process = psutil.Process(pid)
if process.status() == psutil.STATUS_ZOMBIE:
return None
_require(process.create_time() >= started - 1, "测试 PID 的启动时间不匹配")
_require(str(PROBE) in process.cmdline(), "测试 PID 的脚本身份不匹配")
return process
except psutil.NoSuchProcess:
return None
async def _tree_case(root: Path, mode: str):
marker = root / f"{mode}.json"
started = time.time()
task = asyncio.create_task(
run_process(
[
sys.executable,
"-I",
"-B",
str(PROBE),
"tree",
str(marker),
"exit" if mode == "normal" else "linger",
],
cwd=root,
timeout=2 if mode == "timeout" else 10,
)
)
try:
if mode == "cancel":
async with asyncio.timeout(5):
while not marker.exists() and not task.done():
await asyncio.sleep(0.02)
_require(marker.exists(), "取消探针没有启动")
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
else:
raise AssertionError("取消没有传播")
elif mode == "timeout":
try:
await task
except ProcessTimedOut:
pass
else:
raise AssertionError("超时没有生效")
else:
result = await task
_require(result.returncode == 0, "模拟工具异常退出")
_require(marker.exists(), "后代进程探针没有启动")
async with asyncio.timeout(3):
while _owned_process(marker, started) is not None:
await asyncio.sleep(0.02)
finally:
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError, ManagedProcessError):
await task
# Failing implementations must not leave the fault suite's own sleeper alive.
process = _owned_process(marker, started)
if process is not None:
process.kill()
process.wait(timeout=5)
async def run_checks() -> dict:
checks = []
with tempfile.TemporaryDirectory(prefix="artemis-sdk-faults-") as temporary:
root = Path(temporary)
source = root / "synthetic.rom"
approved = b"approved firmware bytes\x00\xff"
source.write_bytes(approved)
digest = hashlib.sha256(approved).hexdigest()
snapshot = snapshot_file(source, digest)
for replacement in (b"X" * len(approved), b"", b"Y" * (len(approved) + 10)):
source.write_bytes(replacement)
_require(b"".join(snapshot.chunks(3)) == approved, "快照受原文件修改影响")
try:
snapshot_file(source, digest)
except FileVerificationError:
pass
else:
raise AssertionError("摘要不符仍被接收")
_require(all(part.readonly for part in snapshot.chunks()), "分块内容可被修改")
checks.append("file_replacement_truncation_growth_and_digest")
result = await run_process(
[sys.executable, "-I", "-B", str(PROBE), "echo"],
cwd=root,
timeout=5,
input_data=snapshot.data,
)
_require(
result.stdout == approved and result.stderr == b"synthetic-stderr",
"二进制输入输出不一致",
)
checks.append("binary_input_and_separate_output")
failed = await run_process(
[sys.executable, "-I", "-B", str(PROBE), "failure"], cwd=root, timeout=5
)
_require(failed.returncode == 7, "非零退出码被隐藏")
checks.append("nonzero_returncode")
for mode in ("normal", "timeout", "cancel"):
await _tree_case(root, mode)
checks.append(f"descendants_reclaimed_on_{mode}")
try:
await run_process(
[sys.executable, "-I", "-B", str(PROBE), "overflow"],
cwd=root,
timeout=5,
max_output_bytes=1024,
)
except ManagedProcessError as exc:
_require(exc.error_code == "process_output_limit", "输出超限错误类型不正确")
else:
raise AssertionError("输出超限没有停止")
checks.append("output_limit_stops_process")
trace = root / "synthetic-trace"
artifacts = trace / "artifacts/plugins/sdk-faults"
artifacts.mkdir(parents=True)
sample = artifacts / "sample.txt"
sample.write_bytes(b"synthetic evidence")
receipt = {"name": sample.name, "sha256": hashlib.sha256(sample.read_bytes()).hexdigest()}
_require(len(persist_attachments(trace, "sdk-faults", [receipt])) == 1, "附件未被回读")
for invalid in ({**receipt, "sha256": "0" * 64}, {**receipt, "name": "../outside.txt"}):
try:
persist_attachments(trace, "sdk-faults", [invalid])
except ValueError:
pass
else:
raise AssertionError("证据摘要不符或路径穿越未拒绝")
checks.append("evidence_hash_and_path_rejection")
for execution in ("partial", "unknown"):
outcome = PluginCallResult(
status="success",
structured_content={
"artemis": {
"version": 1,
"execution": execution,
"side_effect": "possible",
"retryable": True,
}
},
)
decode_receipt(outcome)
_require(
not outcome.retryable and outcome.requires_observation, "不确定写操作被允许重试"
)
checks.append("partial_unknown_never_authorize_retries")
return {
"status": "passed",
"scope": "sdk_faults_only",
"python": sys.version.split()[0],
"platform": sys.platform,
"checks": checks,
"real_device_actions": False,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json-output", type=Path)
args = parser.parse_args()
result = asyncio.run(run_checks())
if args.json_output:
args.json_output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8"
)
print(json.dumps(result, ensure_ascii=False))
print("仅验证 SDK 自身;插件仍需对真实入口做故障注入、证据与任务验收。")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
"""Plugin development checks and a separate market materials gate."""
import argparse
import asyncio
import json
from pathlib import Path
from pydantic import ValidationError
from artemis.plugins.evidence import validate_tools
from artemis.plugins.market import (
REPORT_NAME,
content_fingerprint,
draft_submission,
package_directory,
package_files,
validate_submission,
)
from artemis.plugins.models import PluginManifest, PluginProfile
from artemis.plugins.session import PluginSession, resolve_server
from artemis.plugins.store import contained_path
async def check(
directory: Path,
live: bool,
values: dict[str, str],
*,
market: bool = False,
fingerprint: bool = False,
prepare_market: bool = False,
) -> dict:
if prepare_market and not directory.is_dir():
raise ValueError("请在插件目录生成市场草稿,再打包 ZIP")
with package_directory(directory) as (root, archive_sha256):
manifest = PluginManifest.model_validate_json((root / "plugin.json").read_text("utf-8"))
manifest.check_compatible()
if manifest.skill:
skill = contained_path(root, manifest.skill)
if (
not skill.is_file()
or skill.stat().st_size > 256 * 1024
or not skill.read_text("utf-8").strip()
):
raise ValueError("缺少有效的 Skill 或超过大小限制")
if manifest.schema_version not in {2, 3}:
raise ValueError("新市场插件必须适配 schema_version=2 或 3")
if any(name not in manifest.config or manifest.config[name].secret for name in values):
raise ValueError("--values 只能包含清单中声明的普通配置,不得包含密钥")
files = package_files(root) if market or fingerprint or prepare_market else None
if prepare_market:
assert files is not None
draft = draft_submission(manifest, files)
with (root / REPORT_NAME).open("x", encoding="utf-8") as handle:
handle.write(json.dumps(draft, ensure_ascii=False, indent=2) + "\n")
return {
"status": "draft",
"plugin_id": manifest.id,
"version": manifest.version,
"content_sha256": draft["content_sha256"],
}
submission = None
if market:
assert files is not None
submission = validate_submission(root, manifest, files)
handshake = bool((live or market) and manifest.server)
if handshake:
server = resolve_server(manifest, PluginProfile(values=values), root)
server.env["PYTHONDONTWRITEBYTECODE"] = "1"
session = await PluginSession(server, root).start()
try:
validate_tools(manifest, session.tools)
finally:
await session.aclose()
if files is not None and package_files(root) != files:
raise ValueError("MCP 握手期间包内容发生变化,检查记录失效")
if market:
assert files is not None
if validate_submission(root, manifest, files) != submission:
raise ValueError("MCP 握手期间市场记录发生变化,检查记录失效")
result = {
"status": "passed",
"plugin_id": manifest.id,
"version": manifest.version,
"scope": "market_materials" if market else "development",
"mcp_handshake": handshake,
}
if files is not None:
result["content_sha256"] = content_fingerprint(files)
if archive_sha256 is not None:
result["archive_sha256"] = archive_sha256
return result
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path, help="插件目录或 ZIP;不会安装到现有插件库")
parser.add_argument(
"--live", action="store_true", help="仅启动/关闭 MCP 并核对声明,不调用工具"
)
parser.add_argument("--values", default="{}", help="普通配置 JSON;不得包含密钥")
modes = parser.add_mutually_exclusive_group()
modes.add_argument(
"--market", action="store_true", help="核对市场分发、内容绑定和验收材料,含 MCP 握手"
)
modes.add_argument(
"--prepare-market", action="store_true", help="创建未验证的 MARKET.json 草稿,不覆盖旧文件"
)
modes.add_argument(
"--fingerprint", action="store_true", help="输出包内容摘要;不宣称市场验收通过"
)
parser.add_argument("--json-output", type=Path, help="将检查结果写到包外 JSON 文件")
args = parser.parse_args()
result = {}
can_write = True
try:
if args.json_output and (
args.json_output.resolve() == args.directory.resolve()
or (
args.directory.is_dir()
and args.json_output.resolve().is_relative_to(args.directory.resolve())
)
):
can_write = False
raise ValueError("检查输出必须在插件包之外,不能覆盖输入包")
values = json.loads(args.values)
if not isinstance(values, dict) or any(not isinstance(v, str) for v in values.values()):
raise ValueError("--values 必须是字符串值的 JSON 对象")
result = asyncio.run(
check(
args.directory.resolve(),
args.live,
values,
market=args.market,
fingerprint=args.fingerprint,
prepare_market=args.prepare_market,
)
)
except (ValueError, OSError, RuntimeError) as exc:
message = (
"输入格式或清单/市场材料字段不合法"
if isinstance(exc, (json.JSONDecodeError, ValidationError))
else str(exc)
)
result = {"status": "failed", "error": message}
if args.json_output and can_write:
args.json_output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8"
)
print(json.dumps(result, ensure_ascii=False))
print("检查范围不包括源码正确性与真实业务通过;市场材料仍需来源审查和真实任务验收。")
if result["status"] == "failed":
raise SystemExit(1)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -47,7 +47,7 @@ class WorkspacePanel(WorkspaceModel):
id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,47}$") id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,47}$")
title: str = Field(min_length=1, max_length=100) title: str = Field(min_length=1, max_length=100)
side: Literal["left", "right"] side: Literal["left", "right"]
kind: Literal["initial_prompt", "form"] kind: Literal["initial_prompt", "original_case", "form"]
width: PanelWidth = Field(default_factory=PanelWidth) width: PanelWidth = Field(default_factory=PanelWidth)
+17 -2
View File
@@ -101,7 +101,10 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
# Migration cleanup belongs to the long-lived host. A queued task worker # Migration cleanup belongs to the long-lived host. A queued task worker
# must not wait on another process's optional legacy wake-lock cleanup. # must not wait on another process's optional legacy wake-lock cleanup.
if os.environ.get("ARTEMIS_TASK_WORKER") != "1": if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
ScreenAwakeLease(device_id).cleanup_unowned_references() try:
ScreenAwakeLease(device_id).cleanup_unowned_references()
except OSError as exc:
logger.warning(f"Could not prepare optional legacy awake cleanup on {device_id}: {exc}")
commands = ( commands = (
( (
@@ -209,6 +212,7 @@ class ScreenAwakeService:
self._monitor_thread: threading.Thread | None = None self._monitor_thread: threading.Thread | None = None
self._shutdown_requested = False self._shutdown_requested = False
self._owners: dict[str, FileLock] = {} self._owners: dict[str, FileLock] = {}
self._ownership_warnings: set[str] = set()
def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]: def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
if self._shutdown_requested: if self._shutdown_requested:
@@ -241,13 +245,24 @@ class ScreenAwakeService:
existing = self._strategies.get(device_id) existing = self._strategies.get(device_id)
if existing is not None: if existing is not None:
return existing return existing
owner = _owner_lock(device_id)
try: try:
owner = _owner_lock(device_id)
owner.acquire(timeout=0) owner.acquire(timeout=0)
except Timeout: except Timeout:
# Do not cache a follower as owner: discovery can take over if # Do not cache a follower as owner: discovery can take over if
# the process holding the OS lock exits or releases the device. # the process holding the OS lock exits or releases the device.
return None return None
except OSError as exc:
# Awake is optional. Never bypass ownership or let an ACL / I/O
# failure abort device connection, resume, or the host monitor.
if device_id not in self._ownership_warnings:
logger.warning(
f"Could not acquire awake ownership on {device_id}; skipping optional "
f"stay-awake policy: {exc}"
)
self._ownership_warnings.add(device_id)
return None
self._ownership_warnings.discard(device_id)
try: try:
strategy = _configure_usb_stay_awake(device_id) strategy = _configure_usb_stay_awake(device_id)
except BaseException: except BaseException:
+12 -1
View File
@@ -77,6 +77,8 @@ from typing import Any
import urllib.error import urllib.error
import urllib.request import urllib.request
from filelock import FileLock
from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL
from artemis.config.paths import get_temp_dir from artemis.config.paths import get_temp_dir
from artemis.runtime.adb_endpoint import adb_command from artemis.runtime.adb_endpoint import adb_command
@@ -323,7 +325,7 @@ class AccessibilityHelperManager:
if re.fullmatch(r"[0-9a-f]{32,128}", text): if re.fullmatch(r"[0-9a-f]{32,128}", text):
self._token = text self._token = text
return text return text
except OSError: except FileNotFoundError:
pass pass
token = secrets.token_hex(24) token = secrets.token_hex(24)
try: try:
@@ -550,6 +552,15 @@ class AccessibilityHelperManager:
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16] digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16]
path = root / f"{digest}.mutex" path = root / f"{digest}.mutex"
# An age-based lease alone can be stolen from a live, delayed owner.
# Retain the legacy lease for coexistence with older Artemis processes,
# but serialize current processes with a kernel lock released on exit.
with FileLock(path.with_suffix(".guard"), timeout=_PROVISION_MUTEX_TIMEOUT_SECONDS):
with self._legacy_provision_mutex(path, serial):
yield
@contextmanager
def _legacy_provision_mutex(self, path: Path, serial: str):
started = time.monotonic() started = time.monotonic()
while True: while True:
try: try:
+20 -9
View File
@@ -209,7 +209,10 @@ class Agent:
raise ValueError("任务目标必须是 android、computer 或 mixed") raise ValueError("任务目标必须是 android、computer 或 mixed")
self._execution_target = execution_target self._execution_target = execution_target
if execution_target == "computer": if execution_target == "computer":
from artemis.platform import platform as pal_platform
self._device_context = DeviceContext( self._device_context = DeviceContext(
host_platform=pal_platform.os_type.name,
device_id="computer:local", device_id="computer:local",
mobile_platform=DevicePlatform.COMPUTER, mobile_platform=DevicePlatform.COMPUTER,
device_width=0, device_width=0,
@@ -584,6 +587,7 @@ class Agent:
or getattr(getattr(task, "request", None), "task_name", None) or getattr(getattr(task, "request", None), "task_name", None)
) )
device_lock = None device_lock = None
execution_started = False
try: try:
# Construct the runtime only inside the task's cleanup boundary. A # Construct the runtime only inside the task's cleanup boundary. A
# cancelled queue wait or setup failure must not leave version pins. # cancelled queue wait or setup failure must not leave version pins.
@@ -761,6 +765,7 @@ class Agent:
publish_startup_progress( publish_startup_progress(
"environment_ready", "Device environment is ready", session_id=str(sess_id) "environment_ready", "Device environment is ready", session_id=str(sess_id)
) )
execution_started = True
try: try:
if request.profile and request.profile.lower() == "flash": if request.profile and request.profile.lower() == "flash":
logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...") logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...")
@@ -792,7 +797,7 @@ class Agent:
status = flash_result.get("status") status = flash_result.get("status")
if status == "completed": if status == "completed":
logger.info(f"✅ Automation '{task_name}' is success ✅") logger.info(f"Automation '{task_name}' execution completed.")
await task.finalize(content=output, state=last_state_snapshot) await task.finalize(content=output, state=last_state_snapshot)
if context.data_engine: if context.data_engine:
context.data_engine.end_session("completed") context.data_engine.end_session("completed")
@@ -907,7 +912,7 @@ class Agent:
" see the test summary." " see the test summary."
) )
else: else:
logger.info(f"✅ Automation '{task_name}' is success ✅") logger.info(f"Automation '{task_name}' execution completed.")
await task.finalize(content=output, state=last_state_snapshot) await task.finalize(content=output, state=last_state_snapshot)
if context.data_engine: if context.data_engine:
context.data_engine.end_session("completed") context.data_engine.end_session("completed")
@@ -972,7 +977,11 @@ class Agent:
from artemis.runtime.task_result import save_failure_result from artemis.runtime.task_result import save_failure_result
if sess_id: if sess_id:
save_failure_result(str(sess_id), err) save_failure_result(
str(sess_id),
err,
code="execution_failed" if execution_started else "initialization_failed",
)
if context.data_engine: if context.data_engine:
context.data_engine.end_session("failed") context.data_engine.end_session("failed")
@@ -1681,13 +1690,15 @@ def attach_test_summary(output, run_outcome: dict | None):
def resolve_trace_suffix(task_status: str, run_outcome: dict | None) -> str: def resolve_trace_suffix(task_status: str, run_outcome: dict | None) -> str:
"""Trace naming: assertion failures must be distinguishable from _PASS.""" """Keep execution and the independently verified test verdict distinct."""
if task_status != "completed": if task_status != "completed":
return "_FAIL" return "_FAIL"
tests = (run_outcome or {}).get("tests") or {} tests = (run_outcome or {}).get("tests") or {}
if ( verdict = ((run_outcome or {}).get("test_result") or {}).get("status")
int(tests.get("failed", 0)) > 0 if verdict == "blocked":
or ((run_outcome or {}).get("test_result") or {}).get("status") == "failed" return "_BLOCKED"
): if verdict == "inconclusive":
return "_UNVERIFIED"
if verdict == "failed" or int(tests.get("failed", 0)) > 0:
return "_TESTFAIL" return "_TESTFAIL"
return "_PASS" return "_PASS" if verdict == "passed" else "_UNVERIFIED"
+45 -16
View File
@@ -317,12 +317,32 @@ async def _wait_for_resume(pause_file: Path) -> bool:
"""Wait for the pause file to be cleared. Returns False on deadline.""" """Wait for the pause file to be cleared. Returns False on deadline."""
deadline = float(getattr(settings, "LLM_PAUSE_TIMEOUT_SECONDS", 0.0) or 0.0) deadline = float(getattr(settings, "LLM_PAUSE_TIMEOUT_SECONDS", 0.0) or 0.0)
waited = 0.0 waited = 0.0
while pause_file.exists(): try:
if deadline > 0 and waited >= deadline: owned = pause_file.stat()
return False except FileNotFoundError:
await asyncio.sleep(1) return True
waited += 1 try:
return True while pause_file.exists():
if deadline > 0 and waited >= deadline:
return False
await asyncio.sleep(1)
waited += 1
return True
finally:
# Cancellation (stop, guidance, outer deadline) also ends this wait.
# Do not leave a global pause behind or remove a newer request's pause.
try:
current = pause_file.stat()
if (current.st_ino, current.st_mtime_ns, current.st_size) == (
owned.st_ino,
owned.st_mtime_ns,
owned.st_size,
):
pause_file.unlink(missing_ok=True)
except FileNotFoundError:
pass
except OSError as exc:
llm_logger.warning("Could not clear finished LLM pause: %s", exc)
async def _wait_for_breaker(key: str) -> None: async def _wait_for_breaker(key: str) -> None:
@@ -361,7 +381,7 @@ async def _run_with_recovery[T](
immediately: retrying cannot help and pausing would hang the task. immediately: retrying cannot help and pausing would hang the task.
- When retryable attempts are exhausted: if a fallback model is waiting - When retryable attempts are exhausted: if a fallback model is waiting
(with_fallback), raise LLMExhaustedError so it takes over immediately; (with_fallback), raise LLMExhaustedError so it takes over immediately;
final acceptance also raises instead of pausing an already-finished task. final acceptance and invalid output also raise instead of globally pausing.
Other calls pause (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS) and Other calls pause (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS) and
retry the same request on resume. retry the same request on resume.
""" """
@@ -450,7 +470,7 @@ async def _run_with_recovery[T](
) )
if failure.category is FailureCategory.INVALID_OUTPUT: if failure.category is FailureCategory.INVALID_OUTPUT:
# One fresh response can recover a generation loop. Repeated # One fresh response can recover a generation loop. Repeated
# runaway output must reach the existing fallback/pause path, # runaway output must reach fallback or fail this request,
# even when ordinary network retries are configured higher. # even when ordinary network retries are configured higher.
max_attempts = min( max_attempts = min(
max_attempts, total_attempts + max(0, policy.max_attempts - attempt) max_attempts, total_attempts + max(0, policy.max_attempts - attempt)
@@ -485,7 +505,11 @@ async def _run_with_recovery[T](
ACTIVE_ATTEMPT.reset(observation_token) ACTIVE_ATTEMPT.reset(observation_token)
# Retryable attempts exhausted. # Retryable attempts exhausted.
if _FALLBACK_AVAILABLE.get() or ACTIVE_FINAL_REVIEW.get() is not None: if (
_FALLBACK_AVAILABLE.get()
or ACTIVE_FINAL_REVIEW.get() is not None
or last_failure.category is FailureCategory.INVALID_OUTPUT
):
if failed_endpoints is not None and endpoint_key: if failed_endpoints is not None and endpoint_key:
failed_endpoints[endpoint_key] = last_error failed_endpoints[endpoint_key] = last_error
_record_llm_event( _record_llm_event(
@@ -494,7 +518,11 @@ async def _run_with_recovery[T](
"error": str(last_error)[:1000], "error": str(last_error)[:1000],
"category": last_failure.category.value, "category": last_failure.category.value,
"retryable": True, "retryable": True,
"handover": "fallback" if _FALLBACK_AVAILABLE.get() else "final_review_end", "handover": "fallback"
if _FALLBACK_AVAILABLE.get()
else "final_review_end"
if ACTIVE_FINAL_REVIEW.get() is not None
else "task_failed",
}, },
status="failed", status="failed",
) )
@@ -510,10 +538,6 @@ async def _run_with_recovery[T](
"LLM pause deadline" "LLM pause deadline"
f" ({settings.LLM_PAUSE_TIMEOUT_SECONDS:.0f}s) exceeded; giving up." f" ({settings.LLM_PAUSE_TIMEOUT_SECONDS:.0f}s) exceeded; giving up."
) )
try:
pause_file.unlink(missing_ok=True)
except OSError:
pass
raise LLMExhaustedError( raise LLMExhaustedError(
str(last_error), failure=last_failure, cause=last_error str(last_error), failure=last_failure, cause=last_error
) from last_error ) from last_error
@@ -670,7 +694,12 @@ class RobustChatModelWrapper:
engine = self.ctx.data_engine if self.ctx else None engine = self.ctx.data_engine if self.ctx else None
if engine is None: if engine is None:
engine = _get_current_data_engine() engine = _get_current_data_engine()
record_llm_usage(engine, response, source=self._endpoint_key()) record_llm_usage(
engine,
response,
source=self._endpoint_key(),
model=self.endpoint.model_name if self.endpoint else None,
)
def _diagnostics(self, args, kwargs): def _diagnostics(self, args, kwargs):
prefix = None prefix = None
@@ -1232,7 +1261,7 @@ async def _with_fallback[T](
) )
# The contextvar must be reset BEFORE fallback_call runs: the fallback has # The contextvar must be reset BEFORE fallback_call runs: the fallback has
# no further fallback behind it, so its own exhaustion should pause. # no further fallback behind it, so apply its category's terminal policy.
fallback_token = _FALLBACK_AVAILABLE.set(True) fallback_token = _FALLBACK_AVAILABLE.set(True)
try: try:
result = await main_call() result = await main_call()
+3
View File
@@ -224,6 +224,7 @@ def record_llm_usage(
response: Any, response: Any,
*, *,
source: str | None = None, source: str | None = None,
model: str | None = None,
update_last_prompt: bool = True, update_last_prompt: bool = True,
) -> dict | None: ) -> dict | None:
"""Meters one LLM response and records an ``llm_usage`` trace, best-effort. """Meters one LLM response and records an ``llm_usage`` trace, best-effort.
@@ -251,6 +252,8 @@ def record_llm_usage(
} }
if source: if source:
payload["source"] = source payload["source"] = source
if model:
payload["model"] = model
node = CURRENT_NODE_NAME.get() node = CURRENT_NODE_NAME.get()
if node: if node:
payload["node"] = str(node) payload["node"] = str(node)
+3 -1
View File
@@ -17,6 +17,7 @@
import httpx import httpx
from artemis.config.settings import settings from artemis.config.settings import settings
from artemis.llm.http import model_http_options
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
@@ -91,7 +92,8 @@ async def validate_api_key(
try: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=timeout if timeout is not None else settings.ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS timeout=timeout if timeout is not None else settings.ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS,
**(model_http_options() if clean_provider == "openai" else {}),
) as client: ) as client:
if clean_provider in ("ocr", "vision", "google_vision"): if clean_provider in ("ocr", "vision", "google_vision"):
# Test Google Cloud Vision OCR API # Test Google Cloud Vision OCR API
+2
View File
@@ -112,3 +112,5 @@ Flash 跨端任务由模型通过内部 `update_task_progress` 记录短阶段
批次保留原顺序、循环次数和失败停止策略,默认保留各项配置,也可明确选择统一模型和思考强度。探索用例固定收藏版本,首次无复跑配置时默认 Flash;“收藏为重新探索”会创建新的探索会话并沿用原探索配置。 批次保留原顺序、循环次数和失败停止策略,默认保留各项配置,也可明确选择统一模型和思考强度。探索用例固定收藏版本,首次无复跑配置时默认 Flash;“收藏为重新探索”会创建新的探索会话并沿用原探索配置。
单任务下载全部资料时,未变化的终态报告和资料包会复用;人工复验、结果和资料变化会重新生成。批次沿用已生成的报告包,人工复验后重新打包;旧目录格式的包首次下载时更新。运行中的下载是当前快照。录像分为“安卓”“电脑”“人工示范”目录,索引路径与实际文件一致。打包优先速度,对已压缩的图片、视频和 Office 文件直接存储,保持原画质。 单任务下载全部资料时,未变化的终态报告和资料包会复用;人工复验、结果和资料变化会重新生成。批次沿用已生成的报告包,人工复验后重新打包;旧目录格式的包首次下载时更新。运行中的下载是当前快照。录像分为“安卓”“电脑”“人工示范”目录,索引路径与实际文件一致。打包优先速度,对已压缩的图片、视频和 Office 文件直接存储,保持原画质。
安装并启用任务人工评审 1.1.0 后,批量模板在原七列之后增加“预置条件、操作步骤、预期结果”。三列独立保存原始用例,不追加到 Artemis 提示词;左侧按用例编号和标题展示,右侧可用上一条/下一条评审同批次已结束任务。未启用该插件时仍提供并校验原七列模板。
+6
View File
@@ -1,5 +1,11 @@
# 开发交接:2026-10-19 # 开发交接:2026-10-19
2026-10-28 发布主线整理为 **ARTEMIS 2.4.0**,配套人工评审插件 **1.1.0**。本轮改动、配置和验证边界见 [2.4.0 发行说明](releases/2.4.0.md);下方记录为各阶段历史快照。
2026-10-28 配套升级 OpenAI/LangChain 与安全依赖,审计范围、版本、验证及容器构建限制见[依赖安全更新记录](plans/2026-10-28-dependency-security.md)。
2026-10-28 会话已接续至 **ARTEMIS 2.3.0**,最新提交、插件发布状态、产品约定和待验证范围见[本次交接记录](plans/2026-10-28-session-handoff.md)及[2.3.0 发行说明](releases/2.3.0.md)。以下各日期内容保留为历史快照,不代表当前待办。
2026-10-27 发布主线已整理为 **ARTEMIS 2.2.0**,配套插件为 **Cua 0.38.0+artemis.1**,Helper 为 **1.2.3**。最新升级内容、验证与未测范围以[2.2.0 发行说明](releases/2.2.0.md)为准;下方“尚未发布”、分叉计数和候选版本均属于对应日期快照。插件从独立工作树发布,原插件主目录的 OCU 开发现场保留。 2026-10-27 发布主线已整理为 **ARTEMIS 2.2.0**,配套插件为 **Cua 0.38.0+artemis.1**,Helper 为 **1.2.3**。最新升级内容、验证与未测范围以[2.2.0 发行说明](releases/2.2.0.md)为准;下方“尚未发布”、分叉计数和候选版本均属于对应日期快照。插件从独立工作树发布,原插件主目录的 OCU 开发现场保留。
2026-10-26 已再次接续开发会话,当前基线为主程序本地提交 `fac4ccb`(2.2.0)。两个仓库现场、已撤回机制与后续验收范围见[本次接续记录](plans/2026-10-26-session-handoff.md)。以下继续保留原始交接快照。 2026-10-26 已再次接续开发会话,当前基线为主程序本地提交 `fac4ccb`(2.2.0)。两个仓库现场、已撤回机制与后续验收范围见[本次接续记录](plans/2026-10-26-session-handoff.md)。以下继续保留原始交接快照。
+39
View File
@@ -0,0 +1,39 @@
# 模型流式参数诊断
小型内置动作的参数流仍受 16384 字符保护。超限时关闭输出流、丢弃不完整参数,不执行该次动作;一次新响应重试和可用备用端点耗尽后,只结束当前任务,不进入共享的模型暂停。批次仍遵循“失败即停止”、人工暂停和取消设置。网络故障、认证错误和最终验收保留各自的恢复规则。
## 请求关联
每个模型请求尝试沿用现有 `llm_diagnostic` 记录,不另建日志数据库。
| 字段 / 请求头 | 含义 |
| --- | --- |
| `request_id` / `X-Client-Request-Id` | 同一次恢复循环内保持稳定的逻辑请求编号;切换备用调用或恢复暂停后可能建立新的逻辑请求 |
| `attempt_id` / `X-Client-Attempt-Id` | 本次客户端尝试编号;重试时变化,`previous_attempt_id` 关联前次尝试 |
| `client_*_id_matches` | 实际发送的请求头是否与本地编号一致;调用方已指定的头保留原值 |
| `server_request_id` | 首个有效的 `X-Oneapi-Request-Id`、`X-Request-Id` 或 `Request-Id`,按此前后顺序选择 |
| `response_x_request_id` | 响应中的原始 `X-Request-Id`,不推定它来自模型上游 |
| `error_request_id` | SDK 异常带回的编号,不覆盖已经从响应头读取的编号 |
| `response_id` | SDK 实际返回的 Responses 响应编号 |
编号只接收有长度限制的可打印白名单字符。请求头不控制会话亲和性,不替换 `thread-id` / `session-id`。网关自身的重试应使用独立编号或序号,不能假装是一次新的客户端尝试。
## 参数指纹 v1
`tool_observation_layer=langchain_tool_call_chunks` 明确表示客户端观测的是 SDK 转换后的参数片段,不是原始 SSE 帧。每工具记录实际可得的 `call_id`、`chunk_index`、工具名,以及:
- `argument_chars`:参数片段的 Unicode 字符数之和。
- `argument_bytes` / `argument_sha256`:按原顺序连接片段后,其 UTF-8 字节长度和 SHA256;不解析、重排参数 JSON,也不规范化空白或 Unicode。
- `argument_deltas`:非空参数片段数,不能直接与网关原始事件数比较。
- `argument_prefixes`:每 4096 字节的累计 SHA256,最多保留前 16 个。可以在分片大小不同、上游预读或中途取消时比较相同字节位置。
- `argument_encoding_valid`:遇到不能编码为 UTF-8 的片段时为 false,当前总字节数和总哈希留空,避免伪造可比结果;诊断本身不改变执行流程。
最多记录 64 个工具;超过时设置 `tool_stats_truncated=true`,总参数字符计数仍继续。诊断不保留参数正文。哈希仅证明已收到的字节前缀,不能证明输出完整或动作成功;完整性仍由响应终止状态和解析校验决定。
跨三端优先使用 `response_id + call_id` 关联。LangChain 的 `chunk_index` 可能因文本或推理子块不同于原始 `output_index`,缺失编号时不能按数组位置猜测。正常的 `arguments.done`、`output_item.done`、`response.completed` 快照不重复计入参数;自定义工具转换、非空初始参数或协议异常需要先核对 SDK 映射,不能仅凭跨层哈希不同认定某一端重复发送。
历史日志没有保存的原始帧无法由新增诊断恢复。HTTP 200 只说明响应头已到达;客户端保护后出现的 499 或 Broken pipe 也不能证明最初原因。网关写入本机下游 writer 成功不等于客户端已消费或执行。
## 离线验证
`tests/unit/test_llm_stream_guard.py` 验证保护、有限重试、相同/不同备用端点,以及不会创建或清除其他请求的全局暂停。`tests/unit/test_request_diagnostics.py` 验证请求编号、超时重试、响应头及单次参数计数。`tests/unit/test_argument_fingerprint.py` 验证不同分片、中文和 emoji、固定字节前缀、有界统计与异常编码。这些测试不调用真实模型或设备。
@@ -0,0 +1,87 @@
# 2026-10-28 依赖安全更新与兼容性验证
## 审计结果
范围为主仓库声明的 Python、前端 npm、后台原型依赖及可识别的构建依赖。使用当日 PyPI/pip-audit、npm audit、OSV 和上游安全公告;没有忽略漏洞或降低检查级别。
| 范围 | 更新前 | 更新后 |
| --- | --- | --- |
| `uv.lock` 中全部 169 个 registry 包,含开发依赖及非 Windows 平台包 | 11 个包命中,97 条公告记录 | 0 条,未跳过包 |
| 前端 npm 锁文件 | 17 个受影响包:3 critical、11 high、3 moderate | 0 个受影响包 |
| `playground/backend_manager/requirements.txt` 当日解析结果 | 54 个包,2 个包命中、3 条记录 | 52 个包,0 条,未跳过包 |
| Python 构建工具的当日解析结果 | — | setuptools 84.2.1、Cython 3.3.0,0 条 |
| Android Gradle Plugin 8.5.0 | — | OSV 直接包查询未检出公告;未解析完整 Gradle 构建工具传递依赖 |
公告可能含重复 ID 或别名,97 条不代表 97 个独立 CVE;npm 受影响包数也包含依赖链上的父包。零结果仅表示本次数据库未检出已知漏洞,不代表没有未知风险或业务安全问题。
远程 Python SDK 不含第三方运行时依赖;Android Helper 使用系统 API,不含第三方运行时库。独立 `Artemis-Plugins` 仓库、用户安装的插件、系统软件包和完整容器镜像不在上述零漏洞结论内。
## 版本调整
| 包 | 原版本 | 更新后 |
| --- | --- | --- |
| openai | 3.3.0 | 3.40.0,沿用同日 SDK 更新 |
| langchain / langchain-core | 1.3.15 / 1.5.6 | 1.4.7 / 1.7.0 |
| langchain-openai | 1.5.2 | 1.7.3 |
| langgraph / langgraph-sdk | 1.2.11 / 0.4.2 | 1.2.18 / 0.4.10 |
| httpx | 0.28.1 | 保持 0.28.1,当日最新版 |
| httpx2 / httpcore2 | 2.12.0 | 2.16.0 |
| mcp | 1.29.0 | 1.30.0 |
| aiohttp | 3.14.3 | 3.14.5 |
| certifi | 2026.7.22 | 2026.10.23 |
| filelock | 3.32.3 | 4.2.4 |
| multidict | 6.7.1 | 7.2.1 |
| pillow | 12.3.0 | 13.0.0 |
| pyjwt | 2.13.0 | 2.15.1 |
| tornado | 6.5.8 | 6.6 |
| urllib3 | 2.7.0 | 2.8.0 |
| virtualenv | 21.7.4 | 21.15.1 |
| Angular framework / compiler-cli | 22.1.4 | 22.2.3 |
| Angular CLI / build | 22.1.6 | 22.2.4 |
| Angular CDK | 22.1.5 | 22.2.4 |
| Vite | 8.1.5 | 8.3.5 |
| Piscina | 5.2.0 | 5.3.2 |
| 原型 Nginx 基础镜像 | 1.27-alpine | 1.30.6-alpine |
`langchain-community 0.4.2` 和 `langchain-mcp-adapters 0.3.2` 保持不变。后者要求 MCP `<2`,因此主项目声明 `mcp>=1.30.0,<2.0.0`,使用兼容的 1.x 安全修复版本。其余联动升级见 `uv.lock`。
Angular build 22.2.4 仍精确依赖存在漏洞的 Vite 8.3.0,通过限定在 `@angular/build` 下的 npm override 使用 8.3.5,未使用 `--force` 或 `--legacy-peer-deps`。以后上游采用安全版本时可移除 override,重新审计并验证构建。
后台原型的 `python-jose 3.5.0` 和间接 `ecdsa 0.19.2` 没有可用修复版,改为 `PyJWT[crypto]>=2.15.1,<3.0.0`,保持算法白名单与 HTTP 401 错误处理。原型仍按 requirements 动态解析;该扫描结果不是所有旧镜像或旧安装环境的审计证明。
## 必要的适配
- `langchain-openai 1.7.x` 对没有错误详情的 `response.failed` 抛出 `ValueError("Response <id> failed.")`。补充精确分类,保留 `INVALID_OUTPUT` 的有限重试;未把所有 ValueError 当成无效模型输出。
- 补充失败流重试回归,验证不完整输出不会成为正式结论或工具动作,重试保留已有工具证据。
- Responses 模拟传输改用 OpenAI 默认使用的 httpx2,并修正测试夹具中 `request` 同名遮蔽,确保超过 1 MiB 的中文工具参数确实经过分块 SSE/UTF-8 传输。
- 原型 JWT 回归覆盖签发、声明、当前用户、过期、错误签名、算法不匹配、无签名、畸形 token 和缺失 subject。
- MCP 的 audience 漏洞需要服务端配置配合,单纯升级不自动开启校验。本仓库没有配置 `AuthSettings`/`token_verifier`,当前不使用该 bearer-auth 路径;未来接入时必须同时设置 `validate_token_resource` 并提供正确的 token resource。
## 验证
- 隔离环境完整默认 Python 测试:3207 通过、6 跳过、8 排除;102 条现有 Pillow `getdata()` 弃用警告,该接口计划在 Pillow 14 移除。
- 随后改用 httpx2 的模型流、失败分类和 JWT 专项测试:41 通过;同步原 `.venv` 后同一组 41 项再次通过。
- Ruff 格式/静态检查、质量基线、核心 Pyright 类型检查通过;Pyright 额外显式使用了升级后的解释器。
- 锁文件校验、升级环境 `uv pip check`、`git diff --check` 通过。
- 前端 ChromeHeadless:336 通过;生产构建通过,生成产物不纳入提交。
- 主 Python、原型 Python、Python 构建工具及前端更新后复扫无已知漏洞。
- Nginx 1.30.6 为当日官方稳定修复版本,Docker Hub 标签存在且 active。已尝试构建原型镜像,但 Docker Hub registry/auth 连接超时,尚未完成容器构建和运行验证,也未扫描完整镜像内的 OS 包。
- 未调用付费模型、执行设备业务测试、发布提交或修改用户任务配置。
原始审计 JSON、解析清单和测试日志保存在本地被 Git 忽略的 `artifacts/dependency-audit-20261028/`。
## 生效
初次同步受到运行中服务占用 Pillow DLL 的阻止,已恢复旧环境并通过依赖一致性检查。用户随后明确表示已停止服务,原 `.venv` 已成功执行 `uv sync --dev --locked` 并再次通过版本核对与兼容性测试。
随后通过 `artemis restart --no-open` 恢复后台服务;`/api/system/server-status` 返回 running,首页 HTTP 200 且引用本次新前端构建。原环境全部 165 个已安装 distribution 的版本均与锁文件匹配。
本次沿用 ARTEMIS 2.3.0,按用户要求提交源码和锁文件,不发布。用户已明确无需构建 Docker,本次不再继续容器构建;修改 Dockerfile 不会更新已有容器。
## 上游依据
- [Pillow 13.0.0 安全修复](https://pillow.readthedocs.io/en/stable/releasenotes/13.0.0.html)
- [MCP bearer token audience 公告](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-w4fh-qvv9-3v23)
- [Vite/launch-editor Windows 命令执行公告](https://github.com/advisories/GHSA-587h-7wmj-h5pm)
- [Vite 8.3.5 发布说明](https://github.com/vitejs/vite/releases/tag/v8.3.5)
- [Nginx 安全公告](https://nginx.org/en/security_advisories.html)及[当前稳定版本](https://nginx.org/en/download.html)
+35
View File
@@ -0,0 +1,35 @@
# OpenAI Python SDK 3.40.0 适配
本文保留 SDK 单独升级阶段的结果。同日后续的 LangChain、HTTPX2 与安全依赖升级,以及最终验证与生效状态,见[依赖安全更新记录](2026-10-28-dependency-security.md)。
## 范围
- `pyproject.toml` 显式声明 `openai>=3.40.0,<4.0.0`;项目直接使用 SDK 的 HTTP 客户端,不再仅依赖 `langchain-openai` 间接带入。
- `uv.lock` 将 SDK 从 3.3.0 升至 3.40.0,保留既有 `langchain-openai 1.5.2`、`httpx2 2.12.0` 及其他包版本。锁文件记录发行包和 SHA-256。
- 继续使用 Responses API、`store=False` 与 ARTEMIS 的重试预算;SDK `max_retries=0`,不叠加 SDK 重试。
- 没有修改模型默认值、已有用户配置、设备调度或插件协议,主程序版本仍为 2.3.0。
## 兼容性核对
从 3.3.0 到 3.40.0 的更新涉及 SSE 解码、异步流关闭、传输异常归一化、重试验证、Responses 类型及缓存元数据。现有 Responses 调用与已锁定的 LangChain 适配层在专项回归中兼容,无需新增业务兼容分支。
3.40.0 增加显式缓存断点 TTL 类型。此次只升级 SDK,不自动启用显式缓存断点或改变 TTL;以后接入产品配置时,需要同时处理服务端与模型支持、参数入口和用量展示,不能将 SDK 类型支持当作当前端点支持。
新增回归覆盖超过 1 MiB 的中文工具参数,在 SSE 帧和 UTF-8 字符跨网络块时完整还原;即使参数完整,缺少 `response.completed` 仍拒绝输出正式结论。
## 本次验证
- 在 OpenAI SDK 3.40.0 下,模型调用专项回归 97 项通过;完整默认回归 3198 项通过、6 项跳过、8 项排除,保留 102 条已有 Pillow 弃用警告。
- Ruff 格式与静态检查、质量基线、核心 Pyright 类型检查、`uv pip check`、`uv lock --check` 通过。
- 本次未改前端,未运行前端测试或构建;未执行真实模型或设备业务验收。
## 生效与边界
开发环境已通过 `uv sync --dev --locked` 同步。已有长驻服务需正常重启后完整加载新 SDK,本次不主动重启。验证使用本地模拟传输,不调用付费模型或真实设备。
## 来源
- [OpenAI Python SDK 3.40.0 发布说明](https://github.com/openai/openai-python/releases/tag/v3.40.0)
- [SDK 变更记录](https://github.com/openai/openai-python/blob/v3.40.0/CHANGELOG.md)
- [官方 Python SDK 文档](https://developers.openai.com/api/reference/python)
- [官方提示词缓存说明](https://developers.openai.com/api/docs/guides/prompt-caching)
+33
View File
@@ -0,0 +1,33 @@
# 2026-10-28 运行时故障与批次恢复修复
## 已确认问题
- awake 所属锁的目录创建和获取只处理正常争用,`PermissionError` 可从 UIAutomator、无障碍连接、设备恢复和主程序监控的共享入口传播。
- 旧唤醒租约清理虽然处理了清理过程异常,构造时创建临时目录仍可能失败。
- Flash、Pro/探索 Operator 的工具无进展恢复有备用模型,但模型生成异常未进入该入口。
- 批次模型暂停与用户暂停未区分;模型等待超时后文件已清除,批次仍维持暂停。
- 模型等待被取消时可能残留暂停文件;终态报告的通用失败说明可能遮蔽真实初始化/模型错误。
## 修复范围
- 保活权限/I/O 异常降级为警告。未取得所属锁时不执行保活动作;后续可重新获取,不缓存为成功。同一设备持续获取失败只警告一次,成功后重置。
- 工作进程早期 Agent 准备及 SDK 运行时准备失败记录 `initialization_failed`。批次在报告导出前保存失败原因并暂停剩余项;导出失败不改变这一分类。
- Flash 和 Operator 接入现有 `with_fallback`。取消、永久请求错误和同端点重试预算仍遵守统一网关规则,不修改用户模型配置,不放宽工具参数/完整响应保护。
- 区分模型、初始化、用户、服务恢复暂停。模型恢复后按既定失败策略继续;停止全部任务覆盖自动恢复标记。重启后没有存活任务时仍要求人工继续。
- 模型等待结束或被取消时清理本次暂停;文件已被新请求改写时保留。
## 边界
- 不删除或改 ACL,不更换锁目录绕过互斥。设备执行锁、主程序注册锁和队列 I/O 锁保留失败即阻止执行的行为。
- 未重跑原批次、修改历史裁定或调用真实设备/付费模型;未构建 Docker。
- 原 Windows 锁文件的 ACL、占用者及异常发生时依赖版本尚无本机证据;本修复处理应用传播和恢复缺口,不宣称证明了操作系统拒绝访问的具体来源。
- 旧批次不自动迁移暂停原因,需用户显式继续;不会从历史零步骤数量推断并改写失败分类。
## 验证
针对性测试覆盖锁目录/文件权限拒绝后恢复、监控继续处理其他设备、旧保活清理、Flash/Operator 生成失败切换、暂停取消/超时、初始化错误保留与批次暂停、手动继续不重跑失败项、用户暂停与停止全部、服务重启恢复。
- 默认测试:3225 通过、6 跳过、8 按默认标记排除。102 条既有 Pillow `getdata()` 弃用警告。
- Ruff 格式检查与静态检查通过,质量基线通过,核心 Pyright 检查 0 错误、0 警告。
- 第一轮完整测试暴露新增 CLI 测试的环境变量隔离遗漏,影响两项录像测试;修正测试隔离后第二轮完整测试全部通过,未为此改变产品行为或放宽断言。
- 本次仅修改 Python 与文档,未运行前端构建、真实设备或付费模型测试。
+47
View File
@@ -0,0 +1,47 @@
# ARTEMIS 开发主线交接:2026-10-28
本次依据用户提供的《ARTEMIS开发主线-1027》完整会话记录、仓库发行说明与本地 Git 状态接续开发。agentmemory 未返回匹配本项目目录的会话,因此不采用其他项目的记忆。此次未联网刷新远端、重跑测试、重启服务或操作设备;发布和实测结论来自上一会话记录,不能视为本次重新验证。
## 当前基线
| 范围 | 本次本地核对结果 |
| --- | --- |
| 主程序 | `main` 与本地 `eapil/main` 位于 `bb873ec`,版本 2.3.0;迁入前已跟踪文件无修改,只有未跟踪的 `.angular/` 缓存 |
| 人工评审插件工作树 | 插件仓库 `.tmp/task-review-extension`,分离 HEAD `386f108`,工作树干净;`7bcdb2a` 增加插件,`386f108` 上架市场 |
| Cua 工作树 | 插件仓库 `.tmp/cua-shell-observation`,分支 `codex/cua-shell-observation`,提交 `b780a60`,工作树干净 |
| 插件原主目录 | `main` 位于 `757b829`,相对本地 `origin/main` 领先 4、落后 10;仍有 OCU、Cua 和测试脚本等既有修改与未跟踪文件,必须保留 |
上一会话已完成 ARTEMIS 2.2.0、Cua `0.38.0+artemis.1`、ARTEMIS 2.3.0 和任务人工评审 1.0.0 的推送及发布;Cua 继续标注预览版。最后已核验远端提交、发行附件下载校验值,并完成线上市场隔离安装和启用验证。早期记录中的“2.2.0 尚未发布”“Cua 尚未提交”已经过时。
## 已完成的开发与约定
- 2.2.0 接入仅 PC 测试,网页、MCP、参数 JSON 和 Python SDK 使用 `execution_target="computer"`;PC、安卓和跨端任务统一全局串行。
- 已修复安卓缩放截图的校验坐标、观测恢复预算、输入首次清空与空值确认、Helper 聚焦目标选择、Pro 收尾空转和重复计划、MCP 排队及运行状态、笔记截图路径和验收项展示标签匹配。Helper 升级至 1.2.3。动作卡片文字可复制,长 RESULT 默认折叠。
- 模型选项增加 `gpt-6.1-terra`,新安装探索默认 `gpt-6.1-terra / high`;保存配置优先。
- 2.3.0 提供 `schema_version: 3` 的声明式 `workspace` 插件接口。独立「任务人工评审」插件从市场安装启用,主程序不默认安装启用。
- 回放标题栏控制左右侧栏,普通浮窗和影院模式均支持;单侧展开仍位于对应左右侧,窄屏一次展开一侧。
- 左侧只展示本次运行最初提交的需求提示词,支持 Markdown,不混入模型拆解步骤或后续指导。
- 右侧由人工填写「AI执行准确性」「测试结果正确性」,使用中文「通过 / 失败」,两项及备注均选填,可以修改或清空。结果正确性评价 ARTEMIS 判断是否准确,与业务用例是否通过区分。
- 评审不调用 AI,不覆盖原执行结论或既有人工复验;运行中只读,结束后可编辑,保留修改历史,并防止并发覆盖和重复写入。
- 评审绑定单次运行,复跑不继承;禁用或卸载保留数据但隐藏界面及新导出字段,重新安装启用后恢复,删除任务时清理。
- 已启用插件的字段进入单任务 Word/Excel、批次汇总及资料包报告,空项留空。重新导出反映最新评审,已下载旧文件不变。
- 插件 SDK、工作区接口文档及随程序分发的 Skill 已同步。2.3.0 使用 `catalog-v3.json`,旧 `catalog.json` 保持兼容 2.2.x;执行插件更新需兼顾两个索引,工作区插件只加入新版。
- 已撤回的次数配额、内容指纹和 AI 二次动作否决不恢复;已移除的设备动作回放不恢复,历史步骤和截图回看保留。
## 历史验证与仍待覆盖范围
- 2.2.0 发布前记录:后端 3189 项、前端 326 项、SDK 24 项通过,构建及质量检查通过。集中回归 9/9 正式实测通过;后续对排队状态、截图路径、验收项标签另有修复和实测证据。
- 2.3.0 发布前记录:后端默认回归 3197 项、插件针对性回归 49 项、前端 336 项通过,构建及静态检查通过;Windows 隔离示例验证主题、左右布局、中文评审保存与 Word/Excel 导出。该轮未执行真实设备业务或付费模型测试,macOS/Linux 未实测。
- Cua 真实鼠标跟随问题按用户此前要求暂缓;原任务不足以证明具体误点根因,不能因驱动已发布就标为已解决。
- 真实断线恢复、小时级稳定性、多显示器及多 DPI 等扩大兼容性场景仍缺完整验证。发布完成不等于这些场景全部通过。
- 素材准备句柄、跨次任务取代关系仍只是产品候选,尚未立项。
当前没有尚未完成的发布步骤或待回答的产品确认。本次仅迁移上下文;后续按用户新需求继续,修改前重新核对相关仓库和工作树,不自动恢复旧设备任务或重复发布。
## 参考
- [2.2.0 发行说明](../releases/2.2.0.md)
- [2.3.0 发行说明](../releases/2.3.0.md)
- [工作区插件接口](../workspace-plugins.md)
- [插件开发 SDK](../plugin-development.md)
- [上次会话接续记录](2026-10-26-session-handoff.md)
+6 -2
View File
@@ -1,6 +1,6 @@
# 插件开发 SDK 与证据协议(ARTEMIS 2.3.0) # 插件开发 SDK 与证据协议(ARTEMIS 2.4.5)
新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。需要回放面板、人工表单和报告字段时使用版本 3,见[工作区插件接口](workspace-plugins.md)。模型执行、任务队列、资源锁、超时和报告由 ARTEMIS 管理,插件不另建任务调度器,也不替用户决定用例通过。 新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。需要回放面板、人工表单和报告字段时使用版本 3,见[工作区插件接口](workspace-plugins.md)。模型执行、任务队列、资源锁、整体调用预算和报告由 ARTEMIS 管理;插件负责内部工具的超时、取消与子进程收尾,不另建任务调度器,也不替用户决定用例通过。
## 从可运行模板开始 ## 从可运行模板开始
@@ -23,6 +23,8 @@ uv run python -c "import shutil; shutil.make_archive('evidence-demo', 'zip', 'ex
静态检查通过不等于市场准入,也不表示业务测试通过。需按下面清单提交实际验证记录。 静态检查通过不等于市场准入,也不表示业务测试通过。需按下面清单提交实际验证记录。
涉及外部程序或已批准文件输入时,从 [managed-tool-demo](../examples/plugins/managed-tool-demo/README.md) 开始;它提供真实 MCP 离线回归。通用接口、故障自测和独立 `--market` 检查见[运行辅助接口与市场材料](plugin-sdk.md)。SDK 自测在临时目录运行,不访问真实设备。
## 清单与工具能力 ## 清单与工具能力
`plugin.json` 的安装、版本约束、资源、权限设置沿用 [插件说明](plugins.md)。版本 2 增加: `plugin.json` 的安装、版本约束、资源、权限设置沿用 [插件说明](plugins.md)。版本 2 增加:
@@ -87,3 +89,5 @@ stdio 宿主注入 `ARTEMIS_ARTIFACTS_DIR`,插件默认把生成文件写入
- 视觉:截图来源、遮挡、DPI、窗口切换、坐标一致、无图时拒绝盲点;录屏启动/结束/暂停恢复、编码回退、磁盘不足、时间缺口、关键节点、播放状态、中文可读性及长程体积。 - 视觉:截图来源、遮挡、DPI、窗口切换、坐标一致、无图时拒绝盲点;录屏启动/结束/暂停恢复、编码回退、磁盘不足、时间缺口、关键节点、播放状态、中文可读性及长程体积。
- 文件:中文文件名、覆盖保护、校验失败;终端/API:退出码、业务结果和不确定副作用。 - 文件:中文文件名、覆盖保护、校验失败;终端/API:退出码、业务结果和不确定副作用。
- Skill 必须只描述真实存在且已适配的能力,不绕过权限、调度和证据协议。保留来源和必要许可证,版本与实测范围明确;未测平台/场景不能写成已通过。 - Skill 必须只描述真实存在且已适配的能力,不绕过权限、调度和证据协议。保留来源和必要许可证,版本与实测范围明确;未测平台/场景不能写成已通过。
- 外部程序验证正常退出、内部超时、取消和输出超限后的整个进程树回收;不能只确认 MCP/直接子进程退出。写操作消费文件时,批准摘要必须绑定实际消费的不可变内容,覆盖校验后替换、截断与增长。
- 市场提交须附 [MARKET.json](plugin-sdk.md#marketjson),绑定候选内容、组件分发授权和验收证据;对最终 ZIP 运行 `--market`。本地开发检查不强制公开许可,市场材料检查不替代源码和业务审查。
+121
View File
@@ -0,0 +1,121 @@
# 插件运行辅助接口与市场检查
这些接口从 ARTEMIS **2.4.5** 开始提供,位于 `artemis.plugins.sdk`;使用 ARTEMIS 2.4.5 或更新版本的 Python,并在插件清单中声明 `artemis_version: ">=2.4.5,<3.0.0"`。它们不属于远程薄客户端 `artemis-client`,不启动后台、模型或第二套任务队列。现有不依赖这些辅助接口的插件仍按自身清单兼容旧宿主;采用这些接口才需要提高最低版本。
## 已批准文件必须绑定实际使用的字节
```python
from artemis.plugins.sdk import snapshot_file
snapshot = snapshot_file(authorized_path, approved_sha256)
# 此后只消费快照。连接建立、预检或等待期间原文件发生变化,也不会改变快照。
for chunk in snapshot.chunks():
transport.sendall(chunk)
```
`approved_sha256` 必须来自已批准的内容身份,不能在执行时把新文件的摘要自动当作新的批准。制作快照、验证设备身份和写入授权都应在真正写操作前完成。使用 `.data` 或只读的 `.chunks()`,不要校验之后重新打开原路径;传输后才发现摘要不符不能撤销已经发生的写入。
`snapshot_file` 一次读取授权的普通文件并核对 SHA256,拒绝链接、特殊文件、大小超限和摘要不符。默认上限 64 MiB,副本是不可变内存 bytes;参数 `max_bytes` 仅作用于本次调用,选择更大上限须评估内存。它不负责用户授权或路径沙箱,插件仍要限制允许读取的范围。大文件如采用磁盘快照或流式方案,应提供等价的防修改保证并另行验证,不能恢复到“检查后重开路径”。
`VerifiedFile.size`、`.sha256` 对应快照本身。原路径同长度覆盖、替换、增长或截断后,快照仍保持原内容。对写入端到端的回归必须检查实际发送字节,不能只断言预检函数被调用。
## 外部工具的生命周期
```python
from artemis.plugins.sdk import ManagedProcessError, run_process
try:
result = await run_process(
fixed_argv,
cwd=authorized_directory,
timeout=20,
input_data=snapshot.data,
)
except ManagedProcessError as exc:
# 用稳定的 error_code 返回部分执行/未知;不回显命令或原始敏感输出,不自动重试。
...
```
`fixed_argv` 必须是业务层构造的固定参数列表,不能向模型暴露任意 shell;`cwd` 为明确的现有目录。可通过 `env` 提供所需的环境白名单;未提供时继承当前插件环境,插件应自行排除不需要的密钥和工具日志变量。宿主的凭据管理不会自动证明外部程序的输出安全。
`timeout` 必须为有限正数,作用于本次工具运行;标准输入最多 64 MiB。stdout、stderr 分别返回 bytes,共用默认 4 MiB 上限,可通过本次调用的 `max_output_bytes` 调整。输出超限立即停止;部分输出不作有效结果返回。`ProcessResult.returncode` 保留非零退出码,不据此生成业务用例通过结论。
运行器在 Windows 先将等待启动的引导进程加入不可继承的 Job Object,再允许启动实际工具,防止快速派生后代漏出归属;父进程退出时 Job Object 同样回收其树。POSIX 使用独立会话,正常退出、超时、取消和输出超限均回收所属进程组。它是生命周期辅助,不是恶意程序沙箱,不能承诺回收主动逃离会话的程序。
调用结束后不保留后台子进程。需要常驻驱动的插件应实现会话级归属、关闭与验证,不借本接口后台化工具。取消在清理后继续传播,不能吞掉 `CancelledError`。清理另有固定的有界等待;未确认完成时抛出 `process_cleanup_unconfirmed`,插件必须返回未知状态并核对资源,不声明已全部停止。
常见错误码:`process_timeout`、`process_output_limit`、`process_launch_failed`、`process_ownership_failed`、`process_worker_interrupted`、`process_invalid_status`、`process_cleanup_unconfirmed`。`may_have_started=false` 只表示已知工具尚未启动;其余情况不能授权重发写操作。运行器从不重试。原始输出可能包含密钥,返回给模型前按业务字段白名单处理。
宿主负责整体任务与 MCP 调用预算,插件负责内部操作及其后代。内部超时先发生时,必须在向宿主返回前收尾;宿主未超时、或只关闭 MCP 连接,都不能替代这一步。
## 可运行回归
```sh
<ARTEMIS_PYTHON> -B -m artemis.plugins.sdk_checks --json-output sdk-faults.json
<ARTEMIS_PYTHON> -B <受管工具模板目录>/test_contract.py
```
模板目录在源码仓库的 `examples/plugins/managed-tool-demo`,或本 Skill 的 `assets/managed-tool-demo`;先复制到用户指定开发目录。以下检查均使用 ARTEMIS 2.4.5 或更新版本的 Python。
第一条只在临时目录创建合成文件和自己的测试进程,覆盖文件同长度覆盖、截断/增长、错误摘要、二进制 stdin、非零退出码、正常结束/超时/取消后的后代回收、输出超限、附件摘要与路径拒绝,以及部分/未知执行不能授权重试。它不连接设备、不调用模型、不修改现有任务或配置。结果明确标为 `sdk_faults_only`。
第二条额外通过真实 stdio MCP 调用模板工具,核对有效输入、错误摘要、目录穿越拒绝及宿主附件回读,结果标为 `template_offline_contract`。模板见插件开发文档的受管工具示例。
改写插件后,必须扩展模板测试到自己的实际入口:在最后一次预检与真正消费文件之间替换输入、让实际工具创建后代再超时/取消,并检查实际传输和进程存活。SDK 自测通过不能证明插件调用正确。还需覆盖错误/部分执行、证据丢失或摘要不符、权限、资源互斥以及真实任务时间线。
## 开发检查与市场材料检查
统一入口为仓库/Skill 的 `check_plugin.py`,也可执行 `python -m artemis.plugins.validation`。两处脚本调用同一个实现,不单独维护规则。
```sh
# 日常本地开发,无需公开分发许可;--live 只握手,不派发工具。
<ARTEMIS_PYTHON> <检查脚本> <插件目录或ZIP> --live --values '{"python":"ARTEMIS解释器绝对路径"}'
# 在清洁候选目录生成草稿;所有结论未验证、许可未知,不覆盖已有 MARKET.json。
<ARTEMIS_PYTHON> <检查脚本> <插件目录> --prepare-market
# 对最终候选 ZIP 检查市场材料;包含只读 MCP 声明握手,不调用业务工具。
<ARTEMIS_PYTHON> <检查脚本> <最终ZIP> --market --values '{"python":"ARTEMIS解释器绝对路径"}' --json-output market-check.json
```
路径与 JSON 按当前 Shell 引用;密钥不放进 `--values`。`--json-output` 必须在候选目录外,不能覆盖输入 ZIP。ZIP 沿用宿主的安全解压和大小限制,检查不向已有插件库安装。`--market` 先核对材料,再握手,普通开发检查仍保持轻量。
市场检查拒绝缓存、`.pyc`、虚拟环境、版本库目录、个人 `.env` 配置、链接和冲突路径。环境样例可保留 `.env.example`。不要为通过检查而清理用户运行目录;从可分发输入构建独立候选。
## MARKET.json
此文件随包提交,版本固定为 1,不增加 `plugin.json` 字段,旧宿主仍可安装符合其能力的插件。草稿由检查脚本生成,包含:
| 字段 | 要求 |
| --- | --- |
| `plugin_id`、`plugin_version` | 与候选清单完全一致 |
| `content_sha256` | 全部包内文件路径及 SHA256 的确定性摘要,只排除根层 `MARKET.json` 自身 |
| `traits.uses_subprocesses` | 明确 true/false,不能保留 null |
| `traits.file_inputs_for_writes` | 是否消费文件执行写操作,明确 true/false |
| `components` | 分开列原始实现、适配层、第三方依赖/参考资料的许可和来源 |
| `checks` | 下表全部验收项目,保留实际范围和证据 |
每个组件包含 `name`、`paths`、`license`、`permission`、`license_file`、`source_file`。`paths` 为精确包内相对路径或目录前缀,`.` 表示整个包;全部可分发文件须被覆盖。只有明确的 `permission: "permitted"` 才接受,`local_only` 和 `unknown` 均阻止市场提交。许可和来源文件必须实际在包内;适配层开源许可不会重新许可原始代码、用例、协议或内部资料。可使用合适的 LicenseRef,不强制统一开源,但具体分发授权必须可审查。
每条验收记录包含 `status`、`evidence_file`、`evidence_sha256`、`notes`。`passed` 必须指向真实的非空包内文档及其正确摘要,notes 说明验证环境、范围与结果;缺失、失败和未验证均拒绝。不能把历史记录、SDK 自测或模拟设备改写为本次真实业务通过。
| 验收项目 | 要记录的内容 |
| --- | --- |
| `installation` | 安装、升级、平台和依赖兼容 |
| `tool_contract` | 实际 MCP Schema、能力和参数限制 |
| `failure_partial` | 错误、部分执行、未知副作用与不盲目重试 |
| `timeout_cancel` | 实际工具入口超时与取消 |
| `process_tree` | 工具创建后代后,正常/超时/取消的回收验证 |
| `file_binding` | 已批准文件被替换、截断、增长时的实际消费字节 |
| `resource_conflicts` | 同设备/桌面资源互斥 |
| `evidence_export` | 导出索引、中文路径、摘要不符与缺失附件 |
| `history_uninstall` | 卸载/升级后历史仍可读 |
| `secret_redaction` | 配置、命令、输出和回执脱敏 |
| `real_task` | 经宿主正常任务的操作、时间线、暂停/恢复、证据和报告 |
| `source_review` | 对本次包的源码审查、来源与分发授权核对 |
仅在声明无相应行为时,`process_tree`、`file_binding` 可 `not_applicable`;无 MCP 时 `tool_contract`、`timeout_cancel` 可不适用;无声明资源时 `resource_conflicts` 可不适用。仍须在 notes 中给出理由,不能把未测写成不适用。其他项目须有真实通过记录。
更新任何文件后,旧材料会失效。先重跑受影响验证,再用 `--fingerprint` 查看新的内容摘要,并更新记录;不要只刷新摘要来沿用过期结论。握手结束后重新核对内容与市场记录,检查期间改动会使检查失败。最终 ZIP 检查结果另包含实际解压检查的同一份字节的 SHA256,不重新打开原路径来记录替换文件;公开发布时仍需核对固定附件、市场索引与无认证下载字节。
市场材料检查验证字段、覆盖范围、摘要和证据存在性,不证明声明真实、不替作者确认权利归属,也不自动发布。源码审查和实际业务验收仍由发布方核对;文档、静态检查、握手、SDK 故障自测与真实业务结论分别记录。
+44
View File
@@ -0,0 +1,44 @@
# ARTEMIS 2.4.0
> 全新安装的 npm 校验失败已在 [2.4.1](2.4.1.md) 修复,请使用补丁版源码包或通过更新脚本升级。
本版改进批量用例人工评审与执行详情阅读,并包含 OpenAI SDK 适配、安全依赖更新、Windows 资源锁和报告证据修复。配套「任务人工评审」独立发布 **1.1.0**。
## 人工评审与原始用例
- 安装并启用人工评审 1.1.0 后,批量模板在原七列后增加「预置条件、操作步骤、预期结果」,仍接受七列旧模板。未启用插件时保持原有七列模板和校验;新增内容不进入 AI 提示词。
- 左侧标题为用例编号与用例标题,下面依次展示三项原始内容;历史任务未保存的内容显示缺失,不用 AI 提示词替代。
- 「AI执行准确性」改为「用例执行准确性」,保留字段 ID 和既有评审;备注框缩短,可自行拉高。
- 「上一条/下一条」放在保存评审下方。同批次按已结束任务顺序切换,独立任务使用当前历史列表。
- 选择结果立即保存,备注停止输入 600 毫秒后保存,切换前也先保存。失败时保留输入和当前用例,允许手动重试;连续修改不会被较早回执覆盖。打开任务不会自行填写或保存评审。
- Flash、Pro、探索以及安卓、电脑、跨端任务均可显示面板,步骤与视频视图均支持。
## 执行详情与报告
- Payload、原始结果及超长、多行调试字段默认折叠;展开后限高滚动,全文可读,不截断保存的证据。
- 修复正式验收通过但没有单独输出正文时出现「没有保存最终结论」的误导提示,报告按有效结论生成说明。
- 安卓截图的真实保存回执进入结构化来源汇总,保留路径与哈希;窗口枚举没有单一目标时说明原因,不误报截图丢失。
- 电脑任务记录真实宿主平台;来源窗口标题变化时提示查看原始步骤。结果目录区分通过、不通过、阻塞与未验证。
- 历史模型和思考强度优先读取任务自身诊断,用量统计保留实际请求模型,减少历史条目被当前配置误标;修复步骤、队列与录屏收尾相关读取问题。
## 模型、依赖与运行稳定性
- OpenAI Python 更新为 3.40.0,配套 LangChain 1.4.7、langchain-core 1.7.0、langchain-openai 1.7.3;httpx 保持兼容的 0.28.1。Python 与前端安全依赖更新写入锁文件。
- 新增仅环境配置 `OPENAI_USE_SYSTEM_PROXY=false`,模型请求默认不采用系统代理。需要代理时在 `.env` 设为 `true` 并重启;同步、异步请求和 OpenAI 连接检查一致,TLS 与自定义 CA 校验继续保留。
- 修复 Windows 临时目录 awake 锁访问失败及所有权恢复,检查同类资源锁和运行时收尾。持久权限错误仍保留实际原因,不跳过互斥保护。
- Helper 初始化增加操作系统互斥锁,防止仍在执行的操作仅因锁文件超龄而被抢占;已有宿主令牌无法读取时保留原文件和错误,不生成另一份令牌覆盖它。
- 规划器明确区分规划阶段与执行器的插件能力,避免因本阶段没有动作工具而误判插件不可用;批次模型失败保留明确诊断。
2.4.0 使用独立的 `catalog-v4.json` 市场索引;保留 2.3.x 与 2.2.x 的旧索引,防止旧宿主因新面板类型无法解析而导致市场加载失败。已有官方地址自动路由,自定义地址保持原样。
## 验证范围
发布前最终默认后端测试 **3267 通过、2 跳过、8 按标记排除**,前端 **347 项通过**;生产构建、Ruff 格式与静态检查、质量基线和核心类型检查通过。Windows 浏览器隔离夹具覆盖深浅色、宽窄布局、视频视图面板、自动保存与切换,以及长 Payload 折叠。未写入真实任务人工评审。
此前用户重启后的实际复测中,5 项正式用例通过 AI 验收,另有 1 项电脑路径探索通过,覆盖安卓、电脑与跨端;该样本没有发现 awake 锁访问失败。此结果不等于重跑原 192 项业务批次,未完成大批次压力及 macOS/Linux 实测。本次发版不构建 Docker,不重启用户服务。
## 升级与下载
等待当前任务结束后运行 `update.bat`(国内镜像用 `update-cn.bat`),完成后运行 `start.bat` / `start-cn.bat` 并刷新网页。需要人工评审新功能时,再从插件市场升级并启用 [任务人工评审 1.1.0](https://git.playones.com/chenmingxuan/Artemis-Plugins/releases/tag/task-review-v1.1.0)。插件要求 ARTEMIS 2.4.0+;升级主程序不会自动升级或授权已安装插件。
源码附件为 `artemis-2.4.0-source.zip` 和 `SHA256SUMS.txt`,保留浅层 Git 更新能力,不包含用户配置、凭据、历史任务、依赖目录或预构建网页。工作区扩展接口见 [插件文档](../workspace-plugins.md)。
+26
View File
@@ -0,0 +1,26 @@
# ARTEMIS 2.4.1
本补丁修复 2.4.0 全新安装时的 `npm ci / EINTEGRITY`,适用于 `start.bat` 和 `start-cn.bat`。人工评审插件版本保持 **1.1.0**。
## 修复
- 2.4.0 发版时误将锁文件中的 MCP 间接依赖版本从 2.3.1 改为 2.4.0,仍保留 2.3.1 的 SHA-512。现在恢复 Angular CLI 22.2.4 官方声明的 `@modelcontextprotocol/server@2.3.1` 及其 `core@2.3.1`,使依赖关系、下载地址和校验值一致。未关闭 npm 完整性验证,也不要求用户清空全局缓存。
- Windows 启动脚本准确检查 Angular 支持的 Node.js 范围:22.x 至少 22.22.3,24.x 至少 24.15.0,或 26.0.0 及以上;提示文案同步修正。
- 保留最终 2.4.0 中的独立 v4 市场索引,提供人工评审 1.1.0;旧版宿主继续使用旧索引。曾用于发版准备的早期 2.4.0 候选包仍使用 v3 索引,会显示人工评审 1.0.0,不应继续分发。
## 验证
- Windows、Node.js 24.22.1、npm 11.19.1,独立源码目录及空 npm 缓存,使用国内镜像执行 `npm ci`:448 个包安装成功。实际依赖树与 Angular CLI 官方声明一致。
- 同一全新环境下,前端 ChromeHeadless 347 项通过,生产构建通过;官方 npm 审计未发现已知漏洞。
- 默认后端回归 3268 通过、2 跳过、8 按标记排除;包含 Node 22/24/26 兼容边界与启动脚本回归。Ruff、质量基线和核心类型检查通过。
- 未启动用户服务,未执行设备或真实业务验收;以上为安装、构建和确定性测试结果。
## 升级
已有安装请先等待任务结束,再运行 `update-cn.bat`(国内镜像)或 `update.bat`,随后重新启动。建议使用更新脚本保留已有配置与数据,不要直接用源码 ZIP 覆盖安装目录。
如果此前已手动修改 `package-lock.json`,更新脚本可能因本地修改而停止;先备份这份文件,确认只包含该安装问题的临时修正后再处理冲突,不要对整个仓库执行重置或清理。
新安装请下载本版 `artemis-2.4.1-source.zip`,按 `SHA256SUMS.txt` 校验后使用。插件仍需在市场中单独安装或更新并启用,升级主程序不会自动授权插件。
本次不构建 Docker,不启动或重启用户服务,不操作真实设备或业务任务。
+24
View File
@@ -0,0 +1,24 @@
# ARTEMIS 2.4.2
本版为批次任务增加人工评审标记,并调整任务列表布局。人工评审插件保持 **1.1.0**,第三方依赖版本不变。
## 更新
- 安装并启用人工评审插件后,任务存在已保存的有效评审内容时,批次列表显示“人工已审”。评审结论或非空备注均计入;清空全部内容后隐藏,禁用或卸载插件后也不显示。
- 标记仅说明已有人工评审内容,不代表测试通过,不覆盖 AI 判定或改变任务执行流程。没有安装插件的用户不显示该标记。
- 批次任务标题与状态分行显示:收藏星标位于标题框内,执行状态、AI 判定和人工已审标签在下方等高、左对齐排列。此布局适用于所有用户。
- 按批次一次读取当前评审内容,沿用列表自动刷新,不逐条增加网络请求;重跑任务不继承旧任务的已审标记。
- 补充发版约束:应用与第三方依赖独立版本化,检查锁文件差异,并验证最终源码包的全新安装。
## 验证
- 默认 Python 回归:3269 通过,2 跳过,8 按标记排除;Ruff、质量基线和核心类型检查通过。
- 前端 ChromeHeadless:348 项通过;生产构建通过。
- 内置浏览器使用隔离数据检查收藏星标、两行布局及有评审/无评审标签显示。未写入真实任务评审数据。
- 最终源码包另行执行独立目录、空 npm 缓存的官方源和国内镜像安装及生产构建,结果随发布附件提供。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.2-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
人工评审插件仍需单独安装并启用;本次无需升级已安装的 1.1.0。发布过程不构建 Docker,不重启用户服务,不执行设备或真实业务任务。
+25
View File
@@ -0,0 +1,25 @@
# ARTEMIS 2.4.3
本版修复异常模型输出影响整个批次的问题,并增加流式参数诊断。人工评审插件保持 **1.1.0**,SDK、其他插件及第三方依赖版本不变。
## 更新
- 已识别的异常输出(包括工具参数溢出、响应完整性校验失败)在有限重试和可用备用端点耗尽后,只结束当前任务,不再创建全局模型暂停。批次未开启“失败即停止”时可继续下一条;人工暂停、失败即停止、网络恢复及最终验收的原有规则保留。
- 保留 16384 字符工具参数保护,关闭异常输出流,丢弃未完成内容,不执行本次异常动作。报错改为描述“收到的参数流”,避免在证据不足时直接归责模型。
- 增加单次尝试请求头,区分逻辑请求、客户端尝试、网关响应与 SDK 异常中的请求编号,便于跨服务核对。
- 对收到的工具参数片段记录字符数、UTF-8 字节数及有界 SHA256 前缀摘要,默认不新增参数正文记录。最多跟踪 64 个工具,每工具最多保留 16 个固定位置前缀。SDK 的 chunk_index 与原始协议 output_index 分开命名。
诊断字段及边界见 [流式参数诊断](../llm-stream-diagnostics.md)。本次主程序安装不会部署或升级 NEWAPI、CodexManager;服务端修复独立交付。
## 验证
- 默认 Python 回归:3284 通过,6 跳过,8 按标记排除;Ruff 格式和静态检查、质量基线、核心类型检查通过。
- 五类合成输入经过实际 CodexManager、NEWAPI 转发函数及原 OpenAI/LangChain SDK 的离线文件串接:正常输入有效;超长、累计全文、重复及半关闭输入被拒绝;各层参数摘要一致,设备动作执行数为 0。
- 最终源码 ZIP 的隔离解压、独立空 npm 缓存、官方源与国内镜像安装和生产构建结果随发布附件 `INSTALL-VERIFICATION.md` 提供。
- 未重跑用户原业务批次、未做高并发压力测试或真实模型端到端验证。历史原始参数帧未保存,不能据此认定原事故参数膨胀首先发生在哪一层。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.3-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
人工评审插件 1.1.0 无需更新。本次发布不构建 Docker,不重启用户服务,不执行设备或真实业务任务。
+29
View File
@@ -0,0 +1,29 @@
# ARTEMIS 2.4.4
本版改善任务结束后错误和阻塞说明的阅读体验,基于 2.4.3。人工评审插件保持 **1.1.0**,SDK、其他插件及第三方依赖版本不变。
## 更新
- 历史任务、任务切换列表和批次任务默认只显示一行说明,超长内容省略,点击可展开;报告异常说明采用相同折叠展示。
- 展开后安全渲染 Markdown 正文、列表和表格,并展示已有结论及已记录依据;长正文限高滚动。
- 列表摘要优先使用已保存的结论原因,否则使用执行错误首行;展示时移除 FlashRunner 固定技术前缀,原始记录保持不变。
- 仅展示已结束任务的已有信息,不新增 AI 复盘或模型请求。展开说明不触发任务切换,列表后台刷新保留展开状态。
- `start-cn.bat` 检测到正在运行的服务时,服务占用提示、打开浏览器/重启/取消菜单、输入提示及重启进度均使用中文;非交互启动提示也使用中文。默认仍只打开浏览器,选择重启才停止现有服务。
本版不改变任务执行、模型重试或批次停止策略,继续保留 2.4.3 的异常输出隔离与参数保护。
## 验证
- 前端回归:353 项通过,生产构建通过。
- 内置浏览器使用隔离样例验证默认折叠、Markdown 展开、长内容、无错误完成任务、批次布局、浅色/深色与窄视口。
- 最终源码 ZIP 的隔离解压、独立空 npm 缓存、官方源与国内镜像安装和生产构建结果随附件 `INSTALL-VERIFICATION.md` 提供。
- 启动语言与 Windows 启动脚本定向回归 31 项通过,Ruff 格式与静态检查、质量基线及核心类型检查通过。Python 默认确定性回归结果随安装验证附件提供。
- 未复跑用户业务任务、调用真实模型或进行高并发测试。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.4-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
2026-10-29 撤回首次发布的 2.4.4,补充中文启动提示后重新发布。此前下载过 2.4.4 的用户请重新下载并核对最新校验文件,或使用更新脚本获取最新提交。首次发布包的 SHA256 为 `9a2a678c0cf97b5de140479a3f3946171d20d19efaf3ad249c708ed35a06e67a`,该包已撤回。
人工评审插件 1.1.0 无需更新。本次发布不构建 Docker,不重启用户服务。
+29
View File
@@ -0,0 +1,29 @@
# ARTEMIS 2.4.5
本版基于 2.4.4,补齐插件作者管理外部工具、已批准文件与市场审查材料的公共能力。远程客户端 `artemis-client`、市场插件及第三方依赖独立版本化,本次不升版。
## 更新
- 新增 `artemis.plugins.sdk.snapshot_file`:按明确批准的 SHA256 读取不可变快照,实际工具消费快照字节,避免校验后原文件被替换、覆盖或截断;默认输入上限 64 MiB。
- 新增 `run_process`:固定 argv、工作目录和超时,限制 stdout/stderr 总量,在正常结束、超时与取消后回收自己启动的后代。Windows 先取得 Job Object 归属再启动实际工具;提供稳定错误码,执行结果不明时不声称尚未执行,也不自动重试。
- 新增可独立运行的 SDK 故障检查,以及受管工具模板。模板覆盖真实 stdio MCP 调用和宿主附件回读,随源码与官方 Skill 同步分发。
- 开发与市场检查共用一份实现。`check_plugin.py --prepare-market` 生成未验证的 `MARKET.json` 草稿;`--market` 检查来源许可、验收材料、候选内容指纹和 MCP 工具声明,并拒绝缓存、私密环境文件及握手期间包内容变化。
- 补充模板的 LICENSE、SOURCE.md 和开发说明。市场材料检查仍需结合源码、分发权利与真实业务审查;检查命令通过不会自动发布插件。
这些辅助接口管理工具生命周期和文件内容绑定;插件仍负责权限范围、业务验收、写操作状态及敏感信息处理。POSIX 使用独立进程组,不将运行器作为恶意程序沙箱。
## 兼容性
采用新增 SDK 的插件须声明 `artemis_version: ">=2.4.5,<3.0.0"`,并使用 ARTEMIS 2.4.5 或更新版本的 Python。正式 2.4.4 包不包含该 SDK。
插件清单和证据协议保持兼容,`MARKET.json` 是独立的审查材料。未使用新增 SDK 的现有插件继续按自身最低版本运行。已独立发布的设备测试插件 `0.5.0+artemis.6` 将修复包含在插件自身,仍可用于正式 ARTEMIS 2.4.4;主程序升级不会自动更新或授权插件。
## 验证
Python 默认确定性回归、Ruff 格式与静态检查、质量基线、核心类型检查,以及最终源码 ZIP 的官方源和国内镜像全新安装、生产构建结果,随发布附件 `INSTALL-VERIFICATION.md` 提供。验证记录注明实际 Python、Node/npm、源码提交及 ZIP SHA256。
SDK 故障检查与模板契约使用合成文件及测试进程,不替代设备、固件升级或用户业务验收。Windows 为本次实际验证环境,POSIX 分支未实测。
## 升级
已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.5-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。
+15 -11
View File
@@ -1,6 +1,6 @@
# 工作区插件:侧栏、人工字段与报告 # 工作区插件:侧栏、人工字段与报告
工作区扩展使用 `plugin.json` 的 `schema_version: 3` 和 `workspace` 声明。旧版 1/2 执行插件继续兼容。纯工作区插件无需 MCP、Skill、模型、设备或任务资源,不出现在任务执行插件选择器中;安装后手动启用即可在历史任务上使用。此接口从 ARTEMIS 2.3.0 起提供;插件必须声明 `artemis_version: ">=2.3.0,<3.0.0"`,2.2.x 不支持。 工作区扩展使用 `plugin.json` 的 `schema_version: 3` 和 `workspace` 声明。旧版 1/2 执行插件继续兼容。纯工作区插件无需 MCP、Skill、模型、设备或任务资源,不出现在任务执行插件选择器中;安装后手动启用即可在历史任务上使用。此接口从 ARTEMIS 2.3.0 起提供;插件必须声明 `artemis_version: ">=2.3.0,<3.0.0"`,2.2.x 不支持。`original_case` 面板从 2.4.0 起提供,使用它的插件须声明 `>=2.4.0,<3.0.0`。
## 声明接口 ## 声明接口
@@ -14,31 +14,35 @@
"id": "review-example", "id": "review-example",
"name": "评审扩展示例", "name": "评审扩展示例",
"version": "1.0.0", "version": "1.0.0",
"artemis_version": ">=2.3.0,<3.0.0", "artemis_version": ">=2.4.0,<3.0.0",
"platforms": ["win32", "linux", "darwin"], "platforms": ["win32", "linux", "darwin"],
"targets": [], "targets": [],
"capabilities": {"kind": "other", "tools": {}}, "capabilities": {"kind": "other", "tools": {}},
"workspace": { "workspace": {
"panels": [ "panels": [
{"id":"requirements","title":"任务需求","side":"left","kind":"initial_prompt","width":{"preferred":340,"minimum":240,"maximum":480}}, {"id":"requirements","title":"原始用例","side":"left","kind":"original_case","width":{"preferred":340,"minimum":240,"maximum":480}},
{"id":"review","title":"人工评审","side":"right","kind":"form","width":{"preferred":360,"minimum":260,"maximum":520}} {"id":"review","title":"人工评审","side":"right","kind":"form","width":{"preferred":360,"minimum":260,"maximum":520}}
], ],
"fields": [ "fields": [
{"id":"execution_accuracy","label":"AI执行准确性","kind":"choice","options":["通过","失败"]}, {"id":"execution_accuracy","label":"用例执行准确性","kind":"choice","options":["通过","失败"]},
{"id":"execution_note","label":"AI执行准确性备注","kind":"text","max_length":2000} {"id":"execution_note","label":"用例执行准确性备注","kind":"text","max_length":2000}
] ]
} }
} }
``` ```
- `panels`:最多两个,`id` 和 `kind` 各自唯一;`side` 为 left/right。`initial_prompt` 展示该次运行最初收到的任务需求提示词,复用宿主 Markdown 渲染及 HTML 清理;不展示模型拆解步骤或后续指导。`form` 渲染本插件声明的字段。 - `panels`:最多两个,`id` 和 `kind` 各自唯一;`side` 为 left/right。`initial_prompt` 展示该次运行最初收到的任务需求提示词,复用宿主 Markdown 渲染及 HTML 清理;不展示模型拆解步骤或后续指导。`original_case` 以用例编号与标题为标题,依次展示导入表中的预置条件、操作步骤、预期结果;缺失时明确提示,不从提示词推测。`form` 渲染本插件声明的字段。
- `width`:CSS 像素,满足 minimum ≤ preferred ≤ maximum。minimum 范围 160–800,preferred 200–1000,maximum 200–1200。宿主优先采用首选宽度,并根据播放器、窗口空间调整;窄屏可低于 minimum 防止溢出。开关位于回放标题栏,以左右方向表示展开与收起。普通浮窗、影院均支持,默认收起;宽屏左右独立展开,窄屏一次展开一侧;单侧也始终位于回放左侧或右侧,不切换为上下排列。 - `width`:CSS 像素,满足 minimum ≤ preferred ≤ maximum。minimum 范围 160–800,preferred 200–1000,maximum 200–1200。宿主优先采用首选宽度,并根据播放器、窗口空间调整;窄屏可低于 minimum 防止溢出。开关位于回放标题栏,以左右方向表示展开与收起。Flash/Pro/探索以及安卓、电脑、跨端任务的步骤、视频或实时视图均可使用;普通浮窗、影院均支持,默认收起;宽屏左右独立展开,窄屏一次展开一侧;单侧也始终位于回放左侧或右侧,不切换为上下排列。
- `fields`:最多 20 个,`id` 唯一;`kind` 为 choice/text。choice 声明非空、不重复的 options,显示为互斥分段按钮,再次点击已选项可清空;text 使用多行输入。description 可选。max_length 默认 2000、最高 10000;report 默认 true。所有字段均选填,空字符串表示未填写,用户可清空。 - `fields`:最多 20 个,`id` 唯一;`kind` 为 choice/text。choice 声明非空、不重复的 options,显示为互斥分段按钮,再次点击已选项可清空;text 使用多行输入。description 可选。max_length 默认 2000、最高 10000;report 默认 true。所有字段均选填,空字符串表示未填写,用户可清空。
- 两项人工评审的标签和说明由插件提供;宿主不推断结果、不调用模型、不覆盖执行状态、AI 原判或既有人工复验。 - 两项人工评审的标签和说明由插件提供;宿主不推断结果、不调用模型、不覆盖执行状态、AI 原判或既有人工复验。
## 任务数据接口 ## 任务数据接口
`GET /api/task-extensions/{session_id}` 返回 `session_id`、`initial_prompt`、`editable` 和已安装且启用的插件贡献列表。每项包含 plugin_id/name/version/workspace/revision/values/history。无插件时列表为空。 `GET /api/task-extensions/{session_id}` 返回 `session_id`、`initial_prompt`、`case_title`、`original_case`、`navigation`、`editable` 和已安装且启用的插件贡献列表。每项包含 plugin_id/name/version/workspace/revision/values/history。无插件时列表为空。
`original_case` 为 `{preconditions, steps, expected}` 或 null。只有安装并启用声明 `original_case` 面板的插件时,批量模板才附加“预置条件、操作步骤、预期结果”三列并接受十列表格;否则仍只提供和接受原七列。三列不进入模型提示词。启用时也兼容七列表格,历史缺失字段不补写。
批次任务的 `navigation` 含 scope=batch、position、total、previous、next,按执行顺序在同批次已结束任务之间切换;独立任务使用当前历史列表。上一条/下一条位于保存评审按钮下方。用户选择结果后立即自动保存,备注停止输入 600 毫秒后保存;连续编辑按修订串行写入,旧回执不覆盖新输入。切换前先保存,失败或冲突时留在当前任务并保留输入;手动保存可重试,使用原 request_id 防止重复。切换保留面板展开状态。打开任务或读取已有记录不触发写入。
`PUT /api/task-extensions/{session_id}/{plugin_id}` 接收: `PUT /api/task-extensions/{session_id}/{plugin_id}` 接收:
@@ -58,7 +62,7 @@ values 是本次提交的字段;未提交的字段保持原值。保存仅允
## 独立人工评审插件 ## 独立人工评审插件
插件源代码位于独立 Artemis-Plugins 仓库的 `plugins/task-review/`,包名 task-review,版本 1.0.0,可从内置插件市场安装。它声明需求提示词面板,以及「AI执行准确性」「测试结果正确性」两个 通过/失败 字段和各自可选备注。 插件源代码位于独立 Artemis-Plugins 仓库的 `plugins/task-review/`,包名 task-review,1.1.0 要求宿主 2.4.0,可从插件市场安装,或通过高级接入导入发行 ZIP。它声明原始用例面板,以及「用例执行准确性」「测试结果正确性」两个 通过/失败 字段和各自可选备注。
在已更新宿主的环境,用 `uv run python scripts/check_plugin.py <插件目录>` 检查清单,再在「插件 → 高级接入」导入目录或 ZIP。安装、启用、填写、清空、导出、禁用、卸载和重新安装均应通过确定性测试;只检查清单不代表界面和导出已验证。 在已更新宿主的环境,用 `uv run python scripts/check_plugin.py <插件目录>` 检查清单,再在「插件 → 高级接入」导入目录或 ZIP。安装、启用、填写、清空、导出、禁用、卸载和重新安装均应通过确定性测试;只检查清单不代表界面和导出已验证。
@@ -72,6 +76,6 @@ GET 成功返回完整贡献快照;PUT 成功返回同样结构和新 revision
人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。 人工结果只能由用户填写;AI 不自行调用 PUT 生成或推断评审。此接口未加入模型 MCP 工具或远程 Python SDK 的人工写入方法。插件没有设备目标时,无需为安装验证提交安卓/PC 测试任务;用隔离终态示例验证保存、清空、重装、导出与旧缓存失效即可。
市场发布时保持清单、ZIP、版本标签与 catalog-v3.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。 市场发布时保持清单、ZIP、版本标签与 catalog-v4.json 内嵌清单一致;先上传不可变 ZIP 与 SHA256SUMS.txt 并回读验证,再更新索引。仅静态清单校验通过不能宣称全部平台已实测。
官方市场为 2.3.0+ 提供 catalog-v3.json,catalog.json 保留给 2.2.x。宿主读取原官方地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。 官方市场为 2.4.0+ 提供 catalog-v4.json,catalog-v3.json 保留给 2.3.x,catalog.json 保留给 2.2.x,避免旧宿主严格解析新面板类型时导致整个市场加载失败。2.4.0 读取原官方 catalog.json 或 catalog-v3.json 地址时自动选择新版索引,不改写用户配置;自定义市场地址不会被替换。
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Google LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
# 模板来源
本目录为 ARTEMIS 主程序仓库提供的文件证据开发模板,适用本目录 LICENSE 的 Apache-2.0,来源以随模板交付的主程序源码提交为准。没有包含厂商协议、内部资料或用户测试数据。
复制模板后,新增代码、资料和第三方依赖需要分别记录实际来源与分发许可,并为本次候选包保存真实验证结果。模板许可不重新许可新增内容。
+2 -1
View File
@@ -7,7 +7,8 @@
"artemis_version": ">=2.0.0,<3.0.0", "artemis_version": ">=2.0.0,<3.0.0",
"platforms": ["win32", "linux", "darwin"], "platforms": ["win32", "linux", "darwin"],
"architectures": ["amd64", "arm64"], "architectures": ["amd64", "arm64"],
"server": {"command": "${config.python}", "args": ["${plugin_dir}/server.py"]}, "license": "Apache-2.0",
"server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]},
"config": {"python": {"label": "含 MCP 依赖的 Python", "default": "python"}}, "config": {"python": {"label": "含 MCP 依赖的 Python", "default": "python"}},
"skill": "SKILL.md", "skill": "SKILL.md",
"targets": ["mixed", "computer"], "targets": ["mixed", "computer"],
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Google LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,19 @@
# 受管工具与文件快照模板
本模板使用 ARTEMIS 2.4.5 新增的 `artemis.plugins.sdk`,最低宿主版本为 2.4.5。复制模板后使用该版本或更新版本的 ARTEMIS Python;正式的 2.4.4 包不提供这些接口。模板随源码和 Skill 分发,尚未发布到市场。纯文件写入可用相邻 evidence-demo,本模板用于需要外部工具或已批准文件输入的插件。
复制整个目录,使用 ARTEMIS 自己的 Python:
```sh
<ARTEMIS_PYTHON> -B test_contract.py
```
测试只在临时目录创建样例和休眠进程,验证 SDK 的文件替换、超时、取消、后代回收、输出上限,以及真实 stdio MCP 调用、错误输入拒绝和宿主附件回读。结果分为 SDK 自测与本模板离线契约,不证明改写后的插件、设备或业务通过。
扩展时保持固定 argv 和工作目录。先用 `snapshot_file(path, approved_sha256)` 得到不可变 bytes,再将 `snapshot.data` 或 `snapshot.chunks()` 交给实际消费者;不要重新打开原路径。默认内存输入上限 64 MiB,按明确需求选择上限;超大固件需要另外实现有等价保护的流式存储,并单独验证。
`await run_process(argv, cwd=..., timeout=..., input_data=...)` 不执行 shell、不重试,返回原始退出码与 stdout/stderr。标准输入最多 64 MiB,stdout/stderr 共用默认 4 MiB 上限。它在正常退出、超时、取消、输出超限时回收后代;Windows 先将等待启动的引导进程加入 Job Object,再运行工具。POSIX 使用独立会话;这些是生命周期管理,不是恶意程序沙箱,不能托管主动逃离进程组的程序。
宿主负责整个 MCP 调用的期限;插件负责内部操作和子进程。内部超时先发生时,插件必须在返回前完成回收。取消会在回收后传播,不能吞掉 `CancelledError` 后声称完成。`ManagedProcessError.error_code` 稳定且不回显命令,`may_have_started` 决定能否声明尚未执行;`process_cleanup_unconfirmed` 必须按未知处理。命令输出可能包含敏感数据,按业务白名单转换后再返回。
本工具只处理已有任务文件,不提供观察或任意命令能力。保留模板 LICENSE、SOURCE.md,并补充新增代码、协议、资料与依赖的来源和业务回归。在副本中执行 `check_plugin.py --prepare-market` 生成全部未验证的市场记录;本模板自身的离线通过不能填作真实业务验收。
@@ -0,0 +1,8 @@
---
name: managed-tool-demo
description: 对任务目录中已明确批准的文件制作不可变快照,并通过固定工具校验、保存回执。
---
调用 plugin_load 返回映射后的 verify_note。name 只允许任务目录内的普通文件名,sha256 必须是用户明确批准的内容摘要;不要临时计算另一份内容的摘要来绕过批准。
固定工具只读取快照并计算摘要,不提供任意命令、不访问设备或网络。校验与回执保存成功不等于业务测试通过。超时、取消或结果不明时先观察实际状态,不自动重试写操作。没有视觉能力,不能独立承担纯电脑任务。
@@ -0,0 +1,5 @@
# 模板来源
本目录为 ARTEMIS 主程序仓库提供的插件开发模板,适用本目录 LICENSE 的 Apache-2.0。来源为主程序仓库中随该模板交付的对应源码提交;模板使用主程序的 `artemis.plugins.sdk`,不另行分发设备协议、厂商资料或用户用例。
复制后应记录自己的代码、所适配上游、第三方依赖、协议资料和用例的实际来源与许可证;新增内容的分发范围需要分别确认。模板的许可不会重新许可这些新增材料。运行记录须使用自己的实际候选版本和验证结果,不能沿用模板演示结论。
@@ -0,0 +1,23 @@
{
"schema_version": 2,
"id": "managed-tool-demo",
"name": "受管工具与文件快照示例",
"version": "1.0.0",
"description": "开发模板:读取已批准的任务文件快照,调用固定校验程序并保存真实回执。",
"artemis_version": ">=2.4.5,<3.0.0",
"platforms": ["win32", "linux", "darwin"],
"architectures": ["amd64", "arm64"],
"license": "Apache-2.0",
"server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]},
"config": {"python": {"label": "当前 ARTEMIS 环境的 Python", "default": "python"}},
"skill": "SKILL.md",
"targets": ["android", "mixed", "computer"],
"resources": [],
"capabilities": {
"kind": "file", "recording": "none", "required_tools": ["verify_note"],
"tools": {"verify_note": {
"effect": "read", "observation": false, "idempotent": false,
"cancellation": "confirmed", "evidence": ["text", "file"]
}}
}
}
@@ -0,0 +1,128 @@
"""A fixed checksum tool using the installed ARTEMIS authoring SDK."""
import hashlib
import json
import os
from pathlib import Path
import sys
from typing import Annotated
import uuid
from mcp.server.fastmcp import FastMCP
from mcp.types import CallToolResult, TextContent, ToolAnnotations
from pydantic import Field
from artemis.plugins.sdk import ManagedProcessError, run_process, snapshot_file
mcp = FastMCP("managed-tool-demo")
CHECKSUM = "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
def failure(code: str, *, execution: str = "not_started") -> CallToolResult:
return CallToolResult(
isError=True,
content=[TextContent(type="text", text=code)],
structuredContent={
"artemis": {
"version": 1,
"execution": execution,
"side_effect": "none",
"error_code": code,
"retryable": False,
}
},
)
@mcp.tool(
annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=False)
)
async def verify_note(
name: Annotated[str, Field(min_length=1, max_length=180)],
sha256: Annotated[str, Field(pattern=r"^[a-fA-F0-9]{64}$")],
) -> CallToolResult:
"""Verify a task-owned file of at most 64 MiB against its explicitly approved SHA256."""
location = os.environ.get("ARTEMIS_ARTIFACTS_DIR")
if (
not location
or name in {".", ".."}
or Path(name).name != name
or any(c in name for c in '/\\:*?"<>|')
or name.rstrip(" .") != name
or any(ord(c) < 32 for c in name)
):
return failure("invalid_task_filename")
root = Path(location)
try:
if root.is_symlink() or root.is_junction() or root.resolve() != root.absolute():
return failure("invalid_task_directory")
snapshot = snapshot_file(root / name, sha256)
except (OSError, ValueError):
return failure("input_not_bound_to_approved_digest")
try:
# Never reopen the original pathname after verification. The fixed tool
# receives the same immutable bytes even if the source file changes.
result = await run_process(
[sys.executable, "-I", "-B", "-c", CHECKSUM],
cwd=root,
timeout=5,
input_data=snapshot.data,
)
except ManagedProcessError as exc:
return failure(
exc.error_code,
execution="unknown"
if exc.error_code == "process_cleanup_unconfirmed"
else "partial"
if exc.may_have_started
else "not_started",
)
if (
result.returncode != 0
or result.stdout.strip().decode("ascii", errors="replace") != snapshot.sha256
):
return failure("checksum_tool_result_invalid", execution="partial")
content = json.dumps(
{
"source_name": name,
"snapshot_sha256": snapshot.sha256,
"snapshot_bytes": snapshot.size,
"tool_returncode": result.returncode,
"test_verdict": "not_evaluated",
},
ensure_ascii=False,
).encode("utf-8")
receipt_name = f"verified-{uuid.uuid4().hex}.json"
try:
with (root / receipt_name).open("xb") as handle:
handle.write(content)
except OSError:
return failure("receipt_write_failed", execution="partial")
return CallToolResult(
content=[TextContent(type="text", text="已核对批准的文件快照,回执已保存。")],
structuredContent={
"snapshot_sha256": snapshot.sha256,
"artemis": {
"version": 1,
"execution": "completed",
"side_effect": "none",
"retryable": False,
"source": {
"kind": "file",
"target_id": "task-artifacts",
"label": "已批准文件快照",
},
"attachments": [
{
"name": receipt_name,
"media_type": "application/json",
"sha256": hashlib.sha256(content).hexdigest(),
}
],
},
},
)
if __name__ == "__main__":
mcp.run(transport="stdio")
@@ -0,0 +1,69 @@
"""Run with the current ARTEMIS Python; no pytest/device/service/config needed."""
import asyncio
import hashlib
import json
from pathlib import Path
import sys
import tempfile
from artemis.plugins.evidence import persist_attachments, validate_tools
from artemis.plugins.models import PluginManifest, PluginProfile
from artemis.plugins.sdk_checks import run_checks
from artemis.plugins.session import PluginSession, resolve_server
async def run_contract():
directory = Path(__file__).resolve().parent
manifest = PluginManifest.model_validate_json((directory / "plugin.json").read_text("utf-8"))
with tempfile.TemporaryDirectory(prefix="artemis-template-contract-") as temporary:
trace = Path(temporary).resolve()
artifacts = trace / "artifacts" / "plugins" / manifest.id
artifacts.mkdir(parents=True)
content = "synthetic local sample\n中文\x00".encode()
(artifacts / "样例.txt").write_bytes(content)
server = resolve_server(
manifest, PluginProfile(values={"python": sys.executable}), directory
)
server.env["ARTEMIS_ARTIFACTS_DIR"] = str(artifacts)
server.env["PYTHONDONTWRITEBYTECODE"] = "1"
session = await PluginSession(server, directory).start()
try:
validate_tools(manifest, session.tools)
success = await session.call(
"verify_note", {"name": "样例.txt", "sha256": hashlib.sha256(content).hexdigest()}
)
if success.isError:
raise AssertionError("MCP 校验入口失败")
receipt = success.structuredContent["artemis"]
attachments = persist_attachments(trace, manifest.id, receipt["attachments"])
if len(attachments) != 1:
raise AssertionError("宿主未回读到校验回执")
for name, digest in (("样例.txt", "0" * 64), ("../outside.txt", "0" * 64)):
denied = await session.call("verify_note", {"name": name, "sha256": digest})
if (
not denied.isError
or denied.structuredContent["artemis"]["execution"] != "not_started"
):
raise AssertionError("无效输入没有在工具启动前拒绝")
finally:
await session.aclose()
return {
"status": "passed",
"scope": "template_offline_contract",
"mcp_calls": 3,
"host_attachments_verified": 1,
"real_device_actions": False,
}
async def main():
print(
json.dumps(
{"sdk": await run_checks(), "template": await run_contract()}, ensure_ascii=False
)
)
if __name__ == "__main__":
asyncio.run(main())
@@ -15,7 +15,8 @@
from datetime import datetime, timedelta, timezone, UTC from datetime import datetime, timedelta, timezone, UTC
from fastapi import Depends, HTTPException, Security, status from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt import jwt
from jwt import InvalidTokenError
from app.config import settings from app.config import settings
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
@@ -40,7 +41,7 @@ def decode_access_token(token: str) -> dict:
try: try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
return payload return payload
except JWTError as e: except InvalidTokenError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid or expired credentials: {str(e)}", detail=f"Invalid or expired credentials: {str(e)}",
+1 -1
View File
@@ -2,7 +2,7 @@ fastapi>=0.115.0
uvicorn[standard]>=0.32.0 uvicorn[standard]>=0.32.0
pydantic>=2.9.0 pydantic>=2.9.0
pydantic-settings>=2.5.0 pydantic-settings>=2.5.0
python-jose[cryptography]>=3.3.0 PyJWT[crypto]>=2.15.1,<3.0.0
passlib[bcrypt]>=1.7.4 passlib[bcrypt]>=1.7.4
docker>=7.1.0 docker>=7.1.0
google-cloud-bigquery>=3.26.0 google-cloud-bigquery>=3.26.0
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more