feat(queue): add admin toggle for queued message merging (#2342)
This commit is contained in:
@@ -44,6 +44,48 @@ func TestResolveQueueMaxPerSessionPrecedence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQueueMergeEnabledPrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
persisted *bool
|
||||
want bool
|
||||
}{
|
||||
{name: "no persisted setting defaults to enabled", want: true},
|
||||
{name: "persisted disabled", persisted: new(false), want: false},
|
||||
{name: "persisted enabled", persisted: new(true), want: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pool := newMessageQueueSettingsTestPool(t)
|
||||
if tc.persisted != nil {
|
||||
raw, err := systemsettings.NewStore(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("new system settings store: %v", err)
|
||||
}
|
||||
store := queuesettings.NewStore(raw)
|
||||
if err := store.Save(context.Background(), queuesettings.Settings{
|
||||
MaxPerSession: queuesettings.DefaultMaxPerSession, MergeEnabled: *tc.persisted,
|
||||
}); err != nil {
|
||||
t.Fatalf("save setting: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := resolveQueueMergeEnabled(pool, testLogger(t)); got != tc.want {
|
||||
t.Fatalf("merge enabled = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveQueueMergeEnabledWithoutPool asserts the nil-pool fallback path
|
||||
// (mirrors resolveQueueMaxPerSession's own nil-pool behavior) still defaults
|
||||
// merging to enabled.
|
||||
func TestResolveQueueMergeEnabledWithoutPool(t *testing.T) {
|
||||
if got := resolveQueueMergeEnabled(nil, testLogger(t)); !got {
|
||||
t.Fatalf("merge enabled = %v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newMessageQueueSettingsTestPool(t *testing.T) *db.Pool {
|
||||
t.Helper()
|
||||
connection, err := sqlx.Open("sqlite3", ":memory:")
|
||||
|
||||
@@ -94,9 +94,12 @@ func provideOrchestrator(
|
||||
return nil, nil, fmt.Errorf("init message queue repo: %w", err)
|
||||
}
|
||||
maxPerSession := resolveQueueMaxPerSession(pool, log)
|
||||
mergeEnabled := resolveQueueMergeEnabled(pool, log)
|
||||
msgQueue := messagequeue.NewService(queueRepo, maxPerSession, log)
|
||||
msgQueue.SetMergeEnabled(mergeEnabled)
|
||||
log.Info("Message queue initialized",
|
||||
zap.Int("max_per_session", maxPerSession))
|
||||
zap.Int("max_per_session", maxPerSession),
|
||||
zap.Bool("merge_enabled", mergeEnabled))
|
||||
if taskSvc.AttachmentService() == nil && taskSvc.AttachmentRepository() != nil {
|
||||
attachmentSvc, attachmentErr := taskservice.NewAttachmentService(
|
||||
taskSvc.AttachmentRepository(), cfg.ResolvedHomeDir(), taskSvc.AuthorizeWorkspaceAccess, log,
|
||||
@@ -267,6 +270,21 @@ func githubCredentialBrokerEndpoint(cfg *config.Config) string {
|
||||
// Values <= 0 disable the cap entirely (callers can still flood queues — only
|
||||
// useful in tests / specialized deployments).
|
||||
func resolveQueueMaxPerSession(pool *db.Pool, log *logger.Logger) int {
|
||||
return resolveQueueSettings(pool, log).Effective.MaxPerSession
|
||||
}
|
||||
|
||||
// resolveQueueMergeEnabled honors the persisted message queue setting,
|
||||
// falling back to enabled (the shipped default) when unset or invalid.
|
||||
// Unlike max_per_session it has no environment override.
|
||||
func resolveQueueMergeEnabled(pool *db.Pool, log *logger.Logger) bool {
|
||||
return resolveQueueSettings(pool, log).Effective.MergeEnabled
|
||||
}
|
||||
|
||||
// resolveQueueSettings loads the persisted message queue settings — falling
|
||||
// back to defaults when unset, invalid, or the store is unavailable — and
|
||||
// resolves them against the KANDEV_QUEUE_MAX_PER_SESSION environment
|
||||
// override.
|
||||
func resolveQueueSettings(pool *db.Pool, log *logger.Logger) queuesettings.Resolution {
|
||||
var configured *queuesettings.Settings
|
||||
if pool != nil {
|
||||
rawStore, err := systemsettings.NewStore(pool)
|
||||
@@ -282,14 +300,20 @@ func resolveQueueMaxPerSession(pool *db.Pool, log *logger.Logger) int {
|
||||
}
|
||||
resolution, err := queuesettings.Resolve(configured, queuesettings.ReadEnvironment())
|
||||
if err != nil {
|
||||
log.Warn("Failed to resolve message queue capacity, using default", zap.Error(err))
|
||||
return messagequeue.DefaultMaxPerSession
|
||||
log.Warn("Failed to resolve message queue settings, using defaults", zap.Error(err))
|
||||
return queuesettings.Resolution{Response: queuesettings.Response{
|
||||
Settings: queuesettings.DefaultSettings(),
|
||||
Effective: queuesettings.Effective{
|
||||
MaxPerSession: messagequeue.DefaultMaxPerSession,
|
||||
MergeEnabled: true,
|
||||
},
|
||||
}}
|
||||
}
|
||||
if resolution.InvalidEnvironment {
|
||||
log.Warn("Ignoring invalid message queue capacity environment value",
|
||||
zap.String("environment_variable", queuesettings.EnvironmentVariable))
|
||||
}
|
||||
return resolution.Effective.MaxPerSession
|
||||
return resolution
|
||||
}
|
||||
|
||||
func resolveEventNamespace(cfg *config.Config) string {
|
||||
|
||||
@@ -33,8 +33,11 @@ const (
|
||||
// the combined entity references past the per-message cap; the merge is
|
||||
// rejected atomically instead of dropping persisted references.
|
||||
queueErrorCodeMergeReferenceOverflow = "merge_reference_overflow"
|
||||
queueInvalidReferences = "Invalid entity references"
|
||||
queueAccessDenied = "Session not found"
|
||||
// queueErrorCodeMergeDisabled is surfaced when queued-message merging is
|
||||
// disabled via the message queue system setting.
|
||||
queueErrorCodeMergeDisabled = "merge_disabled"
|
||||
queueInvalidReferences = "Invalid entity references"
|
||||
queueAccessDenied = "Session not found"
|
||||
|
||||
// Payload field names — extracted to satisfy goconst (≥3 occurrences).
|
||||
fieldSessionID = "session_id"
|
||||
@@ -726,6 +729,9 @@ func (h *QueueHandlers) wsMergeIntoAbove(ctx context.Context, msg *ws.Message) (
|
||||
if errors.Is(err, messagequeue.ErrMergeReferenceOverflow) {
|
||||
return ws.NewError(msg.ID, msg.Action, queueErrorCodeMergeReferenceOverflow, err.Error(), nil)
|
||||
}
|
||||
if errors.Is(err, messagequeue.ErrMergeDisabled) {
|
||||
return ws.NewError(msg.ID, msg.Action, queueErrorCodeMergeDisabled, "Message merging is disabled", nil)
|
||||
}
|
||||
h.logger.Error("failed to merge queued message", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to merge queued message", nil)
|
||||
}
|
||||
@@ -834,10 +840,11 @@ func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string) {
|
||||
}
|
||||
status := h.queueService.GetStatus(ctx, sessionID)
|
||||
eventData := map[string]interface{}{
|
||||
fieldSessionID: sessionID,
|
||||
"entries": status.Entries,
|
||||
"count": status.Count,
|
||||
fieldMax: status.Max,
|
||||
fieldSessionID: sessionID,
|
||||
"entries": status.Entries,
|
||||
"count": status.Count,
|
||||
fieldMax: status.Max,
|
||||
"merge_enabled": status.MergeEnabled,
|
||||
}
|
||||
if h.sessionTaskResolver != nil {
|
||||
if taskID, err := h.sessionTaskResolver(ctx, sessionID); err != nil {
|
||||
|
||||
@@ -96,6 +96,29 @@ func TestWsMergeIntoAbove(t *testing.T) {
|
||||
assert.Equal(t, "entry_not_found", parseError(t, response).Code)
|
||||
})
|
||||
|
||||
t.Run("returns merge_disabled when merging is turned off", func(t *testing.T) {
|
||||
handlers, svc := setupQueueHandlers(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a, _ := svc.QueueMessage(ctx, "s", "t", "first", "", "user-1", false, nil)
|
||||
b, _ := svc.QueueMessage(ctx, "s", "t", "second", "", "user-1", false, nil)
|
||||
svc.SetMergeEnabled(false)
|
||||
|
||||
response, err := handlers.wsMergeIntoAbove(ctx,
|
||||
createTestMessage(t, ws.ActionMessageQueueMerge, map[string]interface{}{
|
||||
"session_id": "s",
|
||||
"entry_id": b.ID,
|
||||
"user_id": "user-1",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeError, response.Type)
|
||||
assert.Equal(t, "merge_disabled", parseError(t, response).Code)
|
||||
|
||||
status := svc.GetStatus(ctx, "s")
|
||||
require.Equal(t, 2, status.Count)
|
||||
assert.Equal(t, a.ID, status.Entries[0].ID)
|
||||
})
|
||||
|
||||
t.Run("rejects reserved user_id", func(t *testing.T) {
|
||||
handlers, _ := setupQueueHandlers(t)
|
||||
response, err := handlers.wsMergeIntoAbove(context.Background(),
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
type Service struct {
|
||||
repo Repository
|
||||
maxPerSession atomic.Int64
|
||||
mergeEnabled atomic.Bool
|
||||
logger *logger.Logger
|
||||
admissionMu sync.Mutex
|
||||
admissions map[string]*sessionAdmission
|
||||
@@ -51,6 +52,7 @@ func NewService(repo Repository, maxPerSession int, log *logger.Logger) *Service
|
||||
admissions: make(map[string]*sessionAdmission),
|
||||
}
|
||||
service.SetMaxPerSession(maxPerSession)
|
||||
service.mergeEnabled.Store(true)
|
||||
return service
|
||||
}
|
||||
|
||||
@@ -78,6 +80,17 @@ func (s *Service) SetMaxPerSession(maxPerSession int) {
|
||||
s.maxPerSession.Store(int64(maxPerSession))
|
||||
}
|
||||
|
||||
// MergeEnabled reports whether MergeIntoAbove is currently allowed. Enabled
|
||||
// by default; see SetMergeEnabled.
|
||||
func (s *Service) MergeEnabled() bool { return s.mergeEnabled.Load() }
|
||||
|
||||
// SetMergeEnabled toggles whether queued messages may be folded into the
|
||||
// entry above them via MergeIntoAbove. Disabling it does not affect merges
|
||||
// already applied.
|
||||
func (s *Service) SetMergeEnabled(enabled bool) {
|
||||
s.mergeEnabled.Store(enabled)
|
||||
}
|
||||
|
||||
// WithSessionAdmission runs fn under the per-session queue admission lock.
|
||||
// All queue insertion paths use the same lock. The callback must complete
|
||||
// synchronously; queue methods called with its context reuse the held lock.
|
||||
@@ -600,6 +613,9 @@ func (s *Service) RemoveEntry(ctx context.Context, sessionID, entryID string) er
|
||||
// above it within the same session. See Repository.MergeIntoAbove for the merge
|
||||
// rules and error mapping (ErrEntryNotFound / ErrNoMergeTarget).
|
||||
func (s *Service) MergeIntoAbove(ctx context.Context, sessionID, entryID, queuedBy string) (*QueuedMessage, error) {
|
||||
if !s.MergeEnabled() {
|
||||
return nil, ErrMergeDisabled
|
||||
}
|
||||
merged, err := s.repo.MergeIntoAbove(ctx, sessionID, entryID, queuedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -626,12 +642,13 @@ func (s *Service) CancelAll(ctx context.Context, sessionID string) (int, error)
|
||||
// GetStatus returns the full pending list and capacity info for a session.
|
||||
func (s *Service) GetStatus(ctx context.Context, sessionID string) *QueueStatus {
|
||||
maxPerSession := s.MaxPerSession()
|
||||
mergeEnabled := s.MergeEnabled()
|
||||
entries, err := s.repo.ListBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
s.logger.Error("list queued failed",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
return &QueueStatus{Entries: []QueuedMessage{}, Count: 0, Max: maxPerSession}
|
||||
return &QueueStatus{Entries: []QueuedMessage{}, Count: 0, Max: maxPerSession, MergeEnabled: mergeEnabled}
|
||||
}
|
||||
pending := make([]QueuedMessage, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
@@ -643,9 +660,10 @@ func (s *Service) GetStatus(ctx context.Context, sessionID string) *QueueStatus
|
||||
pending = append(pending, entry)
|
||||
}
|
||||
return &QueueStatus{
|
||||
Entries: pending,
|
||||
Count: len(pending),
|
||||
Max: maxPerSession,
|
||||
Entries: pending,
|
||||
Count: len(pending),
|
||||
Max: maxPerSession,
|
||||
MergeEnabled: mergeEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package messagequeue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestService_MergeEnabledDefaultsToTrue asserts a freshly constructed
|
||||
// Service allows merges out of the box, matching the "enabled by default"
|
||||
// product requirement for queued-message merging.
|
||||
func TestService_MergeEnabledDefaultsToTrue(t *testing.T) {
|
||||
svc := setupService(t)
|
||||
assert.True(t, svc.MergeEnabled())
|
||||
}
|
||||
|
||||
// TestService_MergeIntoAbove_DisabledRejectsMerge asserts SetMergeEnabled(false)
|
||||
// makes MergeIntoAbove fail with ErrMergeDisabled and leaves the queue
|
||||
// untouched, without needing per-call plumbing through every merge call site.
|
||||
func TestService_MergeIntoAbove_DisabledRejectsMerge(t *testing.T) {
|
||||
svc := setupService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.QueueMessage(ctx, "s", "t", "above", "", "alice", false, nil)
|
||||
require.NoError(t, err)
|
||||
source, err := svc.QueueMessage(ctx, "s", "t", "source", "", "alice", false, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc.SetMergeEnabled(false)
|
||||
|
||||
_, err = svc.MergeIntoAbove(ctx, "s", source.ID, "alice")
|
||||
assert.ErrorIs(t, err, ErrMergeDisabled)
|
||||
assert.Equal(t, 2, svc.GetStatus(ctx, "s").Count)
|
||||
|
||||
svc.SetMergeEnabled(true)
|
||||
merged, err := svc.MergeIntoAbove(ctx, "s", source.ID, "alice")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "above\n\nsource", merged.Content)
|
||||
}
|
||||
|
||||
// TestGetStatus_ReflectsMergeEnabled asserts QueueStatus.MergeEnabled mirrors
|
||||
// the live flag, so clients can hide the merge affordance without a separate
|
||||
// settings round trip.
|
||||
func TestGetStatus_ReflectsMergeEnabled(t *testing.T) {
|
||||
svc := setupService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
assert.True(t, svc.GetStatus(ctx, "s").MergeEnabled)
|
||||
|
||||
svc.SetMergeEnabled(false)
|
||||
assert.False(t, svc.GetStatus(ctx, "s").MergeEnabled)
|
||||
}
|
||||
@@ -89,6 +89,10 @@ var (
|
||||
// sender tasks differ, the caller does not own the rows, or the target is a
|
||||
// reserved in-flight lifecycle entry.
|
||||
ErrNoMergeTarget = errors.New("no mergeable message above")
|
||||
// ErrMergeDisabled is returned when a merge is attempted while queued
|
||||
// message merging is disabled (see Service.SetMergeEnabled). The setting
|
||||
// is admin-controlled and enabled by default.
|
||||
ErrMergeDisabled = errors.New("queued message merging is disabled")
|
||||
// ErrTaskInactive means a lifecycle prompt could not be accepted because
|
||||
// its task was deleted or archived before the queue transaction claimed it.
|
||||
ErrTaskInactive = errors.New("queue task is inactive")
|
||||
@@ -187,6 +191,9 @@ type QueueStatus struct {
|
||||
Entries []QueuedMessage `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
Max int `json:"max"`
|
||||
// MergeEnabled mirrors Service.MergeEnabled so clients can hide the
|
||||
// "Merge with above" affordance without a separate settings fetch.
|
||||
MergeEnabled bool `json:"merge_enabled"`
|
||||
}
|
||||
|
||||
// PendingMove represents a workflow step move requested by an agent (via
|
||||
|
||||
@@ -25,14 +25,17 @@ func handleGet(service *Service) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpdate applies a partial SettingsPatch: the request body may name
|
||||
// only the fields it wants to change, and any field it omits keeps its
|
||||
// current persisted value (see Service.Update).
|
||||
func handleUpdate(service *Service) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
var settings Settings
|
||||
if err := ctx.ShouldBindJSON(&settings); err != nil {
|
||||
var patch SettingsPatch
|
||||
if err := ctx.ShouldBindJSON(&patch); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid request"})
|
||||
return
|
||||
}
|
||||
response, err := service.Update(ctx.Request.Context(), settings)
|
||||
response, err := service.Update(ctx.Request.Context(), patch)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrValidation):
|
||||
|
||||
@@ -24,12 +24,19 @@ func TestResolvePrecedenceAndNormalization(t *testing.T) {
|
||||
want Response
|
||||
invalidEnv bool
|
||||
}{
|
||||
{name: "default", want: responseFor(10, 10, SourceDefault, false)},
|
||||
{name: "setting", configured: &Settings{MaxPerSession: 6}, want: responseFor(6, 6, SourceSetting, false)},
|
||||
{name: "environment", configured: &Settings{MaxPerSession: 6}, environment: Environment{Value: "20", Present: true}, want: responseFor(6, 20, SourceEnvironment, true)},
|
||||
{name: "zero environment is unlimited", configured: &Settings{MaxPerSession: 6}, environment: Environment{Value: "0", Present: true}, want: responseFor(6, 0, SourceEnvironment, true)},
|
||||
{name: "negative environment is unlimited", configured: &Settings{MaxPerSession: 6}, environment: Environment{Value: "-3", Present: true}, want: responseFor(6, 0, SourceEnvironment, true)},
|
||||
{name: "invalid environment is ignored", configured: &Settings{MaxPerSession: 6}, environment: Environment{Value: "many", Present: true}, want: responseFor(6, 6, SourceSetting, false), invalidEnv: true},
|
||||
{name: "default", want: responseFor(10, 10, SourceDefault, false, true)},
|
||||
{name: "setting", configured: &Settings{MaxPerSession: 6, MergeEnabled: true}, want: responseFor(6, 6, SourceSetting, false, true)},
|
||||
{name: "environment", configured: &Settings{MaxPerSession: 6, MergeEnabled: true}, environment: Environment{Value: "20", Present: true}, want: responseFor(6, 20, SourceEnvironment, true, true)},
|
||||
{name: "zero environment is unlimited", configured: &Settings{MaxPerSession: 6, MergeEnabled: true}, environment: Environment{Value: "0", Present: true}, want: responseFor(6, 0, SourceEnvironment, true, true)},
|
||||
{name: "negative environment is unlimited", configured: &Settings{MaxPerSession: 6, MergeEnabled: true}, environment: Environment{Value: "-3", Present: true}, want: responseFor(6, 0, SourceEnvironment, true, true)},
|
||||
{name: "invalid environment is ignored", configured: &Settings{MaxPerSession: 6, MergeEnabled: true}, environment: Environment{Value: "many", Present: true}, want: responseFor(6, 6, SourceSetting, false, true), invalidEnv: true},
|
||||
{name: "merge disabled setting", configured: &Settings{MaxPerSession: 6, MergeEnabled: false}, want: responseFor(6, 6, SourceSetting, false, false)},
|
||||
{
|
||||
name: "merge disabled setting survives an environment override of max_per_session",
|
||||
configured: &Settings{MaxPerSession: 6, MergeEnabled: false},
|
||||
environment: Environment{Value: "20", Present: true},
|
||||
want: responseFor(6, 20, SourceEnvironment, true, false),
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -58,11 +65,11 @@ func TestStoreRoundTripAndValidation(t *testing.T) {
|
||||
if err != nil || loaded != nil {
|
||||
t.Fatalf("missing load = %+v, %v", loaded, err)
|
||||
}
|
||||
if err := store.Save(context.Background(), Settings{MaxPerSession: 8}); err != nil {
|
||||
if err := store.Save(context.Background(), Settings{MaxPerSession: 8, MergeEnabled: true}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
loaded, err = store.Load(context.Background())
|
||||
if err != nil || loaded == nil || loaded.MaxPerSession != 8 {
|
||||
if err != nil || loaded == nil || loaded.MaxPerSession != 8 || !loaded.MergeEnabled {
|
||||
t.Fatalf("round trip = %+v, %v", loaded, err)
|
||||
}
|
||||
if err := store.Save(context.Background(), Settings{MaxPerSession: -1}); !errors.Is(err, ErrValidation) {
|
||||
@@ -70,12 +77,49 @@ func TestStoreRoundTripAndValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreLoadDefaultsMergeEnabledForPreExistingRecords guards a real
|
||||
// upgrade hazard: an installation that persisted max_per_session before
|
||||
// merge_enabled existed has a stored JSON object with no "merge_enabled"
|
||||
// key. json.Unmarshal into a plain bool decodes that as false, which would
|
||||
// silently disable merging on upgrade instead of leaving it enabled by
|
||||
// default. Store.Load must treat the missing key as "unset" (-> true), not
|
||||
// "explicitly false".
|
||||
func TestStoreLoadDefaultsMergeEnabledForPreExistingRecords(t *testing.T) {
|
||||
raw := &fakeRawStore{raw: []byte(`{"max_per_session":6}`), found: true}
|
||||
store := NewStore(raw)
|
||||
|
||||
loaded, err := store.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("load pre-existing record: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.MaxPerSession != 6 || !loaded.MergeEnabled {
|
||||
t.Fatalf("loaded = %+v, want {MaxPerSession:6 MergeEnabled:true}", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreLoadPreservesExplicitMergeDisabled is the companion case: once a
|
||||
// record has been re-saved through this code path with merge_enabled
|
||||
// explicitly false, Load must not treat that as "unset" and re-default it to
|
||||
// true.
|
||||
func TestStoreLoadPreservesExplicitMergeDisabled(t *testing.T) {
|
||||
raw := &fakeRawStore{raw: []byte(`{"max_per_session":6,"merge_enabled":false}`), found: true}
|
||||
store := NewStore(raw)
|
||||
|
||||
loaded, err := store.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("load explicit-false record: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.MergeEnabled {
|
||||
t.Fatalf("loaded = %+v, want MergeEnabled:false preserved", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdatePersistsBeforeLiveApply(t *testing.T) {
|
||||
raw := &fakeRawStore{}
|
||||
target := &fakeTarget{max: 10}
|
||||
service := NewService(NewStore(raw), target, func() Environment { return Environment{} }, testLogger(t))
|
||||
|
||||
response, err := service.Update(context.Background(), Settings{MaxPerSession: 4})
|
||||
response, err := service.Update(context.Background(), SettingsPatch{MaxPerSession: new(4)})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
@@ -84,7 +128,7 @@ func TestServiceUpdatePersistsBeforeLiveApply(t *testing.T) {
|
||||
}
|
||||
|
||||
raw.saveErr = errors.New("disk full")
|
||||
_, err = service.Update(context.Background(), Settings{MaxPerSession: 2})
|
||||
_, err = service.Update(context.Background(), SettingsPatch{MaxPerSession: new(2)})
|
||||
if err == nil {
|
||||
t.Fatal("expected persistence error")
|
||||
}
|
||||
@@ -93,6 +137,39 @@ func TestServiceUpdatePersistsBeforeLiveApply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceUpdatePatchPreservesUnspecifiedFields guards against a patch
|
||||
// that touches only one field silently resetting the other to its zero
|
||||
// value: a max_per_session-only patch must not disable merging, and a
|
||||
// merge_enabled-only patch must not reset the capacity.
|
||||
func TestServiceUpdatePatchPreservesUnspecifiedFields(t *testing.T) {
|
||||
raw := &fakeRawStore{}
|
||||
target := &fakeTarget{max: 10}
|
||||
service := NewService(NewStore(raw), target, func() Environment { return Environment{} }, testLogger(t))
|
||||
|
||||
if _, err := service.Update(context.Background(), SettingsPatch{MergeEnabled: new(true)}); err != nil {
|
||||
t.Fatalf("seed merge_enabled: %v", err)
|
||||
}
|
||||
|
||||
response, err := service.Update(context.Background(), SettingsPatch{MaxPerSession: new(4)})
|
||||
if err != nil {
|
||||
t.Fatalf("max-only update: %v", err)
|
||||
}
|
||||
if !response.Settings.MergeEnabled || !target.MergeEnabled() {
|
||||
t.Fatalf("max-only patch reset merge_enabled: response=%+v target=%v", response, target.MergeEnabled())
|
||||
}
|
||||
|
||||
response, err = service.Update(context.Background(), SettingsPatch{MergeEnabled: new(false)})
|
||||
if err != nil {
|
||||
t.Fatalf("merge-only update: %v", err)
|
||||
}
|
||||
if response.Settings.MaxPerSession != 4 || target.MaxPerSession() != 4 {
|
||||
t.Fatalf("merge-only patch reset max_per_session: response=%+v target=%d", response, target.MaxPerSession())
|
||||
}
|
||||
if response.Settings.MergeEnabled || target.MergeEnabled() {
|
||||
t.Fatalf("merge-only patch did not apply: response=%+v target=%v", response, target.MergeEnabled())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRecoversFromInvalidPersistedSettingAndAllowsReplacement(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -113,15 +190,15 @@ func TestServiceRecoversFromInvalidPersistedSettingAndAllowsReplacement(t *testi
|
||||
if err != nil {
|
||||
t.Fatalf("get with invalid persisted setting: %v", err)
|
||||
}
|
||||
if response != responseFor(10, 10, SourceDefault, false) {
|
||||
if response != responseFor(10, 10, SourceDefault, false, true) {
|
||||
t.Fatalf("fallback response = %+v", response)
|
||||
}
|
||||
|
||||
response, err = service.Update(context.Background(), Settings{MaxPerSession: 7})
|
||||
response, err = service.Update(context.Background(), SettingsPatch{MaxPerSession: new(7)})
|
||||
if err != nil {
|
||||
t.Fatalf("replace invalid persisted setting: %v", err)
|
||||
}
|
||||
if response != responseFor(7, 7, SourceSetting, false) || target.max != 7 {
|
||||
if response != responseFor(7, 7, SourceSetting, false, true) || target.max != 7 {
|
||||
t.Fatalf("replacement response=%+v target=%d", response, target.max)
|
||||
}
|
||||
})
|
||||
@@ -138,14 +215,14 @@ func TestServiceSerializesPersistenceAndLiveApply(t *testing.T) {
|
||||
|
||||
firstDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := service.Update(context.Background(), Settings{MaxPerSession: 7})
|
||||
_, err := service.Update(context.Background(), SettingsPatch{MaxPerSession: new(7)})
|
||||
firstDone <- err
|
||||
}()
|
||||
<-raw.firstSaveWritten
|
||||
|
||||
secondDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := service.Update(context.Background(), Settings{MaxPerSession: 9})
|
||||
_, err := service.Update(context.Background(), SettingsPatch{MaxPerSession: new(9)})
|
||||
secondDone <- err
|
||||
}()
|
||||
|
||||
@@ -181,7 +258,7 @@ func TestServiceEnvironmentLockRejectsUpdate(t *testing.T) {
|
||||
return Environment{Value: "20", Present: true}
|
||||
}, testLogger(t))
|
||||
|
||||
_, err := service.Update(context.Background(), Settings{MaxPerSession: 4})
|
||||
_, err := service.Update(context.Background(), SettingsPatch{MaxPerSession: new(4)})
|
||||
if !errors.Is(err, ErrEnvironmentLocked) {
|
||||
t.Fatalf("update error = %v, want environment lock", err)
|
||||
}
|
||||
@@ -190,6 +267,25 @@ func TestServiceEnvironmentLockRejectsUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceEnvironmentLockAllowsMergeOnlyUpdate asserts the environment
|
||||
// lock only blocks a patch that touches max_per_session — merge_enabled has
|
||||
// no environment override and must stay editable.
|
||||
func TestServiceEnvironmentLockAllowsMergeOnlyUpdate(t *testing.T) {
|
||||
raw := &fakeRawStore{}
|
||||
target := &fakeTarget{max: 20}
|
||||
service := NewService(NewStore(raw), target, func() Environment {
|
||||
return Environment{Value: "20", Present: true}
|
||||
}, testLogger(t))
|
||||
|
||||
response, err := service.Update(context.Background(), SettingsPatch{MergeEnabled: new(false)})
|
||||
if err != nil {
|
||||
t.Fatalf("merge-only update under max_per_session lock: %v", err)
|
||||
}
|
||||
if response.Settings.MergeEnabled || target.MergeEnabled() {
|
||||
t.Fatalf("merge-only update did not apply: response=%+v target=%v", response, target.MergeEnabled())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReturnsConflictForEnvironmentLock(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
raw := &fakeRawStore{}
|
||||
@@ -200,7 +296,7 @@ func TestHandlerReturnsConflictForEnvironmentLock(t *testing.T) {
|
||||
group := router.Group("/api/v1/system")
|
||||
RegisterRoutes(group, group, service)
|
||||
|
||||
body, _ := json.Marshal(Settings{MaxPerSession: 4})
|
||||
body, _ := json.Marshal(SettingsPatch{MaxPerSession: new(4)})
|
||||
request := httptest.NewRequest(http.MethodPatch, "/api/v1/system/message-queue/settings", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
@@ -214,7 +310,7 @@ func TestHandlerGetReturnsConfiguredAndEffectiveValues(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
raw := &fakeRawStore{}
|
||||
store := NewStore(raw)
|
||||
if err := store.Save(context.Background(), Settings{MaxPerSession: 6}); err != nil {
|
||||
if err := store.Save(context.Background(), Settings{MaxPerSession: 6, MergeEnabled: true}); err != nil {
|
||||
t.Fatalf("save baseline: %v", err)
|
||||
}
|
||||
service := NewService(store, &fakeTarget{max: 20}, func() Environment {
|
||||
@@ -235,7 +331,7 @@ func TestHandlerGetReturnsConfiguredAndEffectiveValues(t *testing.T) {
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
want := responseFor(6, 20, SourceEnvironment, true)
|
||||
want := responseFor(6, 20, SourceEnvironment, true, true)
|
||||
if payload != want {
|
||||
t.Fatalf("response = %+v, want %+v", payload, want)
|
||||
}
|
||||
@@ -262,10 +358,45 @@ func TestHandlerRejectsNegativeCapacity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func responseFor(configured, effective int, source Source, locked bool) Response {
|
||||
// TestHandlerUpdateOmittingMergeEnabledPreservesIt is the HTTP-level
|
||||
// regression test for the patch-not-replace contract: a PATCH body that
|
||||
// only names max_per_session must never flip a previously-saved
|
||||
// merge_enabled=true back to false.
|
||||
func TestHandlerUpdateOmittingMergeEnabledPreservesIt(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
raw := &fakeRawStore{}
|
||||
target := &fakeTarget{max: 10, mergeEnabled: true}
|
||||
service := NewService(NewStore(raw), target, func() Environment { return Environment{} }, testLogger(t))
|
||||
router := gin.New()
|
||||
group := router.Group("/api/v1/system")
|
||||
RegisterRoutes(group, group, service)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPatch, "/api/v1/system/message-queue/settings",
|
||||
bytes.NewBufferString(`{"max_per_session":5}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload Response
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !payload.Settings.MergeEnabled || !target.MergeEnabled() {
|
||||
t.Fatalf("max_per_session-only PATCH disabled merging: payload=%+v target=%v", payload, target.MergeEnabled())
|
||||
}
|
||||
if payload.Settings.MaxPerSession != 5 || target.MaxPerSession() != 5 {
|
||||
t.Fatalf("max_per_session was not applied: payload=%+v target=%d", payload, target.MaxPerSession())
|
||||
}
|
||||
}
|
||||
|
||||
func responseFor(configured, effective int, source Source, locked bool, mergeEnabled bool) Response {
|
||||
return Response{
|
||||
Settings: Settings{MaxPerSession: configured},
|
||||
Effective: Effective{MaxPerSession: effective, Source: source, Locked: locked},
|
||||
Settings: Settings{MaxPerSession: configured, MergeEnabled: mergeEnabled},
|
||||
Effective: Effective{MaxPerSession: effective, Source: source, Locked: locked, MergeEnabled: mergeEnabled},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,23 +416,26 @@ func (f *fakeRawStore) Save(_ context.Context, _ string, value []byte) error {
|
||||
if f.saveErr != nil {
|
||||
return f.saveErr
|
||||
}
|
||||
f.raw = append([]byte(nil), value...)
|
||||
f.raw = value
|
||||
f.found = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeTarget struct {
|
||||
max int
|
||||
max int
|
||||
mergeEnabled bool
|
||||
}
|
||||
|
||||
func (f *fakeTarget) MaxPerSession() int { return f.max }
|
||||
func (f *fakeTarget) SetMaxPerSession(n int) { f.max = n }
|
||||
func (f *fakeTarget) MergeEnabled() bool { return f.mergeEnabled }
|
||||
func (f *fakeTarget) SetMergeEnabled(v bool) { f.mergeEnabled = v }
|
||||
|
||||
type blockingRawStore struct {
|
||||
mu sync.Mutex
|
||||
raw []byte
|
||||
found bool
|
||||
saveCalls int
|
||||
saveCount int
|
||||
firstSaveWritten chan struct{}
|
||||
secondSaveEntered chan struct{}
|
||||
releaseFirst chan struct{}
|
||||
@@ -318,40 +452,48 @@ func newBlockingRawStore() *blockingRawStore {
|
||||
func (s *blockingRawStore) Get(context.Context, string) ([]byte, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]byte(nil), s.raw...), s.found, nil
|
||||
return s.raw, s.found, nil
|
||||
}
|
||||
|
||||
func (s *blockingRawStore) Save(_ context.Context, _ string, value []byte) error {
|
||||
s.mu.Lock()
|
||||
s.saveCalls++
|
||||
call := s.saveCalls
|
||||
s.raw = append([]byte(nil), value...)
|
||||
s.found = true
|
||||
switch call {
|
||||
case 1:
|
||||
close(s.firstSaveWritten)
|
||||
case 2:
|
||||
close(s.secondSaveEntered)
|
||||
}
|
||||
s.saveCount++
|
||||
count := s.saveCount
|
||||
s.mu.Unlock()
|
||||
if call == 1 {
|
||||
|
||||
if count == 1 {
|
||||
s.mu.Lock()
|
||||
s.raw = value
|
||||
s.found = true
|
||||
s.mu.Unlock()
|
||||
close(s.firstSaveWritten)
|
||||
<-s.releaseFirst
|
||||
return nil
|
||||
}
|
||||
|
||||
close(s.secondSaveEntered)
|
||||
s.mu.Lock()
|
||||
s.raw = value
|
||||
s.found = true
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
type atomicTarget struct {
|
||||
max atomic.Int64
|
||||
max atomic.Int64
|
||||
mergeEnabled atomic.Bool
|
||||
}
|
||||
|
||||
func (t *atomicTarget) MaxPerSession() int { return int(t.max.Load()) }
|
||||
func (t *atomicTarget) SetMaxPerSession(n int) { t.max.Store(int64(n)) }
|
||||
func (t *atomicTarget) MergeEnabled() bool { return t.mergeEnabled.Load() }
|
||||
func (t *atomicTarget) SetMergeEnabled(v bool) { t.mergeEnabled.Store(v) }
|
||||
|
||||
func testLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console", OutputPath: "stderr"})
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
t.Fatalf("new logger: %v", err)
|
||||
}
|
||||
return log
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func Resolve(configured *Settings, environment Environment) (Resolution, error)
|
||||
Effective: Effective{
|
||||
MaxPerSession: settings.MaxPerSession,
|
||||
Source: source,
|
||||
MergeEnabled: settings.MergeEnabled,
|
||||
},
|
||||
}}
|
||||
raw := strings.TrimSpace(environment.Value)
|
||||
@@ -41,6 +42,7 @@ func Resolve(configured *Settings, environment Environment) (Resolution, error)
|
||||
MaxPerSession: value,
|
||||
Source: SourceEnvironment,
|
||||
Locked: true,
|
||||
MergeEnabled: settings.MergeEnabled,
|
||||
}
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
type Target interface {
|
||||
MaxPerSession() int
|
||||
SetMaxPerSession(int)
|
||||
MergeEnabled() bool
|
||||
SetMergeEnabled(bool)
|
||||
}
|
||||
|
||||
type EnvironmentReader func() Environment
|
||||
@@ -53,13 +55,15 @@ func (s *Service) Get(ctx context.Context) (Response, error) {
|
||||
return resolution.Response, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, settings Settings) (Response, error) {
|
||||
// Update applies a partial patch to the persisted settings: fields the caller
|
||||
// omits keep their current effective value rather than resetting to zero, so
|
||||
// a client that edits only one field can never silently clobber another (see
|
||||
// SettingsPatch). The environment lock only blocks a patch that actually
|
||||
// attempts to change max_per_session — it does not control merge_enabled.
|
||||
func (s *Service) Update(ctx context.Context, patch SettingsPatch) (Response, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := Validate(settings); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
environment := s.readEnvironment()
|
||||
current, err := s.loadConfigured(ctx)
|
||||
if err != nil {
|
||||
@@ -70,9 +74,13 @@ func (s *Service) Update(ctx context.Context, settings Settings) (Response, erro
|
||||
return Response{}, err
|
||||
}
|
||||
s.warnInvalidEnvironment(resolution)
|
||||
if resolution.Effective.Locked {
|
||||
if patch.MaxPerSession != nil && resolution.Effective.Locked {
|
||||
return Response{}, ErrEnvironmentLocked
|
||||
}
|
||||
settings := patch.Apply(resolution.Settings)
|
||||
if err := Validate(settings); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if s.target == nil {
|
||||
return Response{}, ErrTargetUnavailable
|
||||
}
|
||||
@@ -80,6 +88,7 @@ func (s *Service) Update(ctx context.Context, settings Settings) (Response, erro
|
||||
return Response{}, err
|
||||
}
|
||||
s.target.SetMaxPerSession(settings.MaxPerSession)
|
||||
s.target.SetMergeEnabled(settings.MergeEnabled)
|
||||
updated, err := Resolve(&settings, environment)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
|
||||
@@ -19,6 +19,27 @@ func NewStore(raw RawStore) *Store {
|
||||
return &Store{raw: raw}
|
||||
}
|
||||
|
||||
// storedSettings mirrors Settings for JSON decoding but keeps MergeEnabled as
|
||||
// a pointer so a missing key ("unset" — a record saved before this field
|
||||
// existed) can be told apart from an explicit `false`. Without this, an
|
||||
// installation that persisted max_per_session before merge_enabled shipped
|
||||
// would decode its stale record as merge_enabled=false and silently disable
|
||||
// merging on upgrade instead of leaving it enabled by default.
|
||||
type storedSettings struct {
|
||||
MaxPerSession int `json:"max_per_session"`
|
||||
MergeEnabled *bool `json:"merge_enabled"`
|
||||
}
|
||||
|
||||
// toSettings normalizes a decoded record into Settings, defaulting
|
||||
// MergeEnabled to true when the persisted JSON has no merge_enabled key.
|
||||
func (s storedSettings) toSettings() Settings {
|
||||
mergeEnabled := true
|
||||
if s.MergeEnabled != nil {
|
||||
mergeEnabled = *s.MergeEnabled
|
||||
}
|
||||
return Settings{MaxPerSession: s.MaxPerSession, MergeEnabled: mergeEnabled}
|
||||
}
|
||||
|
||||
func (s *Store) Load(ctx context.Context) (*Settings, error) {
|
||||
raw, found, err := s.raw.Get(ctx, SettingsKey)
|
||||
if err != nil {
|
||||
@@ -27,10 +48,11 @@ func (s *Store) Load(ctx context.Context) (*Settings, error) {
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
var settings Settings
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
var stored storedSettings
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode JSON: %v", ErrInvalidPersisted, err)
|
||||
}
|
||||
settings := stored.toSettings()
|
||||
if err := Validate(settings); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidPersisted, err)
|
||||
}
|
||||
|
||||
@@ -29,18 +29,43 @@ var (
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
MaxPerSession int `json:"max_per_session"`
|
||||
MaxPerSession int `json:"max_per_session"`
|
||||
MergeEnabled bool `json:"merge_enabled"`
|
||||
}
|
||||
|
||||
// SettingsPatch is a partial update to Settings: a nil field means "leave
|
||||
// unchanged" rather than "reset to zero value". Without this distinction a
|
||||
// client that PATCHes only max_per_session would silently reset
|
||||
// merge_enabled to false, since Settings has no way to represent omission.
|
||||
type SettingsPatch struct {
|
||||
MaxPerSession *int `json:"max_per_session"`
|
||||
MergeEnabled *bool `json:"merge_enabled"`
|
||||
}
|
||||
|
||||
// Apply returns base with every non-nil patch field overlaid.
|
||||
func (p SettingsPatch) Apply(base Settings) Settings {
|
||||
if p.MaxPerSession != nil {
|
||||
base.MaxPerSession = *p.MaxPerSession
|
||||
}
|
||||
if p.MergeEnabled != nil {
|
||||
base.MergeEnabled = *p.MergeEnabled
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Settings Settings `json:"settings"`
|
||||
Effective Effective `json:"effective"`
|
||||
}
|
||||
|
||||
type Effective struct {
|
||||
MaxPerSession int `json:"max_per_session"`
|
||||
Source Source `json:"source"`
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Settings Settings `json:"settings"`
|
||||
Effective Effective `json:"effective"`
|
||||
// MergeEnabled always mirrors Settings.MergeEnabled: unlike MaxPerSession
|
||||
// it has no environment override, so there is no separate source/lock to
|
||||
// track.
|
||||
MergeEnabled bool `json:"merge_enabled"`
|
||||
}
|
||||
|
||||
type Environment struct {
|
||||
@@ -53,8 +78,10 @@ type Resolution struct {
|
||||
InvalidEnvironment bool
|
||||
}
|
||||
|
||||
// DefaultSettings returns the shipped defaults: no session-count cap and
|
||||
// merging enabled.
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{MaxPerSession: DefaultMaxPerSession}
|
||||
return Settings{MaxPerSession: DefaultMaxPerSession, MergeEnabled: true}
|
||||
}
|
||||
|
||||
func Validate(settings Settings) error {
|
||||
|
||||
@@ -158,11 +158,14 @@ func (s *queueSettingsRawStore) Save(_ context.Context, _ string, value []byte)
|
||||
}
|
||||
|
||||
type queueSettingsTarget struct {
|
||||
max int
|
||||
max int
|
||||
mergeEnabled bool
|
||||
}
|
||||
|
||||
func (t *queueSettingsTarget) MaxPerSession() int { return t.max }
|
||||
func (t *queueSettingsTarget) SetMaxPerSession(n int) { t.max = n }
|
||||
func (t *queueSettingsTarget) MergeEnabled() bool { return t.mergeEnabled }
|
||||
func (t *queueSettingsTarget) SetMergeEnabled(v bool) { t.mergeEnabled = v }
|
||||
|
||||
type systemSleepRawStore struct {
|
||||
raw []byte
|
||||
|
||||
@@ -8,6 +8,7 @@ const updateSettingsMock = vi.fn();
|
||||
let saveContributor: SettingsSaveContributor | null = null;
|
||||
let currentRole: "admin" | "member" | undefined;
|
||||
const MAXIMUM_LABEL = "Maximum messages per session";
|
||||
const MERGE_TOGGLE_LABEL = "Enable queued message merging";
|
||||
const EFFECTIVE_VALUE_ID = "message-queue-effective-value";
|
||||
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({
|
||||
@@ -26,6 +27,28 @@ vi.mock("../settings-save-provider", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/switch", () => ({
|
||||
Switch: ({
|
||||
checked,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
"aria-label": string;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
}) => (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={checked}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MessageQueueSettings } from "./message-queue-settings";
|
||||
|
||||
function response(
|
||||
@@ -33,13 +56,26 @@ function response(
|
||||
effective = configured,
|
||||
source: MessageQueueSettingsResponse["effective"]["source"] = "default",
|
||||
locked = false,
|
||||
mergeEnabled = true,
|
||||
): MessageQueueSettingsResponse {
|
||||
return {
|
||||
settings: { max_per_session: configured },
|
||||
effective: { max_per_session: effective, source, locked },
|
||||
settings: { max_per_session: configured, merge_enabled: mergeEnabled },
|
||||
effective: { max_per_session: effective, source, locked, merge_enabled: mergeEnabled },
|
||||
};
|
||||
}
|
||||
|
||||
/** Every save/discard test needs the contributor the component just
|
||||
* registered; centralizing the "not registered yet" guard keeps that
|
||||
* assertion's message out of `sonarjs/no-duplicate-string` territory. */
|
||||
function requireContributor(): SettingsSaveContributor {
|
||||
if (!saveContributor) throw new Error("save contributor was not registered");
|
||||
return saveContributor;
|
||||
}
|
||||
|
||||
function mergeToggle(): HTMLElement {
|
||||
return screen.getByRole("button", { name: MERGE_TOGGLE_LABEL });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSettingsMock.mockReset();
|
||||
updateSettingsMock.mockReset();
|
||||
@@ -53,7 +89,7 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("MessageQueueSettings", () => {
|
||||
describe("MessageQueueSettings — per-session limit", () => {
|
||||
it("shows loading, configured, effective, and default-source states", async () => {
|
||||
let resolveLoad: (value: MessageQueueSettingsResponse) => void = () => {};
|
||||
fetchSettingsMock.mockReturnValueOnce(
|
||||
@@ -97,9 +133,9 @@ describe("MessageQueueSettings", () => {
|
||||
expect(saveContributor?.isDirty).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
|
||||
if (!saveContributor) throw new Error("save contributor was not registered");
|
||||
const contributor = requireContributor();
|
||||
|
||||
await act(async () => saveContributor?.save(saveContributor.revision));
|
||||
await act(async () => contributor.save(contributor.revision));
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({ max_per_session: 25 });
|
||||
expect(screen.getByTestId(EFFECTIVE_VALUE_ID).textContent).toBe("25");
|
||||
@@ -154,10 +190,10 @@ describe("MessageQueueSettings", () => {
|
||||
render(<MessageQueueSettings />);
|
||||
const input = await screen.findByLabelText(MAXIMUM_LABEL);
|
||||
fireEvent.change(input, { target: { value: "20" } });
|
||||
if (!saveContributor) throw new Error("save contributor was not registered");
|
||||
const contributor = requireContributor();
|
||||
|
||||
await act(async () => {
|
||||
await expect(saveContributor?.save(saveContributor.revision)).rejects.toThrow("save failed");
|
||||
await expect(contributor.save(contributor.revision)).rejects.toThrow("save failed");
|
||||
});
|
||||
|
||||
expect(screen.getByText("Failed to save message queue settings.")).toBeTruthy();
|
||||
@@ -169,11 +205,93 @@ describe("MessageQueueSettings", () => {
|
||||
render(<MessageQueueSettings />);
|
||||
const input = await screen.findByLabelText(MAXIMUM_LABEL);
|
||||
fireEvent.change(input, { target: { value: "30" } });
|
||||
if (!saveContributor) throw new Error("save contributor was not registered");
|
||||
const contributor = requireContributor();
|
||||
|
||||
act(() => saveContributor?.discard());
|
||||
act(() => contributor.discard());
|
||||
|
||||
expect(input.getAttribute("value")).toBe("12");
|
||||
expect(saveContributor?.isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MessageQueueSettings — merge toggle", () => {
|
||||
it("is enabled by default and shows its limitations notice", async () => {
|
||||
render(<MessageQueueSettings />);
|
||||
await screen.findByLabelText(MAXIMUM_LABEL);
|
||||
|
||||
expect(mergeToggle().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(
|
||||
screen.getByText(/Lets you fold a queued message into the message directly above it/),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Only adjacent messages from the same sender can be merged/),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/combined, deduplicated entity references would exceed 100/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stages a toggle change and PATCHes merge_enabled without touching max_per_session", async () => {
|
||||
updateSettingsMock.mockResolvedValueOnce(response(10, 10, "default", false, false));
|
||||
render(<MessageQueueSettings />);
|
||||
await screen.findByLabelText(MAXIMUM_LABEL);
|
||||
|
||||
fireEvent.click(mergeToggle());
|
||||
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled();
|
||||
expect(saveContributor?.isDirty).toBe(true);
|
||||
const contributor = requireContributor();
|
||||
|
||||
await act(async () => contributor.save(contributor.revision));
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({ merge_enabled: false });
|
||||
await waitFor(() => expect(saveContributor?.isDirty).toBe(false));
|
||||
expect(mergeToggle().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("sends both fields in one PATCH when max_per_session and merge_enabled are both dirty", async () => {
|
||||
updateSettingsMock.mockResolvedValueOnce(response(25, 25, "setting", false, false));
|
||||
render(<MessageQueueSettings />);
|
||||
const input = await screen.findByLabelText(MAXIMUM_LABEL);
|
||||
|
||||
fireEvent.change(input, { target: { value: "25" } });
|
||||
fireEvent.click(mergeToggle());
|
||||
const contributor = requireContributor();
|
||||
|
||||
await act(async () => contributor.save(contributor.revision));
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({
|
||||
max_per_session: 25,
|
||||
merge_enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays editable for admins even when max_per_session is environment-locked", async () => {
|
||||
fetchSettingsMock.mockResolvedValueOnce(response(25, 50, "environment", true, true));
|
||||
render(<MessageQueueSettings />);
|
||||
await screen.findByLabelText(MAXIMUM_LABEL);
|
||||
|
||||
expect(mergeToggle()).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
it("is disabled for members", async () => {
|
||||
currentRole = "member";
|
||||
render(<MessageQueueSettings />);
|
||||
await screen.findByLabelText(MAXIMUM_LABEL);
|
||||
|
||||
expect(mergeToggle()).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("discards a toggle change back to the authoritative value", async () => {
|
||||
fetchSettingsMock.mockResolvedValueOnce(response(10, 10, "default", false, true));
|
||||
render(<MessageQueueSettings />);
|
||||
await screen.findByLabelText(MAXIMUM_LABEL);
|
||||
fireEvent.click(mergeToggle());
|
||||
const contributor = requireContributor();
|
||||
|
||||
act(() => contributor.discard());
|
||||
|
||||
expect(mergeToggle().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(saveContributor?.isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@ import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconAlertCircle, IconLock } from "@tabler/icons-react";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
import { IconAlertCircle, IconInfoCircle, IconLock } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -21,6 +22,8 @@ import type { MessageQueueSettingsResponse, MessageQueueSettingsSource } from "@
|
||||
|
||||
const ENVIRONMENT_VARIABLE = "KANDEV_QUEUE_MAX_PER_SESSION";
|
||||
|
||||
/** Parses the max-per-session draft text into a non-negative safe integer,
|
||||
* or `null` when the text isn't a valid whole number. */
|
||||
function parseMaximum(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
@@ -28,6 +31,7 @@ function parseMaximum(value: string): number | null {
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/** Maps an effective-settings source enum to its i18n label key. */
|
||||
function sourceLabelKey(source: MessageQueueSettingsSource): string {
|
||||
switch (source) {
|
||||
case "setting":
|
||||
@@ -39,14 +43,16 @@ function sourceLabelKey(source: MessageQueueSettingsSource): string {
|
||||
}
|
||||
}
|
||||
|
||||
function useMessageQueueSettingsDraft() {
|
||||
const { t } = useTranslation();
|
||||
const role = useAppStore((state) => state.auth.user?.role);
|
||||
/** Loads the persisted snapshot and owns the two editable drafts (the
|
||||
* max-per-session text field and the merge-enabled toggle). Split out of
|
||||
* `useMessageQueueSettingsDraft` so neither function needs to grow past the
|
||||
* project's per-function line limit as the page gains fields. */
|
||||
function useMessageQueueSettingsLoad() {
|
||||
const [snapshot, setSnapshot] = useState<MessageQueueSettingsResponse | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [mergeDraft, setMergeDraft] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [saveFailed, setSaveFailed] = useState(false);
|
||||
const loadVersion = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
@@ -58,6 +64,7 @@ function useMessageQueueSettingsDraft() {
|
||||
if (version !== loadVersion.current) return;
|
||||
setSnapshot(response);
|
||||
setDraft(String(response.settings.max_per_session));
|
||||
setMergeDraft(response.settings.merge_enabled);
|
||||
} catch {
|
||||
if (version === loadVersion.current) setLoadFailed(true);
|
||||
} finally {
|
||||
@@ -72,37 +79,73 @@ function useMessageQueueSettingsDraft() {
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
setSnapshot,
|
||||
draft,
|
||||
setDraft,
|
||||
mergeDraft,
|
||||
setMergeDraft,
|
||||
loading,
|
||||
loadFailed,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
/** Derives dirty/validity/permission state from the loaded snapshot and the
|
||||
* two drafts, and registers the combined save/discard contributor that the
|
||||
* page's floating save bar drives. */
|
||||
function useMessageQueueSettingsDraft() {
|
||||
const { t } = useTranslation();
|
||||
const role = useAppStore((state) => state.auth.user?.role);
|
||||
const [saveFailed, setSaveFailed] = useState(false);
|
||||
const load = useMessageQueueSettingsLoad();
|
||||
const { snapshot, setSnapshot, draft, setDraft, mergeDraft, setMergeDraft } = load;
|
||||
|
||||
const parsed = parseMaximum(draft);
|
||||
const baseline = snapshot?.settings.max_per_session;
|
||||
const isDirty = baseline !== undefined && draft !== String(baseline);
|
||||
const mergeBaseline = snapshot?.settings.merge_enabled;
|
||||
const isMaxDirty = baseline !== undefined && draft !== String(baseline);
|
||||
const isMergeDirty = mergeBaseline !== undefined && mergeDraft !== mergeBaseline;
|
||||
const isDirty = isMaxDirty || isMergeDirty;
|
||||
const isAdmin = role === undefined || role === "admin";
|
||||
const isLocked = snapshot?.effective.locked === true;
|
||||
// The environment lock only governs max_per_session; merging has no
|
||||
// environment override, so an admin may always toggle it.
|
||||
const canEdit = isAdmin && !isLocked;
|
||||
const canEditMerge = isAdmin;
|
||||
let invalidReason: string | undefined;
|
||||
if (parsed === null) invalidReason = t("system:messageQueueValidation");
|
||||
else if (!isAdmin) invalidReason = t("system:messageQueueAdminOnly");
|
||||
else if (isLocked) {
|
||||
invalidReason = t("system:messageQueueEnvironmentLocked", {
|
||||
variable: ENVIRONMENT_VARIABLE,
|
||||
});
|
||||
else if (isLocked && isMaxDirty) {
|
||||
invalidReason = t("system:messageQueueEnvironmentLocked", { variable: ENVIRONMENT_VARIABLE });
|
||||
}
|
||||
|
||||
useSettingsSaveContributor({
|
||||
id: "system-message-queue",
|
||||
revision: draft,
|
||||
revision: `${draft}:${mergeDraft}`,
|
||||
isDirty,
|
||||
canSave: parsed !== null && canEdit,
|
||||
canSave: parsed !== null && (isMaxDirty ? canEdit : canEditMerge),
|
||||
invalidReason,
|
||||
save: async () => {
|
||||
if (parsed === null || !canEdit) throw new Error(invalidReason);
|
||||
if (parsed === null || (isMaxDirty && !canEdit) || (isMergeDirty && !canEditMerge)) {
|
||||
throw new Error(invalidReason);
|
||||
}
|
||||
const submitted = draft;
|
||||
const submittedMerge = mergeDraft;
|
||||
setSaveFailed(false);
|
||||
try {
|
||||
const response = await updateMessageQueueSettings({ max_per_session: parsed });
|
||||
const response = await updateMessageQueueSettings({
|
||||
...(isMaxDirty ? { max_per_session: parsed } : {}),
|
||||
...(isMergeDirty ? { merge_enabled: mergeDraft } : {}),
|
||||
});
|
||||
setSnapshot(response);
|
||||
setDraft((current) =>
|
||||
current === submitted ? String(response.settings.max_per_session) : current,
|
||||
);
|
||||
setMergeDraft((current) =>
|
||||
current === submittedMerge ? response.settings.merge_enabled : current,
|
||||
);
|
||||
} catch (error) {
|
||||
setSaveFailed(true);
|
||||
throw error;
|
||||
@@ -110,6 +153,7 @@ function useMessageQueueSettingsDraft() {
|
||||
},
|
||||
discard: () => {
|
||||
if (baseline !== undefined) setDraft(String(baseline));
|
||||
if (mergeBaseline !== undefined) setMergeDraft(mergeBaseline);
|
||||
setSaveFailed(false);
|
||||
},
|
||||
});
|
||||
@@ -118,16 +162,118 @@ function useMessageQueueSettingsDraft() {
|
||||
snapshot,
|
||||
draft,
|
||||
setDraft,
|
||||
loading,
|
||||
loadFailed,
|
||||
mergeDraft,
|
||||
setMergeDraft,
|
||||
loading: load.loading,
|
||||
loadFailed: load.loadFailed,
|
||||
saveFailed,
|
||||
isDirty,
|
||||
isAdmin,
|
||||
isLocked,
|
||||
reload,
|
||||
reload: load.reload,
|
||||
};
|
||||
}
|
||||
|
||||
type QueueLimitFieldsProps = {
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
disabled: boolean;
|
||||
configured: number;
|
||||
effectiveValue: string;
|
||||
source: MessageQueueSettingsSource;
|
||||
};
|
||||
|
||||
/** Renders the per-session limit input plus the configured/effective summary
|
||||
* badge; a pure presentational block extracted so `MessageQueueSettings`
|
||||
* stays under the per-function line limit. */
|
||||
function QueueLimitFields({
|
||||
draft,
|
||||
onDraftChange,
|
||||
disabled,
|
||||
configured,
|
||||
effectiveValue,
|
||||
source,
|
||||
}: QueueLimitFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Label htmlFor="message-queue-max-per-session">
|
||||
{t("system:messageQueueMaximumLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="message-queue-max-per-session"
|
||||
data-testid="message-queue-max-per-session"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
className="h-11 w-full max-w-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("system:messageQueueUnlimitedHelp")}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t("system:messageQueueConfigured")}</span>
|
||||
<span>{configured}</span>
|
||||
<span className="text-muted-foreground">{t("system:messageQueueEffective")}</span>
|
||||
<strong data-testid="message-queue-effective-value">{effectiveValue}</strong>
|
||||
<Badge variant="secondary" data-testid="message-queue-source">
|
||||
{t(sourceLabelKey(source))}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("system:messageQueueEffectiveHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MergeToggleFieldsProps = {
|
||||
enabled: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
/** Renders the merge-enabled switch plus its limitations notice; extracted
|
||||
* for the same per-function line-limit reason as `QueueLimitFields`. */
|
||||
function MergeToggleFields({ enabled, onChange, disabled }: MergeToggleFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-w-0 space-y-3 border-t border-border/70 pt-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label htmlFor="message-queue-merge-enabled">
|
||||
{t("system:messageQueueMergeToggleLabel")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("system:messageQueueMergeDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="message-queue-merge-enabled"
|
||||
data-testid="message-queue-merge-enabled"
|
||||
checked={enabled}
|
||||
disabled={disabled}
|
||||
onCheckedChange={onChange}
|
||||
aria-label={t("system:messageQueueMergeToggleLabel")}
|
||||
className="cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 rounded-md border border-border/70 bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||
<IconInfoCircle className="size-4 shrink-0" />
|
||||
<p>{t("system:messageQueueMergeNotice")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings → General → Message Queue page: the per-session queue limit and
|
||||
* the queued-message merge toggle, sharing one save contributor. */
|
||||
export function MessageQueueSettings() {
|
||||
const { t } = useTranslation();
|
||||
const state = useMessageQueueSettingsDraft();
|
||||
@@ -165,39 +311,14 @@ export function MessageQueueSettings() {
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 space-y-5">
|
||||
<p className="text-sm text-muted-foreground">{t("system:messageQueueLimitDescription")}</p>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Label htmlFor="message-queue-max-per-session">
|
||||
{t("system:messageQueueMaximumLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="message-queue-max-per-session"
|
||||
data-testid="message-queue-max-per-session"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
value={state.draft}
|
||||
disabled={!state.isAdmin || state.isLocked}
|
||||
onChange={(event) => state.setDraft(event.target.value)}
|
||||
className="h-11 w-full max-w-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("system:messageQueueUnlimitedHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t("system:messageQueueConfigured")}</span>
|
||||
<span>{settings.max_per_session}</span>
|
||||
<span className="text-muted-foreground">{t("system:messageQueueEffective")}</span>
|
||||
<strong data-testid="message-queue-effective-value">{effectiveValue}</strong>
|
||||
<Badge variant="secondary" data-testid="message-queue-source">
|
||||
{t(sourceLabelKey(effective.source))}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("system:messageQueueEffectiveHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<QueueLimitFields
|
||||
draft={state.draft}
|
||||
onDraftChange={state.setDraft}
|
||||
disabled={!state.isAdmin || state.isLocked}
|
||||
configured={settings.max_per_session}
|
||||
effectiveValue={effectiveValue}
|
||||
source={effective.source}
|
||||
/>
|
||||
|
||||
{state.isLocked && (
|
||||
<Alert>
|
||||
@@ -217,6 +338,12 @@ export function MessageQueueSettings() {
|
||||
<AlertDescription>{t("system:messageQueueSaveFailed")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<MergeToggleFields
|
||||
enabled={state.mergeDraft}
|
||||
onChange={state.setMergeDraft}
|
||||
disabled={!state.isAdmin}
|
||||
/>
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -83,6 +83,7 @@ function baseState(entries: QueuedMessage[]) {
|
||||
count: entries.length,
|
||||
max: 10,
|
||||
isFull: false,
|
||||
mergeEnabled: true,
|
||||
isLoading: false,
|
||||
queue: vi.fn(async () => {}),
|
||||
clearAll: vi.fn(async () => {}),
|
||||
@@ -403,7 +404,7 @@ describe("QueueAffordance entity-reference edits", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueueAffordance merge wiring", () => {
|
||||
describe("QueueAffordance merge wiring — eligibility", () => {
|
||||
it("shows a merge control on the second row and calls mergeEntry with its id", () => {
|
||||
const state = queueState([
|
||||
entry({ id: "q-1", content: "first", queued_by: QUEUED_BY_USER }),
|
||||
@@ -454,7 +455,9 @@ describe("QueueAffordance merge wiring", () => {
|
||||
fireEvent.click(screen.getByTestId(CHIP_ID));
|
||||
expect(screen.queryByTestId(MERGE_BUTTON_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueueAffordance merge wiring — dispatch", () => {
|
||||
it("toasts an error when the merge fails", async () => {
|
||||
const { toast } = await import("sonner");
|
||||
const state = queueState([
|
||||
@@ -507,4 +510,19 @@ describe("QueueAffordance merge wiring", () => {
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(state.mergeEntry).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("hides the merge control entirely when merging is disabled", () => {
|
||||
const state = queueState(
|
||||
[
|
||||
entry({ id: "q-1", content: "first", queued_by: QUEUED_BY_USER }),
|
||||
entry({ id: "q-2", content: "second", queued_by: QUEUED_BY_USER }),
|
||||
],
|
||||
{ mergeEnabled: false },
|
||||
);
|
||||
useQueueMock.mockReturnValue(state);
|
||||
render(<QueueAffordance sessionId={SESSION_ID}>{CHILD}</QueueAffordance>);
|
||||
|
||||
fireEvent.click(screen.getByTestId(CHIP_ID));
|
||||
expect(screen.queryAllByTestId(MERGE_BUTTON_ID)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,6 +226,7 @@ type QueuePanelDisclosureProps = {
|
||||
canDrain: boolean;
|
||||
isLoading: boolean;
|
||||
cancellationPending: boolean;
|
||||
mergeEnabled: boolean;
|
||||
onClose: () => void;
|
||||
onClear: () => void;
|
||||
onDrain: () => void;
|
||||
@@ -236,6 +237,7 @@ type QueuePanelDisclosureProps = {
|
||||
onSendEntryNow: (entryId: string) => void;
|
||||
};
|
||||
|
||||
/** Wraps QueuePanel in the collapsible open/close animation shell. */
|
||||
function QueuePanelDisclosure({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
@@ -246,6 +248,7 @@ function QueuePanelDisclosure({
|
||||
canDrain,
|
||||
isLoading,
|
||||
cancellationPending,
|
||||
mergeEnabled,
|
||||
onClose,
|
||||
onClear,
|
||||
onDrain,
|
||||
@@ -271,6 +274,7 @@ function QueuePanelDisclosure({
|
||||
canDrain={canDrain}
|
||||
isLoading={isLoading}
|
||||
cancellationPending={cancellationPending}
|
||||
mergeEnabled={mergeEnabled}
|
||||
onClose={onClose}
|
||||
onClear={onClear}
|
||||
onDrain={onDrain}
|
||||
@@ -319,6 +323,7 @@ export function QueueAffordance({
|
||||
count,
|
||||
max,
|
||||
isFull,
|
||||
mergeEnabled,
|
||||
isLoading,
|
||||
clearAll,
|
||||
drainNext,
|
||||
@@ -389,6 +394,7 @@ export function QueueAffordance({
|
||||
canDrain={canDrain}
|
||||
isLoading={isLoading}
|
||||
cancellationPending={cancellationPending}
|
||||
mergeEnabled={mergeEnabled}
|
||||
onClose={close}
|
||||
onClear={handleClear}
|
||||
onDrain={handleDrain}
|
||||
@@ -467,6 +473,7 @@ type QueuePanelProps = {
|
||||
canDrain: boolean;
|
||||
isLoading: boolean;
|
||||
cancellationPending: boolean;
|
||||
mergeEnabled: boolean;
|
||||
onClose: () => void;
|
||||
onClear: () => void;
|
||||
onDrain: () => void;
|
||||
@@ -477,6 +484,8 @@ type QueuePanelProps = {
|
||||
onSendEntryNow: (entryId: string) => void;
|
||||
};
|
||||
|
||||
/** Renders the expanded queue list: header controls plus one QueuedGhostMessage
|
||||
* row per pending entry, gating each row's merge control on `mergeEnabled`. */
|
||||
function QueuePanel({
|
||||
entries,
|
||||
count,
|
||||
@@ -485,6 +494,7 @@ function QueuePanel({
|
||||
canDrain,
|
||||
isLoading,
|
||||
cancellationPending,
|
||||
mergeEnabled,
|
||||
onClose,
|
||||
onClear,
|
||||
onDrain,
|
||||
@@ -530,7 +540,7 @@ function QueuePanel({
|
||||
index={index}
|
||||
canEdit={canUserEditEntry(entry)}
|
||||
canRemove
|
||||
canMerge={canMergeWithAbove(entry, entries[index - 1])}
|
||||
canMerge={mergeEnabled && canMergeWithAbove(entry, entries[index - 1])}
|
||||
onSave={(content, entityReferences) => onSave(entry.id, content, entityReferences)}
|
||||
onRemove={() => onRemove(entry.id)}
|
||||
onMerge={() => onMerge(entry.id)}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { MessageQueueSettingsResponse } from "../../../lib/types/system";
|
||||
import type {
|
||||
MessageQueueSettingsPatch,
|
||||
MessageQueueSettingsResponse,
|
||||
} from "../../../lib/types/system";
|
||||
|
||||
const SETTINGS_PATH = "/api/v1/system/message-queue/settings";
|
||||
|
||||
async function requestSettings(
|
||||
apiClient: ApiClient,
|
||||
method: "GET" | "PATCH",
|
||||
maxPerSession?: number,
|
||||
patch?: MessageQueueSettingsPatch,
|
||||
): Promise<MessageQueueSettingsResponse> {
|
||||
const response = await apiClient.rawRequest(
|
||||
method,
|
||||
SETTINGS_PATH,
|
||||
maxPerSession === undefined ? undefined : { max_per_session: maxPerSession },
|
||||
);
|
||||
const response = await apiClient.rawRequest(method, SETTINGS_PATH, patch);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${method} ${SETTINGS_PATH} failed (${response.status}): ${await response.text()}`,
|
||||
@@ -24,15 +23,22 @@ async function requestSettings(
|
||||
|
||||
test.describe.serial("Message Queue general settings", () => {
|
||||
let baseline: number | undefined;
|
||||
let mergeBaseline: boolean | undefined;
|
||||
|
||||
test.beforeEach(async ({ apiClient }) => {
|
||||
baseline = (await requestSettings(apiClient, "GET")).settings.max_per_session;
|
||||
const current = (await requestSettings(apiClient, "GET")).settings;
|
||||
baseline = current.max_per_session;
|
||||
mergeBaseline = current.merge_enabled;
|
||||
});
|
||||
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
if (baseline === undefined) return;
|
||||
await requestSettings(apiClient, "PATCH", baseline);
|
||||
if (baseline === undefined || mergeBaseline === undefined) return;
|
||||
await requestSettings(apiClient, "PATCH", {
|
||||
max_per_session: baseline,
|
||||
merge_enabled: mergeBaseline,
|
||||
});
|
||||
baseline = undefined;
|
||||
mergeBaseline = undefined;
|
||||
});
|
||||
|
||||
test("admin navigates to Message Queue, saves, and reloads the live setting", async ({
|
||||
@@ -68,14 +74,54 @@ test.describe.serial("Message Queue general settings", () => {
|
||||
await expect(testPage.getByTestId("message-queue-effective-value")).toHaveText(String(updated));
|
||||
});
|
||||
|
||||
test("admin toggles queued message merging without touching the session limit", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
await testPage.goto("/settings/general/message-queue");
|
||||
const toggle = testPage.getByTestId("message-queue-merge-enabled");
|
||||
await expect(toggle).toBeVisible();
|
||||
const wasEnabled = mergeBaseline === true;
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(wasEnabled));
|
||||
await expect(
|
||||
testPage.getByText(/Only adjacent messages from the same sender can be merged/),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
testPage.getByText(/combined, deduplicated entity references would exceed 100/),
|
||||
).toBeVisible();
|
||||
|
||||
const saveResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "PATCH" &&
|
||||
new URL(response.url()).pathname === SETTINGS_PATH,
|
||||
);
|
||||
await toggle.click();
|
||||
await expect(testPage.getByTestId("settings-floating-save")).toBeVisible();
|
||||
await testPage.getByRole("button", { name: "Save changes" }).click();
|
||||
const response = await saveResponse;
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.request().postDataJSON()).toEqual({ merge_enabled: !wasEnabled });
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(!wasEnabled));
|
||||
|
||||
await testPage.reload();
|
||||
await expect(testPage.getByTestId("message-queue-merge-enabled")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
String(!wasEnabled),
|
||||
);
|
||||
});
|
||||
|
||||
test("environment override is visible and read-only", async ({ testPage }) => {
|
||||
await testPage.route(`**${SETTINGS_PATH}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
settings: { max_per_session: baseline },
|
||||
effective: { max_per_session: 41, source: "environment", locked: true },
|
||||
settings: { max_per_session: baseline, merge_enabled: true },
|
||||
effective: {
|
||||
max_per_session: 41,
|
||||
source: "environment",
|
||||
locked: true,
|
||||
merge_enabled: true,
|
||||
},
|
||||
} satisfies MessageQueueSettingsResponse),
|
||||
});
|
||||
});
|
||||
@@ -86,6 +132,8 @@ test.describe.serial("Message Queue general settings", () => {
|
||||
await expect(testPage.getByTestId("message-queue-source")).toHaveText("Environment");
|
||||
await expect(testPage.getByText(/KANDEV_QUEUE_MAX_PER_SESSION/).first()).toBeVisible();
|
||||
await expect(testPage.getByTestId("settings-floating-save")).toHaveCount(0);
|
||||
// The environment lock only governs max_per_session; merging stays editable.
|
||||
await expect(testPage.getByTestId("message-queue-merge-enabled")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("member can navigate to the setting but cannot edit it", async ({ testPage }) => {
|
||||
@@ -111,6 +159,7 @@ test.describe.serial("Message Queue general settings", () => {
|
||||
await settingsNav.getByRole("link", { name: "Message Queue" }).click();
|
||||
|
||||
await expect(testPage.getByTestId("message-queue-max-per-session")).toBeDisabled();
|
||||
await expect(testPage.getByTestId("message-queue-merge-enabled")).toBeDisabled();
|
||||
await expect(testPage.getByText("Only administrators can change this setting.")).toBeVisible();
|
||||
await expect(testPage.getByTestId("settings-floating-save")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ type QueueActionsArgs = {
|
||||
removeQueueEntry: ReturnType<typeof useQueueState>["removeQueueEntry"];
|
||||
setQueueLoading: ReturnType<typeof useQueueState>["setQueueLoading"];
|
||||
metaMax: number | undefined;
|
||||
metaMergeEnabled: boolean | undefined;
|
||||
};
|
||||
|
||||
function useDrainNextAction(
|
||||
@@ -130,6 +131,9 @@ function useSendNowAction(
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetches the authoritative queue snapshot and writes it into the slice,
|
||||
* discarding a response that arrives after a newer request for the same
|
||||
* session was already issued (out-of-order network resolution). */
|
||||
function useQueueRefetch(
|
||||
setQueueEntries: ReturnType<typeof useQueueState>["setQueueEntries"],
|
||||
setQueueLoading: ReturnType<typeof useQueueState>["setQueueLoading"],
|
||||
@@ -146,7 +150,11 @@ function useQueueRefetch(
|
||||
setQueueLoading(sid, true);
|
||||
const status = await getQueueStatus(sid);
|
||||
if (refetchVersion.current[sid] !== version) return;
|
||||
setQueueEntries(sid, status.entries ?? [], { count: status.count, max: status.max });
|
||||
setQueueEntries(sid, status.entries ?? [], {
|
||||
count: status.count,
|
||||
max: status.max,
|
||||
mergeEnabled: status.merge_enabled,
|
||||
});
|
||||
} finally {
|
||||
if (refetchVersion.current[sid] === version) setQueueLoading(sid, false);
|
||||
}
|
||||
@@ -163,6 +171,7 @@ function useQueueActions({
|
||||
removeQueueEntry,
|
||||
setQueueLoading,
|
||||
metaMax,
|
||||
metaMergeEnabled,
|
||||
}: QueueActionsArgs) {
|
||||
const { refetch, invalidate: invalidateRefetch } = useQueueRefetch(
|
||||
setQueueEntries,
|
||||
@@ -212,7 +221,11 @@ function useQueueActions({
|
||||
// optimistic empty state. The explicit refetch below then owns the
|
||||
// newest version and replaces it with the backend's exact result.
|
||||
invalidateRefetch(sessionId);
|
||||
setQueueEntries(sessionId, [], { count: 0, max: metaMax ?? 0 });
|
||||
setQueueEntries(sessionId, [], {
|
||||
count: 0,
|
||||
max: metaMax ?? 0,
|
||||
mergeEnabled: metaMergeEnabled ?? true,
|
||||
});
|
||||
} catch (err) {
|
||||
mutationFailed = true;
|
||||
mutationError = err;
|
||||
@@ -230,7 +243,15 @@ function useQueueActions({
|
||||
} finally {
|
||||
setQueueLoading(sessionId, false);
|
||||
}
|
||||
}, [sessionId, setQueueEntries, setQueueLoading, metaMax, refetch, invalidateRefetch]);
|
||||
}, [
|
||||
sessionId,
|
||||
setQueueEntries,
|
||||
setQueueLoading,
|
||||
metaMax,
|
||||
metaMergeEnabled,
|
||||
refetch,
|
||||
invalidateRefetch,
|
||||
]);
|
||||
|
||||
const drainNext = useDrainNextAction(sessionId, setQueueLoading, refetch);
|
||||
|
||||
@@ -373,6 +394,7 @@ export function useQueue(sessionId: string | null) {
|
||||
removeQueueEntry: state.removeQueueEntry,
|
||||
setQueueLoading: state.setQueueLoading,
|
||||
metaMax: meta?.max,
|
||||
metaMergeEnabled: meta?.mergeEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -405,6 +427,7 @@ export function useQueue(sessionId: string | null) {
|
||||
count: meta?.count ?? entries.length,
|
||||
max: meta?.max ?? 0,
|
||||
isFull: meta ? meta.count >= meta.max && meta.max > 0 : false,
|
||||
mergeEnabled: meta?.mergeEnabled ?? true,
|
||||
isLoading,
|
||||
cancellationPending,
|
||||
queue,
|
||||
|
||||
@@ -21,8 +21,8 @@ import type {
|
||||
DynamicModelsResponse,
|
||||
} from "@/lib/types/http";
|
||||
import type {
|
||||
MessageQueueSettingsPatch,
|
||||
MessageQueueSettingsResponse,
|
||||
MessageQueueSettingsValue,
|
||||
SystemMetricsGlobalSettings,
|
||||
SystemMetricsSettingsResponse,
|
||||
} from "@/lib/types/system";
|
||||
@@ -75,6 +75,7 @@ export async function updateSystemMetricsSettings(
|
||||
|
||||
const MESSAGE_QUEUE_SETTINGS_PATH = "/api/v1/system/message-queue/settings";
|
||||
|
||||
/** Fetches the current message queue settings (configured + effective). */
|
||||
export async function fetchMessageQueueSettings(
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<MessageQueueSettingsResponse> {
|
||||
@@ -84,8 +85,10 @@ export async function fetchMessageQueueSettings(
|
||||
});
|
||||
}
|
||||
|
||||
/** Applies a partial update to message queue settings: fields omitted from
|
||||
* `payload` keep their current persisted value on the backend. */
|
||||
export async function updateMessageQueueSettings(
|
||||
payload: MessageQueueSettingsValue,
|
||||
payload: MessageQueueSettingsPatch,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<MessageQueueSettingsResponse> {
|
||||
return fetchJson<MessageQueueSettingsResponse>(MESSAGE_QUEUE_SETTINGS_PATH, {
|
||||
|
||||
@@ -366,16 +366,34 @@ describe("queue actions", () => {
|
||||
makeEntry({ id: "e2", content: "second" }),
|
||||
];
|
||||
|
||||
store.getState().setQueueEntries(SESSION_ID, entries, { count: 2, max: 10 });
|
||||
store
|
||||
.getState()
|
||||
.setQueueEntries(SESSION_ID, entries, { count: 2, max: 10, mergeEnabled: true });
|
||||
|
||||
expect(store.getState().queue.bySessionId[SESSION_ID]).toEqual(entries);
|
||||
expect(store.getState().queue.metaBySessionId[SESSION_ID]).toEqual({ count: 2, max: 10 });
|
||||
expect(store.getState().queue.metaBySessionId[SESSION_ID]).toEqual({
|
||||
count: 2,
|
||||
max: 10,
|
||||
mergeEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("retains a disabled merge state", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setQueueEntries(SESSION_ID, [makeEntry()], {
|
||||
count: 1,
|
||||
max: 10,
|
||||
mergeEnabled: false,
|
||||
});
|
||||
expect(store.getState().queue.metaBySessionId[SESSION_ID]?.mergeEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("removeQueueEntry drops a single entry by id and refreshes meta.count", () => {
|
||||
const store = makeStore();
|
||||
const entries = [makeEntry({ id: "e1" }), makeEntry({ id: "e2" }), makeEntry({ id: "e3" })];
|
||||
store.getState().setQueueEntries(SESSION_ID, entries, { count: 3, max: 10 });
|
||||
store
|
||||
.getState()
|
||||
.setQueueEntries(SESSION_ID, entries, { count: 3, max: 10, mergeEnabled: true });
|
||||
|
||||
store.getState().removeQueueEntry(SESSION_ID, "e2");
|
||||
|
||||
@@ -392,7 +410,9 @@ describe("queue actions", () => {
|
||||
|
||||
it("clearQueueStatus removes both entries and meta", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setQueueEntries(SESSION_ID, [makeEntry()], { count: 1, max: 10 });
|
||||
store
|
||||
.getState()
|
||||
.setQueueEntries(SESSION_ID, [makeEntry()], { count: 1, max: 10, mergeEnabled: true });
|
||||
|
||||
store.getState().clearQueueStatus(SESSION_ID);
|
||||
|
||||
|
||||
@@ -137,12 +137,16 @@ export type QueuedMessage = {
|
||||
export type QueueMeta = {
|
||||
count: number;
|
||||
max: number;
|
||||
/** Mirrors the server's message queue merge_enabled setting; hides the
|
||||
* "Merge with above" affordance without a separate settings fetch. */
|
||||
mergeEnabled: boolean;
|
||||
};
|
||||
|
||||
export type QueueStatus = {
|
||||
entries: QueuedMessage[];
|
||||
count: number;
|
||||
max: number;
|
||||
merge_enabled: boolean;
|
||||
};
|
||||
|
||||
export type QueueState = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { QueuedMessage } from "@/lib/state/slices/session/types";
|
||||
import type { TaskPlanEventPayload, TaskPlanRevisionEventPayload } from "./task-plan-events";
|
||||
import type { CaptureRequest } from "@/lib/logger/capture";
|
||||
|
||||
@@ -482,20 +483,12 @@ export {
|
||||
|
||||
export type { TaskPlanEventPayload, TaskPlanRevisionEventPayload } from "./task-plan-events";
|
||||
|
||||
export type QueuedMessagePayload = {
|
||||
content: string;
|
||||
model?: string;
|
||||
plan_mode?: boolean;
|
||||
task_id: string;
|
||||
user_id?: string;
|
||||
queued_at: string;
|
||||
};
|
||||
|
||||
export type QueueStatusChangedPayload = {
|
||||
session_id: string;
|
||||
entries?: QueuedMessagePayload[] | null;
|
||||
entries?: QueuedMessage[] | null;
|
||||
count?: number;
|
||||
max?: number;
|
||||
merge_enabled?: boolean;
|
||||
};
|
||||
|
||||
export type TaskStatusSummaryUpdatedPayload = {
|
||||
|
||||
@@ -188,8 +188,12 @@ export interface SystemMetricsSettingsResponse {
|
||||
|
||||
export interface MessageQueueSettingsValue {
|
||||
max_per_session: number;
|
||||
merge_enabled: boolean;
|
||||
}
|
||||
|
||||
/** Partial PATCH payload: omitted fields are left unchanged server-side. */
|
||||
export type MessageQueueSettingsPatch = Partial<MessageQueueSettingsValue>;
|
||||
|
||||
export type MessageQueueSettingsSource = "default" | "setting" | "environment";
|
||||
|
||||
export interface MessageQueueEffectiveSettings extends MessageQueueSettingsValue {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type TaskSession,
|
||||
type TaskSessionState,
|
||||
} from "@/lib/types/http";
|
||||
import type { QueuedMessage } from "@/lib/state/slices/session/types";
|
||||
import type { QueueStatusChangedPayload } from "@/lib/types/backend";
|
||||
import { syncKanbanPrimarySessionState } from "@/lib/ws/handlers/agent-session-kanban-sync";
|
||||
import { parseContextWindowEntry } from "@/lib/state/slices/session-runtime/context-window";
|
||||
|
||||
@@ -668,20 +668,27 @@ function handleCancellationPendingMessage(
|
||||
applyCancellationPending(store, payload);
|
||||
}
|
||||
|
||||
/** Writes a message.queue.status_changed broadcast into the queue slice,
|
||||
* defaulting count/max/mergeEnabled when the backend omits them. */
|
||||
function handleQueueStatusChangedMessage(
|
||||
store: StoreApi<AppState>,
|
||||
payload: QueueStatusChangedPayload,
|
||||
): void {
|
||||
if (!payload.session_id) {
|
||||
console.warn("[Queue] Missing session_id in queue status change event");
|
||||
return;
|
||||
}
|
||||
const entries = payload.entries ?? [];
|
||||
const count = typeof payload.count === "number" ? payload.count : entries.length;
|
||||
const max = typeof payload.max === "number" ? payload.max : 0;
|
||||
const mergeEnabled = typeof payload.merge_enabled === "boolean" ? payload.merge_enabled : true;
|
||||
store.getState().setQueueEntries(payload.session_id, entries, { count, max, mergeEnabled });
|
||||
}
|
||||
|
||||
export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
return {
|
||||
"message.queue.status_changed": (message) => {
|
||||
const payload = message.payload;
|
||||
if (!payload?.session_id) {
|
||||
console.warn("[Queue] Missing session_id in queue status change event");
|
||||
return;
|
||||
}
|
||||
const sessionId = payload.session_id;
|
||||
const entries = (payload.entries as QueuedMessage[] | null | undefined) ?? [];
|
||||
const count = typeof payload.count === "number" ? payload.count : entries.length;
|
||||
const max = typeof payload.max === "number" ? payload.max : 0;
|
||||
store.getState().setQueueEntries(sessionId, entries, { count, max });
|
||||
},
|
||||
"message.queue.status_changed": (message) =>
|
||||
handleQueueStatusChangedMessage(store, message.payload),
|
||||
"session.state_changed": (message) => {
|
||||
const payload = message.payload;
|
||||
if (!payload?.task_id) return;
|
||||
|
||||
@@ -174,6 +174,9 @@
|
||||
"messageQueueLoadFailed": "Message queue settings could not be loaded.",
|
||||
"messageQueueLoading": "Loading message queue settings…",
|
||||
"messageQueueMaximumLabel": "Maximum messages per session",
|
||||
"messageQueueMergeDescription": "Lets you fold a queued message into the message directly above it before the agent picks them up, combining bursts of related follow-ups into one turn.",
|
||||
"messageQueueMergeNotice": "Only adjacent messages from the same sender can be merged: your own queued messages merge into each other, and messages queued by another task's agent merge only when they share the same originating task. Workflow, system, and in-flight messages can never be merged. A merge is also rejected if the two messages' combined, deduplicated entity references would exceed 100, the shared per-message limit. Turning this off hides the merge control immediately and does not affect messages already merged.",
|
||||
"messageQueueMergeToggleLabel": "Enable queued message merging",
|
||||
"messageQueueRetry": "Retry",
|
||||
"messageQueueSaveFailed": "Failed to save message queue settings.",
|
||||
"messageQueueSourceDefault": "Default",
|
||||
|
||||
@@ -174,6 +174,9 @@
|
||||
"messageQueueLoadFailed": "Ḿēśśàĝē qũēũē śēţţĩńĝś ćōũĺď ńōţ ƀē ĺōàďēď.",
|
||||
"messageQueueLoading": "Ĺōàďĩńĝ ḿēśśàĝē qũēũē śēţţĩńĝś…",
|
||||
"messageQueueMaximumLabel": "Ḿàxĩḿũḿ ḿēśśàĝēś ƥēŕ śēśśĩōń",
|
||||
"messageQueueMergeDescription": "Ĺēţś ŷōũ ƒōĺď à qũēũēď ḿēśśàĝē ĩńţō ţĥē ḿēśśàĝē ďĩŕēćţĺŷ àƀōvē ĩţ ƀēƒōŕē ţĥē àĝēńţ ƥĩćķś ţĥēḿ ũƥ, ćōḿƀĩńĩńĝ ƀũŕśţś ōƒ ŕēĺàţēď ƒōĺĺōŵ-ũƥś ĩńţō ōńē ţũŕń.",
|
||||
"messageQueueMergeNotice": "Ōńĺŷ àďĵàćēńţ ḿēśśàĝēś ƒŕōḿ ţĥē śàḿē śēńďēŕ ćàń ƀē ḿēŕĝēď: ŷōũŕ ōŵń qũēũēď ḿēśśàĝēś ḿēŕĝē ĩńţō ēàćĥ ōţĥēŕ, àńď ḿēśśàĝēś qũēũēď ƀŷ àńōţĥēŕ ţàśķ'ś àĝēńţ ḿēŕĝē ōńĺŷ ŵĥēń ţĥēŷ śĥàŕē ţĥē śàḿē ōŕĩĝĩńàţĩńĝ ţàśķ. Ŵōŕķƒĺōŵ, śŷśţēḿ, àńď ĩń-ƒĺĩĝĥţ ḿēśśàĝēś ćàń ńēvēŕ ƀē ḿēŕĝēď. À ḿēŕĝē ĩś àĺśō ŕēĵēćţēď ĩƒ ţĥē ţŵō ḿēśśàĝēś' ćōḿƀĩńēď, ďēďũƥĺĩćàţēď ēńţĩţŷ ŕēƒēŕēńćēś ŵōũĺď ēxćēēď 100, ţĥē śĥàŕēď ƥēŕ-ḿēśśàĝē ĺĩḿĩţ. Ţũŕńĩńĝ ţĥĩś ōƒƒ ĥĩďēś ţĥē ḿēŕĝē ćōńţŕōĺ ĩḿḿēďĩàţēĺŷ àńď ďōēś ńōţ àƒƒēćţ ḿēśśàĝēś àĺŕēàďŷ ḿēŕĝēď.",
|
||||
"messageQueueMergeToggleLabel": "Ēńàƀĺē qũēũēď ḿēśśàĝē ḿēŕĝĩńĝ",
|
||||
"messageQueueRetry": "Ŕēţŕŷ",
|
||||
"messageQueueSaveFailed": "Ƒàĩĺēď ţō śàvē ḿēśśàĝē qũēũē śēţţĩńĝś.",
|
||||
"messageQueueSourceDefault": "Ďēƒàũĺţ",
|
||||
|
||||
Reference in New Issue
Block a user