Skip to content

Commit dd32019

Browse files
committed
fix(summary): harden unified workspace integration
1 parent 2c0dcf2 commit dd32019

32 files changed

Lines changed: 768 additions & 194 deletions

cmd/summary-api/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ func main() {
3838
if cfg.DefaultTimeRangeDays > 0 {
3939
pipeline.DefaultTimeRangeDays = cfg.DefaultTimeRangeDays
4040
}
41+
if cfg.MaxTimeRangeDays > 0 {
42+
pipeline.MaxTimeRangeDays = cfg.MaxTimeRangeDays
43+
}
4144
// EnableIntentShortcut defaults to true, so we always apply it
4245
pipeline.EnableIntentShortcut = cfg.EnableIntentShortcut
4346

cmd/summary-worker/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ func main() {
3535
if cfg.DefaultTimeRangeDays > 0 {
3636
pipeline.DefaultTimeRangeDays = cfg.DefaultTimeRangeDays
3737
}
38+
if cfg.MaxTimeRangeDays > 0 {
39+
pipeline.MaxTimeRangeDays = cfg.MaxTimeRangeDays
40+
}
3841
// EnableIntentShortcut defaults to true, so we always apply it
3942
pipeline.EnableIntentShortcut = cfg.EnableIntentShortcut
4043

internal/agent/channel_scope_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ func TestDiscoverableChannelScopeStartsClosedAndGrantsConfirmedChannels(t *testi
6363
}
6464
}
6565

66+
func TestDiscoverableChannelScopeUnionsMultipleDiscoveryResults(t *testing.T) {
67+
ctx := context.WithValue(context.Background(), ContextKeyUID, "user-1")
68+
ctx = WithDiscoverableChannelScope(ctx)
69+
AuthorizeDiscoveredChannels(ctx, []pipeline.ChannelInfo{{ChannelID: "group-1", ChannelType: model.ChannelTypeGroup}})
70+
AuthorizeDiscoveredChannels(ctx, []pipeline.ChannelInfo{{ChannelID: "group-2", ChannelType: model.ChannelTypeGroup}})
71+
72+
for _, channelID := range []string{"group-1", "group-2"} {
73+
if restricted, allowed := ChannelAllowedByScope(ctx, channelID, model.ChannelTypeGroup); !restricted || !allowed {
74+
t.Fatalf("channel %s = (%v,%v), want retained union grant", channelID, restricted, allowed)
75+
}
76+
}
77+
}
78+
6679
func TestClosedChannelScopeCannotBeExpandedByDiscovery(t *testing.T) {
6780
ctx := context.WithValue(context.Background(), ContextKeyUID, "user-1")
6881
ctx = WithAllowedChannelScope(ctx, []ChannelScope{{ChannelID: "group-1", ChannelType: model.ChannelTypeGroup}})

internal/agent/tool_fetch_channel.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"log"
8+
"strings"
89
"time"
910

1011
"github.com/Mininglamp-OSS/octo-smart-summary/internal/agent/summaryrun"
@@ -132,6 +133,9 @@ func FetchChannelTool() (Tool, Handler) {
132133

133134
// Security: validate channel accessibility for system-injected uid
134135
options := []pipeline.ChannelQueryOption{pipeline.WithIncludeArchived(req.IncludeArchived)}
136+
if spaceID := strings.TrimSpace(WorkspaceSpaceID(ctx)); spaceID != "" {
137+
options = append(options, pipeline.WithSpaceID(spaceID))
138+
}
135139
if !req.IncludeArchived {
136140
options = append(options, pipeline.WithSelectedThreads(SelectedArchivedChannelIDs(ctx)))
137141
}

internal/agent/tool_peek_channel.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"log"
8+
"strings"
89
"time"
910

1011
"github.com/Mininglamp-OSS/octo-smart-summary/internal/pipeline"
@@ -108,6 +109,9 @@ func PeekChannelTool() (Tool, Handler) {
108109

109110
// Security: validate channel accessibility for system-injected uid
110111
options := []pipeline.ChannelQueryOption{pipeline.WithIncludeArchived(req.IncludeArchived)}
112+
if spaceID := strings.TrimSpace(WorkspaceSpaceID(ctx)); spaceID != "" {
113+
options = append(options, pipeline.WithSpaceID(spaceID))
114+
}
111115
if !req.IncludeArchived {
112116
options = append(options, pipeline.WithSelectedThreads(SelectedArchivedChannelIDs(ctx)))
113117
}

internal/agent/types.go

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -197,16 +197,6 @@ func (s *mutableChannelScope) addLocked(channels []ChannelScope, uid string) {
197197
}
198198
}
199199

200-
func (s *mutableChannelScope) replace(channels []ChannelScope, uid string) {
201-
if s == nil {
202-
return
203-
}
204-
s.mu.Lock()
205-
defer s.mu.Unlock()
206-
s.allowed = make(map[int]map[string]ChannelScope)
207-
s.addLocked(channels, uid)
208-
}
209-
210200
// WithAllowedChannelScope restricts channel-reading tools to the exact set
211201
// already authorised by the application layer. Calling it with an empty slice
212202
// intentionally installs an empty allowlist; absence of the value keeps legacy
@@ -247,7 +237,7 @@ func AuthorizeDiscoveredChannels(ctx context.Context, channels []pipeline.Channe
247237
IsArchived: channel.IsArchived,
248238
})
249239
}
250-
scope.replace(grants, uid)
240+
scope.add(grants, uid)
251241
return true
252242
}
253243

internal/api/handler/agent_chat.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ func resolveChatProfile(reqProfile string, hasChannels, hasReferences bool) (pro
7575
}
7676

7777
// maxMessageLen 是单条用户 message 的最大字符数(rune),超长直接 400,避免超长入参打爆上游。
78-
const maxMessageLen = 8192
78+
const (
79+
maxMessageLen = 8192
80+
maxAgentChatRequestBodySize = 512 << 10
81+
)
7982

8083
// sessionIDPattern 约束前端生成的 session_id:仅字母数字下划线连字符、1..128 长。
8184
// 既防注入/异常键,也与 DB varchar(128) 对齐。
@@ -585,6 +588,7 @@ func (h *AgentChatHandler) maybePersistSummaryRun(ctx context.Context, uid strin
585588
// AppendMessages 全程无锁,若同 session 并发进入会读到相同历史各自续写,产生分叉历史;
586589
// 锁 / 版本号方案留后续,本轮不实现。
587590
func (h *AgentChatHandler) Chat(c *gin.Context) {
591+
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxAgentChatRequestBodySize)
588592
var req agentChatRequest
589593
if err := c.ShouldBindJSON(&req); err != nil {
590594
c.JSON(http.StatusBadRequest, apiResponse{Code: 40000, Message: "invalid request body"})
@@ -882,6 +886,7 @@ func (s *sseSink) write(event string, payload []byte) {
882886
// Context timeout: 300s (longer than Chat's 120s for map-reduce workloads).
883887
// Database persistence: same as Chat (AppendMessages only on success, no progress events stored).
884888
func (h *AgentChatHandler) ChatStream(c *gin.Context) {
889+
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxAgentChatRequestBodySize)
885890
var req agentChatRequest
886891
if err := c.ShouldBindJSON(&req); err != nil {
887892
c.JSON(http.StatusBadRequest, apiResponse{Code: 40000, Message: "invalid request body"})

internal/api/handler/agent_session_cleanup.go

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package handler
22

33
import (
44
"context"
5+
"fmt"
56
"log"
7+
"strings"
68
"time"
79

810
"github.com/Mininglamp-OSS/octo-smart-summary/internal/model"
11+
"github.com/Mininglamp-OSS/octo-smart-summary/internal/timezone"
912
"gorm.io/gorm"
1013
)
1114

@@ -34,6 +37,9 @@ const (
3437
cleanupSlowThreshold = 1 * time.Second
3538
// cleanupJitter 首次执行前等一段随机时间,避免多实例撞车 & 冷启动瞬间打 DB
3639
cleanupInitialDelay = 30 * time.Second
40+
// workspaceCleanupBatchSize bounds each cleanup transaction so a large
41+
// backlog does not hold locks across the entire workspace history table.
42+
workspaceCleanupBatchSize = 200
3743
)
3844

3945
// StartAgentSessionCleanup 启动 24h 定时清理 goroutine。
@@ -85,8 +91,9 @@ func StartAgentSessionCleanup(ctx context.Context, db *gorm.DB) {
8591
// summarize_chunk. Evidence expiry uses its own last-activity timestamp, while
8692
// durable workspace references below prevent premature collection.
8793
func runOnce(db *gorm.DB) {
88-
cutoff := time.Now().Add(-cleanupAge)
89-
start := time.Now()
94+
now := timezone.Now()
95+
cutoff := now.Add(-cleanupAge)
96+
start := timezone.Now()
9097

9198
// 只在 Legacy 消息(space_id = '')中按 (user_id, session_id) 聚合
9299
// MAX(created_at),定位两键都过期的 tuple。workspace 消息由持久化 session
@@ -128,6 +135,18 @@ func runOnce(db *gorm.DB) {
128135
elapsed, result.RowsAffected, cutoff.Format(time.RFC3339))
129136
}
130137

138+
workspaceStart := timezone.Now()
139+
workspaceCount, workspaceErr := cleanupExpiredSummaryWorkspaces(db, now)
140+
workspaceElapsed := time.Since(workspaceStart)
141+
if workspaceErr != nil {
142+
log.Printf("[agent-cleanup] ERROR workspace cleanup failed after %s: %v", workspaceElapsed, workspaceErr)
143+
} else if workspaceCount > 0 {
144+
log.Printf("[agent-cleanup] cleaned %d expired workspace sessions in %s", workspaceCount, workspaceElapsed)
145+
}
146+
if workspaceElapsed > cleanupSlowThreshold {
147+
log.Printf("[agent-cleanup] SLOW workspace cleanup took %s (sessions=%d)", workspaceElapsed, workspaceCount)
148+
}
149+
131150
// #161 P2 (yujiawei): symmetric evidence cleanup. Delete evidence rows
132151
// for (user_id, session_id) tuples whose evidence itself is older than
133152
// cleanupAge. Keying off evidence.created_at (not agent_message) is
@@ -138,7 +157,7 @@ func runOnce(db *gorm.DB) {
138157
// agent_session_id. Preserve every (user_id, agent_session_id) tuple referenced
139158
// by agent_summary_session; workspace retirement must remove that session
140159
// before this Legacy cleanup may collect its evidence.
141-
evStart := time.Now()
160+
evStart := timezone.Now()
142161
evResult := db.Exec(`
143162
DELETE FROM agent_message_evidence
144163
WHERE (user_id, session_id) IN (
@@ -171,6 +190,112 @@ func runOnce(db *gorm.DB) {
171190
}
172191
}
173192

193+
// cleanupExpiredSummaryWorkspaces retires inactive workspace state after the
194+
// 30-day sliding retention window maintained by AgentWorkspaceStore. Rows
195+
// created before expires_at was populated fall back to updated_at so rollout
196+
// does not leave permanent NULL tombstones.
197+
func cleanupExpiredSummaryWorkspaces(db *gorm.DB, now time.Time) (int64, error) {
198+
if db == nil {
199+
return 0, fmt.Errorf("workspace cleanup database is required")
200+
}
201+
legacyCutoff := now.Add(-summaryWorkspaceRetention)
202+
var cleaned int64
203+
for {
204+
var sessions []model.AgentSummarySession
205+
if err := db.Where("expires_at <= ? OR (expires_at IS NULL AND updated_at <= ?)", now, legacyCutoff).
206+
Order("id ASC").Limit(workspaceCleanupBatchSize).Find(&sessions).Error; err != nil {
207+
return cleaned, fmt.Errorf("load expired workspace sessions: %w", err)
208+
}
209+
if len(sessions) == 0 {
210+
break
211+
}
212+
if err := db.Transaction(func(tx *gorm.DB) error {
213+
for _, session := range sessions {
214+
if err := deleteWorkspaceSessionState(tx, session); err != nil {
215+
return err
216+
}
217+
}
218+
return nil
219+
}); err != nil {
220+
return cleaned, err
221+
}
222+
cleaned += int64(len(sessions))
223+
if len(sessions) < workspaceCleanupBatchSize {
224+
break
225+
}
226+
}
227+
228+
// Idempotency bindings only need to outlive their live task. Retire old
229+
// orphan/tombstone rows after the same window while preserving bindings for
230+
// summaries that still exist.
231+
if err := db.Exec(`
232+
DELETE FROM summary_workflow_idempotency
233+
WHERE created_at <= ?
234+
AND NOT EXISTS (
235+
SELECT 1 FROM summary_task
236+
WHERE summary_task.id = summary_workflow_idempotency.task_id
237+
AND summary_task.deleted_at IS NULL
238+
)
239+
`, legacyCutoff).Error; err != nil {
240+
return cleaned, fmt.Errorf("clean workflow idempotency tombstones: %w", err)
241+
}
242+
return cleaned, nil
243+
}
244+
245+
func deleteWorkspaceSessionState(tx *gorm.DB, session model.AgentSummarySession) error {
246+
var runIDs []string
247+
if err := tx.Model(&model.AgentMessage{}).
248+
Where("space_id = ? AND user_id = ? AND session_id = ? AND run_id <> ''", session.SpaceID, session.UserID, session.SessionID).
249+
Distinct().Pluck("run_id", &runIDs).Error; err != nil {
250+
return fmt.Errorf("load workspace run ids for session %d: %w", session.ID, err)
251+
}
252+
253+
evidenceSessions := make([]string, 0, len(runIDs)+1)
254+
if strings.TrimSpace(session.AgentSessionID) != "" {
255+
evidenceSessions = append(evidenceSessions, session.AgentSessionID)
256+
}
257+
if len(runIDs) > 0 {
258+
var runs []model.AgentSummaryRun
259+
if err := tx.Select("run_id", "session_id").Where("user_id = ? AND run_id IN ?", session.UserID, runIDs).Find(&runs).Error; err != nil {
260+
return fmt.Errorf("load workspace runs for session %d: %w", session.ID, err)
261+
}
262+
for _, run := range runs {
263+
if strings.TrimSpace(run.SessionID) != "" {
264+
evidenceSessions = append(evidenceSessions, run.SessionID)
265+
}
266+
}
267+
if err := tx.Where("run_id IN ?", runIDs).Delete(&model.AgentCitationManifest{}).Error; err != nil {
268+
return fmt.Errorf("delete workspace citation manifests for session %d: %w", session.ID, err)
269+
}
270+
if err := tx.Where("run_id IN ?", runIDs).Delete(&model.AgentEvidenceArtifact{}).Error; err != nil {
271+
return fmt.Errorf("delete workspace evidence artifacts for session %d: %w", session.ID, err)
272+
}
273+
if err := tx.Where("run_id IN ?", runIDs).Delete(&model.AgentSummarySpec{}).Error; err != nil {
274+
return fmt.Errorf("delete workspace specs for session %d: %w", session.ID, err)
275+
}
276+
if err := tx.Where("user_id = ? AND run_id IN ?", session.UserID, runIDs).Delete(&model.AgentSummaryRun{}).Error; err != nil {
277+
return fmt.Errorf("delete workspace runs for session %d: %w", session.ID, err)
278+
}
279+
}
280+
if len(evidenceSessions) > 0 {
281+
if err := tx.Where("user_id = ? AND session_id IN ?", session.UserID, evidenceSessions).Delete(&model.AgentMessageEvidence{}).Error; err != nil {
282+
return fmt.Errorf("delete workspace evidence for session %d: %w", session.ID, err)
283+
}
284+
}
285+
if err := tx.Where("space_id = ? AND user_id = ? AND session_id = ?", session.SpaceID, session.UserID, session.SessionID).
286+
Delete(&model.AgentMessage{}).Error; err != nil {
287+
return fmt.Errorf("delete workspace messages for session %d: %w", session.ID, err)
288+
}
289+
if err := tx.Where("space_id = ? AND user_id = ? AND session_id = ?", session.SpaceID, session.UserID, session.SessionID).
290+
Delete(&model.AgentSummaryTurn{}).Error; err != nil {
291+
return fmt.Errorf("delete workspace turns for session %d: %w", session.ID, err)
292+
}
293+
if err := tx.Where("id = ?", session.ID).Delete(&model.AgentSummarySession{}).Error; err != nil {
294+
return fmt.Errorf("delete workspace session %d: %w", session.ID, err)
295+
}
296+
return nil
297+
}
298+
174299
// 兜底类型检查:确保 AgentMessage 表名不变时这段代码还生效
175300
var _ = model.AgentMessage{}
176301
var _ = model.AgentMessageEvidence{}

0 commit comments

Comments
 (0)