Files
ttstd-infinite-canvas/backend/internal/repository/cloud_agent.go
ddcat666 10ea9d3fa0 feat(canvas): Agent 审批 - 节点直接生成时自动关闭同节点的待审批任务
用户在画布节点上直接提交生成后,同画布 Agent 对该节点等待中的 generate_media 审批关闭为 superseded_by_node,Agent 带着「用户已手动提交」的结果继续,不重复提交、不扣费。审批卡片识别目标节点正在生成时禁用「同意执行」并提示原因。
2026-09-30 03:40:14 +08:00

433 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"bytes"
"encoding/json"
"fmt"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"infinite-canvas/backend/internal/model"
)
func (r *Repository) CloudAgentPiSession(userID, runID string) (*model.CloudAgentPiSession, error) {
var session model.CloudAgentPiSession
err := r.db.First(&session, "run_id = ? AND user_id = ?", runID, userID).Error
return &session, err
}
// SaveCloudAgentPiSession uses a revision predicate so a stale/recovered Pi
// process cannot overwrite a newer native session snapshot.
func (r *Repository) SaveCloudAgentPiSession(session *model.CloudAgentPiSession, expectedRevision int64) error {
if session == nil || session.RunID == "" || session.UserID == "" {
return fmt.Errorf("invalid Agent session identity")
}
if expectedRevision == 0 {
session.Revision = 1
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(session).Error
}
result := r.db.Model(&model.CloudAgentPiSession{}).
Where("run_id = ? AND user_id = ? AND revision = ?", session.RunID, session.UserID, expectedRevision).
Updates(map[string]any{"session_jsonl": session.SessionJSONL, "revision": expectedRevision + 1, "updated_at": time.Now()})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
session.Revision = expectedRevision + 1
return nil
}
func (r *Repository) EnsureCloudAgent(run *model.CloudAgentExecution) error {
if run.ConversationID == "" {
run.ConversationID = run.ID
if run.ParentID != "" {
var parent model.CloudAgentExecution
if err := r.db.Select("id", "conversation_id", "title").First(&parent, "id = ? AND user_id = ?", run.ParentID, run.UserID).Error; err != nil {
return err
}
if parent.ConversationID != "" {
run.ConversationID = parent.ConversationID
} else {
run.ConversationID = parent.ID
}
run.Title = parent.Title
}
}
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(run).Error
}
// CloudAgent 在同一个只读事务里读取执行行和它的事件/消息行。分开读的话,并发写入
// (运行时事件、工具结果)可能落在两次查询之间:读到旧的计数配新的行,解码时会被
// 当成"记录不完整"而把一次正常运行判死。
func (r *Repository) CloudAgent(userID, id string) (*model.CloudAgentExecution, error) {
var run model.CloudAgentExecution
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.First(&run, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return err
}
return New(tx).hydrateCloudAgent(&run)
})
return &run, err
}
// CloudAgentJournalWindow 是载入执行状态时读取的事件窗口条数。
//
// 事件全量在 cloud_agent_event_records(append-only,主键 run_id + sequence),
// 内存只保留最近这一窗:摘要、卡死判定与个人记忆提取只看最近事件,运行详情与
// SSE 需要更早的记录时按 seq 分页读表。若在这里全量载入,长跑的 run 每读一次
// 运行详情(以及调度器每轮扫描的每个活动 run)都要把整份 journal 拉进内存。
const CloudAgentJournalWindow = 40
func (r *Repository) hydrateCloudAgent(run *model.CloudAgentExecution) error {
if run.CheckpointVersion < 2 {
return nil
}
// 只取最近一窗,再翻回升序:与 EventCount 的尾部对齐(不变量见 app 侧解码)。
window := []model.CloudAgentEventRecord{}
if err := r.db.Where("run_id = ? AND user_id = ?", run.ID, run.UserID).
Order("sequence DESC").Limit(CloudAgentJournalWindow).Find(&window).Error; err != nil {
return err
}
run.Journal = make([]model.CloudAgentEventRecord, 0, len(window))
for index := len(window) - 1; index >= 0; index-- {
run.Journal = append(run.Journal, window[index])
}
if err := r.db.Where("run_id = ? AND user_id = ?", run.ID, run.UserID).Order("kind, sequence").Find(&run.Transcript).Error; err != nil {
return err
}
return nil
}
// CloudAgentEventRecords 取 seq > afterSeq 的事件(升序),keyset 分页走
// (run_id, sequence) 主键,用于增量拉取与 SSE 断线重连。
func (r *Repository) CloudAgentEventRecords(userID, runID string, afterSeq, limit int) ([]model.CloudAgentEventRecord, error) {
if limit <= 0 {
limit = CloudAgentJournalWindow
}
records := []model.CloudAgentEventRecord{}
err := r.db.Where("run_id = ? AND user_id = ? AND sequence > ?", runID, userID, afterSeq).
Order("sequence ASC").Limit(limit).Find(&records).Error
return records, err
}
// CloudAgentEventRecordsBefore 取 seq < beforeSeq 的**最近** limit 条(升序返回),
// 供"请求的页比内存窗口更深、要往前补"的读路径使用。
func (r *Repository) CloudAgentEventRecordsBefore(userID, runID string, beforeSeq, limit int) ([]model.CloudAgentEventRecord, error) {
if limit <= 0 {
limit = CloudAgentJournalWindow
}
records := []model.CloudAgentEventRecord{}
err := r.db.Where("run_id = ? AND user_id = ? AND sequence < ?", runID, userID, beforeSeq).
Order("sequence DESC").Limit(limit).Find(&records).Error
if err != nil {
return nil, err
}
for left, right := 0, len(records)-1; left < right; left, right = left+1, right-1 {
records[left], records[right] = records[right], records[left]
}
return records, nil
}
// CloudAgentEventRecordCount 是该运行已入库的事件条数;只做计数,不载入正文。
func (r *Repository) CloudAgentEventRecordCount(userID, runID string) (int64, error) {
var count int64
err := r.db.Model(&model.CloudAgentEventRecord{}).
Where("run_id = ? AND user_id = ?", runID, userID).Count(&count).Error
return count, err
}
func (r *Repository) CloudAgentForActiveTask(userID, taskID string) (*model.CloudAgentExecution, error) {
var run model.CloudAgentExecution
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ? AND active_task_id = ? AND status IN ?", userID, taskID, []string{"running", "queued"}).First(&run).Error; err != nil {
return err
}
return New(tx).hydrateCloudAgent(&run)
})
return &run, err
}
// CloudAgentIDsWaitingApprovalForCanvas lists the caller's runs paused on an
// approval for one canvas. Callers reload each run before mutating it.
func (r *Repository) CloudAgentIDsWaitingApprovalForCanvas(userID, canvasID string) ([]string, error) {
var ids []string
err := r.db.Model(&model.CloudAgentExecution{}).
Where("user_id = ? AND canvas_id = ? AND status = ?", userID, canvasID, "waiting_approval").
Order("id").Limit(20).Pluck("id", &ids).Error
return ids, err
}
func (r *Repository) CloudAgentRoots() ([]model.Task, error) {
var tasks []model.Task
err := r.db.Where("operation = ? AND id NOT IN (SELECT id FROM cloud_agent_executions)", "cloud_agent").Order("created_at").Limit(50).Find(&tasks).Error
return tasks, err
}
// A stable keyset makes waiting rows yield to later runs without changing business timestamps.
func (r *Repository) ActiveCloudAgentsAfter(after string, limit int) ([]model.CloudAgentExecution, error) {
var runs []model.CloudAgentExecution
if limit < 1 || limit > 50 {
limit = 50
}
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("(status IN ? OR cleanup_pending = ?) AND id > ?", []string{"running", "queued"}, true, after).Order("id").Limit(limit).Find(&runs).Error; err != nil {
return err
}
scoped := New(tx)
for i := range runs {
if err := scoped.hydrateCloudAgent(&runs[i]); err != nil {
return err
}
}
return nil
})
return runs, err
}
func (r *Repository) ActiveCloudAgents() ([]model.CloudAgentExecution, error) {
return r.ActiveCloudAgentsAfter("", 50)
}
// Do not load the transcript, tasks and bills for an unchanged SSE subscription.
func (r *Repository) CloudAgentRevision(userID, id string) (int64, error) {
var run model.CloudAgentExecution
err := r.db.Select("id", "revision").Where("id = ? AND user_id = ?", id, userID).First(&run).Error
return run.Revision, err
}
// RecentCloudAgentEventsForUser returns the journal rows of the caller's most
// recent runs, ordered by event time. Runs are resolved first so a fixed
// event limit cannot cut a run in half; an ongoing run may still add events.
func (r *Repository) RecentCloudAgentEventsForUser(userID string, runLimit int) ([]model.CloudAgentEventRecord, error) {
if runLimit < 1 {
return nil, nil
}
var runIDs []string
if err := r.db.Model(&model.CloudAgentExecution{}).
Where("user_id = ?", userID).
Order("created_at DESC").
Limit(runLimit).
Pluck("id", &runIDs).Error; err != nil {
return nil, err
}
if len(runIDs) == 0 {
return nil, nil
}
var records []model.CloudAgentEventRecord
err := r.db.Where("user_id = ? AND run_id IN ?", userID, runIDs).
Order("created_at, sequence").
Find(&records).Error
return records, err
}
// Lock before reading: checkpoints, canvas writes and task reservations commit together.
func (r *Repository) MutateCloudAgent(userID, id string, revision int64, fn func(*model.CloudAgentExecution, *Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
q := tx.Model(&model.CloudAgentExecution{}).Where("id = ? AND user_id = ? AND revision = ?", id, userID, revision).UpdateColumn("revision", gorm.Expr("revision + 1"))
if q.Error != nil {
return q.Error
}
if q.RowsAffected != 1 {
return ErrCreationConflict
}
run, err := New(tx).CloudAgent(userID, id)
if err != nil {
return err
}
previousEvents := run.EventCount
previousEventBodies := make(map[int]string, len(run.Journal))
for _, event := range run.Journal {
previousEventBodies[event.Sequence] = event.EventJSON
}
previousMessages := make(map[string]string, len(run.Transcript))
for _, message := range run.Transcript {
previousMessages[fmt.Sprintf("%s:%d", message.Kind, message.Sequence)] = message.MessageJSON
}
if err = fn(run, New(tx)); err != nil {
return err
}
if run.EventCount < previousEvents {
return fmt.Errorf("cloud Agent journal cannot be truncated")
}
// journal 是 append-only:本次转移之前已经载入的事件不许改写。
// 内存里只有最近一窗,窗口右移后更早的行会落在新窗口之外——那些行不在
// run.Journal 里,也不会被下面的 Create 循环碰到(只追加 seq > 水位的新行),
// 因此只要求它们确实比新窗口更早,而不是要求它们仍在窗口内。
current := make(map[int]string, len(run.Journal))
for _, event := range run.Journal {
current[event.Sequence] = event.EventJSON
}
for sequence, body := range previousEventBodies {
updated, present := current[sequence]
if !present {
if len(run.Journal) == 0 || sequence >= run.Journal[0].Sequence {
return fmt.Errorf("cloud Agent journal is append-only")
}
continue
}
if !sameJSONDocument(updated, body) {
return fmt.Errorf("cloud Agent journal is append-only")
}
}
// 水位与窗口必须自洽:窗口最后一条就是要落库的最后一条,否则说明写入端
// 算错了窗口起点,或行被删掉过。
if len(run.Journal) > 0 && run.Journal[len(run.Journal)-1].Sequence != run.EventCount {
return fmt.Errorf("cloud Agent journal watermark is inconsistent")
}
if err = tx.Omit("Journal", "Transcript").Save(run).Error; err != nil {
return err
}
for _, event := range run.Journal {
if event.Sequence <= previousEvents {
continue
}
if err = tx.Create(&event).Error; err != nil {
return err
}
}
for _, message := range run.Transcript {
key := fmt.Sprintf("%s:%d", message.Kind, message.Sequence)
if previousMessages[key] == message.MessageJSON {
continue
}
if err = tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "run_id"}, {Name: "kind"}, {Name: "sequence"}}, DoUpdates: clause.AssignmentColumns([]string{"message_json"})}).Create(&message).Error; err != nil {
return err
}
}
for _, kind := range []string{"canonical", "history"} {
count := 0
for _, message := range run.Transcript {
if message.Kind == kind {
count++
}
}
if err = tx.Where("run_id = ? AND user_id = ? AND kind = ? AND sequence > ?", run.ID, run.UserID, kind, count).Delete(&model.CloudAgentMessageRecord{}).Error; err != nil {
return err
}
}
return nil
})
}
// sameJSONDocument compares event records by JSON meaning rather than source
// bytes. Event payloads may contain json.RawMessage (for example tool arguments),
// so decode/re-encode can legally normalize whitespace or object key order while
// preserving the immutable event contract.
func sameJSONDocument(left, right string) bool {
decode := func(raw string) (any, error) {
decoder := json.NewDecoder(bytes.NewReader([]byte(raw)))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
var extra any
if err := decoder.Decode(&extra); err == nil {
return nil, fmt.Errorf("multiple JSON documents")
}
return value, nil
}
leftValue, leftErr := decode(left)
rightValue, rightErr := decode(right)
if leftErr != nil || rightErr != nil {
return left == right
}
leftCanonical, leftErr := json.Marshal(leftValue)
rightCanonical, rightErr := json.Marshal(rightValue)
return leftErr == nil && rightErr == nil && bytes.Equal(leftCanonical, rightCanonical)
}
func (r *Repository) CreateCloudAgentCanvasMutation(mutation *model.CloudAgentCanvasMutation) error {
return r.db.Create(mutation).Error
}
func (r *Repository) LatestCloudAgentCanvasMutation(userID, runID string) (*model.CloudAgentCanvasMutation, error) {
var mutation model.CloudAgentCanvasMutation
err := r.db.Where("user_id = ? AND run_id = ?", userID, runID).
Order("created_at DESC, id DESC").First(&mutation).Error
return &mutation, err
}
func (r *Repository) MarkCloudAgentCanvasMutationUndone(userID, runID, mutationID string, undoneAt time.Time) error {
result := r.db.Model(&model.CloudAgentCanvasMutation{}).
Where("id = ? AND user_id = ? AND run_id = ? AND status = ?", mutationID, userID, runID, "applied").
Updates(map[string]any{"status": "undone", "undone_at": undoneAt})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrCreationConflict
}
return nil
}
// MarkCloudAgentFailed is a terminal CAS transition that does not read or
// rewrite StateJSON. It is deliberately usable when a corrupted runtime state
// can no longer be decoded; leaving such a run in running would make the
// scheduler retry it forever.
func (r *Repository) MarkCloudAgentFailed(userID, id string, revision int64, message ...string) error {
detail := "Agent 运行状态损坏,本轮已停止"
if len(message) > 0 {
detail = message[0]
}
result := r.db.Model(&model.CloudAgentExecution{}).
Where("id = ? AND user_id = ? AND revision = ? AND status IN ?", id, userID, revision, []string{"queued", "running"}).
Updates(map[string]any{"status": "failed", "cleanup_pending": true, "failure_message": detail, "revision": gorm.Expr("revision + 1")})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrCreationConflict
}
return nil
}
// MarkCloudAgentCancelled is the corruption-safe cancellation transition. It
// intentionally does not touch StateJSON: cancellation must still stop the
// scheduler when the orchestration blob can no longer be decoded.
func (r *Repository) MarkCloudAgentCancelled(userID, id string, revision int64) error {
result := r.db.Model(&model.CloudAgentExecution{}).
Where("id = ? AND user_id = ? AND revision = ? AND status IN ?", id, userID, revision, []string{"queued", "running", "waiting_approval"}).
Updates(map[string]any{"status": "cancelled", "cleanup_pending": true, "revision": gorm.Expr("revision + 1")})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrCreationConflict
}
return nil
}
// GeminiCacheByKey returns an official Gemini CachedContent owned by the user.
func (r *Repository) GeminiCacheByKey(userID, cacheKey string) (*model.CloudAgentGeminiCache, error) {
var cache model.CloudAgentGeminiCache
if err := r.db.Where("user_id = ? AND cache_key = ?", userID, cacheKey).First(&cache).Error; err != nil {
return nil, err
}
return &cache, nil
}
// UpsertGeminiCache persists the resource name and expiry returned by Gemini.
func (r *Repository) UpsertGeminiCache(cache *model.CloudAgentGeminiCache) error {
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "cache_key"}},
DoUpdates: clause.AssignmentColumns([]string{
"user_id", "base_url", "model", "credential_hash", "resource_name", "expire_time", "updated_at",
}),
}).Create(cache).Error
}
func (r *Repository) DeleteGeminiCache(userID, cacheKey string) error {
return r.db.Where("user_id = ? AND cache_key = ?", userID, cacheKey).Delete(&model.CloudAgentGeminiCache{}).Error
}
// DeleteGeminiCacheIfResourceMatches prevents a late request using an old
// CachedContent resource from deleting a newer cache for the same identity.
func (r *Repository) DeleteGeminiCacheIfResourceMatches(userID, cacheKey, resourceName string) (bool, error) {
result := r.db.Where("user_id = ? AND cache_key = ? AND resource_name = ?", userID, cacheKey, resourceName).Delete(&model.CloudAgentGeminiCache{})
return result.RowsAffected == 1, result.Error
}