Files
Agent-System-Backend/internal/runmgr/inputs.go
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
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.
2026-10-14 07:33:54 +00:00

194 lines
6.5 KiB
Go

package runmgr
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"unicode/utf8"
core "agent-system-core"
)
// WorkspaceFile 是要落到会话工作区 inputs/ 的原件(上传文件或默认模板)。
type WorkspaceFile struct {
Name string
Content []byte
}
// StageWorkspaceInputs 把原件和可选默认模板写到 <root>/<owner>/<conversation>/inputs/。
// root 为空时不写(工作区未配置)。返回工作区相对路径。同一批里重名的文件加序号。
func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte) ([]string, error) {
return StageWorkspaceInputsChecked(context.Background(), root, owner, conversation, files, templateName, template, nil)
}
// StageWorkspaceInputsChecked rechecks the actor immediately before each
// directory/file mutation. The caller supplies its effective-account check;
// check and filesystem syscall cannot be atomic with an account DB commit.
func StageWorkspaceInputsChecked(ctx context.Context, root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte, check func(context.Context) error) ([]string, error) {
if strings.TrimSpace(root) == "" {
return nil, nil
}
dir := filepath.Join(root, owner, conversation, "inputs")
if err := checkFileEffect(ctx, check); err != nil {
return nil, err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
var rels []string
used := map[string]bool{}
for _, file := range files {
name, ok := safeInputName(file.Name)
if !ok || len(file.Content) == 0 {
continue
}
name = uniqueInputName(name, used)
if err := checkFileEffect(ctx, check); err != nil {
return rels, err
}
if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil {
return rels, err
}
used[name] = true
rels = append(rels, "inputs/"+name)
}
if len(template) > 0 {
ext := filepath.Ext(templateName)
base, ok := safeInputName(templateName)
if ok {
ext = filepath.Ext(base)
}
name := "_default-template" + ext
if err := checkFileEffect(ctx, check); err != nil {
return rels, err
}
if err := os.WriteFile(filepath.Join(dir, name), template, 0o644); err != nil {
return rels, err
}
rels = append(rels, "inputs/"+name)
}
return rels, nil
}
func checkFileEffect(ctx context.Context, check func(context.Context) error) error {
if err := ctx.Err(); err != nil {
return err
}
if check != nil {
return check(ctx)
}
return nil
}
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
// 原本就是文本类(md/txt/csv)的跳过;解析文本过短(<200 字)视为解析失败跳过。
func PersistInputTexts(root, owner, conversation string, attachments []core.ParsedDocument) {
_ = PersistInputTextsChecked(context.Background(), root, owner, conversation, attachments, nil)
}
// PersistInputTextsChecked propagates a failed final check or file write so a
// guarded caller stops before later extracted files are published.
func PersistInputTextsChecked(ctx context.Context, root, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error {
if strings.TrimSpace(root) == "" {
return nil
}
dir := filepath.Join(root, owner, conversation, "inputs")
for _, doc := range attachments {
name := strings.TrimSpace(doc.Meta.Filename)
if name == "" {
continue
}
switch strings.ToLower(filepath.Ext(name)) {
case ".md", ".txt", ".csv", ".json":
continue
}
text := strings.TrimSpace(doc.PlainText())
if utf8.RuneCountInString(text) < 200 {
continue
}
stem := strings.TrimSuffix(name, filepath.Ext(name))
out := filepath.Join(dir, stem+".md")
if _, err := os.Stat(out); err == nil {
continue // 已有同名解析文本(同一附件重发)不覆盖
}
if err := checkFileEffect(ctx, check); err != nil {
return err
}
if err := os.WriteFile(out, []byte(text), 0o644); err != nil {
return err
}
}
return nil
}
// ListWorkspaceInputs 列出会话工作区 inputs/ 目录的相对路径(原样返回给环境段)。
// 覆盖「本轮上传」视角:跨轮可见所有原件与解析文本。目录不存在时回落 staging 列表。
func ListWorkspaceInputs(root, owner, conversation string, staged []string) []string {
dir := filepath.Join(root, owner, conversation, "inputs")
ents, err := os.ReadDir(dir)
if err != nil {
return staged
}
var out []string
for _, e := range ents {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
out = append(out, "inputs/"+e.Name())
}
if len(out) == 0 {
return staged
}
return out
}
func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) {
return m.stageInputsChecked(context.Background(), conv, eff, files, nil)
}
func (m *Manager) stageInputsChecked(ctx context.Context, conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile, check func(context.Context) error) ([]string, error) {
var templateName string
var template []byte
if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil {
if art, err := m.st.GetArtifact(conv.Owner, id); err == nil {
if body, err := m.st.ArtifactContent(conv.Owner, id); err == nil && len(body) > 0 {
templateName, template = art.Name, body
}
}
}
return StageWorkspaceInputsChecked(ctx, m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template, check)
}
func (m *Manager) persistInputTextsChecked(ctx context.Context, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error {
return PersistInputTextsChecked(ctx, m.workspaceRoot, owner, conversation, attachments, check)
}
func safeInputName(name string) (string, bool) {
base := filepath.Base(strings.TrimSpace(name))
if base == "" || base == "." || base == ".." || strings.HasPrefix(base, ".") {
return "", false
}
if strings.ContainsAny(base, `/\`) {
return "", false
}
return base, true
}
func uniqueInputName(name string, used map[string]bool) string {
if !used[name] {
return name
}
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
for n := 2; ; n++ {
candidate := fmt.Sprintf("%s-%d%s", stem, n, ext)
if !used[candidate] {
return candidate
}
}
}