Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
154 lines
4.3 KiB
Go
154 lines
4.3 KiB
Go
package runmgr
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"agent-native/runtime/checkpoint"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func (m *Manager) checkpointFile(owner, convID string) (string, error) {
|
|
if m == nil || m.workspaceRoot == "" || owner == "" || convID == "" {
|
|
return "", fmt.Errorf("未配置工作区,不能记录文件检查点")
|
|
}
|
|
return filepath.Join(m.workspaceRoot, owner, convID, ".agentx", "checkpoints.jsonl"), nil
|
|
}
|
|
|
|
// OpenCheckpoints 打开这个会话的文件检查点日志。
|
|
func (m *Manager) OpenCheckpoints(owner, convID string) (*checkpoint.Log, error) {
|
|
path, err := m.checkpointFile(owner, convID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return checkpoint.Open(path)
|
|
}
|
|
|
|
func (c *runCtx) OpenCheckpoints() (*checkpoint.Log, error) {
|
|
if c == nil || c.mgr == nil {
|
|
return nil, fmt.Errorf("未配置工作区,不能记录文件检查点")
|
|
}
|
|
return c.mgr.OpenCheckpoints(c.run.Owner, c.run.ConversationID)
|
|
}
|
|
|
|
func (c *runCtx) CheckpointAnchor() string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return c.userMessage.ID
|
|
}
|
|
|
|
// NoteFileCheckpoint 记录一次工作区写入的改前内容。
|
|
func (c *runCtx) NoteFileCheckpoint(rel string, before []byte, existed bool, after []byte) error {
|
|
log, err := c.OpenCheckpoints()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root := c.ConversationSandbox()
|
|
return log.Record(c.CheckpointAnchor(), root, rel, before, existed, after)
|
|
}
|
|
|
|
func (t *toolCtx) NoteFileCheckpoint(rel string, before []byte, existed bool, after []byte) error {
|
|
if t == nil || t.rc == nil {
|
|
return nil
|
|
}
|
|
return t.rc.NoteFileCheckpoint(rel, before, existed, after)
|
|
}
|
|
|
|
// PlanRewind 列出从这条用户消息起会被回滚的文件,不改磁盘。
|
|
func (m *Manager) PlanRewind(owner, convID, messageID string) (checkpoint.Result, error) {
|
|
ordered, err := m.userMessageIDs(convID, messageID)
|
|
if err != nil {
|
|
return checkpoint.Result{}, err
|
|
}
|
|
log, err := m.OpenCheckpoints(owner, convID)
|
|
if err != nil {
|
|
return checkpoint.Result{}, err
|
|
}
|
|
return log.Plan(messageID, ordered)
|
|
}
|
|
|
|
// RewindFiles 把文件退回到这条用户消息之前,并追加一条系统说明。
|
|
func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Result, error) {
|
|
ordered, err := m.userMessageIDs(convID, messageID)
|
|
if err != nil {
|
|
return checkpoint.Result{}, err
|
|
}
|
|
cpLog, err := m.OpenCheckpoints(owner, convID)
|
|
if err != nil {
|
|
return checkpoint.Result{}, err
|
|
}
|
|
if m.accountEffectiveAuthorization != nil {
|
|
if err := m.authorizeAccountEffective(context.Background(), owner); err != nil {
|
|
return checkpoint.Result{}, err
|
|
}
|
|
}
|
|
res, err := cpLog.Rewind(messageID, ordered)
|
|
if err != nil {
|
|
return checkpoint.Result{}, err
|
|
}
|
|
// 保留策略(N3 WP3):成功的回滚后按 anchor 数裁到 200,日志有界。
|
|
if cerr := cpLog.Compact(200); cerr != nil {
|
|
log.Printf("[runmgr] checkpoint compact %s: %v", convID, cerr)
|
|
}
|
|
note := rewindNote(res)
|
|
msg := core.Message{
|
|
ConversationID: convID, Role: core.RoleSystem,
|
|
Parts: []core.Part{{Type: core.PartText, Text: note}},
|
|
}
|
|
if m.accountEffectiveAuthorization != nil {
|
|
err = m.st.AppendMessageWithRequestAccountChecked(owner, m.accountAllowImplicitLocal, &msg, "")
|
|
} else {
|
|
err = m.st.AppendMessage(&msg)
|
|
}
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (m *Manager) userMessageIDs(convID, messageID string) ([]string, error) {
|
|
msgs, err := m.st.ListMessages(convID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ordered []string
|
|
found := false
|
|
for _, msg := range msgs {
|
|
if msg.Role != core.RoleUser {
|
|
continue
|
|
}
|
|
ordered = append(ordered, msg.ID)
|
|
if msg.ID == messageID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("找不到用户消息 %s", messageID)
|
|
}
|
|
return ordered, nil
|
|
}
|
|
|
|
func rewindNote(res checkpoint.Result) string {
|
|
var b strings.Builder
|
|
b.WriteString("已把文件回滚到这条消息之前。")
|
|
if len(res.Restored) == 0 && len(res.Skipped) == 0 {
|
|
b.WriteString("没有需要回滚的文件。")
|
|
return b.String()
|
|
}
|
|
if len(res.Restored) > 0 {
|
|
b.WriteString("已恢复:")
|
|
b.WriteString(strings.Join(res.Restored, "、"))
|
|
b.WriteString("。")
|
|
}
|
|
if len(res.Skipped) > 0 {
|
|
b.WriteString("已跳过:")
|
|
b.WriteString(strings.Join(res.Skipped, "、"))
|
|
b.WriteString("(文件在追踪之外被改过)。")
|
|
}
|
|
return b.String()
|
|
}
|