From 3e50da4a49717dd62ac7b7f1fc30e5c2fcdebfbd Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Thu, 7 May 2026 14:21:00 +0800 Subject: [PATCH] feat(workspace): session-scoped Caller.Holder + ReleaseByHolder for ACP integration --- internal/workspace/domain.go | 19 +++++++-- internal/workspace/queries/worktrees.sql | 10 +++++ internal/workspace/repository.go | 15 +++++++ internal/workspace/sqlc/worktrees.sql.go | 42 ++++++++++++++++++++ internal/workspace/workspace_service_test.go | 3 ++ internal/workspace/worktree_service.go | 6 +++ 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/internal/workspace/domain.go b/internal/workspace/domain.go index ede1fe8..d4a870a 100644 --- a/internal/workspace/domain.go +++ b/internal/workspace/domain.go @@ -112,14 +112,21 @@ type FetchTarget struct { } // Caller 表示发起请求的用户上下文。Service 据此判定鉴权与 holder 字符串。 +// SessionID 非 nil 时,Holder() 返回 "session:"(ACP 模块用), +// 否则返回 "user:"。 type Caller struct { - UserID uuid.UUID - IsAdmin bool + UserID uuid.UUID + IsAdmin bool + SessionID *uuid.UUID } -// Holder 把 Caller 折成 acquire_holder 字符串。Plan #4 的 ACP supervisor 会用 -// "session:" 形式调相同接口。 +// Holder 把 Caller 折成 acquire_holder 字符串。 +// - SessionID 非 nil → "session:"(ACP supervisor) +// - 否则 → "user:"(普通 HTTP 调用) func (c Caller) Holder() string { + if c.SessionID != nil { + return "session:" + c.SessionID.String() + } return "user:" + c.UserID.String() } @@ -147,6 +154,10 @@ type WorktreeService interface { Delete(ctx context.Context, c Caller, wtID uuid.UUID) error Acquire(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error) Release(ctx context.Context, c Caller, wtID uuid.UUID) (*Worktree, error) + // ReleaseByHolder 按 holder 字符串释放所有该 holder 持有的 worktree。 + // 由 ACP startup reaper / supervisor.onExit 在不知道 wtID 时使用。 + // 不写 audit(caller 自己写 — audit 上下文是 caller 知道的)。 + ReleaseByHolder(ctx context.Context, holder string) ([]*Worktree, error) } // GitOpsService 暴露 status/commit/push 操作。target 既可以是 Workspace(main_path) diff --git a/internal/workspace/queries/worktrees.sql b/internal/workspace/queries/worktrees.sql index fbd38e7..6d9eeb7 100644 --- a/internal/workspace/queries/worktrees.sql +++ b/internal/workspace/queries/worktrees.sql @@ -81,3 +81,13 @@ DELETE FROM workspace_worktrees WHERE id = $1; UPDATE workspace_worktrees SET status = 'idle' WHERE id = $1 AND status = 'pruning'; + +-- name: ReleaseWorktreesByHolder :many +UPDATE workspace_worktrees +SET status = 'idle', + active_holder = NULL, + acquired_at = NULL, + last_used_at = now(), + prune_warning_at = NULL +WHERE active_holder = $1 AND status = 'active' +RETURNING *; diff --git a/internal/workspace/repository.go b/internal/workspace/repository.go index 09bd8c4..776984f 100644 --- a/internal/workspace/repository.go +++ b/internal/workspace/repository.go @@ -58,6 +58,9 @@ type Repository interface { TryStartPrune(ctx context.Context, id uuid.UUID) (*Worktree, error) FinishPruneSuccess(ctx context.Context, id uuid.UUID) error FinishPruneRollback(ctx context.Context, id uuid.UUID) error + // ReleaseWorktreesByHolder 一次性释放某 holder 持有的所有活跃 worktree。 + // 用于 ACP 启动 reaper / supervisor.onExit 不知道具体 wtID 时的清理。 + ReleaseWorktreesByHolder(ctx context.Context, holder string) ([]*Worktree, error) // Workspace fetch (jobs runner) ListFetchTargets(ctx context.Context) ([]FetchTarget, error) @@ -403,6 +406,18 @@ func (r *pgRepo) FinishPruneRollback(ctx context.Context, id uuid.UUID) error { return nil } +func (r *pgRepo) ReleaseWorktreesByHolder(ctx context.Context, holder string) ([]*Worktree, error) { + rows, err := r.q.ReleaseWorktreesByHolder(ctx, &holder) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "release worktrees by holder") + } + out := make([]*Worktree, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToWorktree(row)) + } + return out, nil +} + // ===== Credential ===== func rowToCredential(r workspacesqlc.GitCredential) *Credential { diff --git a/internal/workspace/sqlc/worktrees.sql.go b/internal/workspace/sqlc/worktrees.sql.go index c99a50d..f1f2298 100644 --- a/internal/workspace/sqlc/worktrees.sql.go +++ b/internal/workspace/sqlc/worktrees.sql.go @@ -282,6 +282,48 @@ func (q *Queries) MarkPruneWarning(ctx context.Context, id pgtype.UUID) error { return err } +const releaseWorktreesByHolder = `-- name: ReleaseWorktreesByHolder :many +UPDATE workspace_worktrees +SET status = 'idle', + active_holder = NULL, + acquired_at = NULL, + last_used_at = now(), + prune_warning_at = NULL +WHERE active_holder = $1 AND status = 'active' +RETURNING id, workspace_id, branch, path, status, active_holder, acquired_at, last_used_at, created_at, prune_warning_at +` + +func (q *Queries) ReleaseWorktreesByHolder(ctx context.Context, activeHolder *string) ([]WorkspaceWorktree, error) { + rows, err := q.db.Query(ctx, releaseWorktreesByHolder, activeHolder) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceWorktree + for rows.Next() { + var i WorkspaceWorktree + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Branch, + &i.Path, + &i.Status, + &i.ActiveHolder, + &i.AcquiredAt, + &i.LastUsedAt, + &i.CreatedAt, + &i.PruneWarningAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setWorktreeActive = `-- name: SetWorktreeActive :one UPDATE workspace_worktrees SET status = 'active', diff --git a/internal/workspace/workspace_service_test.go b/internal/workspace/workspace_service_test.go index a9f8437..fd1fd7a 100644 --- a/internal/workspace/workspace_service_test.go +++ b/internal/workspace/workspace_service_test.go @@ -105,6 +105,9 @@ func (f *fakeRepo) TryStartPrune(_ context.Context, _ uuid.UUID) (*Worktree, err } func (f *fakeRepo) FinishPruneSuccess(_ context.Context, _ uuid.UUID) error { return nil } func (f *fakeRepo) FinishPruneRollback(_ context.Context, _ uuid.UUID) error { return nil } +func (f *fakeRepo) ReleaseWorktreesByHolder(_ context.Context, _ string) ([]*Worktree, error) { + return nil, nil +} func (f *fakeRepo) UpsertCredential(_ context.Context, c *Credential) (*Credential, error) { cp := *c f.creds[c.WorkspaceID] = &cp diff --git a/internal/workspace/worktree_service.go b/internal/workspace/worktree_service.go index 3790b5f..22a607b 100644 --- a/internal/workspace/worktree_service.go +++ b/internal/workspace/worktree_service.go @@ -184,6 +184,12 @@ func (s *worktreeService) Release(ctx context.Context, c Caller, wtID uuid.UUID) return out, nil } +// ReleaseByHolder 释放给定 holder 字符串持有的所有活跃 worktree。 +// 实现仅委托 repo;audit 由 caller 在更高层根据自己上下文写入。 +func (s *worktreeService) ReleaseByHolder(ctx context.Context, holder string) ([]*Worktree, error) { + return s.repo.ReleaseWorktreesByHolder(ctx, holder) +} + func (s *worktreeService) audit(ctx context.Context, uid *uuid.UUID, action, ttype, tid string, meta map[string]any) { _ = s.rec.Record(ctx, audit.Entry{ UserID: uid, Action: action, TargetType: ttype, TargetID: tid, Metadata: meta,