Compare commits
10 Commits
0047448e9d
...
e08768d8fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e08768d8fd | ||
|
|
cb46d5bc04 | ||
|
|
0fd7b17aba | ||
|
|
573d36bb60 | ||
|
|
dd834378af | ||
|
|
ee0af20e2c | ||
|
|
fcfa824f54 | ||
|
|
b6648f384d | ||
|
|
4262191462 | ||
|
|
8c1093a53c |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.DS_Store
|
||||
.superpowers
|
||||
tmp
|
||||
*.log
|
||||
*.bak.*
|
||||
web/node_modules
|
||||
web/dist
|
||||
dist
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS web-build
|
||||
WORKDIR /src/web
|
||||
RUN corepack enable && corepack prepare pnpm@10.30.3 --activate
|
||||
COPY web/package.json web/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY web/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM golang:1.24-alpine AS go-build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH
|
||||
RUN if [ -n "$TARGETARCH" ]; then export GOARCH=$TARGETARCH; fi; \
|
||||
CGO_ENABLED=0 GOOS=$TARGETOS go build -o /out/codex-agent-manager ./cmd/codex-agent-manager
|
||||
|
||||
FROM alpine:3.22
|
||||
WORKDIR /app
|
||||
COPY --from=go-build /out/codex-agent-manager /usr/local/bin/codex-agent-manager
|
||||
COPY --from=web-build /src/web/dist ./web/dist
|
||||
COPY README.md agent.md task_plan.md findings.md progress.md ./
|
||||
COPY docs/ ./docs/
|
||||
|
||||
ENV CODEX_HOME=/codex-home
|
||||
ENV HTTP_ADDR=0.0.0.0:18083
|
||||
ENV STATIC_DIR=/app/web/dist
|
||||
ENV WORKSPACE_ROOT=/app
|
||||
|
||||
EXPOSE 18083
|
||||
ENTRYPOINT ["codex-agent-manager"]
|
||||
51
README.md
51
README.md
@@ -38,6 +38,55 @@ pnpm dev
|
||||
|
||||
打开 `http://127.0.0.1:13083/` 查看工作台。
|
||||
|
||||
## Docker 启动
|
||||
|
||||
构建镜像:
|
||||
|
||||
```bash
|
||||
docker build -t codex-agent-manager:local .
|
||||
```
|
||||
|
||||
启动容器,并把本机 Codex 配置和当前项目目录挂载进去:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
--name codex-agent-manager \
|
||||
-p 0.0.0.0:18083:18083 \
|
||||
-e CODEX_HOME=/codex-home \
|
||||
-e WORKSPACE_ROOT=/Users/yoilun/Code/codex-agent-manager \
|
||||
-v /Users/yoilun/.codex:/codex-home \
|
||||
-v /Users/yoilun/Code/codex-agent-manager:/Users/yoilun/Code/codex-agent-manager:ro \
|
||||
codex-agent-manager:local
|
||||
```
|
||||
|
||||
然后打开 `http://127.0.0.1:18083/`。Docker 镜像内由 Go 后端同时提供 API 和前端静态页面,不需要再单独启动 Vite。
|
||||
|
||||
如果要让局域网内其他设备访问,保持上面的 `0.0.0.0:18083:18083` 端口映射,然后在同一局域网的设备上打开:
|
||||
|
||||
```text
|
||||
http://你的电脑局域网IP:18083/
|
||||
```
|
||||
|
||||
在 macOS 上可用下面命令查看当前 Wi-Fi/LAN IP:
|
||||
|
||||
```bash
|
||||
ipconfig getifaddr en0
|
||||
```
|
||||
|
||||
也可以使用 Compose:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
如需改挂载路径,可在启动前设置本机环境变量:
|
||||
|
||||
```bash
|
||||
CODEX_HOME=/path/to/.codex \
|
||||
CODEX_AGENT_MANAGER_PROJECT=/path/to/codex-agent-manager \
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## 验证命令
|
||||
|
||||
后端和整体检查:
|
||||
@@ -56,7 +105,7 @@ pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
如果需要浏览器视觉验证,由主智能体或可用浏览器工具访问 `http://127.0.0.1:13083/` 后记录截图结果;不要伪造截图记录。
|
||||
浏览器视觉验证已在 2026-05-25 使用本机 Chrome 访问 `http://127.0.0.1:13083/` 完成,覆盖项目视图、工作流视图、智能体视图、草稿和设置页。内置浏览器插件若返回 `Browser is not available: iab`,可改用 Chrome 做只读页面核验,并在 `progress.md` 记录 fallback。
|
||||
|
||||
## API 概览
|
||||
|
||||
|
||||
26
agent.md
26
agent.md
@@ -79,8 +79,7 @@
|
||||
- 视觉方向是“温和的本地工作台 + 编辑器质感”,不要做成工业监控大屏。
|
||||
- 不要使用大面积黑底荧光绿、密集硬边框、无解释的红绿灯状态或全页面等宽字体。
|
||||
- 页面必须清楚区分:
|
||||
- 项目视图:项目里的智能体执行情况。
|
||||
- 工作流视图:阶段进度、交接、审查循环、主智能体监管。
|
||||
- 项目视图:项目里的智能体执行情况、工作流批次、阶段进度、交接、审查循环、主智能体监管。
|
||||
- 智能体视图:名称、描述、角色设定编辑。
|
||||
- 草稿:TOML 校验、diff、备份、写回。
|
||||
- 设置:Codex 路径和数据源配置。
|
||||
@@ -133,6 +132,7 @@
|
||||
## 工作流显示规则
|
||||
|
||||
- 工作流必须建模为动态事件流或有向图,不允许写死固定流程。
|
||||
- 项目内工作流必须先按“工作流批次”分组,再按阶段分组;同一项目的 `v1.0 初始搭建`、`v1.1 优化修复`、`v2.0 重构升级` 必须清楚区分。
|
||||
- `thread_spawn_edges` 是派发关系的主要结构化来源。
|
||||
- `threads` 表用于补充智能体昵称、角色、项目路径和更新时间。
|
||||
- `thread_goals` 用于补充目标状态。
|
||||
@@ -140,6 +140,28 @@
|
||||
- 没有计划文件时,只显示通用事件和推断,不强行命名阶段。
|
||||
- 所有推断必须标注来源和置信度。
|
||||
|
||||
### 子智能体派发元数据
|
||||
|
||||
主智能体派发任何子智能体任务时,必须在任务标题、目标或首段说明中保留以下元数据,方便本项目页面从 Codex 运行记录中准确归类:
|
||||
|
||||
```text
|
||||
[项目: /Users/yoilun/Code/codex-agent-manager]
|
||||
[工作流批次: v1.1 优化修复]
|
||||
[阶段: 优化阶段 1]
|
||||
[角色: 代码审查员]
|
||||
```
|
||||
|
||||
本项目当前已完成初始搭建后进入 `v1.1 优化修复`,当前优化改造归入 `优化阶段 1`。后续如果开启 `codex-agent-manager v2.0`,必须显式写成类似:
|
||||
|
||||
```text
|
||||
[项目: /Users/yoilun/Code/codex-agent-manager]
|
||||
[工作流批次: v2.0 重构升级]
|
||||
[阶段: 阶段 1 产品规划]
|
||||
[角色: 产品经理]
|
||||
```
|
||||
|
||||
如果继续派发下一级子智能体,必须把同一组元数据继续传递给下一级任务。`项目` 必须写完整项目路径,不要只写项目名;不要只写“继续优化”或“修一下页面”,否则页面只能低置信度推断批次和阶段。
|
||||
|
||||
## 状态规则
|
||||
|
||||
运行状态至少包含:
|
||||
|
||||
14
compose.yaml
Normal file
14
compose.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
codex-agent-manager:
|
||||
build: .
|
||||
image: codex-agent-manager:local
|
||||
ports:
|
||||
- "0.0.0.0:18083:18083"
|
||||
environment:
|
||||
CODEX_HOME: /codex-home
|
||||
HTTP_ADDR: 0.0.0.0:18083
|
||||
STATIC_DIR: /app/web/dist
|
||||
WORKSPACE_ROOT: /Users/yoilun/Code/codex-agent-manager
|
||||
volumes:
|
||||
- ${CODEX_HOME:-/Users/yoilun/.codex}:/codex-home
|
||||
- ${CODEX_AGENT_MANAGER_PROJECT:-/Users/yoilun/Code/codex-agent-manager}:/Users/yoilun/Code/codex-agent-manager:ro
|
||||
@@ -87,7 +87,7 @@ cd web
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
前端开发地址为 `http://127.0.0.1:13083/`。前端按视图调用 API:项目视图读取 `/api/projects` 和 `/api/runtime/threads`,工作流视图读取 `/api/workflow/events`,智能体视图读取 `/api/agents`,并仅对当前选中的智能体调用 `/api/agents/{id}/validate` 和 `/api/agents/{id}/write`。静态示例数据仅在接口连接失败时作为 fallback 展示,并必须明确标注“示例/等待连接”;真实接口返回空列表时展示空状态和来源证据,不回退到示例数据。前端可保留 `local_sample`、`api_missing` 等内部 source kind,但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。
|
||||
前端开发地址为 `http://127.0.0.1:13083/`。前端按视图调用 API:项目视图读取 `/api/projects` 和 `/api/runtime/threads`,工作流视图读取 `/api/workflow/events`,智能体视图读取 `/api/agents`,并仅对当前选中的智能体调用 `/api/agents/{id}/validate` 和 `/api/agents/{id}/write`。静态示例数据仅在接口连接失败时作为 fallback 展示,并必须明确标注“示例/等待连接”;真实接口返回空列表时展示空状态和来源证据,不回退到示例数据。前端可保留 `local_sample`、`api_missing` 等内部 source kind,但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。2026-05-25 已通过 Chrome 验证项目视图、工作流视图、智能体视图、草稿和设置页;内置浏览器插件返回 `Browser is not available: iab`,视觉核验使用本机 Chrome 完成。
|
||||
|
||||
## API Overview
|
||||
|
||||
@@ -125,6 +125,6 @@ pnpm dev
|
||||
- Codex 内部 SQLite schema 可能变化。
|
||||
- 运行状态由多来源推断,必须显示置信度。
|
||||
- 真实 SQLite 读取已覆盖临时测试库;如果真实 Codex schema 新增字段或缺少可选字段,应继续走 schema-aware 查询和来源证据,而不是让 API 500。
|
||||
- Agent 列表接口不返回原始 TOML 文本;智能体编辑区展示的是接口返回的已解析字段,并在代码区说明“接口未返回原始 TOML”。
|
||||
- 浏览器视觉验证需要可用浏览器工具或主智能体执行;文档记录不得伪造截图结果。
|
||||
- Agent 列表接口当前返回原始 TOML 文本供草稿编辑;前端仍必须只在“校验 TOML -> 查看差异 -> 创建备份并写回”的显式流程中写回。
|
||||
- 内置浏览器插件可能不可用;若返回 `Browser is not available: iab`,可使用本机 Chrome 做只读页面核验,并在进度记录中注明 fallback。
|
||||
- 写回只覆盖单个已有 agent TOML,不支持创建新 agent、批量写回或自动保存草稿。
|
||||
|
||||
@@ -9,14 +9,19 @@ type Config struct {
|
||||
CodexHome string
|
||||
HTTPAddr string
|
||||
WorkspaceRoot string
|
||||
StaticDir string
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
httpAddr := envOrDefault("HTTP_ADDR", "127.0.0.1:18083")
|
||||
staticDir := os.Getenv("STATIC_DIR")
|
||||
workspaceRoot := envOrDefault("WORKSPACE_ROOT", ".")
|
||||
if codexHome := os.Getenv("CODEX_HOME"); codexHome != "" {
|
||||
return Config{
|
||||
CodexHome: codexHome,
|
||||
HTTPAddr: "127.0.0.1:18083",
|
||||
WorkspaceRoot: ".",
|
||||
HTTPAddr: httpAddr,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
StaticDir: staticDir,
|
||||
}
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
@@ -25,7 +30,15 @@ func DefaultConfig() Config {
|
||||
}
|
||||
return Config{
|
||||
CodexHome: filepath.Join(home, ".codex"),
|
||||
HTTPAddr: "127.0.0.1:18083",
|
||||
WorkspaceRoot: ".",
|
||||
HTTPAddr: httpAddr,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
StaticDir: staticDir,
|
||||
}
|
||||
}
|
||||
|
||||
func envOrDefault(name string, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -17,6 +17,24 @@ func TestDefaultConfigUsesCODEXHomeOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigUsesDockerRuntimeOverrides(t *testing.T) {
|
||||
t.Setenv("HTTP_ADDR", "0.0.0.0:18083")
|
||||
t.Setenv("STATIC_DIR", "/app/web/dist")
|
||||
t.Setenv("WORKSPACE_ROOT", "/app")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.HTTPAddr != "0.0.0.0:18083" {
|
||||
t.Fatalf("HTTPAddr = %q, want %q", cfg.HTTPAddr, "0.0.0.0:18083")
|
||||
}
|
||||
if cfg.StaticDir != "/app/web/dist" {
|
||||
t.Fatalf("StaticDir = %q, want %q", cfg.StaticDir, "/app/web/dist")
|
||||
}
|
||||
if cfg.WorkspaceRoot != "/app" {
|
||||
t.Fatalf("WorkspaceRoot = %q, want %q", cfg.WorkspaceRoot, "/app")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigFallsBackToUserCodexHome(t *testing.T) {
|
||||
t.Setenv("CODEX_HOME", "")
|
||||
home, err := os.UserHomeDir()
|
||||
|
||||
@@ -11,12 +11,19 @@ type Snapshot struct {
|
||||
type SourceMap map[string]SourceEvidence
|
||||
|
||||
type Thread struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Source SourceEvidence `json:"source"`
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CWD string `json:"cwd"`
|
||||
Title string `json:"title"`
|
||||
AgentNickname string `json:"agentNickname,omitempty"`
|
||||
AgentRole string `json:"agentRole,omitempty"`
|
||||
AgentPath string `json:"agentPath,omitempty"`
|
||||
ThreadSource string `json:"threadSource,omitempty"`
|
||||
Preview string `json:"preview,omitempty"`
|
||||
Source SourceEvidence `json:"source"`
|
||||
}
|
||||
|
||||
type SpawnEdge struct {
|
||||
|
||||
@@ -121,7 +121,20 @@ func readThreads(db *sql.DB, sourcePath string, sources SourceMap) ([]Thread, er
|
||||
sources["threads"] = tableSource("sqlite_schema_drift", sourcePath, "threads table is missing required id column.")
|
||||
return []Thread{}, nil
|
||||
}
|
||||
query := `SELECT ` + textColumn(columns, "id") + `, ` + textColumn(columns, "role") + `, ` + textColumn(columns, "status") + `, ` + textColumn(columns, "created_at") + `, ` + textColumn(columns, "updated_at") + ` FROM threads ORDER BY ` + orderBy(columns, "created_at", "id")
|
||||
query := `SELECT ` +
|
||||
textColumn(columns, "id") + `, ` +
|
||||
firstTextColumn(columns, "agent_role", "role") + `, ` +
|
||||
textColumn(columns, "status") + `, ` +
|
||||
firstTextColumn(columns, "created_at_ms", "created_at") + `, ` +
|
||||
firstTextColumn(columns, "updated_at_ms", "updated_at") + `, ` +
|
||||
textColumn(columns, "cwd") + `, ` +
|
||||
textColumn(columns, "title") + `, ` +
|
||||
textColumn(columns, "agent_nickname") + `, ` +
|
||||
textColumn(columns, "agent_role") + `, ` +
|
||||
textColumn(columns, "agent_path") + `, ` +
|
||||
textColumn(columns, "thread_source") + `, ` +
|
||||
textColumn(columns, "preview") +
|
||||
` FROM threads ORDER BY ` + orderBy(columns, "created_at_ms", "created_at", "id")
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
if isMissingTable(err) {
|
||||
@@ -133,7 +146,20 @@ func readThreads(db *sql.DB, sourcePath string, sources SourceMap) ([]Thread, er
|
||||
var threads []Thread
|
||||
for rows.Next() {
|
||||
var item Thread
|
||||
if err := rows.Scan(&item.ID, &item.Role, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.Role,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&item.CWD,
|
||||
&item.Title,
|
||||
&item.AgentNickname,
|
||||
&item.AgentRole,
|
||||
&item.AgentPath,
|
||||
&item.ThreadSource,
|
||||
&item.Preview,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Source = SourceEvidence{Kind: "sqlite_table", Path: sourcePath, Confidence: "high"}
|
||||
@@ -152,11 +178,16 @@ func readSpawnEdges(db *sql.DB, sourcePath string, sources SourceMap) ([]SpawnEd
|
||||
sources["thread_spawn_edges"] = tableSource("sqlite_missing_table", sourcePath, "thread_spawn_edges table was not found.")
|
||||
return []SpawnEdge{}, nil
|
||||
}
|
||||
if !columns["from_thread_id"] || !columns["to_thread_id"] {
|
||||
if !(columns["from_thread_id"] && columns["to_thread_id"]) && !(columns["parent_thread_id"] && columns["child_thread_id"]) {
|
||||
sources["thread_spawn_edges"] = tableSource("sqlite_schema_drift", sourcePath, "thread_spawn_edges table is missing required endpoint columns.")
|
||||
return []SpawnEdge{}, nil
|
||||
}
|
||||
query := `SELECT ` + textColumn(columns, "from_thread_id") + `, ` + textColumn(columns, "to_thread_id") + `, ` + textColumn(columns, "reason") + `, ` + textColumn(columns, "created_at") + ` FROM thread_spawn_edges ORDER BY ` + orderBy(columns, "created_at", "from_thread_id", "to_thread_id")
|
||||
query := `SELECT ` +
|
||||
firstTextColumn(columns, "from_thread_id", "parent_thread_id") + `, ` +
|
||||
firstTextColumn(columns, "to_thread_id", "child_thread_id") + `, ` +
|
||||
firstTextColumn(columns, "reason", "status") + `, ` +
|
||||
textColumn(columns, "created_at") +
|
||||
` FROM thread_spawn_edges ORDER BY ` + orderBy(columns, "created_at", "from_thread_id", "parent_thread_id", "to_thread_id", "child_thread_id")
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
if isMissingTable(err) {
|
||||
@@ -191,7 +222,12 @@ func readGoals(db *sql.DB, sourcePath string, sources SourceMap) ([]Goal, error)
|
||||
sources["thread_goals"] = tableSource("sqlite_schema_drift", sourcePath, "thread_goals table is missing required thread_id column.")
|
||||
return []Goal{}, nil
|
||||
}
|
||||
query := `SELECT ` + textColumn(columns, "thread_id") + `, ` + textColumn(columns, "goal") + `, ` + textColumn(columns, "status") + `, ` + textColumn(columns, "updated_at") + ` FROM thread_goals ORDER BY ` + orderBy(columns, "updated_at", "thread_id")
|
||||
query := `SELECT ` +
|
||||
textColumn(columns, "thread_id") + `, ` +
|
||||
firstTextColumn(columns, "goal", "objective") + `, ` +
|
||||
textColumn(columns, "status") + `, ` +
|
||||
firstTextColumn(columns, "updated_at_ms", "updated_at") +
|
||||
` FROM thread_goals ORDER BY ` + orderBy(columns, "updated_at_ms", "updated_at", "thread_id")
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
if isMissingTable(err) {
|
||||
@@ -244,6 +280,15 @@ func textColumn(columns map[string]bool, name string) string {
|
||||
return "COALESCE(CAST(" + name + " AS TEXT), '')"
|
||||
}
|
||||
|
||||
func firstTextColumn(columns map[string]bool, names ...string) string {
|
||||
for _, name := range names {
|
||||
if columns[name] {
|
||||
return textColumn(columns, name)
|
||||
}
|
||||
}
|
||||
return "''"
|
||||
}
|
||||
|
||||
func orderBy(columns map[string]bool, names ...string) string {
|
||||
var order []string
|
||||
for _, name := range names {
|
||||
|
||||
@@ -66,6 +66,100 @@ func TestStoreReadsThreadsEdgesAndGoalsFromReadonlySQLite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreReadsCurrentCodexRuntimeSchema(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stateDB := openWritableSQLite(t, filepath.Join(root, "state_5.sqlite"))
|
||||
defer stateDB.Close()
|
||||
execSQL(t, stateDB, `CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sandbox_policy TEXT NOT NULL,
|
||||
approval_mode TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
cli_version TEXT NOT NULL DEFAULT '',
|
||||
first_user_message TEXT NOT NULL DEFAULT '',
|
||||
agent_nickname TEXT,
|
||||
agent_role TEXT,
|
||||
memory_mode TEXT NOT NULL DEFAULT 'enabled',
|
||||
model TEXT,
|
||||
reasoning_effort TEXT,
|
||||
agent_path TEXT,
|
||||
created_at_ms INTEGER,
|
||||
updated_at_ms INTEGER,
|
||||
thread_source TEXT,
|
||||
preview TEXT NOT NULL DEFAULT ''
|
||||
)`)
|
||||
execSQL(t, stateDB, `CREATE TABLE thread_spawn_edges (
|
||||
parent_thread_id TEXT NOT NULL,
|
||||
child_thread_id TEXT NOT NULL PRIMARY KEY,
|
||||
status TEXT NOT NULL
|
||||
)`)
|
||||
execSQL(t, stateDB, `INSERT INTO threads (
|
||||
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode,
|
||||
cli_version, agent_nickname, agent_role, agent_path, created_at_ms, updated_at_ms, thread_source, preview
|
||||
) VALUES
|
||||
('thread-a', '/tmp/a.jsonl', 1, 2, 'codex', 'openai', '/repo/a', '主线程', 'workspace-write', 'on-request',
|
||||
'0.0.1', '主控', '智能体编排者', '/agents/orchestrator.toml', 1000, 2000, 'cli', '监管项目流程'),
|
||||
('thread-b', '/tmp/b.jsonl', 3, 4, 'codex', 'openai', '/repo/b', '审查线程', 'workspace-write', 'on-request',
|
||||
'0.0.1', '审查员', '代码审查员', '/agents/reviewer.toml', 3000, 4000, 'cli', '审查实现')`)
|
||||
execSQL(t, stateDB, `INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id, status) VALUES
|
||||
('thread-a', 'thread-b', 'spawned')`)
|
||||
|
||||
goalsDB := openWritableSQLite(t, filepath.Join(root, "goals_1.sqlite"))
|
||||
defer goalsDB.Close()
|
||||
execSQL(t, goalsDB, `CREATE TABLE thread_goals (
|
||||
thread_id TEXT PRIMARY KEY NOT NULL,
|
||||
goal_id TEXT NOT NULL,
|
||||
objective TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
token_budget INTEGER,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
time_used_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)`)
|
||||
execSQL(t, goalsDB, `INSERT INTO thread_goals (
|
||||
thread_id, goal_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('thread-a', 'goal-a', '管理当前项目的智能体流程', 'active', 10000, 1200, 60, 1000, 2000)`)
|
||||
|
||||
snapshot, err := Store{CodexHome: root}.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("Snapshot returned error: %v", err)
|
||||
}
|
||||
if len(snapshot.Threads) != 2 {
|
||||
t.Fatalf("threads = %#v", snapshot.Threads)
|
||||
}
|
||||
first := snapshot.Threads[0]
|
||||
if first.ID != "thread-a" || first.CWD != "/repo/a" || first.Title != "主线程" {
|
||||
t.Fatalf("unexpected first thread identity: %#v", first)
|
||||
}
|
||||
if first.AgentNickname != "主控" || first.AgentRole != "智能体编排者" || first.Role != "智能体编排者" {
|
||||
t.Fatalf("unexpected first thread agent fields: %#v", first)
|
||||
}
|
||||
if first.AgentPath != "/agents/orchestrator.toml" || first.Preview != "监管项目流程" || first.CreatedAt != "1000" || first.UpdatedAt != "2000" {
|
||||
t.Fatalf("unexpected first thread metadata: %#v", first)
|
||||
}
|
||||
if len(snapshot.SpawnEdges) != 1 || snapshot.SpawnEdges[0].FromThreadID != "thread-a" || snapshot.SpawnEdges[0].ToThreadID != "thread-b" || snapshot.SpawnEdges[0].Reason != "spawned" {
|
||||
t.Fatalf("unexpected current-schema edges: %#v", snapshot.SpawnEdges)
|
||||
}
|
||||
if len(snapshot.Goals) != 1 || snapshot.Goals[0].ThreadID != "thread-a" || snapshot.Goals[0].Goal != "管理当前项目的智能体流程" || snapshot.Goals[0].Status != "active" || snapshot.Goals[0].UpdatedAt != "2000" {
|
||||
t.Fatalf("unexpected current-schema goals: %#v", snapshot.Goals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMarksStateMissingWhenOnlyGoalsSQLiteExists(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createRuntimeSQLite(t, root)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codex-agent-manager/internal/agents"
|
||||
@@ -110,6 +112,10 @@ func New(cfg app.Config) http.Handler {
|
||||
"source": view.Source,
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/api/", http.NotFound)
|
||||
if cfg.StaticDir != "" {
|
||||
mux.HandleFunc("/", staticFrontendHandler(cfg.StaticDir))
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -211,3 +217,25 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func staticFrontendHandler(staticDir string) http.HandlerFunc {
|
||||
fileServer := http.FileServer(http.Dir(staticDir))
|
||||
indexPath := filepath.Join(staticDir, "index.html")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
||||
return
|
||||
}
|
||||
cleanPath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
if cleanPath == "" {
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
target := filepath.Join(staticDir, cleanPath)
|
||||
if info, err := os.Stat(target); err == nil && !info.IsDir() {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,60 @@ func TestReadOnlyEndpointsRejectUnsupportedMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticFrontendServesIndexAndAssets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
staticDir := filepath.Join(root, "dist")
|
||||
if err := os.MkdirAll(filepath.Join(staticDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte(`<html><title>管理台</title><script src="/assets/app.js"></script></html>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staticDir, "assets", "app.js"), []byte(`console.log("ok")`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0", StaticDir: staticDir})
|
||||
|
||||
for _, path := range []string{"/", "/workflow"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, body = %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "<title>管理台</title>") {
|
||||
t.Fatalf("%s did not serve index.html: %s", path, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `console.log("ok")`) {
|
||||
t.Fatalf("asset status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticFrontendDoesNotMaskMissingAPIRoutes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
staticDir := filepath.Join(root, "dist")
|
||||
if err := os.MkdirAll(staticDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte(`<html>index</html>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/missing", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0", StaticDir: staticDir}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d, body = %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentValidateEndpointReturnsDiffAndRejectsUnsupportedMethods(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
agentsDir := filepath.Join(root, "agents")
|
||||
|
||||
13
progress.md
13
progress.md
@@ -184,7 +184,16 @@
|
||||
| 2026-05-25 | `cd web && pnpm test` | PASS | Phase 7 前端单测通过;13 个测试全部通过 |
|
||||
| 2026-05-25 | `cd web && pnpm build` | PASS | Phase 7 前端生产构建通过;Vite 构建成功 |
|
||||
| 2026-05-25 | `git diff --check` | PASS | Phase 7 whitespace 检查通过 |
|
||||
| 2026-05-25 | Browser visual verification | PENDING_MAIN_AGENT | 阶段 7 不伪造浏览器截图;待主智能体执行并记录 |
|
||||
| 2026-05-25 | Browser plugin visual verification | DONE_WITH_CONCERNS | 内置浏览器仍返回 `Browser is not available: iab`,改用本机 Chrome 执行同等页面核验 |
|
||||
| 2026-05-25 | Chrome visual verification | PASS | `http://127.0.0.1:13083/` 项目视图、工作流视图、智能体视图、草稿和设置页均显示中文;真实项目/线程矩阵、工作流阶段与监管、智能体 TOML 草稿校验/差异/备份写回控件可见 |
|
||||
| 2026-05-25 | `cd web && pnpm test` | FAIL | TDD 红灯:设置页仍显示“本阶段不会写回 / 不保存智能体 TOML”,与 Phase 6 单文件写回能力冲突 |
|
||||
| 2026-05-25 | `cd web && pnpm test` | PASS | 设置页安全文案修复后前端单测通过;14 个测试全部通过 |
|
||||
| 2026-05-25 | Chrome settings re-check | PASS | 设置页显示“安全配置摘要”“仅校验后单文件写回”“不自动保存或批量写回”,旧文案不再可见 |
|
||||
| 2026-05-25 | Final code review | PASS | 代码审查员复核无阻塞问题;建议将智能体页“只读编辑区”改为更准确的草稿编辑文案 |
|
||||
| 2026-05-25 | `cd web && pnpm test` | FAIL | TDD 红灯:智能体页仍出现“智能体只读编辑区 / 只读编辑区”旧文案 |
|
||||
| 2026-05-25 | `cd web && pnpm test` | PASS | 智能体页改为“字段预览与草稿编辑区”后前端单测通过;15 个测试全部通过 |
|
||||
| 2026-05-25 | `cd web && pnpm build` | PASS | 智能体页审查建议修复后前端生产构建通过 |
|
||||
| 2026-05-25 | Chrome agent view re-check | PASS | 智能体视图显示“字段预览与草稿编辑区”,并保留校验 TOML、查看差异、创建备份并写回控件 |
|
||||
|
||||
## Bug Loop
|
||||
|
||||
@@ -217,3 +226,5 @@
|
||||
| 6 | 写回备份/rename 前路径身份可能变化,且备份后并发修改可能被覆盖 | 写回加进程内临界区,记录 agents 目录和目标文件 inode identity;备份前和 rename 前复核 identity 与 expectedHash | `go test ./internal/agents ./internal/server` PASS |
|
||||
| 6 | validate/write POST 可接收超大 body、trailing JSON,且错误响应透传路径和英文系统错误 | validate/write 使用 1MiB `MaxBytesReader`、拒绝 trailing JSON,并将错误映射为安全中文响应 | `go test ./internal/agents ./internal/server` PASS |
|
||||
| 6 | 复核后到备份/rename 前仍有父目录路径替换窗口 | 使用 `Openat`/`Renameat` 将目标读取、备份、临时文件和 rename 绑定到已打开的 `agents` 目录 fd,并继续复核目录路径身份和目标 hash | `go test ./internal/agents ./internal/server` PASS |
|
||||
| 7 | 设置页安全边界文案停留在只读阶段,误导用户以为智能体 TOML 不会保存 | 改为“仅校验后单文件写回”,明确项目/运行线程/工作流仍只读,且不自动保存或批量写回 | `cd web && pnpm test` PASS;Chrome re-check PASS |
|
||||
| 7 | 智能体页编辑区域文案仍叫“只读编辑区”,与草稿编辑和写回按钮不一致 | 改为“字段预览与草稿编辑区”,并补文案回归测试 | `cd web && pnpm test` PASS;Chrome re-check PASS |
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- [x] 所有阶段完成
|
||||
- [x] Go 测试通过
|
||||
- [x] 前端构建通过
|
||||
- [ ] 浏览器验证关键页面
|
||||
- [x] 浏览器验证关键页面
|
||||
- [x] docs/project.md 记录目标、架构、配置、运行方式、安全边界和恢复方式
|
||||
- [x] 无 blocking bug 或未处理高风险问题
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| 4 | complete | 中文 UI 只读工作台 | 项目/工作流/智能体视图可浏览;空数据可用 |
|
||||
| 5 | complete | API 集成和只读数据显示 | 前端连接只读 API;显示真实 agent 数据和错误状态 |
|
||||
| 6 | complete | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 |
|
||||
| 7 | complete | 集成验证与文档 | 测试/构建通过;浏览器验证待主智能体执行;文档完整 |
|
||||
| 7 | complete | 集成验证与文档 | 测试/构建通过;Chrome 浏览器验证关键页面;文档完整 |
|
||||
|
||||
## Errors Encountered
|
||||
|
||||
@@ -42,3 +42,4 @@
|
||||
| 2026-05-25 | 6 | 规格审查发现 malformed TOML 会通过 `strconv.Unquote` 泄漏英文 `invalid syntax` | TDD 补 List/Validate/Write 中文错误断言后包装字符串解析错误 | 已通过最终验证 |
|
||||
| 2026-05-25 | 6 | 安全审查发现写回 TOCTOU、备份后 CAS 缺失、POST body 无限制、错误响应泄漏路径/英文 | TDD 补目录替换、备份后修改、请求体限制和错误脱敏测试后加身份复核/CAS/MaxBytesReader/中文错误映射 | 已通过最终验证 |
|
||||
| 2026-05-25 | 6 | 安全复审发现复核后到 createBackup/rename 前仍通过路径重新解析父目录 | TDD 补复核后替换 `agents` 为 symlink 测试后,将备份、临时文件和 rename 绑定到已打开的 agents dirfd | 已通过最终验证 |
|
||||
| 2026-05-25 | 7 | 设置页仍显示“本阶段不会写回 / 不保存智能体 TOML”,与单文件确认写回能力冲突 | TDD 补设置页安全文案测试后改为“仅校验后单文件写回”,并声明不自动保存或批量写回 | 已通过前端测试和 Chrome 页面验证 |
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 13083",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import ProjectView from './views/ProjectView.vue'
|
||||
import WorkflowView from './views/WorkflowView.vue'
|
||||
import AgentView from './views/AgentView.vue'
|
||||
import DraftsView from './views/DraftsView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
@@ -9,7 +8,6 @@ import StatusBadge from './components/StatusBadge.vue'
|
||||
|
||||
const tabs = [
|
||||
{ id: 'projects', label: '项目视图', component: ProjectView },
|
||||
{ id: 'workflow', label: '工作流视图', component: WorkflowView },
|
||||
{ id: 'agents', label: '智能体视图', component: AgentView },
|
||||
{ id: 'drafts', label: '草稿', component: DraftsView },
|
||||
{ id: 'settings', label: '设置', component: SettingsView },
|
||||
@@ -30,7 +28,7 @@ const activeComponent = computed(() => tabs.find((tab) => tab.id === activeTab.v
|
||||
<div class="connection-card" aria-label="连接状态">
|
||||
<StatusBadge label="安全接口" status="complete" confidence="中" source="计划文件" />
|
||||
<strong>按需读取和确认写回</strong>
|
||||
<span>项目、运行线程和工作流保持只读;智能体草稿仅在校验、备份和确认后单文件写回。</span>
|
||||
<span>项目、运行线程和项目内工作流保持只读;智能体草稿仅在校验、备份和确认后单文件写回。</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const STATUS_LABELS = {
|
||||
failed: '失败',
|
||||
idle: '空闲',
|
||||
invalid: '无效',
|
||||
active: '运行中',
|
||||
pending: '待处理',
|
||||
recent: '最近活跃',
|
||||
running: '运行中',
|
||||
@@ -164,21 +165,41 @@ export function normalizeAgents(payload = {}) {
|
||||
export function normalizeRuntime(payload = {}) {
|
||||
const threads = Array.isArray(payload.items) ? payload.items : []
|
||||
const goals = Array.isArray(payload.goals) ? payload.goals : []
|
||||
const edges = Array.isArray(payload.edges) ? payload.edges : []
|
||||
const goalsByThread = groupBy(goals, (goal) => goal.threadId)
|
||||
const agents = threads.map((thread) => {
|
||||
const spawnStatusByThread = new Map(
|
||||
edges
|
||||
.filter((edge) => edge.toThreadId)
|
||||
.map((edge) => [edge.toThreadId, normalizeSpawnStatus(edge.reason || edge.status)]),
|
||||
)
|
||||
const agents = threads.filter(isRuntimeAgentThread).map((thread) => {
|
||||
const source = normalizeSource(thread.source, payload.source)
|
||||
const threadGoals = goalsByThread.get(thread.id) ?? []
|
||||
const status = thread.status || spawnStatusByThread.get(thread.id) || threadGoals.find((goal) => goal.status)?.status || 'unknown'
|
||||
const goalText = threadGoals
|
||||
.map((goal) => goal.goal || goal.objective)
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
const taskSummary = summarizeTask(thread.title || thread.preview)
|
||||
return {
|
||||
id: thread.id,
|
||||
name: thread.role || thread.id || '未命名线程',
|
||||
role: thread.id,
|
||||
status: thread.status || 'unknown',
|
||||
statusZh: formatStatus(thread.status),
|
||||
goal: threadGoals.map((goal) => goal.goal).filter(Boolean).join(';') || '没有目标记录',
|
||||
process: 'SQLite 只读快照',
|
||||
name: thread.agentNickname || thread.agentRole || thread.title || thread.role || thread.id || '未命名线程',
|
||||
role: thread.agentRole || thread.role || thread.agentPath || thread.id || '未记录角色',
|
||||
projectPath: thread.cwd || '',
|
||||
projectHints: [thread.title, thread.preview, thread.agentPath].filter(Boolean),
|
||||
status,
|
||||
statusZh: formatStatus(status),
|
||||
goal: goalText || taskSummary || '未记录结构化目标',
|
||||
goalSource: goalText ? '结构化目标' : taskSummary ? '线程任务摘要' : '无目标来源',
|
||||
process: describeRuntimeProcess(thread),
|
||||
source: source.label,
|
||||
confidence: source.confidenceLabel,
|
||||
lastActivity: thread.updatedAt || thread.createdAt || '没有时间记录',
|
||||
displayName: actorDisplayName({
|
||||
name: thread.agentNickname,
|
||||
role: thread.agentRole || thread.role,
|
||||
}),
|
||||
phaseName: extractPhaseName([goalText, taskSummary, thread.title, thread.preview, thread.agentPath]) || '未标注阶段',
|
||||
}
|
||||
})
|
||||
const source = normalizeSource(payload.source)
|
||||
@@ -187,7 +208,7 @@ export function normalizeRuntime(payload = {}) {
|
||||
agents,
|
||||
threads,
|
||||
goals,
|
||||
edges: Array.isArray(payload.edges) ? payload.edges : [],
|
||||
edges,
|
||||
source,
|
||||
isEmpty: threads.length === 0,
|
||||
emptyTitle: '没有运行线程',
|
||||
@@ -195,6 +216,433 @@ export function normalizeRuntime(payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function isRuntimeAgentThread(thread) {
|
||||
if (thread?.threadSource === 'user') {
|
||||
return false
|
||||
}
|
||||
return Boolean(
|
||||
thread?.agentNickname ||
|
||||
thread?.agentRole ||
|
||||
thread?.agentPath ||
|
||||
thread?.threadSource === 'subagent' ||
|
||||
(!thread?.threadSource && thread?.role),
|
||||
)
|
||||
}
|
||||
|
||||
function describeRuntimeProcess(thread) {
|
||||
if (thread.threadSource === 'subagent') {
|
||||
return '子智能体线程'
|
||||
}
|
||||
if (thread.agentPath) {
|
||||
return `智能体配置:${basename(thread.agentPath)}`
|
||||
}
|
||||
if (thread.agentNickname || thread.agentRole || thread.role) {
|
||||
return '智能体线程'
|
||||
}
|
||||
return 'SQLite 只读快照'
|
||||
}
|
||||
|
||||
export function filterRuntimeByProject(runtime = normalizeRuntime(), projectPath = '') {
|
||||
const targetPath = normalizePath(projectPath)
|
||||
const agents = runtime.agents
|
||||
.filter((agent) => belongsToProject(agent, targetPath))
|
||||
.map((agent) => enrichProjectWorkflow(agent, targetPath))
|
||||
const threadIds = new Set(agents.map((agent) => agent.id))
|
||||
const agentByID = new Map(agents.map((agent) => [agent.id, agent]))
|
||||
const threadByID = new Map(runtime.threads.map((thread) => [thread.id, thread]))
|
||||
const projectThreadIds = new Set([
|
||||
...threadIds,
|
||||
...runtime.threads.filter((thread) => threadBelongsToProject(thread, targetPath)).map((thread) => thread.id),
|
||||
])
|
||||
const projectMainThreadIds = new Set(
|
||||
runtime.threads.filter((thread) => projectThreadIds.has(thread.id) && isMainThread(thread)).map((thread) => thread.id),
|
||||
)
|
||||
const agentIdsWithProjectMainEdge = buildAgentIdsWithProjectMainEdge(runtime.edges, threadIds, projectMainThreadIds)
|
||||
const threads = runtime.threads.filter((thread) => threadIds.has(thread.id))
|
||||
const goals = runtime.goals.filter((goal) => threadIds.has(goal.threadId) || projectThreadIds.has(goal.threadId))
|
||||
const edges = runtime.edges.filter((edge) =>
|
||||
edgeBelongsToProjectFlow(edge, { threadIds, projectThreadIds, threadByID, targetPath, agentIdsWithProjectMainEdge }),
|
||||
)
|
||||
const handoffs = edges.map((edge) => normalizeProjectHandoff(edge, { agentByID, threadByID, source: runtime.source }))
|
||||
const phaseGroups = buildPhaseGroups(agents)
|
||||
const phaseHandoffs = buildPhaseHandoffs(handoffs, phaseGroups)
|
||||
const workflowBatches = buildWorkflowBatches(phaseHandoffs)
|
||||
const supervision = buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals })
|
||||
|
||||
return {
|
||||
...runtime,
|
||||
agents,
|
||||
threads,
|
||||
goals,
|
||||
edges,
|
||||
handoffs,
|
||||
phaseGroups,
|
||||
phaseHandoffs,
|
||||
workflowBatches,
|
||||
supervision,
|
||||
isEmpty: agents.length === 0,
|
||||
emptyTitle: '这个项目没有运行线程',
|
||||
emptyText: projectPath
|
||||
? '当前选中的项目没有匹配到 Codex 运行线程;这里不会显示其他项目的智能体状态。'
|
||||
: '请先选择一个项目。',
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentIdsWithProjectMainEdge(edges, threadIds, projectMainThreadIds) {
|
||||
const agentIds = new Set()
|
||||
for (const edge of edges) {
|
||||
if (projectMainThreadIds.has(edge.fromThreadId) && threadIds.has(edge.toThreadId)) {
|
||||
agentIds.add(edge.toThreadId)
|
||||
}
|
||||
if (projectMainThreadIds.has(edge.toThreadId) && threadIds.has(edge.fromThreadId)) {
|
||||
agentIds.add(edge.fromThreadId)
|
||||
}
|
||||
}
|
||||
return agentIds
|
||||
}
|
||||
|
||||
function edgeBelongsToProjectFlow(edge, { threadIds, projectThreadIds, threadByID, targetPath, agentIdsWithProjectMainEdge }) {
|
||||
const fromIsAgent = threadIds.has(edge.fromThreadId)
|
||||
const toIsAgent = threadIds.has(edge.toThreadId)
|
||||
const fromIsProjectThread = projectThreadIds.has(edge.fromThreadId)
|
||||
const toIsProjectThread = projectThreadIds.has(edge.toThreadId)
|
||||
const fromThread = threadByID.get(edge.fromThreadId)
|
||||
const toThread = threadByID.get(edge.toThreadId)
|
||||
const fromIsMain = mainThreadCanJoinProjectFlow(fromThread, targetPath, agentIdsWithProjectMainEdge, edge.toThreadId)
|
||||
const toIsMain = mainThreadCanJoinProjectFlow(toThread, targetPath, agentIdsWithProjectMainEdge, edge.fromThreadId)
|
||||
|
||||
if (fromIsAgent && toIsAgent) {
|
||||
return true
|
||||
}
|
||||
if (fromIsAgent && (toIsProjectThread || toIsMain)) {
|
||||
return true
|
||||
}
|
||||
if (toIsAgent && (fromIsProjectThread || fromIsMain)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function mainThreadCanJoinProjectFlow(thread, targetPath, agentIdsWithProjectMainEdge, agentThreadId) {
|
||||
if (!isMainThread(thread)) {
|
||||
return false
|
||||
}
|
||||
if (threadBelongsToProject(thread, targetPath)) {
|
||||
return true
|
||||
}
|
||||
if (agentIdsWithProjectMainEdge.has(agentThreadId)) {
|
||||
return false
|
||||
}
|
||||
const cwd = normalizePath(thread?.cwd)
|
||||
return Boolean(cwd && targetPath.startsWith(`${cwd}/`))
|
||||
}
|
||||
|
||||
function normalizeProjectHandoff(edge, context) {
|
||||
const source = normalizeSource(edge.source, context.source)
|
||||
const status = normalizeSpawnStatus(edge.reason || edge.status) || 'unknown'
|
||||
const fromAgent = context.agentByID.get(edge.fromThreadId)
|
||||
const toAgent = context.agentByID.get(edge.toThreadId)
|
||||
const fromIsMain = isMainThread(context.threadByID.get(edge.fromThreadId))
|
||||
const toIsMain = isMainThread(context.threadByID.get(edge.toThreadId))
|
||||
const directionLabel = handoffDirectionLabel({ fromAgent, toAgent, fromIsMain, toIsMain })
|
||||
return {
|
||||
id: `${edge.fromThreadId || 'unknown'}-${edge.toThreadId || 'unknown'}-${edge.createdAt || edge.reason || edge.status || 'event'}`,
|
||||
from: runtimeNodeName(edge.fromThreadId, context),
|
||||
to: runtimeNodeName(edge.toThreadId, context),
|
||||
summary: formatStatus(status),
|
||||
directionLabel,
|
||||
status,
|
||||
time: edge.createdAt || '后端事件',
|
||||
source: source.label,
|
||||
confidence: source.confidenceLabel,
|
||||
phaseName: handoffPhaseName({ fromAgent, toAgent, toIsMain }),
|
||||
workflowBatch: handoffWorkflowBatch({ fromAgent, toAgent, toIsMain }),
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeNodeName(threadID, { agentByID, threadByID }) {
|
||||
if (!threadID) {
|
||||
return '未知线程'
|
||||
}
|
||||
const agent = agentByID.get(threadID)
|
||||
if (agent) {
|
||||
return agent.displayName
|
||||
}
|
||||
const thread = threadByID.get(threadID)
|
||||
if (isMainThread(thread)) {
|
||||
return '主线程 / 主智能体监管'
|
||||
}
|
||||
return actorDisplayName({
|
||||
name: thread?.agentNickname,
|
||||
role: thread?.agentRole || thread?.role,
|
||||
fallbackName: '未知线程',
|
||||
fallbackRole: '角色未知',
|
||||
})
|
||||
}
|
||||
|
||||
function handoffPhaseName({ fromAgent, toAgent, toIsMain }) {
|
||||
if (toIsMain) {
|
||||
return fromAgent?.phaseName || '未标注阶段'
|
||||
}
|
||||
if (toAgent?.phaseName) {
|
||||
return toAgent.phaseName
|
||||
}
|
||||
return fromAgent?.phaseName || '未标注阶段'
|
||||
}
|
||||
|
||||
function handoffWorkflowBatch({ fromAgent, toAgent, toIsMain }) {
|
||||
if (toIsMain) {
|
||||
return fromAgent?.workflowBatch || '默认工作流'
|
||||
}
|
||||
if (toAgent?.workflowBatch) {
|
||||
return toAgent.workflowBatch
|
||||
}
|
||||
return fromAgent?.workflowBatch || '默认工作流'
|
||||
}
|
||||
|
||||
function handoffDirectionLabel({ fromAgent, toAgent, fromIsMain, toIsMain }) {
|
||||
if (fromIsMain && toAgent) {
|
||||
return '主线程派发'
|
||||
}
|
||||
if (fromAgent && toIsMain) {
|
||||
return '回到主线程'
|
||||
}
|
||||
if (fromAgent && toAgent && fromAgent.phaseName !== toAgent.phaseName) {
|
||||
return '跨阶段交接'
|
||||
}
|
||||
if (fromAgent && toAgent) {
|
||||
return '子智能体交接'
|
||||
}
|
||||
return '线程交接'
|
||||
}
|
||||
|
||||
function buildPhaseGroups(agents) {
|
||||
const groups = new Map()
|
||||
for (const agent of agents) {
|
||||
const phaseName = agent.phaseName || extractPhaseName([agent.goal, ...(agent.projectHints || [])]) || '未标注阶段'
|
||||
const workflowBatch = agent.workflowBatch || '默认工作流'
|
||||
const groupKey = `${workflowBatch}::${phaseName}`
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, { name: phaseName, workflowBatch, agents: [] })
|
||||
}
|
||||
groups.get(groupKey).agents.push(agent)
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map(({ name, workflowBatch, agents: phaseAgents }) => {
|
||||
const roles = [...new Set(phaseAgents.map((agent) => agent.role).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-CN'))
|
||||
return {
|
||||
name,
|
||||
workflowBatch,
|
||||
status: phaseStatus(phaseAgents),
|
||||
statusZh: formatStatus(phaseStatus(phaseAgents)),
|
||||
roles,
|
||||
agents: phaseAgents,
|
||||
}
|
||||
})
|
||||
.sort(compareWorkflowPhase)
|
||||
}
|
||||
|
||||
function buildPhaseHandoffs(handoffs, phaseGroups) {
|
||||
const groups = new Map(phaseGroups.map((phase) => [`${phase.workflowBatch}::${phase.name}`, { ...phase, handoffs: [] }]))
|
||||
for (const handoff of handoffs) {
|
||||
const phaseName = handoff.phaseName || '未标注阶段'
|
||||
const workflowBatch = handoff.workflowBatch || '默认工作流'
|
||||
const groupKey = `${workflowBatch}::${phaseName}`
|
||||
if (!groups.has(groupKey)) {
|
||||
groups.set(groupKey, {
|
||||
name: phaseName,
|
||||
workflowBatch,
|
||||
status: handoff.status,
|
||||
statusZh: formatStatus(handoff.status),
|
||||
roles: [],
|
||||
agents: [],
|
||||
handoffs: [],
|
||||
})
|
||||
}
|
||||
groups.get(groupKey).handoffs.push(handoff)
|
||||
}
|
||||
return [...groups.values()]
|
||||
.filter((phase) => phase.agents.length > 0 || phase.handoffs.length > 0)
|
||||
.map((phase) => ({
|
||||
...phase,
|
||||
status: phaseStatus([...phase.agents, ...phase.handoffs]),
|
||||
statusZh: formatStatus(phaseStatus([...phase.agents, ...phase.handoffs])),
|
||||
}))
|
||||
.sort(compareWorkflowPhase)
|
||||
}
|
||||
|
||||
function buildWorkflowBatches(phases) {
|
||||
const groups = new Map()
|
||||
for (const phase of phases) {
|
||||
const batchName = phase.workflowBatch || '默认工作流'
|
||||
if (!groups.has(batchName)) {
|
||||
groups.set(batchName, {
|
||||
name: batchName,
|
||||
status: 'unknown',
|
||||
statusZh: '未知',
|
||||
phaseCount: 0,
|
||||
handoffCount: 0,
|
||||
agentCount: 0,
|
||||
phases: [],
|
||||
})
|
||||
}
|
||||
const batch = groups.get(batchName)
|
||||
batch.phases.push(phase)
|
||||
batch.phaseCount += 1
|
||||
batch.handoffCount += phase.handoffs.length
|
||||
batch.agentCount += phase.agents.length
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map((batch) => {
|
||||
const status = phaseStatus(batch.phases)
|
||||
return { ...batch, status, statusZh: formatStatus(status) }
|
||||
})
|
||||
.sort((a, b) => workflowBatchSortKey(a.name) - workflowBatchSortKey(b.name) || a.name.localeCompare(b.name, 'zh-CN'))
|
||||
}
|
||||
|
||||
function buildSupervision({ agents, handoffs, projectThreadIds, runtime, threadByID, goals }) {
|
||||
const mainThread = [...projectThreadIds]
|
||||
.map((threadID) => threadByID.get(threadID))
|
||||
.find((thread) => isMainThread(thread))
|
||||
const orchestrator = agents.find((agent) => /编排|主智能体|监管/.test(`${agent.role} ${agent.name} ${agent.goal}`))
|
||||
const supervisorID = mainThread?.id || orchestrator?.id || ''
|
||||
const supervisorGoal = goals.find((goal) => goal.threadId === supervisorID) || goals.find((goal) => projectThreadIds.has(goal.threadId))
|
||||
const status = mainThread?.status || orchestrator?.status || supervisorGoal?.status || (handoffs.length > 0 ? 'recent' : 'unknown')
|
||||
const source = normalizeSource(mainThread?.source || orchestrator?.sourceDetail, runtime.source)
|
||||
return {
|
||||
actor: mainThread ? '主线程 / 主智能体监管' : orchestrator?.displayName || '主线程 / 主智能体监管',
|
||||
status,
|
||||
statusZh: formatStatus(status),
|
||||
goal: supervisorGoal?.goal || supervisorGoal?.objective || orchestrator?.goal || '没有目标记录',
|
||||
lastActivity: mainThread?.updatedAt || mainThread?.createdAt || supervisorGoal?.updatedAt || orchestrator?.lastActivity || '没有时间记录',
|
||||
handoffCount: handoffs.length,
|
||||
agentCount: agents.length,
|
||||
source: source.label,
|
||||
confidence: source.confidenceLabel,
|
||||
}
|
||||
}
|
||||
|
||||
function phaseStatus(agents) {
|
||||
if (agents.some((agent) => agent.status === 'running' || agent.status === 'active')) {
|
||||
return 'running'
|
||||
}
|
||||
if (agents.length > 0 && agents.every((agent) => agent.status === 'complete' || agent.status === 'done')) {
|
||||
return 'complete'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function extractPhaseName(values) {
|
||||
for (const value of values) {
|
||||
const text = String(value || '')
|
||||
const optimization = text.match(/优化阶段\s*([0-9一二三四五六七八九十]+)/)
|
||||
if (optimization) {
|
||||
return `优化阶段 ${optimization[1]}`
|
||||
}
|
||||
const chinese = text.match(/阶段\s*([0-9一二三四五六七八九十]+)/)
|
||||
if (chinese) {
|
||||
return `阶段 ${chinese[1]}`
|
||||
}
|
||||
const english = text.match(/\bphase\s*([0-9]+)\b/i)
|
||||
if (english) {
|
||||
return `阶段 ${english[1]}`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function enrichProjectWorkflow(agent, targetPath) {
|
||||
const values = [agent.goal, agent.name, agent.role, ...(agent.projectHints || [])]
|
||||
const explicitBatch = extractWorkflowBatchName(values)
|
||||
const explicitPhase = extractPhaseName(values)
|
||||
const isCurrentProject = basename(targetPath) === 'codex-agent-manager'
|
||||
let phaseName = explicitPhase || agent.phaseName || '未标注阶段'
|
||||
let workflowBatch = explicitBatch
|
||||
if (!workflowBatch && isCurrentProject && isOptimizationWork(values)) {
|
||||
if (!/^阶段\s*[0-9一二三四五六七八九十]+$/.test(phaseName) || /^优化阶段/.test(phaseName)) {
|
||||
workflowBatch = 'v1.1 优化修复'
|
||||
if (!explicitPhase || phaseName === '未标注阶段') {
|
||||
phaseName = '优化阶段 1'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!workflowBatch && isCurrentProject && /^阶段\s*[0-9一二三四五六七八九十]+$/.test(phaseName)) {
|
||||
workflowBatch = 'v1.0 初始搭建'
|
||||
}
|
||||
return {
|
||||
...agent,
|
||||
phaseName,
|
||||
workflowBatch: normalizeWorkflowBatchName(workflowBatch || '默认工作流'),
|
||||
}
|
||||
}
|
||||
|
||||
function extractWorkflowBatchName(values) {
|
||||
for (const value of values) {
|
||||
const text = String(value || '')
|
||||
const explicit = text.match(/工作流批次\s*[::]\s*([^\]`\n;]+)/)
|
||||
if (explicit) {
|
||||
return cleanExplicitWorkflowBatchName(explicit[1])
|
||||
}
|
||||
const version = text.match(/\b(v[0-9]+(?:\.[0-9]+)+)\s*([^\]`\n;,,。::/、]*)/i)
|
||||
if (version && /重构|升级|优化|修复|初始|搭建|发布/.test(version[2] || '')) {
|
||||
return `${version[1]} ${version[2].trim()}`.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function cleanExplicitWorkflowBatchName(value) {
|
||||
return String(value || '')
|
||||
.replace(/\s*[\/、]\s*(?:优化阶段|阶段)\s*[0-9一二三四五六七八九十]+.*$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeWorkflowBatchName(value) {
|
||||
return String(value || '默认工作流').replace(/^(v[0-9]+(?:\.[0-9]+)+)([^\s].*)$/, '$1 $2').trim()
|
||||
}
|
||||
|
||||
function isOptimizationWork(values) {
|
||||
return values.some((value) => /优化|修复|改造|调整|合并|布局|详情切换|流程记录|工作流视图|工作流批次|新增|项目视图|交接/.test(String(value || '')))
|
||||
}
|
||||
|
||||
function actorDisplayName({ name = '', role = '', fallbackName = '', fallbackRole = '未记录角色' } = {}) {
|
||||
const cleanName = String(name || '').trim()
|
||||
const cleanRole = String(role || '').trim()
|
||||
if (cleanName && cleanRole) {
|
||||
return `${cleanName} / ${cleanRole}`
|
||||
}
|
||||
if (cleanRole) {
|
||||
return cleanRole
|
||||
}
|
||||
if (cleanName) {
|
||||
return `${cleanName} / ${fallbackRole}`
|
||||
}
|
||||
return fallbackName ? `${fallbackName} / ${fallbackRole}` : `未知线程 / ${fallbackRole}`
|
||||
}
|
||||
|
||||
function phaseSortKey(name) {
|
||||
const match = String(name).match(/(?:阶段|优化阶段)\s*([0-9]+)/)
|
||||
if (!match) {
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
function workflowBatchSortKey(name) {
|
||||
const match = String(name).match(/v([0-9]+)(?:\.([0-9]+))?/i)
|
||||
if (!match) {
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
return Number(match[1]) * 100 + Number(match[2] || 0)
|
||||
}
|
||||
|
||||
function compareWorkflowPhase(a, b) {
|
||||
return workflowBatchSortKey(a.workflowBatch) - workflowBatchSortKey(b.workflowBatch) ||
|
||||
a.workflowBatch.localeCompare(b.workflowBatch, 'zh-CN') ||
|
||||
phaseSortKey(a.name) - phaseSortKey(b.name) ||
|
||||
a.name.localeCompare(b.name, 'zh-CN')
|
||||
}
|
||||
|
||||
export function normalizeWorkflow(payload = {}) {
|
||||
const source = normalizeSource(payload.source)
|
||||
const phases = Array.isArray(payload.phases)
|
||||
@@ -327,6 +775,93 @@ function basename(path) {
|
||||
return String(path).split('/').filter(Boolean).at(-1) ?? String(path)
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
return String(path || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function summarizeTask(value) {
|
||||
const firstLine = String(value || '').split('\n').map((line) => line.trim()).find(Boolean) || ''
|
||||
if (firstLine.length <= 180) {
|
||||
return firstLine
|
||||
}
|
||||
return `${firstLine.slice(0, 177)}...`
|
||||
}
|
||||
|
||||
function belongsToProject(agent, targetPath) {
|
||||
if (!targetPath) {
|
||||
return false
|
||||
}
|
||||
const agentPath = normalizePath(agent.projectPath)
|
||||
if (agentPath === targetPath) {
|
||||
return true
|
||||
}
|
||||
if (!agentPath || !targetPath.startsWith(`${agentPath}/`)) {
|
||||
return false
|
||||
}
|
||||
return [agent.process, ...(agent.projectHints || [])].some((value) => containsPathToken(value, targetPath) || containsProjectToken(value, targetPath))
|
||||
}
|
||||
|
||||
function threadBelongsToProject(thread, targetPath) {
|
||||
if (!targetPath) {
|
||||
return false
|
||||
}
|
||||
const cwd = normalizePath(thread?.cwd)
|
||||
if (cwd === targetPath) {
|
||||
return true
|
||||
}
|
||||
if (!cwd || !targetPath.startsWith(`${cwd}/`)) {
|
||||
return false
|
||||
}
|
||||
return [thread?.title, thread?.preview, thread?.agentPath].some((value) => containsPathToken(value, targetPath) || containsProjectToken(value, targetPath))
|
||||
}
|
||||
|
||||
function threadCanJoinProjectFlow(thread, targetPath) {
|
||||
return threadBelongsToProject(thread, targetPath)
|
||||
}
|
||||
|
||||
function isMainThread(thread) {
|
||||
if (!thread || thread.threadSource === 'subagent') {
|
||||
return false
|
||||
}
|
||||
return Boolean(thread.threadSource === 'user' || (!thread.agentNickname && !thread.agentRole && !thread.agentPath && !thread.role))
|
||||
}
|
||||
|
||||
function normalizeSpawnStatus(status) {
|
||||
if (status === 'open') {
|
||||
return 'running'
|
||||
}
|
||||
if (status === 'closed') {
|
||||
return 'complete'
|
||||
}
|
||||
return status || ''
|
||||
}
|
||||
|
||||
function containsPathToken(value, targetPath) {
|
||||
const text = String(value || '')
|
||||
let index = text.indexOf(targetPath)
|
||||
while (index !== -1) {
|
||||
const after = text[index + targetPath.length] || ''
|
||||
if (!after || !/[A-Za-z0-9._/-]/.test(after)) {
|
||||
return true
|
||||
}
|
||||
index = text.indexOf(targetPath, index + targetPath.length)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function containsProjectToken(value, targetPath) {
|
||||
if (!targetPath) {
|
||||
return false
|
||||
}
|
||||
const text = String(value || '')
|
||||
const metadata = text.match(/\[?项目\s*[::]\s*([^\]\n;]+)\]?/)
|
||||
if (!metadata) {
|
||||
return false
|
||||
}
|
||||
const candidate = normalizePath(metadata[1].trim())
|
||||
return candidate === targetPath
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '没有时间记录'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
formatConfidence,
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
formatStatus,
|
||||
normalizeAgent,
|
||||
normalizeDraftWriteback,
|
||||
filterRuntimeByProject,
|
||||
normalizeProject,
|
||||
normalizeRuntime,
|
||||
normalizeValidationResult,
|
||||
@@ -92,6 +95,51 @@ test('empty agent and settings copy stay Chinese', () => {
|
||||
assert.ok(settings.every((item) => item.name !== 'Codex home'))
|
||||
})
|
||||
|
||||
test('settings safety copy matches confirmed single-file writeback phase', async () => {
|
||||
const thisFile = fileURLToPath(import.meta.url)
|
||||
const viewPath = new URL('../views/SettingsView.vue', `file://${thisFile}`)
|
||||
const source = await readFile(viewPath, 'utf8')
|
||||
|
||||
assert.doesNotMatch(source, /本阶段不会写回/)
|
||||
assert.doesNotMatch(source, /不保存智能体 TOML/)
|
||||
assert.match(source, /仅校验后单文件写回/)
|
||||
assert.match(source, /不自动保存或批量写回/)
|
||||
})
|
||||
|
||||
test('agent editor copy reflects preview and draft editing, not readonly-only mode', async () => {
|
||||
const thisFile = fileURLToPath(import.meta.url)
|
||||
const viewPath = new URL('../views/AgentView.vue', `file://${thisFile}`)
|
||||
const source = await readFile(viewPath, 'utf8')
|
||||
|
||||
assert.doesNotMatch(source, /智能体只读编辑区/)
|
||||
assert.doesNotMatch(source, /<p class="eyebrow">只读编辑区<\/p>/)
|
||||
assert.match(source, /字段预览与草稿编辑区/)
|
||||
})
|
||||
|
||||
test('layout allows main work areas to stretch on wide browser windows', async () => {
|
||||
const thisFile = fileURLToPath(import.meta.url)
|
||||
const stylePath = new URL('../styles.css', `file://${thisFile}`)
|
||||
const source = await readFile(stylePath, 'utf8')
|
||||
const projectMinWidth = 280 + 0 + 310 + 16 * 2
|
||||
const commonLaptopContentWidth = 1366 - 28 * 2
|
||||
const twoColumnMinWidth = Math.max(720 + 340 + 16, 330 + 720 + 16)
|
||||
const responsiveBreakpoint = 1140
|
||||
|
||||
assert.doesNotMatch(source, /\.app-shell\s*\{[^}]*width:\s*min\(1440px,\s*100%\)/s)
|
||||
assert.match(source, /\.app-shell\s*\{[^}]*width:\s*100%/s)
|
||||
assert.doesNotMatch(source, /\.app-shell\s*\{[^}]*max-width:/s)
|
||||
assert.match(source, /\.project-layout\s*\{[^}]*grid-template-columns:\s*280px minmax\(0,\s*1fr\) 310px/s)
|
||||
assert.match(source, /\.matrix-table\s*\{[^}]*overflow-x:\s*auto/s)
|
||||
assert.match(source, /\.matrix-row\s*\{[^}]*min-width:\s*920px/s)
|
||||
assert.match(source, /\.workflow-layout\s*\{[^}]*grid-template-columns:\s*minmax\(720px,\s*1fr\) 340px/s)
|
||||
assert.match(source, /\.agent-layout\s*\{[^}]*grid-template-columns:\s*330px minmax\(720px,\s*1fr\)/s)
|
||||
assert.match(source, /\.drafts-layout\s*\{[^}]*grid-template-columns:\s*minmax\(720px,\s*1fr\) 340px/s)
|
||||
assert.match(source, /\.settings-layout\s*\{[^}]*max-width:\s*1440px/s)
|
||||
assert.match(source, /@media\s*\(max-width:\s*1140px\)\s*\{[^}]*\.project-layout,[^}]*\.workflow-layout,[^}]*\.agent-layout,[^}]*\.drafts-layout,[^}]*\.settings-layout\s*\{[^}]*grid-template-columns:\s*1fr/s)
|
||||
assert.ok(projectMinWidth <= commonLaptopContentWidth)
|
||||
assert.ok(twoColumnMinWidth <= responsiveBreakpoint - 28 * 2)
|
||||
})
|
||||
|
||||
test('normalizes empty runtime without falling back to fake real data', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [],
|
||||
@@ -111,6 +159,676 @@ test('normalizes empty runtime without falling back to fake real data', () => {
|
||||
assert.deepEqual(runtime.agents, [])
|
||||
})
|
||||
|
||||
test('filters runtime to selected project and displays agent names from Codex metadata', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'thread-a',
|
||||
cwd: '/repo/a',
|
||||
title: '主线程',
|
||||
agentNickname: '主控',
|
||||
agentRole: '智能体编排者',
|
||||
status: '',
|
||||
updatedAt: '2000',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'thread-b',
|
||||
cwd: '/repo/b',
|
||||
title: '审查线程',
|
||||
agentNickname: '审查员',
|
||||
agentRole: '代码审查员',
|
||||
status: 'running',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
goals: [
|
||||
{ threadId: 'thread-a', goal: '管理当前项目的智能体流程', status: 'active' },
|
||||
],
|
||||
edges: [{ fromThreadId: 'thread-a', toThreadId: 'thread-b', reason: 'spawned' }],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
|
||||
|
||||
assert.equal(projectRuntime.isEmpty, false)
|
||||
assert.equal(projectRuntime.agents.length, 1)
|
||||
assert.equal(projectRuntime.threads.length, 1)
|
||||
assert.equal(projectRuntime.agents[0].name, '主控')
|
||||
assert.equal(projectRuntime.agents[0].role, '智能体编排者')
|
||||
assert.equal(projectRuntime.agents[0].projectPath, '/repo/a')
|
||||
assert.equal(projectRuntime.agents[0].goal, '管理当前项目的智能体流程')
|
||||
assert.equal(projectRuntime.agents[0].status, 'active')
|
||||
assert.equal(projectRuntime.agents[0].statusZh, '运行中')
|
||||
})
|
||||
|
||||
test('agent display names include role without inventing a nickname', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'role-only',
|
||||
cwd: '/repo/a',
|
||||
title: '阶段 1:产品规划',
|
||||
agentRole: '产品经理',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'name-only',
|
||||
cwd: '/repo/a',
|
||||
title: '阶段 1:补充资料',
|
||||
agentNickname: 'Nash',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
|
||||
|
||||
assert.deepEqual(projectRuntime.agents.map((agent) => agent.displayName), [
|
||||
'产品经理',
|
||||
'Nash / 未记录角色',
|
||||
])
|
||||
})
|
||||
|
||||
test('supervision only uses project-proven main threads', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'wrong-main',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '普通对话,没有项目路径',
|
||||
threadSource: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'right-main',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '监管 /Users/yoilun/Code/codex-agent-manager 项目',
|
||||
threadSource: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'coder',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '阶段 2:实现 /Users/yoilun/Code/codex-agent-manager',
|
||||
agentNickname: 'Ada',
|
||||
agentRole: '前端开发者',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
goals: [
|
||||
{ threadId: 'wrong-main', goal: '监管别的项目', status: 'active' },
|
||||
{ threadId: 'right-main', goal: '监管 codex-agent-manager 项目', status: 'active' },
|
||||
],
|
||||
edges: [
|
||||
{ fromThreadId: 'wrong-main', toThreadId: 'coder', reason: 'open' },
|
||||
{ fromThreadId: 'right-main', toThreadId: 'coder', reason: 'open' },
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
|
||||
|
||||
assert.deepEqual(projectRuntime.handoffs.map((handoff) => handoff.from), ['主线程 / 主智能体监管'])
|
||||
assert.equal(projectRuntime.supervision.goal, '监管 codex-agent-manager 项目')
|
||||
})
|
||||
|
||||
test('bare subagent threads are not treated as main supervision threads', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'main',
|
||||
cwd: '/repo/a',
|
||||
title: '监管 /repo/a 项目',
|
||||
threadSource: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'bare-sub',
|
||||
cwd: '/repo/a',
|
||||
title: '阶段 3:没有角色元数据的子智能体',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'reviewer',
|
||||
cwd: '/repo/a',
|
||||
title: '阶段 3:审查',
|
||||
agentNickname: 'Rawls',
|
||||
agentRole: '代码审查员',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
goals: [
|
||||
{ threadId: 'main', goal: '监管真实项目', status: 'active' },
|
||||
{ threadId: 'bare-sub', goal: '裸子智能体目标', status: 'active' },
|
||||
],
|
||||
edges: [
|
||||
{ fromThreadId: 'bare-sub', toThreadId: 'reviewer', reason: 'open' },
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
|
||||
|
||||
assert.equal(projectRuntime.supervision.goal, '监管真实项目')
|
||||
assert.equal(projectRuntime.handoffs[0].directionLabel, '子智能体交接')
|
||||
})
|
||||
|
||||
test('keeps main thread handoffs to project agents even when the main thread has only ancestor cwd', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'main',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '我想要一个图形页面去管理 code 文件夹下的项目',
|
||||
threadSource: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'reviewer',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '请审查 `/Users/yoilun/Code/codex-agent-manager` 当前未提交改动。背景:用户要求新增批次切换。',
|
||||
agentNickname: 'Mencius',
|
||||
agentRole: '代码审查员',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ fromThreadId: 'main', toThreadId: 'reviewer', reason: 'closed' },
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
|
||||
|
||||
assert.equal(projectRuntime.agents.length, 1)
|
||||
assert.equal(projectRuntime.handoffs.length, 1)
|
||||
assert.equal(projectRuntime.handoffs[0].directionLabel, '主线程派发')
|
||||
assert.equal(projectRuntime.workflowBatches[0].handoffCount, 1)
|
||||
assert.deepEqual(projectRuntime.workflowBatches[0].phases[0].handoffs.map((handoff) => handoff.to), [
|
||||
'Mencius / 代码审查员',
|
||||
])
|
||||
})
|
||||
|
||||
test('keeps parent main handoff when another project main is unrelated to that agent', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'project-main',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '另一个明确提到 /Users/yoilun/Code/codex-agent-manager 的用户线程,但没有派发这个智能体',
|
||||
threadSource: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'parent-main',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '继续整个项目',
|
||||
threadSource: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'reviewer',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '请审查 `/Users/yoilun/Code/codex-agent-manager` 当前 bugfix。',
|
||||
agentNickname: 'Hubble',
|
||||
agentRole: '代码审查员',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'other-agent',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '阶段 2:另一个 /Users/yoilun/Code/codex-agent-manager 智能体',
|
||||
agentNickname: 'Noether',
|
||||
agentRole: '后端架构师',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ fromThreadId: 'parent-main', toThreadId: 'reviewer', reason: 'open' },
|
||||
{ fromThreadId: 'project-main', toThreadId: 'other-agent', reason: 'closed' },
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
|
||||
|
||||
assert.deepEqual(projectRuntime.handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
|
||||
'主线程 / 主智能体监管 -> Hubble / 代码审查员',
|
||||
'主线程 / 主智能体监管 -> Noether / 后端架构师',
|
||||
])
|
||||
})
|
||||
|
||||
test('groups project flow by workflow batch and classifies current optimizations', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'main',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '监管 /Users/yoilun/Code/codex-agent-manager 项目',
|
||||
threadSource: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'initial',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '阶段 8:Docker 发布 /Users/yoilun/Code/codex-agent-manager',
|
||||
agentNickname: 'Ada',
|
||||
agentRole: '前端开发者',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'optimization',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '优化改造:合并工作流视图到项目视图 /Users/yoilun/Code/codex-agent-manager',
|
||||
agentNickname: 'Rawls',
|
||||
agentRole: '代码审查员',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v2.0 重构升级] [阶段: 阶段 3 后端架构]',
|
||||
agentNickname: 'Noether',
|
||||
agentRole: '后端架构师',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'review-prompt',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '请审查 /Users/yoilun/Code/codex-agent-manager。背景:将优化改造归类到 `v1.1 优化修复 / 优化阶段 1`。当前实现会给项目内 agent/handoff 增加工作流批次。',
|
||||
agentNickname: 'Peirce',
|
||||
agentRole: '代码审查员',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'explicit-slash-batch',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v1.2 CI/CD 发布] [阶段: 阶段 9 发布验证]',
|
||||
agentNickname: 'Curie',
|
||||
agentRole: '发布经理',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'explicit-phase-tail',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v1.1 优化修复 / 优化阶段 1] [阶段: 优化阶段 1]',
|
||||
agentNickname: 'Turing',
|
||||
agentRole: '前端开发者',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ fromThreadId: 'main', toThreadId: 'initial', reason: 'closed' },
|
||||
{ fromThreadId: 'main', toThreadId: 'optimization', reason: 'open' },
|
||||
{ fromThreadId: 'optimization', toThreadId: 'v2', reason: 'open' },
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
|
||||
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'initial').workflowBatch, 'v1.0 初始搭建')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'optimization').workflowBatch, 'v1.1 优化修复')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'optimization').phaseName, '优化阶段 1')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'v2').workflowBatch, 'v2.0 重构升级')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'v2').phaseName, '阶段 3')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'review-prompt').workflowBatch, 'v1.1 优化修复')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'explicit-slash-batch').workflowBatch, 'v1.2 CI/CD 发布')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'explicit-phase-tail').workflowBatch, 'v1.1 优化修复')
|
||||
assert.deepEqual(projectRuntime.workflowBatches.map((batch) => batch.name), [
|
||||
'v1.0 初始搭建',
|
||||
'v1.1 优化修复',
|
||||
'v1.2 CI/CD 发布',
|
||||
'v2.0 重构升级',
|
||||
])
|
||||
assert.deepEqual(projectRuntime.workflowBatches[1].phases.map((phase) => phase.name), ['优化阶段 1'])
|
||||
assert.deepEqual(projectRuntime.workflowBatches[1].phases[0].handoffs.map((handoff) => handoff.to), [
|
||||
'Rawls / 代码审查员',
|
||||
])
|
||||
})
|
||||
|
||||
test('workflow metadata requires full project path and keeps optimization phase name', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'short-project',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '[项目: codex-agent-manager] [工作流批次: v1.1 优化修复] [阶段: 优化阶段 1]',
|
||||
agentNickname: '短名项目',
|
||||
agentRole: '产品经理',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'full-project',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '[项目: /Users/yoilun/Code/codex-agent-manager] [工作流批次: v1.1 优化修复] [阶段: 优化阶段 1]',
|
||||
agentNickname: '完整路径项目',
|
||||
agentRole: '产品经理',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'numeric-repair',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '阶段 3:修复初始搭建问题 /Users/yoilun/Code/codex-agent-manager',
|
||||
agentNickname: '数字阶段',
|
||||
agentRole: '代码审查员',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'new-batch',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '新增工作流批次与项目视图交接 /Users/yoilun/Code/codex-agent-manager',
|
||||
agentNickname: '新增批次',
|
||||
agentRole: '前端开发者',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
|
||||
|
||||
assert.deepEqual(projectRuntime.agents.map((agent) => agent.id), ['full-project', 'numeric-repair', 'new-batch'])
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'full-project').phaseName, '优化阶段 1')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'full-project').workflowBatch, 'v1.1 优化修复')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'numeric-repair').workflowBatch, 'v1.0 初始搭建')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'new-batch').workflowBatch, 'v1.1 优化修复')
|
||||
assert.equal(projectRuntime.agents.find((agent) => agent.id === 'new-batch').phaseName, '优化阶段 1')
|
||||
})
|
||||
|
||||
test('does not turn ordinary conversation threads into agents', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'conversation',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '为什么点 yoilun 这个项目出来的都是对话的信息',
|
||||
preview: '普通用户对话,不是智能体',
|
||||
threadSource: 'user',
|
||||
role: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'reviewer',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '审查 /Users/yoilun 项目',
|
||||
agentNickname: 'Lorentz',
|
||||
agentRole: '代码审查员',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun')
|
||||
|
||||
assert.equal(runtime.threads.length, 2)
|
||||
assert.deepEqual(projectRuntime.agents.map((agent) => agent.id), ['reviewer'])
|
||||
assert.equal(projectRuntime.agents[0].name, 'Lorentz')
|
||||
assert.equal(projectRuntime.agents[0].process, '子智能体线程')
|
||||
assert.equal(projectRuntime.agents[0].goal, '审查 /Users/yoilun 项目')
|
||||
assert.equal(projectRuntime.agents[0].goalSource, '线程任务摘要')
|
||||
assert.doesNotMatch(projectRuntime.agents[0].process, /审查 \/Users\/yoilun/)
|
||||
})
|
||||
|
||||
test('builds project handoffs and phase groups from runtime edges and agent tasks', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'main',
|
||||
cwd: '/repo/a',
|
||||
title: '主控对话',
|
||||
threadSource: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'coder',
|
||||
cwd: '/repo/a',
|
||||
title: '阶段 6:实现安全写回',
|
||||
agentNickname: 'Averroes',
|
||||
agentRole: '后端架构师',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'reviewer',
|
||||
cwd: '/repo/a',
|
||||
title: '阶段 6:审查安全写回',
|
||||
agentNickname: 'Rawls',
|
||||
agentRole: '代码审查员',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'pm',
|
||||
cwd: '/repo/a',
|
||||
title: '阶段 7:整理最终文档',
|
||||
agentNickname: 'Zeno',
|
||||
agentRole: '高级项目经理',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'publisher',
|
||||
cwd: '/repo/a',
|
||||
title: '阶段 7:发布变更',
|
||||
agentNickname: 'Noether',
|
||||
agentRole: '发布经理',
|
||||
threadSource: 'subagent',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'other-main',
|
||||
cwd: '/repo/b',
|
||||
title: '其他项目主控对话',
|
||||
threadSource: 'user',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ fromThreadId: 'main', toThreadId: 'coder', reason: 'closed' },
|
||||
{ fromThreadId: 'coder', toThreadId: 'reviewer', reason: 'open' },
|
||||
{ fromThreadId: 'main', toThreadId: 'pm', reason: 'closed' },
|
||||
{ fromThreadId: 'reviewer', toThreadId: 'main', reason: 'closed' },
|
||||
{ fromThreadId: 'reviewer', toThreadId: 'publisher', reason: 'open' },
|
||||
{ fromThreadId: 'other-main', toThreadId: 'coder', reason: 'open' },
|
||||
],
|
||||
goals: [
|
||||
{ threadId: 'main', goal: '监管 /repo/a 项目内智能体流程', status: 'active', updatedAt: '2026-05-26T09:00:00Z' },
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/repo/a')
|
||||
|
||||
assert.deepEqual(projectRuntime.handoffs.map((handoff) => handoff.summary), [
|
||||
'已完成',
|
||||
'运行中',
|
||||
'已完成',
|
||||
'已完成',
|
||||
'运行中',
|
||||
])
|
||||
assert.deepEqual(projectRuntime.handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
|
||||
'主线程 / 主智能体监管 -> Averroes / 后端架构师',
|
||||
'Averroes / 后端架构师 -> Rawls / 代码审查员',
|
||||
'主线程 / 主智能体监管 -> Zeno / 高级项目经理',
|
||||
'Rawls / 代码审查员 -> 主线程 / 主智能体监管',
|
||||
'Rawls / 代码审查员 -> Noether / 发布经理',
|
||||
])
|
||||
assert.deepEqual(projectRuntime.handoffs.map((handoff) => handoff.directionLabel), [
|
||||
'主线程派发',
|
||||
'子智能体交接',
|
||||
'主线程派发',
|
||||
'回到主线程',
|
||||
'跨阶段交接',
|
||||
])
|
||||
assert.ok(projectRuntime.handoffs.every((handoff) => handoff.time === '后端事件'))
|
||||
assert.deepEqual(projectRuntime.phaseGroups.map((phase) => phase.name), ['阶段 6', '阶段 7'])
|
||||
assert.equal(projectRuntime.phaseGroups[0].status, 'running')
|
||||
assert.deepEqual(projectRuntime.phaseGroups[0].roles, ['代码审查员', '后端架构师'])
|
||||
assert.deepEqual(projectRuntime.phaseGroups[0].agents.map((agent) => agent.name), ['Averroes', 'Rawls'])
|
||||
assert.deepEqual(projectRuntime.phaseGroups[0].agents.map((agent) => agent.displayName), ['Averroes / 后端架构师', 'Rawls / 代码审查员'])
|
||||
assert.deepEqual(projectRuntime.phaseHandoffs.map((phase) => phase.name), ['阶段 6', '阶段 7'])
|
||||
assert.deepEqual(projectRuntime.phaseHandoffs[0].handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
|
||||
'主线程 / 主智能体监管 -> Averroes / 后端架构师',
|
||||
'Averroes / 后端架构师 -> Rawls / 代码审查员',
|
||||
'Rawls / 代码审查员 -> 主线程 / 主智能体监管',
|
||||
])
|
||||
assert.deepEqual(projectRuntime.phaseHandoffs[1].handoffs.map((handoff) => `${handoff.from} -> ${handoff.to}`), [
|
||||
'主线程 / 主智能体监管 -> Zeno / 高级项目经理',
|
||||
'Rawls / 代码审查员 -> Noether / 发布经理',
|
||||
])
|
||||
assert.equal(projectRuntime.supervision.actor, '主线程 / 主智能体监管')
|
||||
assert.equal(projectRuntime.supervision.goal, '监管 /repo/a 项目内智能体流程')
|
||||
assert.equal(projectRuntime.supervision.handoffCount, 5)
|
||||
assert.equal(projectRuntime.supervision.agentCount, 4)
|
||||
})
|
||||
|
||||
test('project and app views keep workflow inside the selected project view', async () => {
|
||||
const thisFile = fileURLToPath(import.meta.url)
|
||||
const viewPath = new URL('../views/ProjectView.vue', `file://${thisFile}`)
|
||||
const appPath = new URL('../App.vue', `file://${thisFile}`)
|
||||
const stylePath = new URL('../styles.css', `file://${thisFile}`)
|
||||
const viewSource = await readFile(viewPath, 'utf8')
|
||||
const appSource = await readFile(appPath, 'utf8')
|
||||
const styleSource = await readFile(stylePath, 'utf8')
|
||||
|
||||
assert.doesNotMatch(viewSource, /sampleProjects/)
|
||||
assert.doesNotMatch(viewSource, /sampleAgentMatrix/)
|
||||
assert.doesNotMatch(appSource, /WorkflowView/)
|
||||
assert.doesNotMatch(appSource, /工作流视图/)
|
||||
assert.match(viewSource, /selectedAgentId/)
|
||||
assert.match(viewSource, /selectAgent/)
|
||||
assert.match(viewSource, /selectedWorkflowBatchName/)
|
||||
assert.match(viewSource, /selectedWorkflowBatch/)
|
||||
assert.match(viewSource, /selectWorkflowBatch/)
|
||||
assert.match(viewSource, /批次切换/)
|
||||
assert.match(viewSource, /role="group"/)
|
||||
assert.match(viewSource, /selectedWorkflowBatch\?\.handoffCount \?\? 0/)
|
||||
assert.match(viewSource, /workflowBatches/)
|
||||
assert.match(viewSource, /工作流批次/)
|
||||
assert.match(viewSource, /supervision/)
|
||||
assert.match(viewSource, /goalSource/)
|
||||
assert.match(styleSource, /\.batch-tab:hover/)
|
||||
assert.match(styleSource, /\.batch-tab:focus-visible/)
|
||||
})
|
||||
|
||||
test('agent collaboration guide requires workflow batch metadata', async () => {
|
||||
const thisFile = fileURLToPath(import.meta.url)
|
||||
const agentGuidePath = new URL('../../../agent.md', `file://${thisFile}`)
|
||||
const source = await readFile(agentGuidePath, 'utf8')
|
||||
|
||||
assert.match(source, /工作流批次/)
|
||||
assert.match(source, /v1\.1 优化修复/)
|
||||
assert.match(source, /阶段: 优化阶段 1/)
|
||||
})
|
||||
|
||||
test('handoff timeline uses normalized id as stable key', async () => {
|
||||
const thisFile = fileURLToPath(import.meta.url)
|
||||
const componentPath = new URL('../components/HandoffTimeline.vue', `file://${thisFile}`)
|
||||
const source = await readFile(componentPath, 'utf8')
|
||||
|
||||
assert.match(source, /:key="item\.id \|\|/)
|
||||
})
|
||||
|
||||
test('keeps project threads when Codex cwd is an ancestor but metadata names the project path', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'thread-a',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '处理 /Users/yoilun/Code/codex-agent-manager 的项目状态',
|
||||
agentNickname: 'Nash',
|
||||
agentRole: '代码审查员',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'thread-b',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '处理其他项目',
|
||||
agentNickname: '其他线程',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
|
||||
|
||||
assert.equal(projectRuntime.agents.length, 1)
|
||||
assert.equal(projectRuntime.agents[0].id, 'thread-a')
|
||||
assert.equal(projectRuntime.agents[0].projectPath, '/Users/yoilun')
|
||||
})
|
||||
|
||||
test('does not match sibling projects that only share a path prefix', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{
|
||||
id: 'sibling',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '处理 /Users/yoilun/Code/codex-agent-manager-old 的项目状态',
|
||||
agentNickname: '同名前缀项目',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'target',
|
||||
cwd: '/Users/yoilun',
|
||||
title: '处理 /Users/yoilun/Code/codex-agent-manager 当前工作树',
|
||||
agentNickname: '目标项目',
|
||||
source: { kind: 'sqlite_table', confidence: 'high' },
|
||||
},
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const projectRuntime = filterRuntimeByProject(runtime, '/Users/yoilun/Code/codex-agent-manager')
|
||||
|
||||
assert.deepEqual(projectRuntime.agents.map((agent) => agent.id), ['target'])
|
||||
})
|
||||
|
||||
test('infers runtime status from Codex spawn edge state when thread status is absent', () => {
|
||||
const runtime = normalizeRuntime({
|
||||
items: [
|
||||
{ id: 'parent', cwd: '/repo/a', agentNickname: '主控', source: { kind: 'sqlite_table', confidence: 'high' } },
|
||||
{ id: 'child-open', cwd: '/repo/a', agentNickname: '运行审查员', source: { kind: 'sqlite_table', confidence: 'high' } },
|
||||
{ id: 'child-closed', cwd: '/repo/a', agentNickname: '已完成审查员', source: { kind: 'sqlite_table', confidence: 'high' } },
|
||||
],
|
||||
edges: [
|
||||
{ fromThreadId: 'parent', toThreadId: 'child-open', reason: 'open' },
|
||||
{ fromThreadId: 'parent', toThreadId: 'child-closed', reason: 'closed' },
|
||||
],
|
||||
source: { kind: 'sqlite_readonly', confidence: 'high' },
|
||||
})
|
||||
|
||||
const byId = new Map(runtime.agents.map((agent) => [agent.id, agent]))
|
||||
|
||||
assert.equal(byId.get('child-open').status, 'running')
|
||||
assert.equal(byId.get('child-open').statusZh, '运行中')
|
||||
assert.equal(byId.get('child-closed').status, 'complete')
|
||||
assert.equal(byId.get('child-closed').statusZh, '已完成')
|
||||
})
|
||||
|
||||
test('normalizes empty workflow with source evidence and no sample edges', () => {
|
||||
const workflow = normalizeWorkflow({
|
||||
items: [],
|
||||
|
||||
@@ -6,12 +6,12 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<ol class="handoff-timeline">
|
||||
<li v-for="item in items" :key="`${item.from}-${item.to}-${item.summary}`">
|
||||
<li v-for="item in items" :key="item.id || `${item.from}-${item.to}-${item.summary}`">
|
||||
<div class="timeline-marker" aria-hidden="true"></div>
|
||||
<div>
|
||||
<p class="timeline-title">{{ item.from }} → {{ item.to }}</p>
|
||||
<p>{{ item.summary }}</p>
|
||||
<span>{{ item.time }} · 来源 {{ item.source }} · 置信度 {{ item.confidence }}</span>
|
||||
<span>{{ item.directionLabel || '线程交接' }} · {{ item.time }} · 来源 {{ item.source }} · 置信度 {{ item.confidence }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -47,7 +47,7 @@ button {
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1440px, 100%);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
@@ -260,6 +260,69 @@ button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.project-flow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.batch-switcher {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.batch-tab {
|
||||
flex: 0 0 auto;
|
||||
min-width: 190px;
|
||||
max-width: 280px;
|
||||
padding: 10px 12px;
|
||||
color: var(--green);
|
||||
text-align: left;
|
||||
background: var(--panel-muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.batch-tab:hover {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 8px 20px rgb(36 139 107 / 12%);
|
||||
}
|
||||
|
||||
.batch-tab strong,
|
||||
.batch-tab span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.batch-tab span {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.batch-tab.active {
|
||||
color: var(--panel);
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.batch-tab.active span {
|
||||
color: color-mix(in srgb, var(--panel) 78%, white);
|
||||
}
|
||||
|
||||
.flow-section {
|
||||
min-width: 0;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.compact-heading {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.matrix-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1fr) minmax(170px, 1fr) minmax(150px, 0.8fr) minmax(130px, 0.7fr);
|
||||
@@ -278,6 +341,19 @@ button {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.matrix-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.matrix-row.clickable:hover,
|
||||
.matrix-row.clickable.selected {
|
||||
background: color-mix(in srgb, var(--green-soft) 62%, var(--panel));
|
||||
}
|
||||
|
||||
.matrix-row.clickable.selected {
|
||||
box-shadow: inset 4px 0 0 var(--green);
|
||||
}
|
||||
|
||||
.matrix-row > span {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
@@ -374,6 +450,13 @@ button {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.detail-block span {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
@@ -414,7 +497,9 @@ button {
|
||||
}
|
||||
|
||||
.project-item[role="button"]:focus-visible,
|
||||
.matrix-row[tabindex]:focus-visible,
|
||||
.agent-list-item:focus-visible,
|
||||
.batch-tab:focus-visible,
|
||||
.tab-button:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--terracotta) 48%, transparent);
|
||||
outline-offset: 2px;
|
||||
@@ -422,7 +507,7 @@ button {
|
||||
|
||||
.workflow-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||
grid-template-columns: minmax(720px, 1fr) 340px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -448,6 +533,14 @@ button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.phase-list.compact li {
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.phase-list.compact .status-badge {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.phase-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
@@ -518,6 +611,72 @@ button {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.phase-handoff-groups {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.workflow-batches {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.workflow-batch {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.workflow-batch:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.workflow-batch-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workflow-batch-heading span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.phase-handoff-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.phase-handoff-group:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.phase-handoff-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.phase-handoff-nested {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.muted-line {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.graph-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -562,7 +721,7 @@ button {
|
||||
|
||||
.agent-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 330px minmax(0, 1fr);
|
||||
grid-template-columns: 330px minmax(720px, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -721,12 +880,20 @@ button {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.drafts-layout,
|
||||
.drafts-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(720px, 1fr) 340px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.draft-list,
|
||||
@@ -830,7 +997,7 @@ button {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
@media (max-width: 1140px) {
|
||||
.workspace-header,
|
||||
.project-layout,
|
||||
.workflow-layout,
|
||||
@@ -871,6 +1038,7 @@ button {
|
||||
.panel-heading.horizontal,
|
||||
.draft-header,
|
||||
.setting-row,
|
||||
.project-flow,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -140,10 +140,10 @@ async function writeDraft() {
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section class="panel editor-panel" aria-label="智能体只读编辑区">
|
||||
<section class="panel editor-panel" aria-label="字段预览与草稿编辑区">
|
||||
<div class="panel-heading horizontal">
|
||||
<div>
|
||||
<p class="eyebrow">只读编辑区</p>
|
||||
<p class="eyebrow">字段预览与草稿编辑区</p>
|
||||
<h2>{{ selectedAgent?.name || '没有可显示的智能体' }}</h2>
|
||||
</div>
|
||||
<StatusBadge
|
||||
|
||||
@@ -1,21 +1,59 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import HandoffTimeline from '../components/HandoffTimeline.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import { apiClient } from '../api/client'
|
||||
import { normalizeProjects, normalizeRuntime } from '../api/normalizers'
|
||||
import { agentMatrix as sampleAgentMatrix, projects as sampleProjects } from '../data'
|
||||
import { filterRuntimeByProject, normalizeProjects, normalizeRuntime } from '../api/normalizers'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const projectState = ref(normalizeProjects())
|
||||
const runtimeState = ref(normalizeRuntime())
|
||||
const selectedProjectId = ref('')
|
||||
const selectedAgentId = ref('')
|
||||
const selectedWorkflowBatchName = ref('')
|
||||
|
||||
const selectedProject = computed(() => projectState.value.projects.find((project) => project.id === selectedProjectId.value))
|
||||
const selectedAgent = computed(() => runtimeState.value.agents[0])
|
||||
const projectRuntime = computed(() => filterRuntimeByProject(runtimeState.value, selectedProject.value?.path || ''))
|
||||
const selectedAgent = computed(() =>
|
||||
projectRuntime.value.agents.find((agent) => agent.id === selectedAgentId.value) ?? projectRuntime.value.agents[0],
|
||||
)
|
||||
const selectedWorkflowBatch = computed(() =>
|
||||
projectRuntime.value.workflowBatches.find((batch) => batch.name === selectedWorkflowBatchName.value) ?? projectRuntime.value.workflowBatches[0],
|
||||
)
|
||||
|
||||
onMounted(loadReadonlyData)
|
||||
|
||||
watch(
|
||||
() => projectRuntime.value.agents.map((agent) => agent.id).join('|'),
|
||||
() => {
|
||||
if (!projectRuntime.value.agents.some((agent) => agent.id === selectedAgentId.value)) {
|
||||
selectedAgentId.value = projectRuntime.value.agents[0]?.id ?? ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => projectRuntime.value.workflowBatches.map((batch) => batch.name).join('|'),
|
||||
() => {
|
||||
selectedWorkflowBatchName.value = projectRuntime.value.workflowBatches[0]?.name ?? ''
|
||||
},
|
||||
)
|
||||
|
||||
function selectProject(projectId) {
|
||||
selectedProjectId.value = projectId
|
||||
selectedAgentId.value = ''
|
||||
selectedWorkflowBatchName.value = ''
|
||||
}
|
||||
|
||||
function selectAgent(agent) {
|
||||
selectedAgentId.value = agent.id
|
||||
}
|
||||
|
||||
function selectWorkflowBatch(batch) {
|
||||
selectedWorkflowBatchName.value = batch.name
|
||||
}
|
||||
|
||||
async function loadReadonlyData() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -27,6 +65,8 @@ async function loadReadonlyData() {
|
||||
projectState.value = normalizeProjects(projectsPayload)
|
||||
runtimeState.value = normalizeRuntime(runtimePayload)
|
||||
selectedProjectId.value = projectState.value.projects[0]?.id ?? ''
|
||||
selectedAgentId.value = projectRuntime.value.agents[0]?.id ?? ''
|
||||
selectedWorkflowBatchName.value = projectRuntime.value.workflowBatches[0]?.name ?? ''
|
||||
} catch (err) {
|
||||
error.value = err?.message || '连接后端接口失败'
|
||||
} finally {
|
||||
@@ -59,8 +99,8 @@ async function loadReadonlyData() {
|
||||
:class="{ selected: project.id === selectedProjectId }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectedProjectId = project.id"
|
||||
@keyup.enter="selectedProjectId = project.id"
|
||||
@click="selectProject(project.id)"
|
||||
@keyup.enter="selectProject(project.id)"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ project.name }}</strong>
|
||||
@@ -79,16 +119,6 @@ async function loadReadonlyData() {
|
||||
</dl>
|
||||
</article>
|
||||
</template>
|
||||
<div v-if="error" class="sample-fallback">
|
||||
<p class="eyebrow">示例 / 等待连接</p>
|
||||
<article v-for="project in sampleProjects" :key="project.id" class="project-item">
|
||||
<div>
|
||||
<strong>示例:{{ project.name }}</strong>
|
||||
<span>{{ project.path }}</span>
|
||||
</div>
|
||||
<StatusBadge :label="project.statusZh" :status="project.status" source="示例数据" confidence="低" />
|
||||
</article>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="panel matrix-panel" aria-label="项目状态矩阵">
|
||||
@@ -103,15 +133,15 @@ async function loadReadonlyData() {
|
||||
<div v-if="loading" class="load-state">加载中</div>
|
||||
<div v-else-if="error" class="empty-state compact error-state">
|
||||
<strong>连接失败</strong>
|
||||
<p>无法读取 `/api/runtime/threads`,下面仅显示明确标注的示例状态。</p>
|
||||
<p>无法读取 `/api/runtime/threads`,当前不显示示例运行状态。</p>
|
||||
</div>
|
||||
<div v-else-if="runtimeState.isEmpty" class="empty-state compact">
|
||||
<strong>{{ runtimeState.emptyTitle }}</strong>
|
||||
<p>{{ runtimeState.emptyText }}</p>
|
||||
<div v-else-if="projectRuntime.isEmpty" class="empty-state compact">
|
||||
<strong>{{ projectRuntime.emptyTitle }}</strong>
|
||||
<p>{{ projectRuntime.emptyText }}</p>
|
||||
<StatusBadge label="空数据" status="unknown" :source="runtimeState.source.label" :confidence="runtimeState.source.confidenceLabel" />
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && !error && !runtimeState.isEmpty" class="matrix-table" role="table" aria-label="智能体状态矩阵">
|
||||
<div v-if="!loading && !error && !projectRuntime.isEmpty" class="matrix-table" role="table" aria-label="智能体状态矩阵">
|
||||
<div class="matrix-row head" role="row">
|
||||
<span role="columnheader">智能体</span>
|
||||
<span role="columnheader">状态</span>
|
||||
@@ -119,47 +149,118 @@ async function loadReadonlyData() {
|
||||
<span role="columnheader">进程</span>
|
||||
<span role="columnheader">最近活动</span>
|
||||
</div>
|
||||
<div v-for="agent in runtimeState.agents" :key="agent.id" class="matrix-row" role="row">
|
||||
<div
|
||||
v-for="agent in projectRuntime.agents"
|
||||
:key="agent.id"
|
||||
class="matrix-row clickable"
|
||||
:class="{ selected: agent.id === selectedAgentId }"
|
||||
role="row"
|
||||
tabindex="0"
|
||||
@click="selectAgent(agent)"
|
||||
@keyup.enter="selectAgent(agent)"
|
||||
>
|
||||
<span role="cell">
|
||||
<strong>{{ agent.name }}</strong>
|
||||
<small>{{ agent.role }}</small>
|
||||
<strong>{{ agent.displayName }}</strong>
|
||||
<small>{{ agent.workflowBatch }} / {{ agent.phaseName }}</small>
|
||||
</span>
|
||||
<span role="cell">
|
||||
<StatusBadge :label="agent.statusZh" :status="agent.status" :source="agent.source" :confidence="agent.confidence" />
|
||||
</span>
|
||||
<span role="cell">{{ agent.goal }}</span>
|
||||
<span role="cell">
|
||||
{{ agent.goal }}
|
||||
<small>{{ agent.goalSource }}</small>
|
||||
</span>
|
||||
<span role="cell">{{ agent.process }}</span>
|
||||
<span role="cell">{{ agent.lastActivity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="sample-fallback matrix-table" role="table" aria-label="示例智能体状态矩阵">
|
||||
<div class="matrix-row head" role="row">
|
||||
<span role="columnheader">示例智能体</span>
|
||||
<span role="columnheader">状态</span>
|
||||
<span role="columnheader">目标</span>
|
||||
<span role="columnheader">进程</span>
|
||||
<span role="columnheader">最近活动</span>
|
||||
<div v-if="!loading && !error && !projectRuntime.isEmpty" class="project-flow">
|
||||
<div class="batch-switcher" role="group" aria-label="批次切换">
|
||||
<button
|
||||
v-for="batch in projectRuntime.workflowBatches"
|
||||
:key="batch.name"
|
||||
type="button"
|
||||
class="batch-tab"
|
||||
:class="{ active: batch.name === selectedWorkflowBatch?.name }"
|
||||
:aria-pressed="batch.name === selectedWorkflowBatch?.name"
|
||||
@click="selectWorkflowBatch(batch)"
|
||||
>
|
||||
<strong>{{ batch.name }}</strong>
|
||||
<span>{{ batch.phaseCount }} 阶段 · {{ batch.handoffCount }} 交接</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="agent in sampleAgentMatrix" :key="agent.id" class="matrix-row" role="row">
|
||||
<span role="cell">
|
||||
<strong>示例:{{ agent.name }}</strong>
|
||||
<small>{{ agent.role }}</small>
|
||||
</span>
|
||||
<span role="cell">
|
||||
<StatusBadge :label="agent.statusZh" :status="agent.status" source="示例数据" confidence="低" />
|
||||
</span>
|
||||
<span role="cell">{{ agent.goal }}</span>
|
||||
<span role="cell">{{ agent.process }}</span>
|
||||
<span role="cell">{{ agent.lastActivity }}</span>
|
||||
|
||||
<div class="flow-section">
|
||||
<div class="panel-heading horizontal compact-heading">
|
||||
<div>
|
||||
<p class="eyebrow">阶段进度</p>
|
||||
<h3>工作流批次与阶段</h3>
|
||||
</div>
|
||||
<span class="read-only-chip">{{ projectRuntime.workflowBatches.length }} 个批次</span>
|
||||
</div>
|
||||
<div v-if="selectedWorkflowBatch" class="workflow-batches">
|
||||
<section class="workflow-batch">
|
||||
<div class="workflow-batch-heading">
|
||||
<div>
|
||||
<strong>{{ selectedWorkflowBatch.name }}</strong>
|
||||
<span>{{ selectedWorkflowBatch.phaseCount }} 个阶段 · {{ selectedWorkflowBatch.agentCount }} 个智能体 · {{ selectedWorkflowBatch.handoffCount }} 条交接</span>
|
||||
</div>
|
||||
<StatusBadge :label="selectedWorkflowBatch.statusZh" :status="selectedWorkflowBatch.status" source="SQLite 表" confidence="高" />
|
||||
</div>
|
||||
<ol class="phase-list compact">
|
||||
<li v-for="phase in selectedWorkflowBatch.phases" :key="`${selectedWorkflowBatch.name}-${phase.name}`" :data-status="phase.status">
|
||||
<div class="phase-dot" aria-hidden="true"></div>
|
||||
<div>
|
||||
<strong>{{ phase.name }}</strong>
|
||||
<p>{{ phase.roles.join('、') || '未记录角色分类' }}</p>
|
||||
<span>{{ phase.agents.map((agent) => agent.displayName).join('、') }}</span>
|
||||
</div>
|
||||
<StatusBadge :label="phase.statusZh" :status="phase.status" source="SQLite 表" confidence="高" />
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">
|
||||
<strong>没有阶段归属</strong>
|
||||
<p>当前项目智能体线程没有可解析的阶段标记。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-section">
|
||||
<div class="panel-heading horizontal compact-heading">
|
||||
<div>
|
||||
<p class="eyebrow">交互方向</p>
|
||||
<h3>项目内智能体流程记录</h3>
|
||||
</div>
|
||||
<span class="read-only-chip">{{ selectedWorkflowBatch?.handoffCount ?? 0 }} 条交接</span>
|
||||
</div>
|
||||
<div v-if="selectedWorkflowBatch" class="phase-handoff-groups">
|
||||
<section class="phase-handoff-group">
|
||||
<div class="phase-handoff-heading">
|
||||
<strong>{{ selectedWorkflowBatch.name }}</strong>
|
||||
<StatusBadge :label="selectedWorkflowBatch.statusZh" :status="selectedWorkflowBatch.status" source="SQLite 表" confidence="高" />
|
||||
</div>
|
||||
<section v-for="phase in selectedWorkflowBatch.phases" :key="`${selectedWorkflowBatch.name}-${phase.name}`" class="phase-handoff-nested">
|
||||
<strong>{{ phase.name }}</strong>
|
||||
<HandoffTimeline v-if="phase.handoffs.length > 0" :items="phase.handoffs" />
|
||||
<p v-else class="muted-line">这个阶段暂时没有交接边。</p>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">
|
||||
<strong>没有交接记录</strong>
|
||||
<p>当前项目没有匹配到主线程与智能体之间的交接边。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<aside class="panel detail-panel" aria-label="详情面板">
|
||||
<div class="panel-heading">
|
||||
<p class="eyebrow">详情</p>
|
||||
<h2>{{ selectedAgent?.name || selectedProject?.name || '只读详情' }}</h2>
|
||||
<h2>{{ selectedAgent?.displayName || selectedProject?.name || '只读详情' }}</h2>
|
||||
</div>
|
||||
<StatusBadge
|
||||
:label="selectedAgent?.statusZh || selectedProject?.statusZh || (error ? '连接失败' : '等待数据')"
|
||||
@@ -170,14 +271,22 @@ async function loadReadonlyData() {
|
||||
<div class="detail-block">
|
||||
<h3>角色摘要</h3>
|
||||
<p>{{ selectedAgent?.goal || '选择项目或等待运行线程后显示真实目标。' }}</p>
|
||||
<span v-if="selectedAgent?.goalSource">{{ selectedAgent.goalSource }}</span>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<h3>主智能体监管</h3>
|
||||
<p>{{ projectRuntime.supervision.actor }}:{{ projectRuntime.supervision.goal }}</p>
|
||||
<span>{{ projectRuntime.supervision.statusZh }} · 最近活动 {{ projectRuntime.supervision.lastActivity }}</span>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<h3>证据说明</h3>
|
||||
<p>数据来自只读接口。连接失败时只显示明确标注的示例,不会把示例伪装成真实状态。</p>
|
||||
<p>数据来自只读接口。连接失败时保持错误状态,不使用示例数据伪装真实状态。</p>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<span>项目数</span><strong>{{ projectState.projects.length }}</strong>
|
||||
<span>线程数</span><strong>{{ runtimeState.threads.length }}</strong>
|
||||
<span>当前项目线程数</span><strong>{{ projectRuntime.threads.length }}</strong>
|
||||
<span>子智能体数</span><strong>{{ projectRuntime.supervision.agentCount }}</strong>
|
||||
<span>交接记录</span><strong>{{ projectRuntime.supervision.handoffCount }}</strong>
|
||||
<span>接口</span><strong>{{ error ? '连接失败' : '只读连接' }}</strong>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -9,9 +9,9 @@ import { settings } from '../data'
|
||||
<div class="panel-heading horizontal">
|
||||
<div>
|
||||
<p class="eyebrow">设置</p>
|
||||
<h2>只读配置摘要</h2>
|
||||
<h2>安全配置摘要</h2>
|
||||
</div>
|
||||
<StatusBadge label="接口只读" status="complete" source="计划文件" confidence="中" />
|
||||
<StatusBadge label="配置受控" status="complete" source="计划文件" confidence="中" />
|
||||
</div>
|
||||
|
||||
<div class="settings-list">
|
||||
@@ -29,13 +29,14 @@ import { settings } from '../data'
|
||||
<aside class="panel">
|
||||
<div class="panel-heading">
|
||||
<p class="eyebrow">安全边界</p>
|
||||
<h2>本阶段不会写回</h2>
|
||||
<h2>仅校验后单文件写回</h2>
|
||||
</div>
|
||||
<ul class="safety-list">
|
||||
<li>不读取或展示 `.codex/auth.json`。</li>
|
||||
<li>不写入 Codex SQLite。</li>
|
||||
<li>不保存智能体 TOML。</li>
|
||||
<li>真实数据通过本阶段只读接口展示。</li>
|
||||
<li>智能体 TOML 仅在校验、备份和确认后写回当前文件。</li>
|
||||
<li>不自动保存或批量写回。</li>
|
||||
<li>项目、运行线程和工作流仍通过只读接口展示。</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user