feat(sidebar): add last activity task sorting (#2762)
This commit is contained in:
@@ -295,6 +295,17 @@ func provideGateway(
|
||||
projector := statussummary.NewProjector(statussummary.ProjectorConfig{
|
||||
Store: taskRepo,
|
||||
EventBus: eventBus,
|
||||
LoadTaskActivity: func(ctx context.Context, taskID string) (*time.Time, error) {
|
||||
byTask, err := taskRepo.LoadTaskLastActivity(ctx, []string{taskID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activityAt, ok := byTask[taskID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &activityAt, nil
|
||||
},
|
||||
LoadSessionObservations: func(ctx context.Context, taskID string) (statussummary.SessionObservationSnapshot, error) {
|
||||
return loadTaskSessionObservations(ctx, taskRepo, activityProvider, taskID)
|
||||
},
|
||||
|
||||
@@ -110,6 +110,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
Reviews: repos.Task,
|
||||
ResourceCleanups: repos.Task,
|
||||
StatusSummaries: repos.Task,
|
||||
TaskActivity: repos.Task,
|
||||
SubagentContexts: repos.Task,
|
||||
},
|
||||
eventBus,
|
||||
|
||||
@@ -426,7 +426,7 @@ func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int
|
||||
reviewState := summarizeReviewState(raw.Reviews.Nodes)
|
||||
checksState := ""
|
||||
if len(raw.Commits.Nodes) > 0 && raw.Commits.Nodes[0].Commit.StatusCheckRollup != nil {
|
||||
checksState = strings.ToLower(raw.Commits.Nodes[0].Commit.StatusCheckRollup.State)
|
||||
checksState = normalizeGraphQLCheckRollupState(raw.Commits.Nodes[0].Commit.StatusCheckRollup.State)
|
||||
}
|
||||
unresolved := 0
|
||||
for _, t := range raw.ReviewThreads.Nodes {
|
||||
@@ -447,6 +447,23 @@ func convertBatchedPRResult(raw *batchedPRResult, owner, repo string, number int
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeGraphQLCheckRollupState converts GitHub's GraphQL status-rollup
|
||||
// enum to the smaller contract shared by REST and TaskPR persistence.
|
||||
func normalizeGraphQLCheckRollupState(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "":
|
||||
return ""
|
||||
case checkStatusSuccess:
|
||||
return checkStatusSuccess
|
||||
case checkConclusionFail, "error":
|
||||
return checkConclusionFail
|
||||
case "expected", checkStatusPending:
|
||||
return checkStatusPending
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// reviewNodesToSamples converts the GraphQL reviewNode shape to the shared
|
||||
// reviewSample slice used by the dedup helpers in client_helpers.go.
|
||||
func reviewNodesToSamples(nodes []reviewNode) []reviewSample {
|
||||
|
||||
@@ -220,6 +220,29 @@ func TestRunBatchedPRQuery_DecodesAliasesBackToRefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeGraphQLCheckRollupState_CheckRollup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "success", raw: "SUCCESS", want: checkStatusSuccess},
|
||||
{name: "failure", raw: "FAILURE", want: checkConclusionFail},
|
||||
{name: "error", raw: "ERROR", want: checkConclusionFail},
|
||||
{name: "pending", raw: "PENDING", want: checkStatusPending},
|
||||
{name: "expected", raw: "EXPECTED", want: checkStatusPending},
|
||||
{name: "empty", raw: "", want: ""},
|
||||
{name: "unknown", raw: "NEUTRAL", want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := normalizeGraphQLCheckRollupState(tt.raw); got != tt.want {
|
||||
t.Fatalf("normalizeGraphQLCheckRollupState(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchedPRQuery_PaginatesResolvedReviewThreads(t *testing.T) {
|
||||
exec := &stubGraphQLExecutor{responses: []string{
|
||||
batchedPRReviewThreadsResponse(t, 102, resolvedReviewThreadNodes(100), true, "cursor-1"),
|
||||
|
||||
@@ -797,6 +797,49 @@ func (s *Service) prepareTaskPRSyncState(ctx context.Context, tp *TaskPR, status
|
||||
}
|
||||
}
|
||||
|
||||
func taskPRChangedFields(tp *TaskPR, status *PRStatus, next taskPRSyncState) []string {
|
||||
if tp == nil || status == nil || status.PR == nil {
|
||||
return nil
|
||||
}
|
||||
changed := make([]string, 0, 16)
|
||||
changed = appendChangedField(changed, "state", tp.State != next.state)
|
||||
changed = appendChangedField(changed, "pr_title", tp.PRTitle != status.PR.Title)
|
||||
changed = appendChangedField(changed, "additions", tp.Additions != status.PR.Additions)
|
||||
changed = appendChangedField(changed, "deletions", tp.Deletions != status.PR.Deletions)
|
||||
changed = appendChangedField(changed, "review_state", tp.ReviewState != status.ReviewState)
|
||||
changed = appendChangedField(changed, "checks_state", tp.ChecksState != status.ChecksState)
|
||||
changed = appendChangedField(changed, "mergeable_state", tp.MergeableState != next.mergeableState)
|
||||
changed = appendChangedField(changed, "review_count", tp.ReviewCount != next.reviewCount)
|
||||
changed = appendChangedField(
|
||||
changed,
|
||||
"pending_review_count",
|
||||
tp.PendingReviewCount != next.pendingReviewCount,
|
||||
)
|
||||
changed = appendChangedField(
|
||||
changed,
|
||||
"required_reviews",
|
||||
!intPtrEqual(tp.RequiredReviews, next.requiredReviews),
|
||||
)
|
||||
changed = appendChangedField(changed, "checks_total", tp.ChecksTotal != next.checksTotal)
|
||||
changed = appendChangedField(changed, "checks_passing", tp.ChecksPassing != next.checksPassing)
|
||||
changed = appendChangedField(
|
||||
changed,
|
||||
"unresolved_review_threads",
|
||||
tp.UnresolvedReviewThreads != next.unresolved,
|
||||
)
|
||||
changed = appendChangedField(changed, "base_branch", tp.BaseBranch != next.baseBranch)
|
||||
changed = appendChangedField(changed, "merged_at", !timeEqual(tp.MergedAt, next.mergedAt))
|
||||
changed = appendChangedField(changed, "closed_at", !timeEqual(tp.ClosedAt, next.closedAt))
|
||||
return changed
|
||||
}
|
||||
|
||||
func appendChangedField(changed []string, field string, isChanged bool) []string {
|
||||
if !isChanged {
|
||||
return changed
|
||||
}
|
||||
return append(changed, field)
|
||||
}
|
||||
|
||||
// SyncTaskPR updates a TaskPR record with the latest PR status. Multi-repo:
|
||||
// the row is found by (task_id, owner, repo, pr_number) since the same
|
||||
// task can have several PRs; the legacy GetTaskPR(taskID) "first match"
|
||||
@@ -815,22 +858,7 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatu
|
||||
}
|
||||
next := s.prepareTaskPRSyncState(ctx, tp, status)
|
||||
|
||||
changed := tp.State != next.state ||
|
||||
tp.PRTitle != status.PR.Title ||
|
||||
tp.Additions != status.PR.Additions ||
|
||||
tp.Deletions != status.PR.Deletions ||
|
||||
tp.ReviewState != status.ReviewState ||
|
||||
tp.ChecksState != status.ChecksState ||
|
||||
tp.MergeableState != next.mergeableState ||
|
||||
tp.ReviewCount != next.reviewCount ||
|
||||
tp.PendingReviewCount != next.pendingReviewCount ||
|
||||
!intPtrEqual(tp.RequiredReviews, next.requiredReviews) ||
|
||||
tp.ChecksTotal != next.checksTotal ||
|
||||
tp.ChecksPassing != next.checksPassing ||
|
||||
tp.UnresolvedReviewThreads != next.unresolved ||
|
||||
tp.BaseBranch != next.baseBranch ||
|
||||
!timeEqual(tp.MergedAt, next.mergedAt) ||
|
||||
!timeEqual(tp.ClosedAt, next.closedAt)
|
||||
changedFields := taskPRChangedFields(tp, status, next)
|
||||
|
||||
tp.State = next.state
|
||||
tp.PRTitle = status.PR.Title
|
||||
@@ -856,7 +884,18 @@ func (s *Service) SyncTaskPR(ctx context.Context, taskID string, status *PRStatu
|
||||
return fmt.Errorf("update task PR: %w", err)
|
||||
}
|
||||
|
||||
if changed && s.eventBus != nil {
|
||||
if len(changedFields) > 0 {
|
||||
if s.logger != nil {
|
||||
s.logger.Debug("task PR semantic state changed",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("pr_owner", tp.Owner),
|
||||
zap.String("pr_repo", tp.Repo),
|
||||
zap.Int("pr_number", tp.PRNumber),
|
||||
zap.Strings("changed_fields", changedFields),
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(changedFields) > 0 && s.eventBus != nil {
|
||||
event := bus.NewEvent(events.GitHubTaskPRUpdated, "github", tp)
|
||||
if err := s.eventBus.Publish(ctx, events.GitHubTaskPRUpdated, event); err != nil {
|
||||
s.logger.Debug("failed to publish task PR updated event", zap.Error(err))
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -952,6 +953,121 @@ func TestSyncTaskPR_SecondIdenticalSyncNoEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTaskPR_EquivalentRESTAndGraphQLStatusNoSecondEvent(t *testing.T) {
|
||||
svc, store, eb := setupSyncTest(t)
|
||||
ctx := context.Background()
|
||||
createdAt := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC)
|
||||
updatedAt := createdAt.Add(time.Hour)
|
||||
pr := &PR{
|
||||
Number: 1,
|
||||
Title: "Same status",
|
||||
URL: "https://github.com/owner/repo/pull/1",
|
||||
HTMLURL: "https://github.com/owner/repo/pull/1",
|
||||
State: "open",
|
||||
HeadBranch: "feat",
|
||||
HeadSHA: "abc",
|
||||
BaseBranch: "main",
|
||||
AuthorLogin: "alice",
|
||||
RepoOwner: "owner",
|
||||
RepoName: "repo",
|
||||
Mergeable: true,
|
||||
MergeableState: "clean",
|
||||
Additions: 3,
|
||||
Deletions: 1,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
restStatus := newPRStatus(
|
||||
pr,
|
||||
[]PRReview{{Author: "alice", State: reviewStateApproved, CreatedAt: updatedAt}},
|
||||
[]CheckRun{{Status: checkStatusCompleted, Conclusion: checkConclusionSuccess}},
|
||||
)
|
||||
raw := &batchedPRResult{
|
||||
State: "OPEN",
|
||||
Title: pr.Title,
|
||||
URL: pr.URL,
|
||||
HeadRefName: pr.HeadBranch,
|
||||
BaseRefName: pr.BaseBranch,
|
||||
HeadRefOid: pr.HeadSHA,
|
||||
Additions: pr.Additions,
|
||||
Deletions: pr.Deletions,
|
||||
CreatedAt: pr.CreatedAt,
|
||||
UpdatedAt: pr.UpdatedAt,
|
||||
}
|
||||
raw.Author.Login = pr.AuthorLogin
|
||||
raw.Mergeable = "MERGEABLE"
|
||||
raw.MergeStatus = "CLEAN"
|
||||
raw.Reviews.Nodes = []reviewNode{{State: "APPROVED"}}
|
||||
raw.Reviews.Nodes[0].Author.Login = "alice"
|
||||
raw.Reviews.Nodes[0].SubmittedAt = updatedAt
|
||||
raw.Commits.Nodes = []struct {
|
||||
Commit struct {
|
||||
StatusCheckRollup *struct {
|
||||
State string `json:"state"`
|
||||
} `json:"statusCheckRollup"`
|
||||
} `json:"commit"`
|
||||
}{{}}
|
||||
raw.Commits.Nodes[0].Commit.StatusCheckRollup = &struct {
|
||||
State string `json:"state"`
|
||||
}{State: "SUCCESS"}
|
||||
graphqlStatus := convertBatchedPRResult(raw, "owner", "repo", 1)
|
||||
if graphqlStatus.ChecksState != restStatus.ChecksState {
|
||||
t.Fatalf("GraphQL checks state = %q, REST = %q", graphqlStatus.ChecksState, restStatus.ChecksState)
|
||||
}
|
||||
if graphqlStatus.ReviewState != restStatus.ReviewState || graphqlStatus.ReviewCount != restStatus.ReviewCount {
|
||||
t.Fatalf("GraphQL review summary = (%q, %d), REST = (%q, %d)", graphqlStatus.ReviewState, graphqlStatus.ReviewCount, restStatus.ReviewState, restStatus.ReviewCount)
|
||||
}
|
||||
if err := store.CreateTaskPR(ctx, &TaskPR{
|
||||
TaskID: "t1",
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
PRNumber: 1,
|
||||
PRURL: pr.URL,
|
||||
PRTitle: "Old title",
|
||||
HeadBranch: pr.HeadBranch,
|
||||
BaseBranch: pr.BaseBranch,
|
||||
State: "open",
|
||||
}); err != nil {
|
||||
t.Fatalf("create task PR: %v", err)
|
||||
}
|
||||
if err := svc.SyncTaskPR(ctx, "t1", restStatus); err != nil {
|
||||
t.Fatalf("REST sync: %v", err)
|
||||
}
|
||||
if err := svc.SyncTaskPR(ctx, "t1", graphqlStatus); err != nil {
|
||||
t.Fatalf("GraphQL sync: %v", err)
|
||||
}
|
||||
if got := eb.publishedCount(); got != 1 {
|
||||
t.Fatalf("equivalent REST and GraphQL snapshots published %d events, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTaskPR_ChangedFields(t *testing.T) {
|
||||
taskPR := &TaskPR{
|
||||
State: "open",
|
||||
PRTitle: "before",
|
||||
Additions: 1,
|
||||
Deletions: 2,
|
||||
ReviewState: "pending",
|
||||
ChecksState: "pending",
|
||||
MergeableState: "blocked",
|
||||
ReviewCount: 1,
|
||||
PendingReviewCount: 2,
|
||||
ChecksTotal: 3,
|
||||
ChecksPassing: 1,
|
||||
BaseBranch: "main",
|
||||
}
|
||||
status := &PRStatus{PR: &PR{Title: "after", State: "merged", Additions: 4, Deletions: 5, BaseBranch: "release", MergeableState: "clean"}, ReviewState: "approved", ChecksState: "success", ReviewCount: 2, PendingReviewCount: 0, ChecksTotal: 4, ChecksPassing: 4}
|
||||
next := taskPRSyncState{
|
||||
state: "merged", mergeableState: "clean", reviewCount: 2, pendingReviewCount: 0,
|
||||
checksTotal: 4, checksPassing: 4, baseBranch: "release",
|
||||
}
|
||||
want := []string{"state", "pr_title", "additions", "deletions", "review_state", "checks_state", "mergeable_state", "review_count", "pending_review_count", "checks_total", "checks_passing", "base_branch"}
|
||||
got := taskPRChangedFields(taskPR, status, next)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("taskPRChangedFields() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTaskPR_PublishesEventOnMergeableStateChange(t *testing.T) {
|
||||
svc, store, eb := setupSyncTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -253,7 +253,7 @@ func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to queue message", nil)
|
||||
}
|
||||
|
||||
h.publishStatus(ctx, req.SessionID)
|
||||
h.publishStatus(ctx, req.SessionID, queued)
|
||||
return ws.NewResponse(msg.ID, msg.Action, queued)
|
||||
}
|
||||
|
||||
@@ -954,7 +954,7 @@ func queueAccessDeniedResponse(msg *ws.Message) *ws.Message {
|
||||
|
||||
// publishStatus emits the latest QueueStatus on the event bus so the frontend
|
||||
// updates its store after every mutation.
|
||||
func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string) {
|
||||
func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string, admitted ...*messagequeue.QueuedMessage) {
|
||||
if h.eventBus == nil {
|
||||
return
|
||||
}
|
||||
@@ -966,6 +966,10 @@ func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string) {
|
||||
fieldMax: status.Max,
|
||||
"merge_enabled": status.MergeEnabled,
|
||||
}
|
||||
if len(admitted) > 0 && admitted[0] != nil && admitted[0].QueuedBy != "" && !messagequeue.IsReservedQueuedBy(admitted[0].QueuedBy) {
|
||||
eventData["queued_by"] = admitted[0].QueuedBy
|
||||
eventData["queued_at"] = admitted[0].QueuedAt
|
||||
}
|
||||
if h.sessionTaskResolver != nil {
|
||||
if taskID, err := h.sessionTaskResolver(ctx, sessionID); err != nil {
|
||||
h.logger.Warn("resolve session task for queue status event",
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -73,6 +74,28 @@ func TestPublishStatusIncludesTaskIDWhenResolvable(t *testing.T) {
|
||||
require.Equal(t, "s1", events.lastData["session_id"])
|
||||
}
|
||||
|
||||
func TestWsQueueMessagePublishesUserPromptActivity(t *testing.T) {
|
||||
handlers, _, events := setupQueueHandlersWithResolver(t, func(_ context.Context, sessionID string) (string, error) {
|
||||
if sessionID == "s1" {
|
||||
return "task-1", nil
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
response, err := handlers.wsQueueMessage(context.Background(), createTestMessage(t, ws.ActionMessageQueueAdd, map[string]interface{}{
|
||||
"session_id": "s1",
|
||||
"task_id": "task-1",
|
||||
"content": "queued prompt",
|
||||
"user_id": "user-1",
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
require.NotNil(t, events.lastData)
|
||||
require.Equal(t, "user-1", events.lastData["queued_by"])
|
||||
require.IsType(t, time.Time{}, events.lastData["queued_at"])
|
||||
require.False(t, events.lastData["queued_at"].(time.Time).IsZero())
|
||||
}
|
||||
|
||||
func TestPublishStatusOmitsTaskIDWhenResolutionFails(t *testing.T) {
|
||||
handlers, svc, events := setupQueueHandlersWithResolver(t, func(context.Context, string) (string, error) {
|
||||
return "", errors.New("session gone")
|
||||
|
||||
@@ -3,6 +3,7 @@ package messagequeue
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/entityrefs"
|
||||
"github.com/kandev/kandev/internal/messageconstraints"
|
||||
@@ -13,6 +14,7 @@ type autoMergedValues struct {
|
||||
content string
|
||||
attachments []MessageAttachment
|
||||
metadata map[string]interface{}
|
||||
queuedAt time.Time
|
||||
}
|
||||
|
||||
func buildAutoMergedEntry(target, source *QueuedMessage) (*autoMergedValues, bool) {
|
||||
@@ -31,9 +33,17 @@ func buildAutoMergedEntry(target, source *QueuedMessage) (*autoMergedValues, boo
|
||||
content: joinAutoMergeContent(target.Content, source.Content),
|
||||
attachments: attachments,
|
||||
metadata: metadata,
|
||||
queuedAt: latestQueuedAt(target.QueuedAt, source.QueuedAt),
|
||||
}, true
|
||||
}
|
||||
|
||||
func latestQueuedAt(first, second time.Time) time.Time {
|
||||
if second.After(first) {
|
||||
return second
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
func autoMergeAllowed(target, source *QueuedMessage) bool {
|
||||
if target == nil || source == nil || target.IsDurableLifecycle() || source.IsDurableLifecycle() {
|
||||
return false
|
||||
|
||||
@@ -99,6 +99,9 @@ func TestRepository_AutoMergeIntoAbove_PreservesIdentityAndCombinesAdditiveData(
|
||||
},
|
||||
})
|
||||
targetIdentity := autoMergeIdentity(*target)
|
||||
// The surviving row carries the newest admission timestamp so durable
|
||||
// activity reconstruction does not lose the folded user prompt.
|
||||
targetIdentity.QueuedAt = source.QueuedAt.UTC().Truncate(time.Microsecond)
|
||||
|
||||
merged, didMerge, err := autoRepo.AutoMergeIntoAbove(context.Background(), "session", source.ID)
|
||||
if err != nil || !didMerge {
|
||||
|
||||
@@ -650,6 +650,7 @@ func (r *memoryRepository) MergeIntoAbove(_ context.Context, sessionID, sourceID
|
||||
target.Content = content
|
||||
target.Attachments = attachments
|
||||
target.Metadata = metadata
|
||||
target.QueuedAt = latestQueuedAt(target.QueuedAt, source.QueuedAt)
|
||||
r.entries[sessionID] = append(list[:sourceIndex], list[sourceIndex+1:]...)
|
||||
if len(r.entries[sessionID]) == 0 {
|
||||
delete(r.entries, sessionID)
|
||||
@@ -694,6 +695,7 @@ func (r *memoryRepository) AutoMergeIntoAbove(_ context.Context, sessionID, sour
|
||||
target.Content = values.content
|
||||
target.Attachments = values.attachments
|
||||
target.Metadata = values.metadata
|
||||
target.QueuedAt = values.queuedAt
|
||||
r.entries[sessionID] = append(list[:sourceIndex], list[sourceIndex+1:]...)
|
||||
return cloneQueuedMessage(target), true, nil
|
||||
}
|
||||
@@ -722,6 +724,7 @@ func (r *memoryRepository) AutoMergeCandidateIntoAbove(_ context.Context, candid
|
||||
target.Content = values.content
|
||||
target.Attachments = values.attachments
|
||||
target.Metadata = values.metadata
|
||||
target.QueuedAt = values.queuedAt
|
||||
return cloneQueuedMessage(target), true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -756,9 +756,17 @@ func TestPostgresRepository_MergeDrainOrdering_DrainWins(t *testing.T) {
|
||||
func TestPostgresRepository_MergeDrainOrdering_MergeWins(t *testing.T) {
|
||||
repo := newTestPostgresRepo(t)
|
||||
ctx := context.Background()
|
||||
targetAt := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
|
||||
sourceAt := targetAt.Add(time.Minute)
|
||||
|
||||
target := insertTestEntry(t, repo, "s1", "t1", "first", "user", nil, nil)
|
||||
source := insertTestEntry(t, repo, "s1", "t1", "second", "user", nil, nil)
|
||||
sqlRepo := repo.(*sqliteRepository)
|
||||
for id, queuedAt := range map[string]time.Time{target.ID: targetAt, source.ID: sourceAt} {
|
||||
if _, err := sqlRepo.db.ExecContext(ctx, sqlRepo.db.Rebind(`UPDATE queued_messages SET queued_at = ? WHERE id = ?`), queuedAt, id); err != nil {
|
||||
t.Fatalf("set queued_at for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
merged, err := repo.MergeIntoAbove(ctx, "s1", source.ID, "user")
|
||||
if err != nil {
|
||||
@@ -774,6 +782,9 @@ func TestPostgresRepository_MergeDrainOrdering_MergeWins(t *testing.T) {
|
||||
if drained.Content != "first\n\nsecond" {
|
||||
t.Errorf("drained content = %q, want combined content", drained.Content)
|
||||
}
|
||||
if !merged.QueuedAt.Equal(sourceAt) || !drained.QueuedAt.Equal(sourceAt) {
|
||||
t.Errorf("merged queued_at = %s, drained queued_at = %s, want %s", merged.QueuedAt, drained.QueuedAt, sourceAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgresRepository_MergeIntoAbove_ReferenceOverflow asserts the overflow
|
||||
|
||||
@@ -152,6 +152,7 @@ func (r *sqliteRepository) initSchema() error {
|
||||
queued_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_queued_messages_session_position ON queued_messages(session_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_queued_messages_task_activity ON queued_messages(task_id, queued_by, queued_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lifecycle_queue_generations (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
@@ -1562,6 +1563,7 @@ func (r *sqliteRepository) MergeIntoAbove(ctx context.Context, sessionID, source
|
||||
merged.Content = content
|
||||
merged.Attachments = attachments
|
||||
merged.Metadata = metadata
|
||||
merged.QueuedAt = latestQueuedAt(target.QueuedAt, source.QueuedAt)
|
||||
return &merged, nil
|
||||
}
|
||||
|
||||
@@ -1611,6 +1613,7 @@ func (r *sqliteRepository) AutoMergeIntoAbove(ctx context.Context, sessionID, so
|
||||
target.Content = values.content
|
||||
target.Attachments = values.attachments
|
||||
target.Metadata = values.metadata
|
||||
target.QueuedAt = values.queuedAt
|
||||
return target, true, nil
|
||||
}
|
||||
|
||||
@@ -1675,15 +1678,16 @@ func (r *sqliteRepository) AutoMergeCandidateIntoAbove(ctx context.Context, cand
|
||||
// the head), violating FIFO drain order.
|
||||
res, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE queued_messages
|
||||
SET content = ?, attachments_json = ?, metadata_json = ?
|
||||
SET content = ?, attachments_json = ?, metadata_json = ?, queued_at = ?
|
||||
WHERE id = ? AND session_id = ?
|
||||
AND position = ?
|
||||
AND content = ?
|
||||
AND attachments_json = ?
|
||||
AND metadata_json = ?
|
||||
`), values.content, attachmentsJSON, metadataJSON,
|
||||
AND queued_at = ?
|
||||
`), values.content, attachmentsJSON, metadataJSON, values.queuedAt,
|
||||
target.ID, candidate.SessionID, target.Position,
|
||||
storedContent, storedAttachmentsJSON, storedMetadataJSON)
|
||||
storedContent, storedAttachmentsJSON, storedMetadataJSON, target.QueuedAt)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("update automatic candidate merge target: %w", err)
|
||||
}
|
||||
@@ -1704,6 +1708,7 @@ func (r *sqliteRepository) AutoMergeCandidateIntoAbove(ctx context.Context, cand
|
||||
target.Content = values.content
|
||||
target.Attachments = values.attachments
|
||||
target.Metadata = values.metadata
|
||||
target.QueuedAt = values.queuedAt
|
||||
return target, true, nil
|
||||
}
|
||||
|
||||
@@ -1869,11 +1874,12 @@ func applyMergeWrites(ctx context.Context, r *sqliteRepository, tx *sqlx.Tx, tar
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
queuedAt := latestQueuedAt(target.QueuedAt, source.QueuedAt)
|
||||
res, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE queued_messages
|
||||
SET content = ?, attachments_json = ?, metadata_json = ?
|
||||
SET content = ?, attachments_json = ?, metadata_json = ?, queued_at = ?
|
||||
WHERE id = ? AND session_id = ?
|
||||
`), content, attachmentsJSON, metadataJSON, target.ID, sessionID)
|
||||
`), content, attachmentsJSON, metadataJSON, queuedAt, target.ID, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update merge target: %w", err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/entityrefs"
|
||||
apiv1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
@@ -200,6 +201,8 @@ func TestSQLiteRepository_MergeDrainOrdering_MergeWins(t *testing.T) {
|
||||
func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
ctx := context.Background()
|
||||
targetAt := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC)
|
||||
sourceAt := targetAt.Add(time.Minute)
|
||||
|
||||
target := insertTestEntry(t, repo, "s1", "t1", "first", "user",
|
||||
[]MessageAttachment{{Type: "image", Data: "a", MimeType: "image/png"}},
|
||||
@@ -211,6 +214,12 @@ func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) {
|
||||
map[string]interface{}{
|
||||
MetadataEntityReferences: entityRefs("1", "2"),
|
||||
})
|
||||
sqliteRepo := repo.(*sqliteRepository)
|
||||
for id, queuedAt := range map[string]time.Time{target.ID: targetAt, source.ID: sourceAt} {
|
||||
if _, err := sqliteRepo.db.ExecContext(ctx, `UPDATE queued_messages SET queued_at = ? WHERE id = ?`, queuedAt, id); err != nil {
|
||||
t.Fatalf("set queued_at for %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
merged, err := repo.MergeIntoAbove(ctx, "s1", source.ID, "user")
|
||||
if err != nil {
|
||||
@@ -222,6 +231,9 @@ func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) {
|
||||
if merged.ID != target.ID {
|
||||
t.Errorf("merged id = %s, want target id %s", merged.ID, target.ID)
|
||||
}
|
||||
if !merged.QueuedAt.Equal(sourceAt) {
|
||||
t.Errorf("merged queued_at = %s, want %s", merged.QueuedAt, sourceAt)
|
||||
}
|
||||
if merged.Content != "first\n\nsecond" {
|
||||
t.Errorf("merged content = %q, want %q", merged.Content, "first\n\nsecond")
|
||||
}
|
||||
@@ -235,6 +247,13 @@ func TestSQLiteRepository_MergeIntoAbove_UserMergesIntoUser(t *testing.T) {
|
||||
if merged.QueuedBy != "user" {
|
||||
t.Errorf("merged queued_by = %q, want user", merged.QueuedBy)
|
||||
}
|
||||
entries, err := repo.ListBySession(ctx, "s1")
|
||||
if err != nil {
|
||||
t.Fatalf("list merged row: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || !entries[0].QueuedAt.Equal(sourceAt) {
|
||||
t.Fatalf("persisted merged queued_at = %#v, want %s", entries, sourceAt)
|
||||
}
|
||||
count, _ := repo.CountBySession(ctx, "s1")
|
||||
if count != 1 {
|
||||
t.Errorf("count after merge = %d, want 1", count)
|
||||
|
||||
@@ -83,6 +83,31 @@ func TestSQLiteRepository_InsertList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepository_HasTaskActivityIndex(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
sqliteRepo := repo.(*sqliteRepository)
|
||||
rows, err := sqliteRepo.db.Queryx(`PRAGMA index_info('idx_queued_messages_task_activity')`)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect task activity index: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
wantColumns := []string{"task_id", "queued_by", "queued_at"}
|
||||
for index, want := range wantColumns {
|
||||
if !rows.Next() {
|
||||
t.Fatalf("task activity index ended at column %d, want %q", index, want)
|
||||
}
|
||||
var seqno, columnID int
|
||||
var column string
|
||||
if err := rows.Scan(&seqno, &columnID, &column); err != nil {
|
||||
t.Fatalf("scan task activity index column %d: %v", index, err)
|
||||
}
|
||||
if column != want {
|
||||
t.Fatalf("task activity index column %d = %q, want %q", index, column, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepository_InsertRejectsOverflow(t *testing.T) {
|
||||
repo := newTestSQLiteRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
@@ -195,6 +196,7 @@ func (s *Service) queueMessageWithMetadataAdmission(ctx context.Context, session
|
||||
PlanMode: planMode,
|
||||
Attachments: attachments,
|
||||
Metadata: copyMessageMetadata(metadata, 0),
|
||||
QueuedAt: time.Now().UTC(),
|
||||
QueuedBy: userID,
|
||||
}
|
||||
merged, didMerge, mergeErr := s.repo.AutoMergeCandidateIntoAbove(admittedCtx, candidate)
|
||||
|
||||
@@ -991,12 +991,14 @@ func TestMemoryRepository_MergeIntoAbove(t *testing.T) {
|
||||
|
||||
target := &QueuedMessage{
|
||||
SessionID: "s1", TaskID: "t1", Content: "first", QueuedBy: "alice",
|
||||
QueuedAt: time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC),
|
||||
Attachments: []MessageAttachment{{Type: "image", Data: "a", MimeType: "image/png"}},
|
||||
Metadata: map[string]interface{}{MetadataEntityReferences: []apiv1.EntityReference{ref}},
|
||||
}
|
||||
require.NoError(t, repo.Insert(ctx, target, 0))
|
||||
source := &QueuedMessage{
|
||||
SessionID: "s1", TaskID: "t1", Content: "second", QueuedBy: "alice",
|
||||
QueuedAt: target.QueuedAt.Add(time.Minute),
|
||||
Attachments: []MessageAttachment{{Type: "file", Data: "b", MimeType: "text/plain"}},
|
||||
Metadata: map[string]interface{}{MetadataEntityReferences: []apiv1.EntityReference{ref}},
|
||||
}
|
||||
@@ -1006,6 +1008,7 @@ func TestMemoryRepository_MergeIntoAbove(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, target.ID, merged.ID)
|
||||
assert.Equal(t, "first\n\nsecond", merged.Content)
|
||||
assert.Equal(t, source.QueuedAt, merged.QueuedAt)
|
||||
assert.Len(t, merged.Attachments, 2)
|
||||
assert.Len(t, entityrefs.NormalizePersisted(merged.Metadata[MetadataEntityReferences]), 1)
|
||||
|
||||
@@ -1013,6 +1016,7 @@ func TestMemoryRepository_MergeIntoAbove(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1)
|
||||
assert.Equal(t, "first\n\nsecond", entries[0].Content)
|
||||
assert.Equal(t, source.QueuedAt, entries[0].QueuedAt)
|
||||
}
|
||||
|
||||
// TestMemoryRepository_MergeIntoAbove_MixedKindsRejected covers the memory repo
|
||||
|
||||
@@ -158,6 +158,12 @@ type TaskStatusSummaryRepository interface {
|
||||
DeleteTaskStatusSummary(ctx context.Context, taskID string) error
|
||||
}
|
||||
|
||||
// TaskActivityRepository reconstructs the bounded task activity timestamp
|
||||
// from authoritative task, prompt, and turn rows.
|
||||
type TaskActivityRepository interface {
|
||||
LoadTaskLastActivity(ctx context.Context, taskIDs []string) (map[string]time.Time, error)
|
||||
}
|
||||
|
||||
// TaskRepoRepository handles the task↔repository junction table (models.TaskRepository rows).
|
||||
// Named TaskRepoRepository to reduce reader confusion with the TaskRepository sub-interface above.
|
||||
type TaskRepoRepository interface {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/statussummary"
|
||||
@@ -67,6 +68,116 @@ func (r *Repository) LoadTaskStatusSummaries(ctx context.Context, taskIDs []stri
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LoadTaskLastActivity returns the latest bounded activity timestamp for each
|
||||
// requested task. Every source is filtered by task ID inside its union branch
|
||||
// so the batch remains scoped and does not scan unrelated task history.
|
||||
func (r *Repository) LoadTaskLastActivity(ctx context.Context, taskIDs []string) (map[string]time.Time, error) {
|
||||
result := make(map[string]time.Time, len(taskIDs))
|
||||
if len(taskIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for _, chunk := range chunkIDs(taskIDs, sqliteMaxHostParams/6) {
|
||||
placeholders, ids := buildInPlaceholders(chunk)
|
||||
query := `
|
||||
WITH activity AS (
|
||||
SELECT id AS task_id, created_at AS activity_at
|
||||
FROM tasks
|
||||
WHERE id IN (` + placeholders + `)
|
||||
UNION ALL
|
||||
SELECT id AS task_id, updated_at AS activity_at
|
||||
FROM tasks
|
||||
WHERE id IN (` + placeholders + `)
|
||||
UNION ALL
|
||||
SELECT task_id, created_at AS activity_at
|
||||
FROM task_session_messages
|
||||
WHERE task_id IN (` + placeholders + `)
|
||||
AND author_type = 'user'
|
||||
UNION ALL
|
||||
SELECT task_id, queued_at AS activity_at
|
||||
FROM queued_messages
|
||||
WHERE task_id IN (` + placeholders + `)
|
||||
AND queued_by NOT IN ('agent', 'workflow', 'server')
|
||||
UNION ALL
|
||||
SELECT task_id, started_at AS activity_at
|
||||
FROM task_session_turns
|
||||
WHERE task_id IN (` + placeholders + `)
|
||||
AND started_at IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT task_id, completed_at AS activity_at
|
||||
FROM task_session_turns
|
||||
WHERE task_id IN (` + placeholders + `)
|
||||
AND completed_at IS NOT NULL
|
||||
)
|
||||
SELECT task_id, MAX(activity_at) AS activity_at
|
||||
FROM activity
|
||||
GROUP BY task_id`
|
||||
args := make([]interface{}, 0, len(ids)*6)
|
||||
for range 6 {
|
||||
args = append(args, ids...)
|
||||
}
|
||||
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load task last activity: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var (
|
||||
taskID string
|
||||
rawActivityAt interface{}
|
||||
)
|
||||
if err := rows.Scan(&taskID, &rawActivityAt); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("scan task last activity: %w", err)
|
||||
}
|
||||
activityAt, err := parseTaskActivityTime(rawActivityAt)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("parse task last activity %q: %w", taskID, err)
|
||||
}
|
||||
result[taskID] = activityAt
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("read task last activity: %w", err)
|
||||
}
|
||||
_ = rows.Close()
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseTaskActivityTime(value interface{}) (time.Time, error) {
|
||||
switch value := value.(type) {
|
||||
case time.Time:
|
||||
return value, nil
|
||||
case string:
|
||||
return parseTaskActivityTimeString(value)
|
||||
case []byte:
|
||||
return parseTaskActivityTimeString(string(value))
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf("unsupported timestamp type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTaskActivityTimeString(value string) (time.Time, error) {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
if normalized := strings.Replace(value, " ", "T", 1); normalized != value {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, normalized); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
for _, layout := range []string{
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05",
|
||||
} {
|
||||
if parsed, err := time.ParseInLocation(layout, value, time.UTC); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("invalid timestamp %q", value)
|
||||
}
|
||||
|
||||
// CompareAndUpdateTaskStatusSummary inserts a new row or replaces an older
|
||||
// semantic value. The revision and semantic comparison live in the same SQL
|
||||
// statement, so concurrent source observations cannot overwrite a newer row.
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/statussummary"
|
||||
"github.com/kandev/kandev/internal/testutil"
|
||||
)
|
||||
@@ -53,3 +55,76 @@ func TestPostgresTaskStatusSummarySchemaReplay(t *testing.T) {
|
||||
t.Fatalf("postgres summary = %#v", loaded["task-summary-postgres"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresTaskLastActivityBatch(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("initialize postgres schema: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
base := time.Date(2026, time.August, 17, 10, 0, 0, 0, time.UTC)
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`), "task-activity-postgres", "workspace-activity-postgres", "Postgres activity", base, base.Add(time.Hour)); err != nil {
|
||||
t.Fatalf("seed postgres task: %v", err)
|
||||
}
|
||||
if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-activity-postgres", TaskID: "task-activity-postgres"}); err != nil {
|
||||
t.Fatalf("create postgres session: %v", err)
|
||||
}
|
||||
completedAt := base.Add(3 * time.Hour)
|
||||
if err := repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: "turn-activity-postgres", TaskSessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
|
||||
StartedAt: base.Add(2 * time.Hour), CompletedAt: &completedAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("create postgres turn: %v", err)
|
||||
}
|
||||
if err := repo.CreateMessage(ctx, &models.Message{
|
||||
ID: "message-activity-postgres", TaskSessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
|
||||
TurnID: "turn-activity-postgres", AuthorType: models.MessageAuthorUser, Content: "prompt",
|
||||
CreatedAt: base.Add(4 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("create postgres message: %v", err)
|
||||
}
|
||||
queueRepo, err := messagequeue.NewSQLiteRepository(db, db)
|
||||
if err != nil {
|
||||
t.Fatalf("initialize postgres queue repository: %v", err)
|
||||
}
|
||||
queuedAt := base.Add(5 * time.Hour)
|
||||
queuedTarget := &messagequeue.QueuedMessage{
|
||||
ID: "queued-activity-postgres-user-target", SessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
|
||||
Content: "queued target", QueuedAt: queuedAt.Add(-time.Minute), QueuedBy: "user-1",
|
||||
}
|
||||
if err := queueRepo.Insert(ctx, queuedTarget, 10); err != nil {
|
||||
t.Fatalf("insert postgres queued target: %v", err)
|
||||
}
|
||||
queuedSource := &messagequeue.QueuedMessage{
|
||||
ID: "queued-activity-postgres-user", SessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
|
||||
Content: "queued prompt", QueuedAt: queuedAt, QueuedBy: "user-1",
|
||||
}
|
||||
if err := queueRepo.Insert(ctx, queuedSource, 10); err != nil {
|
||||
t.Fatalf("insert postgres queued prompt: %v", err)
|
||||
}
|
||||
merged, err := queueRepo.MergeIntoAbove(ctx, queuedSource.SessionID, queuedSource.ID, queuedSource.QueuedBy)
|
||||
if err != nil {
|
||||
t.Fatalf("merge postgres queued prompts: %v", err)
|
||||
}
|
||||
if merged == nil || !merged.QueuedAt.Equal(queuedAt) {
|
||||
t.Fatalf("merged postgres queued activity = %#v, want %s", merged, queuedAt)
|
||||
}
|
||||
if err := queueRepo.Insert(ctx, &messagequeue.QueuedMessage{
|
||||
ID: "queued-activity-postgres-agent", SessionID: "session-activity-postgres", TaskID: "task-activity-postgres",
|
||||
Content: "queued agent prompt", QueuedAt: base.Add(6 * time.Hour), QueuedBy: messagequeue.QueuedByAgent,
|
||||
}, 10); err != nil {
|
||||
t.Fatalf("insert postgres queued agent prompt: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.LoadTaskLastActivity(ctx, []string{"task-activity-postgres"})
|
||||
if err != nil {
|
||||
t.Fatalf("load postgres task activity: %v", err)
|
||||
}
|
||||
if gotAt, ok := got["task-activity-postgres"]; !ok || !gotAt.Equal(queuedAt) {
|
||||
t.Fatalf("postgres activity = %v, %v; want %v", gotAt, ok, queuedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
dbutil "github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/statussummary"
|
||||
)
|
||||
|
||||
@@ -151,6 +153,145 @@ func TestTaskStatusSummaryBatchCompareAndUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskLastActivityBatch(t *testing.T) {
|
||||
repo, db := newTaskStatusSummaryTestRepo(t)
|
||||
ctx := context.Background()
|
||||
base := time.Date(2026, time.August, 17, 10, 0, 0, 0, time.UTC)
|
||||
seedTaskAt := func(taskID string, createdAt, updatedAt time.Time) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`), taskID, "workspace-activity", taskID, createdAt, updatedAt); err != nil {
|
||||
t.Fatalf("seed task %q: %v", taskID, err)
|
||||
}
|
||||
}
|
||||
seedTaskAt("task-activity-turn", base, base.Add(time.Hour))
|
||||
seedTaskAt("task-activity-active", base.Add(2*time.Hour), base.Add(3*time.Hour))
|
||||
seedTaskAt("task-activity-no-session", base.Add(4*time.Hour), base.Add(5*time.Hour))
|
||||
seedTaskAt("task-activity-queued", base.Add(4*time.Hour), base.Add(5*time.Hour))
|
||||
|
||||
queueRepo, err := messagequeue.NewSQLiteRepository(db, db)
|
||||
if err != nil {
|
||||
t.Fatalf("initialize queue repository: %v", err)
|
||||
}
|
||||
queuedAt := base.Add(9 * time.Hour)
|
||||
queuedTargetAt := queuedAt.Add(-time.Minute)
|
||||
queuedTarget := &messagequeue.QueuedMessage{
|
||||
ID: "queued-activity-user-target", SessionID: "session-activity-queued", TaskID: "task-activity-queued",
|
||||
Content: "queued target", QueuedAt: queuedTargetAt, QueuedBy: "user-1",
|
||||
}
|
||||
if err := queueRepo.Insert(ctx, queuedTarget, 10); err != nil {
|
||||
t.Fatalf("insert queued user target: %v", err)
|
||||
}
|
||||
queuedSource := &messagequeue.QueuedMessage{
|
||||
ID: "queued-activity-user", SessionID: "session-activity-queued", TaskID: "task-activity-queued",
|
||||
Content: "queued prompt", QueuedAt: queuedAt, QueuedBy: "user-1",
|
||||
}
|
||||
if err := queueRepo.Insert(ctx, queuedSource, 10); err != nil {
|
||||
t.Fatalf("insert queued user prompt: %v", err)
|
||||
}
|
||||
merged, err := queueRepo.MergeIntoAbove(ctx, queuedSource.SessionID, queuedSource.ID, queuedSource.QueuedBy)
|
||||
if err != nil {
|
||||
t.Fatalf("merge queued user prompts: %v", err)
|
||||
}
|
||||
if merged == nil || !merged.QueuedAt.Equal(queuedAt) {
|
||||
t.Fatalf("merged queued activity = %#v, want %s", merged, queuedAt)
|
||||
}
|
||||
if err := queueRepo.Insert(ctx, &messagequeue.QueuedMessage{
|
||||
ID: "queued-activity-agent", SessionID: "session-activity-queued", TaskID: "task-activity-queued",
|
||||
Content: "queued agent prompt", QueuedAt: base.Add(12 * time.Hour), QueuedBy: messagequeue.QueuedByAgent,
|
||||
}, 10); err != nil {
|
||||
t.Fatalf("insert queued agent prompt: %v", err)
|
||||
}
|
||||
|
||||
for _, session := range []*models.TaskSession{
|
||||
{ID: "session-activity-turn", TaskID: "task-activity-turn"},
|
||||
{ID: "session-activity-active", TaskID: "task-activity-active"},
|
||||
} {
|
||||
if err := repo.CreateTaskSession(ctx, session); err != nil {
|
||||
t.Fatalf("create session %q: %v", session.ID, err)
|
||||
}
|
||||
}
|
||||
completedAt := base.Add(8 * time.Hour)
|
||||
if err := repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: "turn-activity-completed",
|
||||
TaskSessionID: "session-activity-turn",
|
||||
TaskID: "task-activity-turn",
|
||||
StartedAt: base.Add(6 * time.Hour),
|
||||
CompletedAt: &completedAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("create completed turn: %v", err)
|
||||
}
|
||||
if err := repo.CreateMessage(ctx, &models.Message{
|
||||
ID: "message-activity-user",
|
||||
TaskSessionID: "session-activity-turn",
|
||||
TaskID: "task-activity-turn",
|
||||
TurnID: "turn-activity-completed",
|
||||
AuthorType: models.MessageAuthorUser,
|
||||
Content: "user prompt",
|
||||
CreatedAt: base.Add(7 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("create user message: %v", err)
|
||||
}
|
||||
if err := repo.CreateMessage(ctx, &models.Message{
|
||||
ID: "message-activity-agent",
|
||||
TaskSessionID: "session-activity-turn",
|
||||
TaskID: "task-activity-turn",
|
||||
TurnID: "turn-activity-completed",
|
||||
AuthorType: models.MessageAuthorAgent,
|
||||
Content: "agent output",
|
||||
CreatedAt: base.Add(12 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("create agent message: %v", err)
|
||||
}
|
||||
if err := repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: "turn-activity-active",
|
||||
TaskSessionID: "session-activity-active",
|
||||
TaskID: "task-activity-active",
|
||||
StartedAt: base.Add(10 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("create active turn: %v", err)
|
||||
}
|
||||
if err := repo.CreateMessage(ctx, &models.Message{
|
||||
ID: "message-activity-agent-active",
|
||||
TaskSessionID: "session-activity-active",
|
||||
TaskID: "task-activity-active",
|
||||
TurnID: "turn-activity-active",
|
||||
AuthorType: models.MessageAuthorAgent,
|
||||
Content: "streamed output",
|
||||
CreatedAt: base.Add(11 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("create active agent message: %v", err)
|
||||
}
|
||||
|
||||
got, err := repo.LoadTaskLastActivity(ctx, []string{
|
||||
"task-activity-turn",
|
||||
"task-activity-active",
|
||||
"task-activity-no-session",
|
||||
"task-activity-queued",
|
||||
"missing-task",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load task activity: %v", err)
|
||||
}
|
||||
want := map[string]time.Time{
|
||||
"task-activity-turn": completedAt,
|
||||
"task-activity-active": base.Add(10 * time.Hour),
|
||||
"task-activity-no-session": base.Add(5 * time.Hour),
|
||||
"task-activity-queued": queuedAt,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("activity rows = %#v, want %#v", got, want)
|
||||
}
|
||||
for taskID, wantAt := range want {
|
||||
gotAt, ok := got[taskID]
|
||||
if !ok || !gotAt.Equal(wantAt) {
|
||||
t.Errorf("activity[%q] = %v, %v; want %v", taskID, gotAt, ok, wantAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatusSummaryConcurrentUpdatesRemainMonotonic(t *testing.T) {
|
||||
repo, db := newTaskStatusSummaryTestRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -278,6 +278,7 @@ type Repos struct {
|
||||
Reviews repository.ReviewRepository
|
||||
ResourceCleanups repository.TaskResourceCleanupRepository
|
||||
StatusSummaries repository.TaskStatusSummaryRepository
|
||||
TaskActivity repository.TaskActivityRepository
|
||||
SubagentContexts repository.SubagentContextRepository
|
||||
}
|
||||
|
||||
@@ -301,6 +302,7 @@ type Service struct {
|
||||
reviews repository.ReviewRepository
|
||||
resourceCleanups repository.TaskResourceCleanupRepository
|
||||
statusSummaries repository.TaskStatusSummaryRepository
|
||||
taskActivity repository.TaskActivityRepository
|
||||
subagentContexts repository.SubagentContextRepository
|
||||
attachmentSvc *AttachmentService
|
||||
statusSummaryPRs TaskStatusSummaryPRReader
|
||||
@@ -440,6 +442,7 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover
|
||||
reviews: repos.Reviews,
|
||||
resourceCleanups: repos.ResourceCleanups,
|
||||
statusSummaries: repos.StatusSummaries,
|
||||
taskActivity: repos.TaskActivity,
|
||||
subagentContexts: repos.SubagentContexts,
|
||||
eventBus: eventBus,
|
||||
logger: log,
|
||||
|
||||
@@ -378,8 +378,8 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas
|
||||
"priority": task.Priority,
|
||||
"position": task.Position,
|
||||
"wip_admitted": task.WIPAdmitted,
|
||||
"created_at": task.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": task.UpdatedAt.Format(time.RFC3339),
|
||||
"created_at": task.CreatedAt.Format(time.RFC3339Nano),
|
||||
"updated_at": task.UpdatedAt.Format(time.RFC3339Nano),
|
||||
"is_ephemeral": task.IsEphemeral,
|
||||
"autopilot": task.Autopilot,
|
||||
// Consumers that restore quick-chat tabs filter on origin, so it has to
|
||||
@@ -388,7 +388,7 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas
|
||||
}
|
||||
data["queued_for_step_id"] = task.QueuedForStepID
|
||||
if task.QueuedAt != nil {
|
||||
data["queued_at"] = task.QueuedAt.Format(time.RFC3339)
|
||||
data["queued_at"] = task.QueuedAt.Format(time.RFC3339Nano)
|
||||
} else {
|
||||
data["queued_at"] = nil
|
||||
}
|
||||
@@ -407,7 +407,7 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas
|
||||
}
|
||||
data["archived_at"] = nil
|
||||
if task.ArchivedAt != nil {
|
||||
data["archived_at"] = task.ArchivedAt.Format(time.RFC3339)
|
||||
data["archived_at"] = task.ArchivedAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
// Orchestrator-originated events fetch the task via the raw repo.GetTask,
|
||||
// which does not populate Repositories. Load on demand so the payload
|
||||
|
||||
@@ -122,6 +122,42 @@ func TestTaskPublication_ActivityRefreshDoesNotOvertakeOrdinaryUpdate(t *testing
|
||||
<-activityDone
|
||||
}
|
||||
|
||||
func TestTaskPublication_PreservesSameSecondActivityOrdering(t *testing.T) {
|
||||
svc, eventBus, _ := createTestService(t)
|
||||
messageAt := time.Date(2026, 8, 18, 10, 20, 0, 100_000_000, time.UTC)
|
||||
taskUpdatedAt := messageAt.Add(800 * time.Millisecond)
|
||||
|
||||
svc.publishTaskEventNow(context.Background(), events.TaskUpdated, &models.Task{
|
||||
ID: "task-precision",
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: messageAt.Add(-time.Minute),
|
||||
UpdatedAt: taskUpdatedAt,
|
||||
}, nil, nil, nil, nil)
|
||||
|
||||
published := eventBus.GetPublishedEvents()
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("published events = %d, want 1", len(published))
|
||||
}
|
||||
data, ok := published[0].Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event data type = %T, want map[string]interface{}", published[0].Data)
|
||||
}
|
||||
updatedAt, ok := data["updated_at"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("updated_at type = %T, want string", data["updated_at"])
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, updatedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse updated_at %q: %v", updatedAt, err)
|
||||
}
|
||||
if !parsed.Equal(taskUpdatedAt) {
|
||||
t.Fatalf("published updated_at = %s, want %s", parsed, taskUpdatedAt)
|
||||
}
|
||||
if !parsed.After(messageAt) {
|
||||
t.Fatalf("task mutation at %s was not ordered after same-second message at %s", parsed, messageAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskPublication_QueuedActivityOutlivesCallerCancellation(t *testing.T) {
|
||||
svc, eventBus, repo := createTestServiceWithSessionsRepo(t, func(repo *sqliterepo.Repository) repository.SessionRepository {
|
||||
return cancellationAwareSessionRepository{SessionRepository: repo}
|
||||
|
||||
@@ -53,8 +53,9 @@ func (s *Service) ReconcileTaskStatusSummaries(
|
||||
if s == nil || s.statusSummaries == nil || len(tasks) == 0 {
|
||||
return summaries, nil
|
||||
}
|
||||
activityByTask, activityObserved := s.loadSummaryActivity(ctx, taskIDs(tasks))
|
||||
failedTaskIDs, reconcileErr := s.reconcileExistingSummaries(
|
||||
ctx, tasks, sessionsByTask, pendingBySession, summaries,
|
||||
ctx, tasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved,
|
||||
)
|
||||
rebuildTasks := tasks
|
||||
if len(failedTaskIDs) > 0 {
|
||||
@@ -69,7 +70,7 @@ func (s *Service) ReconcileTaskStatusSummaries(
|
||||
}
|
||||
}
|
||||
summaries = s.rebuildMissingSummaries(
|
||||
ctx, rebuildTasks, sessionsByTask, pendingBySession, summaries,
|
||||
ctx, rebuildTasks, sessionsByTask, pendingBySession, summaries, activityByTask, activityObserved,
|
||||
)
|
||||
return summaries, reconcileErr
|
||||
}
|
||||
@@ -80,6 +81,8 @@ func (s *Service) reconcileExistingSummaries(
|
||||
sessionsByTask map[string][]*models.TaskSession,
|
||||
pendingBySession map[string]models.TaskPendingAction,
|
||||
summaries map[string]*statussummary.TaskStatusSummary,
|
||||
activityByTask map[string]time.Time,
|
||||
activityObserved bool,
|
||||
) (map[string]struct{}, error) {
|
||||
var reconcileErr error
|
||||
failedTaskIDs := make(map[string]struct{})
|
||||
@@ -94,6 +97,8 @@ func (s *Service) reconcileExistingSummaries(
|
||||
task,
|
||||
summaries[task.ID],
|
||||
action,
|
||||
activityByTask[task.ID],
|
||||
activityObserved,
|
||||
)
|
||||
if err != nil {
|
||||
if reconciled != nil {
|
||||
@@ -127,6 +132,8 @@ func (s *Service) rebuildMissingSummaries(
|
||||
sessionsByTask map[string][]*models.TaskSession,
|
||||
pendingBySession map[string]models.TaskPendingAction,
|
||||
summaries map[string]*statussummary.TaskStatusSummary,
|
||||
activityByTask map[string]time.Time,
|
||||
activityObserved bool,
|
||||
) map[string]*statussummary.TaskStatusSummary {
|
||||
missing := missingSummaryTasks(tasks, summaries)
|
||||
if len(missing) == 0 {
|
||||
@@ -135,8 +142,10 @@ func (s *Service) rebuildMissingSummaries(
|
||||
prByTask, prObserved := s.loadSummaryPRs(ctx, taskIDs(missing))
|
||||
gitBySession, gitObserved := s.loadSummaryGit(ctx, sessionIDsForTasks(missing, sessionsByTask))
|
||||
queuedByTask := s.loadQueuedSummaryCounts(ctx, taskIDs(missing))
|
||||
activityAtByTask := activityByTask
|
||||
now := time.Now().UTC()
|
||||
for _, task := range missing {
|
||||
activityAt := activityAtByTask[task.ID]
|
||||
s.rebuildMissingSummary(ctx, task, summaries, s.rebuildInput(
|
||||
sessionsByTask[task.ID],
|
||||
pendingBySession,
|
||||
@@ -145,6 +154,8 @@ func (s *Service) rebuildMissingSummaries(
|
||||
prByTask[task.ID],
|
||||
prObserved,
|
||||
queuedByTask[task.ID],
|
||||
activityAt,
|
||||
activityObserved,
|
||||
now,
|
||||
))
|
||||
}
|
||||
@@ -162,6 +173,20 @@ func (s *Service) loadQueuedSummaryCounts(ctx context.Context, taskIDs []string)
|
||||
return map[string]int{}
|
||||
}
|
||||
|
||||
func (s *Service) loadSummaryActivity(ctx context.Context, taskIDs []string) (map[string]time.Time, bool) {
|
||||
if s.taskActivity == nil || len(taskIDs) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
activityByTask, err := s.taskActivity.LoadTaskLastActivity(ctx, taskIDs)
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("failed to load task activity for status summary repair", zap.Error(err))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return activityByTask, true
|
||||
}
|
||||
|
||||
func (s *Service) rebuildMissingSummary(
|
||||
ctx context.Context,
|
||||
task *models.Task,
|
||||
@@ -211,9 +236,14 @@ func (s *Service) reconcileExistingSummary(
|
||||
task *models.Task,
|
||||
current *statussummary.TaskStatusSummary,
|
||||
pendingAction string,
|
||||
authoritativeActivity time.Time,
|
||||
activityObserved bool,
|
||||
) (*statussummary.TaskStatusSummary, error) {
|
||||
for attempt := 0; attempt < maxSummaryReconcileAttempts && current != nil; attempt++ {
|
||||
if current.PendingAction == pendingAction {
|
||||
activityNeedsRepair := activityObserved &&
|
||||
(authoritativeActivity.After(time.Time{}) &&
|
||||
(current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt)))
|
||||
if current.PendingAction == pendingAction && !activityNeedsRepair {
|
||||
return current, nil
|
||||
}
|
||||
if err := prepareSummaryReconcileAttempt(ctx, attempt, current.Revision); err != nil {
|
||||
@@ -221,6 +251,9 @@ func (s *Service) reconcileExistingSummary(
|
||||
}
|
||||
next := *current
|
||||
next.PendingAction = pendingAction
|
||||
if activityObserved && authoritativeActivity.After(time.Time{}) {
|
||||
next.LastActivityAt = maxSummaryActivity(current.LastActivityAt, authoritativeActivity)
|
||||
}
|
||||
next.Revision = current.Revision + 1
|
||||
next.UpdatedAt = advancedSummaryTime(current.UpdatedAt, time.Now().UTC())
|
||||
if err := next.Validate(); err != nil {
|
||||
@@ -259,13 +292,32 @@ func (s *Service) reconcileExistingSummary(
|
||||
}
|
||||
pendingAction = pendingActionForTask(refreshedSessions, pendingBySession)
|
||||
}
|
||||
if current != nil && current.PendingAction == pendingAction {
|
||||
activityNeedsRepair := activityObserved &&
|
||||
(authoritativeActivity.After(time.Time{}) &&
|
||||
(current != nil && (current.LastActivityAt == nil || authoritativeActivity.After(*current.LastActivityAt))))
|
||||
if current != nil && current.PendingAction == pendingAction && !activityNeedsRepair {
|
||||
return current, nil
|
||||
}
|
||||
s.logSummaryReconcileExhaustion(task.ID, current)
|
||||
return nil, errors.New("exhausted compare-and-set retries")
|
||||
}
|
||||
|
||||
func maxSummaryActivity(current *time.Time, candidate time.Time) *time.Time {
|
||||
if candidate.IsZero() {
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
copy := current.UTC()
|
||||
return ©
|
||||
}
|
||||
if current != nil && !candidate.After(*current) {
|
||||
copy := current.UTC()
|
||||
return ©
|
||||
}
|
||||
copy := candidate.UTC()
|
||||
return ©
|
||||
}
|
||||
|
||||
func (s *Service) logSummaryReconcileExhaustion(
|
||||
taskID string,
|
||||
current *statussummary.TaskStatusSummary,
|
||||
@@ -447,18 +499,25 @@ func (s *Service) rebuildInput(
|
||||
prs []statussummary.PullRequestInput,
|
||||
prObserved bool,
|
||||
queuedPromptCount int,
|
||||
lastActivityAt time.Time,
|
||||
activityObserved bool,
|
||||
now time.Time,
|
||||
) statussummary.RebuildInput {
|
||||
input := statussummary.RebuildInput{
|
||||
Sessions: make([]statussummary.RebuildSession, 0, len(sessions)),
|
||||
PendingActions: make(map[string]string),
|
||||
ActivityObserved: s.foregroundActivity != nil,
|
||||
LastActivityAt: nil,
|
||||
PullRequests: prs,
|
||||
PRObserved: prObserved,
|
||||
GitObserved: gitObserved,
|
||||
QueuedPromptCount: maxInt(queuedPromptCount, 0),
|
||||
Now: now,
|
||||
}
|
||||
if activityObserved && !lastActivityAt.IsZero() {
|
||||
activityCopy := lastActivityAt.UTC()
|
||||
input.LastActivityAt = &activityCopy
|
||||
}
|
||||
countProvider, hasCountProvider := s.foregroundActivity.(activeSubagentCountProvider)
|
||||
for _, session := range sessions {
|
||||
if session == nil || session.ID == "" {
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
commonlogger "github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
"github.com/kandev/kandev/internal/task/statussummary"
|
||||
@@ -415,6 +417,68 @@ func TestReconcileTaskStatusSummariesRepairsExistingPendingOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSummaryActivityRebuildBackfillsAndPreservesNewerStoredValue(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
svc.statusSummaries = repo
|
||||
svc.taskActivity = repo
|
||||
queueDB := sqlx.NewDb(repo.DB(), "sqlite3")
|
||||
if _, err := messagequeue.NewSQLiteRepository(queueDB, queueDB); err != nil {
|
||||
t.Fatalf("initialize queue repository: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
createTaskWithoutRepositories(t, ctx, repo)
|
||||
base := time.Date(2026, time.August, 17, 10, 0, 0, 0, time.UTC)
|
||||
expected := base.Add(3 * time.Hour)
|
||||
if _, err := repo.DB().Exec(
|
||||
`UPDATE tasks SET created_at = ?, updated_at = ? WHERE id = ?`,
|
||||
base, expected, "task-1",
|
||||
); err != nil {
|
||||
t.Fatalf("set task timestamps: %v", err)
|
||||
}
|
||||
|
||||
tasks := []*models.Task{{ID: "task-1", WorkspaceID: "ws-1"}}
|
||||
got, err := svc.ReconcileTaskStatusSummaries(
|
||||
ctx,
|
||||
tasks,
|
||||
map[string][]*models.TaskSession{},
|
||||
map[string]models.TaskPendingAction{},
|
||||
map[string]*statussummary.TaskStatusSummary{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild missing status summary: %v", err)
|
||||
}
|
||||
backfilled := got["task-1"]
|
||||
if backfilled == nil || backfilled.LastActivityAt == nil || !backfilled.LastActivityAt.Equal(expected) {
|
||||
t.Fatalf("backfilled activity = %+v, want %v", backfilled, expected)
|
||||
}
|
||||
|
||||
newer := expected.Add(time.Hour)
|
||||
stored := statussummary.TaskStatusSummary{
|
||||
Revision: 9,
|
||||
LastActivityAt: &newer,
|
||||
}
|
||||
accepted, err := repo.CompareAndUpdateTaskStatusSummary(ctx, &statussummary.StoredTaskStatusSummary{
|
||||
TaskID: "task-1", WorkspaceID: "ws-1", Summary: stored,
|
||||
})
|
||||
if err != nil || !accepted {
|
||||
t.Fatalf("seed newer summary: accepted=%v err=%v", accepted, err)
|
||||
}
|
||||
got, err = svc.ReconcileTaskStatusSummaries(
|
||||
ctx,
|
||||
tasks,
|
||||
map[string][]*models.TaskSession{},
|
||||
map[string]models.TaskPendingAction{},
|
||||
map[string]*statussummary.TaskStatusSummary{"task-1": &stored},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile newer status summary: %v", err)
|
||||
}
|
||||
preserved := got["task-1"]
|
||||
if preserved == nil || preserved.Revision != stored.Revision || preserved.LastActivityAt == nil || !preserved.LastActivityAt.Equal(newer) {
|
||||
t.Fatalf("newer stored activity = %+v, want revision %d at %v", preserved, stored.Revision, newer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileTaskStatusSummariesInvalidatesStaleSummaryOnRepairFailure(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
repairErr := errors.New("status summary write failed")
|
||||
@@ -584,6 +648,8 @@ func TestReconcileExistingSummaryStopsBeforeRetryWhenContextIsCanceled(t *testin
|
||||
&models.Task{ID: "task-1", WorkspaceID: "ws-1"},
|
||||
stored,
|
||||
"",
|
||||
time.Time{},
|
||||
false,
|
||||
)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("reconcileExistingSummary error = %v, want context canceled", err)
|
||||
|
||||
@@ -8,6 +8,7 @@ const (
|
||||
|
||||
messageTypeClarificationRequest = "clarification_request"
|
||||
messageTypePermissionRequest = "permission_request"
|
||||
messageTypeUser = "user"
|
||||
messageTypeError = "error"
|
||||
messageTypeStatus = "status"
|
||||
messageTypeAgent = "agent"
|
||||
|
||||
@@ -28,6 +28,7 @@ const (
|
||||
type TaskStatusSummary struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
|
||||
PrimarySession *PrimarySessionSummary `json:"primary_session,omitempty"`
|
||||
ForegroundActivity string `json:"foreground_activity,omitempty"`
|
||||
ActiveSubagentCount int `json:"active_subagent_count,omitempty"`
|
||||
@@ -186,6 +187,7 @@ func (s TaskStatusSummary) SemanticJSON() ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(semanticPayload{
|
||||
LastActivityAt: s.LastActivityAt,
|
||||
PrimarySession: s.PrimarySession,
|
||||
ForegroundActivity: s.ForegroundActivity,
|
||||
ActiveSubagentCount: s.ActiveSubagentCount,
|
||||
@@ -198,6 +200,7 @@ func (s TaskStatusSummary) SemanticJSON() ([]byte, error) {
|
||||
}
|
||||
|
||||
type semanticPayload struct {
|
||||
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
|
||||
PrimarySession *PrimarySessionSummary `json:"primary_session,omitempty"`
|
||||
ForegroundActivity string `json:"foreground_activity,omitempty"`
|
||||
ActiveSubagentCount int `json:"active_subagent_count,omitempty"`
|
||||
|
||||
@@ -29,6 +29,34 @@ func TestTaskStatusSummarySemanticEqualityIgnoresTransportMetadata(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatusSummaryLastActivityIsSemanticAndBackwardCompatible(t *testing.T) {
|
||||
activity := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.UTC)
|
||||
first := TaskStatusSummary{LastActivityAt: &activity}
|
||||
second := first
|
||||
if first.SemanticEqual(second) == false {
|
||||
t.Fatal("equal last activity timestamps should compare equal")
|
||||
}
|
||||
later := activity.Add(time.Minute)
|
||||
second.LastActivityAt = &later
|
||||
if first.SemanticEqual(second) {
|
||||
t.Fatal("different last activity timestamps must not compare equal")
|
||||
}
|
||||
payload, err := first.SemanticJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("last activity semantic JSON: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(payload), `"last_activity_at"`) {
|
||||
t.Fatalf("last activity missing from semantic JSON: %s", payload)
|
||||
}
|
||||
var decoded TaskStatusSummary
|
||||
if err := json.Unmarshal([]byte(`{"revision": 4, "updated_at": "2026-08-17T10:00:00Z"}`), &decoded); err != nil {
|
||||
t.Fatalf("decode legacy summary: %v", err)
|
||||
}
|
||||
if decoded.LastActivityAt != nil {
|
||||
t.Fatalf("legacy summary activity = %v, want nil", decoded.LastActivityAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatusSummarySemanticJSONIsBoundedAndOmitsTransportMetadata(t *testing.T) {
|
||||
summary := TaskStatusSummary{
|
||||
Revision: 4,
|
||||
|
||||
@@ -54,6 +54,10 @@ type SessionObservationSnapshot struct {
|
||||
// observations for one task.
|
||||
type SessionObservationLoader func(context.Context, string) (SessionObservationSnapshot, error)
|
||||
|
||||
// TaskActivityLoader rehydrates the durable maximum activity timestamp for a
|
||||
// task when a projector starts with a legacy or incomplete summary row.
|
||||
type TaskActivityLoader func(context.Context, string) (*time.Time, error)
|
||||
|
||||
// PullRequestLoader rehydrates the keyed PR observations needed to preserve
|
||||
// sibling pull requests across projector restarts and CAS rebases.
|
||||
type PullRequestLoader func(context.Context, string) ([]PullRequestInput, error)
|
||||
@@ -78,6 +82,7 @@ type ProjectorConfig struct {
|
||||
LoadGitObservations GitObservationLoader
|
||||
LoadPendingActions PendingActionLoader
|
||||
LoadSessionObservations SessionObservationLoader
|
||||
LoadTaskActivity TaskActivityLoader
|
||||
LoadPullRequests PullRequestLoader
|
||||
// CountQueuedPrompts returns the number of prompts currently en-queued for
|
||||
// a task across all of its sessions (pending semantics identical to
|
||||
@@ -99,6 +104,7 @@ type Projector struct {
|
||||
loadGitObservations GitObservationLoader
|
||||
loadPendingActions PendingActionLoader
|
||||
loadSessionObservations SessionObservationLoader
|
||||
loadTaskActivity TaskActivityLoader
|
||||
loadPullRequests PullRequestLoader
|
||||
countQueuedPrompts func(context.Context, string) (int, error)
|
||||
logger *logger.Logger
|
||||
@@ -121,6 +127,7 @@ type projectionState struct {
|
||||
revision uint64
|
||||
current *TaskStatusSummary
|
||||
queuedCount int
|
||||
lastActivityAt *time.Time
|
||||
sessions map[string]sessionObservation
|
||||
pending map[string]string
|
||||
pendingRequests map[string]pendingRequestIdentity
|
||||
@@ -184,6 +191,7 @@ func NewProjector(cfg ProjectorConfig) *Projector {
|
||||
loadGitObservations: cfg.LoadGitObservations,
|
||||
loadPendingActions: cfg.LoadPendingActions,
|
||||
loadSessionObservations: cfg.LoadSessionObservations,
|
||||
loadTaskActivity: cfg.LoadTaskActivity,
|
||||
loadPullRequests: cfg.LoadPullRequests,
|
||||
countQueuedPrompts: cfg.CountQueuedPrompts,
|
||||
logger: log.WithFields(zap.String("component", "task-status-summary-projector")),
|
||||
@@ -201,13 +209,17 @@ func (p *Projector) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
patterns := []string{
|
||||
events.TaskCreated,
|
||||
events.TaskUpdated,
|
||||
events.TaskStateChanged,
|
||||
events.TaskSessionStateChanged,
|
||||
events.TaskSessionActivityChanged,
|
||||
events.TaskSessionErrorChanged,
|
||||
events.MessageAdded,
|
||||
events.MessageUpdated,
|
||||
events.MessageDeleted,
|
||||
events.TurnStarted,
|
||||
events.TurnCompleted,
|
||||
events.ClarificationAnswered,
|
||||
events.ClarificationPrimaryAnswered,
|
||||
events.ClarificationCancelled,
|
||||
@@ -304,16 +316,16 @@ func (p *Projector) handleEvent(ctx context.Context, event *bus.Event) error {
|
||||
}
|
||||
|
||||
if event.Type == events.MessageQueueStatusChanged {
|
||||
activityChanged := applyTaskActivityEventLocked(state, event.Type, data)
|
||||
pendingChanged := false
|
||||
if p.loadPendingActions != nil && !state.pendingObserved {
|
||||
pendingChanged, refreshErr := p.refreshPendingLocked(ctx, taskID, state)
|
||||
var refreshErr error
|
||||
pendingChanged, refreshErr = p.refreshPendingLocked(ctx, taskID, state)
|
||||
if refreshErr != nil {
|
||||
return refreshErr
|
||||
}
|
||||
if err := p.persistPendingRefreshLocked(ctx, taskID, state, pendingChanged, "", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return p.applyQueueStatusEvent(ctx, state, taskID)
|
||||
return p.applyQueueStatusEvent(ctx, state, taskID, pendingChanged || activityChanged, event.Type, data)
|
||||
}
|
||||
|
||||
refreshPending := p.loadPendingActions != nil &&
|
||||
@@ -391,46 +403,36 @@ const maxQueueCountPersistAttempts = 3
|
||||
// authoritative queue store. The event payload's per-session count is not
|
||||
// reused: the badge is per-task across all sessions, and the queue may have
|
||||
// changed between the status snapshot and this projection.
|
||||
func (p *Projector) applyQueueStatusEvent(ctx context.Context, state *projectionState, taskID string) error {
|
||||
if p.countQueuedPrompts == nil {
|
||||
return nil
|
||||
}
|
||||
func (p *Projector) applyQueueStatusEvent(
|
||||
ctx context.Context,
|
||||
state *projectionState,
|
||||
taskID string,
|
||||
sourceChanged bool,
|
||||
eventType string,
|
||||
eventData map[string]interface{},
|
||||
) error {
|
||||
for attempt := 0; attempt < maxQueueCountPersistAttempts; attempt++ {
|
||||
count, err := p.countQueuedPrompts(ctx, taskID)
|
||||
count, err := p.loadQueueCount(ctx, taskID, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count queued prompts for task %q: %w", taskID, err)
|
||||
if persistErr := p.persistQueueSourceOnCountError(ctx, taskID, state, sourceChanged); persistErr != nil {
|
||||
return persistErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
if count == state.queuedCount {
|
||||
if !sourceChanged && count == state.queuedCount {
|
||||
return nil
|
||||
}
|
||||
previousCount := state.queuedCount
|
||||
state.queuedCount = count
|
||||
accepted, err := p.persistAndPublishLocked(ctx, taskID, state)
|
||||
accepted, err := p.persistQueueStatus(ctx, taskID, state, count)
|
||||
if err != nil {
|
||||
// Keep in-memory state aligned with the last persisted value so a
|
||||
// later zero-count event can retry instead of short-circuiting.
|
||||
state.queuedCount = previousCount
|
||||
// Delete cascades the summary row (FK) before the post-commit
|
||||
// queue-status event. With warm state the recount hits persist
|
||||
// rather than resolveWorkspace. Only suppress a verified gone-task
|
||||
// failure; transient DB errors must propagate.
|
||||
if count == 0 && isGoneTaskPersistErr(err) {
|
||||
p.logger.Debug("skipping queue status persist for gone task",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if accepted {
|
||||
return nil
|
||||
}
|
||||
// A competing writer may have changed any summary domain. Rebuild every
|
||||
// keyed source before recounting so the queue retry cannot overwrite the
|
||||
// winner with stale in-memory observations.
|
||||
if err := p.rebaseProjectionStateFromCurrent(ctx, taskID, state); err != nil {
|
||||
if err := p.rebaseQueueStatusEvent(ctx, taskID, state, eventType, eventData); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceChanged = true
|
||||
}
|
||||
// The count self-corrects on the next queue event or list load, but a
|
||||
// sustained contention run is worth surfacing so a repeated rejector is not
|
||||
@@ -441,6 +443,75 @@ func (p *Projector) applyQueueStatusEvent(ctx context.Context, state *projection
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Projector) loadQueueCount(ctx context.Context, taskID string, state *projectionState) (int, error) {
|
||||
if p.countQueuedPrompts == nil {
|
||||
return state.queuedCount, nil
|
||||
}
|
||||
count, err := p.countQueuedPrompts(ctx, taskID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count queued prompts for task %q: %w", taskID, err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (p *Projector) persistQueueSourceOnCountError(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
state *projectionState,
|
||||
sourceChanged bool,
|
||||
) error {
|
||||
if !sourceChanged {
|
||||
return nil
|
||||
}
|
||||
_, err := p.persistAndPublishLocked(ctx, taskID, state)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Projector) persistQueueStatus(ctx context.Context, taskID string, state *projectionState, count int) (bool, error) {
|
||||
previousCount := state.queuedCount
|
||||
state.queuedCount = count
|
||||
accepted, err := p.persistAndPublishLocked(ctx, taskID, state)
|
||||
if err == nil {
|
||||
return accepted, nil
|
||||
}
|
||||
// Keep in-memory state aligned with the last persisted value so a later
|
||||
// zero-count event can retry instead of short-circuiting.
|
||||
state.queuedCount = previousCount
|
||||
// Delete cascades the summary row (FK) before the post-commit queue-status
|
||||
// event. With warm state the recount hits persist rather than resolveWorkspace.
|
||||
// Only suppress a verified gone-task failure; transient DB errors propagate.
|
||||
if count == 0 && isGoneTaskPersistErr(err) {
|
||||
p.logger.Debug("skipping queue status persist for gone task",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (p *Projector) rebaseQueueStatusEvent(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
state *projectionState,
|
||||
eventType string,
|
||||
eventData map[string]interface{},
|
||||
) error {
|
||||
// A competing writer may have changed any summary domain. Rebuild every
|
||||
// keyed source before recounting so the queue retry cannot overwrite the
|
||||
// winner with stale in-memory observations.
|
||||
if err := p.rebaseProjectionStateFromCurrent(ctx, taskID, state); err != nil {
|
||||
return err
|
||||
}
|
||||
if eventType != "" {
|
||||
applyTaskActivityEventLocked(state, eventType, eventData)
|
||||
}
|
||||
if p.loadPendingActions == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := p.refreshPendingLocked(ctx, taskID, state)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Projector) lockTask(taskID string) func() {
|
||||
p.taskLockMu.Lock()
|
||||
lock := p.taskLocks[taskID]
|
||||
@@ -504,6 +575,7 @@ func (p *Projector) persistAndPublishLocked(ctx context.Context, taskID string,
|
||||
state.current = cloneSummary(stored)
|
||||
state.revision = stored.Revision
|
||||
state.queuedCount = stored.QueuedPromptCount
|
||||
state.lastActivityAt = maxTimePtr(state.lastActivityAt, stored.LastActivityAt)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func deriveSummary(state *projectionState) TaskStatusSummary {
|
||||
Git: deriveGitSummary(state),
|
||||
PullRequest: derivePullRequestSummary(state),
|
||||
QueuedPromptCount: state.queuedCount,
|
||||
LastActivityAt: cloneTimePtr(state.lastActivityAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,52 +4,111 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
)
|
||||
|
||||
func (p *Projector) applySourceEventLocked(state *projectionState, eventType string, data map[string]interface{}) bool {
|
||||
activityChanged := applyTaskActivityEventLocked(state, eventType, data)
|
||||
var sourceChanged bool
|
||||
switch eventType {
|
||||
case events.TaskCreated, events.TaskStateChanged:
|
||||
// These lifecycle events have no additional bounded status fields. Their
|
||||
// persisted timestamps are handled by applyTaskActivityEventLocked.
|
||||
sourceChanged = false
|
||||
case events.TaskUpdated:
|
||||
return p.applyTaskUpdatedEventLocked(state, data)
|
||||
sourceChanged = p.applyTaskUpdatedEventLocked(state, data)
|
||||
case events.TaskSessionStateChanged:
|
||||
return p.applySessionEventLocked(state, data)
|
||||
sourceChanged = p.applySessionEventLocked(state, data)
|
||||
case events.TaskSessionActivityChanged:
|
||||
return p.applyActivityEventLocked(state, data)
|
||||
sourceChanged = p.applyActivityEventLocked(state, data)
|
||||
case events.TaskSessionErrorChanged:
|
||||
return p.applyErrorEventLocked(state, data)
|
||||
sourceChanged = p.applyErrorEventLocked(state, data)
|
||||
case events.MessageAdded, events.MessageUpdated, events.MessageDeleted:
|
||||
return p.applyMessageEventLocked(state, eventType, data)
|
||||
sourceChanged = p.applyMessageEventLocked(state, eventType, data)
|
||||
case events.TurnStarted, events.TurnCompleted:
|
||||
// Turn timestamps are the bounded activity source. The turn payload does
|
||||
// not contribute another task-row status field.
|
||||
sourceChanged = false
|
||||
case events.ClarificationAnswered, events.ClarificationPrimaryAnswered,
|
||||
events.ClarificationCancelled:
|
||||
if p.loadPendingActions != nil {
|
||||
return false
|
||||
sourceChanged = false
|
||||
break
|
||||
}
|
||||
return p.clearPendingLocked(state, stringField(data, "session_id"))
|
||||
sourceChanged = p.clearPendingLocked(state, stringField(data, "session_id"))
|
||||
case events.ClarificationStaleDismissed:
|
||||
if p.loadPendingActions != nil {
|
||||
return false
|
||||
sourceChanged = false
|
||||
break
|
||||
}
|
||||
sessionID := stringField(data, "session_id")
|
||||
dismissedID := stringField(data, "pending_id")
|
||||
if dismissedID != "" {
|
||||
if current, ok := state.pendingRequests[sessionID]; ok && current.pendingID != dismissedID {
|
||||
return false
|
||||
sourceChanged = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return p.clearPendingLocked(state, sessionID)
|
||||
sourceChanged = p.clearPendingLocked(state, sessionID)
|
||||
case events.PermissionRequestReceived:
|
||||
if p.loadPendingActions != nil {
|
||||
sourceChanged = false
|
||||
break
|
||||
}
|
||||
sourceChanged = applyPermissionEventLocked(state, data)
|
||||
case events.GitEvent:
|
||||
sourceChanged = p.applyGitEventLocked(state, data)
|
||||
case events.GitHubTaskPRUpdated:
|
||||
sourceChanged = p.applyPREventLocked(state, data)
|
||||
}
|
||||
return activityChanged || sourceChanged
|
||||
}
|
||||
|
||||
func applyTaskActivityEventLocked(state *projectionState, eventType string, data map[string]interface{}) bool {
|
||||
var candidate time.Time
|
||||
switch eventType {
|
||||
case events.TaskCreated, events.TaskUpdated, events.TaskStateChanged:
|
||||
candidate = timeValue(data["updated_at"])
|
||||
if candidate.IsZero() {
|
||||
candidate = timeValue(data["created_at"])
|
||||
}
|
||||
case events.MessageAdded:
|
||||
if stringField(data, "author_type") != messageTypeUser {
|
||||
return false
|
||||
}
|
||||
return applyPermissionEventLocked(state, data)
|
||||
case events.GitEvent:
|
||||
return p.applyGitEventLocked(state, data)
|
||||
case events.GitHubTaskPRUpdated:
|
||||
return p.applyPREventLocked(state, data)
|
||||
candidate = timeValue(data["created_at"])
|
||||
case events.MessageQueueStatusChanged:
|
||||
if !isUserQueuedPrompt(data) {
|
||||
return false
|
||||
}
|
||||
candidate = timeValue(data["queued_at"])
|
||||
case events.TurnStarted:
|
||||
candidate = timeValue(data["started_at"])
|
||||
case events.TurnCompleted:
|
||||
candidate = timeValue(data["completed_at"])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return advanceTaskActivity(state, candidate)
|
||||
}
|
||||
|
||||
func isUserQueuedPrompt(data map[string]interface{}) bool {
|
||||
queuedBy := stringField(data, "queued_by")
|
||||
return queuedBy != "" && queuedBy != "agent" && queuedBy != "workflow" && queuedBy != "server"
|
||||
}
|
||||
|
||||
func advanceTaskActivity(state *projectionState, candidate time.Time) bool {
|
||||
if candidate.IsZero() {
|
||||
return false
|
||||
}
|
||||
candidate = candidate.UTC()
|
||||
if state.lastActivityAt != nil && !candidate.After(*state.lastActivityAt) {
|
||||
return false
|
||||
}
|
||||
state.lastActivityAt = &candidate
|
||||
return true
|
||||
}
|
||||
|
||||
func applyPermissionEventLocked(state *projectionState, data map[string]interface{}) bool {
|
||||
@@ -218,18 +277,20 @@ func (p *Projector) ensureState(ctx context.Context, taskID string) (*projection
|
||||
}
|
||||
|
||||
func (p *Projector) restorePersistedState(ctx context.Context, taskID string, state *projectionState) error {
|
||||
if p.store == nil {
|
||||
return nil
|
||||
if p.store != nil {
|
||||
rows, err := p.store.LoadTaskStatusSummaries(ctx, []string{taskID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load task status summary %q: %w", taskID, err)
|
||||
}
|
||||
summary := rows[taskID]
|
||||
if summary != nil {
|
||||
state.current = cloneSummary(summary)
|
||||
state.revision = summary.Revision
|
||||
applySummaryBaseline(state, summary)
|
||||
}
|
||||
}
|
||||
rows, err := p.store.LoadTaskStatusSummaries(ctx, []string{taskID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load task status summary %q: %w", taskID, err)
|
||||
}
|
||||
summary := rows[taskID]
|
||||
if summary != nil {
|
||||
state.current = cloneSummary(summary)
|
||||
state.revision = summary.Revision
|
||||
applySummaryBaseline(state, summary)
|
||||
if err := p.restoreTaskActivity(ctx, taskID, state); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.restoreSessionObservations(ctx, taskID, state); err != nil {
|
||||
return err
|
||||
@@ -246,6 +307,7 @@ func (p *Projector) restorePersistedState(ctx context.Context, taskID string, st
|
||||
func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) {
|
||||
state.queuedCount = summary.QueuedPromptCount
|
||||
state.taskPending = summary.PendingAction
|
||||
state.lastActivityAt = maxTimePtr(state.lastActivityAt, summary.LastActivityAt)
|
||||
if summary.PrimarySession != nil && summary.PrimarySession.ID != "" {
|
||||
state.sessions[summary.PrimarySession.ID] = sessionObservation{
|
||||
id: summary.PrimarySession.ID,
|
||||
@@ -267,6 +329,18 @@ func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Projector) restoreTaskActivity(ctx context.Context, taskID string, state *projectionState) error {
|
||||
if p.loadTaskActivity == nil || state.lastActivityAt != nil {
|
||||
return nil
|
||||
}
|
||||
activityAt, err := p.loadTaskActivity(ctx, taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load task activity for task status summary %q: %w", taskID, err)
|
||||
}
|
||||
state.lastActivityAt = maxTimePtr(state.lastActivityAt, activityAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rebaseProjectionStateFromCurrent rebuilds all derived source state from the
|
||||
// summary that won a rejected compare-and-set. Pending authority and the
|
||||
// triggering source event are refreshed by the caller before retrying.
|
||||
@@ -279,8 +353,10 @@ func (p *Projector) rebaseProjectionStateFromCurrent(
|
||||
return nil
|
||||
}
|
||||
current := state.current
|
||||
previousLastActivityAt := cloneTimePtr(state.lastActivityAt)
|
||||
state.sessions = make(map[string]sessionObservation)
|
||||
state.activityObserved = false
|
||||
state.lastActivityAt = previousLastActivityAt
|
||||
state.pending = make(map[string]string)
|
||||
state.pendingRequests = make(map[string]pendingRequestIdentity)
|
||||
state.pendingObserved = false
|
||||
@@ -293,6 +369,9 @@ func (p *Projector) rebaseProjectionStateFromCurrent(
|
||||
state.prBaseline = nil
|
||||
state.prObserved = false
|
||||
applySummaryBaseline(state, current)
|
||||
if err := p.restoreTaskActivity(ctx, taskID, state); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.restoreSessionObservations(ctx, taskID, state); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -282,6 +282,10 @@ func timeValue(value interface{}) time.Time {
|
||||
switch value := value.(type) {
|
||||
case time.Time:
|
||||
return value
|
||||
case *time.Time:
|
||||
if value != nil {
|
||||
return *value
|
||||
}
|
||||
case string:
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err == nil {
|
||||
@@ -291,6 +295,26 @@ func timeValue(value interface{}) time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func cloneTimePtr(value *time.Time) *time.Time {
|
||||
if value == nil || value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
copy := value.UTC()
|
||||
return ©
|
||||
}
|
||||
|
||||
func maxTimePtr(left, right *time.Time) *time.Time {
|
||||
left = cloneTimePtr(left)
|
||||
right = cloneTimePtr(right)
|
||||
if left == nil {
|
||||
return right
|
||||
}
|
||||
if right == nil || !right.After(*left) {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func maxInt(value, minimum int) int {
|
||||
if value < minimum {
|
||||
return minimum
|
||||
|
||||
@@ -39,6 +39,40 @@ func TestProjectorQueueEventUpdatesQueuedPromptCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectorQueueEventTracksUserPromptActivity(t *testing.T) {
|
||||
projector, store, eventBus, updates, _ := newProjectorTest(t)
|
||||
const taskID = "task-queued-activity"
|
||||
queuedAt := time.Date(2026, 8, 1, 19, 0, 0, 0, time.UTC)
|
||||
|
||||
projector.countQueuedPrompts = func(context.Context, string) (int, error) {
|
||||
return 1, nil
|
||||
}
|
||||
publishProjectorEvent(t, eventBus, events.MessageQueueStatusChanged, events.MessageQueueStatusChanged, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"queued_by": "user-1",
|
||||
"queued_at": queuedAt,
|
||||
})
|
||||
|
||||
summary := store.summary(taskID)
|
||||
if summary == nil || summary.LastActivityAt == nil || !summary.LastActivityAt.Equal(queuedAt) {
|
||||
t.Fatalf("queued user activity = %+v, want %s", summary, queuedAt)
|
||||
}
|
||||
if got := updates.Load(); got != 1 {
|
||||
t.Fatalf("queued user admission published %d summary updates, want 1", got)
|
||||
}
|
||||
|
||||
// Queue status events without a user-owned admission must remain count-only
|
||||
// bookkeeping, even when they carry a newer timestamp-shaped value.
|
||||
publishProjectorEvent(t, eventBus, events.MessageQueueStatusChanged, events.MessageQueueStatusChanged, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"queued_by": "agent",
|
||||
"queued_at": queuedAt.Add(time.Hour),
|
||||
})
|
||||
if got := store.summary(taskID).LastActivityAt; got == nil || !got.Equal(queuedAt) {
|
||||
t.Fatalf("agent queue activity changed last activity to %v, want %s", got, queuedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectorQueueEventWithUnchangedCountDoesNotRepublish(t *testing.T) {
|
||||
projector, store, eventBus, updates, _ := newProjectorTest(t)
|
||||
const taskID = "task-queued-unchanged"
|
||||
|
||||
@@ -334,6 +334,170 @@ func TestProjectorDerivesBoundedStatusAcrossSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectorLastActivityTracksBoundedSourcesMonotonically(t *testing.T) {
|
||||
_, store, eventBus, _, _ := newProjectorTest(t)
|
||||
const taskID = "task-last-activity-projector"
|
||||
const sessionID = "session-last-activity-projector"
|
||||
|
||||
publishProjectorEvent(t, eventBus, events.TaskCreated, events.TaskCreated, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"created_at": "2026-08-01T18:01:00Z",
|
||||
"updated_at": "2026-08-01T18:01:00Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z")
|
||||
|
||||
// Focus and subscription changes are operational state, not task activity.
|
||||
publishProjectorEvent(t, eventBus, events.TaskSessionActivityChanged, events.TaskSessionActivityChanged, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"session_id": sessionID,
|
||||
"foreground_activity": "background",
|
||||
"updated_at": "2026-08-01T18:02:00Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z")
|
||||
|
||||
// Provider polling can refresh a PR summary without making the task active.
|
||||
publishProjectorEvent(t, eventBus, events.GitHubTaskPRUpdated, events.GitHubTaskPRUpdated, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"repository_id": "repo-last-activity",
|
||||
"state": "open",
|
||||
"pr_number": 12,
|
||||
"pr_url": "https://example.test/pr/12",
|
||||
"updated_at": "2026-08-01T18:03:00Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z")
|
||||
|
||||
// Agent-authored output and streamed/message-update bookkeeping do not move
|
||||
// activity. The user-authored message below is the first message source that
|
||||
// should move it.
|
||||
publishProjectorEvent(t, eventBus, events.MessageAdded, events.MessageAdded, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"session_id": sessionID,
|
||||
"author_type": "agent",
|
||||
"type": "text",
|
||||
"created_at": "2026-08-01T18:04:00Z",
|
||||
})
|
||||
publishProjectorEvent(t, eventBus, events.MessageUpdated, events.MessageUpdated, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"session_id": sessionID,
|
||||
"author_type": "agent",
|
||||
"type": "text",
|
||||
"updated_at": "2026-08-01T18:04:30Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:01:00Z")
|
||||
|
||||
publishProjectorEvent(t, eventBus, events.MessageAdded, events.MessageAdded, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"session_id": sessionID,
|
||||
"author_type": "user",
|
||||
"type": "text",
|
||||
"created_at": "2026-08-01T18:05:00Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:05:00Z")
|
||||
|
||||
publishProjectorEvent(t, eventBus, events.TurnStarted, events.TurnStarted, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"session_id": sessionID,
|
||||
"started_at": "2026-08-01T18:06:00Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:06:00Z")
|
||||
|
||||
publishProjectorEvent(t, eventBus, events.TurnCompleted, events.TurnCompleted, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"session_id": sessionID,
|
||||
"completed_at": "2026-08-01T18:07:00Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:07:00Z")
|
||||
|
||||
// State-only transitions are activity even when they carry no other
|
||||
// bounded-summary field.
|
||||
publishProjectorEvent(t, eventBus, events.TaskStateChanged, events.TaskStateChanged, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"new_state": "in_progress",
|
||||
"updated_at": "2026-08-01T18:08:00Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:08:00Z")
|
||||
|
||||
// Queue bookkeeping is deliberately ignored, and an older persisted update
|
||||
// cannot move the monotonic maximum backwards.
|
||||
publishProjectorEvent(t, eventBus, events.MessageQueueStatusChanged, events.MessageQueueStatusChanged, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"updated_at": "2026-08-01T18:09:00Z",
|
||||
})
|
||||
publishProjectorEvent(t, eventBus, events.TaskUpdated, events.TaskUpdated, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"updated_at": "2026-08-01T18:07:30Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:08:00Z")
|
||||
|
||||
publishProjectorEvent(t, eventBus, events.TaskUpdated, events.TaskUpdated, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"workspace_id": "workspace-1",
|
||||
"updated_at": "2026-08-01T18:10:00Z",
|
||||
})
|
||||
assertProjectedLastActivity(t, store.summary(taskID), "2026-08-01T18:10:00Z")
|
||||
}
|
||||
|
||||
func TestProjectorLastActivityRehydratesFromDurableLoader(t *testing.T) {
|
||||
const taskID = "task-last-activity-rehydrate"
|
||||
want := time.Date(2026, 8, 1, 18, 11, 0, 0, time.UTC)
|
||||
store := newProjectorTestStore()
|
||||
store.rows[taskID] = &StoredTaskStatusSummary{
|
||||
TaskID: taskID,
|
||||
WorkspaceID: "workspace-1",
|
||||
Summary: TaskStatusSummary{
|
||||
Revision: 4,
|
||||
UpdatedAt: want.Add(-time.Minute),
|
||||
},
|
||||
}
|
||||
var calls atomic.Int64
|
||||
projector := NewProjector(ProjectorConfig{
|
||||
Store: store,
|
||||
ResolveWorkspace: func(context.Context, string) (string, error) {
|
||||
return "workspace-1", nil
|
||||
},
|
||||
LoadTaskActivity: func(context.Context, string) (*time.Time, error) {
|
||||
calls.Add(1)
|
||||
return &want, nil
|
||||
},
|
||||
})
|
||||
|
||||
state, err := projector.ensureState(context.Background(), taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("restore projector state: %v", err)
|
||||
}
|
||||
if state.lastActivityAt == nil || !state.lastActivityAt.Equal(want) {
|
||||
t.Fatalf("rehydrated last activity = %v, want %v", state.lastActivityAt, want)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("durable activity loader calls = %d, want 1", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func assertProjectedLastActivity(t *testing.T, summary *TaskStatusSummary, want string) {
|
||||
t.Helper()
|
||||
if summary == nil || summary.LastActivityAt == nil {
|
||||
t.Fatalf("projected last activity = %+v, want %s", summary, want)
|
||||
}
|
||||
expected, err := time.Parse(time.RFC3339, want)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expected activity %q: %v", want, err)
|
||||
}
|
||||
if !summary.LastActivityAt.Equal(expected) {
|
||||
t.Fatalf("projected last activity = %s, want %s", summary.LastActivityAt.Format(time.RFC3339), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Session events carry a `session_metadata` snapshot taken when the publisher
|
||||
// read the session, so one taken before the error was cleared must not re-arm an
|
||||
// error this projection already cleared.
|
||||
|
||||
@@ -50,6 +50,7 @@ type RebuildInput struct {
|
||||
Sessions []RebuildSession
|
||||
PendingActions map[string]string
|
||||
ActivityObserved bool
|
||||
LastActivityAt *time.Time
|
||||
Git []RebuildGit
|
||||
GitObserved bool
|
||||
PullRequests []PullRequestInput
|
||||
@@ -112,7 +113,12 @@ func BuildFromAuthoritative(input RebuildInput) TaskStatusSummary {
|
||||
}
|
||||
applyPullRequestInputs(state, input.PullRequests)
|
||||
state.queuedCount = maxInt(input.QueuedPromptCount, 0)
|
||||
return deriveSummary(state)
|
||||
summary := deriveSummary(state)
|
||||
if input.LastActivityAt != nil {
|
||||
lastActivityAt := input.LastActivityAt.UTC()
|
||||
summary.LastActivityAt = &lastActivityAt
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func normalizeRebuildError(input *ActiveErrorSummary, now time.Time) *ActiveErrorSummary {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { TaskSwitcherProps } from "../task-switcher";
|
||||
import type { MobileTaskListProps } from "./session-task-switcher-sheet";
|
||||
|
||||
/** Maps mobile view state and actions to the shared task switcher rows. */
|
||||
export function buildMobileTaskSwitcherProps(
|
||||
props: MobileTaskListProps,
|
||||
helpers: {
|
||||
grouped: TaskSwitcherProps["grouped"];
|
||||
collapsedGroupKeys: string[];
|
||||
onToggleGroup: (groupKey: string) => void;
|
||||
collapsedSubtaskParentIds: string[];
|
||||
onToggleSubtasks: (parentTaskId: string) => void;
|
||||
onTogglePin: (taskId: string) => void;
|
||||
onReorderGroup: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
pinnedTaskIds: string[];
|
||||
showActivityTime: boolean;
|
||||
},
|
||||
): TaskSwitcherProps {
|
||||
return {
|
||||
grouped: helpers.grouped,
|
||||
workflows: props.workflows,
|
||||
stepsByWorkflowId: props.stepsByWorkflowId,
|
||||
activeTaskId: props.activeTaskId,
|
||||
selectedTaskId: props.selectedTaskId,
|
||||
collapsedGroupKeys: helpers.collapsedGroupKeys,
|
||||
showActivityTime: helpers.showActivityTime,
|
||||
onToggleGroup: helpers.onToggleGroup,
|
||||
collapsedSubtaskParentIds: helpers.collapsedSubtaskParentIds,
|
||||
onToggleSubtasks: helpers.onToggleSubtasks,
|
||||
onSelectTask: props.onSelectTask,
|
||||
onEditTask: props.onEditTask,
|
||||
onRenameTask: props.onRenameTask,
|
||||
onCreateSubtask: props.onCreateSubtask,
|
||||
onArchiveTask: props.onArchiveTask,
|
||||
onDeleteTask: props.onDeleteTask,
|
||||
onDetachTask: props.onDetachTask,
|
||||
onNestTask: props.onNestTask,
|
||||
onLinkPullRequest: props.onLinkPullRequest,
|
||||
onLinkIssue: props.onLinkIssue,
|
||||
onLinkMergeRequest: props.onLinkMergeRequest,
|
||||
onLinkJiraTicket: props.onLinkJiraTicket,
|
||||
onLinkLinearIssue: props.onLinkLinearIssue,
|
||||
onLinkSentryIssue: props.onLinkSentryIssue,
|
||||
onTogglePin: helpers.onTogglePin,
|
||||
onReorderGroup: helpers.onReorderGroup,
|
||||
onReorderSubtasks: helpers.onReorderSubtasks,
|
||||
pinnedTaskIds: helpers.pinnedTaskIds,
|
||||
deletingTaskId: props.deletingTaskId,
|
||||
isLoading: props.isLoading,
|
||||
loadError: props.loadError,
|
||||
onRetryLoad: props.onRetryLoad,
|
||||
retryLabel: props.retryLabel,
|
||||
totalTaskCount: props.tasks.length,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/dra
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { QuickChatSheetButton } from "./quick-chat-sheet-button";
|
||||
import { TaskSwitcher } from "../task-switcher";
|
||||
import type { TaskSwitcherItem, TaskSwitcherProps } from "../task-switcher";
|
||||
import type { TaskSwitcherItem } from "../task-switcher";
|
||||
import { SidebarFilterBar } from "../sidebar-filter/sidebar-filter-bar";
|
||||
import type { StepDef } from "../task-switcher-context-menu";
|
||||
import type { TaskMoveWorkflow } from "../task-move-context-menu";
|
||||
@@ -39,7 +39,7 @@ import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
|
||||
import { useMobileTaskRename } from "./use-mobile-task-rename";
|
||||
import { SidebarTaskEditDialog, useSidebarTaskEdit } from "../task-session-sidebar-edit";
|
||||
import { usePortForwardingVisibility } from "../port-forwarding-visibility-provider";
|
||||
|
||||
import { buildMobileTaskSwitcherProps } from "./session-task-switcher-sheet-props";
|
||||
type SessionTaskSwitcherSheetProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -47,7 +47,6 @@ type SessionTaskSwitcherSheetProps = {
|
||||
workflowId: string | null;
|
||||
presentation?: "sheet" | "drawer";
|
||||
};
|
||||
|
||||
export function useTaskSheetSelectionController() {
|
||||
const [selectionController] = useState(createTaskSheetSelectionController);
|
||||
useEffect(
|
||||
@@ -58,7 +57,6 @@ export function useTaskSheetSelectionController() {
|
||||
);
|
||||
return selectionController;
|
||||
}
|
||||
|
||||
function useMobileTaskLinking(workspaceId: string | null) {
|
||||
const store = useAppStoreApi();
|
||||
const actions = useSidebarLinkActions(store);
|
||||
@@ -80,7 +78,7 @@ function useSidebarGroupToggle(viewId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
type MobileTaskListProps = {
|
||||
export type MobileTaskListProps = {
|
||||
tasks: TaskSwitcherItem[];
|
||||
workflows: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, StepDef[]>;
|
||||
@@ -107,62 +105,6 @@ type MobileTaskListProps = {
|
||||
retryLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assembles the TaskSwitcher props for the mobile task list, mirroring the
|
||||
* desktop `buildTaskSwitcherProps` so the prop-forwarding surface stays a
|
||||
* thin mapping layer.
|
||||
*/
|
||||
function buildMobileTaskSwitcherProps(
|
||||
props: MobileTaskListProps,
|
||||
helpers: {
|
||||
grouped: TaskSwitcherProps["grouped"];
|
||||
collapsedGroupKeys: string[];
|
||||
onToggleGroup: (groupKey: string) => void;
|
||||
collapsedSubtaskParentIds: string[];
|
||||
onToggleSubtasks: (parentTaskId: string) => void;
|
||||
onTogglePin: (taskId: string) => void;
|
||||
onReorderGroup: (groupTaskIds: string[]) => void;
|
||||
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
|
||||
pinnedTaskIds: string[];
|
||||
},
|
||||
): TaskSwitcherProps {
|
||||
return {
|
||||
grouped: helpers.grouped,
|
||||
workflows: props.workflows,
|
||||
stepsByWorkflowId: props.stepsByWorkflowId,
|
||||
activeTaskId: props.activeTaskId,
|
||||
selectedTaskId: props.selectedTaskId,
|
||||
collapsedGroupKeys: helpers.collapsedGroupKeys,
|
||||
onToggleGroup: helpers.onToggleGroup,
|
||||
collapsedSubtaskParentIds: helpers.collapsedSubtaskParentIds,
|
||||
onToggleSubtasks: helpers.onToggleSubtasks,
|
||||
onSelectTask: props.onSelectTask,
|
||||
onEditTask: props.onEditTask,
|
||||
onRenameTask: props.onRenameTask,
|
||||
onCreateSubtask: props.onCreateSubtask,
|
||||
onArchiveTask: props.onArchiveTask,
|
||||
onDeleteTask: props.onDeleteTask,
|
||||
onDetachTask: props.onDetachTask,
|
||||
onNestTask: props.onNestTask,
|
||||
onLinkPullRequest: props.onLinkPullRequest,
|
||||
onLinkIssue: props.onLinkIssue,
|
||||
onLinkMergeRequest: props.onLinkMergeRequest,
|
||||
onLinkJiraTicket: props.onLinkJiraTicket,
|
||||
onLinkLinearIssue: props.onLinkLinearIssue,
|
||||
onLinkSentryIssue: props.onLinkSentryIssue,
|
||||
onTogglePin: helpers.onTogglePin,
|
||||
onReorderGroup: helpers.onReorderGroup,
|
||||
onReorderSubtasks: helpers.onReorderSubtasks,
|
||||
pinnedTaskIds: helpers.pinnedTaskIds,
|
||||
deletingTaskId: props.deletingTaskId,
|
||||
isLoading: props.isLoading,
|
||||
loadError: props.loadError,
|
||||
onRetryLoad: props.onRetryLoad,
|
||||
retryLabel: props.retryLabel,
|
||||
totalTaskCount: props.tasks.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The mobile task tree surface: renders the shared TaskSwitcher with the
|
||||
* mobile drawer's view state (grouping, ordering, collapse, reorder, nest).
|
||||
@@ -201,6 +143,7 @@ export function MobileTaskList(props: MobileTaskListProps) {
|
||||
onReorderGroup: handleReorderGroup,
|
||||
onReorderSubtasks: handleReorderSubtasks,
|
||||
pinnedTaskIds,
|
||||
showActivityTime: view.sort.key === "lastActivityAt",
|
||||
});
|
||||
return <TaskSwitcher {...switcherProps} />;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
const SORT_OPTIONS: Array<{ key: SortKey; labelKey: string }> = [
|
||||
{ key: "state", labelKey: "task:sortStatus" },
|
||||
{ key: "updatedAt", labelKey: "task:sortUpdated" },
|
||||
{ key: "lastActivityAt", labelKey: "task:sortLastActivity" },
|
||||
{ key: "createdAt", labelKey: "task:sortCreated" },
|
||||
{ key: "title", labelKey: "task:sortTitle" },
|
||||
{ key: "custom", labelKey: "task:sortCustom" },
|
||||
|
||||
@@ -64,7 +64,13 @@ export function TaskItemStatsRow({
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-[11px]">
|
||||
{updatedAt && (
|
||||
<span className="text-muted-foreground/50">{formatRelativeTime(updatedAt)}</span>
|
||||
<span
|
||||
data-testid="sidebar-task-time"
|
||||
data-time-value={updatedAt}
|
||||
className="text-muted-foreground/50"
|
||||
>
|
||||
{formatRelativeTime(updatedAt)}
|
||||
</span>
|
||||
)}
|
||||
{prInfo && <span className="text-muted-foreground/50">#{prInfo.number}</span>}
|
||||
{queuedCount ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { TaskPR } from "@/lib/types/github";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import { TaskItem } from "./task-item";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
|
||||
const TURN_FINISHED_ICON_TEST_ID = "task-state-turn-finished";
|
||||
const WORKFLOW_COMPLETE_ICON_TEST_ID = "task-state-workflow-complete";
|
||||
@@ -577,6 +578,37 @@ describe("TaskItem queued prompt count badge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskItem activity timestamp", () => {
|
||||
it("shows activity time only when the active view sorts by activity", () => {
|
||||
const activityAt = "2026-07-20T00:00:00Z";
|
||||
const updatedAt = "2026-07-24T00:00:00Z";
|
||||
const { rerender } = renderTaskItem({
|
||||
updatedAt,
|
||||
lastActivityAt: activityAt,
|
||||
showActivityTime: true,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("sidebar-task-time").textContent).toBe(
|
||||
formatRelativeTime(activityAt),
|
||||
);
|
||||
|
||||
rerender(
|
||||
<StateProvider>
|
||||
<TooltipProvider>
|
||||
<TaskItem
|
||||
title="Needs answer"
|
||||
state="REVIEW"
|
||||
updatedAt={updatedAt}
|
||||
lastActivityAt={activityAt}
|
||||
showActivityTime={false}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("sidebar-task-time").textContent).toBe(formatRelativeTime(updatedAt));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskItem contribution badges", () => {
|
||||
it("renders the PR badge before the MR badge when the task has both (AC2)", () => {
|
||||
renderTaskItem(
|
||||
|
||||
@@ -68,6 +68,8 @@ type TaskItemProps = {
|
||||
remoteExecutorType?: string;
|
||||
remoteExecutorName?: string;
|
||||
updatedAt?: string;
|
||||
lastActivityAt?: string;
|
||||
showActivityTime?: boolean;
|
||||
menuOpen?: boolean;
|
||||
isDeleting?: boolean;
|
||||
taskId?: string;
|
||||
@@ -324,6 +326,8 @@ function TaskItemContent({
|
||||
isPinned,
|
||||
repositories,
|
||||
updatedAt,
|
||||
lastActivityAt,
|
||||
showActivityTime,
|
||||
prInfo,
|
||||
queuedCount,
|
||||
wipQueue,
|
||||
@@ -341,6 +345,8 @@ function TaskItemContent({
|
||||
isPinned?: boolean;
|
||||
repositories?: string[];
|
||||
updatedAt?: string;
|
||||
lastActivityAt?: string;
|
||||
showActivityTime?: boolean;
|
||||
prInfo?: { number: number; state: string; aggregateState?: string };
|
||||
queuedCount?: number;
|
||||
wipQueue?: WipQueueStatus;
|
||||
@@ -384,7 +390,7 @@ function TaskItemContent({
|
||||
</span>
|
||||
)}
|
||||
<TaskItemStatsRow
|
||||
updatedAt={updatedAt}
|
||||
updatedAt={showActivityTime ? (lastActivityAt ?? updatedAt) : updatedAt}
|
||||
prInfo={prInfo}
|
||||
primarySessionId={primarySessionId}
|
||||
queuedCount={queuedCount}
|
||||
@@ -434,6 +440,8 @@ export const TaskItem = memo(function TaskItem({
|
||||
remoteExecutorType,
|
||||
remoteExecutorName,
|
||||
updatedAt,
|
||||
lastActivityAt,
|
||||
showActivityTime = false,
|
||||
menuOpen = false,
|
||||
isDeleting,
|
||||
taskId,
|
||||
@@ -496,6 +504,8 @@ export const TaskItem = memo(function TaskItem({
|
||||
isPinned={isPinned}
|
||||
repositories={repositories}
|
||||
updatedAt={updatedAt}
|
||||
lastActivityAt={lastActivityAt}
|
||||
showActivityTime={showActivityTime}
|
||||
prInfo={prInfo}
|
||||
queuedCount={queuedCount}
|
||||
wipQueue={wipQueue}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { buildSidebarItem } from "./task-session-sidebar-item";
|
||||
type SidebarTask = Parameters<typeof buildSidebarItem>[0];
|
||||
type SidebarContext = Parameters<typeof buildSidebarItem>[1];
|
||||
const UPDATED_AT = "2026-07-22T00:00:00Z";
|
||||
const ACTIVITY_AT = "2026-07-24T00:00:00Z";
|
||||
const CREATED_AT = "2026-07-20T00:00:00Z";
|
||||
const SUMMARY_UPDATED_AT = "2026-07-25T00:00:00Z";
|
||||
|
||||
function emptyContext(): SidebarContext {
|
||||
return {
|
||||
@@ -122,3 +125,41 @@ describe("buildSidebarItem", () => {
|
||||
expect(item.queuedCount).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSidebarItem activity", () => {
|
||||
it("maps summary activity and falls back through task update to creation", () => {
|
||||
const projected = buildSidebarItem(
|
||||
task({
|
||||
updatedAt: UPDATED_AT,
|
||||
createdAt: CREATED_AT,
|
||||
statusSummary: {
|
||||
revision: 6,
|
||||
updated_at: SUMMARY_UPDATED_AT,
|
||||
last_activity_at: ACTIVITY_AT,
|
||||
},
|
||||
}),
|
||||
emptyContext(),
|
||||
);
|
||||
expect(projected.updatedAt).toBe(SUMMARY_UPDATED_AT);
|
||||
expect(projected.lastActivityAt).toBe(ACTIVITY_AT);
|
||||
|
||||
const updatedFallback = buildSidebarItem(
|
||||
task({
|
||||
updatedAt: UPDATED_AT,
|
||||
createdAt: CREATED_AT,
|
||||
statusSummary: { revision: 7, updated_at: SUMMARY_UPDATED_AT },
|
||||
}),
|
||||
emptyContext(),
|
||||
);
|
||||
expect(updatedFallback.lastActivityAt).toBe(UPDATED_AT);
|
||||
|
||||
const createdFallback = buildSidebarItem(
|
||||
task({
|
||||
createdAt: CREATED_AT,
|
||||
statusSummary: { revision: 8, updated_at: SUMMARY_UPDATED_AT },
|
||||
}),
|
||||
emptyContext(),
|
||||
);
|
||||
expect(createdFallback.lastActivityAt).toBe(CREATED_AT);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,13 @@ function issueInfoForTask(task: KanbanState["tasks"][number]) {
|
||||
return { url: task.issueUrl, number: task.issueNumber };
|
||||
}
|
||||
|
||||
function sidebarLastActivityAt(
|
||||
summary: TaskStatusSummary | null | undefined,
|
||||
task: KanbanState["tasks"][number],
|
||||
) {
|
||||
return summary?.last_activity_at ?? task.updatedAt ?? task.createdAt;
|
||||
}
|
||||
|
||||
function sidebarSessionStatus(
|
||||
summary: TaskStatusSummary | null | undefined,
|
||||
task: KanbanState["tasks"][number],
|
||||
@@ -85,6 +92,7 @@ function sidebarSessionStatus(
|
||||
foregroundActivity: hasSummary ? summary?.foreground_activity : task.foregroundActivity,
|
||||
primarySessionId: hasSummary ? (primarySession?.id ?? null) : (task.primarySessionId ?? null),
|
||||
updatedAt: hasSummary ? summary?.updated_at : (task.updatedAt ?? task.createdAt),
|
||||
lastActivityAt: sidebarLastActivityAt(summary, task),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ export function buildTaskSwitcherProps(args: {
|
||||
stepsByWorkflowId: TaskSwitcherComponentProps["stepsByWorkflowId"];
|
||||
highlightedTaskId: string | null;
|
||||
highlightedSelectedTaskId: string | null;
|
||||
effectiveView: { collapsedGroups: TaskSwitcherComponentProps["collapsedGroupKeys"] };
|
||||
effectiveView: {
|
||||
collapsedGroups: TaskSwitcherComponentProps["collapsedGroupKeys"];
|
||||
sort: { key: string };
|
||||
};
|
||||
handleToggleGroup: TaskSwitcherComponentProps["onToggleGroup"];
|
||||
collapsedSubtaskParents: TaskSwitcherComponentProps["collapsedSubtaskParentIds"];
|
||||
toggleSubtaskCollapsed: TaskSwitcherComponentProps["onToggleSubtasks"];
|
||||
@@ -43,6 +46,7 @@ export function buildTaskSwitcherProps(args: {
|
||||
activeTaskId: args.highlightedTaskId,
|
||||
selectedTaskId: args.highlightedSelectedTaskId,
|
||||
collapsedGroupKeys: args.effectiveView.collapsedGroups,
|
||||
showActivityTime: args.effectiveView.sort.key === "lastActivityAt",
|
||||
onToggleGroup: args.handleToggleGroup,
|
||||
collapsedSubtaskParentIds: args.collapsedSubtaskParents,
|
||||
onToggleSubtasks: args.toggleSubtaskCollapsed,
|
||||
|
||||
@@ -21,6 +21,7 @@ export type TaskRowProps = {
|
||||
stepsByWorkflowId?: Record<string, StepDef[]>;
|
||||
activeTaskId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
showActivityTime?: boolean;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
onRenameTask?: (taskId: string, currentTitle: string) => void;
|
||||
@@ -91,6 +92,7 @@ type TaskRowItemProps = Pick<
|
||||
| "subtaskToggle"
|
||||
| "activeTaskId"
|
||||
| "selectedTaskId"
|
||||
| "showActivityTime"
|
||||
| "onSelectTask"
|
||||
| "selectedTaskIds"
|
||||
| "onToggleSelectTask"
|
||||
@@ -107,6 +109,7 @@ function TaskRowItem({
|
||||
subtaskToggle,
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
showActivityTime,
|
||||
onSelectTask,
|
||||
selectedTaskIds,
|
||||
onToggleSelectTask,
|
||||
@@ -154,6 +157,8 @@ function TaskRowItem({
|
||||
hasPendingClarification={task.hasPendingClarification}
|
||||
hasPendingPermission={task.hasPendingPermission}
|
||||
updatedAt={task.updatedAt}
|
||||
lastActivityAt={task.lastActivityAt}
|
||||
showActivityTime={showActivityTime}
|
||||
repositories={task.repositories}
|
||||
prInfo={task.prInfo}
|
||||
queuedCount={task.queuedCount}
|
||||
|
||||
@@ -36,6 +36,7 @@ export type TaskSwitcherItem = {
|
||||
remoteExecutorType?: string;
|
||||
remoteExecutorName?: string;
|
||||
updatedAt?: string;
|
||||
lastActivityAt?: string;
|
||||
createdAt?: string;
|
||||
isArchived?: boolean;
|
||||
primarySessionId?: string | null;
|
||||
@@ -91,6 +92,7 @@ export type TaskSwitcherProps = {
|
||||
onRetryLoad?: () => void;
|
||||
retryLabel?: string;
|
||||
totalTaskCount?: number;
|
||||
showActivityTime?: boolean;
|
||||
// Multi-select (cmd/shift click). When the selection is non-empty, plain
|
||||
// clicks toggle instead of navigating; the context menu acts on the selection.
|
||||
selectedTaskIds?: Set<string>;
|
||||
|
||||
@@ -20,6 +20,7 @@ function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps {
|
||||
stepsByWorkflowId: props.stepsByWorkflowId,
|
||||
activeTaskId: props.activeTaskId,
|
||||
selectedTaskId: props.selectedTaskId,
|
||||
showActivityTime: props.showActivityTime,
|
||||
onSelectTask: props.onSelectTask,
|
||||
onEditTask: props.onEditTask,
|
||||
onRenameTask: props.onRenameTask,
|
||||
|
||||
@@ -55,6 +55,16 @@ async function addTitleFilter(testPage: Page, sheet: Locator, value: string): Pr
|
||||
await popover.getByTestId("filter-value-input").fill(value);
|
||||
}
|
||||
|
||||
async function taskRowOrder(sheet: Locator, taskIds: string[]) {
|
||||
const rows = await sheet.locator("[data-task-row-id]").all();
|
||||
const order: string[] = [];
|
||||
for (const row of rows) {
|
||||
const id = await row.getAttribute("data-task-row-id");
|
||||
if (id && taskIds.includes(id)) order.push(id);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
test.describe("Mobile sidebar — view system", () => {
|
||||
test("direct creation stays touch-reachable, viewport-safe, and persists", async ({
|
||||
testPage,
|
||||
@@ -244,6 +254,63 @@ test.describe("Mobile sidebar — view system", () => {
|
||||
await expect(sheet.getByText("Fix auth bug")).toBeVisible();
|
||||
});
|
||||
|
||||
test("mobile last activity sort is touch reachable, persistent, and contained", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, [
|
||||
"Mobile activity old",
|
||||
"Mobile activity new",
|
||||
]);
|
||||
const listed = await apiClient.listTasks(seedData.workspaceId);
|
||||
const oldTask = listed.tasks.find((task) => task.title === "Mobile activity old");
|
||||
const newTask = listed.tasks.find((task) => task.title === "Mobile activity new");
|
||||
expect(oldTask?.id).toBeTruthy();
|
||||
expect(newTask?.id).toBeTruthy();
|
||||
await apiClient.updateTaskTitle(oldTask!.id, "Mobile activity old touched");
|
||||
|
||||
const gear = sheet.getByTestId("sidebar-filter-gear");
|
||||
await gear.tap();
|
||||
const popover = testPage.getByTestId("sidebar-filter-popover");
|
||||
await expect(popover).toBeVisible();
|
||||
await popover.getByTestId("sort-key-select").tap();
|
||||
await testPage.getByRole("option", { name: "Last activity", exact: true }).tap();
|
||||
const direction = popover.getByTestId("sort-direction-toggle");
|
||||
if ((await direction.getAttribute("data-direction")) !== "desc") await direction.tap();
|
||||
await popover.getByTestId("view-save-as-button").tap();
|
||||
await popover.getByTestId("view-save-as-name-input").fill("Mobile last activity");
|
||||
await popover.getByTestId("view-save-as-confirm").tap();
|
||||
await testPage.keyboard.press("Escape");
|
||||
|
||||
const taskIds = [oldTask!.id, newTask!.id];
|
||||
await expect.poll(() => taskRowOrder(sheet, taskIds)).toEqual(taskIds);
|
||||
const viewport = testPage.viewportSize();
|
||||
const list = sheet.getByTestId("mobile-task-switcher-list");
|
||||
const sheetBox = await sheet.boundingBox();
|
||||
const listBox = await list.boundingBox();
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(sheetBox).not.toBeNull();
|
||||
expect(listBox).not.toBeNull();
|
||||
expect(listBox!.x).toBeGreaterThanOrEqual(sheetBox!.x);
|
||||
expect(listBox!.x + listBox!.width).toBeLessThanOrEqual(sheetBox!.x + sheetBox!.width);
|
||||
await expect(list).toHaveCSS("overflow-y", "auto");
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await testPage.reload();
|
||||
await new SessionPage(testPage).waitForLoad();
|
||||
await testPage.getByTestId("mobile-session-menu").tap();
|
||||
const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
await expect(
|
||||
reloadedSheet.getByTestId("sidebar-view-chip").filter({ hasText: "Mobile last activity" }),
|
||||
).toHaveAttribute("data-active", "true");
|
||||
await expect.poll(() => taskRowOrder(reloadedSheet, taskIds)).toEqual(taskIds);
|
||||
});
|
||||
|
||||
test("many saved views scroll without covering fixed actions", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -75,6 +75,25 @@ async function saveTitleView(
|
||||
await filters.close();
|
||||
}
|
||||
|
||||
async function taskRowOrder(surface: import("@playwright/test").Locator, taskIds: string[]) {
|
||||
const rows = await surface.locator("[data-task-row-id]").all();
|
||||
const order: string[] = [];
|
||||
for (const row of rows) {
|
||||
const id = await row.getAttribute("data-task-row-id");
|
||||
if (id && taskIds.includes(id)) order.push(id);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
async function taskActivityAt(
|
||||
apiClient: import("../../helpers/api-client").ApiClient,
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await apiClient.listTasks(workspaceId);
|
||||
return result.tasks.find((task) => task.id === taskId)?.status_summary?.last_activity_at ?? null;
|
||||
}
|
||||
|
||||
test.describe("Sidebar filter bar — popover basics", () => {
|
||||
test("gear opens popover; ESC closes it", async ({ testPage, apiClient, seedData }) => {
|
||||
const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Basics Task"]);
|
||||
@@ -306,6 +325,106 @@ test.describe("Sidebar filter — group + sort", () => {
|
||||
const flipped = await toggle.getAttribute("data-direction");
|
||||
expect(flipped).not.toBe(initial);
|
||||
});
|
||||
|
||||
test("sorts by last activity, persists, and ignores provider-only refresh", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const oldTask = await apiClient.createTask(seedData.workspaceId, "Activity old", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const newTask = await apiClient.createTask(seedData.workspaceId, "Activity new", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
await apiClient.updateTaskTitle(oldTask.id, "Activity old touched");
|
||||
const navTask = await apiClient.createTask(seedData.workspaceId, "Activity sort nav", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${navTask.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const filters = new SidebarFilterPopoverPage(testPage);
|
||||
await filters.open();
|
||||
await filters.setGroup("None");
|
||||
await filters.setSort("Last activity", "desc");
|
||||
await filters.saveAs("Last activity view");
|
||||
await filters.close();
|
||||
|
||||
await expect
|
||||
.poll(() => taskRowOrder(session.sidebar, [oldTask.id, newTask.id]))
|
||||
.toEqual([oldTask.id, newTask.id]);
|
||||
let activityAt: string | null = null;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
activityAt = await taskActivityAt(apiClient, seedData.workspaceId, oldTask.id);
|
||||
return activityAt;
|
||||
})
|
||||
.toBeTruthy();
|
||||
const oldRow = session.sidebar.locator(`[data-task-row-id="${oldTask.id}"]`);
|
||||
await expect(oldRow.getByTestId("sidebar-task-time")).toHaveAttribute(
|
||||
"data-time-value",
|
||||
activityAt!,
|
||||
);
|
||||
|
||||
await testPage.reload();
|
||||
await new SessionPage(testPage).waitForLoad();
|
||||
const reloadedFilters = new SidebarFilterPopoverPage(testPage);
|
||||
await reloadedFilters.expectActiveViewChip("Last activity view");
|
||||
await expect
|
||||
.poll(() => taskRowOrder(session.sidebar, [oldTask.id, newTask.id]))
|
||||
.toEqual([oldTask.id, newTask.id]);
|
||||
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
task_id: newTask.id,
|
||||
workspace_id: seedData.workspaceId,
|
||||
repository_id: seedData.repositoryId,
|
||||
owner: "activity-owner",
|
||||
repo: "activity-repo",
|
||||
pr_number: 7,
|
||||
pr_url: "https://github.com/activity-owner/activity-repo/pull/7",
|
||||
pr_title: "Activity PR",
|
||||
head_branch: "activity",
|
||||
base_branch: "main",
|
||||
author_login: "activity-owner",
|
||||
checks_state: "failure",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tasks = await apiClient.listTasks(seedData.workspaceId);
|
||||
return tasks.tasks.find((task) => task.id === newTask.id)?.status_summary?.pull_request
|
||||
?.aggregate_state;
|
||||
})
|
||||
.toBe("failure");
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
task_id: newTask.id,
|
||||
workspace_id: seedData.workspaceId,
|
||||
repository_id: seedData.repositoryId,
|
||||
owner: "activity-owner",
|
||||
repo: "activity-repo",
|
||||
pr_number: 7,
|
||||
pr_url: "https://github.com/activity-owner/activity-repo/pull/7",
|
||||
pr_title: "Activity PR refreshed",
|
||||
head_branch: "activity",
|
||||
base_branch: "main",
|
||||
author_login: "activity-owner",
|
||||
checks_state: "success",
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tasks = await apiClient.listTasks(seedData.workspaceId);
|
||||
return tasks.tasks.find((task) => task.id === newTask.id)?.status_summary?.pull_request
|
||||
?.aggregate_state;
|
||||
})
|
||||
.toBe("passing");
|
||||
await expect
|
||||
.poll(() => taskRowOrder(session.sidebar, [oldTask.id, newTask.id]))
|
||||
.toEqual([oldTask.id, newTask.id]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar filter — saved views CRUD", () => {
|
||||
|
||||
@@ -219,9 +219,12 @@ describe("applyFilters — titleMatch + combos", () => {
|
||||
});
|
||||
|
||||
describe("applySort", () => {
|
||||
const a = task({ id: "a", state: "REVIEW", updatedAt: "2026-04-01", title: "Zeta" });
|
||||
const b = task({ id: "b", sessionState: "RUNNING", updatedAt: "2026-04-05", title: "Alpha" });
|
||||
const c = task({ id: "c", updatedAt: "2026-04-03", title: "Mu" });
|
||||
const early = "2026-04-01";
|
||||
const middle = "2026-04-03";
|
||||
const late = "2026-04-05";
|
||||
const a = task({ id: "a", state: "REVIEW", updatedAt: early, title: "Zeta" });
|
||||
const b = task({ id: "b", sessionState: "RUNNING", updatedAt: late, title: "Alpha" });
|
||||
const c = task({ id: "c", updatedAt: middle, title: "Mu" });
|
||||
|
||||
it("sorts by state bucket asc (review, in_progress, backlog)", () => {
|
||||
const out = applySort([c, b, a], { key: "state", direction: "asc" });
|
||||
@@ -238,6 +241,31 @@ describe("applySort", () => {
|
||||
expect(out.map((t) => t.id)).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
it("sorts by lastActivityAt with update and creation fallbacks", () => {
|
||||
const out = applySort(
|
||||
[
|
||||
task({ id: "created-fallback", createdAt: early }),
|
||||
task({ id: "updated-fallback", updatedAt: middle, createdAt: early }),
|
||||
task({
|
||||
id: "activity",
|
||||
lastActivityAt: late,
|
||||
updatedAt: "2026-04-02",
|
||||
createdAt: early,
|
||||
}),
|
||||
],
|
||||
{ key: "lastActivityAt", direction: "desc" },
|
||||
);
|
||||
expect(out.map((t) => t.id)).toEqual(["activity", "updated-fallback", "created-fallback"]);
|
||||
});
|
||||
|
||||
it("keeps lastActivityAt stable for equal timestamps", () => {
|
||||
const out = applySort(
|
||||
[task({ id: "first", lastActivityAt: late }), task({ id: "second", lastActivityAt: late })],
|
||||
{ key: "lastActivityAt", direction: "asc" },
|
||||
);
|
||||
expect(out.map((t) => t.id)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("sorts by title asc", () => {
|
||||
const out = applySort([a, b, c], { key: "title", direction: "asc" });
|
||||
expect(out.map((t) => t.id)).toEqual(["b", "c", "a"]);
|
||||
|
||||
@@ -117,6 +117,10 @@ export function applyFilters(
|
||||
|
||||
type SortComparator = (a: TaskSwitcherItem, b: TaskSwitcherItem) => number;
|
||||
|
||||
function lastActivitySortValue(task: TaskSwitcherItem): string {
|
||||
return task.lastActivityAt ?? task.updatedAt ?? task.createdAt ?? "";
|
||||
}
|
||||
|
||||
const SORT_COMPARATORS: Record<Exclude<SortKey, "custom">, SortComparator> = {
|
||||
state: (a, b) => {
|
||||
const bucket = STATE_BUCKET_ORDER[getStateBucket(a)] - STATE_BUCKET_ORDER[getStateBucket(b)];
|
||||
@@ -125,6 +129,7 @@ const SORT_COMPARATORS: Record<Exclude<SortKey, "custom">, SortComparator> = {
|
||||
return (b.createdAt ?? "").localeCompare(a.createdAt ?? "");
|
||||
},
|
||||
updatedAt: (a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? ""),
|
||||
lastActivityAt: (a, b) => lastActivitySortValue(a).localeCompare(lastActivitySortValue(b)),
|
||||
createdAt: (a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""),
|
||||
title: (a, b) => (a.title ?? "").localeCompare(b.title ?? ""),
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ export type FilterClause = {
|
||||
value: FilterValue;
|
||||
};
|
||||
|
||||
export type SortKey = "state" | "updatedAt" | "createdAt" | "title" | "custom";
|
||||
export type SortKey = "state" | "updatedAt" | "lastActivityAt" | "createdAt" | "title" | "custom";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type SortSpec = { key: SortKey; direction: SortDirection };
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const view: SidebarView = {
|
||||
{ id: "c2", dimension: "state", op: "in", value: ["review", "in_progress"] },
|
||||
{ id: "c3", dimension: "titleMatch", op: "matches", value: "fix " },
|
||||
],
|
||||
sort: { key: "updatedAt", direction: "desc" },
|
||||
sort: { key: "lastActivityAt", direction: "desc" },
|
||||
group: "workflow",
|
||||
collapsedGroups: ["backlog", "review"],
|
||||
};
|
||||
|
||||
@@ -84,3 +84,15 @@ describe("migrateSidebarViewDraft archived compatibility", () => {
|
||||
expect(migrateSidebarViewDraft(draft)).toEqual(draft);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrate sidebar activity sort", () => {
|
||||
it("preserves lastActivityAt on saved views and drafts", () => {
|
||||
const view = makeSidebarView("view-activity", "Recent activity");
|
||||
view.sort = { key: "lastActivityAt", direction: "desc" };
|
||||
const draft = makeSidebarDraft("view-activity");
|
||||
draft.sort = { key: "lastActivityAt", direction: "asc" };
|
||||
|
||||
expect(migrateView(view).sort).toEqual(view.sort);
|
||||
expect(migrateSidebarViewDraft(draft).sort).toEqual(draft.sort);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export const KNOWN_DIMENSIONS = new Set<string>([
|
||||
export const KNOWN_SORT_KEYS = new Set<string>([
|
||||
"state",
|
||||
"updatedAt",
|
||||
"lastActivityAt",
|
||||
"createdAt",
|
||||
"title",
|
||||
"custom",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ForegroundActivity, TaskPendingAction, TaskSessionState } from "./
|
||||
export type TaskStatusSummary = {
|
||||
revision: number;
|
||||
updated_at: string;
|
||||
/** Semantic task activity, separate from summary projection freshness. */
|
||||
last_activity_at?: string;
|
||||
primary_session?: {
|
||||
id: string;
|
||||
state: TaskSessionState;
|
||||
|
||||
@@ -1261,6 +1261,7 @@
|
||||
"sortCreated": "Created",
|
||||
"sortCustom": "Custom",
|
||||
"sortDirection": "Sort direction {{direction}}",
|
||||
"sortLastActivity": "Last activity",
|
||||
"sortStatus": "Status",
|
||||
"sortTitle": "Title",
|
||||
"sortUpdated": "Updated",
|
||||
|
||||
@@ -1261,6 +1261,7 @@
|
||||
"sortCreated": "Ćŕēàţēď",
|
||||
"sortCustom": "Ćũśţōḿ",
|
||||
"sortDirection": "Śōŕţ ďĩŕēćţĩōń {{direction}}",
|
||||
"sortLastActivity": "Ĺàśţ àćţĩṿĩţŷ",
|
||||
"sortStatus": "Śţàţũś",
|
||||
"sortTitle": "Ţĩţĺē",
|
||||
"sortUpdated": "Ũƥďàţēď",
|
||||
|
||||
@@ -1224,6 +1224,7 @@
|
||||
"sortCreated": "Criação",
|
||||
"sortCustom": "Personalizada",
|
||||
"sortDirection": "Sentido da ordenação {{direction}}",
|
||||
"sortLastActivity": "Última atividade",
|
||||
"sortStatus": "Estado",
|
||||
"sortTitle": "Título",
|
||||
"sortUpdated": "Atualização",
|
||||
|
||||
@@ -1223,6 +1223,7 @@
|
||||
"sortCreated": "创建时间",
|
||||
"sortCustom": "自定义",
|
||||
"sortDirection": "排序方向 {{direction}}",
|
||||
"sortLastActivity": "最后活动",
|
||||
"sortStatus": "状态",
|
||||
"sortTitle": "标题",
|
||||
"sortUpdated": "更新时间",
|
||||
|
||||
@@ -1223,6 +1223,7 @@
|
||||
"sortCreated": "建立時間",
|
||||
"sortCustom": "自訂",
|
||||
"sortDirection": "排序方向 {{direction}}",
|
||||
"sortLastActivity": "最後活動",
|
||||
"sortStatus": "狀態",
|
||||
"sortTitle": "標題",
|
||||
"sortUpdated": "更新時間",
|
||||
|
||||
@@ -1223,6 +1223,7 @@
|
||||
"sortCreated": "建立時間",
|
||||
"sortCustom": "自訂",
|
||||
"sortDirection": "排序方向 {{direction}}",
|
||||
"sortLastActivity": "最後活動",
|
||||
"sortStatus": "狀態",
|
||||
"sortTitle": "標題",
|
||||
"sortUpdated": "更新時間",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-2026-08-17-separate-task-activity-from-summary-freshness: Separate Task Activity From Summary Freshness
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-08-17
|
||||
**Area:** backend, frontend, protocol
|
||||
|
||||
## Context
|
||||
|
||||
Task sidebar rows currently use `TaskStatusSummary.updated_at` as their visible
|
||||
time and saved-view sort value. That field records when any projected status
|
||||
changed. A pull-request poll can therefore make many idle tasks look recently
|
||||
active even when no user opened them and no agent ran.
|
||||
|
||||
Users need both concepts. Projection freshness is useful for status diagnostics,
|
||||
while task activity is useful for finding recent work.
|
||||
|
||||
## Decision
|
||||
|
||||
Kandev will keep `TaskStatusSummary.updated_at` as transport and projection
|
||||
freshness. It will add semantic field `last_activity_at` to the same bounded,
|
||||
rebuildable summary.
|
||||
|
||||
`last_activity_at` is the latest durable time from these sources:
|
||||
|
||||
- task creation or a persisted task mutation
|
||||
- a user-authored prompt, including a prompt that enters the queue
|
||||
- an agent turn start or completion
|
||||
|
||||
The timestamp does not advance for task focus, session subscription, Git or
|
||||
pull-request polling, queue bookkeeping, status-summary repair, or session
|
||||
metadata maintenance. Live projection uses source timestamps and applies a
|
||||
monotonic maximum. Rebuilds batch the same durable task, message, and turn
|
||||
sources and preserve a newer stored value.
|
||||
|
||||
The saved-view wire key is `lastActivityAt`. The existing `updatedAt` key keeps
|
||||
its behavior, so stored views remain compatible and users can choose either
|
||||
meaning.
|
||||
|
||||
This decision extends
|
||||
[ADR-2026-08-01-separate-task-summary-session-stream-traffic](2026-08-01-separate-task-summary-session-stream-traffic.md).
|
||||
Task-list consumers continue to use the bounded task summary and do not load
|
||||
background session streams.
|
||||
|
||||
## Consequences
|
||||
|
||||
Idle tasks stay stable when background provider status changes. Desktop and
|
||||
mobile saved views can sort by meaningful work without opening task sessions.
|
||||
|
||||
The backend needs a batched activity query and more bounded projector inputs.
|
||||
Turn milestones prevent per-chunk agent output from producing task-list update
|
||||
traffic. A running turn becomes recent when it starts and advances again when
|
||||
it completes.
|
||||
|
||||
Older summaries need a one-time semantic repair. During partial rollout, the
|
||||
frontend falls back to the task update or creation time when
|
||||
`last_activity_at` is absent.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Reuse summary `updated_at`.** Rejected because provider and Git status
|
||||
changes are not user or agent activity.
|
||||
- **Use only `tasks.updated_at`.** Rejected because prompts and agent turns do
|
||||
not always mutate the task row.
|
||||
- **Derive activity in each browser.** Rejected because background session
|
||||
history is not loaded and task switchers must not subscribe to it.
|
||||
- **Write every activity to the task row.** Rejected because message and turn
|
||||
domains gain extra task writes and mix activity with task edits.
|
||||
@@ -172,4 +172,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-08-12-task-bound-fork-destinations | [Bind Fork Push Destinations to Tasks](2026-08-12-task-bound-fork-destinations.md) | accepted (amended 2026-08-13) | backend, frontend, workflow, security, GitHub | 2026-08-12 |
|
||||
| 2026-08-13-hard-delete-task-contribution-links | [Hard delete owns task contribution links](2026-08-13-hard-delete-task-contribution-links.md) | accepted | backend | 2026-08-13 |
|
||||
| 2026-08-15-office-mode-follows-active-workspace | [Office Mode Follows the Active Workspace](2026-08-15-office-mode-follows-active-workspace.md) | accepted | frontend, backend | 2026-08-15 |
|
||||
| 2026-08-17-separate-task-activity-from-summary-freshness | [Separate Task Activity From Summary Freshness](2026-08-17-separate-task-activity-from-summary-freshness.md) | accepted | backend, frontend, protocol | 2026-08-17 |
|
||||
| 2026-08-17-release-pr-ruleset-bypass | [Give Stable Release PRs an Administrator Token Bypass](2026-08-17-release-pr-ruleset-bypass.md) | accepted | infra, workflow, security | 2026-08-17 |
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
spec: docs/specs/ui/sidebar-last-activity-sort.md
|
||||
created: 2026-08-17
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Implementation Plan: Sidebar Last Activity Sort
|
||||
|
||||
## Overview
|
||||
|
||||
Add a durable task activity timestamp to the bounded task-status summary. Keep
|
||||
summary freshness separate, then expose the activity timestamp through a new
|
||||
saved-view sort on desktop and mobile.
|
||||
|
||||
The work also closes the observed GitHub refresh loop. REST and batched GraphQL
|
||||
status snapshots must converge on one semantic task-PR value, and diagnostics
|
||||
must name fields that cause a real update.
|
||||
|
||||
## Confirmed current behavior
|
||||
|
||||
- `apps/web/components/task/task-session-sidebar-item.ts` maps summary
|
||||
`updated_at` to `TaskSwitcherItem.updatedAt`.
|
||||
- `apps/web/lib/sidebar/apply-view.ts` uses `updatedAt` for the saved-view
|
||||
`updatedAt` comparator.
|
||||
- `apps/web/components/task/task-item-stats-row.tsx` renders the same value as
|
||||
the row's relative time.
|
||||
- `apps/backend/internal/task/statussummary/projector.go` stamps
|
||||
`TaskStatusSummary.UpdatedAt` when any semantic summary field changes.
|
||||
- The projector subscribes to `github.task_pr.updated`, so provider status can
|
||||
refresh the visible time of an idle task.
|
||||
- Sidebar views persist arbitrary sort strings through portable user settings.
|
||||
Frontend `KNOWN_SORT_KEYS` controls which keys survive migration.
|
||||
- Desktop and phone surfaces share `SortPicker`, `applySort`, saved-view state,
|
||||
and user-settings synchronization.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Timestamp ownership
|
||||
|
||||
Extend `TaskStatusSummary` with semantic `LastActivityAt`. Keep `UpdatedAt` as
|
||||
transport and projection freshness.
|
||||
|
||||
Add a narrow `TaskActivityRepository` to the task service. Its batch query
|
||||
returns the maximum of:
|
||||
|
||||
- `tasks.created_at` and `tasks.updated_at`
|
||||
- user-authored `task_session_messages.created_at`
|
||||
- non-reserved user-owned `queued_messages.queued_at`
|
||||
- `task_session_turns.started_at` and non-null `completed_at`
|
||||
|
||||
The query accepts task IDs, returns one map, and uses the existing task/message
|
||||
and turn indexes. Add an index only if query-plan evidence shows a missing
|
||||
leading task key on a production-sized fixture.
|
||||
|
||||
### Live projection and repair
|
||||
|
||||
Add `lastActivityAt` to projector state and persisted semantic JSON. Rehydrate
|
||||
it from the stored summary and a narrow activity loader.
|
||||
|
||||
Subscribe to `task.created`, `task.state_changed`, `turn.started`, and
|
||||
`turn.completed` in addition to the existing task, message, and queue events.
|
||||
Apply timestamps as follows:
|
||||
|
||||
- `task.created`, `task.updated`, and `task.state_changed`: payload
|
||||
`updated_at`, with `created_at` as the creation fallback
|
||||
- `message.added`: `created_at` only for `author_type=user`
|
||||
- successful user-owned queue admission: `queued_at`
|
||||
- `turn.started`: `started_at`
|
||||
- `turn.completed`: `completed_at`
|
||||
|
||||
Use a monotonic maximum. Foreground-activity task events carry the task row's
|
||||
unchanged timestamp and therefore cannot advance task activity.
|
||||
|
||||
Task-list reconciliation batch-loads activity for all requested tasks. It
|
||||
repairs missing fields and preserves a newer stored value during compare-and-set
|
||||
retries. The complete replacement summary continues to use the existing event.
|
||||
|
||||
### GitHub refresh stability
|
||||
|
||||
Normalize GraphQL check-rollup states to the documented `success`, `failure`,
|
||||
`pending`, or empty contract before `SyncTaskPR` compares fields. Exercise the
|
||||
same semantic fixture through REST feedback and batched GraphQL conversion.
|
||||
|
||||
Refactor the `SyncTaskPR` comparison into a helper that returns changed field
|
||||
names. Publish only when that list is non-empty. Log the names at debug level
|
||||
with task and PR identity, without old or new values.
|
||||
|
||||
### Saved-view and row behavior
|
||||
|
||||
Add `lastActivityAt` to `SortKey`, `KNOWN_SORT_KEYS`, `SORT_OPTIONS`, and the
|
||||
comparator table. Thread `lastActivityAt` from `status_summary` into
|
||||
`TaskSwitcherItem`, with task update and creation fallbacks.
|
||||
|
||||
When the effective view uses this key, rows display activity time. Other views
|
||||
retain the existing time selection. Keep stable input order for equal values.
|
||||
|
||||
Add localized **Last activity** copy to English, Portuguese, Simplified
|
||||
Chinese, Hong Kong Chinese, Taiwan Chinese, and pseudo catalogs. Run the
|
||||
Traditional Chinese generator instead of translating both variants by hand.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- **Outcome and entry:** desktop opens the Tasks section view editor. Phones
|
||||
open `SessionTaskSwitcherSheet`, then the existing filter gear.
|
||||
- **Exemplar:** keep
|
||||
`apps/web/components/task/mobile/session-task-switcher-sheet.tsx` and the
|
||||
shared `SidebarFilterPopover` composition.
|
||||
- **Hierarchy and presentation:** the sort remains a short temporary choice in
|
||||
the existing selector. No route, drawer, or nested overlay is added.
|
||||
- **Interaction:** the existing selector row is the touch target. Selection
|
||||
updates the shared draft and live task order.
|
||||
- **Scroll and geometry:** the task list remains the only vertical scroll body.
|
||||
The existing drawer and portaled selector remain inside the Pixel 5 viewport
|
||||
with no document horizontal overflow.
|
||||
- **Shared state:** saved-view state, activity mapping, comparator, direction,
|
||||
persistence, and rollback are shared between desktop and mobile.
|
||||
|
||||
## Tests
|
||||
|
||||
Use Red-Green-Refactor for each implementation task. Run each focused test
|
||||
before production edits and record the expected failure. Run the same test
|
||||
after the smallest implementation, then refactor while it remains green.
|
||||
|
||||
### Backend
|
||||
|
||||
- GitHub conversion tests cover every GraphQL rollup state and prove an
|
||||
equivalent REST/GraphQL sequence emits no second task-PR update.
|
||||
- Activity repository tests cover tasks without sessions, queued user prompts,
|
||||
active turns, completed turns, multiple sessions, and batched task IDs.
|
||||
- Summary model tests cover semantic JSON, equality, compatibility with absent
|
||||
activity, and monotonic timestamps.
|
||||
- Projector tests cover every included source, including state-only task
|
||||
transitions. They prove focus-adjacent status, PR, Git, queue, agent-message,
|
||||
and older replay events do not advance activity.
|
||||
- Rebuild tests cover field backfill, no N+1 reads, compare-and-set retry, and
|
||||
preservation of a newer stored timestamp.
|
||||
|
||||
### Frontend
|
||||
|
||||
- Comparator tests cover ascending, descending, equal times, and fallback.
|
||||
- Migration, wire, hydration, and live user-settings tests preserve
|
||||
`lastActivityAt` in saved views and drafts.
|
||||
- Sidebar mapping and rendered-row tests select activity time only for the new
|
||||
sort.
|
||||
- Locale checks cover the new label in every required catalog.
|
||||
|
||||
### E2E
|
||||
|
||||
- Desktop creates a view, selects **Last activity**, verifies order and row
|
||||
time, saves it, reloads, and verifies persistence.
|
||||
- The backend regression proves a provider status refresh does not change the
|
||||
activity timestamp. The desktop flow verifies that resulting sort order.
|
||||
- Mobile selects the same option from the task-switcher drawer and verifies
|
||||
order, persistence, touch reachability, viewport containment, scroll
|
||||
ownership, and zero horizontal overflow.
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
make -C apps/backend test lint build
|
||||
cd apps && pnpm install --frozen-lockfile
|
||||
cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/apply-view.test.ts lib/state/slices/ui/ui-slice-migration.test.ts lib/state/slices/ui/sidebar-view-wire.test.ts components/task/task-session-sidebar-item.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps && pnpm --filter @kandev/web lint
|
||||
cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check && pnpm run i18n:ratchet
|
||||
cd apps/web && pnpm e2e:run tests/task/sidebar-filter.spec.ts -- --grep "last activity"
|
||||
cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "last activity"
|
||||
```
|
||||
|
||||
## Implementation waves
|
||||
|
||||
- [Task 01: Stabilize GitHub task-PR refreshes](task-01-stabilize-github-pr-refresh.md), wave 1
|
||||
- [Task 02: Add durable activity reconstruction](task-02-add-activity-reconstruction.md), wave 1
|
||||
- [Task 03: Project task activity live](task-03-project-live-task-activity.md), wave 2, after Task 02
|
||||
- [Task 04: Add the saved-view sort](task-04-add-sidebar-sort.md), wave 3, after Task 03
|
||||
- [Task 05: Prove desktop and mobile behavior](task-05-prove-sidebar-activity-sort.md), wave 4, after Tasks 01 and 04
|
||||
|
||||
This package does not authorize implementation subagents. Implementation stays
|
||||
in the user-controlled primary session.
|
||||
|
||||
## Implementation result
|
||||
|
||||
All five tasks are complete. The implementation includes durable activity
|
||||
reconstruction, live bounded projection, GitHub refresh normalization, the
|
||||
desktop/mobile saved-view sort, localized catalogs, and production-build
|
||||
browser coverage.
|
||||
|
||||
Targeted checks passed during implementation:
|
||||
|
||||
- Backend focused GitHub, activity repository, summary, rebuild, projector, and
|
||||
backend-app tests.
|
||||
- Frontend focused tests, typecheck, lint, i18n completeness, and i18n ratchet.
|
||||
- One desktop Chromium E2E test and one mobile Chromium E2E test, both through
|
||||
the managed production-build runner.
|
||||
|
||||
The full backend test, lint, and build matrix passed as the final pre-PR gate.
|
||||
|
||||
## Risks and boundaries
|
||||
|
||||
- `updated_at` and `last_activity_at` must remain separate in semantic equality,
|
||||
persistence, DTOs, and UI mapping.
|
||||
- Event timestamps can arrive out of order. Every live and rebuild path needs a
|
||||
monotonic maximum.
|
||||
- Counting all agent messages recreates high-frequency task-list traffic.
|
||||
Turn start and completion are the bounded agent milestones.
|
||||
- Existing saved views and default view creation must not change.
|
||||
- The GitHub normalization test must exercise both real conversion paths. A
|
||||
handcrafted `PRStatus` pair does not detect future adapter drift.
|
||||
- Public documentation does not describe sidebar sort choices today. No public
|
||||
documentation update is required.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: "01-stabilize-github-pr-refresh"
|
||||
title: "Stabilize GitHub task-PR refreshes"
|
||||
status: completed
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/sidebar-last-activity-sort.md"
|
||||
---
|
||||
|
||||
# Task 01: Stabilize GitHub task-PR refreshes
|
||||
|
||||
Make REST feedback and batched GraphQL polling converge on one task-PR status.
|
||||
Add safe evidence for any future semantic update.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- GraphQL check-rollup values map to the documented task-PR check states.
|
||||
- Equivalent REST and GraphQL snapshots do not persist or publish a second
|
||||
semantic task-PR update.
|
||||
- A real update logs only changed field names with task and PR identity, then
|
||||
publishes the existing event once.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/github -run 'CheckRollup|Equivalent.*Status|SyncTaskPR.*ChangedFields'
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/github/graphql.go`
|
||||
- `apps/backend/internal/github/graphql_test.go`
|
||||
- `apps/backend/internal/github/service_pr_watch.go`
|
||||
- `apps/backend/internal/github/service_pr_feedback_sync_test.go`
|
||||
- `apps/backend/internal/github/service_pr_watch_batched_budget_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- The regression must call both conversion paths before `SyncTaskPR`.
|
||||
- Logs must not include provider field values, comments, titles, or tokens.
|
||||
- Normalization must preserve the existing empty state for absent checks.
|
||||
|
||||
## Result
|
||||
|
||||
- RED: the focused tests failed because the normalization and changed-field
|
||||
helper were not present.
|
||||
- GREEN: `go test ./internal/github -run
|
||||
'CheckRollup|Equivalent.*Status|SyncTaskPR.*ChangedFields'` passed (10 tests).
|
||||
- Regression: `go test ./internal/github` passed (1572 tests).
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: "02-add-activity-reconstruction"
|
||||
title: "Add durable activity reconstruction"
|
||||
status: completed
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/sidebar-last-activity-sort.md"
|
||||
---
|
||||
|
||||
# Task 02: Add durable activity reconstruction
|
||||
|
||||
Create the authoritative batched read that reconstructs task activity from
|
||||
task, user-message, queued-prompt, and turn records.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- One repository call returns the latest activity for every requested task ID.
|
||||
- The result includes task creation and mutation, user prompts including
|
||||
non-reserved queued prompts, and turn start or completion. It excludes agent
|
||||
messages, reserved queue entries, and session metadata timestamps.
|
||||
- SQLite and Postgres behavior is covered without an N+1 query.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/task/repository/sqlite -run 'TaskLastActivity'
|
||||
cd apps/backend && go test ./internal/task/service -run 'StatusSummary.*Activity.*Rebuild'
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/task/repository/interface.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/task_status_summary.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/task_status_summary_test.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go`
|
||||
- `apps/backend/internal/task/service/service.go`
|
||||
- `apps/backend/internal/backendapp/services.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- The task-ID filter must apply inside every union branch.
|
||||
- Null completion times must not erase a turn start.
|
||||
- Add an index only after query-plan evidence shows the existing indexes are
|
||||
insufficient.
|
||||
|
||||
## Result
|
||||
|
||||
- RED: the repository test failed before `LoadTaskLastActivity` existed.
|
||||
- GREEN: `go test ./internal/task/repository/sqlite -run 'TaskLastActivity'`
|
||||
passed (1 SQLite test). The Postgres counterpart is environment-gated.
|
||||
- GREEN: `go test ./internal/task/service -run
|
||||
'StatusSummary.*Activity.*Rebuild'` passed (1 rebuild test).
|
||||
- GREEN: queued user prompts are included in the same batched max query while
|
||||
agent-owned queued rows remain excluded; affected repository, projector, and
|
||||
handler packages passed 1,031 tests.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: "03-project-live-task-activity"
|
||||
title: "Project task activity live"
|
||||
status: completed
|
||||
wave: 2
|
||||
depends_on: ["02-add-activity-reconstruction"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/sidebar-last-activity-sort.md"
|
||||
---
|
||||
|
||||
# Task 03: Project task activity live
|
||||
|
||||
Extend the bounded task summary with a monotonic activity time and repair older
|
||||
summary rows from the new batched source.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Summary JSON, equality, persistence, DTOs, and replacement events carry
|
||||
optional `last_activity_at` without changing `updated_at`.
|
||||
- Task creation, task update, state-only task transition, user-message, user
|
||||
queue admission, and turn events advance activity by source time. Queue
|
||||
bookkeeping, excluded background status, and older replay events do not
|
||||
advance it.
|
||||
- Initial hydration repairs missing values in batch and preserves newer stored
|
||||
activity through compare-and-set retries.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/task/statussummary -run 'LastActivity'
|
||||
cd apps/backend && go test ./internal/task/service -run 'StatusSummary.*LastActivity'
|
||||
cd apps/backend && go test ./internal/backendapp -run 'StatusSummary'
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/task/statussummary/model.go`
|
||||
- `apps/backend/internal/task/statussummary/model_test.go`
|
||||
- `apps/backend/internal/task/statussummary/projector.go`
|
||||
- `apps/backend/internal/task/statussummary/projector_events.go`
|
||||
- `apps/backend/internal/task/statussummary/projector_derive.go`
|
||||
- `apps/backend/internal/task/statussummary/projector_test.go`
|
||||
- `apps/backend/internal/task/statussummary/projector_restart_rehydration_test.go`
|
||||
- `apps/backend/internal/task/statussummary/rebuild.go`
|
||||
- `apps/backend/internal/task/statussummary/rebuild_test.go`
|
||||
- `apps/backend/internal/task/service/service_status_summary_rebuild.go`
|
||||
- `apps/backend/internal/task/service/service_status_summary_rebuild_test.go`
|
||||
- `apps/backend/internal/backendapp/gateway.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 02.
|
||||
|
||||
## Risks
|
||||
|
||||
- A status-only event must not copy projection time into activity time.
|
||||
- State transitions publish `task.state_changed` without a matching
|
||||
`task.updated` on several service paths, so the projector must consume both.
|
||||
- Message events count only user-authored additions.
|
||||
- Repair and live projection must share the same monotonic rule.
|
||||
|
||||
## Result
|
||||
|
||||
- Added optional semantic `last_activity_at` to live summary replacement
|
||||
payloads and projector state, with durable loader rehydration.
|
||||
- Subscribed to task creation, state-only transitions, user queue admission,
|
||||
and turn start and completion. User-authored message additions and queued
|
||||
admissions use their persisted source time; background status, provider,
|
||||
queue bookkeeping, agent-message, and older replay events do not advance the
|
||||
monotonic maximum. Queue merges retain the newest admission time.
|
||||
- Focused verification passed:
|
||||
- `go test ./internal/task/statussummary -run 'LastActivity'` (3 tests)
|
||||
- `go test ./internal/task/service -run 'StatusSummary.*LastActivity'` (no matching tests)
|
||||
- `go test ./internal/backendapp -run 'StatusSummary'` (7 tests)
|
||||
- `go test ./internal/task/statussummary` (64 tests)
|
||||
- Review fixup verification: affected backend packages passed 1,031 tests,
|
||||
including live queued-prompt projection and SQLite/Postgres reconstruction
|
||||
coverage.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
id: "04-add-sidebar-sort"
|
||||
title: "Add the sidebar activity sort"
|
||||
status: completed
|
||||
wave: 3
|
||||
depends_on: ["03-project-live-task-activity"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/sidebar-last-activity-sort.md"
|
||||
---
|
||||
|
||||
# Task 04: Add the sidebar activity sort
|
||||
|
||||
Expose `lastActivityAt` through the shared saved-view picker, comparator, row
|
||||
time, portable settings, and required locales.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop and mobile view editors offer localized **Last activity** with both
|
||||
directions. The option updates the live order and row time.
|
||||
- Saved views and drafts retain `lastActivityAt` through wire conversion,
|
||||
migration, hydration, settings events, sync failure rollback, and reload.
|
||||
- Existing **Updated** views and new-view defaults remain unchanged. Missing
|
||||
activity uses task update, then creation, as fallback.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web exec vitest run lib/sidebar/apply-view.test.ts lib/state/slices/ui/ui-slice-migration.test.ts lib/state/slices/ui/sidebar-view-wire.test.ts components/task/task-session-sidebar-item.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps && pnpm --filter @kandev/web lint
|
||||
cd apps/web && pnpm run i18n:zh-hant && pnpm run i18n:check && pnpm run i18n:ratchet
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/types/task-status-summary.ts`
|
||||
- `apps/web/lib/state/slices/ui/sidebar-view-types.ts`
|
||||
- `apps/web/lib/state/slices/ui/ui-slice.ts`
|
||||
- `apps/web/components/task/sidebar-filter/sort-picker.tsx`
|
||||
- `apps/web/lib/sidebar/apply-view.ts`
|
||||
- `apps/web/lib/sidebar/apply-view.test.ts`
|
||||
- `apps/web/components/task/task-switcher-types.ts`
|
||||
- `apps/web/components/task/task-session-sidebar-item.ts`
|
||||
- `apps/web/components/task/task-session-sidebar-item.test.ts`
|
||||
- `apps/web/components/task/task-item-stats-row.tsx`
|
||||
- `apps/web/lib/state/slices/ui/ui-slice-migration.test.ts`
|
||||
- `apps/web/lib/state/slices/ui/sidebar-view-wire.test.ts`
|
||||
- `apps/web/lib/state/hydration/hydrator.test.ts`
|
||||
- `apps/web/lib/ws/handlers/users.test.ts`
|
||||
- `apps/web/src/locales/*/task.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 03.
|
||||
|
||||
## Risks
|
||||
|
||||
- Do not translate or rename the persisted key.
|
||||
- Do not call `t()` at module scope in the option registry.
|
||||
- The effective draft controls both sorting and displayed row time.
|
||||
|
||||
## Result
|
||||
|
||||
- Added the `lastActivityAt` sort key, migration allowlist entry, shared
|
||||
comparator with update/creation fallbacks, and wire round-trip coverage.
|
||||
- Threaded `last_activity_at` from summary payloads into desktop and mobile
|
||||
task rows. Relative time uses activity only when the effective view selects
|
||||
the new sort; existing Updated behavior and defaults remain unchanged.
|
||||
- Added the localized label to English, Portuguese, Simplified Chinese,
|
||||
Traditional Chinese, Hong Kong Chinese, and pseudo catalogs.
|
||||
- Verification passed:
|
||||
- focused Vitest suite: 119 tests
|
||||
- `pnpm run typecheck`
|
||||
- `pnpm --filter @kandev/web lint`
|
||||
- `pnpm run i18n:zh-hant`, `i18n:check`, and `i18n:ratchet`
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: "05-prove-sidebar-activity-sort"
|
||||
title: "Prove sidebar activity sorting"
|
||||
status: completed
|
||||
wave: 4
|
||||
depends_on: ["01-stabilize-github-pr-refresh", "04-add-sidebar-sort"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/ui/sidebar-last-activity-sort.md"
|
||||
---
|
||||
|
||||
# Task 05: Prove sidebar activity sorting
|
||||
|
||||
Add production-build browser regressions for the saved option, stable activity
|
||||
order, persistence, and phone geometry. Finish the change-aware verification
|
||||
gates.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop proves **Last activity** order and row time survive save and reload.
|
||||
A provider-only refresh does not reorder activity.
|
||||
- Mobile proves the same saved option and order from the task-switcher drawer.
|
||||
- Mobile assertions cover touch interaction, viewport containment, the task-list
|
||||
scroll owner, and zero document horizontal overflow.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm install --frozen-lockfile
|
||||
cd apps/web && pnpm e2e:run tests/task/sidebar-filter.spec.ts -- --grep "last activity"
|
||||
cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-sidebar-views.spec.ts -- --grep "last activity"
|
||||
make -C apps/backend test lint build
|
||||
cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet
|
||||
cd apps && pnpm --filter @kandev/web lint
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/helpers/api-client.ts`
|
||||
- `apps/web/e2e/pages/sidebar-filter-popover.ts`
|
||||
- `apps/web/e2e/tests/task/sidebar-filter.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts`
|
||||
- `apps/web/components/task/task-item-stats-row.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 01 and 04.
|
||||
|
||||
## Risks
|
||||
|
||||
- Seed distinct activity milestones instead of relying on fixed sleeps.
|
||||
- Scope portaled selectors to the active desktop or mobile surface.
|
||||
- Confirm Playwright discovers one desktop test and one mobile test before
|
||||
treating the runs as evidence.
|
||||
|
||||
## Result
|
||||
|
||||
- Desktop discovery found one targeted test, then the managed production E2E
|
||||
run passed. It verified Last activity ordering, row timestamp mapping, saved
|
||||
view persistence after reload, and stable order after two provider-only PR
|
||||
refreshes.
|
||||
- Mobile discovery found one targeted test, then the managed mobile-chrome E2E
|
||||
run passed. It verified touch selection, saved-view persistence, drawer
|
||||
containment, the task-list scroll owner, and zero document horizontal
|
||||
overflow.
|
||||
- The desktop fixture explicitly uses the existing None group so provider
|
||||
association does not intentionally change repository grouping while the test
|
||||
measures activity ordering.
|
||||
@@ -233,6 +233,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
|
||||
| [external-vcs-file-links](ui/external-vcs-file-links.md) | shipped |
|
||||
| [task-listing-display-preferences](ui/task-listing-display-preferences.md) | shipped |
|
||||
| [sidebar-archived-filter](ui/sidebar-archived-filter.md) | draft |
|
||||
| [sidebar-last-activity-sort](ui/sidebar-last-activity-sort.md) | draft |
|
||||
| [task-workspace-content-search](ui/task-workspace-content-search.md) | shipped |
|
||||
| [file-tree-chat-context](ui/file-tree-chat-context.md) | shipped |
|
||||
| [task-review-shortcut](ui/task-review-shortcut.md) | approved |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
status: approved
|
||||
created: 2026-08-01
|
||||
updated: 2026-08-15
|
||||
updated: 2026-08-17
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
@@ -52,6 +52,7 @@ The initial contract is:
|
||||
| Field | Meaning | Bound |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `revision`, `updated_at` | Monotonic task-local version and projection time | Constant |
|
||||
| `last_activity_at` | Latest durable task, user-prompt, or turn milestone | Constant |
|
||||
| `primary_session` | Primary session ID and lifecycle state | One session |
|
||||
| `foreground_activity`, `active_subagent_count` | Existing task-level busy aggregate | Constant |
|
||||
| `pending_action` | `permission`, `clarification`, or absent | Constant |
|
||||
@@ -115,6 +116,14 @@ remain during migration, but switchers use the summary when present.
|
||||
- A semantic no-op does not increment `revision` or emit an update.
|
||||
- Clients ignore a summary delta whose revision is not newer than the stored
|
||||
revision.
|
||||
- `last_activity_at` is separate from projection freshness. Task creation,
|
||||
persisted task mutations, user-authored prompts, and turn start or completion
|
||||
advance it by source time. Focus, subscriptions, Git or pull-request polling,
|
||||
queue bookkeeping, summary repair, and streamed chunks do not advance it.
|
||||
- Missing and older summaries rebuild `last_activity_at` in one batch from
|
||||
task, user-message, and turn records. Live and rebuilt values use a monotonic
|
||||
maximum, so replay and repair cannot move activity backward. See
|
||||
[ADR-2026-08-17-separate-task-activity-from-summary-freshness](../../decisions/2026-08-17-separate-task-activity-from-summary-freshness.md).
|
||||
|
||||
## API and event surface
|
||||
|
||||
@@ -254,6 +263,9 @@ intermediate replacement.
|
||||
changes its Git tree, or receives a PR update, **WHEN** its summary revision
|
||||
arrives, **THEN** desktop and mobile rows update without a session
|
||||
subscription.
|
||||
- **GIVEN** an idle task receives Git or pull-request summary changes, **WHEN**
|
||||
its replacement summary arrives, **THEN** `updated_at` can advance while
|
||||
`last_activity_at` remains unchanged.
|
||||
- **GIVEN** a recoverable error is dismissed or followed by a newer agent
|
||||
response, **WHEN** the projector processes that occurrence, **THEN** the
|
||||
independent error indicator clears on both task switchers.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
status: draft
|
||||
created: 2026-08-17
|
||||
updated: 2026-08-17
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Sidebar Last Activity Sort
|
||||
|
||||
## Why
|
||||
|
||||
Saved sidebar views can sort tasks by **Updated**. That value currently follows
|
||||
task-status summary freshness. Background pull-request updates can refresh many
|
||||
idle rows together, so this order does not answer which tasks a user or agent
|
||||
worked on most recently.
|
||||
|
||||
Users need a separate **Last activity** option that remains stable during
|
||||
background status refreshes.
|
||||
|
||||
## What
|
||||
|
||||
- Desktop and mobile sidebar view editors offer **Last activity** as a sort
|
||||
option.
|
||||
- The saved-view wire key is `lastActivityAt`. The key persists in saved views
|
||||
and in-flight drafts through user settings, boot hydration, and live settings
|
||||
updates.
|
||||
- Ascending order places the oldest activity first. Descending order places the
|
||||
newest activity first.
|
||||
- Equal timestamps retain the existing input order. Sorting does not introduce
|
||||
row movement for a tie.
|
||||
- The existing `updatedAt` key and **Updated** label remain valid. Existing
|
||||
saved views keep their current behavior.
|
||||
- The built-in view and new-view defaults do not change.
|
||||
- When a view sorts by **Last activity**, each row displays that activity time.
|
||||
Other sort modes retain the existing row time behavior.
|
||||
|
||||
Decision:
|
||||
[ADR-2026-08-17-separate-task-activity-from-summary-freshness](../../decisions/2026-08-17-separate-task-activity-from-summary-freshness.md).
|
||||
|
||||
## Activity definition
|
||||
|
||||
`last_activity_at` is the greatest known timestamp from these durable actions:
|
||||
|
||||
| Source | Activity time |
|
||||
|---|---|
|
||||
| Task creation | `tasks.created_at` |
|
||||
| Persisted task mutation | `tasks.updated_at` |
|
||||
| User-authored prompt, including a queued prompt | message `created_at` |
|
||||
| Agent turn starts | turn `started_at` |
|
||||
| Agent turn completes | turn `completed_at` |
|
||||
|
||||
The following events do not advance activity:
|
||||
|
||||
- opening, focusing, or subscribing to a task or session
|
||||
- Git or pull-request polling and reconciliation
|
||||
- queued-prompt count changes and other queue bookkeeping
|
||||
- status-summary rebuilds or transport delivery
|
||||
- session metadata maintenance without a user prompt or agent turn
|
||||
- streamed agent chunks inside an already-started turn
|
||||
|
||||
A task-state or workflow-placement change counts because it is a persisted task
|
||||
mutation. A foreground-activity publication that carries an unchanged task
|
||||
timestamp does not advance activity.
|
||||
|
||||
## Data model and derivation
|
||||
|
||||
- `TaskStatusSummary` gains optional semantic field
|
||||
`last_activity_at: RFC3339 timestamp`.
|
||||
- `revision` and `updated_at` keep their existing meanings: task-local summary
|
||||
version and projection freshness.
|
||||
- A change to `last_activity_at` is semantic. It advances the summary revision
|
||||
and uses the existing complete-replacement event.
|
||||
- Live projection consumes both `task.updated` and `task.state_changed`, because
|
||||
some persisted state transitions publish only the latter. It takes the
|
||||
monotonic maximum of the stored value and a source event timestamp. An older
|
||||
replay cannot move activity backward.
|
||||
- Initial load and repair use one batched query for all requested task IDs. The
|
||||
query combines task, user-message, and turn timestamps without one query per
|
||||
task.
|
||||
- Repair updates older summaries that lack the field. It preserves a stored
|
||||
activity time that is newer than the rebuilt durable maximum.
|
||||
- A missing summary or an older backend falls back to task `updated_at`, then
|
||||
task `created_at`. The row remains sortable during partial recovery.
|
||||
|
||||
## API and persistence
|
||||
|
||||
The existing task surfaces carry the additive field:
|
||||
|
||||
- boot state and task-list snapshots use `status_summary.last_activity_at`
|
||||
- `task.status_summary.updated` uses the same complete summary payload
|
||||
- sidebar views and drafts persist sort key `lastActivityAt` through the
|
||||
existing user-settings JSON contract
|
||||
|
||||
No new endpoint or WebSocket action is required. The summary remains bounded
|
||||
and contains no transcript, file, or provider payload.
|
||||
|
||||
## Provider refresh stability
|
||||
|
||||
Equivalent GitHub status snapshots from the REST feedback path and the batched
|
||||
GraphQL poller must produce one canonical task-PR state. A semantic no-op does
|
||||
not emit `github.task_pr.updated`.
|
||||
|
||||
GitHub sync diagnostics record the names of changed semantic fields without
|
||||
recording field values or provider content. This evidence makes future
|
||||
cross-path oscillation visible without exposing sensitive data.
|
||||
|
||||
Provider changes can advance summary `updated_at`. They never advance
|
||||
`last_activity_at`.
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
Desktop uses the existing Tasks section view editor. Phones use the existing
|
||||
task-switcher drawer and its shared view editor. Both surfaces use the same
|
||||
sort key, comparator, draft, and saved setting.
|
||||
|
||||
The option stays inside the existing sort selector. It adds no mobile route,
|
||||
drawer, popover, or scroll owner. The current task list remains the only
|
||||
vertical scroll body.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If activity backfill fails, Kandev keeps the last valid summary and uses the
|
||||
frontend fallback. A later list load or source event retries convergence.
|
||||
- If a summary event is dropped, normal task-list hydration restores the
|
||||
complete value.
|
||||
- If user-settings sync fails, the existing saved-view rollback and error
|
||||
treatment restores the previous view and draft.
|
||||
- An unknown stored sort key keeps the existing migration fallback to Status.
|
||||
`lastActivityAt` is added to the known-key set before it is offered in the
|
||||
editor.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** several idle tasks receive pull-request status refreshes, **WHEN** a
|
||||
view sorts by **Last activity**, **THEN** their order and displayed activity
|
||||
times do not change.
|
||||
- **GIVEN** a task with an older summary refresh but a newer user prompt,
|
||||
**WHEN** the view sorts descending by **Last activity**, **THEN** that task
|
||||
appears before a task with newer provider status but older activity.
|
||||
- **GIVEN** a user sends a queued prompt, **WHEN** the prompt is stored,
|
||||
**THEN** the task activity advances before the agent begins that turn.
|
||||
- **GIVEN** an agent begins and completes a turn, **WHEN** each milestone is
|
||||
projected, **THEN** activity advances at start and again at completion but
|
||||
not for every streamed chunk.
|
||||
- **GIVEN** an existing saved view sorted by **Updated**, **WHEN** Kandev is
|
||||
upgraded, **THEN** the view keeps that sort and its current order.
|
||||
- **GIVEN** a saved view sorted by **Last activity**, **WHEN** the page reloads,
|
||||
**THEN** the selection, direction, order, and row timestamps persist.
|
||||
- **GIVEN** a phone viewport, **WHEN** the user selects **Last activity** in the
|
||||
task-switcher drawer, **THEN** the same order is applied and the selector
|
||||
remains touch-reachable and viewport-contained.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replacing or renaming the existing **Updated** sort.
|
||||
- Changing the built-in view or new-view defaults.
|
||||
- Sorting the Kanban board, Office task list, command panel, or quick-chat tabs.
|
||||
- Treating passive focus, provider polling, or raw stream chunks as activity.
|
||||
- Adding a general task activity feed or audit log.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
- [Sidebar Last Activity Sort](../../plans/sidebar-last-activity-sort/plan.md)
|
||||
Reference in New Issue
Block a user