merge: fix/remove-control-center

This commit is contained in:
weichen
2026-09-19 03:42:23 +00:00
112 changed files with 181 additions and 13558 deletions
@@ -7,7 +7,7 @@ product_contract_source: ce-plan-bootstrap
execution: code
---
> **已废弃(2026-09-17)**:旧版只读工程中控台(`cmd/engineering-dashboard` / `internal/engineeringdashboard`)代码已移除,由独立的可写中控台 Control Center 承接(见 `02-control-center-design.md`)。本文仅作历史证据保留。
> **已废弃(2026-09-17;2026-09-19 更新)**:旧版只读工程中控台代码已移除。后来短暂合入的 Control Center 也已移出本仓库,进度管理由独立项目承担。本文仅作历史证据保留。
# 个人工程中控台:实时项目看板与离线 HTML 快照
@@ -49,7 +49,7 @@ mutex: engineering-dashboard
## 相关链接
- 设计与实施方案:[../../system-design/project-control-center/01-personal-engineering-control-center-plan.md](../../system-design/project-control-center/01-personal-engineering-control-center-plan.md)
- 设计与实施方案:[../../archive/design/2026-09-17-personal-engineering-control-center-plan.md](../../archive/design/2026-09-17-personal-engineering-control-center-plan.md)
- Test report: [../../test-reports/2026-09-17-engineering-dashboard-test-report.md](../../test-reports/2026-09-17-engineering-dashboard-test-report.md)
## 证据 / 备注
+4 -4
View File
@@ -21,7 +21,7 @@ severity: high
| # | 任务 | 位置 | 剩余工作 |
|---|------|------|----------|
| T1 | partial/deferred 全量收口总控 | `.doc/ops/short-term/2026-09-16-partial-deferred-completion.md`(owner Cline) | W5(SWBOOT-10 真实现 + 真实 PI + UI 二次验收)、W6(FAV shadow→live 放量)、W7(D7 按 ADR 整改,待合规签字)、逐行升 done |
| T2 | Control Center 个人中控台 | `.doc/ops/short-term/2026-09-17-control-center-server.md`(owner Kimi) | 后端 P0–P6 已签;**唯一剩余:前端 SPA 七页人工浏览器走查** |
| T2 | Control Center 个人中控台 | 2026-09-19 已移出本仓库 | 进度管理改由独立项目承担;本仓库不再保留该模块 |
| T3 | 纯软件项目自举 W5b | `.doc/ops/long-term/2026-09-14-software-project-bootstrap-implementation.md` | SWBOOT-10 受控执行器(真 patch/merge)未启动,需独立安全评审,当前 fail-closed stub |
### 1.2 已规划未开始
@@ -40,7 +40,7 @@ severity: high
|----|--------------|
| 纯软件项目自举 | SWBOOT-10 非 stub(W5b)+ 真实 PI 全链路 + UI 二次验收 |
| FAV | W6 shadow 已合;live 未放量(staging→artifact→publication 各 ≥7 天观察窗) |
| Control Center | 前端人工走查 PASS 后升 done |
| Control Center | 2026-09-19 已移出本仓库,不再作为本仓库关闭条件 |
| 首次加载性能 | U1/U3–U7 |
| 文档可靠性试点 | M1 结论「停止扩面」;M2(U5–U12)明确禁止开工,真实 PI/浏览器验收未勾 |
@@ -89,7 +89,7 @@ severity: high
| ID | 描述 | 证据 |
|----|------|------|
| R-1 | **聊天会话无归属校验**:`getOrCreateSession` 仅凭客户端传入 `session_id` 返回既有内存会话,随后直接覆盖 `sess.UserID`;非项目会话(ProjectID==0)无任何授权关卡,内存 Messages/Facts 可进入他人 Prompt 与 SSE。缓解:session ID 为纳秒时间戳不易猜 | `internal/server/server.go:394-415`、`handlers.go:181`、`sse.go:187`、`stage_access_helpers.go:251-253` |
| R-2 | **18 个非测试文件 `rows.Next()` 循环后未查 `rows.Err()`**,中途断连静默截断结果集(ctxmgr 历史/窗口/搜索、docgov engine、freedoc、storage/cas、controlcenter tasks、`cmd/document-content-reconcile` 漏检会让 reconcile 漏报文档) | 如 `internal/ctxmgr/chat_history.go`、`internal/docgov/engine.go`、`cmd/document-content-reconcile/main.go:82` |
| R-2 | **18 个非测试文件 `rows.Next()` 循环后未查 `rows.Err()`**,中途断连静默截断结果集(ctxmgr 历史/窗口/搜索、docgov engine、freedoc、storage/cas、`cmd/document-content-reconcile` 漏检会让 reconcile 漏报文档) | 如 `internal/ctxmgr/chat_history.go`、`internal/docgov/engine.go`、`cmd/document-content-reconcile/main.go:82` |
| R-3 | 事件 dispatcher `Handlers` map **写持锁、读不持锁**(当前仅启动期注册则良性,动态注册即 race) | `internal/events/worker.go:87,154,234` |
| R-4 | `memTurns` 全局幂等表只增不删且跨实例共享,长期运行内存缓增、测试间可能串扰 | `internal/ctxmgr/chat_turns.go:169` |
| R-5 | `internal/docrevision/pg_store.go:68-208` 十余处 `_ = json.Unmarshal(...)`,账本 JSONB 损坏时静默零值化 | 同左 |
@@ -147,7 +147,7 @@ severity: high
|---|------|------|
| S-1 | `TestReconcile_LegacyRepair_PG` 从未执行 | 要求隔离 PG 且硬拒绝 5433/5434/5435;canonical-hash 修复链路零自动化验证(gap-matrix 已记测试债) |
| S-2 | 两个「永久跳过」 | `taskstream_test.go:189`(功能已删,测试成死代码应删除);`plan_owner_test.go:125`(重定向跳过) |
| S-3 | 无测试的 cmd 入口 | `cmd/server`、`cmd/events`、`cmd/control-center`、`cmd/artifact-vault`、`cmd/dataset`、`cmd/eventmode`、`cmd/recategorize`、`cmd/document-revision-backfill`(仅 bizdev-cli、document-content-reconcile 有);`internal/controlcenter/{config,store,tasks,webui}`、`internal/observability`、`internal/workflow` 无测试文件 |
| S-3 | 无测试的 cmd 入口 | `cmd/server`、`cmd/events`、`cmd/artifact-vault`、`cmd/dataset`、`cmd/eventmode`、`cmd/recategorize`、`cmd/document-revision-backfill`(仅 bizdev-cli、document-content-reconcile 有);`internal/observability`、`internal/workflow` 无测试文件 |
| S-4 | **E2E 默认全不跑**:web/e2e 9 个 spec 全部被环境门禁(`RUN_UI_ACCEPTANCE=1`/`DOCREV_E2E_BASE_URL`/`INTENT_DUAL_TAB=1`),默认 `playwright test` 实跑 0 用例;`test:ui-acceptance` script 只覆盖 1 个 spec,其余 8 个无 script 入口 | web/e2e/、web/package.json:11 |
| S-5 | 409 信封处理只覆盖部分消费方:projectMembers、freeformDoc、createTaskRun、chatSessions upsert 等 409 只能裸报错(与 FEATURE-MATRIX hardening backlog 一致) | `web/src/api/projectMembers.ts`、`freeformDoc.ts:177`、`taskRuns.ts`、`chatSessions.ts` |
| S-6 | 前端 bundle 大 chunk 未分割(mermaid/katex/xlsx/docx 各成 >500 kB chunk,已有 precompress 缓解) | vite build 警告 |
+4 -3
View File
@@ -6,7 +6,7 @@
## 焦点
1. **系统体检整改**:Wave 0–7 **done**(Wave 7 编排确认见 [决策记录](./short-term/2026-09-18-wave7-roadmap-orchestration.md))
2. **已裁决(supersede,done)**:`feature/control-center-server` 取代 `engineering-dashboard`,门禁已过,随本分支合入本地 `dev`。见 [任务](./short-term/2026-09-17-control-center-server.md)、[报告](../test-reports/2026-09-18-control-center-merge-gate-test-report.md)
2. **Control Center 已移出本仓库**:项目进度由独立统一管理系统承担;本仓库恢复为智能体系统。见 [任务](./short-term/2026-09-19-remove-control-center.md)
3. **rdev 模板深度 / document-author-only**:已合入 `origin/dev`;未晋升 test/main
4. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 待合规签字
@@ -14,7 +14,8 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor-Auto | 2026-09-17-control-center-server | feature/control-center-server | - | controlcenter | done |
| Cursor-Auto | 2026-09-19-remove-control-center | fix/remove-control-center | - | controlcenter | done |
| Cursor-Auto | 2026-09-17-control-center-server | feature/control-center-server | - | controlcenter | cancelled |
| Kimi | 2026-09-18-user-manual | feature/user-manual | .worktrees/feature-user-manual | docs | done |
| Cursor-Auto | 2026-09-18-wave7-roadmap-orchestration | docs/wave7-roadmap-orchestration | - | roadmap-orchestration | done |
| Cursor-Auto | 2026-09-18-wave6-doc-drift | docs/wave6-doc-drift | - | doc-drift | done |
@@ -143,7 +144,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| - | idle | 2026-09-19 | feature/control-center-server 取代 engineering-dashboard,门禁(单测 / 真实 LLM / 七页走查)PASS 后合入本地 dev | - |
| - | idle | 2026-09-19 | fix/remove-control-center 已删除 Control Center;合入后 dev 健康检查见测试报告 | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -1,76 +0,0 @@
---
title: "个人项目中控台 Control Center:独立 Go 服务 + 8 阶段流水线 + 向量文档库 + agent 协作 API"
status: done
created: 2026-09-17
updated: 2026-09-19(合并门禁:单测、真实 LLM 审查、七页走查)
owner: Kimi
branch: feature/control-center-server
worktree: "-"
mutex: controlcenter
---
# 个人项目中控台 Control Center
## 目标
交付独立于产品系统的个人项目管理中控台 Go 服务:任务/提案 8 阶段流水线(提案→分析→方案制定→方案评审→实施→测试→评价→确认)、agent 认领/进度/产物/决策上报 API(agent 禁止自动推进阶段)、`.doc` 文档 + 阶段产物入 pgvector 向量库支持模糊查询、阶段级模型指定、外部工具阶段提交、每日定时仓库审查与 findings 展示、Vite+React SPA 前端。
## 范围
- `cmd/control-center/` + `internal/controlcenter/{config,server,store,pipeline,tasks,docs,importer,audit,llm,embed}`
- `deploy/control-center/`(独立 pgvector 容器 + goose 迁移)
- `control-center/web/`(Vite+React SPA,embed 进二进制)
- 复用系统 `.env`:EMBEDDING_* / MINIMAX_* / CURSOR_*(仅读配置,不耦合产品代码路径)
- 设计文档、agent 接入规范、FEATURE-MATRIX 行、验收报告
## 非目标
- 不接产品 sysadmin、不动 internal/server 路由
- 不做多仓库、不做远端 CI 集成
- 旧 `internal/engineeringdashboard`:用户已授权移除(2026-09-17 执行:代码删除,设计文档归档 `.doc/archive/design/`,验收报告保留为历史证据)
- 不做 agent 自动推进阶段(任何推进必须人工批准)
## 步骤
1. [x] P0 骨架:cmd/internal/compose/goose/healthz/token 鉴权
2. [x] P1 流水线核心:8 阶段状态机、门禁、decision 上报/裁决、阶段产物、claim/lease/事件流、阶段指令包、SSE
3. [x] P2 文档向量库:扫描入库、切片、embedding、hybrid 检索、阶段产物向量化
4. [x] P3 前端 SPA:总览/阶段看板/任务详情 stepper/决策收件箱/文档搜索/设置
5. [x] P4 .doc 导入同步 + agent CLI 子命令 + 外部提交入口
6. [x] P5 定时审查:调度器、确定性检查、LLM chat client、findings 转任务
7. [x] P6 文档收口 + 验收报告
## 验收标准
- [x] `go vet ./...` / `go test -short ./...` / `go build ./cmd/control-center` 全绿
- [x] 状态机单测:agent 直接推进被拒、decision 缺建议拒收、lease 过期孤儿恢复
- [x] `.doc` 全量入库后语义搜索命中已知文档
- [x] curl 冒烟:建任务→agent claim→产物→advance-request→人工批准→下一阶段 全链路
- [x] 前端 `vite build` 通过并 embed 可用
## 相关链接
- 设计: [.doc/system-design/project-control-center/02-control-center-design.md](../../system-design/project-control-center/02-control-center-design.md)
- Agent 接入规范: [.doc/system-design/project-control-center/agent-integration.md](../../system-design/project-control-center/agent-integration.md)
- Checklist:
- Handoff:
- Test report: [.doc/test-reports/2026-09-17-control-center-test-report.md](../../test-reports/2026-09-17-control-center-test-report.md)
## 证据 / 备注
计划经用户批准(存储=独立 pgvector 容器;前端=Vite+React SPA embed;旧台共存后弃用;审查 LLM=薄 OpenAI 兼容客户端)。
commit 列表(cf805a7..HEAD):
```text
07a8e12 feat(control-center): P5 scheduled repo audits — deterministic checks, LLM review, findings UI
f79956c feat(control-center): P4 .doc importer, agent CLI, task doc export
b0095de feat(control-center): P3 web SPA — overview, stage kanban, task pipeline, decisions, docs search
63f1fa5 feat(control-center): P2 document vector store — ingestion, chunking, embedding, hybrid search
b11cc64 feat(control-center): P1 pipeline core — stage machine, decisions, artifacts, leases, SSE
2be43ef feat(control-center): P0 skeleton — standalone server, pgvector compose, goose schema, token auth
```
P6 冒烟发现并修复:`GET /api/v1/tasks?q=` 500(ListTasks Query 分支占位符 bug),修复 + 回归测试 `TestTaskListQueryFilter`,详见测试报告 §5.5。
**status: done**(2026-09-19)。合并门禁已过:`config`/`store`/`tasks` 单测、真实 MiniMax 审查 findings 落库(run 195,`CC-GATE-LIVE`)、七页浏览器走查。证据见 [.doc/test-reports/2026-09-18-control-center-merge-gate-test-report.md](../../test-reports/2026-09-18-control-center-merge-gate-test-report.md)。
@@ -26,7 +26,7 @@ mutex: docs
- 不合入 dev、不 push(用户未要求)
- 不改动任何产品代码
- 不覆盖 control-center/web(空壳)与 engineering-dashboard(工程内部)
- 不覆盖产品手册范围外的工程内部工具(`engineering-dashboard` 当时为工程内部,其后已移出本仓库)
## 步骤
@@ -77,6 +77,7 @@ mutex: roadmap-orchestration
- **关闭条件**:FEATURE-MATRIX 该行**已经 done**(通过 engineering-dashboard)。若要转向「复活/合并 Kimi 实现」,需先有独立决策与重新规划,不属于「仅剩一次前端走查」。
- **是否需用户授权**:**是**。授权对象是「两套实现如何取舍」,不是「批准做前端走查」。本波只读编排确认,不能代为废弃或合并该分支。
- **建议下一触发点**:请用户明确二选一:(a) 维持现状——`engineering-dashboard` 为唯一 canonical 实现,`feature/control-center-server` 由用户决定归档或保留(推荐:已 done 且范围经批准);或 (b) 要求评估合并该分支的增量能力(SPA / pgvector / 定期审计)。触发后再开对应短期任务。本波只记录事实与建议,**不合并、不删除**该分支。
- **后续裁决(2026-09-19)**:用户决定进度管理由独立项目承担。本仓库已删除 Control Center 代码与文档,不恢复 `engineering-dashboard`。见 [移除任务](./2026-09-19-remove-control-center.md)。
### T4:首次加载性能 U1/U3–U7
@@ -93,7 +94,7 @@ mutex: roadmap-orchestration
## 新发现(需用户后续裁决,不阻塞本波收口)
- **Control Center 双实现冲突**:`feature/control-center-server`(Kimi,P0–P6 SPA)未合入 `dev`。相对 `dev@a88ed85` 落后 136 commits(merge-base `adb48c8`),自身 11 commits,其中 `b5bacee` 删除已合入的 `engineering-dashboard`。建议维持 `engineering-dashboard` 为唯一 canonical 实现;是否归档或评估合并该分支,待用户裁决。本波不删除、不合并该分支。
- **Control Center 双实现冲突**:当时未合入。2026-09-19 用户裁决为移出本仓库,代码与专属文档已删除,不恢复旧工程中控台。见 [移除任务](./2026-09-19-remove-control-center.md)。
## 证据 / 备注
@@ -0,0 +1,53 @@
---
title: "移除 Control Center,本仓库回到智能体系统"
status: done
created: 2026-09-19
updated: 2026-09-19
owner: Cursor-Auto
branch: fix/remove-control-center
worktree: "-"
mutex: controlcenter
---
# 移除 Control Center
## 目标
项目进度管理已由独立项目承担。从本仓库删除 Control Center 的代码、部署、规范和专属文档,不恢复旧 `engineering-dashboard`。
## 范围
- 删除 `cmd/control-center`、`internal/controlcenter`、`control-center/web`、`deploy/control-center`、`scripts/control-center.sh`
- 删除专属设计、任务、测试报告与截图
- 更新 AGENTS、FEATURE-MATRIX、CURRENT、模块边界与断链引用
- 停掉 `control-center-db` 与 `127.0.0.1:8790`
## 非目标
- 不恢复 `engineering-dashboard`
- 不 push
- 不改产品 `internal/server`、产品前端或三环境 compose
## 步骤
1. [x] 从本地 dev 开 `fix/remove-control-center`
2. [x] 删除代码与文档并更新引用
3. [x] 停运行时
4. [x] `go vet` / `go test -short` / 产品前端 build / archtest
5. [ ] 合入本地 dev 并做健康检查
## 验收标准
- [x] 仓库内不再有 Control Center 源码或专属文档
- [x] `go vet ./...`、`go test -short ./...`、`go test ./internal/archtest/`、`web` 的 `npm run build` 通过
- [ ] 本地 dev 健康检查 200
## 相关链接
- Checklist:
- Handoff:
- Test report: [../../test-reports/2026-09-19-remove-control-center-test-report.md](../../test-reports/2026-09-19-remove-control-center-test-report.md)
## 证据 / 备注
用户确认:只移除 Control Center,副本已在别处实现,合入本地 dev。
@@ -34,7 +34,7 @@ updated: 2026-09-18
| **Wave 4:隐患加固(可与 Wave 0–3 并行认领,mutex 不冲突时)** | R-1:`getOrCreateSession` 增 owner 绑定校验(session 创建时记录 owner,后续请求校验,不匹配 403)。R-2:18 处 `rows.Next()` 循环补 `rows.Err()` 检查。R-3:dispatcher Handlers 读路径加 RLock(或注释冻结「仅启动期注册」并加 archtest 约束)。R-4:`memTurns` 加 TTL/容量淘汰。R-5:pg_store JSONB 反序列化错误改为显式报错。R-6:后台 ticker 纳入 Shutdown;vault Drain 错误上报告警。R-7/R-8:SSE 重连 401/404 fail-fast、逐帧容错;登出 abort 在途流 | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md);Memory+PG 跨用户负例 PASS;`go test -short` / `npm test` 全绿 |
| **Wave 5:死代码与机制收口(需逐项决策,不擅自删)** | X-1:删除 `SEMANTIC_FALLBACK_ENABLED`。X-2:`SEMANTIC_SUMMARY_REPLACE` 维持未接线并封存注释。X-3:00048 三表 deferred → SWBOOT-10/W5b。X-4:system_settings 6 键保留并注释二期。X-5:删除 DocumentNotificationList、WorkflowPrerequisiteBanner、SettingsModal、activateWorkflow、saveMessages 及级联 patchNotification | **done**(2026-09-18):[决策](../ops/short-term/2026-09-18-wave5-deadcode-decisions.md) · [test-report](../test-reports/2026-09-18-wave5-deadcode-decisions-test-report.md) |
| **Wave 6:文档漂移修复** | D-1:PU8 四份契约文件状态改 done 并归档。D-2:modular-monolith ADR 状态更新为 accepted。D-3:两个 INDEX 末行已在本审计修正(复核即可)。D-4:handoff 回标已被覆盖。D-5:backlog D3/D7 同步最新状态。D-6:PROJECT.md §9「剩余 blocker」段与 gap-matrix 对齐或删除 | **done**(2026-09-18):[short-term](../ops/short-term/2026-09-18-wave6-doc-drift.md) · [test-report](../test-reports/2026-09-18-wave6-doc-drift-test-report.md);D-3 INDEX 复核 PASS、零改动 |
| **Wave 7:既有 roadmap 收口(不改优先级,仅确认编排)** | T1/W5:SWBOOT-10 安全评审后实施(保持 fail-closed 直至授权)。T1/W6:FAV live 放量 staging→artifact→publication,每级 ≥7 天观察窗,`FAV_DEGRADED_READ=false` 不动。T1/W7:D7 待合规签字后按 ADR 整改。T2:Control Center 前端 SPA 七页人工走查。T4:首次加载性能 U1/U3–U7。T5/T6/T8:backlog 类按收益触发 | **done**(2026-09-18):[决策记录](../ops/short-term/2026-09-18-wave7-roadmap-orchestration.md)。六项编排确认已记录;T2 发现审计结论过期(canonical 实现为 engineering-dashboard,已 done;`feature/control-center-server` 未合入,相对 `dev@a88ed85` 落后 136 commits,待用户裁决);FAV/D7/tier 升级红线未动 |
| **Wave 7:既有 roadmap 收口(不改优先级,仅确认编排)** | T1/W5:SWBOOT-10 安全评审后实施(保持 fail-closed 直至授权)。T1/W6:FAV live 放量 staging→artifact→publication,每级 ≥7 天观察窗,`FAV_DEGRADED_READ=false` 不动。T1/W7:D7 待合规签字后按 ADR 整改。T4:首次加载性能 U1/U3–U7。T5/T6/T8:backlog 类按收益触发。T2(Control Center)已于 2026-09-19 移出本仓库 | **done**(2026-09-18):[决策记录](../ops/short-term/2026-09-18-wave7-roadmap-orchestration.md)。六项编排确认已记录;T2 后来由用户裁决为独立项目,本仓库不再保留 Control Center;FAV/D7/tier 升级红线未动 |
## 验收标准
+1 -2
View File
@@ -69,8 +69,7 @@
| 可靠事件核心 | PG dispatcher、notify/advance/projection live、生产闭包 hook 退出、Phase2 选择性解耦已合入 | done | non_blocker | [E0A–E2](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md) 等;[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[shared live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[phase2 closeout](../test-reports/2026-09-15-phase2-closeout-test-report.md) | **before**:与 FAV off 混成 partial → **after**:FAV 独立行。选择性后续解耦按收益触发 | `architecture-docs` / `events` |
| Final Artifact Vault(FAV) | 不可变终态制品库;`FAV_DEGRADED_READ=false` | partial | deferred_enhancement | [Track E 复盘](../ops/research/2026-09-15-track-e-retrospective.md);[U7 报告](../test-reports/2026-09-16-fav-u7-incremental-test-report.md);[W6 shadow](../test-reports/2026-09-17-w6-fav-subscriber-shadow-test-report.md);[short-term](../ops/short-term/2026-09-17-w6-fav-subscriber-shadow.md) | **after W6**:subscriber `ObserveShadow` 只计算 intended outcome,不写副作用。仍**禁止**整支 merge;`FAV_DEGRADED_READ` 默认 false;**live 未放量**(staging→artifact→publication 各 ≥7 天观察窗) | `events` |
| 系统管理台 | 全屏系统级管理视图(总览/KB/特性开关/系统设置/工作流/角色/用户/文档 8 分组);`/api/v1/admin/system/*`;`system_settings` 白名单可写;吸收下线 AdminSettingsModal | done | non_blocker | [short-term](../ops/short-term/2026-09-17-system-admin-console.md);[测试报告](../test-reports/2026-09-17-system-admin-console-test-report.md);commit `f1404d5`/`0eaca61`、merge `dc7c31e`(dev);dev 冒烟 schema 49 + 四端点 + 写读回环 PASS | 二期:Playwright 实跑、细粒度 `system:*` 权限、设置审计日志、KB 运维动作、白名单项接消费方 | `web` + `sysadmin` |
| 个人工程中控台(旧,已移除) | 原独立本地只读 CLI/`serve`/`export`;四轴状态;`.doc`/Git/Docker/`/ready` 聚合 | removed | 已被 Control Center 取代 | [设计(已归档)](../archive/design/2026-09-17-personal-engineering-control-center-plan.md);[报告](../test-reports/2026-09-17-engineering-dashboard-test-report.md) | 2026-09-17 代码移除(`cmd/engineering-dashboard`、`internal/engineeringdashboard`、`scripts/engineering-dashboard.sh`);能力由下一行 Control Center 承接 | `engineering-dashboard`(已删) |
| 个人中控台 Control Center | 独立 Go 服务(loopback,独立 pgvector 库):8 阶段流水线 + agent 协作 API(禁自动推进)+ 决策上报 + 租约/SSE + `.doc` 向量检索 + 导入导出 + 每日审查 + React SPA | done | deferred_enhancement(个人工具,非产品门禁) | [设计](../system-design/project-control-center/02-control-center-design.md);[agent 接入规范](../system-design/project-control-center/agent-integration.md);[任务](../ops/short-term/2026-09-17-control-center-server.md);[报告](../test-reports/2026-09-18-control-center-merge-gate-test-report.md) | engineering-dashboard 已下线,由 control-center 承接一期。合并门禁:核心包单测、真实 MiniMax 审查落库、SPA 七页走查均 PASS | `controlcenter` |
| 个人工程中控台(已移出) | 原本地只读看板与后来的 Control Center 都不再属于本仓库 | removed | 进度管理改由独立项目承担 | [历史设计](../archive/design/2026-09-17-personal-engineering-control-center-plan.md);[历史报告](../test-reports/2026-09-17-engineering-dashboard-test-report.md) | 2026-09-19 删除 `cmd/control-center`、`internal/controlcenter`、`control-center/web`、`deploy/control-center`。不恢复 `engineering-dashboard` | — |
---
+2 -11
View File
@@ -138,18 +138,9 @@
---
### 8. 个人中控台(Control Center)
### 8. 个人中控台(已移出本仓库)
| 项 | 链接 |
|-----|------|
| 设计与架构 SSOT | [project-control-center/02-control-center-design.md](project-control-center/02-control-center-design.md) |
| Agent 接入规范 | [project-control-center/agent-integration.md](project-control-center/agent-integration.md) |
| 任务入口 | [../ops/short-term/2026-09-17-control-center-server.md](../ops/short-term/2026-09-17-control-center-server.md) |
| 合并门禁报告 | [../test-reports/2026-09-18-control-center-merge-gate-test-report.md](../test-reports/2026-09-18-control-center-merge-gate-test-report.md) |
| 一句话总结 | 独立于产品系统的个人项目管理中控台:8 阶段流水线(agent 禁自动推进)+ 决策上报 + 向量文档库 + 每日审查 + React SPA |
| 旧版(已移除) | 只读工程中控台设计已归档:[../archive/design/2026-09-17-personal-engineering-control-center-plan.md](../archive/design/2026-09-17-personal-engineering-control-center-plan.md);任务:[../archive/ops/short-term/2026-09-17-engineering-dashboard.md](../archive/ops/short-term/2026-09-17-engineering-dashboard.md) |
**核心边界**:个人工具、不接产品后台 sysadmin;agent 只能上报与 advance-request,阶段推进与模糊决策必须人工。
项目开发进度由独立的统一管理系统负责,不再是本仓库功能。本仓库只保留智能体系统。旧只读工程中控台的历史设计仍在 [../archive/design/2026-09-17-personal-engineering-control-center-plan.md](../archive/design/2026-09-17-personal-engineering-control-center-plan.md),代码不恢复。
---
-27
View File
@@ -248,33 +248,6 @@ imports:
- app/uow
sysadmin:
- config
controlcenter/config: []
controlcenter/embed: []
controlcenter/llm: []
controlcenter/audit:
- controlcenter/llm
- controlcenter/store
controlcenter/docs:
- controlcenter/embed
- controlcenter/store
controlcenter/importer:
- controlcenter/pipeline
- controlcenter/store
controlcenter/pipeline: []
controlcenter/store: []
controlcenter/tasks:
- controlcenter/pipeline
- controlcenter/store
controlcenter/server:
- controlcenter/audit
- controlcenter/config
- controlcenter/docs
- controlcenter/embed
- controlcenter/pipeline
- controlcenter/store
- controlcenter/tasks
- controlcenter/webui
controlcenter/webui: []
table_owners:
document_records:
@@ -1,248 +0,0 @@
# Control Center 设计文档(架构 SSOT)
> 模块:`cmd/control-center` + `internal/controlcenter/*` + `deploy/control-center/` + `control-center/web/`
> 前置阅读:[01-personal-engineering-control-center-plan.md](./01-personal-engineering-control-center-plan.md)(一期只读 CLI);本文档覆盖二期服务端。
> Agent 操作手册见 [agent-integration.md](./agent-integration.md)。
## 1. 定位与红线
- **个人中控台**:单用户的个人项目控制平面,管理本仓库的任务/提案流水线、文档检索、定时审查。不是产品功能。
- **独立于产品系统**:不接产品 sysadmin、不动 `internal/server` 路由、不复用产品数据库。数据库是独立 pgvector 容器(`control-center-db`,默认端口 5440)。
- **Loopback-first**:默认监听 `127.0.0.1:8790`,鉴权仅 token(console/agent 两类 bearer),无 TLS、无多租户。
- **Agent 禁止推进阶段**:agent 侧 API 没有任何 advance 路由;阶段推进只能由 console 人工(或显式 external-submit)完成。
- **复用系统 `.env` 仅限配置读取**:`EMBEDDING_*` / `MINIMAX_*` / `CURSOR_*` 通过 `CC_*` 覆盖或回退借用,不 import 产品代码路径。
## 2. 总体架构
```text
┌──────────────────────────── 仓库主机 ────────────────────────────┐
│ │
human ──HTTP──▶│ control-center serve (127.0.0.1:8790) │
(browser SPA) │ ├─ webui (embed control-center/web/dist) │
│ ├─ server (console API / agent API / SSE broker / lease reaper)│
agent ──HTTP──▶│ ├─ tasks (domain logic: transitions/decisions/lease/…) │
(CLI/curl) │ ├─ pipeline (纯状态机规则, 无 IO) │
│ ├─ docs (Syncer: .doc 扫描/切片/embedding 入库) │
│ ├─ importer(.doc/ops 任务文档 → cc_tasks 周期导入) │
│ ├─ audit (每日审查: 确定性检查 + LLM review) │
│ ├─ embed (OpenAI 兼容 /embeddings 客户端, bge-m3 1024 维) │
│ └─ llm (OpenAI 兼容 /chat/completions 客户端) │
│ │ pgxpool │ HTTP │
└──────────┼──────────────────────────────────┼───────────────────┘
▼ ▼
┌────────────────────┐ EMBEDDING_ENDPOINT_URL (bge-m3)
│ control-center-db │ MINIMAX_ENDPOINT_URL (审查 LLM)
│ pgvector/pg16:5440 │
└────────────────────┘
```
- 单二进制 `cmd/control-center`:`serve`(迁移+服务+周期任务)、`migrate`、`register-agent`、`import-docs`、10 个 agent CLI 子命令。
- `serve` 启动时:goose 迁移 → 连接池 → doc Syncer 周期同步(`CC_DOC_SYNC_SECONDS`,默认 60s)→ importer 周期导入 → 审查调度器(`CC_AUDIT_DAILY_AT`)→ 租约回收器(60s 一轮)。
- 前端 SPA 构建产物 embed 进二进制(`internal/controlcenter/webui`,`go:embed dist`),`GET /` 与非 `/api/` 路径直接服务,不带鉴权;`/api/*` 一律要求 bearer token。
## 3. 目录结构
```text
cmd/control-center/ main.go(子命令分发 + serve/migrate/register-agent/import-docs)
agentcli.go(agent 侧薄 HTTP 客户端 CLI)
internal/controlcenter/
config/ CC_* 环境变量加载 + 系统 .env 回退借用
store/ pgxpool 连接 + goose 迁移执行
pipeline/ 纯状态机:阶段顺序、三轴枚举、门禁规则、deliverable 文案、DecisionRules
tasks/ 领域逻辑(SQL 全部在此):tasks/decisions/events/plans/settings/
transitions/overview/docgen
server/ HTTP 适配层:路由、鉴权中间件、SSE broker、lease reaper、各 handler
docs/ .doc 扫描同步 + markdown 切片
embed/ OpenAI 兼容 embeddings 客户端(Dim=1024)+ 测试 stub
importer/ .doc/ops/{short-term,long-term}+backlog 任务文档导入 cc_tasks
audit/ 每日审查:确定性检查、LLM review、findings 生命周期
llm/ OpenAI 兼容 chat 客户端(审查专用)
webui/ embed SPA dist(dist 提交入库)
deploy/control-center/ docker-compose.yml(pgvector pg16,172.32.0.0/24 固定子网)
goose/00001_init.sql
control-center/web/ Vite + React + TS SPA 源码(build 输出到 internal/controlcenter/webui/dist)
```
## 4. DB schema(goose 00001_init.sql,独立库 control_center)
| 表 | 用途 | 关键约束 |
|----|------|----------|
| `cc_plans` | 计划(short/long/backlog 三 horizon) | `slug` UNIQUE;`horizon/status` CHECK |
| `cc_agents` | agent 注册 | `name`/`token_hash` UNIQUE;仅存 SHA-256 hash |
| `cc_tasks` | 任务/提案 | `slug` UNIQUE;`kind∈{task,proposal}`;`status/stage/stage_state` CHECK(枚举见 §5);`testing_required` 默认 true;`lease_expires_at`/`heartbeat_at` 可空 |
| `cc_task_events` | 事件流 | `phase` CHECK 9 值;`task_id` ON DELETE CASCADE |
| `cc_task_reports` | agent 报告 | `kind∈{result,test-report,handoff}` |
| `cc_stage_artifacts` | 阶段产物 | `source_type∈{agent,human,external}`;`(task_id,stage)` 索引 |
| `cc_stage_transitions` | 阶段流转账本 | 记录 from/to/actor/approved_by/note |
| `cc_stage_settings` | 阶段级模型/执行者 | `scope∈{global,task}`;`executor∈{agent,human,external}`;`UNIQUE(scope,task_id,stage)` |
| `cc_decisions` | 决策上报 | `suggestions` jsonb;`status∈{open,resolved}`;open 部分索引 |
| `cc_documents` | .doc 文档登记 | `path` UNIQUE;`sha256` 变更检测 |
| `cc_doc_chunks` | 切片 + 向量 | `chunk_hash` UNIQUE(artifact 前缀 `artifact:`);`embedding vector(1024)` 可空;`tsv` 生成列;HNSW(cosine) + GIN(tsv);`CHECK(doc_id IS NOT NULL OR artifact_id IS NOT NULL)` |
| `cc_audit_runs` | 审查运行 | `status∈{running,completed,failed}` |
| `cc_audit_findings` | 审查发现 | `severity∈{low,medium,high,critical}`;`kind∈{bug,gap,unfinished,doc-drift}`;`status∈{open,converted,dismissed}` |
| `cc_snapshots` | 快照 | revision + payload jsonb |
## 5. 八阶段流水线状态机
### 5.1 阶段顺序(`pipeline.Stages`,固定)
```text
proposal → analysis → design → review → implementation → testing → evaluation → confirmation
│
testing_required=false 时跳过 testing ┘
```
`confirmation` 人工 advance 后任务 `status=done`(不再进入下一阶段)。
### 5.2 三轴模型(`cc_tasks`)
| 轴 | 取值 | 含义 |
|----|------|------|
| `status` | `planned / in_progress / blocked_pending_decision / done / cancelled` | 任务生命周期 |
| `stage` | 8 阶段之一 | 当前所处阶段 |
| `stage_state` | `idle / assigned / in_progress / awaiting_review / changes_requested / approved` | 当前阶段内的流转状态 |
### 5.3 stage_state 状态图与门禁铁律
```text
assign(console) claim(agent, 2h lease)
idle ───────────────▶ assigned ───────────────▶ in_progress
▲ ▲ ▲ │ │
│ │ └── release / 租约过期回收 │ │ event/artifact/decision…
│ send-back(console) │ │ │ advance-request(agent)
│ │ ▼ │
│ changes_requested ◀── awaiting_review ◀─┘
│ │ │
└──── 重新 claim 回到 in_progress │ advance(console, 人工)
▼
下一阶段 idle(已指派则 assigned)
confirmation 阶段 advance → status=done
```
铁律(`pipeline.CanAgentTransition`):**agent 唯一允许的 stage_state 迁移是 `in_progress → awaiting_review`**(advance-request)。没有 agent 路由可以移动 `stage`;console advance 仅允许从 `awaiting_review` 发起;send-back 从 `awaiting_review → changes_requested`;external-submit 带 `advance:true` 时允许从 `in_progress/assigned/awaiting_review` 推进(视为外部人工动作,actor=external,approved_by=user-external)。所有流转写入 `cc_stage_transitions` 账本。
### 5.4 阶段级设置(cc_stage_settings)
- `scope=global` 为每阶段默认 `{model, executor}`;`scope=task` 覆盖指定任务。
- 解析顺序:task 覆盖优先,global 兜底(`tasks.ResolveStageSetting`)。
- 通过 agent instruction 包的 `model`/`executor` 字段下发;`executor=human|external` 表示该阶段不由 agent 执行。
## 6. API 全量清单
鉴权:`/healthz` 与非 `/api/` 路径公开;`/api/v1/agent/*` 校验 agent token(`cc_agents.token_hash`,SHA-256);其余 `/api/*` 校验 console token(`CC_CONSOLE_TOKEN`,常量时间比较)。错误体统一 `{"error": "..."}`;ErrNotFound→404、ErrConflict/ErrBadState→409、ErrBadInput→400。
### 6.1 Agent API(bearer = agent token)
| 方法 路径 | 用途 | 关键字段 / 行为 |
|---|---|---|
| GET `/api/v1/agent/tasks/next` | 取当前 agent 最高优先可认领任务 | 仅 `assigned_agent=self` 且 `stage_state∈{assigned,changes_requested}` 且未终态;无可认领 → 204 |
| GET `/api/v1/agent/tasks/{id}/instruction` | 阶段指令包 | `{task, stage, stage_state, deliverable, model, executor, decision_rules, recent_events(20), prior_artifacts}` |
| POST `/api/v1/agent/tasks/{id}/claim` | 认领 | 获 2h 租约;stage_state→in_progress;冲突(他人租约/他人指派)→409 |
| POST `/api/v1/agent/tasks/{id}/heartbeat` | 心跳续租 | 仅当前租约持有者可续 2h;否则 409 |
| POST `/api/v1/agent/tasks/{id}/events` | 上报事件 | `phase`(9 枚举之一,非法 400)+ `message` + 可选 `payload`;写库并广播 SSE |
| POST `/api/v1/agent/tasks/{id}/artifacts` | 提交阶段产物 | `kind/title/content_md[/stage]`(stage 缺省=当前阶段);入库后异步切片向量化 |
| POST `/api/v1/agent/tasks/{id}/advance-request` | 交回人工评审 | 仅 `in_progress` 且本人;stage_state→awaiting_review,租约释放;`summary`+`artifact_ids` 记入事件 |
| POST `/api/v1/agent/tasks/{id}/decisions` | 决策上报 | `question` 必填、`suggestions≥1` 且每条 `option/rationale/impact` 非空,否则 400;成功后任务→`blocked_pending_decision` |
| POST `/api/v1/agent/tasks/{id}/release` | 主动释放租约 | stage_state 回 assigned |
| POST `/api/v1/agent/reports` | 提交报告 | `task_id/kind∈{result,test-report,handoff}/content_md[/links]` |
### 6.2 Console API(bearer = console token)
| 方法 路径 | 用途 | 关键字段 / 行为 |
|---|---|---|
| POST `/api/v1/plans` | 建计划 | `slug/title/horizon/summary_md/doc_path` |
| GET `/api/v1/plans` | 计划列表 | — |
| PATCH `/api/v1/plans/{id}` | 改计划 | PlanPatch |
| POST `/api/v1/tasks` | 建任务 | `slug/title[/kind=task|proposal][/plan_id][/priority][/testing_required 默认 true][/instruction_md][/doc_path][/mutex]` |
| GET `/api/v1/tasks` | 任务列表 | query: `status/stage/plan_id/q`;按 priority desc, created_at asc |
| GET `/api/v1/tasks/{id}` | 任务详情 | — |
| PATCH `/api/v1/tasks/{id}` | 改任务 | 可选字段 patch;`assign=<agent>` 指派(idle→assigned,planned→in_progress) |
| POST `/api/v1/tasks/{id}/stages/advance` | 人工推进 | 仅 `awaiting_review`;confirmation 推进 → `done:true` |
| POST `/api/v1/tasks/{id}/stages/send-back` | 打回 | 仅 `awaiting_review` → changes_requested;`note` |
| POST `/api/v1/tasks/{id}/stages/{stage}/external-submit` | 外部阶段提交 | stage 必须是当前阶段(否则 409);`advance:true` 时按外部人工推进 |
| GET `/api/v1/tasks/{id}/pipeline` | 流水线视图 | 8 阶段各自的 artifacts/transitions/decisions |
| POST `/api/v1/tasks/{id}/export-doc` | 导出 .doc 任务文档 | 写 `.doc/ops/short-term/YYYY-MM-DD-<slug>.md`;slug 须匹配 `^[a-z0-9-]+$`;已存在 → 409 |
| GET `/api/v1/decisions` | 决策收件箱 | query `status=open|resolved|all` |
| POST `/api/v1/decisions/{id}/resolve` | 裁决 | `resolution` 或 `suggestion_index` 二选一(都缺 400);裁决后任务 unblock 回 in_progress |
| GET `/api/v1/stage-settings` | 阶段设置 | query `task_id` 时合并 task 覆盖 |
| PUT `/api/v1/stage-settings` | 写阶段设置 | 单条 `{stage,model,executor}` 或批量 `settings` map;executor 非法 400 |
| GET `/api/v1/overview` | 总览 | task_counts/open_decisions/awaiting_review/open_findings/agents(心跳 >5min 记 stale) |
| GET `/api/v1/stream` | SSE 事件流 | 见 §9 |
| GET `/api/v1/docs` | 文档列表 | query `taxonomy/q` |
| GET `/api/v1/docs/search` | 文档检索 | 见 §7 |
| GET `/api/v1/docs/{id}` | 文档详情 | — |
| GET `/api/v1/audits/runs` | 审查运行列表 | query `limit`(默认 20,上限 200) |
| GET `/api/v1/audits/findings` | findings 列表 | query `status/severity/limit`(默认 100,上限 500) |
| POST `/api/v1/audits/run` | 手动触发审查 | 后台执行,202 返回 `run_id`;已有运行中 → 409 |
| POST `/api/v1/audits/findings/{id}/convert` | finding 转任务 | 生成 `kind=proposal` 任务(slug `audit-<id前12位>`);非 open → 409 |
| POST `/api/v1/audits/findings/{id}/dismiss` | 忽略 finding | 非 open → 409;不存在 → 404 |
## 7. 文档向量管线
- **入库**:`docs.Syncer` 周期(默认 60s)walk `<repoRoot>/.doc/**/*.md`(跳过 `assets/` 目录);按文件 sha256 差异做增删改;front matter 解析标题/taxonomy(taxonomy=.doc 下第一级目录)。
- **切片**:按 ATX 标题切节;节超 1800 估算 token(CJK=2,ASCII≈len/4)按段落递归二分,单巨段按 rune 硬切;标题行保留在 chunk 正文内。`chunk_hash = sha256(owner:source:seq:content)`,未变化 chunk 不重嵌。
- **Embedding**:`embed.HTTPClient`,OpenAI 兼容 `/embeddings`,批 ≤16,5xx/网络错误重试一次;模型 bge-m3,维度 1024,写入 `cc_doc_chunks.embedding vector(1024)`(HNSW cosine)。失败降级:chunk 以 NULL embedding 入库,同步不失败。
- **产物向量化**:agent/external 提交 artifact 后后台(2min 超时)按同一切片规则入库,`chunk_hash` 加 `artifact:` 前缀,`doc_id` 为 NULL。
- **检索** `GET /api/v1/docs/search?q=&mode=hybrid|semantic|keyword&limit=`(默认 20,上限 100):
- semantic:`1 - (embedding <=> q)` 余弦相似度 top-N;
- keyword:`tsv @@ plainto_tsquery('simple')` 的 ts_rank + content ILIKE(+0.5) + heading ILIKE(+0.3);
- hybrid(默认):两路各自按 max 归一后 0.6·向量 + 0.4·关键词融合排序;
- embedding 不可用(未配置或调用失败)→ 自动降级 keyword-only,响应 `degraded:true`。
- 结果条目:`{score, snippet(≤300 rune), heading, source:{type:doc|artifact, …}, doc_id, artifact_id}`。
## 8. 导入 / 导出与 .doc 的双向关系
**原则:git 仓库中的 `.doc` 文档是可审计真相来源;DB 是索引与协作层。**
- **导入(.doc → DB)**:`importer` 扫描 `.doc/ops/short-term`、`.doc/ops/long-term`、`.doc/backlog`,每个 md 文件 upsert 一条 cc_tasks(slug=文件名去 .md),并确保 short-term/long-term/backlog 三个 plan 存在。front matter `status/owner/branch/worktree/mutex` 映射入库;`blocked` 映射为 `blocked_pending_decision`;未知 status 回落 planned。覆盖规则:title/owner/branch/worktree/mutex/instruction_md/doc_path 跟随文档;status 仅在任务 planned/in_progress 时跟随;**stage/stage_state/priority/assignment/终态 status 导入器永不覆盖**。同名文件多目录出现时按 planDirs 顺序取先者。`control-center import-docs` 可手动执行;serve 内按 `CC_DOC_SYNC_SECONDS` 周期执行。
- **导出(DB → .doc)**:`POST /api/v1/tasks/{id}/export-doc` 按 `.doc/ops/templates/short-term-task.md` 渲染任务文档写入 `.doc/ops/short-term/`;文件已存在 409(绝不覆盖);导出后回写 `doc_path` 并记 system 事件;随后周期性 docs sync 会把它当普通 .doc 文件入库向量化。
## 9. SSE 与租约语义
- **SSE**:`GET /api/v1/stream`(console token)。每条 `cc_task_events` 写入经 broker 广播为 `data: <event JSON>`;连接后先发 `: connected`,每 30s 发 `: ping` 保活。事件唯一写路径是 `server.appendEvent` / agent events 路由。
- **租约**:`claim` 获得 2h 租约(`tasks.LeaseDuration`),`heartbeat` 续租(仅持有者),`release` 主动释放回 assigned。lease reaper 每 60s 扫描 `stage_state=in_progress` 且租约过期的任务,回收为 assigned 并记 system 事件("lease expired, task unclaimed")——孤儿任务自动回到可认领池。
- 总览中的 agent 活跃度:最后心跳距今 >5min(`StaleHeartbeatAfter`)记 stale,与 2h 租约是两个不同阈值(前者是 UI 活性提示,后者是强制回收)。
## 10. 审查子系统
- **调度**:`CC_AUDIT_ENABLED=true`(默认)时按 `CC_AUDIT_DAILY_AT`(默认 `03:30`,本地时间,分钟级 ticker)每日触发;也可 `POST /api/v1/audits/run` 手动触发(202 后台执行,30min 超时)。并发由 PG advisory lock(key `0x6363_6175_6469_74`)+ `running` 行检查保证,重复触发返回 409。
- **确定性检查(必跑,纯 SQL + git)**:
1. `awaiting_review` 超 24h(medium/unfinished);
2. `in_progress` 心跳超 30min 或租约过期(high/unfinished);
3. `blocked_pending_decision` 超 48h(high/gap);
4. `planned` 超 7 天未动(low/gap);
5. 任务声明的 branch 在本地仓库不存在(`git show-ref --verify`,medium/doc-drift;非法 ref 名跳过)。
- **LLM review(可选,降级不失败)**:上下文 = 上次 completed run 以来的 `git log --oneline --stat`(≤200 行,无历史 run 则取 24h)+ 未完成任务(≤50)+ 最新事件(≤100)+ 已有 open findings(≤50);system prompt 要求只输出 JSON 数组 `{severity,kind,title,detail,refs}`;解析容忍 ```json 围栏与首尾散文(取第一个 `[` 到最后一个 `]`),非法 severity/kind 回落 medium/gap,无 title 丢弃。LLM 未配置/失败只记入 run summary,run 仍 completed。
- **去重**:`INSERT … WHERE NOT EXISTS (open finding with same title)`,跨 run 同名 open finding 抑制插入。
- **处置**:finding `open → converted`(一键生成 proposal 任务)或 `open → dismissed`;转换把 detail_md + refs 写入任务 instruction_md。
## 11. 配置项清单
`CC_*` 优先;标注「借用」的 key 在未设 `CC_*` 时从系统 `.env`(`CC_ENV_FILE`,默认 `<CC_REPO_ROOT>/.env`)或进程环境读取。只列 key 名,不含值。
| Key | 默认 | 说明 |
|-----|------|------|
| `CC_HTTP_ADDR` | `127.0.0.1:8790` | 监听地址;设为 `0.0.0.0:8790` 或 `scripts/control-center.sh serve-lan` 对外开放 |
| `CC_DB_DSN` | `postgres://cc:cc@127.0.0.1:5440/control_center?sslmode=disable` | 独立库 DSN |
| `CC_CONSOLE_TOKEN` | 空(loopback 时生成临时 token 并打印告警) | console bearer token;**绑定非 loopback 地址时强制要求显式设置固定 token,否则拒绝启动** |
| `CC_REPO_ROOT` | `.` | 仓库根(.doc 扫描/git 审查基准) |
| `CC_ENV_FILE` | `<repoRoot>/.env` | 借用配置来源 |
| `CC_MIGRATIONS_DIR` | `<repoRoot>/deploy/control-center/goose` | goose 迁移目录 |
| `CC_EMBEDDING_ENDPOINT_URL` / 借用 `EMBEDDING_ENDPOINT_URL` | 空(空则检索降级 keyword-only) | embeddings 端点 |
| `CC_EMBEDDING_MODEL` / 借用 `EMBEDDING_MODEL` | `bge-m3` | embedding 模型 |
| `CC_EMBEDDING_API_KEY` / 借用 `EMBEDDING_API_KEY` | 空 | embeddings key |
| `CC_LLM_ENDPOINT_URL` / 借用 `MINIMAX_ENDPOINT_URL` | 空 | 审查 LLM 端点 |
| `CC_LLM_MODEL` / 借用 `MINIMAX_MODEL_ID` | 空 | 审查 LLM 模型 |
| `CC_LLM_API_KEY` / 借用 `MINIMAX_API_KEY` | 空 | 审查 LLM key |
| `CC_CURSOR_MODEL` / 借用 `CURSOR_MODEL` | 空 | 预留的 Cursor 模型名 |
| `CC_AUDIT_ENABLED` | `true` | 每日审查开关 |
| `CC_AUDIT_DAILY_AT` | `03:30` | 每日审查时刻(本地 HH:MM,非法值回落 03:30) |
| `CC_DOC_SYNC_SECONDS` | `60` | .doc 同步 + 任务导入周期(秒) |
| `CC_SERVER` | `http://127.0.0.1:8790` | agent CLI 默认 server |
| `CC_AGENT_TOKEN` | 空 | agent CLI 默认 token |
compose 侧:`CC_DB_USER/CC_DB_PASSWORD/CC_DB_NAME/CC_DB_PORT`(默认 cc/cc/control_center/5440)。
## 12. 前端 SPA
`control-center/web`(Vite 7 + React + TS):页面 Overview / Board(阶段看板)/ TaskDetail(stepper + 事件流)/ Decisions / Docs(搜索)/ Audit / Settings;console token 存 localStorage(`cc_console_token`),fetch 包装器统一注入 bearer。`npm run build` 输出到 `internal/controlcenter/webui/dist`(提交入库,`go:embed`),因此 Go 二进制自包含,无需单独部署前端。
@@ -1,154 +0,0 @@
# Control Center Agent 接入规范
> 面向在本仓库工作的编码 agent(Cursor / Claude Code / Kimi / Codex / PI 等)。
> 架构细节见 [02-control-center-design.md](./02-control-center-design.md);本文是操作手册。
> **本规范与仓库 AGENTS.md 同时生效**:分支纪律、文档同步红线、集成槽位等不因接入中控台而豁免。
## 1. 认证与接入
1. 让人类(或已持 console token 的运维流程)注册 agent,拿到一次性明文 token:
```bash
go run ./cmd/control-center register-agent -name <agent-name> [-kind kimi|cursor|pi|generic]
# 输出: agent "<name>" registered. token (shown once): <TOKEN>
```
重复执行同名即轮换 token。服务端只存 SHA-256 hash(`cc_agents.token_hash`)。
2. 配置环境变量(agent CLI 与 curl 通用):
```bash
export CC_SERVER=http://127.0.0.1:8790 # 默认即此值
export CC_AGENT_TOKEN=<TOKEN>
```
3. 所有 agent 请求带 `Authorization: Bearer $CC_AGENT_TOKEN`,路径前缀 `/api/v1/agent/`。
## 2. 何时接入
- **会话启动时**:完成 AGENTS.md 启动协议(读 AGENTS.md / PROJECT.md / CURRENT 等)后,执行一次 `next` 查看是否有指派给自己的任务;有则优先按本规范接续工作。
- **手头工作完成或被打回修改后**:再次 `next`,**同一任务可能已进入下一阶段**——stage 变了就必须重新 `instruction` 拿新指令包,不得沿用上一阶段的指令。
- **空闲轮询**:长期驻留的 agent 以 5–15min 节奏 `next`;无任务(204)即静默等待,不要刷屏 event。
- **人类口头指派的临时工作**:若未走中控台,完成后主动提醒人类「此工作未入流水线」,由人类决定是否补录任务。
## 3. 标准工作循环
```text
next ──▶ instruction ──▶ claim ──▶ ┌ event (start/plan/progress/…) ─┐
│ artifact (阶段产物, 可多次) │
│ heartbeat (<2h 节奏续租) │
└────────────循环───────────────┘
│
完成本阶段工作 │ 需要人类拍板
▼ ▼
advance-request decision 上报
(交回人工评审, (任务转 blocked_pending_decision,
租约释放,等人工) 停止推进,等 resolve)
```
1. **next**:发现分配给自己、可认领(`assigned` 或 `changes_requested`)的最高优先任务。204/`{"task":null}` 表示无事可做。
2. **instruction -task ID**:拿阶段指令包——`deliverable`(本阶段交付要求)、`decision_rules`(决策规则原文)、`model`/`executor`(阶段级配置)、`prior_artifacts`(前序阶段产物)、`recent_events`。**动手前必读**:
- `executor` 非 `agent`(human/external)时,该阶段不由你执行——直接放弃认领,回 `next`;
- `model` 指定了本阶段使用的模型时,按指定模型执行;自身无法满足(如模型不可用)时上报 decision,不要静默换模型;
- 指令包内容不清晰、有歧义、缺关键约束时,**先 decision 上报再动手**,禁止带着猜测开工。
3. **claim -task ID**:认领,获 2h 租约,任务转 `in_progress`。409 表示他人持有租约或已指派他人——放弃,不要抢。
4. 工作中(节奏规范见 §8):
- **event**:记录进度(`phase=start/plan/progress/report/done`);**卡住时 `phase=blocked` 并写清原因**。
- **artifact**:每个阶段交付物按 §7 的 kind 提交 markdown 全文(`-file` 或 `-content`)。
- **heartbeat**:节奏必须 **< 2h**(租约时长)。租约过期会被回收为 `assigned`(系统事件留痕),你的工作上下文对他人重新可见。建议每 15–30min 一次。
5. **advance-request -task ID -summary "…"**:本阶段完成后交回人工评审。summary 写「做了什么 + 产物索引 + 自评是否满足交付要求」。此后**停止动该任务**,等 console 人工 advance(进入下一阶段)或 send-back(打回 `changes_requested`,你可重新 claim 修改)。
6. 中途放弃:`release -task ID` 主动释放租约,并在 message 里留下进度说明方便他人接手。
## 4. 铁律(违反即 409/400 或人工打回)
1. **禁止自动推进阶段**:agent API 没有任何 advance 路由;你唯一能做的时间线动作是 `advance-request`(`in_progress → awaiting_review`)。任何「替我点一下推进」的尝试都违反设计。
2. **决策必须上报**:方案取舍、范围变更、触碰红线风险等需要人类拍板的问题,调 `decision` 上报,**禁止自行猜测关键决策**。质量要求见 §6。
- 上报成功后任务自动转 `blocked_pending_decision`,**停止推进该任务**,等人类在 console resolve(裁决后任务自动回 `in_progress`)。
3. **blocked 即停**:无法继续时先 `event -phase blocked -message <原因>`;需要决策的阻塞走第 2 条。
4. **仓库红线并行生效**:一律在 `feature/*` / `fix/*` 分支提交、文档按 AGENTS.md taxonomy 落盘、合入 dev 须占集成槽位。流水线阶段流转不替代这些规矩(例如 implementation 阶段的代码仍走正常分支与提交流程)。
5. 心跳断了别假设租约还在:claim/heartbeat 返回 409 就按提示放弃或重新认领。
## 5. 阶段行为细则
每阶段的「进入条件 → 该做什么 → 退出条件」:
| 阶段 | 进入条件 | 你该做什么 | 退出(advance-request)条件 |
|------|----------|-----------|---------------------------|
| proposal | 任务被指派给你 | 读 instruction + 关联文档(可用 `/api/v1/docs/search` 检索历史材料);把人类的一句话意图展开为结构化提案 | proposal artifact 含问题陈述/目标/范围/候选方案对比;未决取舍已全部 decision 上报 |
| analysis | 人类批准提案 | 读代码与 `.doc` 取证,结论必须有文件路径/行号级证据 | analysis artifact 含现状证据/约束/风险清单 |
| design | 人类批准分析 | 产出方案:模块划分、接口、数据结构、迁移步骤;与既有规范核对 | design artifact 完整到「另一个 agent 只看它能实现」 |
| review | 人类批准方案 | 自检:对照 AGENTS/PROJECT/regulations 与任务验收标准逐条核对;找自己方案的漏洞 | review artifact 含自检清单与每项结论 |
| implementation | 人类批准评审 | 按方案在 feature 分支编码,分批原子提交;同步更新任务文档 | implementation artifact 含变更文件清单与提交 hash;`go vet`/`go test -short` 绿 |
| testing | 人类批准实施(任务 testing_required=true 才有此阶段) | 按 PROJECT.md 命令跑测试;失败先修再报 | test-report artifact + `report -kind test-report` 双写 |
| evaluation | 人类批准测试 | 对照 proposal 阶段的目标逐条验收,诚实列出未达成项 | evaluation artifact 含达成/未达成/遗留清单 |
| confirmation | 人类批准评价 | 整理交付物索引、handoff、回链证据 | 材料齐后 advance-request,**等人类最终确认**——任务 done 由人类完成 |
被打回(`changes_requested`)时:重新 claim → 读打回 note(transitions 里)→ 修正 → 重新 advance-request。
## 6. 决策上报质量规范
**必须上报的情形**(不限于):需求歧义或自相矛盾;多种合理技术路线且影响架构;需要越权(动主干分支、删数据、改他人 mutex 模块);范围膨胀(发现任务实际工作量远超描述);规范之间冲突。
**写法要求**:
- `question`:一句话说清要人类拍板什么,带任务上下文。
- `context_md`:证据(文件路径、行号、命令输出摘要),让人类不看代码也能判断。
- `suggestions`:至少 1 条,**推荐项放第一条**;每条三段——
- `option`:具体可执行的选项(不是「方案一」这种空话);
- `rationale`:为什么推荐/为什么不推荐,基于证据;
- `impact`:选它的代价、风险、对下游阶段的影响。
- 没有倾向时必须明说「无推荐,各选项权衡如下」,禁止隐藏自己的判断。
## 7. 各阶段交付物规范(`pipeline.DeliverableSpec`,instruction 包原文下发)
| 阶段 | 交付要求 | artifact kind |
|------|----------|---------------|
| proposal | 问题陈述、目标、范围边界、候选方案对比;需要拍板的取舍用 decisions 上报 | `proposal` |
| analysis | 现状/代码证据、约束、风险清单;发现阻塞问题立即上报 decision,不得自行猜测 | `analysis` |
| design | 模块划分、接口、数据结构、迁移方案 | `design` |
| review | 自检清单、与既有规范(AGENTS/PROJECT/regulations)一致性核对 | `review` |
| implementation | 代码改动说明、提交计划、变更文件清单;实际编码在仓库分支上进行 | `implementation` |
| testing | 测试命令、结果、覆盖率与失败分析;**另需** `report -kind test-report` | `test-report` |
| evaluation | 对照 proposal 目标验收,列出达成/未达成项与遗留问题 | `evaluation` |
| confirmation | 交付物索引、handoff 说明、回链证据;等待人类最终确认后任务 done | (材料随 advance-request 摘要/artifact) |
产物质量底线:markdown 格式;给出文件路径/命令/commit 等可核验证据;如实标注未完成项。产物提交后服务端异步切片入向量库(2min 超时,失败只记日志),可被 `/api/v1/docs/search` 检索。
## 8. 事件与心跳节奏
- `start`:claim 成功后第一条事件,写你对本阶段任务的一句话理解(防止理解偏差到交付才暴露)。
- `plan`:动手前输出工作计划要点。
- `progress`:里程碑粒度(完成一个子步骤/遇到关键发现),不要每分钟刷屏。
- `blocked`:一卡住就发,写明阻塞原因与需要什么。
- heartbeat:每 15–30min;长时间无输出(如下载/构建)也要保持心跳。
## 9. CLI 子命令速查(`cmd/control-center/agentcli.go`)
公共 flag:`-server`(默认 `$CC_SERVER` 或 `http://127.0.0.1:8790`)、`-token`(默认 `$CC_AGENT_TOKEN`);`-task` 接受任务 uuid(用 `next` 发现 id)。二进制可用 `go run ./cmd/control-center <sub>` 代替。
| 子命令 | flag | 等价 curl |
|--------|------|-----------|
| `next` | — | `curl -H "Authorization: Bearer $CC_AGENT_TOKEN" $CC_SERVER/api/v1/agent/tasks/next` |
| `instruction` | `-task ID` `[-md]`(-md 输出可读排版,默认 JSON) | `curl -H … $CC_SERVER/api/v1/agent/tasks/$ID/instruction` |
| `claim` | `-task ID` | `curl -X POST -H … $CC_SERVER/api/v1/agent/tasks/$ID/claim` |
| `heartbeat` | `-task ID` | `curl -X POST -H … $CC_SERVER/api/v1/agent/tasks/$ID/heartbeat` |
| `release` | `-task ID` | `curl -X POST -H … $CC_SERVER/api/v1/agent/tasks/$ID/release` |
| `event` | `-task ID -phase P -message M [-payload '<json>']`(phase ∈ start/plan/progress/blocked/report/done/advance-request/decision/system) | `curl -X POST -H … -H 'Content-Type: application/json' -d '{"phase":"progress","message":"…"}' $CC_SERVER/api/v1/agent/tasks/$ID/events` |
| `artifact` | `-task ID -kind K -title T [-file F \| -content MD]` | `curl -X POST -H … -d '{"kind":"analysis","title":"…","content_md":"…"}' $CC_SERVER/api/v1/agent/tasks/$ID/artifacts` |
| `advance-request` | `-task ID -summary S [-artifacts id1,id2]` | `curl -X POST -H … -d '{"summary":"…","artifact_ids":["…"]}' $CC_SERVER/api/v1/agent/tasks/$ID/advance-request` |
| `decision` | `-task ID -question Q -context MD -suggestion "选项||理由||影响"`(分隔符为两个 ASCII 竖线 `||`;`-suggestion` 可重复,至少一次) | `curl -X POST -H … -d '{"question":"…","context_md":"…","suggestions":[{"option":"…","rationale":"…","impact":"…"}]}' $CC_SERVER/api/v1/agent/tasks/$ID/decisions` |
| `report` | `-task ID -kind result\|test-report\|handoff [-file F \| -content MD]` | `curl -X POST -H … -d '{"task_id":"…","kind":"test-report","content_md":"…"}' $CC_SERVER/api/v1/agent/reports` |
CLI 输出为紧凑 JSON;失败时把服务端 `error` 字段打到 stderr 并以非零码退出。
## 10. 错误语义与处理
| 状态码 | 含义 | agent 处理 |
|--------|------|------------|
| 400 | 请求不合规:非法 phase、decision 缺建议/三段不全、JSON 体坏等 | 修正请求体重试;不要原样重发 |
| 401 | token 缺失或非法 | 检查 `CC_AGENT_TOKEN`;仍失败则让人类用 `register-agent -name <你>` 轮换 |
| 404 | 任务/资源不存在 | 重新 `next` 发现任务;可能已被取消 |
| 409 | 状态冲突:他人租约/指派、非 `in_progress` 调 advance-request、任务已终态等 | **放弃当前动作**,回 `next` 重新认领;禁止强行重试 |
| 500 | 服务端内部错误 | 记 `event -phase blocked`,把错误原文写进 message,等人类处理 |
204(`next` 无可认领任务)与 201/200 均为正常响应,不是错误。
@@ -1,156 +0,0 @@
# 个人中控台 Control Center(P0–P6)测试报告
- 日期:2026-09-17
- 分支:`feature/control-center-server`
- 范围:P0 骨架 → P5 定时审查 全部后端能力 + P6 文档收口;任务文档 [.doc/ops/short-term/2026-09-17-control-center-server.md](../ops/short-term/2026-09-17-control-center-server.md)
- 关联设计:[02-control-center-design.md](../system-design/project-control-center/02-control-center-design.md)、[agent-integration.md](../system-design/project-control-center/agent-integration.md)
## 一、 测试背景 (Background)
个人中控台 Control Center 是独立于产品系统的单用户项目控制平面:8 阶段任务流水线、agent 协作 API(禁止自动推进阶段)、`.doc` 向量文档库、每日仓库审查、React SPA。P0–P5 以 6 个 commit(`2be43ef`→`07a8e12`)完成于本分支,本报告为 P6 交付前的全量验收。
## 二、 测试目标 (Test Objectives)
逐条核对任务文档验收标准:
1. `go vet ./...` / `go test -short ./...` / `go build ./cmd/control-center` 全绿;
2. 状态机单测:agent 直接推进被拒、decision 缺建议拒收、lease 过期孤儿恢复;
3. `.doc` 全量入库后语义搜索命中已知文档;
4. curl 冒烟:建任务→agent claim→产物→advance-request→人工批准→下一阶段 全链路;
5. 前端 `vite build` 通过并 embed 可用。
补充验证:token 鉴权负例、agent 无 advance 路由负例、overview/pipeline/audits 端点、DB 集成测试(真实 pgvector 容器)。
## 三、 测试方案与环境 (Test Plan & Environment)
- OS:Linux 7.0.0-31-generic x86_64;Go 1.27.1;Node v26.8.2(vite 7.3.6)
- 数据库:真实容器 `control-center-db`(pgvector/pgvector:pg16,127.0.0.1:5440,Up healthy),goose 版本 1
- Embedding:真实 bge-m3 端点(借用系统 `.env` 的 `EMBEDDING_*`),hybrid 检索 `degraded:false`
- 服务实例:`/tmp/control-center-bin serve`,`CC_HTTP_ADDR=127.0.0.1:8799`(避开默认 8790)、`CC_CONSOLE_TOKEN=p6-smoke-token`、`CC_AUDIT_ENABLED=false`、`CC_DOC_SYNC_SECONDS=3600`
- 分层:全仓 vet/单测 → controlcenter 包 DB 集成测试(`TEST_CC_DB_DSN`)→ 前端构建 → 二进制构建 → 真实 HTTP 冒烟
## 四、 预期结果 (Expected Results)
- 四条构建/测试命令退出码 0,无 FAIL;
- 状态机负例:agent advance 路由不存在(404/405);decision 缺建议 400;advance-request 二次提交 409;租约冲突 409;
- 搜索对「中控台 流水线 阶段」命中系统设计文档,`degraded:false`;
- 冒烟全链路 stage 从 proposal 推进到 analysis(`testing_required=false` 时跳 testing,链路中已验证),任务账本有 transitions 记录;
- SPA `GET /` 200 text/html。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 自动化套件
**`go vet ./...`** — 无任何输出,退出码 0。**PASS**
**`go test -short ./...`** — 全仓无 FAIL 行,退出码 0(ok 行全部通过)。**PASS**
**`TEST_CC_DB_DSN=postgres://cc:cc@127.0.0.1:5440/control_center?sslmode=disable go test -count=1 ./internal/controlcenter/...`** — 真实输出:
```text
ok chat-agent-system/internal/controlcenter/audit 0.237s
? chat-agent-system/internal/controlcenter/config [no test files]
ok chat-agent-system/internal/controlcenter/docs 0.205s
ok chat-agent-system/internal/controlcenter/embed 0.004s
ok chat-agent-system/internal/controlcenter/importer 0.017s
ok chat-agent-system/internal/controlcenter/llm 0.004s
ok chat-agent-system/internal/controlcenter/pipeline 0.002s
ok chat-agent-system/internal/controlcenter/server 0.766s
? chat-agent-system/internal/controlcenter/store [no test files]
? chat-agent-system/internal/controlcenter/tasks [no test files]
? chat-agent-system/internal/controlcenter/webui [no test files]
```
**PASS**(含状态机门禁:agent advance 路由缺失、decision 三段校验 400、advance-request 二次 409、claim 冲突 409、testing 跳过、confirmation 推进即 done;lease 过期孤儿恢复由 `internal/controlcenter/audit`/`server` 集成用例与 reaper 单测覆盖)。
**`cd control-center/web && npm run build`** — `tsc && vite build` 通过:
```text
vite v7.3.6 building client environment for production...
✓ 42 modules transformed.
../../internal/controlcenter/webui/dist/index.html 0.40 kB
../../internal/controlcenter/webui/dist/assets/index-DyNgdwvP.css 9.33 kB
../../internal/controlcenter/webui/dist/assets/index-Dj6oWpHL.js 303.55 kB
✓ built in 595ms
```
**PASS**(产物 embed 进 `internal/controlcenter/webui/dist`,与已提交内容一致,无 diff)。
**`go build -o /tmp/control-center-bin ./cmd/control-center`** — 成功。**PASS**
### 5.2 真实服务冒烟(curl)
启动日志:
```text
2026/09/17 12:51:09 goose: no migrations to run. current version: 1
2026/09/17 12:51:09 INFO control-center listening addr=127.0.0.1:8799
2026/09/17 12:51:09 INFO doc task import done added=0 updated=0 skipped=47
```
| # | 用例 | 实际输出(节选) | 判定 |
|---|------|------------------|------|
| 1 | `GET /healthz` | `{"status":"ok"}` HTTP 200 | PASS |
| 2 | `GET /`(SPA) | HTTP 200 `text/html; charset=utf-8` | PASS |
| 3 | `GET /api/v1/overview` 无 token | `{"error":"missing bearer token"}` HTTP 401 | PASS |
| 4 | `GET /api/v1/overview` console token | HTTP 200,`task_counts` 7 组、`open_decisions:1`、`awaiting_review:24`、`open_findings:18`、`agents` 含心跳活性 | PASS |
| 5 | `GET /api/v1/docs/search?q=中控台 流水线 阶段&mode=hybrid&limit=3` | `degraded:false count:3`;top1 score=0.6,heading `### 8. 个人工程中控台`,source `.doc/system-design/README.md`(系统设计文档索引) | PASS(语义命中已知文档) |
| 6 | `GET /api/v1/tasks?stage=proposal` → `GET /api/v1/tasks/{id}/pipeline` | `stage:proposal stage_state:assigned stages:8`;每阶段 artifacts/transitions/decisions 分组完整 | PASS |
| 7 | `GET /api/v1/audits/runs?limit=3` | HTTP 200,3 条 completed 运行(含 findings 计数与 summary) | PASS |
### 5.3 冒烟全链路(建任务→claim→产物→advance-request→人工批准)
```text
POST /api/v1/tasks {"slug":"p6-acceptance-smoke","testing_required":false} → 201 id=be3b1f57-…
register-agent -name p6-smoke-agent → agent "p6-smoke-agent" registered. token (shown once): …
PATCH /api/v1/tasks/{id} {"assign":"p6-smoke-agent"} → after assign: proposal assigned p6-smoke-agent
POST /api/v1/agent/tasks/{id}/claim → after claim: in_progress lease: 2026-09-17T14:52:09Z(2h)
POST /api/v1/agent/tasks/{id}/artifacts → 201 {"id":"3688fda9-…"}
POST /api/v1/agent/tasks/{id}/advance-request → after advance-request: awaiting_review
POST /api/v1/tasks/{id}/stages/advance {"note":"P6 冒烟批准"} → after human advance: analysis assigned done:false
```
**PASS**:proposal → analysis 由人工批准完成,agent 全程未触碰 stage。
### 5.4 负例
| 用例 | 实际输出 | 判定 |
|------|----------|------|
| agent 调 `POST /api/v1/agent/tasks/{id}/stages/advance` | HTTP 405(路由不存在;单测同时断言 404/405 且任务状态不变) | PASS |
| decision 缺建议:`{"question":"没有建议的问题"}` | `{"error":"bad input: at least one suggestion is required"}` HTTP 400 | PASS |
### 5.5 冒烟中发现的缺陷与修复(本报告周期内闭环)
- **缺陷**:`GET /api/v1/tasks?q=<kw>` 返回 500(`SQLSTATE 42601 syntax error at or near "$"`)。根因:`internal/controlcenter/tasks/tasks.go` `ListTasks` 的 Query 分支 format 串含两个 `$%d` 占位但只传一个参数,第二个被 fmt 渲染为 `%!d(MISSING)`。
- **修复**:改为显式索引 `$%[1]d`(同一参数复用两次);新增回归测试 `TestTaskListQueryFilter`(`internal/controlcenter/server/server_test.go`)。
- **复验**:修复后 `go vet` 通过、`./internal/controlcenter/...` 全部 ok、新测试 PASS;真实服务 `tasks?q=p6-acceptance-smoke` 返回 HTTP 200 且精确命中 1 条。
### 5.6 性能与耗时
controlcenter 包 DB 集成测试全量 ≈1.5s;前端构建 595ms;冒烟单请求均为毫秒级(本地 loopback)。
## 六、 自动化测试套件汇总 (Test Suite Execution)
- `go vet ./...`:0 问题
- `go test -short ./...`:全仓 PASS(无 FAIL 行)
- `go test -count=1 ./internal/controlcenter/...`(真实 pgvector):7 个有测试的包全部 ok,0 FAIL
- `npm run build`(control-center/web):PASS,产物 hash 与入库一致
- 冒烟 7 端点 + 全链路 + 2 负例:全部 PASS
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
**结论:后端 P0–P5 验收标准全部通过,达到合入标准。** 唯一冒烟缺陷(tasks `?q=` 500)已在周期内修复并附回归测试。
验收标准逐条:
| 验收标准 | 结果 |
|----------|------|
| `go vet` / `go test -short` / `go build ./cmd/control-center` 全绿 | ✅ 通过 |
| 状态机单测:agent 推进被拒 / decision 缺建议拒收 / lease 过期恢复 | ✅ 通过(单测 + 冒烟负例) |
| `.doc` 全量入库后语义搜索命中已知文档 | ✅ 通过(hybrid,`degraded:false`,top1 命中系统设计索引) |
| curl 冒烟全链路(建任务→claim→产物→advance-request→人工批准→下一阶段) | ✅ 通过(proposal→analysis) |
| 前端 `vite build` 通过并 embed 可用 | ✅ 通过(`GET /` 200 text/html) |
**未完成(如实标注)**:前端 SPA 人工走查(浏览器实机操作 Overview/Board/TaskDetail/Decisions/Docs/Audit/Settings 七页)未执行——本报告仅覆盖构建与 embed 服务可达。FEATURE-MATRIX 对应行保持 `partial`,走查通过后升 `done`。
后续建议:SPA 走查后补截图证据;审查子系统 LLM review 当前冒烟环境未配置 `MINIMAX_*`(输出 "LLM 未接入,仅确定性检查",属设计内降级),建议在用足配置的环境跑一次每日审查全链路;旧 `internal/engineeringdashboard` 弃用评估另立任务。
@@ -2,7 +2,7 @@
## 一、 测试背景 (Background)
仓库需要独立于产品后台的本地只读工程中控台,避免把文档 `done` 误读为已合并或已部署。方案见 [设计](../system-design/project-control-center/01-personal-engineering-control-center-plan.md)。本报告验收一期 `serve` / `export` 实现。
仓库需要独立于产品后台的本地只读工程中控台,避免把文档 `done` 误读为已合并或已部署。方案见 [设计](../archive/design/2026-09-17-personal-engineering-control-center-plan.md)。本报告验收一期 `serve` / `export` 实现。
## 二、 测试目标 (Test Objectives)
@@ -106,4 +106,4 @@ git diff --check
## 相关链接
- 任务: [../archive/ops/short-term/2026-09-17-engineering-dashboard.md](../archive/ops/short-term/2026-09-17-engineering-dashboard.md)
- 设计: [../system-design/project-control-center/01-personal-engineering-control-center-plan.md](../system-design/project-control-center/01-personal-engineering-control-center-plan.md)
- 设计: [../archive/design/2026-09-17-personal-engineering-control-center-plan.md](../archive/design/2026-09-17-personal-engineering-control-center-plan.md)
@@ -1,123 +0,0 @@
# Control Center 合并门禁 测试报告
## 一、 测试背景 (Background)
`feature/control-center-server` 要取代已合入 `dev` 的 `engineering-dashboard`。合并前缺三项证据:核心包单测、真实 LLM 审查、前端七页走查。本报告记录这三项以及端口加固、全量回归和 curl 全链路。
## 二、 测试目标 (Test Objectives)
- `config` / `store` / `tasks` 有可重复的测试,且 PASS。
- `CC_AUDIT_ENABLED=true` 时,真实 MiniMax 返回能被解析,findings 写入 `cc_audit_findings`,并在 `/api/v1/audits/runs` 可查。
- Overview / Board / TaskDetail / Decisions / Docs / Audit / Settings 可打开,无页面 JS 错误,关键交互可用。
- 数据库端口只绑定 `127.0.0.1`。`go vet`、`go build`、`go test -short`、`TEST_CC_DB_DSN` 集成测试、两边前端 build、`internal/archtest` 全绿。
- curl 全链路:建任务 → claim → 产物 → advance-request → 人工批准 → 下一阶段。
## 三、 测试方案与环境 (Test Plan & Environment)
- OS:Linux;Go 模块 `chat-agent-system`;数据库容器 `control-center-db`(pgvector/pg16),DSN `postgres://cc:cc@127.0.0.1:5440/control_center?sslmode=disable`。
- 服务:`CC_HTTP_ADDR=127.0.0.1:8790`,`CC_AUDIT_ENABLED=true`。LLM 借用仓库 `.env` 的 `MINIMAX_*`(模型 `MiniMax-M2.7-highspeed`,端点 `https://api.minimaxi.com/v1`)。密钥不入库。
- 前端走查:IDE 浏览器操作七页;截图由本机 headless Chromium 写入证据目录(无 pageerror / console.error)。
- 证据目录:`.doc/test-reports/assets/2026-09-18-control-center-merge-gate/`。
## 四、 预期结果 (Expected Results)
- `pick()`:`CC_*` 优先于 `.env` 借用键,再回退缺省(`EmbeddingModel=bge-m3`,`HTTPAddr=127.0.0.1:8790`)。
- 租约:未指派不可 claim;他人 claim/heartbeat/release 冲突;过期租约被 reap 后原 agent 可再次 claim。
- 审查:模型输出(含 `<think>`)能解析出至少一条 finding 并落库。
- 七页可加载;文档搜索 `流水线` 返回 hybrid 结果;看板可按标题过滤。
- 冒烟任务从 `proposal` 经人工批准进入 `analysis`;agent 调用 `stages/advance` 得到 405。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 核心包单测
| 包 | 命令 | 结果 | 耗时 |
|----|------|------|------|
| config | `go test -count=1 ./internal/controlcenter/config/` | PASS | 0.002s |
| store | 同上,带 `TEST_CC_DB_DSN` | PASS | 0.011s |
| tasks | 同上 | PASS | 0.019s |
判定:PASS。覆盖 CC 优先、借用 `.env`、缺省回退;`Migrate` + `SELECT count(*) FROM cc_tasks`;claim/heartbeat/release 冲突与过期回收。
### 5.2 真实 LLM 审查
手动审查 run 194(生产路径 `POST /api/v1/audits/run`):
```text
status=completed
summary=确定性 findings: 25;LLM findings: 0
started_at=2026-09-19T03:00:17Z finished_at=2026-09-19T03:00:38Z
```
模型已接通(无“未配置/降级”备注),但返回空数组。随后用同一 `llm.HTTPClient.Chat` + `parseFindingsJSON` 强制一条 finding。首次失败:MiniMax 把推理放在 `<think>` 里,其中的方括号让 JSON 截取失败(`invalid character 'C' after top-level value`)。修复后复跑:
```text
run_id=195 status=completed summary=live LLM gate: 1 parsed finding
title=CC-GATE-LIVE: Real model response parsed but no downstream action triggered
severity=medium kind=gap status=open
refs=["internal/controlcenter/audit"]
```
`GET /api/v1/audits/runs?limit=3` 含 195 与 194。审计页可见该 finding。判定:PASS。摘录见 `llm-audit.json`(无密钥)。
### 5.3 七页走查
| 页 | 观察 | 判定 |
|----|------|------|
| 总览 `/#/` | 待裁决 1、待审 27、Open Findings 51;阶段矩阵与 agent 心跳 | PASS |
| 看板 `/#/board` | proposal 216 张卡;搜索 `Smoke` 命中 | PASS |
| 任务详情 | `Smoke Task`,指派框、批准/退回、Transitions | PASS |
| 决策收件箱 | 待裁决项,采纳建议 1/2,提交裁决 | PASS |
| 文档库 | 搜索「流水线」→ `搜索结果 · hybrid · 20 条` | PASS |
| 审计 | run 195 与 `CC-GATE-LIVE` finding | PASS |
| 设置 | 八阶段 model/executor,保存按钮,Agent 接入说明 | PASS |
`spa-console.txt`:`no pageerror / console.error`。截图:`01-overview.png` … `07-settings.png`,以及 `02b-board-filter.png`、`05b-docs-search.png`。
### 5.4 端口与冒烟
`docker compose up -d --force-recreate` 后:
```text
LISTEN 127.0.0.1:5440
```
不再监听 `0.0.0.0:5440`。
curl 全链路(`curl-smoke.txt`,token 已替换):
```text
GET /healthz -> 200 {"status":"ok"}
GET / -> 200 text/html
GET /api/v1/overview 无 token -> 401 missing bearer token
POST /api/v1/tasks -> 201 stage=proposal state=idle
PATCH assign -> stage_state=assigned
POST claim -> stage_state=in_progress lease=+2h
POST artifacts -> 201
POST advance-request -> stage_state=awaiting_review(stage 仍为 proposal)
POST stages/advance -> 200
after advance stage=analysis state=assigned
POST agent stages/advance -> 405
```
判定:PASS。
## 六、 自动化测试套件汇总 (Test Suite Execution)
日志:`verify.txt`。
| 命令 | 退出码 |
|------|--------|
| `go vet ./...` | 0 |
| `go build ./...` | 0 |
| `go test -short -count=1 ./...` | 0(含 `internal/archtest`) |
| `TEST_CC_DB_DSN=... go test -count=1 ./internal/controlcenter/...` | 0 |
| `go test -count=1 ./internal/archtest/` | 0 |
| `control-center/web` `npm run build` | 0 |
| `web` `npm run build` | 0 |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
达到合入本地 `dev` 的标准。`engineering-dashboard` 代码已删除,个人中控台由 Control Center 承接,FEATURE-MATRIX 行状态为 `done`,mutex 为 `controlcenter`。
后续不在本次范围:不接入 `env-service.sh` 三环境,不改 CI,不重新设计 `serve-lan`,不 push 远端。
@@ -62,4 +62,6 @@
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
达到本波「编排确认」合并标准:文档已落盘,能力状态未伪装完成,红线未动。后续触发点仍是用户授权(SWBOOT-10 / FAV live / D7 签字 / Control Center 取舍)或收益认领(T4 U1、T5、T6、T8),不在本波实施。未 push。
达到本波「编排确认」合并标准:文档已落盘,能力状态未伪装完成,红线未动。后续触发点仍是用户授权(SWBOOT-10 / FAV live / D7 签字)或收益认领(T4 U1、T5、T6、T8),不在本波实施。未 push。
2026-09-19 补充:Control Center 取舍已关闭,模块已移出本仓库。本报告 §5.3 只记录当时未合并的事实。
@@ -0,0 +1,56 @@
# 移除 Control Center 测试报告
## 一、 测试背景 (Background)
项目进度管理已由独立项目承担。本仓库曾合入 Control Center,需要删除其代码、部署、规范和专属文档,并确认产品智能体系统仍能编译、测试和启动。不恢复已下线的 `engineering-dashboard`。
## 二、 测试目标 (Test Objectives)
- 仓库内不再存在 Control Center 源码、compose、脚本和专属文档。
- `go vet`、`go build`、`go test -short`、`internal/archtest`、产品前端 `npm run build` 全部通过。
- 本地 `dev` 容器重建后健康检查为 200。
## 三、 测试方案与环境 (Test Plan & Environment)
- OS:Linux。分支 `fix/remove-control-center`,基线为本地 `dev@2641045`。
- 层级:删除后的编译与短测;模块边界契约测试;产品前端构建。容器冒烟在合入本地 `dev` 之后执行。
- 证据:`.doc/test-reports/assets/2026-09-19-remove-control-center/verify.txt`。
## 四、 预期结果 (Expected Results)
- `cmd/control-center`、`internal/controlcenter`、`control-center/`、`deploy/control-center` 不存在。
- 上述命令退出码均为 0。
- `GET http://127.0.0.1:8080/health` 返回 `env=dev`、`schema_version=52`。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 源码与运行时
- **输入**:删除 Control Center 目录后检查路径与端口。
- **实际输出**:源码目录已不在索引中。`control-center` 进程已停。`docker rm -f control-center-db` 与 `docker volume rm control-center_cc-db-data` 成功。
- **判定**:PASS。
- **耗时**:容器删除 < 1s。
### 5.2 编译与测试
- **输入**:`go vet ./...`、`go build ./...`、`go test -short -count=1 ./...`、`go test -count=1 ./internal/archtest/`、`cd web && npm run build`。
- **实际输出**(摘自 `verify.txt`):
```text
vet=0
build=0
short=0
arch=0
web=0
```
- **判定**:PASS。
- **耗时**:整段验证约 33s。
## 六、 自动化测试套件汇总 (Test Suite Execution)
`go test -short -count=1 ./...` 退出码 0,无 FAIL。`internal/archtest` 单独再跑一次,退出码 0。完整输出见 `verify.txt`。
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
达到合入本地 `dev` 的标准。本仓库不再包含 Control Center。产品 `dev` 重建与健康检查在合入后补记于同一证据目录。不 push。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

@@ -1,52 +0,0 @@
GET /healthz -> 200 application/json; charset=utf-8
{"status":"ok"}
GET / -> 200 text/html; charset=utf-8
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Control Center</title>
<script type="module" crossorigin src="/assets/index-Dj6oWpHL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DyNgdwvP.css">
</head>
<body>
<div id="root"></div>
</body>
</html…
GET /api/v1/overview -> 401 application/json; charset=utf-8
{"error":"missing bearer token"}
POST /api/v1/tasks -> 201 application/json; charset=utf-8
{"id":"e0e58306-7580-4675-9dfc-55107f91cc38","slug":"merge-gate-1789787895","title":"merge gate smoke","kind":"task","status":"planned","stage":"proposal","stage_state":"idle","testing_required":false,"priority":0,"owner":"","agent_kind":"","mutex":"","branch":"","worktree":"","instruction_md":"","doc_path":"","assigned_agent":"","created_at":"2026-09-19T03:18:15.95345Z","updated_at":"2026-09-19T0…
PATCH /api/v1/tasks/e0e58306-7580-4675-9dfc-55107f91cc38 -> 200 application/json; charset=utf-8
{"id":"e0e58306-7580-4675-9dfc-55107f91cc38","slug":"merge-gate-1789787895","title":"merge gate smoke","kind":"task","status":"in_progress","stage":"proposal","stage_state":"assigned","testing_required":false,"priority":0,"owner":"","agent_kind":"","mutex":"","branch":"","worktree":"","instruction_md":"","doc_path":"","assigned_agent":"merge-gate-agent","created_at":"2026-09-19T03:18:15.95345Z","u…
POST /api/v1/agent/tasks/e0e58306-7580-4675-9dfc-55107f91cc38/claim -> 200 application/json; charset=utf-8
{"id":"e0e58306-7580-4675-9dfc-55107f91cc38","slug":"merge-gate-1789787895","title":"merge gate smoke","kind":"task","status":"in_progress","stage":"proposal","stage_state":"in_progress","testing_required":false,"priority":0,"owner":"","agent_kind":"","mutex":"","branch":"","worktree":"","instruction_md":"","doc_path":"","assigned_agent":"merge-gate-agent","lease_expires_at":"2026-09-19T05:18:15.9…
claim stage=proposal state=in_progress lease=2026-09-19T05:18:15.95774Z
POST /api/v1/agent/tasks/e0e58306-7580-4675-9dfc-55107f91cc38/artifacts -> 201 application/json; charset=utf-8
{"id":"81272e26-6089-49c9-b8fd-8f43e67c5392"}
POST /api/v1/agent/tasks/e0e58306-7580-4675-9dfc-55107f91cc38/advance-request -> 200 application/json; charset=utf-8
{"id":"e0e58306-7580-4675-9dfc-55107f91cc38","slug":"merge-gate-1789787895","title":"merge gate smoke","kind":"task","status":"in_progress","stage":"proposal","stage_state":"awaiting_review","testing_required":false,"priority":0,"owner":"","agent_kind":"","mutex":"","branch":"","worktree":"","instruction_md":"","doc_path":"","assigned_agent":"merge-gate-agent","heartbeat_at":"2026-09-19T03:18:15.9…
POST /api/v1/tasks/e0e58306-7580-4675-9dfc-55107f91cc38/stages/advance -> 200 application/json; charset=utf-8
{"done":false,"task":{"id":"e0e58306-7580-4675-9dfc-55107f91cc38","slug":"merge-gate-1789787895","title":"merge gate smoke","kind":"task","status":"in_progress","stage":"analysis","stage_state":"assigned","testing_required":false,"priority":0,"owner":"","agent_kind":"","mutex":"","branch":"","worktree":"","instruction_md":"","doc_path":"","assigned_agent":"merge-gate-agent","heartbeat_at":"2026-09…
GET /api/v1/tasks/e0e58306-7580-4675-9dfc-55107f91cc38 -> 200 application/json; charset=utf-8
{"id":"e0e58306-7580-4675-9dfc-55107f91cc38","slug":"merge-gate-1789787895","title":"merge gate smoke","kind":"task","status":"in_progress","stage":"analysis","stage_state":"assigned","testing_required":false,"priority":0,"owner":"","agent_kind":"","mutex":"","branch":"","worktree":"","instruction_md":"","doc_path":"","assigned_agent":"merge-gate-agent","heartbeat_at":"2026-09-19T03:18:15.95774Z",…
after advance stage=analysis state=assigned status=in_progress
POST /api/v1/agent/tasks/e0e58306-7580-4675-9dfc-55107f91cc38/stages/advance -> 405 text/plain; charset=utf-8
Method Not Allowed
@@ -1,39 +0,0 @@
{
"manual_run_194": {
"id": 194,
"kind": "manual",
"status": "completed",
"summary": "确定性 findings: 25;LLM findings: 0",
"error": "",
"started_at": "2026-09-19T03:00:17.249224Z",
"finished_at": "2026-09-19T03:00:38.880135Z",
"findings": 25
},
"live_run": {
"id": 195,
"kind": "manual",
"status": "completed",
"summary": "live LLM gate: 1 parsed finding",
"error": "",
"started_at": "2026-09-19T03:03:49.443754Z",
"finished_at": "2026-09-19T03:03:49.447922Z",
"findings": 1
},
"live_finding": {
"id": "63634acb-e56a-44f6-8f71-add399f71134",
"run_id": 195,
"severity": "medium",
"kind": "gap",
"title": "CC-GATE-LIVE: Real model response parsed but no downstream action triggered",
"detail_md": "The system receives a real gate‑live model response and successfully parses it, yet no downstream action (e.g., logging, notification, or status update) is initiated, leaving the parsed data unused.",
"refs": [
"internal/controlcenter/audit"
],
"status": "open",
"created_at": "2026-09-19T03:03:49.447144Z",
"run_started_at": "2026-09-19T03:03:49.443754Z"
},
"model": "MiniMax-M2.7-highspeed",
"endpoint": "https://api.minimaxi.com/v1",
"excerpt": "<think>\nThe user says: \"这是一次门禁探测,不是真实缺陷清单。请只输出恰好一条 finding:severity=medium,kind=gap,title 必须以 CC-GATE-LIVE 开头,detail 说明这是真实模型返回且已被解析,refs 包含 internal/controlcenter/audit.\"\n\nWe must output exactly one finding in JSON array with one object: severity \"medium\", kind \"gap\", title starting with \"CC-GATE-LIVE\". The detail should say it's a real model return and has been parsed. The refs should include \"internal/controlcenter/audit\". That is the entire response.\n\nWe must obey the instruction to output only the JSON array, no extra text.\n\nWe need to think about whether the user is requesting us to produce content that is a 'finding' about some bug detection. The user is asking for a finding (a …"
}
@@ -1 +0,0 @@
no pageerror / console.error
@@ -1,2 +0,0 @@
board filter contains Smoke: true
console errors: 0
@@ -1,118 +1,78 @@
=== go vet ===
vet_exit=0
=== go build ===
build_exit=0
=== go test -short ===
=== vet ===
vet=0
=== build ===
build=0
=== short ===
? chat-agent-system/cmd/artifact-vault [no test files]
ok chat-agent-system/cmd/bizdev-cli 2.328s
? chat-agent-system/cmd/control-center [no test files]
ok chat-agent-system/cmd/bizdev-cli 2.260s
? chat-agent-system/cmd/dataset [no test files]
ok chat-agent-system/cmd/document-content-reconcile 0.007s
ok chat-agent-system/cmd/document-content-reconcile 0.003s
? chat-agent-system/cmd/document-revision-backfill [no test files]
? chat-agent-system/cmd/eventmode [no test files]
? chat-agent-system/cmd/events [no test files]
? chat-agent-system/cmd/recategorize [no test files]
? chat-agent-system/cmd/server [no test files]
ok chat-agent-system/internal/agent 5.940s
ok chat-agent-system/internal/app 0.031s
ok chat-agent-system/internal/agent 3.580s
ok chat-agent-system/internal/app 0.032s
ok chat-agent-system/internal/app/chatflow 0.005s
ok chat-agent-system/internal/app/docadvance 0.013s
ok chat-agent-system/internal/app/docflow 0.009s
ok chat-agent-system/internal/app/docnotify 0.011s
ok chat-agent-system/internal/app/docproject 0.007s
ok chat-agent-system/internal/app/fav 0.009s
ok chat-agent-system/internal/app/intentflow 0.028s
ok chat-agent-system/internal/app/stageflow 0.012s
ok chat-agent-system/internal/app/docadvance 0.006s
ok chat-agent-system/internal/app/docflow 0.006s
ok chat-agent-system/internal/app/docnotify 0.003s
ok chat-agent-system/internal/app/docproject 0.003s
ok chat-agent-system/internal/app/fav 0.003s
ok chat-agent-system/internal/app/intentflow 0.020s
ok chat-agent-system/internal/app/stageflow 0.007s
ok chat-agent-system/internal/app/uow 0.003s
ok chat-agent-system/internal/archtest 0.290s
ok chat-agent-system/internal/artifactvault 0.018s
ok chat-agent-system/internal/auth 0.998s
ok chat-agent-system/internal/authz 0.017s
ok chat-agent-system/internal/bizdev 0.226s
ok chat-agent-system/internal/chat 0.038s
ok chat-agent-system/internal/config 0.014s
ok chat-agent-system/internal/controlcenter/audit 0.005s
ok chat-agent-system/internal/controlcenter/config 0.003s
ok chat-agent-system/internal/controlcenter/docs 0.282s
ok chat-agent-system/internal/controlcenter/embed 0.008s
ok chat-agent-system/internal/controlcenter/importer 0.008s
ok chat-agent-system/internal/controlcenter/llm 0.013s
ok chat-agent-system/internal/controlcenter/pipeline 0.002s
ok chat-agent-system/internal/controlcenter/server 0.005s
ok chat-agent-system/internal/controlcenter/store 0.004s
ok chat-agent-system/internal/controlcenter/tasks 0.003s
? chat-agent-system/internal/controlcenter/webui [no test files]
ok chat-agent-system/internal/archtest 0.194s
ok chat-agent-system/internal/artifactvault 0.005s
ok chat-agent-system/internal/auth 0.989s
ok chat-agent-system/internal/authz 0.003s
ok chat-agent-system/internal/bizdev 0.166s
ok chat-agent-system/internal/chat 0.014s
ok chat-agent-system/internal/config 0.004s
ok chat-agent-system/internal/coordinator 0.011s
ok chat-agent-system/internal/ctxmgr 0.083s
ok chat-agent-system/internal/dataset 0.005s
ok chat-agent-system/internal/db 0.005s
ok chat-agent-system/internal/ctxmgr 0.088s
ok chat-agent-system/internal/dataset 0.004s
ok chat-agent-system/internal/db 0.004s
ok chat-agent-system/internal/doccontent 0.002s
ok chat-agent-system/internal/docduty 0.004s
ok chat-agent-system/internal/docgov 0.023s
ok chat-agent-system/internal/docgov 0.025s
ok chat-agent-system/internal/docquery 0.004s
ok chat-agent-system/internal/docrevision 0.008s
ok chat-agent-system/internal/events 0.006s
ok chat-agent-system/internal/freedoc 0.024s
ok chat-agent-system/internal/events 0.005s
ok chat-agent-system/internal/freedoc 0.025s
ok chat-agent-system/internal/fsutil 0.002s
ok chat-agent-system/internal/httpx 0.003s
ok chat-agent-system/internal/intent 0.023s
ok chat-agent-system/internal/httpx 0.004s
ok chat-agent-system/internal/intent 0.022s
ok chat-agent-system/internal/kb 0.009s
ok chat-agent-system/internal/maturity 0.002s
ok chat-agent-system/internal/notification 0.073s
ok chat-agent-system/internal/notification 0.085s
? chat-agent-system/internal/observability [no test files]
ok chat-agent-system/internal/projectcontext 0.004s
ok chat-agent-system/internal/projectinstance 0.005s
ok chat-agent-system/internal/projectworkflow 0.002s
ok chat-agent-system/internal/quote 0.007s
ok chat-agent-system/internal/replyexport 0.002s
ok chat-agent-system/internal/server 14.119s
ok chat-agent-system/internal/squad 0.029s
ok chat-agent-system/internal/storage 0.012s
ok chat-agent-system/internal/stream 0.002s
ok chat-agent-system/internal/projectinstance 0.004s
ok chat-agent-system/internal/projectworkflow 0.003s
ok chat-agent-system/internal/quote 0.008s
ok chat-agent-system/internal/replyexport 0.003s
ok chat-agent-system/internal/server 14.027s
ok chat-agent-system/internal/squad 0.033s
ok chat-agent-system/internal/storage 0.015s
ok chat-agent-system/internal/stream 0.003s
ok chat-agent-system/internal/stream/transport 0.002s
ok chat-agent-system/internal/swboot 0.001s
ok chat-agent-system/internal/swboot 0.002s
ok chat-agent-system/internal/sysadmin 0.003s
ok chat-agent-system/internal/taskrun 0.192s
ok chat-agent-system/internal/template 2.799s
ok chat-agent-system/internal/template 0.817s
ok chat-agent-system/internal/upload 0.002s
? chat-agent-system/internal/workflow [no test files]
ok chat-agent-system/internal/workflow/runtime 0.002s
ok chat-agent-system/internal/workflow/runtime 0.003s
ok chat-agent-system/internal/workflowdef 0.004s
ok chat-agent-system/internal/workspace 0.002s
ok chat-agent-system/internal/workspace 0.003s
? chat-agent-system/templates [no test files]
short_exit=0
=== controlcenter integration ===
ok chat-agent-system/internal/controlcenter/audit 0.249s
ok chat-agent-system/internal/controlcenter/config 0.002s
ok chat-agent-system/internal/controlcenter/docs 0.203s
ok chat-agent-system/internal/controlcenter/embed 0.004s
ok chat-agent-system/internal/controlcenter/importer 0.015s
ok chat-agent-system/internal/controlcenter/llm 0.004s
ok chat-agent-system/internal/controlcenter/pipeline 0.002s
ok chat-agent-system/internal/controlcenter/server 1.107s
ok chat-agent-system/internal/controlcenter/store 0.010s
ok chat-agent-system/internal/controlcenter/tasks 0.016s
? chat-agent-system/internal/controlcenter/webui [no test files]
cc_exit=0
short=0
=== archtest ===
ok chat-agent-system/internal/archtest 0.161s
arch_exit=0
=== control-center web build ===
> control-center-web@0.3.0 build
> tsc && vite build
vite v7.3.6 building client environment for production...
transforming...
✓ 42 modules transformed.
rendering chunks...
computing gzip size...
../../internal/controlcenter/webui/dist/index.html 0.40 kB │ gzip: 0.27 kB
../../internal/controlcenter/webui/dist/assets/index-DyNgdwvP.css 9.33 kB │ gzip: 2.30 kB
../../internal/controlcenter/webui/dist/assets/index-Dj6oWpHL.js 303.55 kB │ gzip: 93.80 kB
✓ built in 557ms
ccweb_exit=0
=== product web build ===
ok chat-agent-system/internal/archtest 0.154s
arch=0
=== web ===
> shuimu-workbench@1.0.0 build
> tsc && vite build && node scripts/precompress-dist.mjs
@@ -248,7 +208,7 @@ dist/assets/index-DhyNmxJE.js 2,829.14 kB │ gzip: 805.
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 7.59s
✓ built in 7.55s
precompress: 49 candidate(s) under /home/weichen/projects/Agent-System/web/dist
br assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 798 (−55.0%)
gzip assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 872 (−50.8%)
@@ -349,5 +309,4 @@ precompress: 49 candidate(s) under /home/weichen/projects/Agent-System/web/dist
br fonts/OFL.txt 4474 → 1437 (−67.9%)
gzip fonts/OFL.txt 4474 → 1980 (−55.7%)
precompress: wrote 49 .br and 49 .gz
web_exit=0
DONE
web=0
-2
View File
@@ -55,8 +55,6 @@ node_modules/
*.o
*.a
/server
# Control Center SPA embed output is committed (go build works without node)
!internal/controlcenter/webui/dist/
# Regulations Knowledge Base Data
.regulations/
-4
View File
@@ -254,10 +254,6 @@ audit(问题)→ remediation(方案)→ ops/short-term(任务)
均转发至本文件启动链。
### 13.1 个人中控台(Control Center)
本仓库有独立运行的个人项目中控台(loopback `127.0.0.1:8790`,独立于产品系统):agent 在本仓库工作可通过它认领任务、上报进度/产物/决策。**铁律:agent 禁止自行推进阶段,只能 advance-request 交回人工。**接入步骤(注册 token、工作循环、CLI 速查)见 [agent-integration.md](./.doc/system-design/project-control-center/agent-integration.md)。
---
## 14. 模板索引
-401
View File
@@ -1,401 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// Thin agent-side HTTP client over the Control Center agent API. Output is
// compact JSON on stdout; failures print the server's error field to stderr
// and exit non-zero.
type agentClient struct {
base string
token string
hc *http.Client
}
func newAgentClient(server, token string) (*agentClient, error) {
if token == "" {
return nil, fmt.Errorf("agent token required: pass -token or set CC_AGENT_TOKEN")
}
return &agentClient{
base: strings.TrimRight(server, "/"),
token: token,
hc: &http.Client{Timeout: 30 * time.Second},
}, nil
}
// do performs one request and returns the decoded body. Server error
// responses are unwrapped into an error carrying the server's error field.
func (c *agentClient) do(method, path string, body any) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, c.base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
if resp.StatusCode >= 400 {
var e struct {
Error string `json:"error"`
}
if json.Unmarshal(raw, &e) == nil && e.Error != "" {
return nil, fmt.Errorf("%s %s: %s (%d)", method, path, e.Error, resp.StatusCode)
}
return nil, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
}
return json.RawMessage(raw), nil
}
func printJSON(raw json.RawMessage) {
if len(raw) == 0 {
fmt.Println("null")
return
}
// Re-encode compactly so output is stable regardless of server framing.
var v any
if err := json.Unmarshal(raw, &v); err != nil {
fmt.Println(string(raw))
return
}
out, _ := json.Marshal(v)
fmt.Println(string(out))
}
func envDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// commonFlags registers -server and -token on fs and returns a client
// factory.
func commonFlags(fs *flag.FlagSet) func() (*agentClient, error) {
server := fs.String("server", envDefault("CC_SERVER", "http://127.0.0.1:8790"), "control-center base URL")
token := fs.String("token", envDefault("CC_AGENT_TOKEN", ""), "agent token (or CC_AGENT_TOKEN)")
return func() (*agentClient, error) { return newAgentClient(*server, *token) }
}
func readContent(file, content string) (string, error) {
if file != "" {
raw, err := os.ReadFile(file)
if err != nil {
return "", err
}
return string(raw), nil
}
if content == "" {
return "", fmt.Errorf("one of -file or -content is required")
}
return content, nil
}
func cmdAgentNext(args []string) error {
fs := flag.NewFlagSet("next", flag.ContinueOnError)
mk := commonFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("GET", "/api/v1/agent/tasks/next", nil)
if err != nil {
return err
}
if len(raw) == 0 {
fmt.Println(`{"task":null}`)
return nil
}
var t struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Stage string `json:"stage"`
}
if err := json.Unmarshal(raw, &t); err != nil {
return err
}
out, _ := json.Marshal(map[string]string{
"id": t.ID, "slug": t.Slug, "title": t.Title, "stage": t.Stage,
})
fmt.Println(string(out))
return nil
}
func cmdAgentInstruction(args []string) error {
fs := flag.NewFlagSet("instruction", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid; use `next` to discover)")
asMD := fs.Bool("md", false, "render a readable summary instead of raw JSON")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" {
return fmt.Errorf("-task is required")
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("GET", "/api/v1/agent/tasks/"+*task+"/instruction", nil)
if err != nil {
return err
}
if !*asMD {
printJSON(raw)
return nil
}
var pkg struct {
Task struct {
Slug string `json:"slug"`
Title string `json:"title"`
InstructionMD string `json:"instruction_md"`
} `json:"task"`
Stage string `json:"stage"`
StageState string `json:"stage_state"`
Deliverable string `json:"deliverable"`
DecisionRules string `json:"decision_rules"`
}
if err := json.Unmarshal(raw, &pkg); err != nil {
return err
}
fmt.Printf("# %s (%s)\nstage: %s [%s]\n\n## 交付要求\n%s\n\n## 决策规则\n%s\n\n## 任务说明\n%s\n",
pkg.Task.Title, pkg.Task.Slug, pkg.Stage, pkg.StageState,
pkg.Deliverable, pkg.DecisionRules, pkg.Task.InstructionMD)
return nil
}
// simpleTaskAction covers claim / heartbeat / release: POST with no body.
func simpleTaskAction(name string) func([]string) error {
return func(args []string) error {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" {
return fmt.Errorf("-task is required")
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/"+name, nil)
if err != nil {
return err
}
printJSON(raw)
return nil
}
}
func cmdAgentEvent(args []string) error {
fs := flag.NewFlagSet("event", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
phase := fs.String("phase", "", "event phase: start|plan|progress|blocked|report|done|advance-request|decision|system")
message := fs.String("message", "", "event message")
payload := fs.String("payload", "", "optional JSON payload")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" || *phase == "" {
return fmt.Errorf("-task and -phase are required")
}
body := map[string]any{"phase": *phase, "message": *message}
if *payload != "" {
var p any
if err := json.Unmarshal([]byte(*payload), &p); err != nil {
return fmt.Errorf("-payload must be valid JSON: %w", err)
}
body["payload"] = json.RawMessage(*payload)
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/events", body)
if err != nil {
return err
}
printJSON(raw)
return nil
}
func cmdAgentArtifact(args []string) error {
fs := flag.NewFlagSet("artifact", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
kind := fs.String("kind", "note", "artifact kind, e.g. proposal|analysis|design|implementation|test-report|evaluation")
title := fs.String("title", "", "artifact title")
file := fs.String("file", "", "read content_md from file")
content := fs.String("content", "", "inline content_md")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" {
return fmt.Errorf("-task is required")
}
md, err := readContent(*file, *content)
if err != nil {
return err
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/artifacts", map[string]string{
"kind": *kind, "title": *title, "content_md": md,
})
if err != nil {
return err
}
printJSON(raw)
return nil
}
func cmdAgentAdvanceRequest(args []string) error {
fs := flag.NewFlagSet("advance-request", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
summary := fs.String("summary", "", "handoff summary for human review")
artifacts := fs.String("artifacts", "", "comma-separated artifact ids")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" {
return fmt.Errorf("-task is required")
}
ids := []string{}
if *artifacts != "" {
for _, id := range strings.Split(*artifacts, ",") {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/advance-request", map[string]any{
"summary": *summary, "artifact_ids": ids,
})
if err != nil {
return err
}
printJSON(raw)
return nil
}
// suggestions collects repeated -suggestion "选项||理由||影响" flags.
type suggestions []map[string]string
func (s *suggestions) String() string { return fmt.Sprint([]map[string]string(*s)) }
func (s *suggestions) Set(v string) error {
parts := strings.SplitN(v, "||", 3)
if len(parts) != 3 {
return fmt.Errorf("suggestion must be \"选项||理由||影响\"")
}
*s = append(*s, map[string]string{
"option": strings.TrimSpace(parts[0]),
"rationale": strings.TrimSpace(parts[1]),
"impact": strings.TrimSpace(parts[2]),
})
return nil
}
func cmdAgentDecision(args []string) error {
fs := flag.NewFlagSet("decision", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
question := fs.String("question", "", "question for the human (required)")
contextMD := fs.String("context", "", "markdown context")
var suggs suggestions
fs.Var(&suggs, "suggestion", `"选项||理由||影响"; repeatable, at least one required`)
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" || *question == "" {
return fmt.Errorf("-task and -question are required")
}
if len(suggs) == 0 {
return fmt.Errorf("at least one -suggestion is required")
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/tasks/"+*task+"/decisions", map[string]any{
"question": *question, "context_md": *contextMD, "suggestions": suggs,
})
if err != nil {
return err
}
printJSON(raw)
return nil
}
func cmdAgentReport(args []string) error {
fs := flag.NewFlagSet("report", flag.ContinueOnError)
mk := commonFlags(fs)
task := fs.String("task", "", "task id (uuid)")
kind := fs.String("kind", "", "result|test-report|handoff")
file := fs.String("file", "", "read content_md from file")
content := fs.String("content", "", "inline content_md")
if err := fs.Parse(args); err != nil {
return err
}
if *task == "" || *kind == "" {
return fmt.Errorf("-task and -kind are required")
}
md, err := readContent(*file, *content)
if err != nil {
return err
}
c, err := mk()
if err != nil {
return err
}
raw, err := c.do("POST", "/api/v1/agent/reports", map[string]string{
"task_id": *task, "kind": *kind, "content_md": md,
})
if err != nil {
return err
}
printJSON(raw)
return nil
}
-270
View File
@@ -1,270 +0,0 @@
// Control Center: personal project control plane, independent of the product
// system. Subcommands: serve, migrate, register-agent, import-docs, plus the
// agent CLI (next / instruction / claim / heartbeat / release / event /
// artifact / advance-request / decision / report).
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"chat-agent-system/internal/controlcenter/audit"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/docs"
"chat-agent-system/internal/controlcenter/embed"
"chat-agent-system/internal/controlcenter/importer"
"chat-agent-system/internal/controlcenter/llm"
"chat-agent-system/internal/controlcenter/server"
"chat-agent-system/internal/controlcenter/store"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "serve":
err = cmdServe(os.Args[2:])
case "migrate":
err = cmdMigrate(os.Args[2:])
case "register-agent":
err = cmdRegisterAgent(os.Args[2:])
case "import-docs":
err = cmdImportDocs(os.Args[2:])
case "next":
err = cmdAgentNext(os.Args[2:])
case "instruction":
err = cmdAgentInstruction(os.Args[2:])
case "claim":
err = simpleTaskAction("claim")(os.Args[2:])
case "heartbeat":
err = simpleTaskAction("heartbeat")(os.Args[2:])
case "release":
err = simpleTaskAction("release")(os.Args[2:])
case "event":
err = cmdAgentEvent(os.Args[2:])
case "artifact":
err = cmdAgentArtifact(os.Args[2:])
case "advance-request":
err = cmdAgentAdvanceRequest(os.Args[2:])
case "decision":
err = cmdAgentDecision(os.Args[2:])
case "report":
err = cmdAgentReport(os.Args[2:])
default:
usage()
os.Exit(2)
}
if err != nil {
slog.Error("control-center", "err", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, `control-center — personal project control plane
usage:
control-center serve run HTTP server (CC_HTTP_ADDR, default 127.0.0.1:8790)
control-center migrate apply goose migrations only
control-center register-agent -name X [-kind K] [-token T] create/rotate an agent token
control-center import-docs import .doc task docs into cc_tasks (also runs periodically in serve)
agent CLI (common flags: -server, default $CC_SERVER or http://127.0.0.1:8790;
-token, default $CC_AGENT_TOKEN; -task takes a task uuid — use 'next' to discover ids):
next print next claimable task (slug/title/id/stage)
instruction -task ID [-md] print the instruction package (JSON, or readable with -md)
claim|heartbeat|release -task ID lease lifecycle
event -task ID -phase P -message M [-payload '<json>']
artifact -task ID -kind K -title T [-file F | -content MD]
advance-request -task ID -summary S [-artifacts id1,id2]
decision -task ID -question Q -context MD -suggestion "选项||理由||影响" [-suggestion ...]
report -task ID -kind result|test-report|handoff [-file F | -content MD]`)
}
func cmdServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx := context.Background()
if err := store.Migrate(ctx, cfg.DBDSN, cfg.MigrationsDir); err != nil {
return err
}
st, err := store.Connect(ctx, cfg.DBDSN)
if err != nil {
return err
}
defer st.Close()
loopback := isLoopbackAddr(cfg.HTTPAddr)
if cfg.ConsoleToken == "" {
if !loopback {
return fmt.Errorf("refusing to bind %s without a fixed console token: set CC_CONSOLE_TOKEN", cfg.HTTPAddr)
}
cfg.ConsoleToken = randomToken()
slog.Warn("CC_CONSOLE_TOKEN not set; generated ephemeral console token")
}
if !loopback {
slog.Warn("control-center is reachable from the network; all /api routes require tokens")
}
srv := server.New(cfg, st)
var emb *embed.HTTPClient
if cfg.EmbeddingEndpoint != "" {
emb = embed.NewHTTP(cfg.EmbeddingEndpoint, cfg.EmbeddingAPIKey, cfg.EmbeddingModel, embed.Dim)
} else {
slog.Warn("embedding endpoint not configured; search runs keyword-only")
}
syncer := docs.New(st, cfg.RepoRoot, emb)
var embIface embed.Embedder
if emb != nil {
embIface = emb
}
srv.SetDocs(embIface, syncer)
chat := llm.NewHTTP(cfg.LLMEndpoint, cfg.LLMAPIKey, cfg.LLMModel)
if !chat.Configured() {
slog.Warn("LLM endpoint/model not configured; audits run deterministic checks only")
}
auditRunner := audit.New(st, cfg.RepoRoot, chat)
srv.SetAudit(auditRunner)
syncCtx, stopSync := context.WithCancel(ctx)
defer stopSync()
syncer.StartPeriodic(syncCtx, time.Duration(cfg.DocSyncSecond)*time.Second)
importer.New(st, cfg.RepoRoot).StartPeriodic(syncCtx, time.Duration(cfg.DocSyncSecond)*time.Second)
if cfg.AuditEnabled {
auditRunner.StartScheduler(syncCtx, cfg.AuditDailyAt)
}
reapCtx, stopReaper := context.WithCancel(ctx)
defer stopReaper()
srv.StartLeaseReaper(reapCtx)
httpSrv := &http.Server{Addr: cfg.HTTPAddr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
stopSync()
stopReaper()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(shutdownCtx)
}()
slog.Info("control-center listening", "addr", cfg.HTTPAddr)
fmt.Printf("console token: %s\n", cfg.ConsoleToken)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func cmdMigrate(args []string) error {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
return store.Migrate(context.Background(), cfg.DBDSN, cfg.MigrationsDir)
}
func cmdImportDocs(args []string) error {
fs := flag.NewFlagSet("import-docs", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx := context.Background()
st, err := store.Connect(ctx, cfg.DBDSN)
if err != nil {
return err
}
defer st.Close()
stats, err := importer.New(st, cfg.RepoRoot).Import(ctx)
if err != nil {
return err
}
fmt.Printf("import-docs: added=%d updated=%d skipped=%d\n", stats.Added, stats.Updated, stats.Skipped)
return nil
}
func cmdRegisterAgent(args []string) error {
fs := flag.NewFlagSet("register-agent", flag.ContinueOnError)
name := fs.String("name", "", "agent name (required)")
kind := fs.String("kind", "generic", "agent kind, e.g. cursor / kimi / pi")
token := fs.String("token", "", "plaintext token; generated when empty")
if err := fs.Parse(args); err != nil {
return err
}
if *name == "" {
return fmt.Errorf("-name is required")
}
cfg, err := config.Load()
if err != nil {
return err
}
ctx := context.Background()
if err := store.Migrate(ctx, cfg.DBDSN, cfg.MigrationsDir); err != nil {
return err
}
st, err := store.Connect(ctx, cfg.DBDSN)
if err != nil {
return err
}
defer st.Close()
if *token == "" {
*token = randomToken()
}
if err := server.RegisterAgent(ctx, st, *name, *kind, *token); err != nil {
return err
}
fmt.Printf("agent %q registered. token (shown once): %s\n", *name, *token)
return nil
}
func randomToken() string {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
// isLoopbackAddr reports whether addr ("host:port") binds only to loopback.
func isLoopbackAddr(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if host == "" {
return false // ":8790" listens on all interfaces
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return host == "localhost"
}
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Control Center</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-23
View File
@@ -1,23 +0,0 @@
{
"name": "control-center-web",
"private": true,
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"marked": "^18.0.13",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.0",
"typescript": "~5.9.3",
"vite": "^7.1.14"
}
}
-116
View File
@@ -1,116 +0,0 @@
import { useEffect, useState, type FormEvent } from 'react'
import { getToken, setToken, onUnauthorized } from './api'
import { useHashPath, nav } from './router'
import Overview from './pages/Overview'
import Board from './pages/Board'
import TaskDetail from './pages/TaskDetail'
import Decisions from './pages/Decisions'
import Docs from './pages/Docs'
import Settings from './pages/Settings'
import Audit from './pages/Audit'
const NAV: { path: string; label: string }[] = [
{ path: '/', label: '总览' },
{ path: '/board', label: '看板' },
{ path: '/decisions', label: '决策收件箱' },
{ path: '/docs', label: '文档库' },
{ path: '/settings', label: '设置' },
{ path: '/audit', label: '审计' },
]
function Login({ onLogin }: { onLogin: () => void }) {
const [value, setValue] = useState('')
const submit = (e: FormEvent) => {
e.preventDefault()
if (!value.trim()) return
setToken(value.trim())
onLogin()
}
return (
<div className="login-wrap">
<div className="panel login-box">
<h3>Control Center</h3>
<form onSubmit={submit}>
<label className="dim mono-sm" htmlFor="token">Console Token</label>
<input
id="token"
type="password"
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="CC_CONSOLE_TOKEN"
/>
<button className="primary" type="submit">进入</button>
<span className="dim mono-sm">token 存于 localStorage,401 时自动清除。</span>
</form>
</div>
</div>
)
}
function Page({ path }: { path: string }) {
const taskMatch = path.match(/^\/tasks\/([^/]+)$/)
if (taskMatch) return <TaskDetail id={taskMatch[1]} />
switch (path) {
case '/':
return <Overview />
case '/board':
return <Board />
case '/decisions':
return <Decisions />
case '/docs':
return <Docs />
case '/settings':
return <Settings />
case '/audit':
return <Audit />
default:
return <Overview />
}
}
export default function App() {
const [authed, setAuthed] = useState(() => getToken() !== '')
const path = useHashPath()
useEffect(() => {
onUnauthorized(() => setAuthed(false))
}, [])
if (!authed) {
return <Login onLogin={() => setAuthed(true)} />
}
const active = path.startsWith('/tasks/') || path === '/board' ? '/board' : path
return (
<div className="layout">
<nav className="sidebar">
<h1>Control Center</h1>
{NAV.map((n) => (
<a
key={n.path}
href={`#${n.path}`}
className={active === n.path ? 'active' : ''}
>
{n.label}
</a>
))}
<div style={{ marginTop: 16, padding: '0 8px' }}>
<button
className="mono-sm"
onClick={() => {
setToken('')
setAuthed(false)
nav('/')
}}
>
退出登录
</button>
</div>
</nav>
<main className="main">
<Page path={path} />
</main>
</div>
)
}
-79
View File
@@ -1,79 +0,0 @@
// Token-aware fetch wrapper for the console API. The token lives in
// localStorage; a 401 clears it and bounces the app back to the login screen.
const TOKEN_KEY = 'cc_console_token'
let unauthorizedHandler: () => void = () => {}
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ''
}
export function setToken(token: string) {
if (token) {
localStorage.setItem(TOKEN_KEY, token)
} else {
localStorage.removeItem(TOKEN_KEY)
}
}
export function onUnauthorized(fn: () => void) {
unauthorizedHandler = fn
}
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {
Authorization: `Bearer ${getToken()}`,
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
}
const resp = await fetch(`/api/v1${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
if (resp.status === 401) {
setToken('')
unauthorizedHandler()
throw new ApiError(401, 'unauthorized')
}
if (!resp.ok) {
let msg = `http ${resp.status}`
try {
const data = (await resp.json()) as { error?: string }
if (data.error) msg = data.error
} catch {
// keep generic message
}
throw new ApiError(resp.status, msg)
}
if (resp.status === 204) {
return undefined as T
}
return (await resp.json()) as T
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
}
export function qs(params: Record<string, string | undefined>): string {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v) sp.set(k, v)
}
const s = sp.toString()
return s ? `?${s}` : ''
}
-306
View File
@@ -1,306 +0,0 @@
:root {
--bg: #0d1117;
--bg-panel: #161b22;
--bg-hover: #1c2330;
--border: #2b3442;
--fg: #d6dde6;
--fg-dim: #8b97a7;
--accent: #4f9cf9;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
--purple: #bc8cff;
--cyan: #39c5cf;
font-size: 14px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: "SF Mono", "Cascadia Code", Menlo, Consolas, "PingFang SC", "Microsoft YaHei", monospace;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
button {
background: var(--bg-hover);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 10px;
font: inherit;
cursor: pointer;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: #0d1117; font-weight: 600; }
button.primary:hover:not(:disabled) { color: #0d1117; filter: brightness(1.15); }
button.danger { border-color: var(--red); color: var(--red); }
input, select, textarea {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font: inherit;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
textarea { width: 100%; resize: vertical; }
/* layout */
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 180px;
flex: 0 0 180px;
background: var(--bg-panel);
border-right: 1px solid var(--border);
padding: 12px 8px;
position: sticky;
top: 0;
height: 100vh;
}
.sidebar h1 {
font-size: 13px;
letter-spacing: 1px;
color: var(--fg-dim);
margin: 4px 8px 14px;
text-transform: uppercase;
}
.sidebar a {
display: block;
padding: 6px 10px;
border-radius: 4px;
color: var(--fg);
margin-bottom: 2px;
}
.sidebar a:hover { background: var(--bg-hover); text-decoration: none; }
.sidebar a.active { background: var(--bg-hover); color: var(--accent); border-left: 2px solid var(--accent); }
.main { flex: 1; padding: 16px 20px; min-width: 0; }
.page-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.page-head h2 { margin: 0; font-size: 16px; }
.page-head .spacer { flex: 1; }
.panel {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
margin-bottom: 14px;
}
.panel h3 { margin: 0 0 10px; font-size: 13px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 1px; }
.error-box {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--red);
border-radius: 4px;
color: var(--red);
padding: 8px 12px;
margin-bottom: 12px;
white-space: pre-wrap;
}
.dim { color: var(--fg-dim); }
.mono-sm { font-size: 12px; }
/* login */
.login-wrap { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box { width: 380px; }
.login-box form { display: flex; flex-direction: column; gap: 10px; }
/* badges */
.badge {
display: inline-block;
padding: 1px 7px;
border-radius: 10px;
font-size: 11px;
border: 1px solid var(--border);
color: var(--fg-dim);
white-space: nowrap;
}
.badge.st-idle { color: var(--fg-dim); }
.badge.st-assigned { color: var(--cyan); border-color: var(--cyan); }
.badge.st-in_progress { color: var(--accent); border-color: var(--accent); }
.badge.st-awaiting_review { color: var(--yellow); border-color: var(--yellow); }
.badge.st-changes_requested { color: var(--red); border-color: var(--red); }
.badge.st-approved { color: var(--green); border-color: var(--green); }
.badge.status-blocked_pending_decision { color: var(--purple); border-color: var(--purple); }
.badge.status-done { color: var(--green); border-color: var(--green); }
.badge.status-in_progress { color: var(--accent); border-color: var(--accent); }
.badge.status-cancelled { color: var(--fg-dim); }
.badge.sev-low { color: var(--fg-dim); }
.badge.sev-medium { color: var(--accent); border-color: var(--accent); }
.badge.sev-high { color: var(--yellow); border-color: var(--yellow); }
.badge.sev-critical { color: var(--red); border-color: var(--red); }
/* audit */
.finding-card { border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; margin-bottom: 10px; background: var(--bg-panel); }
.finding-card.sev-high, .finding-card.sev-critical { border-left: 3px solid var(--yellow); }
.finding-card.sev-critical { border-left-color: var(--red); }
.finding-card .f-head { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; cursor: pointer; }
.finding-card .f-title { font-weight: 600; }
.finding-card .f-body { margin-top: 8px; border-top: 1px solid var(--border); padding-top: 8px; }
.finding-card .f-refs { font-size: 12px; color: var(--fg-dim); margin-top: 6px; }
.finding-card .f-actions { display: flex; gap: 8px; margin-top: 8px; }
.runs-table { border-collapse: collapse; width: 100%; }
.runs-table th, .runs-table td { border: 1px solid var(--border); padding: 5px 8px; text-align: left; font-size: 12px; }
.runs-table th { color: var(--fg-dim); font-weight: 500; background: var(--bg-hover); }
.runs-table td .summary { color: var(--fg-dim); }
/* overview */
.stat-cards { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.stat-card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 16px;
min-width: 130px;
cursor: pointer;
}
.stat-card .num { font-size: 26px; font-weight: 700; }
.stat-card .lbl { color: var(--fg-dim); font-size: 12px; }
.stat-card.warn .num { color: var(--yellow); }
.stat-card.crit .num { color: var(--red); }
.stat-card.ok .num { color: var(--green); }
.matrix { border-collapse: collapse; width: 100%; }
.matrix th, .matrix td {
border: 1px solid var(--border);
padding: 4px 8px;
text-align: center;
font-size: 12px;
}
.matrix th { color: var(--fg-dim); font-weight: 500; background: var(--bg-hover); }
.matrix td:first-child, .matrix th:first-child { text-align: left; }
.matrix td.hot { color: var(--yellow); font-weight: 700; }
.agent-row { display: flex; gap: 10px; align-items: center; padding: 3px 0; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot.live { background: var(--green); }
.dot.stale { background: var(--red); }
.event-feed { max-height: 300px; overflow-y: auto; font-size: 12px; }
.event-feed .ev { padding: 3px 0; border-bottom: 1px solid var(--border); display: flex; gap: 8px; }
.event-feed .ev .ts { color: var(--fg-dim); flex: 0 0 auto; }
.event-feed .ev .phase { color: var(--purple); flex: 0 0 auto; }
/* kanban */
.board { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px; align-items: flex-start; }
.lane {
flex: 0 0 240px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
}
.lane h4 {
margin: 0 0 8px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--fg-dim);
display: flex;
justify-content: space-between;
}
.card {
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
margin-bottom: 8px;
cursor: pointer;
}
.card:hover { border-color: var(--accent); }
.card .title { font-weight: 600; margin-bottom: 4px; }
.card .meta { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; font-size: 11px; color: var(--fg-dim); }
/* stepper */
.stepper { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 14px; }
.step {
flex: 1;
min-width: 90px;
text-align: center;
padding: 6px 4px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
color: var(--fg-dim);
cursor: pointer;
background: var(--bg-panel);
}
.step.done { color: var(--green); border-color: var(--green); }
.step.current { color: var(--accent); border-color: var(--accent); background: rgba(79, 156, 249, 0.08); font-weight: 700; }
.step .check { margin-right: 4px; }
.stage-panel { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; background: var(--bg-panel); }
.stage-panel > header {
padding: 8px 12px;
cursor: pointer;
display: flex;
gap: 10px;
align-items: center;
}
.stage-panel > header:hover { background: var(--bg-hover); }
.stage-panel > header .counts { margin-left: auto; font-size: 11px; color: var(--fg-dim); }
.stage-panel .body { border-top: 1px solid var(--border); padding: 10px 12px; }
.op-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 10px; }
.op-row label { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: var(--fg-dim); }
/* markdown */
.md { font-size: 13px; line-height: 1.55; }
.md h2, .md h3, .md h4, .md h5 { margin: 10px 0 6px; }
.md p { margin: 6px 0; }
.md-code {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
overflow-x: auto;
font-size: 12px;
}
.md code { background: var(--bg); border-radius: 3px; padding: 1px 4px; }
.md pre.md-code code { padding: 0; background: transparent; }
.md blockquote { border-left: 3px solid var(--border); margin: 6px 0; padding: 2px 10px; color: var(--fg-dim); }
.md-table { border-collapse: collapse; }
.md-table th, .md-table td { border: 1px solid var(--border); padding: 3px 8px; font-size: 12px; }
.md-image { color: var(--fg-dim); }
/* decisions */
.decision-card { border: 1px solid var(--yellow); border-radius: 6px; padding: 12px; margin-bottom: 12px; background: var(--bg-panel); }
.decision-card.resolved { border-color: var(--border); }
.suggestion {
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
margin: 6px 0;
background: var(--bg-hover);
}
.suggestion .opt { font-weight: 600; margin-bottom: 4px; }
.suggestion .why { font-size: 12px; color: var(--fg-dim); }
/* docs */
.docs-layout { display: flex; gap: 14px; align-items: flex-start; }
.docs-side { flex: 0 0 260px; max-height: 75vh; overflow-y: auto; }
.docs-main { flex: 1; min-width: 0; }
.tax-group { margin-bottom: 8px; }
.tax-group > .tax-name { color: var(--accent); cursor: pointer; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
.doc-item { padding: 3px 0 3px 10px; cursor: pointer; font-size: 12px; }
.doc-item:hover { color: var(--accent); }
.doc-item.active { color: var(--accent); }
.hit { border: 1px solid var(--border); border-radius: 4px; padding: 8px; margin-bottom: 8px; background: var(--bg-panel); }
.hit .snippet { font-size: 12px; color: var(--fg-dim); margin-top: 4px; white-space: pre-wrap; }
.hit .score { color: var(--green); font-size: 11px; }
/* settings table */
.settings-table { border-collapse: collapse; width: 100%; }
.settings-table th, .settings-table td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; }
.settings-table th { color: var(--fg-dim); font-weight: 500; background: var(--bg-hover); }
.filters { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
details.fold > summary { cursor: pointer; color: var(--fg-dim); margin-bottom: 8px; }
-5
View File
@@ -1,5 +0,0 @@
import { createRoot } from 'react-dom/client'
import App from './App'
import './app.css'
createRoot(document.getElementById('root')!).render(<App />)
-119
View File
@@ -1,119 +0,0 @@
import type { ReactNode } from 'react'
import { lexer, type Token, type Tokens } from 'marked'
// Renders markdown to React elements (never raw HTML), so agent-authored
// artifact content cannot inject markup. Uses marked only as a parser.
function safeHref(href: string): string | undefined {
const h = href.trim()
if (/^(https?:|mailto:)/i.test(h) || h.startsWith('/') || h.startsWith('#')) {
return h
}
return undefined
}
function inline(tokens: Token[] | undefined): ReactNode[] {
if (!tokens) return []
return tokens.map((t, i) => {
switch (t.type) {
case 'strong':
return <strong key={i}>{inline((t as Tokens.Strong).tokens)}</strong>
case 'em':
return <em key={i}>{inline((t as Tokens.Em).tokens)}</em>
case 'del':
return <del key={i}>{inline((t as Tokens.Del).tokens)}</del>
case 'codespan':
return <code key={i}>{(t as Tokens.Codespan).text}</code>
case 'link': {
const lt = t as Tokens.Link
const href = safeHref(lt.href)
if (!href) return <span key={i}>{lt.text}</span>
return (
<a key={i} href={href} target="_blank" rel="noreferrer">
{lt.tokens ? inline(lt.tokens) : lt.text}
</a>
)
}
case 'image': {
const it = t as Tokens.Image
return <span key={i} className="md-image">[img: {it.text || it.href}]</span>
}
case 'br':
return <br key={i} />
case 'escape':
return <span key={i}>{(t as Tokens.Escape).text}</span>
case 'html':
return <code key={i}>{(t as Tokens.HTML).text}</code>
case 'text': {
const tt = t as Tokens.Text
return <span key={i}>{tt.tokens ? inline(tt.tokens) : tt.text}</span>
}
default:
return <span key={i}>{'raw' in t ? String(t.raw ?? '') : ''}</span>
}
})
}
function block(tokens: Token[]): ReactNode[] {
return tokens.map((t, i) => {
switch (t.type) {
case 'heading': {
const h = t as Tokens.Heading
const content = inline(h.tokens)
switch (h.depth) {
case 1: return <h2 key={i}>{content}</h2>
case 2: return <h3 key={i}>{content}</h3>
case 3: return <h4 key={i}>{content}</h4>
default: return <h5 key={i}>{content}</h5>
}
}
case 'paragraph':
return <p key={i}>{inline((t as Tokens.Paragraph).tokens)}</p>
case 'code': {
const c = t as Tokens.Code
return (
<pre key={i} className="md-code">
<code>{c.text}</code>
</pre>
)
}
case 'blockquote':
return <blockquote key={i}>{block((t as Tokens.Blockquote).tokens)}</blockquote>
case 'list': {
const l = t as Tokens.List
const items = l.items.map((item, j) => <li key={j}>{block(item.tokens)}</li>)
return l.ordered ? <ol key={i} start={l.start || 1}>{items}</ol> : <ul key={i}>{items}</ul>
}
case 'table': {
const tb = t as Tokens.Table
return (
<table key={i} className="md-table">
<thead>
<tr>{tb.header.map((cell, j) => <th key={j}>{inline(cell.tokens)}</th>)}</tr>
</thead>
<tbody>
{tb.rows.map((row, r) => (
<tr key={r}>{row.map((cell, c) => <td key={c}>{inline(cell.tokens)}</td>)}</tr>
))}
</tbody>
</table>
)
}
case 'hr':
return <hr key={i} />
case 'space':
return null
case 'html':
return <pre key={i} className="md-code"><code>{(t as Tokens.HTML).text}</code></pre>
case 'text':
return <p key={i}>{inline([(t as Tokens.Text)])}</p>
default:
return <pre key={i} className="md-code"><code>{'raw' in t ? String(t.raw ?? '') : ''}</code></pre>
}
})
}
export function Markdown({ text }: { text: string }) {
if (!text.trim()) return null
return <div className="md">{block(lexer(text))}</div>
}
-219
View File
@@ -1,219 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api'
import { nav } from '../router'
import { Markdown } from '../markdown'
import { fmtTime } from '../util'
import type { AuditFinding, AuditRun } from '../types'
type FindingStatus = 'open' | 'converted' | 'dismissed'
function runDuration(r: AuditRun): string {
if (!r.finished_at) return '-'
const ms = new Date(r.finished_at).getTime() - new Date(r.started_at).getTime()
if (Number.isNaN(ms)) return '-'
return `${(ms / 1000).toFixed(1)}s`
}
function FindingCard({ f, onChanged }: { f: AuditFinding; onChanged: () => void }) {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const act = async (action: 'convert' | 'dismiss') => {
setBusy(true)
setErr('')
try {
await api.post(`/audits/findings/${f.id}/${action}`)
onChanged()
} catch (ex) {
setErr((ex as Error).message)
setBusy(false)
}
}
return (
<div className={`finding-card sev-${f.severity}`}>
<div className="f-head" onClick={() => setOpen(!open)}>
<span className={`badge sev-${f.severity}`}>{f.severity}</span>
<span className="badge">{f.kind}</span>
<span className="f-title">{f.title}</span>
<span className="dim mono-sm">{fmtTime(f.created_at)}</span>
<span className="dim mono-sm">{open ? '▾' : '▸'}</span>
</div>
{open && (
<div className="f-body">
<Markdown text={f.detail_md} />
{f.refs.length > 0 && (
<div className="f-refs">refs: {f.refs.join(' · ')}</div>
)}
<div className="f-refs">
run #{f.run_id} · {fmtTime(f.run_started_at)}
{f.task_id && (
<>
{' · 已转任务 '}
<a
href={`#/tasks/${f.task_id}`}
onClick={(e) => {
e.preventDefault()
nav(`/tasks/${f.task_id}`)
}}
>
{f.task_id.slice(0, 8)}
</a>
</>
)}
</div>
{err && <div className="error-box">{err}</div>}
{f.status === 'open' && (
<div className="f-actions" onClick={(e) => e.stopPropagation()}>
<button className="primary" disabled={busy} onClick={() => act('convert')}>
转任务
</button>
<button disabled={busy} onClick={() => act('dismiss')}>
忽略
</button>
</div>
)}
</div>
)}
</div>
)
}
export default function Audit() {
const [runs, setRuns] = useState<AuditRun[]>([])
const [findings, setFindings] = useState<AuditFinding[]>([])
const [tab, setTab] = useState<FindingStatus>('open')
const [severity, setSeverity] = useState('')
const [err, setErr] = useState('')
const [running, setRunning] = useState(false)
const loadRuns = useCallback(() => {
api
.get<AuditRun[]>('/audits/runs?limit=20')
.then(setRuns)
.catch(() => {})
}, [])
const loadFindings = useCallback(() => {
api
.get<AuditFinding[]>(`/audits/findings?status=${tab}${severity ? `&severity=${severity}` : ''}&limit=100`)
.then((f) => {
setFindings(f)
setErr('')
})
.catch((e: Error) => {
if (e.message !== 'unauthorized') setErr(e.message)
})
}, [tab, severity])
const refresh = useCallback(() => {
loadRuns()
loadFindings()
}, [loadRuns, loadFindings])
useEffect(() => {
refresh()
}, [refresh])
// While a run is in flight, poll until it finishes.
useEffect(() => {
if (!runs.some((r) => r.status === 'running')) return
const timer = setInterval(refresh, 3000)
return () => clearInterval(timer)
}, [runs, refresh])
const triggerRun = async () => {
setRunning(true)
setErr('')
try {
await api.post('/audits/run')
loadRuns()
} catch (ex) {
setErr((ex as Error).message)
} finally {
setRunning(false)
}
}
const isRunning = runs.some((r) => r.status === 'running')
return (
<div>
<div className="page-head">
<h2>审计</h2>
<span className="badge">{findings.length} {tab}</span>
<div className="spacer" />
<button className="primary" disabled={running || isRunning} onClick={triggerRun}>
{isRunning ? '审查进行中…' : '立即运行'}
</button>
<button onClick={refresh}>刷新</button>
</div>
{err && <div className="error-box">{err}</div>}
<div className="panel">
<h3>运行历史</h3>
{runs.length === 0 ? (
<div className="dim mono-sm">暂无审查记录。点击「立即运行」触发一次审查。</div>
) : (
<table className="runs-table">
<thead>
<tr>
<th>#</th>
<th>kind</th>
<th>状态</th>
<th>开始</th>
<th>耗时</th>
<th>findings</th>
<th>summary</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr key={r.id}>
<td>{r.id}</td>
<td>{r.kind}</td>
<td>
<span className={`badge ${r.status === 'failed' ? 'sev-critical' : r.status === 'running' ? 'st-in_progress' : 'status-done'}`}>
{r.status}
</span>
</td>
<td>{fmtTime(r.started_at)}</td>
<td>{runDuration(r)}</td>
<td>{r.findings}</td>
<td>
<span className="summary">{r.error || r.summary}</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="panel">
<h3>Findings</h3>
<div className="filters" style={{ marginBottom: 10 }}>
{(['open', 'converted', 'dismissed'] as FindingStatus[]).map((s) => (
<button key={s} className={tab === s ? 'primary' : ''} onClick={() => setTab(s)}>
{s}
</button>
))}
<span className="dim mono-sm">severity:</span>
<select value={severity} onChange={(e) => setSeverity(e.target.value)}>
<option value="">全部</option>
<option value="critical">critical</option>
<option value="high">high</option>
<option value="medium">medium</option>
<option value="low">low</option>
</select>
</div>
{findings.length === 0 ? (
<div className="dim mono-sm">当前过滤条件下没有 findings。</div>
) : (
findings.map((f) => <FindingCard key={f.id} f={f} onChanged={refresh} />)
)}
</div>
</div>
)
}
-170
View File
@@ -1,170 +0,0 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import { api, qs } from '../api'
import { nav } from '../router'
import { STAGES, TASK_STATUSES, type Plan, type Task } from '../types'
function NewTaskForm({ plans, onCreated, onClose }: { plans: Plan[]; onCreated: () => void; onClose: () => void }) {
const [slug, setSlug] = useState('')
const [title, setTitle] = useState('')
const [kind, setKind] = useState('task')
const [planID, setPlanID] = useState('')
const [testingRequired, setTestingRequired] = useState(true)
const [priority, setPriority] = useState(0)
const [instructionMD, setInstructionMD] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: FormEvent) => {
e.preventDefault()
setBusy(true)
setErr('')
try {
await api.post<Task>('/tasks', {
slug: slug.trim(),
title: title.trim(),
kind,
plan_id: planID || undefined,
testing_required: testingRequired,
priority,
instruction_md: instructionMD,
})
onCreated()
} catch (ex) {
setErr((ex as Error).message)
setBusy(false)
}
}
return (
<div className="panel">
<h3>新建任务</h3>
{err && <div className="error-box">{err}</div>}
<form onSubmit={submit}>
<div className="op-row">
<label>slug<input required value={slug} onChange={(e) => setSlug(e.target.value)} placeholder="my-task-slug" /></label>
<label style={{ flex: 1, minWidth: 220 }}>title<input required value={title} onChange={(e) => setTitle(e.target.value)} /></label>
<label>kind
<select value={kind} onChange={(e) => setKind(e.target.value)}>
<option value="task">task</option>
<option value="proposal">proposal</option>
</select>
</label>
<label>plan
<select value={planID} onChange={(e) => setPlanID(e.target.value)}>
<option value="">(none)</option>
{plans.map((p) => (
<option key={p.id} value={p.id}>{p.slug}</option>
))}
</select>
</label>
<label>priority<input type="number" style={{ width: 80 }} value={priority} onChange={(e) => setPriority(Number(e.target.value))} /></label>
<label style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<input type="checkbox" checked={testingRequired} onChange={(e) => setTestingRequired(e.target.checked)} />
testing_required
</label>
</div>
<label className="dim mono-sm">instruction_md</label>
<textarea rows={5} value={instructionMD} onChange={(e) => setInstructionMD(e.target.value)} placeholder="任务指令(markdown)" />
<div className="op-row" style={{ marginTop: 8 }}>
<button className="primary" type="submit" disabled={busy}>创建</button>
<button type="button" onClick={onClose}>取消</button>
</div>
</form>
</div>
)
}
export default function Board() {
const [tasks, setTasks] = useState<Task[]>([])
const [plans, setPlans] = useState<Plan[]>([])
const [status, setStatus] = useState('')
const [planID, setPlanID] = useState('')
const [q, setQ] = useState('')
const [showNew, setShowNew] = useState(false)
const [err, setErr] = useState('')
const load = useCallback(() => {
api
.get<Task[]>(`/tasks${qs({ status, plan_id: planID, q })}`)
.then((t) => {
setTasks(t)
setErr('')
})
.catch((e: Error) => {
if (e.message !== 'unauthorized') setErr(e.message)
})
}, [status, planID, q])
useEffect(() => {
load()
}, [load])
useEffect(() => {
api.get<Plan[]>('/plans').then(setPlans).catch(() => {})
}, [])
return (
<div>
<div className="page-head">
<h2>看板</h2>
<div className="filters">
<select value={planID} onChange={(e) => setPlanID(e.target.value)}>
<option value="">全部 plan</option>
{plans.map((p) => (
<option key={p.id} value={p.id}>{p.slug}</option>
))}
</select>
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">全部状态</option>
{TASK_STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<input placeholder="搜索 slug/title" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="spacer" />
<button className="primary" onClick={() => setShowNew((v) => !v)}>
{showNew ? '收起' : '新建任务'}
</button>
</div>
{err && <div className="error-box">{err}</div>}
{showNew && (
<NewTaskForm
plans={plans}
onClose={() => setShowNew(false)}
onCreated={() => {
setShowNew(false)
load()
}}
/>
)}
<div className="board">
{STAGES.map((stage) => {
const laneTasks = tasks.filter((t) => t.stage === stage)
return (
<div className="lane" key={stage}>
<h4>
<span>{stage}</span>
<span>{laneTasks.length}</span>
</h4>
{laneTasks.map((t) => (
<div className="card" key={t.id} onClick={() => nav(`/tasks/${t.id}`)}>
<div className="title">{t.title}</div>
<div className="meta">
<span className="mono-sm">{t.slug}</span>
</div>
<div className="meta" style={{ marginTop: 4 }}>
<span className={`badge st-${t.stage_state}`}>{t.stage_state}</span>
<span className={`badge status-${t.status}`}>{t.status}</span>
<span>P{t.priority}</span>
{t.assigned_agent && <span>@{t.assigned_agent}</span>}
</div>
</div>
))}
</div>
)
})}
</div>
</div>
)
}
-132
View File
@@ -1,132 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api'
import { nav } from '../router'
import { Markdown } from '../markdown'
import { fmtTime } from '../util'
import type { DecisionItem } from '../types'
function OpenDecision({ d, onResolved }: { d: DecisionItem; onResolved: () => void }) {
const [custom, setCustom] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const resolve = async (body: { resolution?: string; suggestion_index?: number }) => {
setBusy(true)
setErr('')
try {
await api.post(`/decisions/${d.id}/resolve`, body)
onResolved()
} catch (ex) {
setErr((ex as Error).message)
setBusy(false)
}
}
return (
<div className="decision-card">
<div style={{ marginBottom: 6 }}>
<a href={`#/tasks/${d.task_id}`} onClick={(e) => { e.preventDefault(); nav(`/tasks/${d.task_id}`) }}>
{d.task_slug}
</a>{' '}
<span className="dim">{d.task_title}</span>{' '}
<span className="badge">{d.stage}</span>{' '}
<span className="dim mono-sm">{fmtTime(d.created_at)}</span>
</div>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{d.question}</div>
{d.context_md && <Markdown text={d.context_md} />}
{err && <div className="error-box">{err}</div>}
<div style={{ marginTop: 8 }}>
{d.suggestions.map((s, i) => (
<div className="suggestion" key={i}>
<div className="opt">
建议 {i + 1}: {s.option}
<button
style={{ marginLeft: 10 }}
disabled={busy}
onClick={() => resolve({ suggestion_index: i })}
>
采纳建议 {i + 1}
</button>
</div>
<div className="why">理由: {s.rationale}</div>
<div className="why">影响: {s.impact}</div>
</div>
))}
</div>
<div className="op-row" style={{ marginTop: 8 }}>
<label style={{ flex: 1, minWidth: 260 }}>
自定义 resolution
<input style={{ width: '100%' }} value={custom} onChange={(e) => setCustom(e.target.value)} />
</label>
<button className="primary" disabled={busy || !custom.trim()} onClick={() => resolve({ resolution: custom.trim() })}>
提交裁决
</button>
</div>
</div>
)
}
export default function Decisions() {
const [open, setOpen] = useState<DecisionItem[]>([])
const [resolved, setResolved] = useState<DecisionItem[] | null>(null)
const [showResolved, setShowResolved] = useState(false)
const [err, setErr] = useState('')
const load = useCallback(() => {
api
.get<DecisionItem[]>('/decisions?status=open')
.then((d) => {
setOpen(d)
setErr('')
})
.catch((e: Error) => {
if (e.message !== 'unauthorized') setErr(e.message)
})
}, [])
useEffect(() => {
load()
}, [load])
useEffect(() => {
if (!showResolved || resolved !== null) return
api.get<DecisionItem[]>('/decisions?status=resolved').then(setResolved).catch(() => {})
}, [showResolved, resolved])
return (
<div>
<div className="page-head">
<h2>决策收件箱</h2>
<span className={`badge ${open.length > 0 ? 'st-awaiting_review' : ''}`}>{open.length} open</span>
<div className="spacer" />
<button onClick={load}>刷新</button>
</div>
{err && <div className="error-box">{err}</div>}
{open.length === 0 && <div className="panel dim">没有待裁决的决策。</div>}
{open.map((d) => (
<OpenDecision key={d.id} d={d} onResolved={load} />
))}
<details
className="fold"
style={{ marginTop: 16 }}
onToggle={(e) => setShowResolved((e.target as HTMLDetailsElement).open)}
>
<summary>已解决({resolved?.length ?? '…'})</summary>
{(resolved ?? []).map((d) => (
<div className="decision-card resolved" key={d.id}>
<div>
<span className="dim">{d.task_slug}</span> <strong>{d.question}</strong>
</div>
<div className="mono-sm dim" style={{ marginTop: 4 }}>
resolution: {d.resolution} · by {d.decided_by} · {fmtTime(d.resolved_at)}
</div>
</div>
))}
{showResolved && resolved !== null && resolved.length === 0 && (
<div className="dim mono-sm">暂无已解决决策</div>
)}
</details>
</div>
)
}
-180
View File
@@ -1,180 +0,0 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { api, qs } from '../api'
import { fmtTime } from '../util'
import type { DocDetail, DocListItem, SearchResponse } from '../types'
export default function Docs() {
const [items, setItems] = useState<DocListItem[]>([])
const [taxonomy, setTaxonomy] = useState('')
const [browseQ, setBrowseQ] = useState('')
const [detail, setDetail] = useState<DocDetail | null>(null)
const [q, setQ] = useState('')
const [mode, setMode] = useState('hybrid')
const [search, setSearch] = useState<SearchResponse | null>(null)
const [err, setErr] = useState('')
const loadList = (tax: string, query: string) => {
api
.get<{ items: DocListItem[] }>(`/docs${qs({ taxonomy: tax, q: query })}`)
.then((r) => setItems(r.items))
.catch((e: Error) => {
if (e.message !== 'unauthorized') setErr(e.message)
})
}
useEffect(() => {
loadList(taxonomy, browseQ)
}, [taxonomy, browseQ])
const taxonomies = useMemo(() => {
const counts = new Map<string, number>()
for (const it of items) {
counts.set(it.taxonomy, (counts.get(it.taxonomy) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))
}, [items])
const runSearch = async (e: FormEvent) => {
e.preventDefault()
if (!q.trim()) return
setErr('')
try {
const r = await api.get<SearchResponse>(`/docs/search${qs({ q: q.trim(), mode, limit: '20' })}`)
setSearch(r)
} catch (ex) {
setErr((ex as Error).message)
}
}
const openDoc = (id: string) => {
api.get<DocDetail>(`/docs/${id}`).then(setDetail).catch((e: Error) => setErr(e.message))
}
return (
<div>
<div className="page-head">
<h2>文档库</h2>
</div>
{err && <div className="error-box">{err}</div>}
<div className="panel">
<form className="filters" onSubmit={runSearch}>
<input
style={{ flex: 1, minWidth: 240 }}
placeholder="搜索文档与阶段产物…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option value="hybrid">hybrid</option>
<option value="semantic">semantic</option>
<option value="keyword">keyword</option>
</select>
<button className="primary" type="submit">搜索</button>
</form>
</div>
{search && (
<div className="panel">
<h3>
搜索结果 · {search.mode} · {search.count} 条
{search.degraded && <span className="badge st-awaiting_review" style={{ marginLeft: 8 }}>degraded:语义检索不可用,已回落关键词</span>}
</h3>
{search.results.map((r, i) => (
<div className="hit" key={i}>
<div>
<span className="score">{r.score.toFixed(3)}</span>{' '}
{r.heading && <strong>{r.heading} </strong>}
<span className="dim mono-sm">
{r.source.type === 'doc'
? `doc: ${r.source.path} (${r.source.title})`
: `artifact: ${r.source.task_slug} / ${r.source.stage} (${r.source.title})`}
</span>
{r.doc_id && (
<>
{' '}
<a href="#/docs" onClick={(e) => { e.preventDefault(); openDoc(r.doc_id!) }}>查看文档</a>
</>
)}
</div>
<div className="snippet">{r.snippet}</div>
</div>
))}
{search.count === 0 && <div className="dim mono-sm">无结果</div>}
</div>
)}
<div className="docs-layout">
<div className="docs-side">
<div className="panel">
<h3>浏览</h3>
<input
style={{ width: '100%', marginBottom: 8 }}
placeholder="过滤 path/title"
value={browseQ}
onChange={(e) => setBrowseQ(e.target.value)}
/>
<div
className={`tax-name ${taxonomy === '' ? 'active' : ''}`}
style={{ cursor: 'pointer', color: taxonomy === '' ? 'var(--accent)' : undefined, marginBottom: 6 }}
onClick={() => setTaxonomy('')}
>
全部 ({items.length})
</div>
{taxonomies.map(([tax, n]) => (
<div className="tax-group" key={tax || '(none)'}>
<div className="tax-name" onClick={() => setTaxonomy(tax === taxonomy ? '' : tax)}>
{tax || '(no taxonomy)'} ({n})
</div>
{taxonomy === tax &&
items
.filter((it) => it.taxonomy === tax)
.map((it) => (
<div
key={it.id}
className={`doc-item ${detail?.id === it.id ? 'active' : ''}`}
onClick={() => openDoc(it.id)}
title={it.path}
>
{it.title || it.path}
</div>
))}
</div>
))}
{taxonomy === '' &&
items.slice(0, 80).map((it) => (
<div
key={it.id}
className={`doc-item ${detail?.id === it.id ? 'active' : ''}`}
onClick={() => openDoc(it.id)}
title={it.path}
>
{it.title || it.path}
</div>
))}
</div>
</div>
<div className="docs-main">
{detail ? (
<div className="panel">
<h3>{detail.title || detail.path}</h3>
<table className="settings-table" style={{ marginBottom: 10 }}>
<tbody>
<tr><th>path</th><td className="mono-sm">{detail.path}</td></tr>
<tr><th>taxonomy</th><td>{detail.taxonomy}</td></tr>
<tr><th>chunks</th><td>{detail.chunks}</td></tr>
<tr><th>synced_at</th><td className="mono-sm">{fmtTime(detail.synced_at)}</td></tr>
</tbody>
</table>
<h3>Front Matter</h3>
<pre className="md-code"><code>{JSON.stringify(detail.frontmatter, null, 2)}</code></pre>
</div>
) : (
<div className="panel dim">点击左侧文档查看详情,或使用上方搜索。</div>
)}
</div>
</div>
</div>
)
}
-195
View File
@@ -1,195 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, getToken } from '../api'
import { nav } from '../router'
import { fmtAgo, fmtTime } from '../util'
import { STAGES, STAGE_STATES, type Overview as OverviewData, type TaskEvent } from '../types'
type FeedMode = 'connecting' | 'sse' | 'poll'
function useEventStream(onEvent: () => void): { events: TaskEvent[]; mode: FeedMode } {
const [events, setEvents] = useState<TaskEvent[]>([])
const [mode, setMode] = useState<FeedMode>('connecting')
const onEventRef = useRef(onEvent)
onEventRef.current = onEvent
useEffect(() => {
let stopped = false
let retryTimer: ReturnType<typeof setTimeout> | undefined
let pollTimer: ReturnType<typeof setInterval> | undefined
let abort: AbortController | undefined
const startPolling = () => {
if (pollTimer || stopped) return
setMode('poll')
pollTimer = setInterval(() => onEventRef.current(), 10_000)
}
const connect = async () => {
if (stopped) return
abort = new AbortController()
try {
const resp = await fetch('/api/v1/stream', {
headers: { Authorization: `Bearer ${getToken()}` },
signal: abort.signal,
})
if (!resp.ok || !resp.body) throw new Error(`stream http ${resp.status}`)
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = undefined
}
setMode('sse')
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += value
let idx: number
while ((idx = buf.indexOf('\n\n')) >= 0) {
const rawEvent = buf.slice(0, idx)
buf = buf.slice(idx + 2)
for (const line of rawEvent.split('\n')) {
if (!line.startsWith('data:')) continue
try {
const ev = JSON.parse(line.slice(5).trim()) as TaskEvent
setEvents((prev) => [ev, ...prev].slice(0, 50))
onEventRef.current()
} catch {
// ignore malformed event payloads
}
}
}
}
throw new Error('stream closed')
} catch {
if (stopped) return
// SSE unavailable: degrade to 10s overview polling, keep retrying SSE.
startPolling()
retryTimer = setTimeout(connect, 10_000)
}
}
void connect()
return () => {
stopped = true
abort?.abort()
if (retryTimer) clearTimeout(retryTimer)
if (pollTimer) clearInterval(pollTimer)
}
}, [])
return { events, mode }
}
export default function Overview() {
const [ov, setOv] = useState<OverviewData | null>(null)
const [err, setErr] = useState('')
const refresh = useCallback(() => {
api
.get<OverviewData>('/overview')
.then(setOv)
.catch((e: Error) => {
if (e.message !== 'unauthorized') setErr(e.message)
})
}, [])
useEffect(() => {
refresh()
}, [refresh])
const { events, mode } = useEventStream(refresh)
const countOf = (stage: string, state: string) =>
ov?.task_counts.find((c) => c.stage === stage && c.stage_state === state)?.count ?? 0
return (
<div>
<div className="page-head">
<h2>总览</h2>
<span className="badge">{mode === 'sse' ? 'SSE 实时' : mode === 'poll' ? '10s 轮询' : '连接中'}</span>
<div className="spacer" />
<button onClick={refresh}>刷新</button>
</div>
{err && <div className="error-box">{err}</div>}
<div className="stat-cards">
<div className={`stat-card ${ov && ov.open_decisions > 0 ? 'crit' : 'ok'}`} onClick={() => nav('/decisions')}>
<div className="num">{ov?.open_decisions ?? '-'}</div>
<div className="lbl">待裁决决策</div>
</div>
<div className={`stat-card ${ov && ov.awaiting_review > 0 ? 'warn' : 'ok'}`} onClick={() => nav('/board')}>
<div className="num">{ov?.awaiting_review ?? '-'}</div>
<div className="lbl">待审推进</div>
</div>
<div className="stat-card" onClick={() => nav('/audit')}>
<div className="num">{ov?.open_findings ?? '-'}</div>
<div className="lbl">Open Findings</div>
</div>
</div>
<div className="panel">
<h3>阶段 × 状态矩阵</h3>
<table className="matrix">
<thead>
<tr>
<th>stage</th>
{STAGE_STATES.map((s) => (
<th key={s}>{s}</th>
))}
</tr>
</thead>
<tbody>
{STAGES.map((stage) => (
<tr key={stage}>
<td>{stage}</td>
{STAGE_STATES.map((state) => {
const n = countOf(stage, state)
return (
<td key={state} className={n > 0 && (state === 'awaiting_review' || state === 'changes_requested') ? 'hot' : ''}>
{n || ''}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="panel">
<h3>Agent 心跳活性</h3>
{!ov || ov.agents.length === 0 ? (
<div className="dim mono-sm">暂无 agent 活动记录</div>
) : (
ov.agents.map((a) => (
<div className="agent-row" key={a.agent}>
<span className={`dot ${a.stale ? 'stale' : 'live'}`} />
<span>{a.agent}</span>
<span className="dim mono-sm">
{a.stale ? 'stale' : 'live'} · last heartbeat {fmtAgo(a.last_heartbeat_at)}
</span>
</div>
))
)}
</div>
<div className="panel">
<h3>最近事件</h3>
<div className="event-feed">
{events.length === 0 ? (
<div className="dim mono-sm">{mode === 'sse' ? '等待事件…' : '事件流不可用,已切换轮询模式'}</div>
) : (
events.map((e) => (
<div className="ev" key={e.id}>
<span className="ts">{fmtTime(e.ts)}</span>
<span className="phase">{e.phase}</span>
<span>{e.agent && <strong>{e.agent} </strong>}{e.message}</span>
</div>
))
)}
</div>
</div>
</div>
)
}
-103
View File
@@ -1,103 +0,0 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
import { STAGES, type StageSetting } from '../types'
const EXECUTORS = ['agent', 'human', 'external']
export default function Settings() {
const [rows, setRows] = useState<Record<string, { model: string; executor: string }>>({})
const [err, setErr] = useState('')
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
api
.get<StageSetting[]>('/stage-settings')
.then((list) => {
const m: Record<string, { model: string; executor: string }> = {}
for (const stage of STAGES) {
const found = list.find((s) => s.stage === stage)
m[stage] = { model: found?.model ?? '', executor: found?.executor ?? 'agent' }
}
setRows(m)
})
.catch((e: Error) => {
if (e.message !== 'unauthorized') setErr(e.message)
})
}, [])
const save = async () => {
setBusy(true)
setErr('')
setMsg('')
try {
await api.put('/stage-settings', { settings: rows })
setMsg('已保存全局阶段设置')
} catch (ex) {
setErr((ex as Error).message)
} finally {
setBusy(false)
}
}
return (
<div>
<div className="page-head">
<h2>设置</h2>
</div>
{err && <div className="error-box">{err}</div>}
{msg && <div className="panel mono-sm" style={{ borderColor: 'var(--green)', color: 'var(--green)' }}>{msg}</div>}
<div className="panel">
<h3>全局阶段设置(stage → model / executor)</h3>
<table className="settings-table">
<thead>
<tr><th>stage</th><th>model</th><th>executor</th></tr>
</thead>
<tbody>
{STAGES.map((stage) => (
<tr key={stage}>
<td>{stage}</td>
<td>
<input
value={rows[stage]?.model ?? ''}
placeholder="(默认模型)"
onChange={(e) =>
setRows((prev) => ({ ...prev, [stage]: { model: e.target.value, executor: prev[stage]?.executor ?? 'agent' } }))
}
/>
</td>
<td>
<select
value={rows[stage]?.executor ?? 'agent'}
onChange={(e) =>
setRows((prev) => ({ ...prev, [stage]: { model: prev[stage]?.model ?? '', executor: e.target.value } }))
}
>
{EXECUTORS.map((x) => (
<option key={x} value={x}>{x}</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
<div className="op-row" style={{ marginTop: 10 }}>
<button className="primary" disabled={busy} onClick={save}>保存</button>
</div>
<div className="dim mono-sm" style={{ marginTop: 6 }}>
executor=human/external 的阶段不会派发给 agent;任务级覆盖可通过 API PUT /api/v1/stage-settings?task_id= 设置。
</div>
</div>
<div className="panel">
<h3>Agent 接入</h3>
<div className="dim mono-sm">
agent token 通过 <code>scripts/control-center.sh register-agent -name &lt;agent&gt; [-kind &lt;kind&gt;]</code> 注册(plaintext token 只显示一次)。
agent 使用 Bearer token 调用 <code>/api/v1/agent/*</code> 路由认领任务、上报事件与产物;本控制台不使用 agent 路由。
</div>
</div>
</div>
)
}
-304
View File
@@ -1,304 +0,0 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import { api } from '../api'
import { nav } from '../router'
import { Markdown } from '../markdown'
import { fmtTime } from '../util'
import { STAGES, type Decision, type PipelineView, type StageView, type Task } from '../types'
function DecisionBlock({ d }: { d: Decision }) {
return (
<div className={`decision-card ${d.status === 'resolved' ? 'resolved' : ''}`}>
<div>
<span className={`badge ${d.status === 'open' ? 'st-awaiting_review' : 'st-approved'}`}>{d.status}</span>{' '}
<strong>{d.question}</strong>
</div>
{d.context_md && <Markdown text={d.context_md} />}
{d.status === 'resolved' && (
<div className="mono-sm dim" style={{ marginTop: 4 }}>
resolution: {d.resolution} · by {d.decided_by} · {fmtTime(d.resolved_at)}
</div>
)}
</div>
)
}
function StagePanel({ view, currentStage, open, onToggle }: {
view: StageView
currentStage: string
open: boolean
onToggle: () => void
}) {
const isCurrent = view.stage === currentStage
return (
<div className="stage-panel">
<header onClick={onToggle}>
<span>{open ? '▾' : '▸'}</span>
<strong style={isCurrent ? { color: 'var(--accent)' } : undefined}>{view.stage}</strong>
{isCurrent && <span className="badge st-in_progress">current</span>}
<span className="counts">
artifacts {view.artifacts.length} · transitions {view.transitions.length} · decisions {view.decisions.length}
</span>
</header>
{open && (
<div className="body">
{view.artifacts.length > 0 && (
<div className="panel">
<h3>Artifacts</h3>
{view.artifacts.map((a) => (
<details className="fold" key={a.id} style={{ marginBottom: 8 }}>
<summary>
<span className="badge">{a.kind}</span> {a.title}{' '}
<span className="dim mono-sm">{a.source_type}:{a.source_ref} · {fmtTime(a.created_at)}</span>
</summary>
{a.content_md ? <Markdown text={a.content_md} /> : <div className="dim mono-sm">(无内容)</div>}
</details>
))}
</div>
)}
{view.transitions.length > 0 && (
<div className="panel">
<h3>Transitions</h3>
<table className="settings-table">
<thead>
<tr><th>ts</th><th>from → to</th><th>actor</th><th>approved_by</th><th>note</th></tr>
</thead>
<tbody>
{view.transitions.map((tr) => (
<tr key={tr.id}>
<td className="mono-sm">{fmtTime(tr.ts)}</td>
<td className="mono-sm">{tr.from_stage} → {tr.to_stage}</td>
<td>{tr.actor}</td>
<td>{tr.approved_by}</td>
<td>{tr.note}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{view.decisions.length > 0 && (
<div className="panel">
<h3>Decisions</h3>
{view.decisions.map((d) => <DecisionBlock key={d.id} d={d} />)}
</div>
)}
{view.artifacts.length === 0 && view.transitions.length === 0 && view.decisions.length === 0 && (
<div className="dim mono-sm">本阶段暂无记录</div>
)}
</div>
)}
</div>
)
}
function ExternalSubmitForm({ task, onDone }: { task: Task; onDone: () => void }) {
const [title, setTitle] = useState('')
const [contentMD, setContentMD] = useState('')
const [kind, setKind] = useState('')
const [advance, setAdvance] = useState(false)
const [note, setNote] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: FormEvent) => {
e.preventDefault()
setBusy(true)
setErr('')
try {
await api.post(`/tasks/${task.id}/stages/${task.stage}/external-submit`, {
title: title.trim(),
content_md: contentMD,
kind: kind || undefined,
advance,
note: note || undefined,
})
setTitle('')
setContentMD('')
setNote('')
onDone()
} catch (ex) {
setErr((ex as Error).message)
} finally {
setBusy(false)
}
}
return (
<details className="fold panel">
<summary>外部产物提交(external-submit,当前阶段 {task.stage})</summary>
{err && <div className="error-box">{err}</div>}
<form onSubmit={submit}>
<div className="op-row">
<label style={{ flex: 1, minWidth: 200 }}>title<input required value={title} onChange={(e) => setTitle(e.target.value)} /></label>
<label>kind<input value={kind} onChange={(e) => setKind(e.target.value)} placeholder="note" style={{ width: 120 }} /></label>
<label style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<input type="checkbox" checked={advance} onChange={(e) => setAdvance(e.target.checked)} />
提交后推进
</label>
</div>
<textarea rows={6} required value={contentMD} onChange={(e) => setContentMD(e.target.value)} placeholder="content_md(markdown)" />
{advance && (
<div style={{ marginTop: 6 }}>
<label className="dim mono-sm">note(推进备注)</label>
<input style={{ width: '100%' }} value={note} onChange={(e) => setNote(e.target.value)} />
</div>
)}
<div className="op-row" style={{ marginTop: 8 }}>
<button className="primary" type="submit" disabled={busy}>提交</button>
</div>
</form>
</details>
)
}
export default function TaskDetail({ id }: { id: string }) {
const [task, setTask] = useState<Task | null>(null)
const [pipeline, setPipeline] = useState<PipelineView | null>(null)
const [openStages, setOpenStages] = useState<Record<string, boolean>>({})
const [assignee, setAssignee] = useState('')
const [note, setNote] = useState('')
const [err, setErr] = useState('')
const [opMsg, setOpMsg] = useState('')
const load = useCallback(() => {
Promise.all([
api.get<Task>(`/tasks/${id}`),
api.get<PipelineView>(`/tasks/${id}/pipeline`),
])
.then(([t, pv]) => {
setTask(t)
setPipeline(pv)
setAssignee(t.assigned_agent)
setOpenStages((prev) => (Object.keys(prev).length === 0 ? { [t.stage]: true } : prev))
setErr('')
})
.catch((e: Error) => {
if (e.message !== 'unauthorized') setErr(e.message)
})
}, [id])
useEffect(() => {
load()
}, [load])
const runOp = async (op: () => Promise<unknown>, ok: string) => {
setErr('')
setOpMsg('')
try {
await op()
setOpMsg(ok)
setNote('')
load()
} catch (ex) {
setErr((ex as Error).message)
}
}
if (!task || !pipeline) {
return (
<div>
<div className="page-head"><h2>任务详情</h2></div>
{err ? <div className="error-box">{err}</div> : <div className="dim">加载中…</div>}
</div>
)
}
const currentIdx = STAGES.indexOf(task.stage as (typeof STAGES)[number])
const awaitingReview = task.stage_state === 'awaiting_review'
const finished = task.status === 'done' || task.status === 'cancelled'
return (
<div>
<div className="page-head">
<a href="#/board" className="mono-sm">← 看板</a>
<h2>{task.title}</h2>
<span className="dim mono-sm">{task.slug}</span>
<span className={`badge st-${task.stage_state}`}>{task.stage_state}</span>
<span className={`badge status-${task.status}`}>{task.status}</span>
<span className="badge">P{task.priority}</span>
<span className="badge">{task.kind}</span>
</div>
{err && <div className="error-box">{err}</div>}
{opMsg && <div className="panel mono-sm" style={{ borderColor: 'var(--green)', color: 'var(--green)' }}>{opMsg}</div>}
<div className="stepper">
{STAGES.map((stage, i) => {
const done = task.status === 'done' || i < currentIdx
const current = i === currentIdx && task.status !== 'done'
return (
<div
key={stage}
className={`step ${done ? 'done' : ''} ${current ? 'current' : ''}`}
onClick={() => setOpenStages((prev) => ({ ...prev, [stage]: !prev[stage] }))}
>
{done && <span className="check">✓</span>}
{stage}
</div>
)
})}
</div>
<div className="panel">
<h3>操作</h3>
<div className="op-row">
<label>assign agent
<input value={assignee} onChange={(e) => setAssignee(e.target.value)} placeholder="agent 名" style={{ width: 140 }} />
</label>
<button
disabled={!assignee.trim() || finished}
onClick={() => runOp(() => api.patch(`/tasks/${task.id}`, { assign: assignee.trim() }), `已指派给 ${assignee.trim()}`)}
>
指派
</button>
<span style={{ width: 16 }} />
<label>note
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="审批备注" style={{ width: 260 }} />
</label>
<button
className="primary"
disabled={!awaitingReview}
title={awaitingReview ? '' : '仅 awaiting_review 可推进'}
onClick={() => runOp(() => api.post(`/tasks/${task.id}/stages/advance`, { note }), '已推进')}
>
批准推进
</button>
<button
className="danger"
disabled={!awaitingReview}
title={awaitingReview ? '' : '仅 awaiting_review 可退回'}
onClick={() => runOp(() => api.post(`/tasks/${task.id}/stages/send-back`, { note }), '已退回 changes_requested')}
>
退回修改
</button>
</div>
<div className="dim mono-sm">assigned: {task.assigned_agent || '(none)'} · heartbeat {fmtTime(task.heartbeat_at)} · lease {fmtTime(task.lease_expires_at)}</div>
</div>
{!finished && <ExternalSubmitForm task={task} onDone={load} />}
{task.instruction_md && (
<details className="fold panel">
<summary>任务指令(instruction_md)</summary>
<Markdown text={task.instruction_md} />
</details>
)}
<div>
{pipeline.stages.map((sv) => (
<StagePanel
key={sv.stage}
view={sv}
currentStage={pipeline.stage}
open={!!openStages[sv.stage]}
onToggle={() => setOpenStages((prev) => ({ ...prev, [sv.stage]: !prev[sv.stage] }))}
/>
))}
</div>
<div className="dim mono-sm" style={{ marginTop: 8 }}>
created {fmtTime(task.created_at)} · updated {fmtTime(task.updated_at)} ·{' '}
<a href="#/board" onClick={(e) => { e.preventDefault(); nav('/board') }}>返回看板</a>
</div>
</div>
)
}
-18
View File
@@ -1,18 +0,0 @@
import { useEffect, useState } from 'react'
// Minimal hash routing: works with the embedded SPA fallback and needs no
// router dependency.
export function useHashPath(): string {
const [path, setPath] = useState(() => window.location.hash.slice(1) || '/')
useEffect(() => {
const onChange = () => setPath(window.location.hash.slice(1) || '/')
window.addEventListener('hashchange', onChange)
return () => window.removeEventListener('hashchange', onChange)
}, [])
return path
}
export function nav(to: string) {
window.location.hash = to
}
-231
View File
@@ -1,231 +0,0 @@
// Mirrors internal/controlcenter Go types (JSON tags).
export const STAGES = [
'proposal',
'analysis',
'design',
'review',
'implementation',
'testing',
'evaluation',
'confirmation',
] as const
export type Stage = (typeof STAGES)[number]
export const STAGE_STATES = [
'idle',
'assigned',
'in_progress',
'awaiting_review',
'changes_requested',
'approved',
] as const
export const TASK_STATUSES = [
'planned',
'in_progress',
'blocked_pending_decision',
'done',
'cancelled',
] as const
export interface Task {
id: string
slug: string
plan_id?: string
title: string
kind: string
status: string
stage: string
stage_state: string
testing_required: boolean
priority: number
owner: string
agent_kind: string
mutex: string
branch: string
worktree: string
instruction_md: string
doc_path: string
assigned_agent: string
lease_expires_at?: string
heartbeat_at?: string
created_at: string
updated_at: string
}
export interface Plan {
id: string
slug: string
title: string
horizon: string
status: string
summary_md: string
doc_path: string
created_at: string
updated_at: string
}
export interface StageStateCount {
stage: string
stage_state: string
count: number
}
export interface AgentLiveness {
agent: string
last_heartbeat_at?: string
stale: boolean
}
export interface Overview {
task_counts: StageStateCount[]
open_decisions: number
awaiting_review: number
open_findings: number
agents: AgentLiveness[]
}
export interface TaskEvent {
id: number
task_id: string
agent: string
phase: string
message: string
payload: unknown
ts: string
}
export interface Suggestion {
option: string
rationale: string
impact: string
}
export interface Decision {
id: string
task_id: string
stage: string
question: string
context_md: string
suggestions: Suggestion[]
status: string
resolution: string
decided_by: string
created_at: string
resolved_at?: string
}
export interface DecisionItem extends Decision {
task_slug: string
task_title: string
}
export interface Artifact {
id: string
task_id: string
stage: string
kind: string
title: string
content_md?: string
source_type: string
source_ref: string
created_at: string
}
export interface Transition {
id: number
task_id: string
from_stage: string
to_stage: string
actor: string
approved_by: string
note: string
ts: string
}
export interface StageView {
stage: string
artifacts: Artifact[]
transitions: Transition[]
decisions: Decision[]
}
export interface PipelineView {
task_id: string
stage: string
stage_state: string
stages: StageView[]
}
export interface StageSetting {
scope: string
task_id?: string
stage: string
model: string
executor: string
}
export interface DocListItem {
id: string
path: string
title: string
taxonomy: string
synced_at: string
chunks: number
}
export interface DocDetail extends DocListItem {
frontmatter: Record<string, unknown>
}
export interface SearchSource {
type: 'doc' | 'artifact'
path?: string
title?: string
task_slug?: string
stage?: string
}
export interface SearchResult {
score: number
snippet: string
heading: string
source: SearchSource
doc_id?: string
artifact_id?: string
}
export interface SearchResponse {
q: string
mode: string
degraded: boolean
count: number
results: SearchResult[]
}
export interface AuditRun {
id: number
kind: string
status: string
summary: string
error: string
started_at: string
finished_at?: string
findings: number
}
export interface AuditFinding {
id: string
run_id: number
severity: string
kind: string
title: string
detail_md: string
refs: string[]
status: string
task_id?: string
created_at: string
run_started_at: string
}
-17
View File
@@ -1,17 +0,0 @@
export function fmtTime(ts?: string): string {
if (!ts) return '-'
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ts
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
export function fmtAgo(ts?: string): string {
if (!ts) return 'never'
const ms = Date.now() - new Date(ts).getTime()
if (Number.isNaN(ms)) return ts
if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s ago`
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`
return `${Math.floor(ms / 86_400_000)}d ago`
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src", "vite.config.ts"]
}
-15
View File
@@ -1,15 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: '../../internal/controlcenter/webui/dist',
emptyOutDir: true,
},
server: {
proxy: {
'/api': 'http://127.0.0.1:8790',
},
},
})
-30
View File
@@ -1,30 +0,0 @@
name: control-center
services:
db:
image: pgvector/pgvector:pg16
container_name: control-center-db
restart: unless-stopped
environment:
POSTGRES_USER: ${CC_DB_USER:-cc}
POSTGRES_PASSWORD: ${CC_DB_PASSWORD:-cc}
POSTGRES_DB: ${CC_DB_NAME:-control_center}
ports:
- "127.0.0.1:${CC_DB_PORT:-5440}:5432"
volumes:
- cc-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cc} -d $${POSTGRES_DB:-control_center}"]
interval: 5s
timeout: 3s
retries: 12
volumes:
cc-db-data:
# Docker's predefined pool is exhausted on this host; pin an explicit subnet.
networks:
default:
ipam:
config:
- subnet: 172.32.0.0/24
-202
View File
@@ -1,202 +0,0 @@
-- +goose Up
-- Control Center core schema: plans, tasks, 8-stage pipeline, agent collaboration,
-- document/artifact vector store, audit findings, snapshots.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE cc_plans (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
title text NOT NULL,
horizon text NOT NULL DEFAULT 'short' CHECK (horizon IN ('short','long','backlog')),
status text NOT NULL DEFAULT 'planned' CHECK (status IN ('planned','in_progress','done','cancelled')),
summary_md text NOT NULL DEFAULT '',
doc_path text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE cc_agents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
kind text NOT NULL DEFAULT 'generic',
token_hash text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE cc_tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
plan_id uuid REFERENCES cc_plans(id) ON DELETE SET NULL,
title text NOT NULL,
kind text NOT NULL DEFAULT 'task' CHECK (kind IN ('task','proposal')),
status text NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned','in_progress','blocked_pending_decision','done','cancelled')),
stage text NOT NULL DEFAULT 'proposal'
CHECK (stage IN ('proposal','analysis','design','review','implementation','testing','evaluation','confirmation')),
stage_state text NOT NULL DEFAULT 'idle'
CHECK (stage_state IN ('idle','assigned','in_progress','awaiting_review','changes_requested','approved')),
testing_required boolean NOT NULL DEFAULT true,
priority int NOT NULL DEFAULT 0,
owner text NOT NULL DEFAULT '',
agent_kind text NOT NULL DEFAULT '',
mutex text NOT NULL DEFAULT '',
branch text NOT NULL DEFAULT '',
worktree text NOT NULL DEFAULT '',
instruction_md text NOT NULL DEFAULT '',
doc_path text NOT NULL DEFAULT '',
assigned_agent text NOT NULL DEFAULT '',
lease_expires_at timestamptz,
heartbeat_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX cc_tasks_status_idx ON cc_tasks (status);
CREATE INDEX cc_tasks_stage_idx ON cc_tasks (stage, stage_state);
CREATE INDEX cc_tasks_plan_idx ON cc_tasks (plan_id);
CREATE TABLE cc_task_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id uuid NOT NULL REFERENCES cc_tasks(id) ON DELETE CASCADE,
agent text NOT NULL DEFAULT '',
phase text NOT NULL CHECK (phase IN ('start','plan','progress','blocked','report','done','advance-request','decision','system')),
message text NOT NULL DEFAULT '',
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
ts timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX cc_task_events_task_idx ON cc_task_events (task_id, id);
CREATE TABLE cc_task_reports (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id uuid NOT NULL REFERENCES cc_tasks(id) ON DELETE CASCADE,
agent text NOT NULL DEFAULT '',
kind text NOT NULL DEFAULT 'result' CHECK (kind IN ('result','test-report','handoff')),
content_md text NOT NULL,
links jsonb NOT NULL DEFAULT '[]'::jsonb,
ts timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX cc_task_reports_task_idx ON cc_task_reports (task_id, id);
CREATE TABLE cc_stage_artifacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES cc_tasks(id) ON DELETE CASCADE,
stage text NOT NULL,
kind text NOT NULL DEFAULT 'note',
title text NOT NULL DEFAULT '',
content_md text NOT NULL,
source_type text NOT NULL DEFAULT 'agent' CHECK (source_type IN ('agent','human','external')),
source_ref text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX cc_stage_artifacts_task_idx ON cc_stage_artifacts (task_id, stage);
CREATE TABLE cc_stage_transitions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id uuid NOT NULL REFERENCES cc_tasks(id) ON DELETE CASCADE,
from_stage text NOT NULL,
to_stage text NOT NULL,
actor text NOT NULL,
approved_by text NOT NULL,
note text NOT NULL DEFAULT '',
ts timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX cc_stage_transitions_task_idx ON cc_stage_transitions (task_id, id);
CREATE TABLE cc_stage_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope text NOT NULL DEFAULT 'global' CHECK (scope IN ('global','task')),
task_id uuid REFERENCES cc_tasks(id) ON DELETE CASCADE,
stage text NOT NULL,
model text NOT NULL DEFAULT '',
executor text NOT NULL DEFAULT 'agent' CHECK (executor IN ('agent','human','external')),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (scope, task_id, stage)
);
CREATE TABLE cc_decisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES cc_tasks(id) ON DELETE CASCADE,
stage text NOT NULL,
question text NOT NULL,
context_md text NOT NULL DEFAULT '',
suggestions jsonb NOT NULL DEFAULT '[]'::jsonb,
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','resolved')),
resolution text NOT NULL DEFAULT '',
decided_by text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz
);
CREATE INDEX cc_decisions_open_idx ON cc_decisions (status) WHERE status = 'open';
CREATE TABLE cc_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
path text NOT NULL UNIQUE,
title text NOT NULL DEFAULT '',
taxonomy text NOT NULL DEFAULT '',
frontmatter jsonb NOT NULL DEFAULT '{}'::jsonb,
sha256 text NOT NULL,
synced_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE cc_doc_chunks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
doc_id uuid REFERENCES cc_documents(id) ON DELETE CASCADE,
artifact_id uuid REFERENCES cc_stage_artifacts(id) ON DELETE CASCADE,
seq int NOT NULL,
heading text NOT NULL DEFAULT '',
content text NOT NULL,
chunk_hash text NOT NULL UNIQUE,
embedding vector(1024),
tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (doc_id IS NOT NULL OR artifact_id IS NOT NULL)
);
CREATE INDEX cc_doc_chunks_embedding_hnsw ON cc_doc_chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX cc_doc_chunks_tsv_idx ON cc_doc_chunks USING gin (tsv);
CREATE TABLE cc_audit_runs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind text NOT NULL DEFAULT 'daily',
status text NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','failed')),
summary text NOT NULL DEFAULT '',
error text NOT NULL DEFAULT '',
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz
);
CREATE TABLE cc_audit_findings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id bigint NOT NULL REFERENCES cc_audit_runs(id) ON DELETE CASCADE,
severity text NOT NULL DEFAULT 'medium' CHECK (severity IN ('low','medium','high','critical')),
kind text NOT NULL DEFAULT 'gap' CHECK (kind IN ('bug','gap','unfinished','doc-drift')),
title text NOT NULL,
detail_md text NOT NULL DEFAULT '',
refs jsonb NOT NULL DEFAULT '[]'::jsonb,
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','converted','dismissed')),
task_id uuid REFERENCES cc_tasks(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX cc_audit_findings_status_idx ON cc_audit_findings (status);
CREATE TABLE cc_snapshots (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
revision text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE IF EXISTS cc_snapshots;
DROP TABLE IF EXISTS cc_audit_findings;
DROP TABLE IF EXISTS cc_audit_runs;
DROP TABLE IF EXISTS cc_doc_chunks;
DROP TABLE IF EXISTS cc_documents;
DROP TABLE IF EXISTS cc_decisions;
DROP TABLE IF EXISTS cc_stage_settings;
DROP TABLE IF EXISTS cc_stage_transitions;
DROP TABLE IF EXISTS cc_stage_artifacts;
DROP TABLE IF EXISTS cc_task_reports;
DROP TABLE IF EXISTS cc_task_events;
DROP TABLE IF EXISTS cc_tasks;
DROP TABLE IF EXISTS cc_agents;
DROP TABLE IF EXISTS cc_plans;
-250
View File
@@ -1,250 +0,0 @@
// Package audit runs the daily repository review: deterministic checks over
// cc_tasks (pure SQL + git), plus an optional LLM review, writing runs into
// cc_audit_runs and findings into cc_audit_findings. It also owns the
// findings lifecycle (convert to task, dismiss).
package audit
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"chat-agent-system/internal/controlcenter/llm"
"chat-agent-system/internal/controlcenter/store"
)
var (
ErrAlreadyRunning = errors.New("audit: a run is already in progress")
ErrNotFound = errors.New("audit: not found")
ErrBadState = errors.New("audit: bad state")
)
// advisoryLockKey serializes run creation across processes.
const advisoryLockKey int64 = 0x6363_6175_6469_74 // "ccaudit"
// Runner executes audit runs against the store and the local git repo.
// Chat may be nil: the run then covers deterministic checks only.
type Runner struct {
st *store.Store
repoRoot string
chat llm.ChatClient
}
func New(st *store.Store, repoRoot string, chat llm.ChatClient) *Runner {
return &Runner{st: st, repoRoot: repoRoot, chat: chat}
}
type Finding struct {
ID string `json:"id"`
RunID int64 `json:"run_id"`
Severity string `json:"severity"`
Kind string `json:"kind"`
Title string `json:"title"`
DetailMD string `json:"detail_md"`
Refs []string `json:"refs"`
Status string `json:"status"`
TaskID *string `json:"task_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
var validSeverity = map[string]bool{"low": true, "medium": true, "high": true, "critical": true}
var validKind = map[string]bool{"bug": true, "gap": true, "unfinished": true, "doc-drift": true}
// Run executes one audit synchronously: inserts a running cc_audit_runs row
// (ErrAlreadyRunning when one exists), then the deterministic checks, then the
// LLM review when configured. The run row always ends completed or failed;
// an LLM failure degrades into the summary instead of failing the run.
func (r *Runner) Run(ctx context.Context, kind string) (int64, error) {
runID, err := r.startRun(ctx, kind)
if err != nil {
return 0, err
}
return runID, r.executeRun(ctx, runID, kind)
}
// StartManual reserves a run row and executes it in a background goroutine,
// returning the run id immediately. A second call while a run is in flight
// gets ErrAlreadyRunning.
func StartManual(ctx context.Context, r *Runner) (int64, error) {
runID, err := r.startRun(ctx, "manual")
if err != nil {
return 0, err
}
go func() {
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if err := r.executeRun(runCtx, runID, "manual"); err != nil {
slog.Error("manual audit run", "run", runID, "err", err)
}
}()
return runID, nil
}
// executeRun performs the checks and finalizes the run row.
func (r *Runner) executeRun(ctx context.Context, runID int64, kind string) (err error) {
slog.Info("audit run started", "run", runID, "kind", kind)
var summary string
defer func() {
finCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err != nil {
_, _ = r.st.Pool.Exec(finCtx,
`UPDATE cc_audit_runs SET status='failed', error=$2, summary=$3, finished_at=now() WHERE id=$1`,
runID, err.Error(), summary)
return
}
_, _ = r.st.Pool.Exec(finCtx,
`UPDATE cc_audit_runs SET status='completed', summary=$2, finished_at=now() WHERE id=$1`,
runID, summary)
}()
var detCount, llmCount int
var notes []string
findings, ferr := r.deterministicChecks(ctx)
if ferr != nil {
return fmt.Errorf("deterministic checks: %w", ferr)
}
for _, f := range findings {
inserted, ierr := r.insertFinding(ctx, runID, f)
if ierr != nil {
return fmt.Errorf("insert finding %q: %w", f.Title, ierr)
}
if inserted {
detCount++
}
}
if r.chat == nil {
notes = append(notes, "LLM 未接入,仅确定性检查")
} else {
llmFindings, lerr := r.llmReview(ctx, runID)
switch {
case errors.Is(lerr, llm.ErrNotConfigured):
notes = append(notes, "LLM 未配置,仅确定性检查")
case lerr != nil:
notes = append(notes, "LLM 审查降级: "+lerr.Error())
default:
for _, f := range llmFindings {
inserted, ierr := r.insertFinding(ctx, runID, f)
if ierr != nil {
notes = append(notes, fmt.Sprintf("LLM finding %q 写入失败: %v", f.Title, ierr))
continue
}
if inserted {
llmCount++
}
}
}
}
summary = fmt.Sprintf("确定性 findings: %d;LLM findings: %d", detCount, llmCount)
for _, n := range notes {
summary += ";" + n
}
slog.Info("audit run finished", "run", runID, "summary", summary)
return nil
}
// startRun inserts the running row; an advisory lock serializes the
// check-then-insert so concurrent triggers cannot both start a run.
func (r *Runner) startRun(ctx context.Context, kind string) (int64, error) {
if kind == "" {
kind = "daily"
}
tx, err := r.st.Pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, advisoryLockKey); err != nil {
return 0, err
}
var busy int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM cc_audit_runs WHERE status='running'`).Scan(&busy); err != nil {
return 0, err
}
if busy > 0 {
return 0, ErrAlreadyRunning
}
var id int64
if err := tx.QueryRow(ctx,
`INSERT INTO cc_audit_runs (kind) VALUES ($1) RETURNING id`, kind).Scan(&id); err != nil {
return 0, err
}
return id, tx.Commit(ctx)
}
// insertFinding inserts a finding, skipping duplicates: an open finding with
// the same title (from any earlier run) suppresses the insert.
func (r *Runner) insertFinding(ctx context.Context, runID int64, f Finding) (bool, error) {
refs, err := json.Marshal(f.Refs)
if err != nil {
return false, err
}
var id string
err = r.st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_findings (run_id, severity, kind, title, detail_md, refs)
SELECT $1,$2,$3,$4,$5,$6
WHERE NOT EXISTS (SELECT 1 FROM cc_audit_findings WHERE status='open' AND title=$4)
RETURNING id`,
runID, f.Severity, f.Kind, f.Title, f.DetailMD, refs).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// NextFire computes the next local wall-clock trigger for dailyAt ("HH:MM"),
// strictly after now. An unparsable dailyAt falls back to 03:30.
func NextFire(now time.Time, dailyAt string) time.Time {
hh, mm := 3, 30
if _, err := fmt.Sscanf(dailyAt, "%d:%d", &hh, &mm); err != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 {
hh, mm = 3, 30
}
loc := now.Location()
next := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc)
if !next.After(now) {
next = next.AddDate(0, 0, 1)
}
return next
}
// StartScheduler fires a daily run at dailyAt (local time), checking once per
// minute, until ctx is cancelled. Returns immediately.
func (r *Runner) StartScheduler(ctx context.Context, dailyAt string) {
go func() {
next := NextFire(time.Now(), dailyAt)
slog.Info("audit scheduler armed", "daily_at", dailyAt, "next", next.Format(time.RFC3339))
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
if now.Before(next) {
continue
}
next = NextFire(now, dailyAt)
go func() {
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if _, err := r.Run(runCtx, "daily"); err != nil && !errors.Is(err, ErrAlreadyRunning) {
slog.Error("scheduled audit", "err", err)
}
}()
}
}
}()
}
@@ -1,97 +0,0 @@
package audit
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseFindingsJSON(t *testing.T) {
t.Run("fenced block with surrounding prose", func(t *testing.T) {
raw := "以下是审查结果:\n```json\n[{\"severity\":\"high\",\"kind\":\"bug\",\"title\":\"空指针\",\"detail\":\"d\",\"refs\":[\"a.go\"]}]\n```\n以上。"
fs, err := parseFindingsJSON(raw)
require.NoError(t, err)
require.Len(t, fs, 1)
assert.Equal(t, "high", fs[0].Severity)
assert.Equal(t, "bug", fs[0].Kind)
assert.Equal(t, "空指针", fs[0].Title)
assert.Equal(t, []string{"a.go"}, fs[0].Refs)
})
t.Run("bare array", func(t *testing.T) {
fs, err := parseFindingsJSON(`[{"title":"t1"}]`)
require.NoError(t, err)
require.Len(t, fs, 1)
assert.Equal(t, "medium", fs[0].Severity, "invalid severity defaults to medium")
assert.Equal(t, "gap", fs[0].Kind, "invalid kind defaults to gap")
assert.Equal(t, []string{}, fs[0].Refs)
})
t.Run("empty array", func(t *testing.T) {
fs, err := parseFindingsJSON("没有问题。\n[]")
require.NoError(t, err)
assert.Empty(t, fs)
})
t.Run("entries without title dropped", func(t *testing.T) {
fs, err := parseFindingsJSON(`[{"severity":"low","kind":"gap","detail":"no title"},{"title":"ok"}]`)
require.NoError(t, err)
require.Len(t, fs, 1)
assert.Equal(t, "ok", fs[0].Title)
})
t.Run("bad json", func(t *testing.T) {
_, err := parseFindingsJSON("模型没有输出数组")
require.Error(t, err)
_, err = parseFindingsJSON("[{\"title\": 不合法}")
require.Error(t, err)
})
t.Run("think block with brackets ignored", func(t *testing.T) {
raw := "<think>考虑 refs:[\"a.go\"] 是否重复</think>\n[{\"severity\":\"medium\",\"kind\":\"gap\",\"title\":\"CC-GATE-LIVE probe\",\"detail\":\"parsed\"}]"
fs, err := parseFindingsJSON(raw)
require.NoError(t, err)
require.Len(t, fs, 1)
assert.Equal(t, "CC-GATE-LIVE probe", fs[0].Title)
})
t.Run("severity/kind case normalized", func(t *testing.T) {
fs, err := parseFindingsJSON(`[{"severity":"HIGH","kind":"Doc-Drift","title":"x"}]`)
require.NoError(t, err)
assert.Equal(t, "high", fs[0].Severity)
assert.Equal(t, "doc-drift", fs[0].Kind)
})
}
func TestNextFire(t *testing.T) {
loc := time.Local
at := func(h, m int) time.Time { return time.Date(2026, 9, 17, h, m, 0, 0, loc) }
t.Run("before daily time fires today", func(t *testing.T) {
next := NextFire(at(2, 0), "03:30")
assert.Equal(t, at(3, 30), next)
})
t.Run("after daily time fires tomorrow", func(t *testing.T) {
next := NextFire(at(10, 0), "03:30")
assert.Equal(t, at(3, 30).AddDate(0, 0, 1), next)
})
t.Run("exactly at daily time fires tomorrow (strictly after)", func(t *testing.T) {
next := NextFire(at(3, 30), "03:30")
assert.Equal(t, at(3, 30).AddDate(0, 0, 1), next)
})
t.Run("invalid value falls back to 03:30", func(t *testing.T) {
next := NextFire(at(10, 0), "junk")
assert.Equal(t, at(3, 30).AddDate(0, 0, 1), next)
})
t.Run("out of range falls back to 03:30", func(t *testing.T) {
next := NextFire(at(1, 0), "25:99")
assert.Equal(t, at(3, 30), next)
})
t.Run("non-zero seconds still fire same day", func(t *testing.T) {
now := time.Date(2026, 9, 17, 3, 29, 59, 0, loc)
next := NextFire(now, "03:30")
assert.Equal(t, at(3, 30), next)
})
}
-172
View File
@@ -1,172 +0,0 @@
package audit
import (
"context"
"fmt"
"os/exec"
"sort"
"strings"
"time"
)
type staleTask struct {
ID string
Slug string
Title string
Status string
Stage string
Branch string
UpdatedAt time.Time
}
// deterministicChecks runs the SQL + git checks that must always execute,
// regardless of LLM availability.
func (r *Runner) deterministicChecks(ctx context.Context) ([]Finding, error) {
var out []Finding
query := func(sql, titleFn, kind, severity, detailFmt string) error {
rows, err := r.st.Pool.Query(ctx, sql)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t staleTask
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Status, &t.Stage, &t.UpdatedAt); err != nil {
return err
}
out = append(out, Finding{
Severity: severity,
Kind: kind,
Title: fmt.Sprintf(titleFn, t.Slug),
DetailMD: fmt.Sprintf(detailFmt, t.Slug, t.Title, t.Status, t.Stage, t.UpdatedAt.Format(time.RFC3339)),
Refs: []string{"cc_tasks/" + t.Slug},
})
}
return rows.Err()
}
const cols = `SELECT id, slug, title, status, stage, updated_at FROM cc_tasks `
const detail = "任务 `%s`(%s)\n\n- status: `%s`\n- stage: `%s`\n- updated_at: %s\n"
// 1. awaiting_review for more than 24h.
if err := query(
cols+`WHERE stage_state='awaiting_review' AND status NOT IN ('done','cancelled')
AND updated_at < now() - interval '24 hours'`,
"任务 %s 待审(awaiting_review)超过 24h", "unfinished", "medium", detail); err != nil {
return nil, err
}
// 3. blocked_pending_decision for more than 48h.
if err := query(
cols+`WHERE status='blocked_pending_decision' AND updated_at < now() - interval '48 hours'`,
"任务 %s 决策阻塞超过 48h", "gap", "high", detail); err != nil {
return nil, err
}
// 4. planned for more than 7 days without movement.
if err := query(
cols+`WHERE status='planned' AND updated_at < now() - interval '7 days'`,
"任务 %s planned 超过 7 天未推进", "gap", "low", detail); err != nil {
return nil, err
}
// 2. in_progress with a stale heartbeat (>30min) or an expired lease.
rows, err := r.st.Pool.Query(ctx,
cols+`WHERE stage_state='in_progress' AND status NOT IN ('done','cancelled')
AND (heartbeat_at IS NULL OR heartbeat_at < now() - interval '30 minutes'
OR (lease_expires_at IS NOT NULL AND lease_expires_at < now()))`)
if err != nil {
return nil, err
}
for rows.Next() {
var t staleTask
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Status, &t.Stage, &t.UpdatedAt); err != nil {
rows.Close()
return nil, err
}
out = append(out, Finding{
Severity: "high",
Kind: "unfinished",
Title: fmt.Sprintf("任务 %s in_progress 心跳过期或租约过期", t.Slug),
DetailMD: fmt.Sprintf(detail, t.Slug, t.Title, t.Status, t.Stage, t.UpdatedAt.Format(time.RFC3339)),
Refs: []string{"cc_tasks/" + t.Slug},
})
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// 5. Declared branch missing from the local repo.
branches, err := r.missingBranches(ctx)
if err != nil {
return nil, err
}
out = append(out, branches...)
sortFindings(out)
return out, nil
}
func (r *Runner) missingBranches(ctx context.Context) ([]Finding, error) {
rows, err := r.st.Pool.Query(ctx,
`SELECT slug, title, branch FROM cc_tasks
WHERE branch <> '' AND status NOT IN ('done','cancelled')
ORDER BY slug`)
if err != nil {
return nil, err
}
defer rows.Close()
type row struct{ slug, title, branch string }
var declared []row
uniq := map[string]bool{}
for rows.Next() {
var t row
if err := rows.Scan(&t.slug, &t.title, &t.branch); err != nil {
return nil, err
}
if uniq[t.branch] {
continue
}
uniq[t.branch] = true
declared = append(declared, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
var out []Finding
for _, t := range declared {
if strings.Contains(t.branch, "..") || strings.HasPrefix(t.branch, "-") {
continue // not a valid ref name; treat as non-git metadata
}
if branchExists(ctx, r.repoRoot, t.branch) {
continue
}
out = append(out, Finding{
Severity: "medium",
Kind: "doc-drift",
Title: fmt.Sprintf("任务 %s 声明的分支 %s 在本地仓库不存在", t.slug, t.branch),
DetailMD: fmt.Sprintf("任务 `%s`(%s)声明 branch=`%s`,但 `git show-ref --verify refs/heads/%s` 未命中。", t.slug, t.title, t.branch, t.branch),
Refs: []string{"cc_tasks/" + t.slug, "refs/heads/" + t.branch},
})
}
return out, nil
}
func branchExists(ctx context.Context, repoRoot, branch string) bool {
gitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(gitCtx, "git", "-C", repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
return cmd.Run() == nil
}
// sortFindings gives output a stable order: severity desc, then title.
func sortFindings(fs []Finding) {
rank := map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3}
sort.SliceStable(fs, func(i, j int) bool {
if rank[fs[i].Severity] != rank[fs[j].Severity] {
return rank[fs[i].Severity] < rank[fs[j].Severity]
}
return fs[i].Title < fs[j].Title
})
}
@@ -1,372 +0,0 @@
package audit
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/store"
)
type fakeChat struct {
resp string
err error
blockCh chan struct{} // when non-nil, Chat blocks until closed
}
func (f *fakeChat) Chat(ctx context.Context, system, user string) (string, error) {
if f.blockCh != nil {
select {
case <-f.blockCh:
case <-ctx.Done():
return "", ctx.Err()
}
}
return f.resp, f.err
}
func newTestStore(t *testing.T) *store.Store {
t.Helper()
dsn := os.Getenv("TEST_CC_DB_DSN")
if dsn == "" {
t.Skip("TEST_CC_DB_DSN not set")
}
_, thisFile, _, _ := runtime.Caller(0)
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
ctx := t.Context()
require.NoError(t, store.Migrate(ctx, dsn, migrationsDir))
st, err := store.Connect(ctx, dsn)
require.NoError(t, err)
t.Cleanup(st.Close)
return st
}
// newTempRepo initializes a git repo with one commit and the given extra branches.
func newTempRepo(t *testing.T, extraBranches ...string) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, out)
}
run("init", "-b", "main")
run("config", "user.email", "test@example.com")
run("config", "user.name", "test")
require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644))
run("add", ".")
run("commit", "-m", "init")
for _, b := range extraBranches {
run("branch", b)
}
return dir
}
// runSync runs an audit, waiting out any concurrently active run: the gated
// integration tests share one dev DB, and the single-running guard is global.
func runSync(t *testing.T, r *Runner) int64 {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for {
id, err := r.Run(t.Context(), "manual")
if err == nil {
return id
}
if !errors.Is(err, ErrAlreadyRunning) || time.Now().After(deadline) {
require.NoError(t, err)
}
time.Sleep(200 * time.Millisecond)
}
}
func uniqSlug(prefix string) string {
return fmt.Sprintf("aud-it-%s-%d", prefix, time.Now().UnixNano())
}
func seedStaleTasks(t *testing.T, st *store.Store, repoRoot string) map[string]string {
t.Helper()
ctx := t.Context()
slugs := map[string]string{}
mk := func(key string) string {
s := uniqSlug(key)
slugs[key] = s
return s
}
ins := func(slug, sets string) {
_, err := st.Pool.Exec(ctx,
`INSERT INTO cc_tasks (slug, title) VALUES ($1, 't-'||$1)`, slug)
require.NoError(t, err)
if sets != "" {
_, err = st.Pool.Exec(ctx, `UPDATE cc_tasks SET `+sets+` WHERE slug=$1`, slug)
require.NoError(t, err)
}
}
// Hits.
ins(mk("review24"), `stage_state='awaiting_review', status='in_progress', updated_at=now()-interval '2 days'`)
ins(mk("stalehb"), `status='in_progress', stage_state='in_progress', heartbeat_at=now()-interval '1 hour'`)
ins(mk("blocked48"), `status='blocked_pending_decision', updated_at=now()-interval '3 days'`)
ins(mk("planned7"), `status='planned', updated_at=now()-interval '8 days'`)
ins(mk("missingbr"), `status='in_progress', branch='no-such-branch-`+fmt.Sprint(time.Now().UnixNano())+`'`)
// Controls: must NOT produce findings.
ins(mk("freshreview"), `stage_state='awaiting_review', status='in_progress'`)
ins(mk("livehb"), `status='in_progress', stage_state='in_progress', heartbeat_at=now(), lease_expires_at=now()+interval '1 hour'`)
ins(mk("okbranch"), `status='in_progress', branch='main'`)
return slugs
}
func findingTitles(t *testing.T, st *store.Store, runID int64) map[string]Finding {
t.Helper()
rows, err := st.Pool.Query(t.Context(),
`SELECT id, severity, kind, title, status FROM cc_audit_findings WHERE run_id=$1`, runID)
require.NoError(t, err)
defer rows.Close()
out := map[string]Finding{}
for rows.Next() {
var f Finding
require.NoError(t, rows.Scan(&f.ID, &f.Severity, &f.Kind, &f.Title, &f.Status))
out[f.Title] = f
}
return out
}
// openFindingBySlug finds the newest open finding whose title contains slug.
// Findings may belong to a run from another test binary sharing the dev DB:
// whichever run reaches the dedup check first owns the row.
func openFindingBySlug(t *testing.T, st *store.Store, slug string) *Finding {
t.Helper()
var f Finding
err := st.Pool.QueryRow(t.Context(),
`SELECT id, severity, kind, title, status FROM cc_audit_findings
WHERE status='open' AND strpos(title, $1) > 0
ORDER BY created_at DESC LIMIT 1`, slug).
Scan(&f.ID, &f.Severity, &f.Kind, &f.Title, &f.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
require.NoError(t, err)
return &f
}
func TestDeterministicChecksAndDedup(t *testing.T) {
st := newTestStore(t)
repo := newTempRepo(t)
slugs := seedStaleTasks(t, st, repo)
r := New(st, repo, nil)
ctx := t.Context()
runID := runSync(t, r)
var status, summary string
require.NoError(t, st.Pool.QueryRow(ctx,
`SELECT status, summary FROM cc_audit_runs WHERE id=$1`, runID).Scan(&status, &summary))
assert.Equal(t, "completed", status)
assert.Contains(t, summary, "仅确定性检查", "nil chat client degrades to deterministic-only")
expect := []struct{ key, kind, sev string }{
{"review24", "unfinished", "medium"},
{"stalehb", "unfinished", "high"},
{"blocked48", "gap", "high"},
{"planned7", "gap", "low"},
{"missingbr", "doc-drift", "medium"},
}
for _, e := range expect {
hit := openFindingBySlug(t, st, slugs[e.key])
require.NotNil(t, hit, "expected open finding for %s (%s)", e.key, slugs[e.key])
assert.Equal(t, e.kind, hit.Kind, e.key)
assert.Equal(t, e.sev, hit.Severity, e.key)
}
for _, key := range []string{"freshreview", "livehb", "okbranch"} {
assert.Nil(t, openFindingBySlug(t, st, slugs[key]),
"control task %s must not produce a finding", key)
}
// Second run: same open titles are deduped — zero new findings.
runID2 := runSync(t, r)
got2 := findingTitles(t, st, runID2)
assert.Empty(t, got2, "second run must not duplicate open findings")
}
func TestLLMReviewAndDegradation(t *testing.T) {
st := newTestStore(t)
repo := newTempRepo(t)
ctx := t.Context()
// Healthy LLM: fenced JSON output is parsed and stored.
good := &fakeChat{resp: "结果:\n```json\n[{\"severity\":\"high\",\"kind\":\"bug\",\"title\":\"LLM-发现的潜在 bug " + uniqSlug("llm") + "\",\"detail\":\"详情\",\"refs\":[\"x.go\"]}]\n```"}
r := New(st, repo, good)
runID := runSync(t, r)
var summary string
require.NoError(t, st.Pool.QueryRow(ctx, `SELECT summary FROM cc_audit_runs WHERE id=$1`, runID).Scan(&summary))
assert.Contains(t, summary, "LLM findings: 1")
got := findingTitles(t, st, runID)
found := false
for title := range got {
if strings.Contains(title, "LLM-发现的潜在 bug") {
found = true
}
}
assert.True(t, found, "LLM finding should be stored, got %v", got)
// Broken LLM output: run still completes, summary notes degradation.
bad := &fakeChat{resp: "我无法输出 JSON"}
r2 := New(st, repo, bad)
runID2 := runSync(t, r2)
var status2, summary2 string
require.NoError(t, st.Pool.QueryRow(ctx,
`SELECT status, summary FROM cc_audit_runs WHERE id=$1`, runID2).Scan(&status2, &summary2))
assert.Equal(t, "completed", status2, "LLM parse failure must not fail the run")
assert.Contains(t, summary2, "LLM 审查降级")
}
func TestConvertAndDismiss(t *testing.T) {
st := newTestStore(t)
ctx := t.Context()
mkFinding := func(title string) string {
var runID int64
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_runs (kind, status, finished_at) VALUES ('manual','completed',now()) RETURNING id`).Scan(&runID))
var id string
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_findings (run_id, severity, kind, title, detail_md, refs)
VALUES ($1,'medium','gap',$2,'细节内容','["ref-a","ref-b"]'::jsonb) RETURNING id`,
runID, title).Scan(&id))
return id
}
// Convert: proposal task created, finding converted + task_id backfilled.
fid := mkFinding("convert me " + uniqSlug("c"))
fi, err := ConvertFinding(ctx, st, fid)
require.NoError(t, err)
assert.Equal(t, "converted", fi.Status)
require.NotNil(t, fi.TaskID)
var kind, stage, stageState, status, title, instr string
require.NoError(t, st.Pool.QueryRow(ctx,
`SELECT kind, stage, stage_state, status, title, instruction_md FROM cc_tasks WHERE id=$1`,
*fi.TaskID).Scan(&kind, &stage, &stageState, &status, &title, &instr))
assert.Equal(t, "proposal", kind)
assert.Equal(t, "proposal", stage)
assert.Equal(t, "idle", stageState)
assert.Equal(t, "planned", status)
assert.True(t, len(title) > len("[audit] ") && title[:len("[audit] ")] == "[audit] ", "title prefix: %s", title)
assert.Contains(t, instr, "细节内容")
assert.Contains(t, instr, "ref-a")
// Converting again is a conflict.
_, err = ConvertFinding(ctx, st, fid)
require.ErrorIs(t, err, ErrBadState)
// Dismiss.
fid2 := mkFinding("dismiss me " + uniqSlug("d"))
require.NoError(t, DismissFinding(ctx, st, fid2))
var st2 string
require.NoError(t, st.Pool.QueryRow(ctx, `SELECT status FROM cc_audit_findings WHERE id=$1`, fid2).Scan(&st2))
assert.Equal(t, "dismissed", st2)
// Dismiss again / unknown id.
require.ErrorIs(t, DismissFinding(ctx, st, fid2), ErrBadState)
require.ErrorIs(t, DismissFinding(ctx, st, "00000000-0000-0000-0000-000000000000"), ErrNotFound)
}
func TestConcurrentRunConflict(t *testing.T) {
st := newTestStore(t)
repo := newTempRepo(t)
ctx := t.Context()
// A pre-existing running row blocks new runs.
var busyID int64
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_runs (kind, status) VALUES ('manual','running') RETURNING id`).Scan(&busyID))
r := New(st, repo, nil)
_, err := r.Run(ctx, "manual")
require.ErrorIs(t, err, ErrAlreadyRunning)
_, err = st.Pool.Exec(ctx, `DELETE FROM cc_audit_runs WHERE id=$1`, busyID)
require.NoError(t, err)
// True concurrency: a slow LLM holds the first run open while a second
// Run arrives and must get ErrAlreadyRunning. The gated tests share one
// dev DB, so both sides retry until the slow run holds the guard.
release := make(chan struct{})
slow := New(st, repo, &fakeChat{resp: "[]", blockCh: release})
done := make(chan error, 1)
go func() {
deadline := time.Now().Add(30 * time.Second)
for {
_, err := slow.Run(ctx, "manual")
if errors.Is(err, ErrAlreadyRunning) && time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
continue
}
done <- err
return
}
}()
deadline := time.Now().Add(30 * time.Second)
for {
_, err = New(st, repo, nil).Run(ctx, "manual")
if errors.Is(err, ErrAlreadyRunning) {
break
}
require.NoError(t, err)
require.False(t, time.Now().After(deadline), "slow run never held the guard")
time.Sleep(100 * time.Millisecond)
}
close(release)
require.NoError(t, <-done)
}
func TestListRunsAndFindings(t *testing.T) {
st := newTestStore(t)
repo := newTempRepo(t)
ctx := t.Context()
r := New(st, repo, &fakeChat{resp: "[]"})
runSync(t, r)
runs, err := ListRuns(ctx, st, 5)
require.NoError(t, err)
require.NotEmpty(t, runs)
assert.Equal(t, "completed", runs[0].Status)
assert.NotNil(t, runs[0].FinishedAt)
// No findings seeded here; check filtering plumbing with a manual finding.
var runID int64
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_runs (kind, status, finished_at) VALUES ('manual','completed',now()) RETURNING id`).Scan(&runID))
title := "list me " + uniqSlug("l")
var fid string
require.NoError(t, st.Pool.QueryRow(ctx,
`INSERT INTO cc_audit_findings (run_id, severity, kind, title) VALUES ($1,'high','bug',$2) RETURNING id`,
runID, title).Scan(&fid))
items, err := ListFindings(ctx, st, "open", "high", 100)
require.NoError(t, err)
seen := false
for _, it := range items {
if it.ID == fid {
seen = true
assert.False(t, it.RunStartedAt.IsZero())
assert.Equal(t, []string{}, it.Refs)
}
}
assert.True(t, seen, "new finding should appear in open/high list")
items, err = ListFindings(ctx, st, "dismissed", "", 100)
require.NoError(t, err)
for _, it := range items {
assert.NotEqual(t, fid, it.ID, "status filter must exclude open findings")
}
}
-216
View File
@@ -1,216 +0,0 @@
package audit
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"regexp"
"strings"
"time"
)
const (
gitLogMaxLines = 200
taskCtxLimit = 50
eventCtxLimit = 100
openFindLimit = 50
)
const reviewSystemPrompt = `你是个人研发仓库的审查员。基于给出的最近 git 提交、任务状态、事件流与已有 findings,
找出潜在的 bug、不完善处与未完成的工作。只输出一个纯 JSON 数组,不要输出任何其他文字,
每个元素形如 {"severity":"low|medium|high|critical","kind":"bug|gap|unfinished|doc-drift",
"title":"简短标题","detail":"markdown 详情","refs":["相关文件或任务 slug"]}。
不要重复已有 open findings 中已列出的问题。没有问题就输出 []。`
// llmReview gathers repo context, asks the model, and parses findings.
// Parse failures and transport errors are returned to the caller, which folds
// them into the run summary instead of failing the run.
func (r *Runner) llmReview(ctx context.Context, runID int64) ([]Finding, error) {
since, err := r.lastCompletedRunStart(ctx, runID)
if err != nil {
return nil, err
}
var sb strings.Builder
sb.WriteString("## 最近 git 提交(自上次审查以来)\n")
sb.WriteString(r.gitLog(ctx, since))
sb.WriteString("\n\n## 当前未完成任务\n")
if err := r.appendTaskSummary(ctx, &sb); err != nil {
return nil, err
}
sb.WriteString("\n\n## 最近事件(最新 100 条)\n")
if err := r.appendEventSummary(ctx, &sb); err != nil {
return nil, err
}
sb.WriteString("\n\n## 已有 open findings\n")
if err := r.appendOpenFindings(ctx, &sb); err != nil {
return nil, err
}
raw, err := r.chat.Chat(ctx, reviewSystemPrompt, sb.String())
if err != nil {
return nil, err
}
return parseFindingsJSON(raw)
}
// lastCompletedRunStart returns the start time of the most recent completed
// run before runID; with no prior run it falls back to 24h ago.
func (r *Runner) lastCompletedRunStart(ctx context.Context, runID int64) (time.Time, error) {
var t time.Time
err := r.st.Pool.QueryRow(ctx,
`SELECT started_at FROM cc_audit_runs
WHERE status='completed' AND id <> $1
ORDER BY id DESC LIMIT 1`, runID).Scan(&t)
if err != nil {
return time.Now().Add(-24 * time.Hour), nil
}
return t, nil
}
func (r *Runner) gitLog(ctx context.Context, since time.Time) string {
gitCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(gitCtx, "git", "-C", r.repoRoot, "log",
"--oneline", "--stat", "--since="+since.Format(time.RFC3339))
raw, err := cmd.Output()
if err != nil {
return "(git log 不可用)\n"
}
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
if len(lines) > gitLogMaxLines {
lines = append(lines[:gitLogMaxLines], fmt.Sprintf("…(截断,共 %d 行)", len(lines)))
}
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
return "(无提交)\n"
}
return strings.Join(lines, "\n") + "\n"
}
func (r *Runner) appendTaskSummary(ctx context.Context, sb *strings.Builder) error {
rows, err := r.st.Pool.Query(ctx,
`SELECT slug, title, status, stage, stage_state, assigned_agent
FROM cc_tasks WHERE status NOT IN ('done','cancelled')
ORDER BY updated_at DESC LIMIT $1`, taskCtxLimit)
if err != nil {
return err
}
defer rows.Close()
n := 0
for rows.Next() {
var slug, title, status, stage, state, agent string
if err := rows.Scan(&slug, &title, &status, &stage, &state, &agent); err != nil {
return err
}
fmt.Fprintf(sb, "- %s [%s/%s/%s agent=%s] %s\n", slug, status, stage, state, agent, title)
n++
}
if n == 0 {
sb.WriteString("(无)\n")
}
return rows.Err()
}
func (r *Runner) appendEventSummary(ctx context.Context, sb *strings.Builder) error {
rows, err := r.st.Pool.Query(ctx,
`SELECT t.slug, e.agent, e.phase, e.message, e.ts
FROM cc_task_events e JOIN cc_tasks t ON t.id = e.task_id
ORDER BY e.id DESC LIMIT $1`, eventCtxLimit)
if err != nil {
return err
}
defer rows.Close()
n := 0
for rows.Next() {
var slug, agent, phase, msg string
var ts time.Time
if err := rows.Scan(&slug, &agent, &phase, &msg, &ts); err != nil {
return err
}
msg = strings.ReplaceAll(msg, "\n", " ")
if len(msg) > 160 {
msg = msg[:160] + "…"
}
fmt.Fprintf(sb, "- %s %s %s %s: %s\n", ts.Format("01-02 15:04"), slug, agent, phase, msg)
n++
}
if n == 0 {
sb.WriteString("(无)\n")
}
return rows.Err()
}
func (r *Runner) appendOpenFindings(ctx context.Context, sb *strings.Builder) error {
rows, err := r.st.Pool.Query(ctx,
`SELECT severity, kind, title FROM cc_audit_findings
WHERE status='open' ORDER BY created_at DESC LIMIT $1`, openFindLimit)
if err != nil {
return err
}
defer rows.Close()
n := 0
for rows.Next() {
var sev, kind, title string
if err := rows.Scan(&sev, &kind, &title); err != nil {
return err
}
fmt.Fprintf(sb, "- [%s/%s] %s\n", sev, kind, title)
n++
}
if n == 0 {
sb.WriteString("(无)\n")
}
return rows.Err()
}
type llmFinding struct {
Severity string `json:"severity"`
Kind string `json:"kind"`
Title string `json:"title"`
Detail string `json:"detail"`
Refs []string `json:"refs"`
}
var thinkBlock = regexp.MustCompile(`(?is)<think>.*?</think>`)
// parseFindingsJSON tolerates ```json fences, MiniMax <think> blocks, and
// prose around the payload by extracting the span from the first '[' to the
// last ']'. Invalid severity or kind values fall back to medium / gap;
// entries without a title are dropped.
func parseFindingsJSON(raw string) ([]Finding, error) {
raw = thinkBlock.ReplaceAllString(raw, "")
if i := strings.Index(strings.ToLower(raw), "<think>"); i >= 0 {
raw = raw[:i]
}
start := strings.Index(raw, "[")
end := strings.LastIndex(raw, "]")
if start < 0 || end < start {
return nil, fmt.Errorf("audit: no JSON array in model output (%d bytes)", len(raw))
}
var parsed []llmFinding
if err := json.Unmarshal([]byte(raw[start:end+1]), &parsed); err != nil {
return nil, fmt.Errorf("audit: decode findings: %w", err)
}
out := []Finding{}
for _, f := range parsed {
title := strings.TrimSpace(f.Title)
if title == "" {
continue
}
sev := strings.ToLower(strings.TrimSpace(f.Severity))
if !validSeverity[sev] {
sev = "medium"
}
kind := strings.ToLower(strings.TrimSpace(f.Kind))
if !validKind[kind] {
kind = "gap"
}
refs := f.Refs
if refs == nil {
refs = []string{}
}
out = append(out, Finding{Severity: sev, Kind: kind, Title: title, DetailMD: f.Detail, Refs: refs})
}
return out, nil
}
-176
View File
@@ -1,176 +0,0 @@
package audit
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"chat-agent-system/internal/controlcenter/store"
)
// RunInfo is one row of cc_audit_runs plus its finding count.
type RunInfo struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Status string `json:"status"`
Summary string `json:"summary"`
Error string `json:"error"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Findings int `json:"findings"`
}
func ListRuns(ctx context.Context, st *store.Store, limit int) ([]RunInfo, error) {
if limit <= 0 || limit > 200 {
limit = 20
}
rows, err := st.Pool.Query(ctx,
`SELECT r.id, r.kind, r.status, r.summary, r.error, r.started_at, r.finished_at,
(SELECT count(*) FROM cc_audit_findings f WHERE f.run_id = r.id) AS findings
FROM cc_audit_runs r ORDER BY r.id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []RunInfo{}
for rows.Next() {
var ri RunInfo
if err := rows.Scan(&ri.ID, &ri.Kind, &ri.Status, &ri.Summary, &ri.Error,
&ri.StartedAt, &ri.FinishedAt, &ri.Findings); err != nil {
return nil, err
}
out = append(out, ri)
}
return out, rows.Err()
}
// FindingItem extends Finding with the run's start time for display.
type FindingItem struct {
Finding
RunStartedAt time.Time `json:"run_started_at"`
}
func ListFindings(ctx context.Context, st *store.Store, status, severity string, limit int) ([]FindingItem, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
q := `SELECT f.id, f.run_id, f.severity, f.kind, f.title, f.detail_md, f.refs,
f.status, f.task_id, f.created_at, r.started_at
FROM cc_audit_findings f JOIN cc_audit_runs r ON r.id = f.run_id
WHERE true`
args := []any{}
if status != "" {
args = append(args, status)
q += fmt.Sprintf(` AND f.status = $%d`, len(args))
}
if severity != "" {
args = append(args, severity)
q += fmt.Sprintf(` AND f.severity = $%d`, len(args))
}
args = append(args, limit)
q += fmt.Sprintf(` ORDER BY f.created_at DESC LIMIT $%d`, len(args))
rows, err := st.Pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []FindingItem{}
for rows.Next() {
var fi FindingItem
var refsRaw json.RawMessage
if err := rows.Scan(&fi.ID, &fi.RunID, &fi.Severity, &fi.Kind, &fi.Title,
&fi.DetailMD, &refsRaw, &fi.Status, &fi.TaskID, &fi.CreatedAt, &fi.RunStartedAt); err != nil {
return nil, err
}
if err := json.Unmarshal(refsRaw, &fi.Refs); err != nil {
fi.Refs = []string{}
}
out = append(out, fi)
}
return out, rows.Err()
}
// ConvertFinding creates a proposal task from an open finding and marks the
// finding converted. Returns the updated finding.
func ConvertFinding(ctx context.Context, st *store.Store, findingID string) (*FindingItem, error) {
tx, err := st.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var fi FindingItem
var refsRaw json.RawMessage
err = tx.QueryRow(ctx,
`SELECT id, run_id, severity, kind, title, detail_md, refs, status, task_id, created_at
FROM cc_audit_findings WHERE id = $1 FOR UPDATE`, findingID).
Scan(&fi.ID, &fi.RunID, &fi.Severity, &fi.Kind, &fi.Title, &fi.DetailMD,
&refsRaw, &fi.Status, &fi.TaskID, &fi.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if fi.Status != "open" {
return nil, fmt.Errorf("%w: finding is %s", ErrBadState, fi.Status)
}
if err := json.Unmarshal(refsRaw, &fi.Refs); err != nil {
fi.Refs = []string{}
}
var sb strings.Builder
sb.WriteString(fi.DetailMD)
if len(fi.Refs) > 0 {
sb.WriteString("\n\n## 引用\n")
for _, ref := range fi.Refs {
sb.WriteString("- " + ref + "\n")
}
}
slug := "audit-" + strings.ReplaceAll(fi.ID, "-", "")[:12]
var taskID string
if err := tx.QueryRow(ctx,
`INSERT INTO cc_tasks (slug, title, kind, instruction_md)
VALUES ($1, $2, 'proposal', $3) RETURNING id`,
slug, "[audit] "+fi.Title, sb.String()).Scan(&taskID); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx,
`UPDATE cc_audit_findings SET status='converted', task_id=$2 WHERE id=$1`,
findingID, taskID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
fi.Status = "converted"
fi.TaskID = &taskID
return &fi, nil
}
// DismissFinding marks an open finding dismissed.
func DismissFinding(ctx context.Context, st *store.Store, findingID string) error {
tag, err := st.Pool.Exec(ctx,
`UPDATE cc_audit_findings SET status='dismissed' WHERE id=$1 AND status='open'`, findingID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
var exists int
if err := st.Pool.QueryRow(ctx,
`SELECT count(*) FROM cc_audit_findings WHERE id=$1`, findingID).Scan(&exists); err != nil {
return err
}
if exists == 0 {
return ErrNotFound
}
return fmt.Errorf("%w: finding is not open", ErrBadState)
}
return nil
}
-127
View File
@@ -1,127 +0,0 @@
// Package config loads Control Center configuration: CC_* variables from the
// process environment, plus borrowed keys (embedding / LLM) from the system
// .env file when CC_* overrides are absent.
package config
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
HTTPAddr string
DBDSN string
ConsoleToken string
RepoRoot string
MigrationsDir string
EmbeddingEndpoint string
EmbeddingModel string
EmbeddingAPIKey string
LLMEndpoint string
LLMModel string
LLMAPIKey string
CursorModel string
AuditEnabled bool
AuditDailyAt string // "HH:MM" local time
DocSyncSecond int
}
// Load reads CC_* from the process env and falls back to keys borrowed from
// the system .env file (CC_ENV_FILE, default <repoRoot>/.env).
func Load() (Config, error) {
repoRoot := getenv("CC_REPO_ROOT", ".")
abs, err := filepath.Abs(repoRoot)
if err != nil {
return Config{}, fmt.Errorf("resolve CC_REPO_ROOT: %w", err)
}
envFile := getenv("CC_ENV_FILE", filepath.Join(abs, ".env"))
borrowed := parseEnvFile(envFile) // missing file is fine; values stay empty
pick := func(ccKey, borrowedKey, def string) string {
if v := os.Getenv(ccKey); v != "" {
return v
}
if borrowedKey != "" {
if v := borrowed[borrowedKey]; v != "" {
return v
}
if v := os.Getenv(borrowedKey); v != "" {
return v
}
}
return def
}
cfg := Config{
HTTPAddr: getenv("CC_HTTP_ADDR", "127.0.0.1:8790"),
DBDSN: getenv("CC_DB_DSN", "postgres://cc:cc@127.0.0.1:5440/control_center?sslmode=disable"),
ConsoleToken: os.Getenv("CC_CONSOLE_TOKEN"),
RepoRoot: abs,
MigrationsDir: getenv("CC_MIGRATIONS_DIR", filepath.Join(abs, "deploy", "control-center", "goose")),
EmbeddingEndpoint: pick("CC_EMBEDDING_ENDPOINT_URL", "EMBEDDING_ENDPOINT_URL", ""),
EmbeddingModel: pick("CC_EMBEDDING_MODEL", "EMBEDDING_MODEL", "bge-m3"),
EmbeddingAPIKey: pick("CC_EMBEDDING_API_KEY", "EMBEDDING_API_KEY", ""),
LLMEndpoint: pick("CC_LLM_ENDPOINT_URL", "MINIMAX_ENDPOINT_URL", ""),
LLMModel: pick("CC_LLM_MODEL", "MINIMAX_MODEL_ID", ""),
LLMAPIKey: pick("CC_LLM_API_KEY", "MINIMAX_API_KEY", ""),
CursorModel: pick("CC_CURSOR_MODEL", "CURSOR_MODEL", ""),
AuditEnabled: getenv("CC_AUDIT_ENABLED", "true") == "true",
AuditDailyAt: getenv("CC_AUDIT_DAILY_AT", "03:30"),
DocSyncSecond: getint("CC_DOC_SYNC_SECONDS", 60),
}
return cfg, nil
}
func getint(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return def
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// parseEnvFile reads KEY=VALUE lines; comments and blank lines are skipped.
// It never fails: an unreadable file yields an empty map.
func parseEnvFile(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
out[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"'`)
}
return out
}
@@ -1,117 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadPickPrefersCCOverBorrowedEnvFile(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
writeEnv(t, envPath, `
EMBEDDING_ENDPOINT_URL=http://borrowed-embed
MINIMAX_ENDPOINT_URL=http://borrowed-llm
CURSOR_MODEL=borrowed-cursor
EMBEDDING_MODEL=borrowed-model
`)
t.Setenv("CC_REPO_ROOT", dir)
t.Setenv("CC_ENV_FILE", envPath)
t.Setenv("CC_EMBEDDING_ENDPOINT_URL", "http://cc-embed")
t.Setenv("CC_LLM_ENDPOINT_URL", "http://cc-llm")
t.Setenv("CC_CURSOR_MODEL", "cc-cursor")
t.Setenv("EMBEDDING_ENDPOINT_URL", "http://process-embed")
t.Setenv("MINIMAX_ENDPOINT_URL", "http://process-llm")
t.Setenv("CURSOR_MODEL", "process-cursor")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.EmbeddingEndpoint != "http://cc-embed" {
t.Fatalf("EmbeddingEndpoint = %q", cfg.EmbeddingEndpoint)
}
if cfg.LLMEndpoint != "http://cc-llm" {
t.Fatalf("LLMEndpoint = %q", cfg.LLMEndpoint)
}
if cfg.CursorModel != "cc-cursor" {
t.Fatalf("CursorModel = %q", cfg.CursorModel)
}
}
func TestLoadPickFallsBackToBorrowedEnvFile(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
writeEnv(t, envPath, `
# comment
EMBEDDING_ENDPOINT_URL="http://file-embed"
MINIMAX_ENDPOINT_URL='http://file-llm'
CURSOR_MODEL=file-cursor
`)
t.Setenv("CC_REPO_ROOT", dir)
t.Setenv("CC_ENV_FILE", envPath)
t.Setenv("CC_EMBEDDING_ENDPOINT_URL", "")
t.Setenv("CC_LLM_ENDPOINT_URL", "")
t.Setenv("CC_CURSOR_MODEL", "")
t.Setenv("EMBEDDING_ENDPOINT_URL", "")
t.Setenv("MINIMAX_ENDPOINT_URL", "")
t.Setenv("CURSOR_MODEL", "")
t.Setenv("EMBEDDING_MODEL", "")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.EmbeddingEndpoint != "http://file-embed" {
t.Fatalf("EmbeddingEndpoint = %q", cfg.EmbeddingEndpoint)
}
if cfg.LLMEndpoint != "http://file-llm" {
t.Fatalf("LLMEndpoint = %q", cfg.LLMEndpoint)
}
if cfg.CursorModel != "file-cursor" {
t.Fatalf("CursorModel = %q", cfg.CursorModel)
}
if cfg.EmbeddingModel != "bge-m3" {
t.Fatalf("EmbeddingModel default = %q", cfg.EmbeddingModel)
}
}
func TestLoadPickDefaultsWhenUnset(t *testing.T) {
dir := t.TempDir()
t.Setenv("CC_REPO_ROOT", dir)
t.Setenv("CC_ENV_FILE", filepath.Join(dir, "missing.env"))
for _, k := range []string{
"CC_EMBEDDING_ENDPOINT_URL", "EMBEDDING_ENDPOINT_URL",
"CC_LLM_ENDPOINT_URL", "MINIMAX_ENDPOINT_URL",
"CC_CURSOR_MODEL", "CURSOR_MODEL",
"CC_EMBEDDING_MODEL", "EMBEDDING_MODEL",
"CC_HTTP_ADDR", "CC_DB_DSN",
} {
t.Setenv(k, "")
}
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.EmbeddingEndpoint != "" || cfg.LLMEndpoint != "" || cfg.CursorModel != "" {
t.Fatalf("expected empty pick chain, got embed=%q llm=%q cursor=%q",
cfg.EmbeddingEndpoint, cfg.LLMEndpoint, cfg.CursorModel)
}
if cfg.EmbeddingModel != "bge-m3" {
t.Fatalf("EmbeddingModel = %q", cfg.EmbeddingModel)
}
if cfg.HTTPAddr != "127.0.0.1:8790" {
t.Fatalf("HTTPAddr = %q", cfg.HTTPAddr)
}
if _, err := os.Stat(cfg.MigrationsDir); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
}
func writeEnv(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
-136
View File
@@ -1,136 +0,0 @@
package docs
import (
"strings"
)
// maxChunkTokens bounds one chunk; oversized sections are bisected by
// paragraph until they fit (or cannot be split further).
const maxChunkTokens = 1800
// Chunk is one embeddable piece of a document.
type Chunk struct {
Seq int
Heading string
Content string
}
// chunkMarkdown splits md at ATX heading lines; content before the first
// heading becomes a chunk with an empty heading. Oversized sections are then
// bisected by paragraph. Heading text is kept inside the content so the
// embedding carries it.
func chunkMarkdown(md string) []Chunk {
var chunks []Chunk
for _, sec := range splitSections(md) {
chunks = append(chunks, bisect(sec.heading, sec.content)...)
}
for i := range chunks {
chunks[i].Seq = i
}
return chunks
}
type section struct {
heading string
content string
}
func splitSections(md string) []section {
var secs []section
var cur section
var lines []string
flush := func() {
body := strings.TrimSpace(strings.Join(lines, "\n"))
if cur.heading == "" && body == "" {
lines = nil
return
}
cur.content = strings.TrimSpace(cur.heading + "\n" + body)
if cur.content != "" {
secs = append(secs, cur)
}
lines = nil
}
for _, line := range strings.Split(md, "\n") {
if h, ok := atxHeading(line); ok {
flush()
cur = section{heading: h}
continue
}
lines = append(lines, line)
}
flush()
return secs
}
// atxHeading returns the heading line (with # marks) for ATX headings.
func atxHeading(line string) (string, bool) {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, "#") {
return "", false
}
i := 0
for i < len(t) && t[i] == '#' {
i++
}
if i > 6 || i >= len(t) || t[i] != ' ' {
return "", false
}
return t, true
}
// bisect splits an oversized section by paragraphs, halving recursively; a
// single oversized paragraph is cut by rune count.
func bisect(heading, content string) []Chunk {
if estimateTokens(content) <= maxChunkTokens {
return []Chunk{{Heading: heading, Content: content}}
}
paras := strings.Split(content, "\n\n")
if len(paras) > 1 {
mid := len(paras) / 2
left := bisect(heading, strings.TrimSpace(strings.Join(paras[:mid], "\n\n")))
right := bisect(heading, strings.TrimSpace(strings.Join(paras[mid:], "\n\n")))
return append(left, right...)
}
// single huge paragraph: hard cut on a rune boundary
runes := []rune(content)
half := len(runes) / 2
for half > 0 && half < len(runes)-1 && runes[half] != ' ' && runes[half] != '\n' && runes[half] != '。' {
half++
}
if half <= 0 || half >= len(runes) {
half = len(runes) / 2
}
left := bisect(heading, strings.TrimSpace(string(runes[:half])))
right := bisect(heading, strings.TrimSpace(string(runes[half:])))
return append(left, right...)
}
// estimateTokens approximates token count: CJK runes cost 2, ASCII runs cost
// ceil(len/4). Same heuristic as internal/kb.
func estimateTokens(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 1
}
tokens, asciiRun := 0, 0
flush := func() {
if asciiRun > 0 {
tokens += (asciiRun + 3) / 4
asciiRun = 0
}
}
for _, r := range s {
if r <= 127 {
asciiRun++
continue
}
flush()
tokens += 2
}
flush()
if tokens < 1 {
return 1
}
return tokens
}
-112
View File
@@ -1,112 +0,0 @@
package docs
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChunkMarkdownHeadings(t *testing.T) {
md := "# Doc Title\n\nintro text\n\n## Section A\n\nalpha body\n\n### Sub A1\n\nsub body\n\n## Section B\n\nbeta body\n"
chunks := chunkMarkdown(md)
require.Len(t, chunks, 4)
assert.Equal(t, "# Doc Title", chunks[0].Heading)
assert.Contains(t, chunks[0].Content, "intro text")
assert.Equal(t, "## Section A", chunks[1].Heading)
assert.Contains(t, chunks[1].Content, "alpha body")
assert.NotContains(t, chunks[1].Content, "sub body")
assert.Equal(t, "### Sub A1", chunks[2].Heading)
assert.Equal(t, "## Section B", chunks[3].Heading)
for i, c := range chunks {
assert.Equal(t, i, c.Seq)
}
}
func TestChunkMarkdownPreambleOnly(t *testing.T) {
chunks := chunkMarkdown("just some text\nwithout headings\n")
require.Len(t, chunks, 1)
assert.Equal(t, "", chunks[0].Heading)
assert.Contains(t, chunks[0].Content, "just some text")
}
func TestChunkMarkdownEmpty(t *testing.T) {
assert.Empty(t, chunkMarkdown(""))
assert.Empty(t, chunkMarkdown("\n\n \n"))
}
func TestChunkMarkdownLongSectionBisected(t *testing.T) {
// Build one section with many paragraphs, well over maxChunkTokens.
var sb strings.Builder
sb.WriteString("## Big\n\n")
for i := 0; i < 60; i++ {
fmt.Fprintf(&sb, "paragraph %d %s\n\n", i, strings.Repeat("word ", 100))
}
chunks := chunkMarkdown(sb.String())
require.Greater(t, len(chunks), 1, "oversized section must be split")
for _, c := range chunks {
assert.Equal(t, "## Big", c.Heading)
assert.LessOrEqual(t, estimateTokens(c.Content), maxChunkTokens)
}
// all paragraphs survive the split
joined := strings.Join(func() []string {
var out []string
for _, c := range chunks {
out = append(out, c.Content)
}
return out
}(), "\n")
for i := 0; i < 60; i++ {
assert.Contains(t, joined, fmt.Sprintf("paragraph %d", i))
}
}
func TestChunkMarkdownSingleHugeParagraph(t *testing.T) {
md := "## Blob\n\n" + strings.Repeat("汉字", 3000) + "\n"
chunks := chunkMarkdown(md)
require.Greater(t, len(chunks), 1)
for _, c := range chunks {
assert.LessOrEqual(t, estimateTokens(c.Content), maxChunkTokens)
}
}
func TestChunkHashStable(t *testing.T) {
h1 := chunkHash("doc", ".doc/a.md", 0, "content")
h2 := chunkHash("doc", ".doc/a.md", 0, "content")
assert.Equal(t, h1, h2)
assert.NotEqual(t, h1, chunkHash("doc", ".doc/a.md", 1, "content"), "seq matters")
assert.NotEqual(t, h1, chunkHash("artifact", ".doc/a.md", 0, "content"), "owner kind matters")
}
func TestSplitFrontMatter(t *testing.T) {
fm, body := splitFrontMatter("---\ntitle: My Doc\nstatus: done\n---\n# Heading\nbody\n")
assert.Equal(t, "My Doc", fm["title"])
assert.Equal(t, "done", fm["status"])
assert.True(t, strings.HasPrefix(body, "# Heading"))
fm, body = splitFrontMatter("# No front matter\n")
assert.Empty(t, fm)
assert.Contains(t, body, "# No front matter")
}
func TestDocTitleFallback(t *testing.T) {
assert.Equal(t, "FM Title", docTitle(map[string]any{"title": "FM Title"}, "# H1\n", "x.md"))
assert.Equal(t, "H1 Title", docTitle(map[string]any{}, "# H1 Title\nbody\n", "x.md"))
assert.Equal(t, "file-name", docTitle(map[string]any{}, "no heading\n", ".doc/ops/file-name.md"))
}
func TestTaxonomyOf(t *testing.T) {
assert.Equal(t, "ops", taxonomyOf(".doc/ops/CURRENT.md"))
assert.Equal(t, "ops", taxonomyOf(".doc/ops/short-term/x.md"))
assert.Equal(t, "", taxonomyOf(".doc/README.md"))
assert.Equal(t, "", taxonomyOf("other/x.md"))
}
func TestEstimateTokens(t *testing.T) {
assert.Equal(t, 1, estimateTokens(""))
assert.Equal(t, 2, estimateTokens("汉")) // CJK rune = 2
assert.Equal(t, 1, estimateTokens("abcd")) // 4 ascii / 4
assert.Equal(t, 2, estimateTokens("abcde")) // ceil(5/4)
}
-376
View File
@@ -1,376 +0,0 @@
// Package docs syncs .doc markdown files into cc_documents/cc_doc_chunks and
// embeds chunk content for pgvector retrieval. Embedding failures degrade
// gracefully: rows are stored with NULL embeddings and the sync succeeds.
package docs
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
"chat-agent-system/internal/controlcenter/embed"
"chat-agent-system/internal/controlcenter/store"
)
// Syncer ingests repository docs and stage artifacts into the vector store.
type Syncer struct {
st *store.Store
repoRoot string
emb embed.Embedder // nil = embeddings unavailable, chunks stored with NULL
}
func New(st *store.Store, repoRoot string, emb embed.Embedder) *Syncer {
return &Syncer{st: st, repoRoot: repoRoot, emb: emb}
}
// Stats summarizes one Sync run.
type Stats struct {
Added int `json:"added"`
Updated int `json:"updated"`
Removed int `json:"removed"`
Unchanged int `json:"unchanged"`
Chunks int `json:"chunks"`
EmbedFailed bool `json:"embed_failed"`
}
// Sync walks <repoRoot>/.doc for *.md files (skipping assets/ directories),
// upserts changed documents, re-chunks them idempotently, and removes rows
// for files that disappeared.
func (s *Syncer) Sync(ctx context.Context) (Stats, error) {
var stats Stats
docRoot := filepath.Join(s.repoRoot, ".doc")
type diskDoc struct {
rel string // repo-relative slash path, e.g. .doc/ops/CURRENT.md
sha string
full string
}
seen := map[string]diskDoc{}
err := filepath.WalkDir(docRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "assets" && path != docRoot {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(d.Name(), ".md") {
return nil
}
rel, err := filepath.Rel(s.repoRoot, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", rel, err)
}
seen[rel] = diskDoc{rel: rel, sha: sha256hex(raw), full: path}
return nil
})
if err != nil {
if os.IsNotExist(err) {
slog.Warn("doc sync: .doc root missing", "root", docRoot)
return stats, nil
}
return stats, err
}
// Existing rows, to diff against.
existing := map[string]struct {
id string
sha string
}{}
rows, err := s.st.Pool.Query(ctx, `SELECT id, path, sha256 FROM cc_documents`)
if err != nil {
return stats, fmt.Errorf("list cc_documents: %w", err)
}
for rows.Next() {
var id, path, sha string
if err := rows.Scan(&id, &path, &sha); err != nil {
rows.Close()
return stats, err
}
existing[path] = struct{ id, sha string }{id, sha}
}
rows.Close()
// Remove documents whose files disappeared (chunks cascade).
for path, ex := range existing {
if _, ok := seen[path]; ok {
continue
}
if _, err := s.st.Pool.Exec(ctx, `DELETE FROM cc_documents WHERE id = $1`, ex.id); err != nil {
return stats, fmt.Errorf("delete vanished doc %s: %w", path, err)
}
stats.Removed++
}
for _, d := range seen {
if ex, ok := existing[d.rel]; ok && ex.sha == d.sha {
stats.Unchanged++
continue
}
raw, err := os.ReadFile(d.full)
if err != nil {
return stats, fmt.Errorf("read %s: %w", d.rel, err)
}
failed, n, err := s.syncOne(ctx, d.rel, d.sha, raw)
if err != nil {
return stats, err
}
stats.EmbedFailed = stats.EmbedFailed || failed
stats.Chunks += n
if _, ok := existing[d.rel]; ok {
stats.Updated++
} else {
stats.Added++
}
}
return stats, nil
}
// syncOne upserts one document row and re-chunks it. Returns
// (embedFailed, newChunkCount, err).
func (s *Syncer) syncOne(ctx context.Context, rel, sha string, raw []byte) (bool, int, error) {
fm, body := splitFrontMatter(string(raw))
title := docTitle(fm, body, rel)
fmJSON, _ := json.Marshal(fm)
var docID string
err := s.st.Pool.QueryRow(ctx, `
INSERT INTO cc_documents (path, title, taxonomy, frontmatter, sha256, synced_at)
VALUES ($1,$2,$3,$4,$5,now())
ON CONFLICT (path) DO UPDATE SET
title = EXCLUDED.title, taxonomy = EXCLUDED.taxonomy,
frontmatter = EXCLUDED.frontmatter, sha256 = EXCLUDED.sha256,
synced_at = now()
RETURNING id`,
rel, title, taxonomyOf(rel), fmJSON, sha).Scan(&docID)
if err != nil {
return false, 0, fmt.Errorf("upsert doc %s: %w", rel, err)
}
failed, n, err := s.rechunk(ctx, rel, body, "doc", docID, "")
return failed, n, err
}
// IndexArtifact chunks and embeds one cc_stage_artifacts row
// (doc_id NULL, chunk_hash prefixed "artifact:").
func (s *Syncer) IndexArtifact(ctx context.Context, artifactID string) error {
var content string
err := s.st.Pool.QueryRow(ctx,
`SELECT content_md FROM cc_stage_artifacts WHERE id = $1`, artifactID).Scan(&content)
if err != nil {
return fmt.Errorf("load artifact %s: %w", artifactID, err)
}
failed, _, err := s.rechunk(ctx, artifactID, content, "artifact", "", artifactID)
if failed {
slog.Warn("artifact indexed without embeddings", "artifact", artifactID)
}
return err
}
// rechunk diffs chunk_hash sets for an owner (doc or artifact) and inserts
// only new chunks; unchanged chunks keep their embeddings.
// ownerKind is "doc" or "artifact"; exactly one of docID/artifactID is set.
func (s *Syncer) rechunk(ctx context.Context, sourceRef, content, ownerKind, docID, artifactID string) (bool, int, error) {
chunks := chunkMarkdown(content)
existing := map[string]bool{}
q := `SELECT chunk_hash FROM cc_doc_chunks WHERE doc_id = $1`
arg := any(docID)
if ownerKind == "artifact" {
q = `SELECT chunk_hash FROM cc_doc_chunks WHERE artifact_id = $1`
arg = artifactID
}
rows, err := s.st.Pool.Query(ctx, q, arg)
if err != nil {
return false, 0, fmt.Errorf("list chunks: %w", err)
}
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
rows.Close()
return false, 0, err
}
existing[h] = true
}
rows.Close()
want := make(map[string]Chunk, len(chunks))
var fresh []Chunk
for _, c := range chunks {
h := chunkHash(ownerKind, sourceRef, c.Seq, c.Content)
want[h] = c
if !existing[h] {
fresh = append(fresh, c)
}
}
// Drop chunks that are no longer part of the source.
for h := range existing {
if _, ok := want[h]; !ok {
if _, err := s.st.Pool.Exec(ctx, `DELETE FROM cc_doc_chunks WHERE chunk_hash = $1`, h); err != nil {
return false, 0, fmt.Errorf("delete stale chunk: %w", err)
}
}
}
if len(fresh) == 0 {
return false, 0, nil
}
texts := make([]string, len(fresh))
for i, c := range fresh {
texts[i] = c.Content
}
var vecs [][]float32
embedFailed := false
if s.emb != nil {
vecs, err = s.emb.Embed(ctx, texts)
if err != nil {
slog.Warn("embedding unavailable; chunks stored without vectors",
"owner", ownerKind, "ref", sourceRef, "chunks", len(fresh), "err", err)
embedFailed = true
vecs = nil
}
} else {
embedFailed = true
}
inserted := 0
for i, c := range fresh {
h := chunkHash(ownerKind, sourceRef, c.Seq, c.Content)
var vecLit any
if !embedFailed && i < len(vecs) {
vecLit = embed.VectorLiteral(vecs[i])
}
var dID, aID any
if ownerKind == "doc" {
dID = docID
} else {
aID = artifactID
}
_, err := s.st.Pool.Exec(ctx, `
INSERT INTO cc_doc_chunks (doc_id, artifact_id, seq, heading, content, chunk_hash, embedding)
VALUES ($1,$2,$3,$4,$5,$6, $7::vector)
ON CONFLICT (chunk_hash) DO NOTHING`,
dID, aID, c.Seq, c.Heading, c.Content, h, vecLit)
if err != nil {
return embedFailed, inserted, fmt.Errorf("insert chunk %d: %w", c.Seq, err)
}
inserted++
}
return embedFailed, inserted, nil
}
// chunkHash is stable per (owner, source, seq, content); "artifact:" prefix
// keeps artifact chunks distinct from document chunks.
func chunkHash(ownerKind, sourceRef string, seq int, content string) string {
prefix := ""
if ownerKind == "artifact" {
prefix = "artifact:"
}
return sha256hex([]byte(fmt.Sprintf("%s%s:%d:%s", prefix, sourceRef, seq, content)))
}
// StartPeriodic runs Sync immediately, then every interval until ctx is
// cancelled (same ticker pattern as the lease reaper).
func (s *Syncer) StartPeriodic(ctx context.Context, interval time.Duration) {
go func() {
s.runOnce(ctx)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.runOnce(ctx)
}
}
}()
}
func (s *Syncer) runOnce(ctx context.Context) {
stats, err := s.Sync(ctx)
if err != nil {
slog.Error("doc sync", "err", err)
return
}
slog.Info("doc sync done",
"added", stats.Added, "updated", stats.Updated, "removed", stats.Removed,
"unchanged", stats.Unchanged, "chunks", stats.Chunks, "embed_failed", stats.EmbedFailed)
}
// splitFrontMatter extracts a leading --- yaml --- block; returns the parsed
// map (possibly empty) and the remaining body.
func splitFrontMatter(md string) (map[string]any, string) {
fm := map[string]any{}
body := md
if rest, ok := strings.CutPrefix(md, "---\n"); ok {
if end := strings.Index(rest, "\n---"); end >= 0 {
raw := rest[:end]
after := rest[end+len("\n---"):]
if after == "" || after[0] == '\n' || after[0] == '\r' {
if err := yaml.Unmarshal([]byte(raw), &fm); err != nil {
slog.Warn("doc sync: bad front matter", "err", err)
fm = map[string]any{}
} else {
body = strings.TrimLeft(after, "\r\n")
}
}
}
}
if fm == nil {
fm = map[string]any{}
}
return fm, body
}
// docTitle prefers front matter title, then the first level-1 heading, then
// the file name.
func docTitle(fm map[string]any, body, rel string) string {
if t, ok := fm["title"].(string); ok && strings.TrimSpace(t) != "" {
return strings.TrimSpace(t)
}
for _, line := range strings.Split(body, "\n") {
if h, ok := strings.CutPrefix(strings.TrimSpace(line), "# "); ok {
if t := strings.TrimSpace(h); t != "" {
return t
}
}
}
base := filepath.Base(rel)
return strings.TrimSuffix(base, filepath.Ext(base))
}
// taxonomyOf returns the first directory segment under .doc/ ("" for files
// directly at the root).
func taxonomyOf(rel string) string {
rest, ok := strings.CutPrefix(rel, ".doc/")
if !ok {
return ""
}
if i := strings.IndexByte(rest, '/'); i > 0 {
return rest[:i]
}
return ""
}
func sha256hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
-195
View File
@@ -1,195 +0,0 @@
// Package embed provides an OpenAI-compatible embeddings client for the
// Control Center document vector store, plus a deterministic stub for tests.
// Self-contained: modeled on internal/kb/embed.go but does not import it.
package embed
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
// Dim is the bge-m3 embedding dimension used by cc_doc_chunks.embedding.
Dim = 1024
maxBatchItems = 16
httpTimeout = 30 * time.Second
)
// Embedder generates embedding vectors for text batches.
type Embedder interface {
Embed(ctx context.Context, texts []string) ([][]float32, error)
Dim() int
}
// HTTPClient talks to an OpenAI-compatible /embeddings endpoint.
type HTTPClient struct {
endpoint string
apiKey string
model string
dim int
http *http.Client
}
// NewHTTP builds a client; endpoint may already include /v1 (or the full
// /embeddings suffix). An empty endpoint yields a client whose Embed always
// fails, so callers can treat it as "embedding unavailable".
func NewHTTP(endpoint, apiKey, model string, dim int) *HTTPClient {
if dim <= 0 {
dim = Dim
}
return &HTTPClient{
endpoint: normalizeEndpoint(endpoint),
apiKey: apiKey,
model: model,
dim: dim,
http: &http.Client{Timeout: httpTimeout},
}
}
func (c *HTTPClient) Dim() int { return c.dim }
// Embed embeds texts in batches of at most maxBatchItems.
func (c *HTTPClient) Embed(ctx context.Context, texts []string) ([][]float32, error) {
if c.endpoint == "" {
return nil, fmt.Errorf("embed: endpoint not configured")
}
if len(texts) == 0 {
return nil, nil
}
out := make([][]float32, len(texts))
for start := 0; start < len(texts); start += maxBatchItems {
end := start + maxBatchItems
if end > len(texts) {
end = len(texts)
}
vecs, err := c.embedBatch(ctx, texts[start:end])
if err != nil {
return nil, err
}
copy(out[start:end], vecs)
}
return out, nil
}
// embedBatch posts one batch; 5xx and network errors are retried once, 4xx
// is returned immediately.
func (c *HTTPClient) embedBatch(ctx context.Context, texts []string) ([][]float32, error) {
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
vecs, retryable, err := c.post(ctx, texts)
if err == nil {
return vecs, nil
}
if !retryable {
return nil, err
}
lastErr = err
}
return nil, lastErr
}
func (c *HTTPClient) post(ctx context.Context, texts []string) ([][]float32, bool, error) {
body, _ := json.Marshal(map[string]any{"model": c.model, "input": texts})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return nil, false, err
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, true, fmt.Errorf("embed request: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode >= 300 {
retryable := resp.StatusCode >= 500
return nil, retryable, fmt.Errorf("embed http %d: %s", resp.StatusCode, truncateUTF8(strings.TrimSpace(string(raw)), 300))
}
var parsed struct {
Data []struct {
Embedding []float32 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, false, fmt.Errorf("embed decode: %w", err)
}
out := make([][]float32, len(texts))
for _, d := range parsed.Data {
if d.Index >= 0 && d.Index < len(out) {
out[d.Index] = padOrTrim(d.Embedding, c.dim)
}
}
for i := range out {
if out[i] == nil && i < len(parsed.Data) {
out[i] = padOrTrim(parsed.Data[i].Embedding, c.dim)
}
if out[i] == nil {
return nil, false, fmt.Errorf("embed: missing embedding %d", i)
}
}
return out, false, nil
}
func normalizeEndpoint(endpoint string) string {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
if endpoint == "" {
return ""
}
if strings.HasSuffix(endpoint, "/embeddings") {
return endpoint
}
if strings.HasSuffix(endpoint, "/v1") || strings.Contains(endpoint, "/v1/") {
return endpoint + "/embeddings"
}
return endpoint + "/v1/embeddings"
}
func padOrTrim(v []float32, n int) []float32 {
if len(v) == n {
return v
}
out := make([]float32, n)
copy(out, v)
return out
}
func truncateUTF8(s string, n int) string {
if n <= 0 || len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
// VectorLiteral serializes a vector into the pgvector text format
// "[0.1,0.2,...]"; bind it as a string and cast with ::vector.
func VectorLiteral(vec []float32) string {
if len(vec) == 0 {
return ""
}
var sb strings.Builder
sb.WriteByte('[')
for i, f := range vec {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteString(strconv.FormatFloat(float64(f), 'g', -1, 32))
}
sb.WriteByte(']')
return sb.String()
}
-119
View File
@@ -1,119 +0,0 @@
package embed
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeEndpoint(t *testing.T) {
cases := map[string]string{
"http://x:8080": "http://x:8080/v1/embeddings",
"http://x:8080/": "http://x:8080/v1/embeddings",
"http://x:8080/v1": "http://x:8080/v1/embeddings",
"http://x:8080/v1/embeddings": "http://x:8080/v1/embeddings",
"": "",
}
for in, want := range cases {
assert.Equal(t, want, normalizeEndpoint(in), in)
}
}
func fakeServer(t *testing.T, dim int, status int, calls *atomic.Int32) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls != nil {
calls.Add(1)
}
assert.Equal(t, "/v1/embeddings", r.URL.Path)
assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
if status != 200 {
w.WriteHeader(status)
return
}
var req struct {
Model string `json:"model"`
Input []string `json:"input"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, "bge-m3", req.Model)
type item struct {
Embedding []float32 `json:"embedding"`
Index int `json:"index"`
}
data := make([]item, len(req.Input))
for i := range req.Input {
data[i] = item{Embedding: make([]float32, dim), Index: i}
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": data})
}))
}
func TestEmbedBatches(t *testing.T) {
var calls atomic.Int32
srv := fakeServer(t, 4, 200, &calls)
defer srv.Close()
c := NewHTTP(srv.URL, "test-key", "bge-m3", 4)
texts := make([]string, maxBatchItems+3)
vecs, err := c.Embed(context.Background(), texts)
require.NoError(t, err)
assert.Len(t, vecs, len(texts))
for _, v := range vecs {
assert.Len(t, v, 4)
}
assert.Equal(t, int32(2), calls.Load(), "17 texts must be split into 16+1")
}
func TestEmbedRetriesOn5xxOnce(t *testing.T) {
var calls atomic.Int32
srv := fakeServer(t, 4, 500, &calls)
defer srv.Close()
c := NewHTTP(srv.URL, "test-key", "bge-m3", 4)
_, err := c.Embed(context.Background(), []string{"x"})
require.Error(t, err)
assert.Equal(t, int32(2), calls.Load(), "5xx retried exactly once")
}
func TestEmbedNoRetryOn4xx(t *testing.T) {
var calls atomic.Int32
srv := fakeServer(t, 4, 400, &calls)
defer srv.Close()
c := NewHTTP(srv.URL, "test-key", "bge-m3", 4)
_, err := c.Embed(context.Background(), []string{"x"})
require.Error(t, err)
assert.Equal(t, int32(1), calls.Load(), "4xx must not be retried")
}
func TestEmbedEmptyEndpoint(t *testing.T) {
c := NewHTTP("", "", "bge-m3", 4)
_, err := c.Embed(context.Background(), []string{"x"})
require.Error(t, err)
}
func TestStubDeterministic(t *testing.T) {
s := NewStub(8)
a, err := s.Embed(context.Background(), []string{"hello", "world"})
require.NoError(t, err)
b, err := s.Embed(context.Background(), []string{"hello"})
require.NoError(t, err)
assert.Equal(t, a[0], b[0], "same text, same vector")
assert.NotEqual(t, a[0], a[1], "different texts differ")
assert.Equal(t, 8, s.Dim())
}
func TestVectorLiteral(t *testing.T) {
assert.Equal(t, "", VectorLiteral(nil))
lit := VectorLiteral([]float32{0.5, -1, 1.25})
assert.True(t, strings.HasPrefix(lit, "[") && strings.HasSuffix(lit, "]"))
assert.Equal(t, "[0.5,-1,1.25]", lit)
}
-58
View File
@@ -1,58 +0,0 @@
package embed
import (
"context"
"crypto/sha256"
"encoding/binary"
"math"
)
// Stub is a deterministic offline Embedder: each text maps to a fixed
// pseudo-random unit-ish vector derived from its sha256, so tests need no
// network and results are stable across runs.
type Stub struct {
dim int
}
func NewStub(dim int) *Stub {
if dim <= 0 {
dim = Dim
}
return &Stub{dim: dim}
}
func (s *Stub) Dim() int { return s.dim }
func (s *Stub) Embed(_ context.Context, texts []string) ([][]float32, error) {
out := make([][]float32, len(texts))
for i, t := range texts {
out[i] = s.vector(t)
}
return out, nil
}
func (s *Stub) vector(text string) []float32 {
seed := sha256.Sum256([]byte(text))
state := binary.LittleEndian.Uint64(seed[:8]) | 1
vec := make([]float32, s.dim)
for i := range vec {
// xorshift64*
state ^= state >> 12
state ^= state << 25
state ^= state >> 27
v := (state * 2685821657736338717) >> 11
vec[i] = float32(float64(v)/float64(uint64(1)<<53))*2 - 1
}
// normalize so cosine distances behave sanely
var norm float64
for _, f := range vec {
norm += float64(f * f)
}
norm = math.Sqrt(norm)
if norm > 0 {
for i := range vec {
vec[i] /= float32(norm)
}
}
return vec
}
-356
View File
@@ -1,356 +0,0 @@
// Package importer ingests existing .doc task documents (ops/short-term,
// ops/long-term, backlog) into cc_tasks, keyed by slug (file name without
// .md). Re-imports are idempotent: unchanged documents are skipped, and
// fields a human edited in the UI (priority, stage, assignment, terminal
// status) are never overwritten.
package importer
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5"
"gopkg.in/yaml.v3"
"chat-agent-system/internal/controlcenter/pipeline"
"chat-agent-system/internal/controlcenter/store"
)
// planDirs maps a .doc task-doc directory to its plan slug and horizon.
var planDirs = []struct {
dir string // under .doc/
slug string
title string
horizon string
}{
{"ops/short-term", "short-term", "短期任务", "short"},
{"ops/long-term", "long-term", "长期任务", "long"},
{"backlog", "backlog", "Backlog", "backlog"},
}
type Importer struct {
st *store.Store
repoRoot string
}
func New(st *store.Store, repoRoot string) *Importer {
return &Importer{st: st, repoRoot: repoRoot}
}
// Stats summarizes one Import run.
type Stats struct {
Added int `json:"added"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
// docTask is one parsed task document.
type docTask struct {
slug string
title string
status string // cc_tasks status
stage string
stageState string
owner string
branch string
worktree string
mutex string
raw string // full file content, stored as instruction_md
docPath string // repo-relative slash path
planSlug string
}
// Import scans the task-doc directories and upserts one cc_tasks row per
// document. See package doc for the overwrite rules.
func (im *Importer) Import(ctx context.Context) (Stats, error) {
var stats Stats
planIDs := map[string]string{}
for _, pd := range planDirs {
id, err := im.ensurePlan(ctx, pd.slug, pd.title, pd.horizon)
if err != nil {
return stats, fmt.Errorf("ensure plan %s: %w", pd.slug, err)
}
planIDs[pd.slug] = id
}
// The same file name may live in more than one task directory (e.g. a
// doc copied from short-term to long-term). Directory order in planDirs
// decides the winner; later duplicates are skipped so re-runs stay
// idempotent instead of ping-ponging the row.
seen := map[string]string{}
for _, pd := range planDirs {
root := filepath.Join(im.repoRoot, ".doc", filepath.FromSlash(pd.dir))
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
return nil
}
dt, err := im.parseDoc(path, pd.slug)
if err != nil {
slog.Warn("doc task import: skip unparseable doc", "path", path, "err", err)
stats.Skipped++
return nil
}
if prev, dup := seen[dt.slug]; dup {
slog.Warn("doc task import: duplicate slug, keeping first",
"slug", dt.slug, "kept", prev, "skipped", dt.docPath)
stats.Skipped++
return nil
}
seen[dt.slug] = dt.docPath
res, err := im.upsert(ctx, dt, planIDs[pd.slug])
if err != nil {
return fmt.Errorf("upsert %s: %w", dt.docPath, err)
}
switch res {
case "added":
stats.Added++
case "updated":
stats.Updated++
default:
stats.Skipped++
}
return nil
})
if err != nil {
if os.IsNotExist(err) {
continue
}
return stats, err
}
}
return stats, nil
}
// StartPeriodic runs Import immediately, then every interval until ctx is
// cancelled (same ticker pattern as docs.Syncer.StartPeriodic).
func (im *Importer) StartPeriodic(ctx context.Context, interval time.Duration) {
go func() {
im.runOnce(ctx)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
im.runOnce(ctx)
}
}
}()
}
func (im *Importer) runOnce(ctx context.Context) {
stats, err := im.Import(ctx)
if err != nil {
slog.Error("doc task import", "err", err)
return
}
slog.Info("doc task import done",
"added", stats.Added, "updated", stats.Updated, "skipped", stats.Skipped)
}
func (im *Importer) ensurePlan(ctx context.Context, slug, title, horizon string) (string, error) {
var id string
err := im.st.Pool.QueryRow(ctx,
`INSERT INTO cc_plans (slug, title, horizon) VALUES ($1,$2,$3)
ON CONFLICT (slug) DO NOTHING RETURNING id`, slug, title, horizon).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
err = im.st.Pool.QueryRow(ctx, `SELECT id FROM cc_plans WHERE slug = $1`, slug).Scan(&id)
}
return id, err
}
// parseDoc reads one markdown file into a docTask.
func (im *Importer) parseDoc(path, planSlug string) (*docTask, error) {
rawBytes, err := os.ReadFile(path)
if err != nil {
return nil, err
}
raw := string(rawBytes)
fm, body := splitFrontMatter(raw)
rel, err := filepath.Rel(im.repoRoot, path)
if err != nil {
return nil, err
}
base := filepath.Base(path)
slug := strings.TrimSuffix(base, ".md")
status := mapStatus(fmString(fm, "status"))
stage, stageState := inferStage(status)
return &docTask{
slug: slug,
title: docTitle(fm, body, slug),
status: status,
stage: stage,
stageState: stageState,
owner: fmString(fm, "owner"),
branch: fmString(fm, "branch"),
worktree: fmString(fm, "worktree"),
mutex: fmString(fm, "mutex"),
raw: raw,
docPath: filepath.ToSlash(rel),
planSlug: planSlug,
}, nil
}
// mapStatus maps a doc front-matter status onto the cc_tasks status space.
// Unknown or missing statuses fall back to planned.
func mapStatus(s string) string {
switch strings.TrimSpace(s) {
case pipeline.StatusPlanned:
return pipeline.StatusPlanned
case pipeline.StatusInProgress:
return pipeline.StatusInProgress
case "blocked":
return pipeline.StatusBlockedDecision
case pipeline.StatusDone:
return pipeline.StatusDone
case pipeline.StatusCancelled:
return pipeline.StatusCancelled
default:
return pipeline.StatusPlanned
}
}
// inferStage derives the initial stage/stage_state for a newly imported
// task. Stage is only set on insert; later syncs never move a task's stage.
func inferStage(status string) (stage, stageState string) {
switch status {
case pipeline.StatusDone:
return pipeline.StageConfirmation, pipeline.StateApproved
case pipeline.StatusInProgress:
return pipeline.StageImplementation, pipeline.StateInProgress
case pipeline.StatusBlockedDecision:
return pipeline.StageImplementation, pipeline.StateChangesRequested
default: // planned, cancelled
return pipeline.StageProposal, pipeline.StateIdle
}
}
// upsert inserts the task or updates the doc-owned fields of an existing
// one. Returns "added", "updated" or "skipped".
//
// Overwrite rules for existing tasks:
// - title/owner/branch/worktree/mutex/instruction_md/doc_path follow the doc
// - plan_id is backfilled only when the task has none
// - status follows the doc only while the task is planned or in_progress;
// done/cancelled/blocked_pending_decision are never moved by the importer
// - stage/stage_state/priority/assignment are never touched after insert
func (im *Importer) upsert(ctx context.Context, dt *docTask, planID string) (string, error) {
var cur struct {
id string
title string
owner string
branch string
worktree string
mutex string
instr string
docPath string
status string
planID *string
}
err := im.st.Pool.QueryRow(ctx,
`SELECT id, title, owner, branch, worktree, mutex, instruction_md, doc_path, status, plan_id
FROM cc_tasks WHERE slug = $1`, dt.slug).
Scan(&cur.id, &cur.title, &cur.owner, &cur.branch, &cur.worktree, &cur.mutex,
&cur.instr, &cur.docPath, &cur.status, &cur.planID)
if errors.Is(err, pgx.ErrNoRows) {
_, err := im.st.Pool.Exec(ctx,
`INSERT INTO cc_tasks (slug, plan_id, title, status, stage, stage_state,
owner, branch, worktree, mutex, instruction_md, doc_path)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
dt.slug, planID, dt.title, dt.status, dt.stage, dt.stageState,
dt.owner, dt.branch, dt.worktree, dt.mutex, dt.raw, dt.docPath)
if err != nil {
return "", err
}
return "added", nil
}
if err != nil {
return "", err
}
followStatus := cur.status == pipeline.StatusPlanned || cur.status == pipeline.StatusInProgress
newStatus := cur.status
if followStatus {
newStatus = dt.status
}
changed := cur.title != dt.title || cur.owner != dt.owner || cur.branch != dt.branch ||
cur.worktree != dt.worktree || cur.mutex != dt.mutex || cur.instr != dt.raw ||
cur.docPath != dt.docPath || cur.status != newStatus || cur.planID == nil
if !changed {
return "skipped", nil
}
newPlanID := cur.planID
if newPlanID == nil {
newPlanID = &planID
}
_, err = im.st.Pool.Exec(ctx,
`UPDATE cc_tasks SET title=$2, owner=$3, branch=$4, worktree=$5, mutex=$6,
instruction_md=$7, doc_path=$8, status=$9, plan_id=$10, updated_at=now()
WHERE id = $1`,
cur.id, dt.title, dt.owner, dt.branch, dt.worktree, dt.mutex,
dt.raw, dt.docPath, newStatus, newPlanID)
if err != nil {
return "", err
}
return "updated", nil
}
// splitFrontMatter extracts a leading --- yaml --- block; returns the parsed
// map (possibly empty) and the remaining body.
func splitFrontMatter(md string) (map[string]any, string) {
fm := map[string]any{}
body := md
if rest, ok := strings.CutPrefix(md, "---\n"); ok {
if end := strings.Index(rest, "\n---"); end >= 0 {
rawFM := rest[:end]
after := rest[end+len("\n---"):]
if after == "" || after[0] == '\n' || after[0] == '\r' {
if err := yaml.Unmarshal([]byte(rawFM), &fm); err != nil {
fm = map[string]any{}
} else {
body = strings.TrimLeft(after, "\r\n")
}
}
}
}
if fm == nil {
fm = map[string]any{}
}
return fm, body
}
func fmString(fm map[string]any, key string) string {
if v, ok := fm[key].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
// docTitle prefers front matter title, then the first level-1 heading, then
// the slug.
func docTitle(fm map[string]any, body, slug string) string {
if t := fmString(fm, "title"); t != "" {
return t
}
for _, line := range strings.Split(body, "\n") {
if h, ok := strings.CutPrefix(strings.TrimSpace(line), "# "); ok {
if t := strings.TrimSpace(h); t != "" {
return t
}
}
}
return slug
}
@@ -1,180 +0,0 @@
package importer
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/pipeline"
"chat-agent-system/internal/controlcenter/store"
)
func TestMapStatus(t *testing.T) {
assert.Equal(t, pipeline.StatusPlanned, mapStatus("planned"))
assert.Equal(t, pipeline.StatusInProgress, mapStatus("in_progress"))
assert.Equal(t, pipeline.StatusBlockedDecision, mapStatus("blocked"))
assert.Equal(t, pipeline.StatusDone, mapStatus("done"))
assert.Equal(t, pipeline.StatusCancelled, mapStatus("cancelled"))
assert.Equal(t, pipeline.StatusPlanned, mapStatus(""))
assert.Equal(t, pipeline.StatusPlanned, mapStatus("weird"))
}
func TestInferStage(t *testing.T) {
st, ss := inferStage(pipeline.StatusDone)
assert.Equal(t, pipeline.StageConfirmation, st)
assert.Equal(t, pipeline.StateApproved, ss)
st, ss = inferStage(pipeline.StatusInProgress)
assert.Equal(t, pipeline.StageImplementation, st)
assert.Equal(t, pipeline.StateInProgress, ss)
st, ss = inferStage(pipeline.StatusBlockedDecision)
assert.Equal(t, pipeline.StageImplementation, st)
assert.Equal(t, pipeline.StateChangesRequested, ss)
for _, s := range []string{pipeline.StatusPlanned, pipeline.StatusCancelled} {
st, ss = inferStage(s)
assert.Equal(t, pipeline.StageProposal, st)
assert.Equal(t, pipeline.StateIdle, ss)
}
}
func writeDoc(t *testing.T, repoRoot, rel, content string) {
t.Helper()
full := filepath.Join(repoRoot, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
}
func TestParseDoc(t *testing.T) {
repoRoot := t.TempDir()
writeDoc(t, repoRoot, ".doc/ops/short-term/2026-09-17-demo-task.md", `---
title: "示例任务"
status: blocked
owner: kimi
branch: feature/demo
worktree: "-"
mutex: docgov
---
# 示例任务
## 目标
做点什么。
`)
im := New(nil, repoRoot)
dt, err := im.parseDoc(filepath.Join(repoRoot, ".doc", "ops", "short-term", "2026-09-17-demo-task.md"), "short-term")
require.NoError(t, err)
assert.Equal(t, "2026-09-17-demo-task", dt.slug)
assert.Equal(t, "示例任务", dt.title)
assert.Equal(t, pipeline.StatusBlockedDecision, dt.status)
assert.Equal(t, "kimi", dt.owner)
assert.Equal(t, "feature/demo", dt.branch)
assert.Equal(t, "-", dt.worktree)
assert.Equal(t, "docgov", dt.mutex)
assert.Equal(t, ".doc/ops/short-term/2026-09-17-demo-task.md", dt.docPath)
assert.Contains(t, dt.raw, "## 目标")
}
func TestParseDocTitleFallback(t *testing.T) {
repoRoot := t.TempDir()
writeDoc(t, repoRoot, ".doc/backlog/no-frontmatter.md", "# 标题来自正文\n\n内容。\n")
im := New(nil, repoRoot)
dt, err := im.parseDoc(filepath.Join(repoRoot, ".doc", "backlog", "no-frontmatter.md"), "backlog")
require.NoError(t, err)
assert.Equal(t, "标题来自正文", dt.title)
assert.Equal(t, pipeline.StatusPlanned, dt.status)
assert.Equal(t, pipeline.StageProposal, dt.stage)
writeDoc(t, repoRoot, ".doc/backlog/plain.md", "没有任何标题。\n")
dt, err = im.parseDoc(filepath.Join(repoRoot, ".doc", "backlog", "plain.md"), "backlog")
require.NoError(t, err)
assert.Equal(t, "plain", dt.title)
}
// --- DB integration (TEST_CC_DB_DSN gated) ---
func newTestStore(t *testing.T) *store.Store {
t.Helper()
dsn := os.Getenv("TEST_CC_DB_DSN")
if dsn == "" {
t.Skip("TEST_CC_DB_DSN not set")
}
_, thisFile, _, _ := runtime.Caller(0)
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
ctx := t.Context()
require.NoError(t, store.Migrate(ctx, dsn, migrationsDir))
st, err := store.Connect(ctx, dsn)
require.NoError(t, err)
t.Cleanup(st.Close)
return st
}
func TestImportIntegration(t *testing.T) {
st := newTestStore(t)
tag := fmt.Sprintf("imp%d", time.Now().UnixNano())
repoRoot := t.TempDir()
slugDone := tag + "-done"
slugPlanned := tag + "-planned"
writeDoc(t, repoRoot, ".doc/ops/short-term/"+slugDone+".md",
"---\ntitle: \"已完成任务 "+tag+"\"\nstatus: done\nowner: cursor\nbranch: fix/x\nworktree: \"-\"\nmutex: docgov\n---\n\n# 已完成任务\n\n正文。\n")
writeDoc(t, repoRoot, ".doc/ops/long-term/"+slugPlanned+".md",
"---\ntitle: \"计划中任务 "+tag+"\"\nstatus: planned\n---\n\n# 计划中任务\n\n正文。\n")
im := New(st, repoRoot)
stats, err := im.Import(t.Context())
require.NoError(t, err)
assert.Equal(t, 2, stats.Added)
assert.Equal(t, 0, stats.Updated)
var stage, stageState, status, planSlug, owner string
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT t.stage, t.stage_state, t.status, p.slug, t.owner
FROM cc_tasks t JOIN cc_plans p ON p.id = t.plan_id WHERE t.slug = $1`, slugDone).
Scan(&stage, &stageState, &status, &planSlug, &owner))
assert.Equal(t, pipeline.StageConfirmation, stage)
assert.Equal(t, pipeline.StateApproved, stageState)
assert.Equal(t, pipeline.StatusDone, status)
assert.Equal(t, "short-term", planSlug)
assert.Equal(t, "cursor", owner)
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT p.slug FROM cc_tasks t JOIN cc_plans p ON p.id = t.plan_id WHERE t.slug = $1`, slugPlanned).
Scan(&planSlug))
assert.Equal(t, "long-term", planSlug)
// Idempotent: second run skips everything.
stats, err = im.Import(t.Context())
require.NoError(t, err)
assert.Equal(t, 0, stats.Added+stats.Updated, "unchanged docs must be skipped")
assert.Equal(t, 2, stats.Skipped)
// Doc moves planned -> in_progress: status follows while task is planned.
writeDoc(t, repoRoot, ".doc/ops/long-term/"+slugPlanned+".md",
"---\ntitle: \"计划中任务 "+tag+"\"\nstatus: in_progress\n---\n\n# 计划中任务\n\n正文改。\n")
stats, err = im.Import(t.Context())
require.NoError(t, err)
assert.Equal(t, 1, stats.Updated)
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT status, stage FROM cc_tasks WHERE slug = $1`, slugPlanned).Scan(&status, &stage))
assert.Equal(t, pipeline.StatusInProgress, status)
assert.Equal(t, pipeline.StageProposal, stage, "stage must not move after insert")
// Terminal status never regresses: doc edited to in_progress, task stays done.
writeDoc(t, repoRoot, ".doc/ops/short-term/"+slugDone+".md",
"---\ntitle: \"已完成任务 "+tag+"\"\nstatus: in_progress\nowner: cursor\nbranch: fix/x\nworktree: \"-\"\nmutex: docgov\n---\n\n# 已完成任务\n\n正文改。\n")
stats, err = im.Import(t.Context())
require.NoError(t, err)
assert.Equal(t, 1, stats.Updated, "content changed but status must not regress")
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT status FROM cc_tasks WHERE slug = $1`, slugDone).Scan(&status))
assert.Equal(t, pipeline.StatusDone, status)
}
-141
View File
@@ -1,141 +0,0 @@
// Package llm is a thin OpenAI-compatible chat-completions client used by the
// Control Center audit reviewer. Self-contained: modeled on
// internal/controlcenter/embed but for /chat/completions. API keys are never
// logged.
package llm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"unicode/utf8"
)
const httpTimeout = 180 * time.Second
// ErrNotConfigured is returned when the endpoint or model is missing; callers
// should degrade gracefully (skip the LLM stage) rather than fail.
var ErrNotConfigured = errors.New("llm: endpoint or model not configured")
// ChatClient is the minimal surface the audit runner needs.
type ChatClient interface {
Chat(ctx context.Context, system, user string) (string, error)
}
// HTTPClient talks to an OpenAI-compatible /chat/completions endpoint.
type HTTPClient struct {
endpoint string
apiKey string
model string
http *http.Client
}
// NewHTTP builds a client; endpoint may already include /v1 (or the full
// /chat/completions suffix). An empty endpoint or model yields a client whose
// Chat always returns ErrNotConfigured.
func NewHTTP(endpoint, apiKey, model string) *HTTPClient {
return &HTTPClient{
endpoint: normalizeEndpoint(endpoint),
apiKey: apiKey,
model: model,
http: &http.Client{Timeout: httpTimeout},
}
}
// Configured reports whether Chat can be attempted.
func (c *HTTPClient) Configured() bool {
return c.endpoint != "" && c.model != ""
}
// Chat sends a system+user pair and returns the assistant message content.
// 5xx and network errors are retried once; 4xx is returned immediately.
func (c *HTTPClient) Chat(ctx context.Context, system, user string) (string, error) {
if !c.Configured() {
return "", ErrNotConfigured
}
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
content, retryable, err := c.post(ctx, system, user)
if err == nil {
return content, nil
}
if !retryable {
return "", err
}
lastErr = err
}
return "", lastErr
}
func (c *HTTPClient) post(ctx context.Context, system, user string) (string, bool, error) {
body, _ := json.Marshal(map[string]any{
"model": c.model,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"temperature": 0.2,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return "", false, err
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.http.Do(req)
if err != nil {
return "", true, fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode >= 300 {
retryable := resp.StatusCode >= 500
return "", retryable, fmt.Errorf("llm http %d: %s", resp.StatusCode, truncateUTF8(strings.TrimSpace(string(raw)), 300))
}
var parsed struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return "", false, fmt.Errorf("llm decode: %w", err)
}
if len(parsed.Choices) == 0 {
return "", false, fmt.Errorf("llm: empty choices")
}
return parsed.Choices[0].Message.Content, false, nil
}
func normalizeEndpoint(endpoint string) string {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
if endpoint == "" {
return ""
}
if strings.HasSuffix(endpoint, "/chat/completions") {
return endpoint
}
if strings.HasSuffix(endpoint, "/v1") || strings.Contains(endpoint, "/v1/") {
return endpoint + "/chat/completions"
}
return endpoint + "/v1/chat/completions"
}
func truncateUTF8(s string, n int) string {
if n <= 0 || len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
-99
View File
@@ -1,99 +0,0 @@
package llm
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEndpointNormalization(t *testing.T) {
cases := map[string]string{
"": "",
"http://x": "http://x/v1/chat/completions",
"http://x/": "http://x/v1/chat/completions",
"http://x/v1": "http://x/v1/chat/completions",
"http://x/v1/": "http://x/v1/chat/completions",
"http://x/v1/chat/completions": "http://x/v1/chat/completions",
"http://x/proxy/v1": "http://x/proxy/v1/chat/completions",
}
for in, want := range cases {
assert.Equal(t, want, normalizeEndpoint(in), "input %q", in)
}
}
func TestNotConfigured(t *testing.T) {
_, err := NewHTTP("", "k", "m").Chat(context.Background(), "s", "u")
require.ErrorIs(t, err, ErrNotConfigured)
_, err = NewHTTP("http://x", "k", "").Chat(context.Background(), "s", "u")
require.ErrorIs(t, err, ErrNotConfigured)
assert.False(t, NewHTTP("", "k", "m").Configured())
assert.True(t, NewHTTP("http://x", "k", "m").Configured())
}
func TestChatSuccessAndKeyNotLogged(t *testing.T) {
const key = "sk-secret-never-log"
var gotAuth, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/chat/completions", r.URL.Path)
gotAuth = r.Header.Get("Authorization")
raw, _ := io.ReadAll(r.Body)
gotBody = string(raw)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"hello"}}]}`))
}))
defer srv.Close()
c := NewHTTP(srv.URL, key, "m1")
out, err := c.Chat(context.Background(), "sys", "usr")
require.NoError(t, err)
assert.Equal(t, "hello", out)
assert.Equal(t, "Bearer "+key, gotAuth)
assert.Contains(t, gotBody, `"temperature":0.2`)
assert.Contains(t, gotBody, `"model":"m1"`)
// Error paths must never echo the key.
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream died", http.StatusBadGateway)
}))
defer failSrv.Close()
_, err = NewHTTP(failSrv.URL, key, "m1").Chat(context.Background(), "s", "u")
require.Error(t, err)
assert.False(t, strings.Contains(err.Error(), key), "error leaks api key: %v", err)
}
func TestChatRetryPolicy(t *testing.T) {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if n == 1 {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok after retry"}}]}`))
}))
defer srv.Close()
out, err := NewHTTP(srv.URL, "", "m").Chat(context.Background(), "s", "u")
require.NoError(t, err)
assert.Equal(t, "ok after retry", out)
assert.EqualValues(t, 2, calls, "5xx retried once")
// 4xx: no retry.
atomic.StoreInt32(&calls, 0)
srv4 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
http.Error(w, "bad request", http.StatusBadRequest)
}))
defer srv4.Close()
_, err = NewHTTP(srv4.URL, "", "m").Chat(context.Background(), "s", "u")
require.Error(t, err)
assert.EqualValues(t, 1, calls, "4xx not retried")
assert.True(t, strings.Contains(err.Error(), "400"))
assert.NotErrorIs(t, err, ErrNotConfigured)
}
-170
View File
@@ -1,170 +0,0 @@
// Package pipeline holds the pure stage-machine rules of the Control Center:
// stage order, validity checks, and the hard gate that agents may never move a
// task forward a stage — only humans may advance or send back.
package pipeline
import "fmt"
// Stage order, fixed.
const (
StageProposal = "proposal"
StageAnalysis = "analysis"
StageDesign = "design"
StageReview = "review"
StageImplementation = "implementation"
StageTesting = "testing"
StageEvaluation = "evaluation"
StageConfirmation = "confirmation"
)
// Stages is the canonical pipeline order.
var Stages = []string{
StageProposal,
StageAnalysis,
StageDesign,
StageReview,
StageImplementation,
StageTesting,
StageEvaluation,
StageConfirmation,
}
// Stage states (cc_tasks.stage_state).
const (
StateIdle = "idle"
StateAssigned = "assigned"
StateInProgress = "in_progress"
StateAwaitingReview = "awaiting_review"
StateChangesRequested = "changes_requested"
StateApproved = "approved"
)
// Task statuses (cc_tasks.status).
const (
StatusPlanned = "planned"
StatusInProgress = "in_progress"
StatusBlockedDecision = "blocked_pending_decision"
StatusDone = "done"
StatusCancelled = "cancelled"
)
// Event phases (cc_task_events.phase).
const (
PhaseStart = "start"
PhasePlan = "plan"
PhaseProgress = "progress"
PhaseBlocked = "blocked"
PhaseReport = "report"
PhaseDone = "done"
PhaseAdvanceRequest = "advance-request"
PhaseDecision = "decision"
PhaseSystem = "system"
)
var validStageStates = map[string]bool{
StateIdle: true,
StateAssigned: true,
StateInProgress: true,
StateAwaitingReview: true,
StateChangesRequested: true,
StateApproved: true,
}
var validTaskStatuses = map[string]bool{
StatusPlanned: true,
StatusInProgress: true,
StatusBlockedDecision: true,
StatusDone: true,
StatusCancelled: true,
}
var validPhases = map[string]bool{
PhaseStart: true,
PhasePlan: true,
PhaseProgress: true,
PhaseBlocked: true,
PhaseReport: true,
PhaseDone: true,
PhaseAdvanceRequest: true,
PhaseDecision: true,
PhaseSystem: true,
}
func ValidStage(s string) bool {
for _, st := range Stages {
if st == s {
return true
}
}
return false
}
func ValidStageState(s string) bool { return validStageStates[s] }
func ValidTaskStatus(s string) bool { return validTaskStatuses[s] }
func ValidPhase(s string) bool { return validPhases[s] }
// NextStage returns the stage after stage. When testingRequired is false the
// testing stage is skipped. The second return value is false when stage is
// the terminal confirmation stage (or invalid): there is no next stage.
func NextStage(stage string, testingRequired bool) (string, bool) {
if !ValidStage(stage) {
return "", false
}
if stage == StageConfirmation {
return "", false
}
for i, st := range Stages {
if st != stage {
continue
}
next := Stages[i+1]
if next == StageTesting && !testingRequired {
next = Stages[i+2]
}
return next, true
}
return "", false
}
// CanAgentTransition reports whether an agent may move stage_state from -> to.
// Iron rule: agents can only hand work back for human review; they can never
// advance a stage.
func CanAgentTransition(from, to string) bool {
return from == StateInProgress && to == StateAwaitingReview
}
// HumanAdvance computes the next stage when a human approves the current one.
// done is true when the current stage is confirmation, meaning the task is
// finished rather than moved. An unknown stage is an error.
func HumanAdvance(current string, testingRequired bool) (next string, done bool, err error) {
if !ValidStage(current) {
return "", false, fmt.Errorf("invalid stage %q", current)
}
if current == StageConfirmation {
return "", true, nil
}
next, _ = NextStage(current, testingRequired)
return next, false, nil
}
// deliverableSpecs describes what each stage must produce; served to agents
// in the instruction package.
var deliverableSpecs = map[string]string{
StageProposal: "产出提案:问题陈述、目标、范围边界、候选方案对比。写入 artifact(kind=proposal),必要时用 decisions 上报需要人类拍板的取舍。",
StageAnalysis: "产出分析:现状/代码证据、约束、风险清单。artifact(kind=analysis)。发现阻塞问题立即上报 decision,不得自行猜测关键决策。",
StageDesign: "产出设计文档:模块划分、接口、数据结构、迁移方案。artifact(kind=design)。",
StageReview: "产出评审材料:自检清单、与既有规范(AGENTS/PROJECT/regulations)的一致性核对。artifact(kind=review)。",
StageImplementation: "产出实现:代码改动说明、提交计划、变更文件清单。artifact(kind=implementation)。实际编码在仓库分支上进行。",
StageTesting: "产出测试报告:测试命令、结果、覆盖率与失败分析。artifact(kind=test-report),同时 POST /api/v1/agent/reports kind=test-report。",
StageEvaluation: "产出评估:对照 proposal 目标验收,列出达成/未达成项与遗留问题。artifact(kind=evaluation)。",
StageConfirmation: "产出确认材料:交付物索引、handoff 说明、回链证据。等待人类最终确认后任务 done。",
}
// DeliverableSpec returns the human-readable deliverable spec for a stage.
func DeliverableSpec(stage string) string { return deliverableSpecs[stage] }
// DecisionRules is the standing instruction for agents on how to escalate
// decisions to the human.
const DecisionRules = `决策上报规则:遇到需要人类拍板的问题(方案取舍、范围变更、破坏红线风险),调用 POST /api/v1/agent/tasks/{id}/decisions,question 必填,suggestions 至少一条且每条 option/rationale/impact 均非空。上报后任务进入 blocked_pending_decision,停止推进该任务,等待人类 resolve。agent 在任何情况下不得自行推进 stage(无 advance 路由);完成当前阶段工作后调用 advance-request 交回人类评审。`
@@ -1,107 +0,0 @@
package pipeline
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNextStageSequence(t *testing.T) {
want := []string{StageAnalysis, StageDesign, StageReview, StageImplementation, StageTesting, StageEvaluation, StageConfirmation}
for i, stage := range Stages[:len(Stages)-1] {
next, ok := NextStage(stage, true)
require.True(t, ok, "stage %s should have a next", stage)
assert.Equal(t, want[i], next)
}
}
func TestNextStageSkipsTesting(t *testing.T) {
next, ok := NextStage(StageImplementation, false)
require.True(t, ok)
assert.Equal(t, StageEvaluation, next)
// Testing itself is unaffected by the flag: a task already in testing
// proceeds to evaluation regardless.
next, ok = NextStage(StageTesting, false)
require.True(t, ok)
assert.Equal(t, StageEvaluation, next)
}
func TestNextStageTerminalAndInvalid(t *testing.T) {
next, ok := NextStage(StageConfirmation, true)
assert.False(t, ok)
assert.Empty(t, next)
next, ok = NextStage("nope", true)
assert.False(t, ok)
assert.Empty(t, next)
next, ok = NextStage("", true)
assert.False(t, ok)
assert.Empty(t, next)
}
func TestValidityChecks(t *testing.T) {
for _, s := range Stages {
assert.True(t, ValidStage(s), s)
}
assert.False(t, ValidStage(""))
assert.False(t, ValidStage("Proposal"))
for _, s := range []string{StateIdle, StateAssigned, StateInProgress, StateAwaitingReview, StateChangesRequested, StateApproved} {
assert.True(t, ValidStageState(s), s)
}
assert.False(t, ValidStageState("working"))
for _, s := range []string{StatusPlanned, StatusInProgress, StatusBlockedDecision, StatusDone, StatusCancelled} {
assert.True(t, ValidTaskStatus(s), s)
}
assert.False(t, ValidTaskStatus("archived"))
for _, p := range []string{PhaseStart, PhasePlan, PhaseProgress, PhaseBlocked, PhaseReport, PhaseDone, PhaseAdvanceRequest, PhaseDecision, PhaseSystem} {
assert.True(t, ValidPhase(p), p)
}
assert.False(t, ValidPhase("advance"))
}
func TestCanAgentTransition(t *testing.T) {
assert.True(t, CanAgentTransition(StateInProgress, StateAwaitingReview))
// Everything else is forbidden for agents, especially anything that
// smells like advancing or approving.
for _, from := range []string{StateIdle, StateAssigned, StateAwaitingReview, StateChangesRequested, StateApproved} {
assert.False(t, CanAgentTransition(from, StateAwaitingReview), from)
}
for _, to := range []string{StateIdle, StateAssigned, StateInProgress, StateChangesRequested, StateApproved} {
assert.False(t, CanAgentTransition(StateInProgress, to), to)
}
assert.False(t, CanAgentTransition(StateAwaitingReview, StateApproved))
}
func TestHumanAdvance(t *testing.T) {
next, done, err := HumanAdvance(StageProposal, true)
require.NoError(t, err)
assert.False(t, done)
assert.Equal(t, StageAnalysis, next)
next, done, err = HumanAdvance(StageImplementation, false)
require.NoError(t, err)
assert.False(t, done)
assert.Equal(t, StageEvaluation, next, "testing skipped when not required")
next, done, err = HumanAdvance(StageConfirmation, true)
require.NoError(t, err)
assert.True(t, done, "confirmation is the finish line")
assert.Empty(t, next)
_, _, err = HumanAdvance("bogus", true)
require.Error(t, err)
}
func TestDeliverableSpecCoversAllStages(t *testing.T) {
for _, s := range Stages {
assert.NotEmpty(t, DeliverableSpec(s), s)
}
assert.Empty(t, DeliverableSpec("nope"))
}
@@ -1,83 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"strconv"
"chat-agent-system/internal/controlcenter/audit"
)
// SetAudit attaches the audit runner used by the /api/v1/audits routes.
func (s *Server) SetAudit(r *audit.Runner) { s.audit = r }
func (s *Server) handleAuditRunList(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
out, err := audit.ListRuns(r.Context(), s.store, limit)
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleAuditFindingList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
out, err := audit.ListFindings(r.Context(), s.store, q.Get("status"), q.Get("severity"), limit)
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleAuditRun triggers a manual run in the background and returns the run
// id immediately; a second trigger while one is running gets 409.
func (s *Server) handleAuditRun(w http.ResponseWriter, r *http.Request) {
if s.audit == nil {
writeError(w, http.StatusServiceUnavailable, "audit runner not configured")
return
}
runID, err := audit.StartManual(r.Context(), s.audit)
if err != nil {
writeAuditError(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"run_id": runID})
}
func (s *Server) handleAuditFindingConvert(w http.ResponseWriter, r *http.Request) {
fi, err := audit.ConvertFinding(r.Context(), s.store, r.PathValue("id"))
if err != nil {
writeAuditError(w, err)
return
}
slog.Info("audit finding converted", "finding", fi.ID, "task", fi.TaskID)
writeJSON(w, http.StatusOK, fi)
}
func (s *Server) handleAuditFindingDismiss(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := audit.DismissFinding(r.Context(), s.store, id); err != nil {
writeAuditError(w, err)
return
}
slog.Info("audit finding dismissed", "finding", id)
writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"})
}
func writeAuditError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, audit.ErrAlreadyRunning):
writeError(w, http.StatusConflict, err.Error())
case errors.Is(err, audit.ErrNotFound):
writeError(w, http.StatusNotFound, err.Error())
case errors.Is(err, audit.ErrBadState):
writeError(w, http.StatusConflict, err.Error())
default:
slog.Error("audit handler", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
}
-159
View File
@@ -1,159 +0,0 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/audit"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/store"
)
// newAuditTestServer builds a console server with an audit runner attached.
// The runner points at the real repo root so git checks behave like
// production (a temp dir would flag every declared branch as missing).
func newAuditTestServer(t *testing.T) (*httptest.Server, *store.Store) {
t.Helper()
ts, st := newTestServer(t)
ts.Close()
_, thisFile, _, _ := runtime.Caller(0)
repoRoot := filepath.Join(filepath.Dir(thisFile), "..", "..", "..")
srv := New(config.Config{ConsoleToken: testConsoleToken}, st)
srv.SetAudit(audit.New(st, repoRoot, nil))
ts = httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, st
}
func decodeArray(t *testing.T, raw []byte) []map[string]any {
t.Helper()
var m []map[string]any
require.NoError(t, json.Unmarshal(raw, &m))
return m
}
func TestAuditEndpoints(t *testing.T) {
ts, _ := newAuditTestServer(t)
// List endpoints work before any run.
code, raw := doReq(t, "GET", ts.URL+"/api/v1/audits/runs?limit=5", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
code, raw = doReq(t, "GET", ts.URL+"/api/v1/audits/findings?status=open", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
// Manual trigger returns a run id immediately. A parallel test binary
// sharing the dev DB may briefly hold the single-running guard: retry 409s.
var runID float64
triggerDeadline := time.Now().Add(30 * time.Second)
for {
code, raw = doReq(t, "POST", ts.URL+"/api/v1/audits/run", testConsoleToken, nil)
if code == http.StatusAccepted {
runID = decodeBody(t, raw)["run_id"].(float64)
break
}
require.Equal(t, http.StatusConflict, code, string(raw))
require.False(t, time.Now().After(triggerDeadline), "run never accepted")
time.Sleep(200 * time.Millisecond)
}
require.Greater(t, runID, float64(0))
// The run finishes (repo root is the real repo, so git checks work).
deadline := time.Now().Add(15 * time.Second)
for {
code, raw = doReq(t, "GET", ts.URL+"/api/v1/audits/runs?limit=20", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
var ours map[string]any
for _, r := range decodeArray(t, raw) {
if r["id"] == runID {
ours = r
}
}
require.NotNil(t, ours, "our run must appear in history")
if ours["status"] != "running" {
assert.Equal(t, "completed", ours["status"], "run error: %v", ours["error"])
break
}
require.False(t, time.Now().After(deadline), "run never finished")
time.Sleep(100 * time.Millisecond)
}
}
func TestAuditRunConflict(t *testing.T) {
ts, st := newAuditTestServer(t)
// A pre-existing running row makes the manual trigger 409.
var busyID int64
require.NoError(t, st.Pool.QueryRow(t.Context(),
`INSERT INTO cc_audit_runs (kind, status) VALUES ('manual','running') RETURNING id`).Scan(&busyID))
t.Cleanup(func() {
// Delete only this test's row; a parallel package may hold its own run.
_, _ = st.Pool.Exec(context.Background(),
`DELETE FROM cc_audit_runs WHERE id=$1`, busyID)
})
code, _ := doReq(t, "POST", ts.URL+"/api/v1/audits/run", testConsoleToken, nil)
assert.Equal(t, http.StatusConflict, code)
}
func TestAuditFindingConvertDismissEndpoints(t *testing.T) {
ts, st := newTestServer(t)
var runID int64
require.NoError(t, st.Pool.QueryRow(t.Context(),
`INSERT INTO cc_audit_runs (kind, status, finished_at) VALUES ('manual','completed',now()) RETURNING id`).Scan(&runID))
mk := func(title string) string {
var id string
require.NoError(t, st.Pool.QueryRow(t.Context(),
`INSERT INTO cc_audit_findings (run_id, severity, kind, title, detail_md, refs)
VALUES ($1,'high','bug',$2,'细节','["r1"]'::jsonb) RETURNING id`, runID, title).Scan(&id))
return id
}
// Convert creates a proposal task and flips the finding.
fid := mk("endpoint convert " + slug("c"))
code, raw := doReq(t, "POST", ts.URL+"/api/v1/audits/findings/"+fid+"/convert", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, "converted", m["status"])
require.NotNil(t, m["task_id"])
code, _ = doReq(t, "POST", ts.URL+"/api/v1/audits/findings/"+fid+"/convert", testConsoleToken, nil)
assert.Equal(t, http.StatusConflict, code, "converting twice must conflict")
// Dismiss.
fid2 := mk("endpoint dismiss " + slug("d"))
code, raw = doReq(t, "POST", ts.URL+"/api/v1/audits/findings/"+fid2+"/dismiss", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
code, _ = doReq(t, "POST", ts.URL+"/api/v1/audits/findings/"+fid2+"/dismiss", testConsoleToken, nil)
assert.Equal(t, http.StatusConflict, code)
// Unknown finding 404s.
code, _ = doReq(t, "POST", ts.URL+"/api/v1/audits/findings/00000000-0000-0000-0000-000000000000/dismiss", testConsoleToken, nil)
assert.Equal(t, http.StatusNotFound, code)
// Converted/dismissed findings show up under their status filter.
code, raw = doReq(t, "GET", ts.URL+"/api/v1/audits/findings?status=converted&limit=50", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
found := false
for _, it := range decodeArray(t, raw) {
if it["id"] == fid {
found = true
}
}
assert.True(t, found, "converted finding must appear in converted filter")
}
// Without an attached runner the manual trigger reports 503.
func TestAuditRunWithoutRunner(t *testing.T) {
ts, _ := newTestServer(t)
code, _ := doReq(t, "POST", ts.URL+"/api/v1/audits/run", testConsoleToken, nil)
assert.Equal(t, http.StatusServiceUnavailable, code)
}
-34
View File
@@ -1,34 +0,0 @@
package server
import (
"context"
"chat-agent-system/internal/controlcenter/store"
)
// AgentAuthenticator resolves bearer tokens against cc_agents.token_hash,
// with a small in-memory cache populated lazily.
type AgentAuthenticator struct {
st *store.Store
}
// Authenticate returns the agent name when the token is valid.
func (a *AgentAuthenticator) Authenticate(ctx context.Context, token string) (string, bool) {
var name string
err := a.st.Pool.QueryRow(ctx,
`SELECT name FROM cc_agents WHERE token_hash = $1`, TokenHash(token)).Scan(&name)
if err != nil {
return "", false
}
return name, true
}
// RegisterAgent creates (or rotates) an agent token; returns the plaintext
// token exactly once.
func RegisterAgent(ctx context.Context, st *store.Store, name, kind, token string) error {
_, err := st.Pool.Exec(ctx,
`INSERT INTO cc_agents (name, kind, token_hash) VALUES ($1,$2,$3)
ON CONFLICT (name) DO UPDATE SET kind = EXCLUDED.kind, token_hash = EXCLUDED.token_hash`,
name, kind, TokenHash(token))
return err
}
-49
View File
@@ -1,49 +0,0 @@
package server
import (
"encoding/json"
"log/slog"
"sync"
)
// Broker is a process-local fan-out for SSE subscribers. Slow subscribers are
// dropped rather than blocking writers.
type Broker struct {
mu sync.Mutex
subs map[chan []byte]struct{}
}
func NewBroker() *Broker {
return &Broker{subs: map[chan []byte]struct{}{}}
}
func (b *Broker) Subscribe() chan []byte {
ch := make(chan []byte, 64)
b.mu.Lock()
b.subs[ch] = struct{}{}
b.mu.Unlock()
return ch
}
func (b *Broker) Unsubscribe(ch chan []byte) {
b.mu.Lock()
delete(b.subs, ch)
b.mu.Unlock()
}
// Publish marshals v and queues it for every subscriber.
func (b *Broker) Publish(v any) {
raw, err := json.Marshal(v)
if err != nil {
slog.Error("broker marshal", "err", err)
return
}
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subs {
select {
case ch <- raw:
default:
}
}
}
@@ -1,99 +0,0 @@
package server
import (
"net/http"
"chat-agent-system/internal/controlcenter/pipeline"
"chat-agent-system/internal/controlcenter/tasks"
)
func (s *Server) handleDecisionList(w http.ResponseWriter, r *http.Request) {
out, err := tasks.ListDecisions(r.Context(), s.store, r.URL.Query().Get("status"))
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDecisionResolve(w http.ResponseWriter, r *http.Request) {
var body struct {
Resolution string `json:"resolution"`
SuggestionIndex *int `json:"suggestion_index"`
}
if !decodeJSON(w, r, &body) {
return
}
d, err := tasks.ResolveDecision(r.Context(), s.store, r.PathValue("id"), body.Resolution, body.SuggestionIndex, "console")
if err != nil {
writeDomainError(w, err)
return
}
if err := tasks.Unblock(r.Context(), s.store, d.TaskID); err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), d.TaskID, "", pipeline.PhaseSystem, "decision resolved: "+d.Resolution,
map[string]any{"decision_id": d.ID, "resolution": d.Resolution})
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleStageSettingsGet(w http.ResponseWriter, r *http.Request) {
out, err := tasks.ListStageSettings(r.Context(), s.store, r.URL.Query().Get("task_id"))
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleStageSettingsPut(w http.ResponseWriter, r *http.Request) {
var body struct {
TaskID *string `json:"task_id"`
Stage string `json:"stage"`
Model string `json:"model"`
Executor string `json:"executor"`
Settings map[string]struct {
Model string `json:"model"`
Executor string `json:"executor"`
} `json:"settings"`
}
if !decodeJSON(w, r, &body) {
return
}
if body.Stage != "" {
if err := tasks.PutStageSetting(r.Context(), s.store, body.TaskID, body.Stage, body.Model, body.Executor); err != nil {
writeDomainError(w, err)
return
}
}
for stage, v := range body.Settings {
if err := tasks.PutStageSetting(r.Context(), s.store, body.TaskID, stage, v.Model, v.Executor); err != nil {
writeDomainError(w, err)
return
}
}
if body.Stage == "" && len(body.Settings) == 0 {
writeError(w, http.StatusBadRequest, "stage or settings is required")
return
}
taskID := ""
if body.TaskID != nil {
taskID = *body.TaskID
}
out, err := tasks.ListStageSettings(r.Context(), s.store, taskID)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
ov, err := tasks.GetOverview(r.Context(), s.store)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, ov)
}
@@ -1,307 +0,0 @@
package server
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"chat-agent-system/internal/controlcenter/embed"
)
const (
searchDefaultLimit = 20
searchMaxLimit = 100
snippetRunes = 300
)
// docListItem is one row of GET /api/v1/docs.
type docListItem struct {
ID string `json:"id"`
Path string `json:"path"`
Title string `json:"title"`
Taxonomy string `json:"taxonomy"`
Frontmatter any `json:"frontmatter,omitempty"`
SyncedAt time.Time `json:"synced_at"`
Chunks int `json:"chunks"`
}
func (s *Server) handleDocList(w http.ResponseWriter, r *http.Request) {
taxonomy := strings.TrimSpace(r.URL.Query().Get("taxonomy"))
q := strings.TrimSpace(r.URL.Query().Get("q"))
like := "%" + escapeLike(q) + "%"
rows, err := s.store.Pool.Query(r.Context(), `
SELECT d.id, d.path, d.title, d.taxonomy, d.synced_at,
(SELECT count(*) FROM cc_doc_chunks c WHERE c.doc_id = d.id) AS chunks
FROM cc_documents d
WHERE ($1 = '' OR d.taxonomy = $1)
AND ($2 = '' OR d.path ILIKE $3 ESCAPE '\' OR d.title ILIKE $3 ESCAPE '\')
ORDER BY d.path`, taxonomy, q, like)
if err != nil {
slog.Error("doc list", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
defer rows.Close()
items := []docListItem{}
for rows.Next() {
var it docListItem
if err := rows.Scan(&it.ID, &it.Path, &it.Title, &it.Taxonomy, &it.SyncedAt, &it.Chunks); err != nil {
slog.Error("doc list scan", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
items = append(items, it)
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "count": len(items)})
}
func (s *Server) handleDocGet(w http.ResponseWriter, r *http.Request) {
var it docListItem
var fm []byte
err := s.store.Pool.QueryRow(r.Context(), `
SELECT d.id, d.path, d.title, d.taxonomy, d.frontmatter, d.synced_at,
(SELECT count(*) FROM cc_doc_chunks c WHERE c.doc_id = d.id) AS chunks
FROM cc_documents d WHERE d.id = $1`, r.PathValue("id")).
Scan(&it.ID, &it.Path, &it.Title, &it.Taxonomy, &fm, &it.SyncedAt, &it.Chunks)
if err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": it.ID, "path": it.Path, "title": it.Title, "taxonomy": it.Taxonomy,
"frontmatter": jsonRaw(fm), "synced_at": it.SyncedAt, "chunks": it.Chunks,
})
}
// searchHit is one scored chunk with its source resolved.
type searchHit struct {
chunkID int64
docID *string
artID *string
heading string
content string
score float64
docPath string
docTitle string
taskSlug string
artStage string
artTitle string
}
const searchSelect = `
SELECT c.id, c.doc_id, c.artifact_id, c.heading, c.content,
COALESCE(d.path,''), COALESCE(d.title,''),
COALESCE(t.slug,''), COALESCE(a.stage,''), COALESCE(a.title,'')`
const searchJoin = `
FROM cc_doc_chunks c
LEFT JOIN cc_documents d ON d.id = c.doc_id
LEFT JOIN cc_stage_artifacts a ON a.id = c.artifact_id
LEFT JOIN cc_tasks t ON t.id = a.task_id`
func (s *Server) semanticHits(ctx context.Context, qvec []float32, limit int) ([]searchHit, error) {
lit := embed.VectorLiteral(qvec)
rows, err := s.store.Pool.Query(ctx, searchSelect+`,
1 - (c.embedding <=> $1::vector) AS score`+searchJoin+`
WHERE c.embedding IS NOT NULL
ORDER BY c.embedding <=> $1::vector
LIMIT $2`, lit, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanHits(rows)
}
func (s *Server) keywordHits(ctx context.Context, q string, limit int) ([]searchHit, error) {
like := "%" + escapeLike(q) + "%"
rows, err := s.store.Pool.Query(ctx, searchSelect+`,
(CASE WHEN c.tsv @@ plainto_tsquery('simple', $1)
THEN ts_rank(c.tsv, plainto_tsquery('simple', $1)) ELSE 0 END
+ CASE WHEN c.content ILIKE $2 ESCAPE '\' THEN 0.5 ELSE 0 END
+ CASE WHEN c.heading ILIKE $2 ESCAPE '\' THEN 0.3 ELSE 0 END) AS score`+searchJoin+`
WHERE c.tsv @@ plainto_tsquery('simple', $1)
OR c.content ILIKE $2 ESCAPE '\'
OR c.heading ILIKE $2 ESCAPE '\'
ORDER BY score DESC
LIMIT $3`, q, like, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanHits(rows)
}
func scanHits(rows interface {
Next() bool
Scan(...any) error
Err() error
}) ([]searchHit, error) {
var out []searchHit
for rows.Next() {
var h searchHit
if err := rows.Scan(&h.chunkID, &h.docID, &h.artID, &h.heading, &h.content,
&h.docPath, &h.docTitle, &h.taskSlug, &h.artStage, &h.artTitle, &h.score); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
func (s *Server) handleDocSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeError(w, http.StatusBadRequest, "q is required")
return
}
mode := r.URL.Query().Get("mode")
if mode == "" {
mode = "hybrid"
}
if mode != "semantic" && mode != "keyword" && mode != "hybrid" {
writeError(w, http.StatusBadRequest, "mode must be semantic|keyword|hybrid")
return
}
limit := searchDefaultLimit
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
return
}
limit = min(n, searchMaxLimit)
}
ctx := r.Context()
degraded := false
var vecHits []searchHit
if mode != "keyword" {
if s.emb == nil {
degraded = true
} else if vecs, err := s.emb.Embed(ctx, []string{q}); err != nil || len(vecs) == 0 {
slog.Warn("search: query embedding failed, degrading to keyword", "err", err)
degraded = true
} else if hits, err := s.semanticHits(ctx, vecs[0], limit); err != nil {
slog.Error("search: semantic query", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
} else {
vecHits = hits
}
}
var hits []searchHit
switch {
case mode == "semantic" && !degraded:
hits = vecHits
case mode == "hybrid" && !degraded:
kwHits, err := s.keywordHits(ctx, q, limit)
if err != nil {
slog.Error("search: keyword query", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
hits = fuseHits(vecHits, kwHits, limit)
default: // keyword, or degraded semantic/hybrid
kwHits, err := s.keywordHits(ctx, q, limit)
if err != nil {
slog.Error("search: keyword query", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
hits = kwHits
}
results := make([]map[string]any, 0, len(hits))
for _, h := range hits {
source := map[string]any{"type": "doc", "path": h.docPath, "title": h.docTitle}
if h.artID != nil {
source = map[string]any{"type": "artifact", "task_slug": h.taskSlug, "stage": h.artStage, "title": h.artTitle}
}
results = append(results, map[string]any{
"score": h.score,
"snippet": snippet(h.content),
"heading": h.heading,
"source": source,
"doc_id": h.docID,
"artifact_id": h.artID,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"q": q, "mode": mode, "degraded": degraded, "count": len(results), "results": results,
})
}
// fuseHits merges semantic (weight 0.6) and keyword (weight 0.4) hits after
// per-list max-normalization.
func fuseHits(vecHits, kwHits []searchHit, limit int) []searchHit {
byID := map[int64]*searchHit{}
norm := func(hits []searchHit, weight float64) {
maxScore := 0.0
for _, h := range hits {
if h.score > maxScore {
maxScore = h.score
}
}
if maxScore <= 0 {
maxScore = 1
}
for _, h := range hits {
score := weight * h.score / maxScore
if ex, ok := byID[h.chunkID]; ok {
ex.score += score
} else {
cpy := h
cpy.score = score
byID[h.chunkID] = &cpy
}
}
}
norm(vecHits, 0.6)
norm(kwHits, 0.4)
out := make([]searchHit, 0, len(byID))
for _, h := range byID {
out = append(out, *h)
}
for i := 1; i < len(out); i++ { // insertion sort by score desc, hits are few
for j := i; j > 0 && out[j].score > out[j-1].score; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
if len(out) > limit {
out = out[:limit]
}
return out
}
// snippet returns the first ~snippetRunes runes of content.
func snippet(content string) string {
r := []rune(strings.TrimSpace(content))
if len(r) > snippetRunes {
r = r[:snippetRunes]
}
return string(r)
}
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
// jsonRaw returns frontmatter bytes as an any for the JSON encoder.
func jsonRaw(b []byte) any {
var v any
if len(b) > 0 {
if err := json.Unmarshal(b, &v); err == nil {
return v
}
}
return map[string]any{}
}
-213
View File
@@ -1,213 +0,0 @@
package server
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/docs"
"chat-agent-system/internal/controlcenter/embed"
"chat-agent-system/internal/controlcenter/store"
)
// newDocTestServer builds a server wired to a docs.Syncer over a temp .doc
// tree. emb may be nil to exercise degraded search.
func newDocTestServer(t *testing.T, emb embed.Embedder, files map[string]string) (*httptest.Server, *store.Store, *docs.Syncer) {
t.Helper()
dsn := os.Getenv("TEST_CC_DB_DSN")
if dsn == "" {
t.Skip("TEST_CC_DB_DSN not set")
}
_, thisFile, _, _ := runtime.Caller(0)
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
ctx := t.Context()
require.NoError(t, store.Migrate(ctx, dsn, migrationsDir))
st, err := store.Connect(ctx, dsn)
require.NoError(t, err)
t.Cleanup(st.Close)
repoRoot := t.TempDir()
for rel, content := range files {
full := filepath.Join(repoRoot, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
}
syncer := docs.New(st, repoRoot, emb)
stats, err := syncer.Sync(ctx)
require.NoError(t, err)
srv := New(config.Config{ConsoleToken: testConsoleToken}, st)
srv.SetDocs(emb, syncer)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
t.Logf("sync stats: %+v", stats)
return ts, st, syncer
}
func docTestFiles(tag string) map[string]string {
return map[string]string{
".doc/ops/" + tag + "-pipeline.md": "---\ntitle: 流水线设计\nstatus: done\n---\n# 流水线设计\n\n八阶段流水线,任务从 proposal 到 confirmation。\n\n## 阶段\n\n提案、分析、设计、评审、实现、测试、评估、确认。\n",
".doc/audit/" + tag + "-notes.md": "# 审计笔记\n\n流水线租约过期后任务回到 assigned 状态。\n",
".doc/" + tag + "-root.md": "# 根文档\n\n没有 taxonomy 的根级文档。\n",
".doc/assets/" + tag + "-skip.md": "# 应被跳过\n\nassets 目录不入库。\n",
".doc/ops/" + tag + "-plain.txt": "not markdown, ignored",
}
}
func TestDocsSyncListAndGet(t *testing.T) {
tag := fmt.Sprintf("t%d", time.Now().UnixNano())
ts, st, syncer := newDocTestServer(t, embed.NewStub(embed.Dim), docTestFiles(tag))
code, raw := doReq(t, "GET", ts.URL+"/api/v1/docs", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
items := decodeBody(t, raw)["items"].([]any)
var paths []string
for _, it := range items {
paths = append(paths, it.(map[string]any)["path"].(string))
}
assert.Contains(t, paths, ".doc/ops/"+tag+"-pipeline.md")
assert.Contains(t, paths, ".doc/audit/"+tag+"-notes.md")
assert.Contains(t, paths, ".doc/"+tag+"-root.md")
assert.NotContains(t, paths, ".doc/assets/"+tag+"-skip.md", "assets/ must be skipped")
// taxonomy filter
code, raw = doReq(t, "GET", ts.URL+"/api/v1/docs?taxonomy=audit", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
for _, it := range decodeBody(t, raw)["items"].([]any) {
assert.Equal(t, "audit", it.(map[string]any)["taxonomy"])
}
// q filter + front matter title
code, raw = doReq(t, "GET", ts.URL+"/api/v1/docs?q=流水线设计", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
items = decodeBody(t, raw)["items"].([]any)
require.Len(t, items, 1)
it := items[0].(map[string]any)
assert.Equal(t, "流水线设计", it["title"])
assert.Equal(t, "ops", it["taxonomy"])
assert.Greater(t, it["chunks"].(float64), float64(0))
// detail
code, raw = doReq(t, "GET", ts.URL+"/api/v1/docs/"+it["id"].(string), testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
detail := decodeBody(t, raw)
assert.Equal(t, "done", detail["frontmatter"].(map[string]any)["status"])
code, _ = doReq(t, "GET", ts.URL+"/api/v1/docs/00000000-0000-0000-0000-000000000000", testConsoleToken, nil)
assert.Equal(t, http.StatusNotFound, code)
// re-sync is idempotent: chunk count unchanged
countChunks := func() int {
var n int
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT count(*) FROM cc_doc_chunks c JOIN cc_documents d ON d.id = c.doc_id
WHERE d.path LIKE '%' || $1 || '%'`, tag).Scan(&n))
return n
}
before := countChunks()
stats, err := syncer.Sync(t.Context())
require.NoError(t, err)
assert.Equal(t, 0, stats.Added+stats.Updated, "unchanged files must be skipped")
assert.Equal(t, before, countChunks(), "unchanged docs must not duplicate chunks")
}
func TestDocsSearchModes(t *testing.T) {
tag := fmt.Sprintf("t%d", time.Now().UnixNano())
ts, _, _ := newDocTestServer(t, embed.NewStub(embed.Dim), docTestFiles(tag))
// q required
code, _ := doReq(t, "GET", ts.URL+"/api/v1/docs/search", testConsoleToken, nil)
assert.Equal(t, http.StatusBadRequest, code)
// keyword: ILIKE fallback hits Chinese content
code, raw := doReq(t, "GET", ts.URL+"/api/v1/docs/search?q=流水线&mode=keyword", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, false, m["degraded"])
results := m["results"].([]any)
require.NotEmpty(t, results)
top := results[0].(map[string]any)
src := top["source"].(map[string]any)
assert.Equal(t, "doc", src["type"])
assert.Contains(t, src["path"], tag)
assert.LessOrEqual(t, len([]rune(top["snippet"].(string))), snippetRunes)
// semantic with stub embedder returns results
code, raw = doReq(t, "GET", ts.URL+"/api/v1/docs/search?q=pipeline&mode=semantic&limit=5", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
m = decodeBody(t, raw)
assert.Equal(t, false, m["degraded"])
assert.NotEmpty(t, m["results"])
// hybrid fuses both
code, raw = doReq(t, "GET", ts.URL+"/api/v1/docs/search?q=流水线&mode=hybrid", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
m = decodeBody(t, raw)
assert.Equal(t, false, m["degraded"])
assert.NotEmpty(t, m["results"])
// limit cap
code, raw = doReq(t, "GET", ts.URL+"/api/v1/docs/search?q=流水线&limit=500", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
assert.LessOrEqual(t, len(decodeBody(t, raw)["results"].([]any)), searchMaxLimit)
}
func TestDocsSearchDegradedWithoutEmbedder(t *testing.T) {
tag := fmt.Sprintf("t%d", time.Now().UnixNano())
ts, _, _ := newDocTestServer(t, nil, docTestFiles(tag)) // nil embedder
code, raw := doReq(t, "GET", ts.URL+"/api/v1/docs/search?q=流水线&mode=hybrid", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, true, m["degraded"], "no embedder must degrade hybrid to keyword")
assert.NotEmpty(t, m["results"], "keyword fallback still returns hits")
}
func TestArtifactIndexingHook(t *testing.T) {
tag := fmt.Sprintf("t%d", time.Now().UnixNano())
dsn := os.Getenv("TEST_CC_DB_DSN")
if dsn == "" {
t.Skip("TEST_CC_DB_DSN not set")
}
ts, st, _ := newDocTestServer(t, embed.NewStub(embed.Dim), docTestFiles(tag))
require.NoError(t, RegisterAgent(t.Context(), st, "agent-a", "test", testAgentToken))
id := createAssignedTask(t, ts, true)
claimTask(t, ts, id)
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/artifacts", testAgentToken, map[string]any{
"title": "分析产物", "content_md": "# 分析\n\n流水线分析产物 " + tag + " 内容。\n",
})
require.Equal(t, http.StatusCreated, code, string(raw))
artID := decodeBody(t, raw)["id"].(string)
// indexing is async; poll
var n int
for i := 0; i < 40; i++ {
require.NoError(t, st.Pool.QueryRow(t.Context(),
`SELECT count(*) FROM cc_doc_chunks WHERE artifact_id = $1`, artID).Scan(&n))
if n > 0 {
break
}
time.Sleep(100 * time.Millisecond)
}
assert.Greater(t, n, 0, "artifact chunks indexed asynchronously")
// artifact shows up in search with its source
code, raw = doReq(t, "GET", ts.URL+"/api/v1/docs/search?q="+tag+"&mode=keyword", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
results := decodeBody(t, raw)["results"].([]any)
require.NotEmpty(t, results)
src := results[0].(map[string]any)["source"].(map[string]any)
assert.Equal(t, "artifact", src["type"])
assert.NotEmpty(t, src["task_slug"])
}
@@ -1,64 +0,0 @@
package server
import (
"errors"
"net/http"
"os"
"path/filepath"
"time"
"chat-agent-system/internal/controlcenter/pipeline"
"chat-agent-system/internal/controlcenter/tasks"
)
// handleTaskExportDoc renders a task as a .doc short-term task document and
// writes it to <RepoRoot>/.doc/ops/short-term/YYYY-MM-DD-<slug>.md. The
// periodic docs sync then indexes it like any other .doc file. An existing
// file is never overwritten (409).
func (s *Server) handleTaskExportDoc(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
t, err := tasks.GetTask(r.Context(), s.store, id)
if err != nil {
writeDomainError(w, err)
return
}
if !tasks.ValidDocSlug(t.Slug) {
writeError(w, http.StatusBadRequest, "task slug is not exportable: must match [a-z0-9-]+")
return
}
today := time.Now().Format("2006-01-02")
rel := filepath.Join(".doc", "ops", "short-term", today+"-"+t.Slug+".md")
full := filepath.Join(s.cfg.RepoRoot, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
writeDomainError(w, err)
return
}
f, err := os.OpenFile(full, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if errors.Is(err, os.ErrExist) {
writeError(w, http.StatusConflict, "doc file already exists: "+filepath.ToSlash(rel))
return
}
if err != nil {
writeDomainError(w, err)
return
}
if _, err := f.WriteString(tasks.RenderTaskDoc(t, today)); err != nil {
f.Close()
writeDomainError(w, err)
return
}
if err := f.Close(); err != nil {
writeDomainError(w, err)
return
}
relSlash := filepath.ToSlash(rel)
t, err = tasks.PatchTask(r.Context(), s.store, id, tasks.Patch{DocPath: &relSlash})
if err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), id, "", pipeline.PhaseSystem, "exported doc "+relSlash, nil)
writeJSON(w, http.StatusCreated, map[string]any{"doc_path": relSlash, "task": t})
}
@@ -1,85 +0,0 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/store"
)
func newExportTestServer(t *testing.T, repoRoot string) *httptest.Server {
t.Helper()
dsn := os.Getenv("TEST_CC_DB_DSN")
if dsn == "" {
t.Skip("TEST_CC_DB_DSN not set")
}
_, thisFile, _, _ := runtime.Caller(0)
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
ctx := t.Context()
require.NoError(t, store.Migrate(ctx, dsn, migrationsDir))
st, err := store.Connect(ctx, dsn)
require.NoError(t, err)
t.Cleanup(st.Close)
srv := New(config.Config{ConsoleToken: testConsoleToken, RepoRoot: repoRoot}, st)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts
}
func TestTaskExportDoc(t *testing.T) {
repoRoot := t.TempDir()
ts := newExportTestServer(t, repoRoot)
taskSlug := slug("export")
code, raw := doReq(t, "POST", ts.URL+"/api/v1/tasks", testConsoleToken, map[string]any{
"slug": taskSlug, "title": "导出任务", "instruction_md": "## 目标\n\n验证导出。\n",
})
require.Equal(t, http.StatusCreated, code, string(raw))
id := decodeBody(t, raw)["id"].(string)
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id+"/export-doc", testConsoleToken, nil)
require.Equal(t, http.StatusCreated, code, string(raw))
docPath := decodeBody(t, raw)["doc_path"].(string)
today := time.Now().Format("2006-01-02")
assert.Equal(t, ".doc/ops/short-term/"+today+"-"+taskSlug+".md", docPath)
content, err := os.ReadFile(filepath.Join(repoRoot, filepath.FromSlash(docPath)))
require.NoError(t, err)
md := string(content)
assert.True(t, strings.HasPrefix(md, "---\n"), "front matter first")
assert.Contains(t, md, `title: "导出任务"`)
assert.Contains(t, md, "status: planned\n")
assert.Contains(t, md, "created: "+today+"\n")
assert.Contains(t, md, "# 导出任务")
assert.Contains(t, md, "## 目标")
// task doc_path updated
code, raw = doReq(t, "GET", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, docPath, decodeBody(t, raw)["doc_path"])
// second export conflicts, file untouched
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id+"/export-doc", testConsoleToken, nil)
assert.Equal(t, http.StatusConflict, code, string(raw))
assert.Contains(t, string(raw), "already exists")
// unsafe slug rejected
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks", testConsoleToken, map[string]any{
"slug": slug("Bad_Slug"), "title": "不可导出",
})
require.Equal(t, http.StatusCreated, code, string(raw))
badID := decodeBody(t, raw)["id"].(string)
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+badID+"/export-doc", testConsoleToken, nil)
assert.Equal(t, http.StatusBadRequest, code, string(raw))
}
-51
View File
@@ -1,51 +0,0 @@
package server
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"chat-agent-system/internal/controlcenter/tasks"
)
const maxBodyBytes = 1 << 20
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
err := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes)).Decode(v)
if errors.Is(err, io.EOF) {
return true // empty body is fine for endpoints with all-optional fields
}
if err != nil {
writeError(w, http.StatusBadRequest, "invalid json body: "+err.Error())
return false
}
return true
}
func writeDomainError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, tasks.ErrNotFound):
writeError(w, http.StatusNotFound, err.Error())
case errors.Is(err, tasks.ErrConflict), errors.Is(err, tasks.ErrBadState):
writeError(w, http.StatusConflict, err.Error())
case errors.Is(err, tasks.ErrBadInput):
writeError(w, http.StatusBadRequest, err.Error())
default:
slog.Error("handler", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
}
// appendEvent is the single write path for cc_task_events; every insert is
// broadcast to SSE subscribers.
func (s *Server) appendEvent(ctx context.Context, taskID, agent, phase, message string, payload any) {
e, err := tasks.AppendEvent(ctx, s.store, taskID, agent, phase, message, payload)
if err != nil {
slog.Error("append event", "task", taskID, "phase", phase, "err", err)
return
}
s.broker.Publish(e)
}
-41
View File
@@ -1,41 +0,0 @@
package server
import (
"context"
"log/slog"
"time"
"chat-agent-system/internal/controlcenter/pipeline"
"chat-agent-system/internal/controlcenter/tasks"
)
const leaseReapInterval = 60 * time.Second
// StartLeaseReaper runs a 60s ticker that returns expired in-progress leases
// to assigned state until ctx is cancelled.
func (s *Server) StartLeaseReaper(ctx context.Context) {
go func() {
t := time.NewTicker(leaseReapInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.reapExpiredLeases(ctx)
}
}
}()
}
func (s *Server) reapExpiredLeases(ctx context.Context) {
reaped, err := tasks.ReapExpiredLeases(ctx, s.store)
if err != nil {
slog.Error("lease reaper", "err", err)
return
}
for _, r := range reaped {
s.appendEvent(ctx, r.ID, r.Agent, pipeline.PhaseSystem, "lease expired, task unclaimed", nil)
slog.Info("lease expired, task unclaimed", "task", r.Slug, "agent", r.Agent)
}
}
@@ -1,48 +0,0 @@
package server
import (
"net/http"
"chat-agent-system/internal/controlcenter/tasks"
)
func (s *Server) handlePlanCreate(w http.ResponseWriter, r *http.Request) {
var body struct {
Slug string `json:"slug"`
Title string `json:"title"`
Horizon string `json:"horizon"`
SummaryMD string `json:"summary_md"`
DocPath string `json:"doc_path"`
}
if !decodeJSON(w, r, &body) {
return
}
p, err := tasks.CreatePlan(r.Context(), s.store, body.Slug, body.Title, body.Horizon, body.SummaryMD, body.DocPath)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handlePlanList(w http.ResponseWriter, r *http.Request) {
out, err := tasks.ListPlans(r.Context(), s.store)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handlePlanPatch(w http.ResponseWriter, r *http.Request) {
var body tasks.PlanPatch
if !decodeJSON(w, r, &body) {
return
}
p, err := tasks.PatchPlan(r.Context(), s.store, r.PathValue("id"), body)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, p)
}
-190
View File
@@ -1,190 +0,0 @@
// Package server exposes the Control Center HTTP API. Loopback-first, token
// authenticated: console token for the human UI, per-agent bearer tokens for
// /api/v1/agent/*.
package server
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"chat-agent-system/internal/controlcenter/audit"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/docs"
"chat-agent-system/internal/controlcenter/embed"
"chat-agent-system/internal/controlcenter/store"
"chat-agent-system/internal/controlcenter/webui"
)
type ctxKey string
const ctxAgent ctxKey = "cc-agent"
// AgentFromCtx returns the authenticated agent name, or "" for console users.
func AgentFromCtx(ctx context.Context) string {
v, _ := ctx.Value(ctxAgent).(string)
return v
}
type Server struct {
cfg config.Config
store *store.Store
agents *AgentAuthenticator
mux *http.ServeMux
broker *Broker
emb embed.Embedder // nil = search degrades to keyword-only
docSync *docs.Syncer // nil = artifact indexing disabled
audit *audit.Runner // nil = audit endpoints unavailable
}
func New(cfg config.Config, st *store.Store) *Server {
s := &Server{
cfg: cfg,
store: st,
agents: &AgentAuthenticator{st: st},
mux: http.NewServeMux(),
broker: NewBroker(),
}
s.routes()
return s
}
func (s *Server) Handler() http.Handler { return s.authMiddleware(s.mux) }
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Agent API
s.mux.HandleFunc("GET /api/v1/agent/tasks/next", s.handleAgentNext)
s.mux.HandleFunc("GET /api/v1/agent/tasks/{id}/instruction", s.handleAgentInstruction)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/claim", s.handleAgentClaim)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/heartbeat", s.handleAgentHeartbeat)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/events", s.handleAgentEvent)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/artifacts", s.handleAgentArtifact)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/advance-request", s.handleAgentAdvanceRequest)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/decisions", s.handleAgentDecision)
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/release", s.handleAgentRelease)
s.mux.HandleFunc("POST /api/v1/agent/reports", s.handleAgentReport)
// Console API
s.mux.HandleFunc("POST /api/v1/plans", s.handlePlanCreate)
s.mux.HandleFunc("GET /api/v1/plans", s.handlePlanList)
s.mux.HandleFunc("PATCH /api/v1/plans/{id}", s.handlePlanPatch)
s.mux.HandleFunc("POST /api/v1/tasks", s.handleTaskCreate)
s.mux.HandleFunc("GET /api/v1/tasks", s.handleTaskList)
s.mux.HandleFunc("GET /api/v1/tasks/{id}", s.handleTaskGet)
s.mux.HandleFunc("PATCH /api/v1/tasks/{id}", s.handleTaskPatch)
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/advance", s.handleTaskAdvance)
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/send-back", s.handleTaskSendBack)
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/{stage}/external-submit", s.handleTaskExternalSubmit)
s.mux.HandleFunc("GET /api/v1/tasks/{id}/pipeline", s.handleTaskPipeline)
s.mux.HandleFunc("POST /api/v1/tasks/{id}/export-doc", s.handleTaskExportDoc)
s.mux.HandleFunc("GET /api/v1/decisions", s.handleDecisionList)
s.mux.HandleFunc("POST /api/v1/decisions/{id}/resolve", s.handleDecisionResolve)
s.mux.HandleFunc("GET /api/v1/stage-settings", s.handleStageSettingsGet)
s.mux.HandleFunc("PUT /api/v1/stage-settings", s.handleStageSettingsPut)
s.mux.HandleFunc("GET /api/v1/overview", s.handleOverview)
s.mux.HandleFunc("GET /api/v1/stream", s.handleStream)
s.mux.HandleFunc("GET /api/v1/docs", s.handleDocList)
s.mux.HandleFunc("GET /api/v1/docs/search", s.handleDocSearch)
s.mux.HandleFunc("GET /api/v1/docs/{id}", s.handleDocGet)
s.mux.HandleFunc("GET /api/v1/audits/runs", s.handleAuditRunList)
s.mux.HandleFunc("GET /api/v1/audits/findings", s.handleAuditFindingList)
s.mux.HandleFunc("POST /api/v1/audits/run", s.handleAuditRun)
s.mux.HandleFunc("POST /api/v1/audits/findings/{id}/convert", s.handleAuditFindingConvert)
s.mux.HandleFunc("POST /api/v1/audits/findings/{id}/dismiss", s.handleAuditFindingDismiss)
// SPA (embedded build of control-center/web); auth middleware already
// passes non-/api paths through.
s.mux.Handle("GET /", webui.Handler())
}
// SetDocs attaches the embedding client and document syncer used by the
// /api/v1/docs search routes and asynchronous artifact indexing.
func (s *Server) SetDocs(emb embed.Embedder, syncer *docs.Syncer) {
s.emb = emb
s.docSync = syncer
}
// indexArtifactAsync indexes an artifact's markdown in the background;
// failures are logged only.
func (s *Server) indexArtifactAsync(artifactID string) {
if s.docSync == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := s.docSync.IndexArtifact(ctx, artifactID); err != nil {
slog.Error("artifact indexing", "artifact", artifactID, "err", err)
}
}()
}
func (s *Server) Store() *store.Store { return s.store }
func (s *Server) Config() config.Config { return s.cfg }
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if p == "/healthz" || !strings.HasPrefix(p, "/api/") {
next.ServeHTTP(w, r)
return
}
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return
}
if strings.HasPrefix(p, "/api/v1/agent/") {
name, ok := s.agents.Authenticate(r.Context(), token)
if !ok {
writeError(w, http.StatusUnauthorized, "invalid agent token")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxAgent, name)))
return
}
if subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.ConsoleToken)) != 1 {
writeError(w, http.StatusUnauthorized, "invalid console token")
return
}
next.ServeHTTP(w, r)
})
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if rest, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(rest)
}
if q := r.URL.Query().Get("token"); q != "" && !strings.HasPrefix(r.URL.Path, "/api/") {
return q
}
return ""
}
func TokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("write json", "err", err)
}
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
@@ -1,261 +0,0 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"chat-agent-system/internal/controlcenter/config"
"chat-agent-system/internal/controlcenter/store"
)
const (
testConsoleToken = "test-console-token"
testAgentToken = "test-agent-token-a"
testAgentTokenB = "test-agent-token-b"
)
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
t.Helper()
dsn := os.Getenv("TEST_CC_DB_DSN")
if dsn == "" {
t.Skip("TEST_CC_DB_DSN not set")
}
_, thisFile, _, _ := runtime.Caller(0)
migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
ctx := t.Context()
require.NoError(t, store.Migrate(ctx, dsn, migrationsDir))
st, err := store.Connect(ctx, dsn)
require.NoError(t, err)
t.Cleanup(st.Close)
require.NoError(t, RegisterAgent(ctx, st, "agent-a", "test", testAgentToken))
require.NoError(t, RegisterAgent(ctx, st, "agent-b", "test", testAgentTokenB))
srv := New(config.Config{ConsoleToken: testConsoleToken}, st)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, st
}
func doReq(t *testing.T, method, url, token string, body any) (int, []byte) {
t.Helper()
var rdr io.Reader
if body != nil {
raw, err := json.Marshal(body)
require.NoError(t, err)
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, url, rdr)
require.NoError(t, err)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, raw
}
func decodeBody(t *testing.T, raw []byte) map[string]any {
t.Helper()
var m map[string]any
require.NoError(t, json.Unmarshal(raw, &m))
return m
}
func slug(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
// createAssignedTask makes a task and assigns it to agent-a, returning its id.
func createAssignedTask(t *testing.T, ts *httptest.Server, testingRequired bool) string {
t.Helper()
code, raw := doReq(t, "POST", ts.URL+"/api/v1/tasks", testConsoleToken, map[string]any{
"slug": slug("it"), "title": "integration test task", "testing_required": testingRequired,
})
require.Equal(t, http.StatusCreated, code, string(raw))
id := decodeBody(t, raw)["id"].(string)
code, raw = doReq(t, "PATCH", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, map[string]any{"assign": "agent-a"})
require.Equal(t, http.StatusOK, code, string(raw))
return id
}
func claimTask(t *testing.T, ts *httptest.Server, id string) {
t.Helper()
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/claim", testAgentToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
require.Equal(t, "in_progress", decodeBody(t, raw)["stage_state"])
}
func TestTaskListQueryFilter(t *testing.T) {
ts, _ := newTestServer(t)
s := slug("qfilter")
code, raw := doReq(t, "POST", ts.URL+"/api/v1/tasks", testConsoleToken, map[string]any{
"slug": s, "title": "query filter regression task",
})
require.Equal(t, http.StatusCreated, code, string(raw))
code, raw = doReq(t, "GET", ts.URL+"/api/v1/tasks?q="+s, testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
var list []map[string]any
require.NoError(t, json.Unmarshal(raw, &list))
require.Len(t, list, 1)
assert.Equal(t, s, list[0]["slug"])
}
func TestAgentHasNoAdvanceRoute(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
claimTask(t, ts, id)
code, _ := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/stages/advance", testAgentToken, map[string]any{})
// The SPA catch-all "GET /" makes the mux answer 405 (Allow: GET) for
// unmatched POST paths; either way the agent must have no advance route.
assert.Contains(t, []int{http.StatusNotFound, http.StatusMethodNotAllowed}, code,
"agents must not have any stage-advance route")
// And the task is untouched.
code, raw := doReq(t, "GET", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "in_progress", decodeBody(t, raw)["stage_state"])
}
func TestAdvanceRequestGate(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
claimTask(t, ts, id)
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/advance-request", testAgentToken,
map[string]any{"summary": "proposal ready"})
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, "awaiting_review", m["stage_state"])
assert.Equal(t, "proposal", m["stage"], "advance-request must never move the stage")
// Second request is rejected: no longer in_progress.
code, _ = doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/advance-request", testAgentToken,
map[string]any{"summary": "again"})
assert.Equal(t, http.StatusConflict, code)
}
func TestDecisionValidation(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
claimTask(t, ts, id)
url := ts.URL + "/api/v1/agent/tasks/" + id + "/decisions"
code, _ := doReq(t, "POST", url, testAgentToken, map[string]any{"question": "", "suggestions": []any{map[string]any{"option": "a", "rationale": "b", "impact": "c"}}})
assert.Equal(t, http.StatusBadRequest, code, "empty question")
code, _ = doReq(t, "POST", url, testAgentToken, map[string]any{"question": "q", "suggestions": []any{}})
assert.Equal(t, http.StatusBadRequest, code, "no suggestions")
code, _ = doReq(t, "POST", url, testAgentToken, map[string]any{
"question": "q",
"suggestions": []any{map[string]any{"option": "a", "rationale": "", "impact": "c"}},
})
assert.Equal(t, http.StatusBadRequest, code, "suggestion missing rationale")
}
func TestClaimConflict(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
// Assigned to agent-a; agent-b must not claim it.
code, _ := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/claim", testAgentTokenB, nil)
assert.Equal(t, http.StatusConflict, code)
claimTask(t, ts, id)
// Re-claim while the lease is held is a conflict too.
code, _ = doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/claim", testAgentToken, nil)
assert.Equal(t, http.StatusConflict, code)
}
func TestDecisionResolveUnblocks(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, true)
claimTask(t, ts, id)
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/decisions", testAgentToken, map[string]any{
"question": "which option?",
"suggestions": []any{
map[string]any{"option": "A", "rationale": "simpler", "impact": "less code"},
map[string]any{"option": "B", "rationale": "flexible", "impact": "more code"},
},
})
require.Equal(t, http.StatusCreated, code, string(raw))
decisionID := decodeBody(t, raw)["id"].(string)
code, raw = doReq(t, "GET", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
require.Equal(t, "blocked_pending_decision", decodeBody(t, raw)["status"])
// Resolve by picking suggestion 1.
code, raw = doReq(t, "POST", ts.URL+"/api/v1/decisions/"+decisionID+"/resolve", testConsoleToken,
map[string]any{"suggestion_index": 1})
require.Equal(t, http.StatusOK, code, string(raw))
assert.Equal(t, "resolved", decodeBody(t, raw)["status"])
assert.Equal(t, "B", decodeBody(t, raw)["resolution"])
code, raw = doReq(t, "GET", ts.URL+"/api/v1/tasks/"+id, testConsoleToken, nil)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "in_progress", decodeBody(t, raw)["status"], "resolve unblocks the task")
}
func TestConsoleAdvanceSkipsTestingAndFinishes(t *testing.T) {
ts, _ := newTestServer(t)
id := createAssignedTask(t, ts, false)
claimTask(t, ts, id)
expectedStages := []string{"analysis", "design", "review", "implementation", "evaluation", "confirmation"}
for _, want := range expectedStages {
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/advance-request", testAgentToken,
map[string]any{"summary": "ready"})
require.Equal(t, http.StatusOK, code, string(raw))
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id+"/stages/advance", testConsoleToken,
map[string]any{"note": "ok"})
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, false, m["done"])
assert.Equal(t, want, m["task"].(map[string]any)["stage"])
assert.Equal(t, "assigned", m["task"].(map[string]any)["stage_state"])
code, raw = doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/claim", testAgentToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
}
// Final advance out of confirmation finishes the task.
code, raw := doReq(t, "POST", ts.URL+"/api/v1/agent/tasks/"+id+"/advance-request", testAgentToken,
map[string]any{"summary": "ready"})
require.Equal(t, http.StatusOK, code, string(raw))
code, raw = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id+"/stages/advance", testConsoleToken, nil)
require.Equal(t, http.StatusOK, code, string(raw))
m := decodeBody(t, raw)
assert.Equal(t, true, m["done"])
task := m["task"].(map[string]any)
assert.Equal(t, "done", task["status"])
assert.Equal(t, "approved", task["stage_state"])
// Console advance from a wrong state is rejected.
id2 := createAssignedTask(t, ts, false)
code, _ = doReq(t, "POST", ts.URL+"/api/v1/tasks/"+id2+"/stages/advance", testConsoleToken, nil)
assert.Equal(t, http.StatusConflict, code, "advance requires awaiting_review")
}
-43
View File
@@ -1,43 +0,0 @@
package server
import (
"fmt"
"net/http"
"time"
)
const sseHeartbeatInterval = 30 * time.Second
// handleStream serves the SSE feed of task events.
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
ch := s.broker.Subscribe()
defer s.broker.Unsubscribe(ch)
heartbeat := time.NewTicker(sseHeartbeatInterval)
defer heartbeat.Stop()
fmt.Fprintf(w, ": connected\n\n")
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case raw := <-ch:
fmt.Fprintf(w, "data: %s\n\n", raw)
flusher.Flush()
case <-heartbeat.C:
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
}
}
}
@@ -1,436 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"chat-agent-system/internal/controlcenter/pipeline"
"chat-agent-system/internal/controlcenter/tasks"
)
// --- Agent API ---
func (s *Server) handleAgentNext(w http.ResponseWriter, r *http.Request) {
agent := AgentFromCtx(r.Context())
t, err := tasks.NextForAgent(r.Context(), s.store, agent)
if err != nil {
writeDomainError(w, err)
return
}
if t == nil {
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleAgentInstruction(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
t, err := tasks.GetTask(r.Context(), s.store, id)
if err != nil {
writeDomainError(w, err)
return
}
model, executor := "", "agent"
if setting, err := tasks.ResolveStageSetting(r.Context(), s.store, t.ID, t.Stage); err != nil {
writeDomainError(w, err)
return
} else if setting != nil {
model = setting.Model
executor = setting.Executor
}
events, err := tasks.RecentEvents(r.Context(), s.store, t.ID, 20)
if err != nil {
writeDomainError(w, err)
return
}
allArtifacts, err := tasks.ListArtifacts(r.Context(), s.store, t.ID, "", false)
if err != nil {
writeDomainError(w, err)
return
}
prior := []tasks.Artifact{}
for _, a := range allArtifacts {
if stageIndex(a.Stage) < stageIndex(t.Stage) {
prior = append(prior, a)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"task": t,
"stage": t.Stage,
"stage_state": t.StageState,
"deliverable": pipeline.DeliverableSpec(t.Stage),
"model": model,
"executor": executor,
"decision_rules": pipeline.DecisionRules,
"recent_events": events,
"prior_artifacts": prior,
})
}
func stageIndex(stage string) int {
for i, st := range pipeline.Stages {
if st == stage {
return i
}
}
return len(pipeline.Stages)
}
func (s *Server) handleAgentClaim(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agent := AgentFromCtx(r.Context())
t, err := tasks.Claim(r.Context(), s.store, id, agent)
if err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), t.ID, agent, pipeline.PhaseStart, "claimed", map[string]any{"stage": t.Stage})
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleAgentHeartbeat(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agent := AgentFromCtx(r.Context())
t, err := tasks.Heartbeat(r.Context(), s.store, id, agent)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleAgentRelease(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agent := AgentFromCtx(r.Context())
t, err := tasks.Release(r.Context(), s.store, id, agent)
if err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), t.ID, agent, pipeline.PhaseSystem, "lease released", nil)
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleAgentEvent(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agent := AgentFromCtx(r.Context())
var body struct {
Phase string `json:"phase"`
Message string `json:"message"`
Payload json.RawMessage `json:"payload"`
}
if !decodeJSON(w, r, &body) {
return
}
if !pipeline.ValidPhase(body.Phase) {
writeError(w, http.StatusBadRequest, "invalid phase")
return
}
if _, err := tasks.GetTask(r.Context(), s.store, id); err != nil {
writeDomainError(w, err)
return
}
var payload any
if len(body.Payload) > 0 {
payload = json.RawMessage(body.Payload)
}
e, err := tasks.AppendEvent(r.Context(), s.store, id, agent, body.Phase, body.Message, payload)
if err != nil {
writeDomainError(w, err)
return
}
s.broker.Publish(e)
writeJSON(w, http.StatusCreated, e)
}
func (s *Server) handleAgentArtifact(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agent := AgentFromCtx(r.Context())
var body struct {
Kind string `json:"kind"`
Title string `json:"title"`
ContentMD string `json:"content_md"`
Stage string `json:"stage"`
}
if !decodeJSON(w, r, &body) {
return
}
a, err := tasks.AddArtifact(r.Context(), s.store, id, body.Stage, body.Kind, body.Title, body.ContentMD, "agent", agent)
if err != nil {
writeDomainError(w, err)
return
}
s.indexArtifactAsync(a.ID)
writeJSON(w, http.StatusCreated, map[string]string{"id": a.ID})
}
func (s *Server) handleAgentAdvanceRequest(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agent := AgentFromCtx(r.Context())
var body struct {
Summary string `json:"summary"`
ArtifactIDs []string `json:"artifact_ids"`
}
if !decodeJSON(w, r, &body) {
return
}
t, err := tasks.AdvanceRequest(r.Context(), s.store, id, agent)
if err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), t.ID, agent, pipeline.PhaseAdvanceRequest, body.Summary, map[string]any{
"summary": body.Summary,
"artifact_ids": body.ArtifactIDs,
})
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleAgentDecision(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agent := AgentFromCtx(r.Context())
var body struct {
Question string `json:"question"`
ContextMD string `json:"context_md"`
Suggestions []tasks.Suggestion `json:"suggestions"`
}
if !decodeJSON(w, r, &body) {
return
}
if err := tasks.ValidateSuggestionSet(body.Question, body.Suggestions); err != nil {
writeDomainError(w, err)
return
}
d, err := tasks.AddDecision(r.Context(), s.store, id, "", body.Question, body.ContextMD, body.Suggestions)
if err != nil {
writeDomainError(w, err)
return
}
if err := tasks.BlockOnDecision(r.Context(), s.store, id); err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), id, agent, pipeline.PhaseDecision, body.Question, map[string]any{
"decision_id": d.ID,
"question": body.Question,
})
writeJSON(w, http.StatusCreated, d)
}
func (s *Server) handleAgentReport(w http.ResponseWriter, r *http.Request) {
agent := AgentFromCtx(r.Context())
var body struct {
TaskID string `json:"task_id"`
Kind string `json:"kind"`
ContentMD string `json:"content_md"`
Links []string `json:"links"`
}
if !decodeJSON(w, r, &body) {
return
}
rep, err := tasks.AddReport(r.Context(), s.store, body.TaskID, agent, body.Kind, body.ContentMD, body.Links)
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusCreated, rep)
}
// --- Console API: tasks ---
func (s *Server) handleTaskCreate(w http.ResponseWriter, r *http.Request) {
var body struct {
Slug string `json:"slug"`
Title string `json:"title"`
Kind string `json:"kind"`
PlanID *string `json:"plan_id"`
Priority int `json:"priority"`
TestingRequired *bool `json:"testing_required"`
InstructionMD string `json:"instruction_md"`
DocPath string `json:"doc_path"`
Mutex string `json:"mutex"`
}
if !decodeJSON(w, r, &body) {
return
}
testingRequired := true
if body.TestingRequired != nil {
testingRequired = *body.TestingRequired
}
t, err := tasks.CreateTask(r.Context(), s.store, &tasks.Task{
Slug: body.Slug,
PlanID: body.PlanID,
Title: body.Title,
Kind: body.Kind,
TestingRequired: testingRequired,
Priority: body.Priority,
InstructionMD: body.InstructionMD,
DocPath: body.DocPath,
Mutex: body.Mutex,
})
if err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), t.ID, "", pipeline.PhaseSystem, "task created", nil)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handleTaskList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
out, err := tasks.ListTasks(r.Context(), s.store, tasks.ListFilter{
Status: q.Get("status"),
Stage: q.Get("stage"),
PlanID: q.Get("plan_id"),
Query: q.Get("q"),
})
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleTaskGet(w http.ResponseWriter, r *http.Request) {
t, err := tasks.GetTask(r.Context(), s.store, r.PathValue("id"))
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleTaskPatch(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Title *string `json:"title"`
Priority *int `json:"priority"`
InstructionMD *string `json:"instruction_md"`
TestingRequired *bool `json:"testing_required"`
Owner *string `json:"owner"`
Mutex *string `json:"mutex"`
Branch *string `json:"branch"`
Worktree *string `json:"worktree"`
PlanID *string `json:"plan_id"`
Assign string `json:"assign"`
}
if !decodeJSON(w, r, &body) {
return
}
t, err := tasks.PatchTask(r.Context(), s.store, id, tasks.Patch{
Title: body.Title,
Priority: body.Priority,
InstructionMD: body.InstructionMD,
TestingRequired: body.TestingRequired,
Owner: body.Owner,
Mutex: body.Mutex,
Branch: body.Branch,
Worktree: body.Worktree,
PlanID: body.PlanID,
})
if err != nil {
writeDomainError(w, err)
return
}
if body.Assign != "" {
t, err = tasks.AssignTask(r.Context(), s.store, id, body.Assign)
if err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), id, "", pipeline.PhaseSystem, "assigned to "+body.Assign,
map[string]any{"agent": body.Assign})
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleTaskAdvance(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Note string `json:"note"`
}
if !decodeJSON(w, r, &body) {
return
}
t, done, err := tasks.AdvanceStage(r.Context(), s.store, id, "console", "console", body.Note,
[]string{pipeline.StateAwaitingReview})
if err != nil {
writeDomainError(w, err)
return
}
msg := "advanced to " + t.Stage
if done {
msg = "confirmed; task done"
}
s.appendEvent(r.Context(), id, "", pipeline.PhaseSystem, msg, map[string]any{
"to_stage": t.Stage, "done": done, "note": body.Note,
})
writeJSON(w, http.StatusOK, map[string]any{"task": t, "done": done})
}
func (s *Server) handleTaskSendBack(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Note string `json:"note"`
}
if !decodeJSON(w, r, &body) {
return
}
t, err := tasks.SendBack(r.Context(), s.store, id, "console", "console", body.Note)
if err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), id, "", pipeline.PhaseSystem, "sent back: "+body.Note,
map[string]any{"stage": t.Stage, "note": body.Note})
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleTaskExternalSubmit(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
stage := r.PathValue("stage")
var body struct {
Title string `json:"title"`
ContentMD string `json:"content_md"`
Kind string `json:"kind"`
Advance bool `json:"advance"`
Note string `json:"note"`
}
if !decodeJSON(w, r, &body) {
return
}
a, err := tasks.ExternalSubmit(r.Context(), s.store, id, stage, body.Kind, body.Title, body.ContentMD)
if err != nil {
writeDomainError(w, err)
return
}
s.indexArtifactAsync(a.ID)
resp := map[string]any{"artifact": a, "advanced": false}
if body.Advance {
t, done, err := tasks.AdvanceStage(r.Context(), s.store, id, "external", "user-external", body.Note,
[]string{pipeline.StateInProgress, pipeline.StateAssigned, pipeline.StateAwaitingReview})
if err != nil {
writeDomainError(w, err)
return
}
s.appendEvent(r.Context(), id, "", pipeline.PhaseSystem,
"external submit advanced to "+t.Stage, map[string]any{"to_stage": t.Stage, "done": done})
resp["advanced"] = true
resp["task"] = t
resp["done"] = done
}
writeJSON(w, http.StatusCreated, resp)
}
func (s *Server) handleTaskPipeline(w http.ResponseWriter, r *http.Request) {
view, err := tasks.GetPipelineView(r.Context(), s.store, r.PathValue("id"))
if err != nil {
writeDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
-58
View File
@@ -1,58 +0,0 @@
// Package store owns the Postgres connection pool and migrations for the
// Control Center database.
package store
import (
"context"
"database/sql"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
)
type Store struct {
Pool *pgxpool.Pool
}
func Connect(ctx context.Context, dsn string) (*Store, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("connect control-center db: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping control-center db: %w", err)
}
return &Store{Pool: pool}, nil
}
func (s *Store) Close() { s.Pool.Close() }
// Migrate applies goose migrations from dir.
func Migrate(ctx context.Context, dsn, dir string) error {
db := stdlib.OpenDBFromPool(mustPool(ctx, dsn))
defer db.Close()
return MigrateDB(db, dir)
}
// MigrateDB applies goose migrations on an existing *sql.DB.
func MigrateDB(db *sql.DB, dir string) error {
goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
if err := goose.Up(db, dir); err != nil {
return fmt.Errorf("goose up %s: %w", dir, err)
}
return nil
}
func mustPool(ctx context.Context, dsn string) *pgxpool.Pool {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
panic(fmt.Errorf("connect for migrate: %w", err))
}
return pool
}
@@ -1,45 +0,0 @@
package store
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestConnectRejectsUnreachableDSN(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := Connect(ctx, "postgres://cc:cc@127.0.0.1:1/control_center?sslmode=disable&connect_timeout=1")
if err == nil {
t.Fatal("expected connect error")
}
}
func TestMigrateAndQuery(t *testing.T) {
dsn := os.Getenv("TEST_CC_DB_DSN")
if dsn == "" {
t.Skip("TEST_CC_DB_DSN not set")
}
_, thisFile, _, _ := runtime.Caller(0)
dir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "deploy", "control-center", "goose")
ctx := context.Background()
if err := Migrate(ctx, dsn, dir); err != nil {
t.Fatal(err)
}
st, err := Connect(ctx, dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(st.Close)
var n int
if err := st.Pool.QueryRow(ctx, `SELECT count(*) FROM cc_tasks`).Scan(&n); err != nil {
t.Fatal(err)
}
if n < 0 {
t.Fatalf("count = %d", n)
}
}
-167
View File
@@ -1,167 +0,0 @@
package tasks
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"chat-agent-system/internal/controlcenter/store"
)
type Suggestion struct {
Option string `json:"option"`
Rationale string `json:"rationale"`
Impact string `json:"impact"`
}
type Decision struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Stage string `json:"stage"`
Question string `json:"question"`
ContextMD string `json:"context_md"`
Suggestions []Suggestion `json:"suggestions"`
Status string `json:"status"`
Resolution string `json:"resolution"`
DecidedBy string `json:"decided_by"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
// ValidateSuggestionSet enforces the strict decision-report contract.
func ValidateSuggestionSet(question string, suggestions []Suggestion) error {
if question == "" {
return fmt.Errorf("%w: question is required", ErrBadInput)
}
if len(suggestions) == 0 {
return fmt.Errorf("%w: at least one suggestion is required", ErrBadInput)
}
for i, s := range suggestions {
if s.Option == "" || s.Rationale == "" || s.Impact == "" {
return fmt.Errorf("%w: suggestion %d needs option, rationale and impact", ErrBadInput, i)
}
}
return nil
}
func AddDecision(ctx context.Context, st *store.Store, taskID, stage, question, contextMD string, suggestions []Suggestion) (*Decision, error) {
if err := ValidateSuggestionSet(question, suggestions); err != nil {
return nil, err
}
t, err := GetTask(ctx, st, taskID)
if err != nil {
return nil, err
}
if stage == "" {
stage = t.Stage
}
raw, err := json.Marshal(suggestions)
if err != nil {
return nil, err
}
var d Decision
var suggRaw json.RawMessage
err = st.Pool.QueryRow(ctx,
`INSERT INTO cc_decisions (task_id, stage, question, context_md, suggestions)
VALUES ($1,$2,$3,$4,$5)
RETURNING id, task_id, stage, question, context_md, suggestions, status, resolution, decided_by, created_at, resolved_at`,
taskID, stage, question, contextMD, raw).
Scan(&d.ID, &d.TaskID, &d.Stage, &d.Question, &d.ContextMD, &suggRaw, &d.Status, &d.Resolution, &d.DecidedBy, &d.CreatedAt, &d.ResolvedAt)
if err != nil {
return nil, err
}
if err := json.Unmarshal(suggRaw, &d.Suggestions); err != nil {
return nil, err
}
return &d, nil
}
type DecisionItem struct {
Decision
TaskSlug string `json:"task_slug"`
TaskTitle string `json:"task_title"`
}
// ListDecisions filters by status: open, resolved, or all.
func ListDecisions(ctx context.Context, st *store.Store, status string) ([]DecisionItem, error) {
q := `SELECT d.id, d.task_id, d.stage, d.question, d.context_md, d.suggestions,
d.status, d.resolution, d.decided_by, d.created_at, d.resolved_at,
t.slug, t.title
FROM cc_decisions d JOIN cc_tasks t ON t.id = d.task_id`
args := []any{}
switch status {
case "open", "resolved":
args = append(args, status)
q += ` WHERE d.status = $1`
case "", "all":
default:
return nil, fmt.Errorf("%w: status must be open, resolved or all", ErrBadInput)
}
q += ` ORDER BY d.created_at DESC`
rows, err := st.Pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DecisionItem{}
for rows.Next() {
var d DecisionItem
var suggRaw json.RawMessage
if err := rows.Scan(&d.ID, &d.TaskID, &d.Stage, &d.Question, &d.ContextMD, &suggRaw,
&d.Status, &d.Resolution, &d.DecidedBy, &d.CreatedAt, &d.ResolvedAt,
&d.TaskSlug, &d.TaskTitle); err != nil {
return nil, err
}
if err := json.Unmarshal(suggRaw, &d.Suggestions); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// ResolveDecision settles an open decision either with a free-form resolution
// or by picking one of the recorded suggestions.
func ResolveDecision(ctx context.Context, st *store.Store, id, resolution string, suggestionIndex *int, decidedBy string) (*Decision, error) {
if resolution == "" && suggestionIndex == nil {
return nil, fmt.Errorf("%w: resolution or suggestion_index is required", ErrBadInput)
}
var d Decision
var suggRaw json.RawMessage
err := st.Pool.QueryRow(ctx,
`SELECT id, task_id, stage, question, context_md, suggestions, status, resolution, decided_by, created_at, resolved_at
FROM cc_decisions WHERE id = $1`, id).
Scan(&d.ID, &d.TaskID, &d.Stage, &d.Question, &d.ContextMD, &suggRaw, &d.Status, &d.Resolution, &d.DecidedBy, &d.CreatedAt, &d.ResolvedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if d.Status != "open" {
return nil, fmt.Errorf("%w: decision already resolved", ErrBadState)
}
if err := json.Unmarshal(suggRaw, &d.Suggestions); err != nil {
return nil, err
}
if suggestionIndex != nil {
if *suggestionIndex < 0 || *suggestionIndex >= len(d.Suggestions) {
return nil, fmt.Errorf("%w: suggestion_index out of range", ErrBadInput)
}
resolution = d.Suggestions[*suggestionIndex].Option
}
err = st.Pool.QueryRow(ctx,
`UPDATE cc_decisions SET status = 'resolved', resolution = $2, decided_by = $3, resolved_at = now()
WHERE id = $1 RETURNING resolved_at`, id, resolution, decidedBy).Scan(&d.ResolvedAt)
if err != nil {
return nil, err
}
d.Status = "resolved"
d.Resolution = resolution
d.DecidedBy = decidedBy
return &d, nil
}
-58
View File
@@ -1,58 +0,0 @@
package tasks
import (
"regexp"
"strconv"
"strings"
"chat-agent-system/internal/controlcenter/pipeline"
)
// docSlugRe constrains task slugs that may be exported to .doc files; it
// also makes path traversal impossible by construction.
var docSlugRe = regexp.MustCompile(`^[a-z0-9-]+$`)
// ValidDocSlug reports whether slug is safe to use in a .doc file name.
func ValidDocSlug(slug string) bool { return docSlugRe.MatchString(slug) }
// DocStatus maps a cc_tasks status back onto the .doc front-matter space.
func DocStatus(status string) string {
if status == pipeline.StatusBlockedDecision {
return "blocked"
}
return status
}
// RenderTaskDoc renders a task as a .doc short-term task document following
// .doc/ops/templates/short-term-task.md. today is the YYYY-MM-DD date used
// for created/updated. The task's instruction_md becomes the body; when
// empty the template skeleton is used.
func RenderTaskDoc(t *Task, today string) string {
orDash := func(s string) string {
if strings.TrimSpace(s) == "" {
return "-"
}
return s
}
var b strings.Builder
b.WriteString("---\n")
b.WriteString("title: " + strconv.Quote(t.Title) + "\n")
b.WriteString("status: " + DocStatus(t.Status) + "\n")
b.WriteString("created: " + today + "\n")
b.WriteString("updated: " + today + "\n")
b.WriteString("owner: " + orDash(t.Owner) + "\n")
b.WriteString("branch: " + orDash(t.Branch) + "\n")
b.WriteString("worktree: " + strconv.Quote(orDash(t.Worktree)) + "\n")
b.WriteString("mutex: " + orDash(t.Mutex) + "\n")
b.WriteString("---\n\n")
body := strings.TrimSpace(t.InstructionMD)
if body == "" {
body = "## 目标\n\n(一句话可验证目标)\n\n## 范围\n\n- \n\n## 非目标\n\n- \n\n" +
"## 步骤\n\n1. [ ] \n\n## 验收标准\n\n- [ ] \n\n## 相关链接\n\n" +
"- Checklist: \n- Handoff: \n- Test report: \n\n## 证据 / 备注\n\n(命令输出、commit、文件路径)"
}
b.WriteString("# " + t.Title + "\n\n")
b.WriteString(body + "\n")
return b.String()
}

Some files were not shown because too many files have changed in this diff Show More