Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/unified-summary-safe-track.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Unified summary: safe-track implementation boundary

This branch establishes the low-conflict backend foundation for the unified
summary entry without changing the Agent loop contract.

## Implemented now

- `SummaryWorkflowService` owns the existing `POST /summaries` validation,
normalization and transaction instead of the HTTP Handler.
- Optional `Idempotency-Key` support prevents duplicate task creation and
duplicate Worker dispatch; same-key/different-request returns `40009`.
- `DeriveSummaryRoute` expresses the deterministic personal/team/preview/
revision/explanation/clarification routing boundary. It is a tested contract,
not production enforcement until the Agent adapter is connected.
- Existing SUM-BE1 `SnapshotScope` validation and PR #202 idempotency patterns
are reused from `main`; old Loop worktree commits are not cherry-picked.

## Compatibility boundary

`CreateFromLegacyHTTP` deliberately preserves the existing endpoint contract,
including its legacy `uid` override and pre-existing authorization behavior.
It must not be called directly by an Agent tool.

The future Agent adapters must provide separate policy-gated entry points that:

1. bind the creator to the authenticated actor;
2. require an idempotency key;
3. authorize every source and participant in the current Space;
4. require a current server-side proposal version before team creation.

The legacy endpoint still uses `pipeline.DefaultTimeRangeDays` (currently 31)
for compatibility. If the product keeps the proposed seven-day Agent default,
the Agent adapter must pass that explicit range instead of changing legacy HTTP
behavior in this branch.

## Deferred until the core Agent PRs stabilize

- `registry.go` / `runner.go` terminal-tool support (`emit_summary_response`);
- `agent_chat.go` JSON, SSE and history contract changes;
- `agent_message` result metadata and session wiring;
- preview/revision-only save enforcement in `agent_summary*`;
- team proposal persistence and confirmation wiring.

The Agent integration should be based on the final versions of #213, #210 and
#215. PR #209's whole-session finalize semantics are not a prerequisite.
36 changes: 23 additions & 13 deletions internal/agent/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,26 @@ import (
"time"

"github.com/Mininglamp-OSS/octo-smart-summary/internal/pipeline"
"github.com/google/uuid"
)

// messageCache is an in-memory cache for fetched messages with owner isolation.
// Handles are bound to uid; Retrieve validates ownership before returning data.
// messageCache is an in-memory cache for fetched messages with owner and
// conversation-scope isolation. A handle is valid only for the exact
// (uid, sessionID) pair that minted it. summary_workspace supplies its derived
// Agent session id here (space + public session + scope version), while Legacy
// supplies its existing public session id.
var messageCache = newMessageCache()

type cacheEntry struct {
messages []pipeline.Message
uid string
sessionID string
createdAt time.Time
}

type msgCache struct {
mu sync.RWMutex
store map[string]cacheEntry
counter int
maxSize int // max number of entries before eviction
ttl time.Duration // time-to-live for entries
}
Expand All @@ -34,9 +38,13 @@ func newMessageCache() *msgCache {
}
}

// Store saves messages bound to a uid and returns a unique handle.
// The handle encodes the uid for ownership validation on Retrieve.
func (c *msgCache) Store(messages []pipeline.Message, uid string) string {
// Store saves messages bound to an exact uid/session identity and returns a
// unique opaque handle. Identity is stored in the entry rather than trusted
// from the handle text.
func (c *msgCache) Store(messages []pipeline.Message, uid, sessionID string) string {
if uid == "" || sessionID == "" {
return ""
}
c.mu.Lock()
defer c.mu.Unlock()

Expand All @@ -48,19 +56,22 @@ func (c *msgCache) Store(messages []pipeline.Message, uid string) string {
c.evictOldest()
}

c.counter++
handle := fmt.Sprintf("msg_%s_%d", safeHandleUID(uid), c.counter)
handle := fmt.Sprintf("msg_%s_%s", safeHandleUID(uid), uuid.NewString())
c.store[handle] = cacheEntry{
messages: messages,
uid: uid,
sessionID: sessionID,
createdAt: time.Now(),
}
return handle
}

// Retrieve fetches messages by handle, validating that the requesting uid matches the owner.
// Returns nil if handle not found or uid mismatch.
func (c *msgCache) Retrieve(handle, uid string) []pipeline.Message {
// Retrieve fetches messages by handle, validating the exact owner and session
// identity. Returns nil for a missing, expired, or cross-session handle.
func (c *msgCache) Retrieve(handle, uid, sessionID string) []pipeline.Message {
if handle == "" || uid == "" || sessionID == "" {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()

Expand All @@ -70,7 +81,7 @@ func (c *msgCache) Retrieve(handle, uid string) []pipeline.Message {
}

// Ownership validation
if entry.uid != uid {
if entry.uid != uid || entry.sessionID != sessionID {
return nil
}

Expand Down Expand Up @@ -136,5 +147,4 @@ func ResetForTest() {
messageCache.mu.Lock()
defer messageCache.mu.Unlock()
messageCache.store = make(map[string]cacheEntry)
messageCache.counter = 0
}
133 changes: 133 additions & 0 deletions internal/agent/channel_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package agent

import (
"context"
"errors"
"strings"
"testing"
"time"

"github.com/Mininglamp-OSS/octo-smart-summary/internal/model"
"github.com/Mininglamp-OSS/octo-smart-summary/internal/pipeline"
)

func TestAllowedChannelScopeIsOptInAndExact(t *testing.T) {
if restricted, allowed := ChannelAllowedByScope(context.Background(), "group-1", 2); restricted || allowed {
t.Fatalf("unscoped legacy context = (%v, %v), want (false, false)", restricted, allowed)
}

ctx := WithAllowedChannelScope(context.Background(), []ChannelScope{
{ChannelID: "group-1", ChannelType: 2},
})
if restricted, allowed := ChannelAllowedByScope(ctx, "group-1", 2); !restricted || !allowed {
t.Fatalf("selected channel = (%v, %v), want (true, true)", restricted, allowed)
}
for _, candidate := range []ChannelScope{
{ChannelID: "group-2", ChannelType: 2},
{ChannelID: "group-1", ChannelType: 5},
} {
if restricted, allowed := ChannelAllowedByScope(ctx, candidate.ChannelID, candidate.ChannelType); !restricted || allowed {
t.Fatalf("unselected channel %+v = (%v, %v), want (true, false)", candidate, restricted, allowed)
}
}

empty := WithAllowedChannelScope(context.Background(), nil)
if restricted, allowed := ChannelAllowedByScope(empty, "group-1", 2); !restricted || allowed {
t.Fatalf("empty explicit scope = (%v, %v), want (true, false)", restricted, allowed)
}
}

func TestDiscoverableChannelScopeStartsClosedAndGrantsConfirmedChannels(t *testing.T) {
ctx := context.WithValue(context.Background(), ContextKeyUID, "user-1")
ctx = WithDiscoverableChannelScope(ctx)
if restricted, allowed := ChannelAllowedByScope(ctx, "group-1", model.ChannelTypeGroup); !restricted || allowed {
t.Fatalf("open discovery must still start with a deny-all read scope, got (%v,%v)", restricted, allowed)
}

// This models an all-chat narrowing result: returning the full candidate set
// is still an explicit, trusted scope decision and must authorize every item.
all := []pipeline.ChannelInfo{
{ChannelID: "group-1", ChannelType: model.ChannelTypeGroup, ChannelName: "项目群"},
{ChannelID: "group-2", ChannelType: model.ChannelTypeGroup, ChannelName: "产品群"},
}
if !AuthorizeDiscoveredChannels(ctx, all) {
t.Fatal("discoverable scope rejected trusted discovery result")
}
for _, channel := range all {
if restricted, allowed := ChannelAllowedByScope(ctx, channel.ChannelID, channel.ChannelType); !restricted || !allowed {
t.Fatalf("discovered channel %s = (%v,%v), want (true,true)", channel.ChannelID, restricted, allowed)
}
}
if got := AllowedChannelScopes(ctx); len(got) != 2 {
t.Fatalf("effective discovered scope = %#v, want 2 channels", got)
}
}

func TestClosedChannelScopeCannotBeExpandedByDiscovery(t *testing.T) {
ctx := context.WithValue(context.Background(), ContextKeyUID, "user-1")
ctx = WithAllowedChannelScope(ctx, []ChannelScope{{ChannelID: "group-1", ChannelType: model.ChannelTypeGroup}})
if AuthorizeDiscoveredChannels(ctx, []pipeline.ChannelInfo{{ChannelID: "group-2", ChannelType: model.ChannelTypeGroup}}) {
t.Fatal("closed UI scope must reject discovery grants")
}
visible := RestrictDiscoveredChannels(ctx, []pipeline.ChannelInfo{
{ChannelID: "group-1", ChannelType: model.ChannelTypeGroup},
{ChannelID: "group-2", ChannelType: model.ChannelTypeGroup},
})
if len(visible) != 1 || visible[0].ChannelID != "group-1" {
t.Fatalf("closed-scope discovery leaked channels: %#v", visible)
}
}

func TestClosedDiscoveryFilterCanonicalizesDMIDs(t *testing.T) {
ctx := context.WithValue(context.Background(), ContextKeyUID, "self-user")
ctx = WithAllowedChannelScope(ctx, []ChannelScope{{ChannelID: "peer-user", ChannelType: model.ChannelTypeDM}})
visible := RestrictDiscoveredChannels(ctx, []pipeline.ChannelInfo{{
ChannelID: "self-user@peer-user", ChannelType: model.ChannelTypeDM,
}})
if len(visible) != 1 {
t.Fatalf("logical/canonical DM comparison dropped selected channel: %#v", visible)
}
}

func TestAllowedTimeRangeOverridesModelArguments(t *testing.T) {
trustedStart := time.Date(2026, 8, 21, 8, 0, 0, 0, time.UTC)
trustedEnd := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC)
ctx := WithAllowedTimeRange(context.Background(), trustedStart, trustedEnd)
gotStart, gotEnd := ResolveAllowedTimeRange(ctx, time.Time{}, time.Now())
if !gotStart.Equal(trustedStart) || !gotEnd.Equal(trustedEnd) {
t.Fatalf("resolved range = %s..%s, want %s..%s", gotStart, gotEnd, trustedStart, trustedEnd)
}
}

func TestAllowedChannelScopeCanonicalizesLogicalDMID(t *testing.T) {
ctx := context.WithValue(context.Background(), ContextKeyUID, "self-user")
canonical := pipeline.NormalizeDMChannelID("peer-user", "self-user", model.ChannelTypeDM)

ctx = WithAllowedChannelScope(ctx, []ChannelScope{{ChannelID: canonical, ChannelType: model.ChannelTypeDM}})
if restricted, allowed := ChannelAllowedByScope(ctx, "peer-user", model.ChannelTypeDM); !restricted || !allowed {
t.Fatalf("logical DM id did not match selected canonical id: (%v, %v), canonical=%q", restricted, allowed, canonical)
}
}

func TestChannelReadToolsRejectOutsideSelectedScopeBeforeLookup(t *testing.T) {
ctx := context.WithValue(context.Background(), ContextKeyUID, "user-1")
ctx = WithAllowedChannelScope(ctx, []ChannelScope{{ChannelID: "group-1", ChannelType: 2}})

_, fetch := FetchChannelTool()
if _, err := fetch(ctx, []byte(`{"channel_id":"group-2","time_start":"2026-08-20T00:00:00Z","time_end":"2026-08-27T00:00:00Z"}`)); err == nil || !strings.Contains(err.Error(), "channel_type is required") {
t.Fatalf("missing channel type must remain a repairable argument error, got %v", err)
}
if _, err := fetch(ctx, []byte(`{"channel_id":"group-2","channel_type":2,"time_start":"2026-08-20T00:00:00Z","time_end":"2026-08-27T00:00:00Z"}`)); err == nil || !strings.Contains(err.Error(), "outside the selected summary scope") {
t.Fatalf("fetch outside scope error = %v", err)
} else {
var outside *ErrChannelOutsideSelectedScope
if !errors.As(err, &outside) || outside.ChannelID != "group-2" || outside.ChannelType != 2 {
t.Fatalf("fetch error type = %T %+v", err, err)
}
}

_, peek := PeekChannelTool()
if _, err := peek(ctx, []byte(`{"channel_id":"group-1","channel_type":5}`)); err == nil || !strings.Contains(err.Error(), "outside the selected summary scope") {
t.Fatalf("peek with mismatched type error = %v", err)
}
}
2 changes: 1 addition & 1 deletion internal/agent/coverage_gate_cgo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func (f *coverageGateFixture) seedFetched(t *testing.T, channelID string, msgs [
if err := f.runStore.RecordChannelFetch(context.Background(), f.uid, f.runID, channelID, true, false); err != nil {
t.Fatalf("record fetch %s: %v", channelID, err)
}
handle := messageCache.Store(msgs, f.uid)
handle := messageCache.Store(msgs, f.uid, f.sessionID)
t.Cleanup(func() {
messageCache.mu.Lock()
delete(messageCache.store, handle)
Expand Down
10 changes: 10 additions & 0 deletions internal/agent/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,15 @@ func GetSummaryDeps() (summaryDB *gorm.DB, imDB *gorm.DB, octoClient *service.Oc
return summDeps.summaryDB, summDeps.imDB, summDeps.octoClient, summDeps.cfg
}

// GetSummaryConfig returns the currently injected tool configuration without
// requiring the dependency bundle to have been initialized. Workspace setup
// uses it for shard-aware read-only channel discovery; tests that construct a
// handler directly safely receive the zero value and apply local defaults.
func GetSummaryConfig() config.Config {
depsMu.RLock()
defer depsMu.RUnlock()
return summDeps.cfg
}

// ChannelInfo is an alias for pipeline.ChannelInfo for convenience in tool handlers.
type ChannelInfo = pipeline.ChannelInfo
Loading
Loading