feat: add safe agent writeback flow
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
## Target Architecture
|
||||
|
||||
计划架构为:Go 后端提供 localhost HTTP API,Vue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查、`.codex/agents/*.toml` 只读读取、项目配置只读读取、运行线程只读模型和动态工作流事件模型;前端已通过集中 API client 接入这些只读端点并显示加载中、连接失败和空数据状态。目标后端后续仅在用户确认后写回 `.codex/agents/*.toml`。
|
||||
计划架构为:Go 后端提供 localhost HTTP API,Vue 3 + Vite 前端提供中文工作台界面。当前后端已提供健康检查、`.codex/agents/*.toml` 读取、项目配置只读读取、运行线程只读模型、动态工作流事件模型,以及单个智能体 TOML 的草稿校验和确认写回;前端已通过集中 API client 接入这些端点并显示加载中、连接失败、空数据和写回状态。
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -37,6 +37,22 @@ curl http://127.0.0.1:18083/api/health
|
||||
curl http://127.0.0.1:18083/api/agents
|
||||
```
|
||||
|
||||
校验智能体草稿,不写文件:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:18083/api/agents/backend/validate \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content":"name = \"后端架构师\"\n"}'
|
||||
```
|
||||
|
||||
创建备份并写回单个智能体文件:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:18083/api/agents/backend/write \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content":"name = \"后端架构师\"\n","expectedHash":"<validate 返回的 currentHash>"}'
|
||||
```
|
||||
|
||||
读取项目配置:
|
||||
|
||||
```bash
|
||||
@@ -71,21 +87,26 @@ cd web
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
前端开发地址为 `http://127.0.0.1:13083/`。Phase 5 前端按视图调用只读 API:项目视图读取 `/api/projects` 和 `/api/runtime/threads`,工作流视图读取 `/api/workflow/events`,智能体视图读取 `/api/agents`。静态示例数据仅在接口连接失败时作为 fallback 展示,并必须明确标注“示例/等待连接”;真实接口返回空列表时展示空状态和来源证据,不回退到示例数据。前端可保留 `local_sample`、`api_missing` 等内部 source kind,但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。
|
||||
前端开发地址为 `http://127.0.0.1:13083/`。前端按视图调用 API:项目视图读取 `/api/projects` 和 `/api/runtime/threads`,工作流视图读取 `/api/workflow/events`,智能体视图读取 `/api/agents`,并仅对当前选中的智能体调用 `/api/agents/{id}/validate` 和 `/api/agents/{id}/write`。静态示例数据仅在接口连接失败时作为 fallback 展示,并必须明确标注“示例/等待连接”;真实接口返回空列表时展示空状态和来源证据,不回退到示例数据。前端可保留 `local_sample`、`api_missing` 等内部 source kind,但用户界面必须显示中文来源和 `高 / 中 / 低` 置信度。
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
- 不读取 `.codex/auth.json`。
|
||||
- 不写入 Codex SQLite。
|
||||
- 不读取或写入 `.codex/config.toml` 以外的项目只读解析;写回目标仅限 `CODEX_HOME/agents/{id}.toml` 直属文件。
|
||||
- SQLite 通过纯 Go `modernc.org/sqlite v1.35.0` 和 `mode=ro&immutable=1` 打开;缺失 SQLite 返回空列表和低置信度来源说明。
|
||||
- 运行线程 API 返回聚合 `source` 和分数据源 `sources`;仅 `state_5.sqlite` 或仅 `goals_1.sqlite` 缺失时,聚合来源为 `sqlite_partial` / `medium`,缺失的一侧为 `sqlite_missing` / `low`。
|
||||
- 运行线程读取使用 `PRAGMA table_info` 适配 schema drift;缺关键列时对应表返回空列表和 `sqlite_schema_drift` / `low` 证据,可选列缺失、NULL 和数值字段按空字符串或文本值处理。
|
||||
- `.codex/agents/*.toml` 写回必须先备份。
|
||||
- 当前 `/api/agents` 只读列出 `.codex/agents` 直属 `.toml` 文件,读取前通过 Codex home 边界和 agent TOML 专用 resolver;坏 TOML 以单条 `invalid` 状态返回,不导致服务崩溃。
|
||||
- `POST /api/agents/{id}/validate` 只校验请求体中的 TOML 草稿、生成 diff、返回目标路径、当前 sha256 hash 和字段变更;该端点不写文件。
|
||||
- `POST /api/agents/{id}/write` 会重新校验 TOML,重新读取目标文件并比较 `expectedHash`,先创建 `*.bak-<timestamp>` 备份,备份成功后通过同目录临时文件和 rename 原子写回。
|
||||
- 写回 id 只允许安全 bare file stem:字母、数字、下划线、连字符,且不能包含路径分隔符、绝对路径、`.toml` 扩展名或子目录。`agents` 目录 symlink、目标叶子 symlink、非普通文件和路径穿越都会被拒绝。
|
||||
- 无效 TOML、hash 冲突、备份失败或临时文件写入失败都不会替换原文件;临时文件会尽量清理。
|
||||
- 当前 `/api/projects` 只读解析 `.codex/config.toml` 中的 `[projects."..."]`,展示路径、显示名、信任等级和目录存在性。
|
||||
- 当前 `/api/workflow/events` 从运行线程、spawn edges、goals 和计划文件证据生成事件流/交接边/阶段状态,不写死固定流程。
|
||||
- Phase 4 前端不调用真实 API、不保存草稿、不写回 `.codex/agents/*.toml`;所有写回相关控件仅作为只读步骤展示。
|
||||
- Phase 5 前端只调用 GET 只读端点,不新增保存、草稿写入或后端写入逻辑。
|
||||
- Phase 6 前端仅在智能体视图提供单文件草稿入口,按钮为“校验 TOML”“查看差异”“创建备份并写回”;只有校验成功且存在 currentHash 时才允许写回,写回前使用浏览器确认。
|
||||
- Phase 5 前端 normalizer 负责把 source kind、confidence、状态和 parseStatus 转成中文显示,并过滤工作流中非阶段表格行。
|
||||
|
||||
## Known Risks
|
||||
|
||||
@@ -36,3 +36,6 @@
|
||||
- Phase 5 前端新增集中 API client,仅调用 `/api/agents`、`/api/projects`、`/api/runtime/threads`、`/api/workflow/events` 四个 GET 只读端点。
|
||||
- Phase 5 前端 normalizer 统一把后端 source kind、confidence、状态、parseStatus 转为中文展示;空 runtime/workflow 不回退到示例数据,连接失败时示例数据必须标注“示例/等待连接”。
|
||||
- 工作流 phases 需要在前端过滤计划文件中误解析出的非阶段表格行,并把数字阶段名展示为“阶段 N”,避免出现内部英文或无效状态。
|
||||
- Phase 6 写回只支持单个已有 agent TOML 文件,不创建新 agent;validate 返回当前 sha256,write 使用 expectedHash 阻止校验后覆盖用户改动。
|
||||
- Phase 6 备份文件写在目标文件旁边,命名为 `*.bak-<timestamp>`;只有备份创建成功后才使用同目录临时文件和 rename 替换目标。
|
||||
- Phase 6 前端不维护批量草稿队列,不使用 localStorage 自动保存草稿;写回入口在智能体视图,用户确认后才调用 write API。
|
||||
|
||||
@@ -6,6 +6,7 @@ type AgentDefinition struct {
|
||||
ID string `json:"id"`
|
||||
FilePath string `json:"filePath"`
|
||||
FileName string `json:"fileName"`
|
||||
Content string `json:"content"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DeveloperInstructions string `json:"developerInstructions"`
|
||||
@@ -15,3 +16,25 @@ type AgentDefinition struct {
|
||||
ParseError string `json:"parseError,omitempty"`
|
||||
DraftStatus string `json:"draftStatus"`
|
||||
}
|
||||
|
||||
type FieldChange struct {
|
||||
Field string `json:"field"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
}
|
||||
|
||||
type DraftValidation struct {
|
||||
Valid bool `json:"valid"`
|
||||
Errors []string `json:"errors"`
|
||||
Diff string `json:"diff"`
|
||||
TargetPath string `json:"targetPath"`
|
||||
CurrentHash string `json:"currentHash"`
|
||||
FieldChanges []FieldChange `json:"fieldChanges"`
|
||||
}
|
||||
|
||||
type WriteResult struct {
|
||||
Status string `json:"status"`
|
||||
TargetPath string `json:"targetPath"`
|
||||
BackupPath string `json:"backupPath"`
|
||||
CurrentHash string `json:"currentHash"`
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ func (s Store) readOne(fileName string) AgentDefinition {
|
||||
def.ParseError = err.Error()
|
||||
return def
|
||||
}
|
||||
def.Content = string(data)
|
||||
values, err := parseSimpleTOML(string(data))
|
||||
if err != nil {
|
||||
def.ParseStatus = "invalid"
|
||||
|
||||
228
internal/agents/writeback.go
Normal file
228
internal/agents/writeback.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codex-agent-manager/internal/codexhome"
|
||||
)
|
||||
|
||||
var ErrWriteConflict = errors.New("目标文件已在校验后发生变化")
|
||||
|
||||
var safeAgentID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
|
||||
|
||||
func (s Store) ValidateDraft(id string, content string) (DraftValidation, error) {
|
||||
targetPath, current, _, err := s.readWriteTarget(id)
|
||||
if err != nil {
|
||||
return DraftValidation{}, err
|
||||
}
|
||||
|
||||
result := DraftValidation{
|
||||
TargetPath: targetPath,
|
||||
CurrentHash: hashBytes(current),
|
||||
}
|
||||
|
||||
currentFields, currentErr := parseSimpleTOML(string(current))
|
||||
draftFields, draftErr := parseSimpleTOML(content)
|
||||
if draftErr != nil {
|
||||
result.Valid = false
|
||||
result.Errors = []string{draftErr.Error()}
|
||||
result.Diff = simpleDiff(string(current), content)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Valid = true
|
||||
result.Errors = []string{}
|
||||
result.Diff = simpleDiff(string(current), content)
|
||||
if currentErr == nil {
|
||||
result.FieldChanges = changedFields(currentFields, draftFields)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s Store) WriteDraft(id string, content string, expectedHash string) (WriteResult, error) {
|
||||
validation, err := s.ValidateDraft(id, content)
|
||||
if err != nil {
|
||||
return WriteResult{}, err
|
||||
}
|
||||
if !validation.Valid {
|
||||
return WriteResult{}, errors.New(strings.Join(validation.Errors, ";"))
|
||||
}
|
||||
if expectedHash == "" || validation.CurrentHash != expectedHash {
|
||||
return WriteResult{}, ErrWriteConflict
|
||||
}
|
||||
|
||||
targetPath, current, mode, err := s.readWriteTarget(id)
|
||||
if err != nil {
|
||||
return WriteResult{}, err
|
||||
}
|
||||
if hashBytes(current) != expectedHash {
|
||||
return WriteResult{}, ErrWriteConflict
|
||||
}
|
||||
|
||||
backupPath, err := s.createBackup(targetPath, current, mode)
|
||||
if err != nil {
|
||||
return WriteResult{}, err
|
||||
}
|
||||
if err := atomicWrite(targetPath, []byte(content), mode); err != nil {
|
||||
return WriteResult{}, err
|
||||
}
|
||||
|
||||
return WriteResult{
|
||||
Status: "written",
|
||||
TargetPath: targetPath,
|
||||
BackupPath: backupPath,
|
||||
CurrentHash: hashBytes([]byte(content)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Store) readWriteTarget(id string) (string, []byte, os.FileMode, error) {
|
||||
if !safeAgentID.MatchString(id) {
|
||||
return "", nil, 0, codexhome.ErrForbiddenPath
|
||||
}
|
||||
agentsPath := filepath.Join(s.CodexHome, "agents")
|
||||
if info, err := os.Lstat(agentsPath); err != nil {
|
||||
return "", nil, 0, err
|
||||
} else if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return "", nil, 0, codexhome.ErrForbiddenPath
|
||||
}
|
||||
|
||||
fileName := id + ".toml"
|
||||
targetPath, err := codexhome.ResolveAgentTOML(s.CodexHome, fileName)
|
||||
if err != nil {
|
||||
return "", nil, 0, err
|
||||
}
|
||||
info, err := os.Lstat(targetPath)
|
||||
if err != nil {
|
||||
return "", nil, 0, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return "", nil, 0, codexhome.ErrForbiddenPath
|
||||
}
|
||||
data, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
return "", nil, 0, err
|
||||
}
|
||||
return targetPath, data, info.Mode().Perm(), nil
|
||||
}
|
||||
|
||||
func (s Store) createBackup(targetPath string, content []byte, mode os.FileMode) (string, error) {
|
||||
backupPath := fmt.Sprintf("%s.bak-%s", targetPath, time.Now().UTC().Format("20060102T150405.000000000Z"))
|
||||
file, err := os.OpenFile(backupPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := file.Write(content); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(backupPath)
|
||||
return "", err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
_ = os.Remove(backupPath)
|
||||
return "", err
|
||||
}
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
func atomicWrite(targetPath string, content []byte, mode os.FileMode) error {
|
||||
dir := filepath.Dir(targetPath)
|
||||
base := filepath.Base(targetPath)
|
||||
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() {
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, targetPath)
|
||||
}
|
||||
|
||||
func hashBytes(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func changedFields(before map[string]string, after map[string]string) []FieldChange {
|
||||
keys := map[string]bool{}
|
||||
for key := range before {
|
||||
keys[key] = true
|
||||
}
|
||||
for key := range after {
|
||||
keys[key] = true
|
||||
}
|
||||
ordered := make([]string, 0, len(keys))
|
||||
for key := range keys {
|
||||
ordered = append(ordered, key)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
|
||||
changes := make([]FieldChange, 0)
|
||||
for _, key := range ordered {
|
||||
if before[key] == after[key] {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, FieldChange{Field: key, Before: before[key], After: after[key]})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func simpleDiff(before string, after string) string {
|
||||
if before == after {
|
||||
return "无差异\n"
|
||||
}
|
||||
beforeLines := splitLines(before)
|
||||
afterLines := splitLines(after)
|
||||
prefix := 0
|
||||
for prefix < len(beforeLines) && prefix < len(afterLines) && beforeLines[prefix] == afterLines[prefix] {
|
||||
prefix++
|
||||
}
|
||||
suffix := 0
|
||||
for suffix < len(beforeLines)-prefix &&
|
||||
suffix < len(afterLines)-prefix &&
|
||||
beforeLines[len(beforeLines)-1-suffix] == afterLines[len(afterLines)-1-suffix] {
|
||||
suffix++
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("--- current\n+++ draft\n@@\n")
|
||||
for _, line := range beforeLines[prefix : len(beforeLines)-suffix] {
|
||||
b.WriteString("-")
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, line := range afterLines[prefix : len(afterLines)-suffix] {
|
||||
b.WriteString("+")
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func splitLines(input string) []string {
|
||||
trimmed := strings.TrimSuffix(input, "\n")
|
||||
if trimmed == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Split(trimmed, "\n")
|
||||
}
|
||||
161
internal/agents/writeback_test.go
Normal file
161
internal/agents/writeback_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateInvalidTOMLReturnsInvalidAndDoesNotWrite(t *testing.T) {
|
||||
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
|
||||
|
||||
got, err := store.ValidateDraft("backend", `name = "未闭合`+"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDraft returned error: %v", err)
|
||||
}
|
||||
if got.Valid {
|
||||
t.Fatalf("validation should be invalid: %#v", got)
|
||||
}
|
||||
if len(got.Errors) == 0 {
|
||||
t.Fatalf("expected validation errors: %#v", got)
|
||||
}
|
||||
assertFileContent(t, target, `name = "旧名称"`+"\n")
|
||||
}
|
||||
|
||||
func TestValidateValidTOMLReturnsDiffAndCurrentHash(t *testing.T) {
|
||||
store, _ := writebackFixture(t, `name = "旧名称"`+"\ndescription = \"旧描述\"\n")
|
||||
|
||||
got, err := store.ValidateDraft("backend", `name = "新名称"`+"\ndescription = \"旧描述\"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDraft returned error: %v", err)
|
||||
}
|
||||
if !got.Valid {
|
||||
t.Fatalf("validation should be valid: %#v", got)
|
||||
}
|
||||
if got.CurrentHash == "" {
|
||||
t.Fatalf("current hash must be returned: %#v", got)
|
||||
}
|
||||
if !strings.Contains(got.Diff, `-name = "旧名称"`) || !strings.Contains(got.Diff, `+name = "新名称"`) {
|
||||
t.Fatalf("diff does not show changed line: %q", got.Diff)
|
||||
}
|
||||
if len(got.FieldChanges) != 1 || got.FieldChanges[0].Field != "name" {
|
||||
t.Fatalf("unexpected field changes: %#v", got.FieldChanges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteInvalidTOMLRejectsAndLeavesOriginal(t *testing.T) {
|
||||
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
|
||||
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = store.WriteDraft("backend", `name = "未闭合`+"\n", validation.CurrentHash)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid TOML write to be rejected")
|
||||
}
|
||||
assertFileContent(t, target, `name = "旧名称"`+"\n")
|
||||
}
|
||||
|
||||
func TestWriteExpectedHashMismatchRejectsAndLeavesOriginal(t *testing.T) {
|
||||
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
|
||||
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte(`name = "用户已改"`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = store.WriteDraft("backend", `name = "新名称"`+"\n", validation.CurrentHash)
|
||||
if !errors.Is(err, ErrWriteConflict) {
|
||||
t.Fatalf("expected conflict error, got %v", err)
|
||||
}
|
||||
assertFileContent(t, target, `name = "用户已改"`+"\n")
|
||||
}
|
||||
|
||||
func TestWriteSuccessCreatesBackupAndAtomicallyWrites(t *testing.T) {
|
||||
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
|
||||
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := store.WriteDraft("backend", `name = "新名称"`+"\n", validation.CurrentHash)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteDraft returned error: %v", err)
|
||||
}
|
||||
if got.Status != "written" || got.TargetPath != target || got.BackupPath == "" {
|
||||
t.Fatalf("unexpected write result: %#v", got)
|
||||
}
|
||||
assertFileContent(t, target, `name = "新名称"`+"\n")
|
||||
assertFileContent(t, got.BackupPath, `name = "旧名称"`+"\n")
|
||||
}
|
||||
|
||||
func TestWriteRejectsUnsafeIDAndSymlinks(t *testing.T) {
|
||||
store, target := writebackFixture(t, `name = "旧名称"`+"\n")
|
||||
validation, err := store.ValidateDraft("backend", `name = "新名称"`+"\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, id := range []string{"../backend", "nested/backend", "/tmp/backend", "backend.toml"} {
|
||||
t.Run("unsafe id "+id, func(t *testing.T) {
|
||||
if _, err := store.ValidateDraft(id, `name = "x"`+"\n"); err == nil {
|
||||
t.Fatal("expected unsafe validate id to be rejected")
|
||||
}
|
||||
if _, err := store.WriteDraft(id, `name = "x"`+"\n", validation.CurrentHash); err == nil {
|
||||
t.Fatal("expected unsafe write id to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.Remove(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../config.toml", target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.WriteDraft("backend", `name = "新名称"`+"\n", validation.CurrentHash); err == nil {
|
||||
t.Fatal("expected symlink leaf to be rejected")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "config.toml"), []byte(`name = "secret"`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(".", filepath.Join(root, "agents")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
symlinkedStore := Store{CodexHome: root}
|
||||
if _, err := symlinkedStore.WriteDraft("backend", `name = "x"`+"\n", validation.CurrentHash); err == nil {
|
||||
t.Fatal("expected symlinked agents directory to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func writebackFixture(t *testing.T, content string) (Store, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
agentsDir := filepath.Join(root, "agents")
|
||||
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(agentsDir, "backend.toml")
|
||||
if err := os.WriteFile(target, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Store{CodexHome: root}, target
|
||||
}
|
||||
|
||||
func assertFileContent(t *testing.T, path string, want string) {
|
||||
t.Helper()
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("%s content = %q, want %q", path, string(got), want)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codex-agent-manager/internal/agents"
|
||||
"codex-agent-manager/internal/app"
|
||||
"codex-agent-manager/internal/codexhome"
|
||||
"codex-agent-manager/internal/projects"
|
||||
"codex-agent-manager/internal/runtime"
|
||||
"codex-agent-manager/internal/workflow"
|
||||
@@ -37,6 +40,25 @@ func New(cfg app.Config) http.Handler {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
mux.HandleFunc("/api/agents/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id, action, ok := parseAgentActionPath(r.URL.Path)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "validate":
|
||||
handleAgentValidate(w, r, agentStore, id)
|
||||
case "write":
|
||||
handleAgentWrite(w, r, agentStore, id)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/projects", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"})
|
||||
@@ -87,6 +109,62 @@ func New(cfg app.Config) http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
type validateRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type writeRequest struct {
|
||||
Content string `json:"content"`
|
||||
ExpectedHash string `json:"expectedHash"`
|
||||
}
|
||||
|
||||
func parseAgentActionPath(path string) (string, string, bool) {
|
||||
rest := strings.TrimPrefix(path, "/api/agents/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
func handleAgentValidate(w http.ResponseWriter, r *http.Request, store agents.Store, id string) {
|
||||
var body validateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"})
|
||||
return
|
||||
}
|
||||
result, err := store.ValidateDraft(id, body.Content)
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func handleAgentWrite(w http.ResponseWriter, r *http.Request, store agents.Store, id string) {
|
||||
var body writeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "请求体不是有效 JSON"})
|
||||
return
|
||||
}
|
||||
result, err := store.WriteDraft(id, body.Content, body.ExpectedHash)
|
||||
if err != nil {
|
||||
writeAgentError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func writeAgentError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, agents.ErrWriteConflict) {
|
||||
status = http.StatusConflict
|
||||
} else if errors.Is(err, codexhome.ErrForbiddenPath) || errors.Is(err, codexhome.ErrOutsideCodexHome) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codex-agent-manager/internal/app"
|
||||
@@ -199,3 +201,113 @@ func TestReadOnlyEndpointsRejectUnsupportedMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentValidateEndpointReturnsDiffAndRejectsUnsupportedMethods(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
agentsDir := filepath.Join(root, "agents")
|
||||
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsDir, "backend.toml"), []byte(`name = "旧名称"`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/agents/backend/validate", bytes.NewBufferString(`{"content":"name = \"新名称\"\n"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Valid bool `json:"valid"`
|
||||
Diff string `json:"diff"`
|
||||
CurrentHash string `json:"currentHash"`
|
||||
TargetPath string `json:"targetPath"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
if !body.Valid || body.CurrentHash == "" || body.TargetPath == "" || !strings.Contains(body.Diff, "新名称") {
|
||||
t.Fatalf("unexpected validate body: %#v", body)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/agents/backend/validate", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("GET validate status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentValidateEndpointReturnsBadRequestForInvalidBody(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/agents/backend/validate", bytes.NewBufferString(`{`))
|
||||
rec := httptest.NewRecorder()
|
||||
New(app.Config{CodexHome: t.TempDir(), HTTPAddr: "127.0.0.1:0"}).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentWriteEndpointCreatesBackupAndRejectsConflicts(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
agentsDir := filepath.Join(root, "agents")
|
||||
target := filepath.Join(agentsDir, "backend.toml")
|
||||
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte(`name = "旧名称"`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := New(app.Config{CodexHome: root, HTTPAddr: "127.0.0.1:0"})
|
||||
validateReq := httptest.NewRequest(http.MethodPost, "/api/agents/backend/validate", bytes.NewBufferString(`{"content":"name = \"新名称\"\n"}`))
|
||||
validateRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(validateRec, validateReq)
|
||||
if validateRec.Code != http.StatusOK {
|
||||
t.Fatalf("validate status = %d, body = %s", validateRec.Code, validateRec.Body.String())
|
||||
}
|
||||
var validation struct {
|
||||
CurrentHash string `json:"currentHash"`
|
||||
}
|
||||
if err := json.Unmarshal(validateRec.Body.Bytes(), &validation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeBody := `{"content":"name = \"新名称\"\n","expectedHash":"` + validation.CurrentHash + `"}`
|
||||
writeReq := httptest.NewRequest(http.MethodPost, "/api/agents/backend/write", bytes.NewBufferString(writeBody))
|
||||
writeRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(writeRec, writeReq)
|
||||
|
||||
if writeRec.Code != http.StatusOK {
|
||||
t.Fatalf("write status = %d, body = %s", writeRec.Code, writeRec.Body.String())
|
||||
}
|
||||
var written struct {
|
||||
Status string `json:"status"`
|
||||
TargetPath string `json:"targetPath"`
|
||||
BackupPath string `json:"backupPath"`
|
||||
}
|
||||
if err := json.Unmarshal(writeRec.Body.Bytes(), &written); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if written.Status != "written" || written.TargetPath != target || written.BackupPath == "" {
|
||||
t.Fatalf("unexpected write body: %#v", written)
|
||||
}
|
||||
if data, err := os.ReadFile(target); err != nil || string(data) != `name = "新名称"`+"\n" {
|
||||
t.Fatalf("target content = %q, err = %v", string(data), err)
|
||||
}
|
||||
|
||||
conflictReq := httptest.NewRequest(http.MethodPost, "/api/agents/backend/write", bytes.NewBufferString(writeBody))
|
||||
conflictRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(conflictRec, conflictReq)
|
||||
if conflictRec.Code != http.StatusConflict {
|
||||
t.Fatalf("conflict status = %d, want %d, body = %s", conflictRec.Code, http.StatusConflict, conflictRec.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/agents/backend/write", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("GET write status = %d, want %d", getRec.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
12
progress.md
12
progress.md
@@ -27,6 +27,7 @@
|
||||
| 2026-05-25 | 5 | coding agent | TDD 接入前端只读 API client、normalizer 和项目/工作流/智能体真实数据视图 | 完成;提交前已通过测试、构建和本地接口 smoke 验证 |
|
||||
| 2026-05-25 | 5 | spec review | 规格审查未通过:valid agent 状态不明确,工作流和 agent 只读文案仍含内部英文 | coding agent 按 blocking 范围修复 |
|
||||
| 2026-05-25 | 5 | quality review | 代码质量审查未通过:未知后端枚举值会直接进入 UI label | coding agent 按 blocking 范围修复 |
|
||||
| 2026-05-25 | 6 | coding agent | TDD 实现智能体草稿校验、diff、hash 冲突检测、备份和原子写回 | 完成;待最终全量验证 |
|
||||
|
||||
## Test Results
|
||||
|
||||
@@ -143,6 +144,15 @@
|
||||
| 2026-05-25 | `pnpm build` | PASS | Phase 5 unknown workflow phase 修复后前端构建通过 |
|
||||
| 2026-05-25 | `go test ./...` | PASS | Phase 5 unknown workflow phase 修复未改 Go 行为;全量 Go 回归通过 |
|
||||
| 2026-05-25 | `git diff --check` | PASS | Phase 5 unknown workflow phase 修复 whitespace 检查通过 |
|
||||
| 2026-05-25 | `go test ./internal/agents ./internal/server` | FAIL | TDD 红灯:`ValidateDraft`/`WriteDraft` 未实现,validate/write 端点返回 404 |
|
||||
| 2026-05-25 | `cd web && pnpm test` | FAIL | TDD 红灯:writeback normalizer 和 validate/write client 方法未实现 |
|
||||
| 2026-05-25 | `go test ./internal/agents ./internal/server` | PASS | Phase 6 后端 validate/write、hash 冲突、备份、路径和 symlink 边界测试通过 |
|
||||
| 2026-05-25 | `cd web && pnpm test` | PASS | Phase 6 前端 client/normalizer 写回状态测试通过;共 13 个单测 |
|
||||
| 2026-05-25 | `go test ./internal/agents ./internal/server` | PASS | Phase 6 指定后端目标包验证通过 |
|
||||
| 2026-05-25 | `go test ./...` | PASS | Phase 6 全量 Go 验证通过 |
|
||||
| 2026-05-25 | `cd web && pnpm test` | PASS | Phase 6 前端单测验证通过;共 13 个单测 |
|
||||
| 2026-05-25 | `cd web && pnpm build` | PASS | Phase 6 前端生产构建通过 |
|
||||
| 2026-05-25 | `git diff --check` | PASS | Phase 6 whitespace 检查通过 |
|
||||
|
||||
## Bug Loop
|
||||
|
||||
@@ -169,3 +179,5 @@
|
||||
| 5 | valid agent 状态只显示“已读取”,且工作流/智能体可见文案残留内部英文 | normalizer 改为“TOML 有效”/“TOML 无效”,角色设定字段改中文,WorkflowView 改“交接边”和“主智能体” | `pnpm test` PASS |
|
||||
| 5 | 未知后端枚举值可通过 source/confidence/status/trust label 暴露到 UI | 未知 source 显示“来源未知”,未知 confidence 显示“低”,未知 status/trust 显示“未知” | `pnpm test` PASS |
|
||||
| 5 | 未知 workflow phase status 被白名单过滤,真实阶段从 UI 消失 | phase 过滤改为只排除表头/伪行,未知 status 交给中文状态兜底显示“未知” | `pnpm test` PASS |
|
||||
| 6 | 写回可能覆盖校验后用户修改的文件 | validate 返回当前 sha256;write 重新读取并比较 expectedHash,不匹配返回冲突且不写回 | `go test ./internal/agents ./internal/server` PASS |
|
||||
| 6 | 无效 TOML 或 unsafe id/symlink 可能进入写回路径 | write 重新执行 TOML 校验,id 只允许安全 bare stem,拒绝 leaf symlink 和 symlinked agents 目录 | `go test ./internal/agents ./internal/server` PASS |
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| 3 | complete | 项目配置、运行线程和工作流模型 | 能读取 projects、threads、spawn edges、goals;状态含来源/置信度;工作流不写死固定流程 |
|
||||
| 4 | complete | 中文 UI 只读工作台 | 项目/工作流/智能体视图可浏览;空数据可用 |
|
||||
| 5 | complete | API 集成和只读数据显示 | 前端连接只读 API;显示真实 agent 数据和错误状态 |
|
||||
| 6 | pending | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 |
|
||||
| 6 | complete | 草稿、TOML 校验、diff、备份、写回 | 草稿不覆盖原文件;无效 TOML 阻止写回;备份成功后才能写回 |
|
||||
| 7 | pending | 集成验证与文档 | 测试/构建/浏览器验证通过;文档完整 |
|
||||
|
||||
## Errors Encountered
|
||||
|
||||
@@ -23,14 +23,14 @@ const activeComponent = computed(() => tabs.find((tab) => tab.id === activeTab.v
|
||||
<div class="app-shell">
|
||||
<header class="workspace-header">
|
||||
<div>
|
||||
<p class="eyebrow">本地只读工作台</p>
|
||||
<p class="eyebrow">本地安全工作台</p>
|
||||
<h1>Codex 智能体管理台</h1>
|
||||
<p class="lede">前端按视图连接后端只读接口,显示真实数据、加载中、连接失败和空数据状态。</p>
|
||||
<p class="lede">前端按视图连接后端接口,显示真实数据、加载中、连接失败、空数据和单文件写回状态。</p>
|
||||
</div>
|
||||
<div class="connection-card" aria-label="连接状态">
|
||||
<StatusBadge label="只读接口" status="complete" confidence="中" source="计划文件" />
|
||||
<strong>按需读取后端数据</strong>
|
||||
<span>项目、运行线程、工作流和智能体视图只调用只读端点;失败时显示“示例/等待连接”标注。</span>
|
||||
<StatusBadge label="安全接口" status="complete" confidence="中" source="计划文件" />
|
||||
<strong>按需读取和确认写回</strong>
|
||||
<span>项目、运行线程和工作流保持只读;智能体草稿仅在校验、备份和确认后单文件写回。</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -56,4 +56,18 @@ export const apiClient = {
|
||||
getWorkflowEvents() {
|
||||
return requestJSON('/api/workflow/events')
|
||||
},
|
||||
validateAgentDraft(id, content) {
|
||||
return requestJSON(`/api/agents/${encodeURIComponent(id)}/validate`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
},
|
||||
writeAgentDraft(id, content, expectedHash) {
|
||||
return requestJSON(`/api/agents/${encodeURIComponent(id)}/write`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, expectedHash }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
33
web/src/api/client.test.mjs
Normal file
33
web/src/api/client.test.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { apiClient } from './client.js'
|
||||
|
||||
test('api client keeps readonly APIs on GET and uses POST only for validate/write', async () => {
|
||||
const calls = []
|
||||
globalThis.fetch = async (path, options = {}) => {
|
||||
calls.push({ path, method: options.method ?? 'GET', body: options.body ?? '' })
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
await apiClient.getAgents()
|
||||
await apiClient.getProjects()
|
||||
await apiClient.getRuntimeThreads()
|
||||
await apiClient.getWorkflowEvents()
|
||||
await apiClient.validateAgentDraft('backend', 'name = "新名称"\n')
|
||||
await apiClient.writeAgentDraft('backend', 'name = "新名称"\n', 'abc123')
|
||||
|
||||
assert.deepEqual(
|
||||
calls.map((call) => [call.path, call.method]),
|
||||
[
|
||||
['/api/agents', 'GET'],
|
||||
['/api/projects', 'GET'],
|
||||
['/api/runtime/threads', 'GET'],
|
||||
['/api/workflow/events', 'GET'],
|
||||
['/api/agents/backend/validate', 'POST'],
|
||||
['/api/agents/backend/write', 'POST'],
|
||||
],
|
||||
)
|
||||
assert.equal(calls[4].body, JSON.stringify({ content: 'name = "新名称"\n' }))
|
||||
assert.equal(calls[5].body, JSON.stringify({ content: 'name = "新名称"\n', expectedHash: 'abc123' }))
|
||||
})
|
||||
@@ -247,10 +247,64 @@ export function normalizeWorkflow(payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeValidationResult(result = {}) {
|
||||
const valid = result.valid === true
|
||||
const errors = Array.isArray(result.errors) ? result.errors.filter(Boolean) : []
|
||||
return {
|
||||
valid,
|
||||
errors,
|
||||
diff: result.diff || '',
|
||||
targetPath: result.targetPath || '',
|
||||
currentHash: result.currentHash || '',
|
||||
fieldChanges: Array.isArray(result.fieldChanges) ? result.fieldChanges : [],
|
||||
statusLabel: valid ? 'TOML 有效' : 'TOML 无效',
|
||||
errorText: errors.join(';') || (valid ? '' : 'TOML 校验失败'),
|
||||
steps: valid ? ['草稿', '已校验'] : ['草稿'],
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeWritebackResult(result = {}) {
|
||||
return {
|
||||
status: result.status || 'unknown',
|
||||
statusLabel: result.status === 'written' ? '已写回' : '未知',
|
||||
targetPath: result.targetPath || '',
|
||||
backupPath: result.backupPath || '',
|
||||
currentHash: result.currentHash || '',
|
||||
steps: result.status === 'written' ? ['草稿', '已校验', '已备份', '已写回'] : ['草稿'],
|
||||
summary: `目标路径:${result.targetPath || '未返回'};备份路径:${result.backupPath || '未返回'}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDraftWriteback({ validation = null, writeback = null } = {}) {
|
||||
const normalizedValidation = validation
|
||||
? (Object.hasOwn(validation, 'statusLabel') ? validation : normalizeValidationResult(validation))
|
||||
: null
|
||||
const normalizedWriteback = writeback
|
||||
? (Object.hasOwn(writeback, 'statusLabel') ? writeback : normalizeWritebackResult(writeback))
|
||||
: null
|
||||
const canWrite = Boolean(normalizedValidation?.valid && normalizedValidation.currentHash && !normalizedWriteback)
|
||||
return {
|
||||
canWrite,
|
||||
validation: normalizedValidation,
|
||||
writeback: normalizedWriteback,
|
||||
steps: normalizedWriteback?.steps ?? normalizedValidation?.steps ?? ['草稿'],
|
||||
writeDisabledReason: canWrite
|
||||
? ''
|
||||
: normalizedValidation && !normalizedValidation.valid
|
||||
? 'TOML 无效,不能写回'
|
||||
: normalizedWriteback
|
||||
? '已写回'
|
||||
: '请先校验 TOML',
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizeAgentPreview(agent, { isInvalid, description }) {
|
||||
if (isInvalid) {
|
||||
return `# TOML 无效,仅只读展示解析错误\n# ${description}`
|
||||
}
|
||||
if (agent.content) {
|
||||
return agent.content
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'# 接口未返回原始 TOML;以下为只读展示的已解析字段',
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
formatSourceKind,
|
||||
formatStatus,
|
||||
normalizeAgent,
|
||||
normalizeDraftWriteback,
|
||||
normalizeProject,
|
||||
normalizeRuntime,
|
||||
normalizeValidationResult,
|
||||
normalizeWritebackResult,
|
||||
normalizeWorkflow,
|
||||
} from './normalizers.js'
|
||||
import { settings } from '../data.js'
|
||||
@@ -164,3 +167,32 @@ test('keeps workflow phases with unknown backend status visible', () => {
|
||||
assert.equal(workflow.phases[0].name, '阶段 5')
|
||||
assert.equal(workflow.phases[0].label, '未知')
|
||||
})
|
||||
|
||||
test('normalizes writeback response with Chinese steps and paths', () => {
|
||||
const result = normalizeWritebackResult({
|
||||
status: 'written',
|
||||
targetPath: '/tmp/codex/agents/backend.toml',
|
||||
backupPath: '/tmp/codex/agents/backend.toml.bak-20260525',
|
||||
})
|
||||
|
||||
assert.deepEqual(result.steps, ['草稿', '已校验', '已备份', '已写回'])
|
||||
assert.equal(result.statusLabel, '已写回')
|
||||
assert.equal(result.targetPath, '/tmp/codex/agents/backend.toml')
|
||||
assert.equal(result.backupPath, '/tmp/codex/agents/backend.toml.bak-20260525')
|
||||
assert.match(result.summary, /目标路径/)
|
||||
assert.match(result.summary, /备份路径/)
|
||||
})
|
||||
|
||||
test('invalid validation disables writeback state', () => {
|
||||
const validation = normalizeValidationResult({
|
||||
valid: false,
|
||||
errors: ['第 1 行不是有效的键值字段'],
|
||||
targetPath: '/tmp/codex/agents/backend.toml',
|
||||
})
|
||||
const draft = normalizeDraftWriteback({ validation })
|
||||
|
||||
assert.equal(validation.statusLabel, 'TOML 无效')
|
||||
assert.equal(draft.canWrite, false)
|
||||
assert.equal(draft.writeDisabledReason, 'TOML 无效,不能写回')
|
||||
assert.deepEqual(draft.steps, ['草稿'])
|
||||
})
|
||||
|
||||
@@ -158,6 +158,6 @@ export const settings = [
|
||||
{ name: 'Codex 主目录', value: '/Users/yoilun/.codex', detail: '由后端配置提供', enabled: true },
|
||||
{ name: 'SQLite 状态读取', value: '只读 mode=ro&immutable=1', detail: '通过只读接口展示', enabled: true },
|
||||
{ name: '本机进程辅助判断', value: '等待后端聚合', detail: '只显示来源与置信度,不伪装确定状态', enabled: true },
|
||||
{ name: '写回能力', value: '阶段 6 才启用', detail: '当前没有保存、写入或批量写回按钮', enabled: false },
|
||||
{ name: '写回能力', value: '单文件确认写回', detail: '只允许校验后创建备份并写回一个智能体 TOML', enabled: true },
|
||||
{ name: '敏感文件黑名单', value: 'auth.json', detail: '前端只展示规则摘要,不读取内容', enabled: true },
|
||||
]
|
||||
|
||||
@@ -650,6 +650,60 @@ button {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.action-tabs button {
|
||||
flex: 0 0 auto;
|
||||
padding: 9px 12px;
|
||||
color: var(--green);
|
||||
background: var(--panel-muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.action-tabs button:not(:disabled):hover {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.action-tabs button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.draft-workspace {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.draft-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.draft-editor span,
|
||||
.draft-hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.draft-editor textarea {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
padding: 14px;
|
||||
color: #25322d;
|
||||
background: #ebe4d8;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.draft-hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.readonly-code {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import WritebackSteps from '../components/WritebackSteps.vue'
|
||||
import { apiClient } from '../api/client'
|
||||
import { normalizeAgents } from '../api/normalizers'
|
||||
import {
|
||||
normalizeAgents,
|
||||
normalizeDraftWriteback,
|
||||
normalizeValidationResult,
|
||||
normalizeWritebackResult,
|
||||
} from '../api/normalizers'
|
||||
import { agents as sampleAgents } from '../data'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const agentState = ref(normalizeAgents())
|
||||
const selectedId = ref('')
|
||||
const draftContent = ref('')
|
||||
const draftError = ref('')
|
||||
const validation = ref(null)
|
||||
const writeback = ref(null)
|
||||
const validating = ref(false)
|
||||
const writing = ref(false)
|
||||
const showDiff = ref(false)
|
||||
const validatedContent = ref('')
|
||||
const displayAgents = computed(() => (error.value ? sampleAgents.map((agent) => ({ ...agent, name: `示例:${agent.name}`, statusLabel: agent.status })) : agentState.value.agents))
|
||||
const selectedAgent = computed(() => displayAgents.value.find((agent) => agent.id === selectedId.value) ?? displayAgents.value[0])
|
||||
const draftFlow = computed(() => normalizeDraftWriteback({ validation: validation.value, writeback: writeback.value }))
|
||||
const canUseWriteback = computed(() => Boolean(selectedAgent.value && !error.value && selectedAgent.value.parseStatus !== 'invalid'))
|
||||
|
||||
watch(displayAgents, (items) => {
|
||||
selectedId.value = items[0]?.id ?? ''
|
||||
})
|
||||
|
||||
watch(selectedAgent, (agent) => {
|
||||
draftContent.value = agent?.toml ?? ''
|
||||
validation.value = null
|
||||
writeback.value = null
|
||||
draftError.value = ''
|
||||
showDiff.value = false
|
||||
validatedContent.value = ''
|
||||
})
|
||||
|
||||
watch(draftContent, (content) => {
|
||||
if (!validation.value || content === validatedContent.value) return
|
||||
validation.value = null
|
||||
writeback.value = null
|
||||
showDiff.value = false
|
||||
draftError.value = '草稿已修改,请重新校验 TOML'
|
||||
})
|
||||
|
||||
onMounted(loadAgents)
|
||||
|
||||
async function loadAgents() {
|
||||
@@ -29,6 +62,47 @@ async function loadAgents() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDraft() {
|
||||
if (!selectedAgent.value) return
|
||||
validating.value = true
|
||||
draftError.value = ''
|
||||
writeback.value = null
|
||||
try {
|
||||
validation.value = normalizeValidationResult(
|
||||
await apiClient.validateAgentDraft(selectedAgent.value.id, draftContent.value),
|
||||
)
|
||||
validatedContent.value = draftContent.value
|
||||
showDiff.value = validation.value.valid
|
||||
} catch (err) {
|
||||
validation.value = null
|
||||
draftError.value = err?.message || '校验请求失败'
|
||||
} finally {
|
||||
validating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDiff() {
|
||||
showDiff.value = !showDiff.value
|
||||
}
|
||||
|
||||
async function writeDraft() {
|
||||
if (!selectedAgent.value || !validation.value?.currentHash) return
|
||||
if (!window.confirm(`确认创建备份并写回 ${selectedAgent.value.fileName}?`)) {
|
||||
return
|
||||
}
|
||||
writing.value = true
|
||||
draftError.value = ''
|
||||
try {
|
||||
writeback.value = normalizeWritebackResult(
|
||||
await apiClient.writeAgentDraft(selectedAgent.value.id, draftContent.value, validation.value.currentHash),
|
||||
)
|
||||
} catch (err) {
|
||||
draftError.value = err?.message || '写回请求失败'
|
||||
} finally {
|
||||
writing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,20 +178,65 @@ async function loadAgents() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="subtabs" aria-label="智能体信息子标签">
|
||||
<span class="active">预览</span>
|
||||
<span>TOML</span>
|
||||
<span>差异</span>
|
||||
<span>备份</span>
|
||||
<div class="subtabs action-tabs" aria-label="智能体草稿操作">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!canUseWriteback || validating"
|
||||
@click="validateDraft"
|
||||
>
|
||||
{{ validating ? '校验中' : '校验 TOML' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!validation"
|
||||
@click="toggleDiff"
|
||||
>
|
||||
查看差异
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!draftFlow.canWrite || writing"
|
||||
@click="writeDraft"
|
||||
>
|
||||
{{ writing ? '写回中' : '创建备份并写回' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedAgent" class="readonly-code">
|
||||
<pre>{{ selectedAgent.toml }}</pre>
|
||||
<div v-if="selectedAgent" class="draft-workspace">
|
||||
<WritebackSteps :active-steps="draftFlow.steps" />
|
||||
<label class="draft-editor">
|
||||
<span>草稿内容</span>
|
||||
<textarea
|
||||
v-model="draftContent"
|
||||
:readonly="!canUseWriteback"
|
||||
rows="14"
|
||||
aria-label="智能体 TOML 草稿内容"
|
||||
></textarea>
|
||||
</label>
|
||||
<div v-if="draftError" class="empty-state compact error-state">
|
||||
<strong>操作失败</strong>
|
||||
<p>{{ draftError }}</p>
|
||||
</div>
|
||||
<div v-if="validation" class="empty-state compact" :class="{ 'error-state': !validation.valid }">
|
||||
<strong>{{ validation.statusLabel }}</strong>
|
||||
<p v-if="validation.valid">
|
||||
目标路径:{{ validation.targetPath }};字段变更:{{ validation.fieldChanges.length ? validation.fieldChanges.map((item) => item.field).join('、') : '无' }}
|
||||
</p>
|
||||
<p v-else>{{ validation.errorText }}</p>
|
||||
</div>
|
||||
<div v-if="validation && showDiff" class="readonly-code">
|
||||
<pre>{{ validation.diff || '无差异' }}</pre>
|
||||
</div>
|
||||
<div v-if="writeback" class="empty-state compact">
|
||||
<strong>{{ writeback.statusLabel }}</strong>
|
||||
<p>{{ writeback.summary }}</p>
|
||||
</div>
|
||||
<p v-if="!draftFlow.canWrite && !writeback" class="draft-hint">{{ draftFlow.writeDisabledReason }}</p>
|
||||
</div>
|
||||
|
||||
<div class="empty-state compact">
|
||||
<strong>当前阶段没有保存入口</strong>
|
||||
<p>这里只读展示接口返回的智能体字段。真实草稿、校验、差异和写回会在阶段 6 实现。</p>
|
||||
<strong>单文件安全写回</strong>
|
||||
<p>写回前会重新校验 TOML、比较校验时的文件 hash,并先创建备份。校验失败或文件已变化时不会写回。</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { drafts } from '../data'
|
||||
<p class="eyebrow">草稿</p>
|
||||
<h2>未写回变更</h2>
|
||||
</div>
|
||||
<span class="read-only-chip">写回未启用</span>
|
||||
<span class="read-only-chip">智能体视图写回</span>
|
||||
</div>
|
||||
|
||||
<div class="draft-list">
|
||||
@@ -36,12 +36,12 @@ import { drafts } from '../data'
|
||||
|
||||
<aside class="panel draft-side">
|
||||
<div class="panel-heading">
|
||||
<p class="eyebrow">空状态</p>
|
||||
<h2>等待阶段 6 写回流程</h2>
|
||||
<p class="eyebrow">入口</p>
|
||||
<h2>到智能体视图编辑草稿</h2>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<strong>没有真实草稿队列</strong>
|
||||
<p>当前只展示步骤结构:草稿、已校验、已备份、已写回。不会创建、放弃或写回任何文件。</p>
|
||||
<p>当前不做批量队列,也不自动保存草稿。请在智能体视图中选择单个文件,按“草稿、已校验、已备份、已写回”流程操作。</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user