feat: persist and expire quick chats (#1612)
* feat: persist and expire quick chats * fix: address quick chat review feedback * fix: resolve quick chat review findings * fix: close remaining quick chat review gaps * fix: avoid repeated quick chat expiration rechecks * fix: guard quick chat expiration deletes * fix: expire abandoned starting quick chats * fix: ignore missing quick chat expiration candidates
This commit is contained in:
@@ -3,6 +3,8 @@ package backendapp
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
|
||||
taskdto "github.com/kandev/kandev/internal/task/dto"
|
||||
@@ -16,6 +18,8 @@ import (
|
||||
const (
|
||||
activeWorkspaceCookie = "kandev-active-workspace"
|
||||
legacyOfficeWorkspaceCookie = "office-active-workspace"
|
||||
bootStateKeySessionID = "sessionId"
|
||||
bootStateKeyWorkspaceID = "workspaceId"
|
||||
)
|
||||
|
||||
func bootInitialState(
|
||||
@@ -48,6 +52,7 @@ func bootInitialState(
|
||||
if route.Route == webapp.RouteOffice {
|
||||
builder.addOfficeRouteState(ctx, req, state)
|
||||
}
|
||||
builder.addQuickChatState(ctx, req, state)
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -261,6 +266,210 @@ func (b bootStateBuilder) addRepositoriesState(ctx context.Context, state map[st
|
||||
}
|
||||
}
|
||||
|
||||
func (b bootStateBuilder) addQuickChatState(ctx context.Context, req *http.Request, state map[string]any) {
|
||||
workspaceID := b.resolveQuickChatWorkspaceID(ctx, req, state)
|
||||
if workspaceID == "" {
|
||||
return
|
||||
}
|
||||
quickChat, err := b.quickChatSessions(ctx, workspaceID)
|
||||
if err != nil {
|
||||
b.logBootError("list quick-chat sessions", err)
|
||||
return
|
||||
}
|
||||
state["quickChat"] = map[string]any{
|
||||
"isOpen": false,
|
||||
"sessions": quickChat.sessions,
|
||||
"activeSessionId": nil,
|
||||
}
|
||||
mergeBootTaskSessionItems(state, quickChat.taskSessions)
|
||||
}
|
||||
|
||||
func (b bootStateBuilder) resolveQuickChatWorkspaceID(ctx context.Context, req *http.Request, state map[string]any) string {
|
||||
if active := activeWorkspaceIDFromState(state); active != "" {
|
||||
return active
|
||||
}
|
||||
if b.p.taskSvc == nil {
|
||||
return ""
|
||||
}
|
||||
workspaces, err := b.p.taskSvc.ListWorkspaces(ctx)
|
||||
if err != nil {
|
||||
b.logBootError("list quick-chat workspaces", err)
|
||||
return ""
|
||||
}
|
||||
settingsWorkspaceID := ""
|
||||
if settings, ok := b.userSettings(ctx); ok {
|
||||
settingsWorkspaceID = settings.Settings.WorkspaceID
|
||||
}
|
||||
return firstValidID(
|
||||
workspaceIDSet(workspaces),
|
||||
queryValue(req, "workspaceId"),
|
||||
queryValue(req, "workspace"),
|
||||
readActiveWorkspaceCookie(req),
|
||||
settingsWorkspaceID,
|
||||
firstWorkspaceID(workspaces),
|
||||
)
|
||||
}
|
||||
|
||||
func activeWorkspaceIDFromState(state map[string]any) string {
|
||||
workspaces, ok := state["workspaces"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
active, _ := workspaces["activeId"].(string)
|
||||
return active
|
||||
}
|
||||
|
||||
func (b bootStateBuilder) quickChatSessions(ctx context.Context, workspaceID string) (quickChatBootState, error) {
|
||||
tasks, err := b.listQuickChatTasks(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return quickChatBootState{}, err
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return quickChatBootState{sessions: []map[string]any{}}, nil
|
||||
}
|
||||
taskIDs := taskIDs(tasks)
|
||||
sessionsByTask, err := b.p.taskSvc.BatchGetSessionsForTasks(ctx, taskIDs)
|
||||
if err != nil {
|
||||
return quickChatBootState{}, err
|
||||
}
|
||||
primaryByTask, err := b.p.taskSvc.GetPrimarySessionInfoForTasks(ctx, taskIDs)
|
||||
if err != nil {
|
||||
return quickChatBootState{}, err
|
||||
}
|
||||
|
||||
items := make([]quickChatBootSession, 0, len(tasks))
|
||||
taskSessions := make(map[string]taskdto.TaskSessionDTO, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if !isRestorableQuickChatTask(task) {
|
||||
continue
|
||||
}
|
||||
primary := primaryByTask[task.ID]
|
||||
if primary == nil || primary.ID == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, quickChatBootSession{
|
||||
state: mapQuickChatSessionState(task, primary),
|
||||
lastActivity: quickChatLastActivity(task, sessionsByTask[task.ID]),
|
||||
})
|
||||
taskSessions[primary.ID] = taskdto.FromTaskSession(primary)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return items[i].lastActivity.After(items[j].lastActivity)
|
||||
})
|
||||
result := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, item.state)
|
||||
}
|
||||
return quickChatBootState{sessions: result, taskSessions: taskSessions}, nil
|
||||
}
|
||||
|
||||
func (b bootStateBuilder) listQuickChatTasks(ctx context.Context, workspaceID string) ([]*taskmodels.Task, error) {
|
||||
const pageSize = 1000
|
||||
var all []*taskmodels.Task
|
||||
for page := 1; ; page++ {
|
||||
tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, pageSize, "", false, false, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, tasks...)
|
||||
if len(tasks) == 0 || len(all) >= total {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type quickChatBootSession struct {
|
||||
state map[string]any
|
||||
lastActivity time.Time
|
||||
}
|
||||
|
||||
type quickChatBootState struct {
|
||||
sessions []map[string]any
|
||||
taskSessions map[string]taskdto.TaskSessionDTO
|
||||
}
|
||||
|
||||
func mergeBootTaskSessionItems(state map[string]any, items map[string]taskdto.TaskSessionDTO) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
taskSessions, ok := state["taskSessions"].(map[string]any)
|
||||
if !ok {
|
||||
state["taskSessions"] = map[string]any{"items": items}
|
||||
return
|
||||
}
|
||||
merged := make(map[string]any, len(items))
|
||||
switch existing := taskSessions["items"].(type) {
|
||||
case map[string]taskdto.TaskSessionDTO:
|
||||
for id, session := range existing {
|
||||
merged[id] = session
|
||||
}
|
||||
case map[string]any:
|
||||
for id, session := range existing {
|
||||
merged[id] = session
|
||||
}
|
||||
}
|
||||
for id, session := range items {
|
||||
merged[id] = session
|
||||
}
|
||||
taskSessions["items"] = merged
|
||||
}
|
||||
|
||||
func taskIDs(tasks []*taskmodels.Task) []string {
|
||||
ids := make([]string, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if task != nil {
|
||||
ids = append(ids, task.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func isRestorableQuickChatTask(task *taskmodels.Task) bool {
|
||||
return task != nil &&
|
||||
task.IsEphemeral &&
|
||||
task.WorkflowID == "" &&
|
||||
task.Origin != taskmodels.TaskOriginAutomationRun
|
||||
}
|
||||
|
||||
func mapQuickChatSessionState(task *taskmodels.Task, primary *taskmodels.TaskSession) map[string]any {
|
||||
state := map[string]any{
|
||||
bootStateKeySessionID: primary.ID,
|
||||
bootStateKeyWorkspaceID: task.WorkspaceID,
|
||||
}
|
||||
if task.Title != "" && task.Title != "Quick Chat" {
|
||||
state["name"] = task.Title
|
||||
}
|
||||
if agentProfileID := quickChatAgentProfileID(task, primary); agentProfileID != "" {
|
||||
state["agentProfileId"] = agentProfileID
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func quickChatAgentProfileID(task *taskmodels.Task, primary *taskmodels.TaskSession) string {
|
||||
if task != nil {
|
||||
if value, ok := task.Metadata[taskmodels.MetaKeyAgentProfileID].(string); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if primary != nil {
|
||||
return primary.AgentProfileID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func quickChatLastActivity(task *taskmodels.Task, sessions []*taskmodels.TaskSession) time.Time {
|
||||
last := time.Time{}
|
||||
if task != nil {
|
||||
last = task.UpdatedAt
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if session != nil && session.UpdatedAt.After(last) {
|
||||
last = session.UpdatedAt
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func (b bootStateBuilder) addKanbanSnapshotsState(
|
||||
ctx context.Context,
|
||||
state map[string]any,
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
taskdto "github.com/kandev/kandev/internal/task/dto"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskrepo "github.com/kandev/kandev/internal/task/repository"
|
||||
sqlitetaskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
usercontroller "github.com/kandev/kandev/internal/user/controller"
|
||||
userservice "github.com/kandev/kandev/internal/user/service"
|
||||
@@ -30,6 +31,7 @@ import (
|
||||
workflowrepo "github.com/kandev/kandev/internal/workflow/repository"
|
||||
workflowservice "github.com/kandev/kandev/internal/workflow/service"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
@@ -612,6 +614,139 @@ func TestBootPayloadIncludesDebugRuntimeWhenDevMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootPayloadRestoresQuickChatSessions(t *testing.T) {
|
||||
harness := newBootStateTestHarness(t)
|
||||
ctx := context.Background()
|
||||
repo := harness.taskRepo
|
||||
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-qc", Name: "Quick Chats"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-qc", WorkspaceID: "ws-qc", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("CreateWorkflow: %v", err)
|
||||
}
|
||||
base := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{
|
||||
id: "task-old", title: "Older Chat", updatedAt: base.Add(-2 * time.Hour),
|
||||
sessionUpdatedAt: base.Add(-90 * time.Minute), agentProfileID: "agent-old",
|
||||
})
|
||||
seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{
|
||||
id: "task-new", title: "Newer Chat", updatedAt: base.Add(-4 * time.Hour),
|
||||
sessionUpdatedAt: base.Add(-10 * time.Minute), agentProfileID: "agent-new",
|
||||
})
|
||||
seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{
|
||||
id: "task-config", title: "Config", updatedAt: base, sessionUpdatedAt: base,
|
||||
agentProfileID: "agent-config", metadata: map[string]interface{}{"config_mode": true},
|
||||
})
|
||||
seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{
|
||||
id: "task-automation", title: "Automation", updatedAt: base, sessionUpdatedAt: base,
|
||||
agentProfileID: "agent-automation", origin: models.TaskOriginAutomationRun,
|
||||
})
|
||||
seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{
|
||||
id: "task-workflow", title: "Workflow Ephemeral", updatedAt: base, sessionUpdatedAt: base,
|
||||
agentProfileID: "agent-workflow", workflowID: "wf-qc",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/?workspaceId=ws-qc", nil)
|
||||
payload := bootPayload(ctx, req, routeParams{
|
||||
taskSvc: harness.taskSvc,
|
||||
services: &Services{Workflow: harness.workflowSvc},
|
||||
userCtrl: harness.userCtrl,
|
||||
}, webapp.ClassifyRoute("/"))
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal payload: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
InitialState struct {
|
||||
QuickChat struct {
|
||||
Sessions []struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Name string `json:"name"`
|
||||
AgentProfileID string `json:"agentProfileId"`
|
||||
} `json:"sessions"`
|
||||
IsOpen bool `json:"isOpen"`
|
||||
} `json:"quickChat"`
|
||||
TaskSessions struct {
|
||||
Items map[string]struct {
|
||||
TaskID string `json:"task_id"`
|
||||
} `json:"items"`
|
||||
} `json:"taskSessions"`
|
||||
} `json:"initialState"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal payload: %v", err)
|
||||
}
|
||||
|
||||
sessions := decoded.InitialState.QuickChat.Sessions
|
||||
if len(sessions) != 2 {
|
||||
t.Fatalf("quickChat sessions = %#v, want 2 restored sessions", sessions)
|
||||
}
|
||||
if got := sessions[0].SessionID; got != "task-new-session" {
|
||||
t.Fatalf("first restored session = %q, want newest task-new-session", got)
|
||||
}
|
||||
if sessions[0].AgentProfileID != "agent-new" || sessions[1].AgentProfileID != "agent-old" {
|
||||
t.Fatalf("agent profile IDs = %#v", sessions)
|
||||
}
|
||||
if got := decoded.InitialState.TaskSessions.Items["task-new-session"].TaskID; got != "task-new" {
|
||||
t.Fatalf("quick chat task session task_id = %q, want task-new", got)
|
||||
}
|
||||
if decoded.InitialState.QuickChat.IsOpen {
|
||||
t.Fatal("quick chat should hydrate closed")
|
||||
}
|
||||
}
|
||||
|
||||
type bootQuickChatFixture struct {
|
||||
id string
|
||||
title string
|
||||
workflowID string
|
||||
origin string
|
||||
agentProfileID string
|
||||
metadata map[string]interface{}
|
||||
updatedAt time.Time
|
||||
sessionUpdatedAt time.Time
|
||||
}
|
||||
|
||||
func seedBootQuickChatTask(t *testing.T, repo *sqlitetaskrepo.Repository, ctx context.Context, f bootQuickChatFixture) {
|
||||
t.Helper()
|
||||
metadata := map[string]interface{}{models.MetaKeyAgentProfileID: f.agentProfileID}
|
||||
for key, value := range f.metadata {
|
||||
metadata[key] = value
|
||||
}
|
||||
if err := repo.CreateTask(ctx, &models.Task{
|
||||
ID: f.id,
|
||||
WorkspaceID: "ws-qc",
|
||||
WorkflowID: f.workflowID,
|
||||
Title: f.title,
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: "medium",
|
||||
IsEphemeral: true,
|
||||
Origin: f.origin,
|
||||
Metadata: metadata,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTask(%s): %v", f.id, err)
|
||||
}
|
||||
if _, err := repo.DB().ExecContext(ctx,
|
||||
`UPDATE tasks SET created_at = ?, updated_at = ? WHERE id = ?`,
|
||||
f.updatedAt.Add(-time.Hour), f.updatedAt, f.id,
|
||||
); err != nil {
|
||||
t.Fatalf("backdate task(%s): %v", f.id, err)
|
||||
}
|
||||
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: f.id + "-session",
|
||||
TaskID: f.id,
|
||||
AgentProfileID: f.agentProfileID,
|
||||
State: models.TaskSessionStateCompleted,
|
||||
StartedAt: f.sessionUpdatedAt.Add(-time.Hour),
|
||||
UpdatedAt: f.sessionUpdatedAt,
|
||||
IsPrimary: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskSession(%s): %v", f.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootRouteDataIntegrationRouteIncludesOnlyLocalContext(t *testing.T) {
|
||||
taskSvc, workflowSvc := newBootStateTestServices(t)
|
||||
ctx := context.Background()
|
||||
@@ -827,6 +962,7 @@ func TestQueryValueReadsRouteQueryFromAppStatePath(t *testing.T) {
|
||||
|
||||
type bootStateTestHarness struct {
|
||||
taskSvc *taskservice.Service
|
||||
taskRepo *sqlitetaskrepo.Repository
|
||||
workflowSvc *workflowservice.Service
|
||||
userCtrl *usercontroller.Controller
|
||||
userSvc *userservice.Service
|
||||
@@ -901,6 +1037,7 @@ func newBootStateTestHarness(t *testing.T) bootStateTestHarness {
|
||||
workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc})
|
||||
return bootStateTestHarness{
|
||||
taskSvc: taskSvc,
|
||||
taskRepo: taskRepo,
|
||||
workflowSvc: workflowSvc,
|
||||
userCtrl: usercontroller.NewController(userSvc),
|
||||
userSvc: userSvc,
|
||||
|
||||
@@ -700,6 +700,7 @@ func startGatewayAndServe(
|
||||
}
|
||||
|
||||
services.Task.StartAutoArchiveLoop(ctx)
|
||||
services.Task.StartQuickChatExpirationLoop(ctx)
|
||||
|
||||
// ============================================
|
||||
// SYSTEM PAGES
|
||||
|
||||
@@ -111,6 +111,17 @@ func TestNowMinusHours(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreatestTimestamp(t *testing.T) {
|
||||
got := GreatestTimestamp(SQLite3, "t.updated_at", "MAX(ts.updated_at)")
|
||||
if got != "max(t.updated_at, MAX(ts.updated_at))" {
|
||||
t.Errorf("sqlite: got %q", got)
|
||||
}
|
||||
got = GreatestTimestamp(PGX, "t.updated_at", "MAX(ts.updated_at)")
|
||||
if got != "GREATEST(t.updated_at, MAX(ts.updated_at))" {
|
||||
t.Errorf("pgx: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDate(t *testing.T) {
|
||||
if CurrentDate(SQLite3) != "date('now')" {
|
||||
t.Errorf("sqlite: got %q", CurrentDate(SQLite3))
|
||||
|
||||
@@ -47,6 +47,17 @@ func NowMinusHours(driver, hoursExpr string) string {
|
||||
return fmt.Sprintf("datetime('now', '-' || %s || ' hours')", hoursExpr)
|
||||
}
|
||||
|
||||
// GreatestTimestamp returns the greater of two timestamp expressions.
|
||||
//
|
||||
// SQLite: max(left, right)
|
||||
// Postgres: GREATEST(left, right)
|
||||
func GreatestTimestamp(driver, left, right string) string {
|
||||
if IsPostgres(driver) {
|
||||
return fmt.Sprintf("GREATEST(%s, %s)", left, right)
|
||||
}
|
||||
return fmt.Sprintf("max(%s, %s)", left, right)
|
||||
}
|
||||
|
||||
// CurrentDate returns the SQL expression for the current date (no time component).
|
||||
//
|
||||
// SQLite: date('now')
|
||||
|
||||
@@ -83,6 +83,12 @@ func (m *mockRepository) ArchiveTask(ctx context.Context, id string) error {
|
||||
func (m *mockRepository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) ListExpiredQuickChatTasks(ctx context.Context, cutoff time.Time) ([]*models.Task, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) DeleteExpiredQuickChatTask(ctx context.Context, id string, cutoff time.Time) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (m *mockRepository) CountOpenWatcherCreatedTasks(_ context.Context, _, _ string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
var ErrWorkspaceNameMismatch = repoerrors.ErrWorkspaceNameMismatch
|
||||
var ErrWorkspaceNotFound = repoerrors.ErrWorkspaceNotFound
|
||||
var ErrTaskNotFound = repoerrors.ErrTaskNotFound
|
||||
|
||||
// WorkspaceRepository handles workspace CRUD.
|
||||
type WorkspaceRepository interface {
|
||||
@@ -43,6 +44,8 @@ type TaskRepository interface {
|
||||
// archived by the named cascade. Returns whether the row was updated.
|
||||
UnarchiveTaskByCascade(ctx context.Context, id, cascadeID string) (bool, error)
|
||||
ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error)
|
||||
ListExpiredQuickChatTasks(ctx context.Context, cutoff time.Time) ([]*models.Task, error)
|
||||
DeleteExpiredQuickChatTask(ctx context.Context, id string, cutoff time.Time) (bool, error)
|
||||
// CountOpenWatcherCreatedTasks returns the number of open watcher-created
|
||||
// tasks for a single watch, identified by the integration's task-metadata
|
||||
// key (e.g. "sentry_issue_watch_id") and the watch id. Open = non-archived
|
||||
|
||||
@@ -8,3 +8,6 @@ var ErrWorkspaceNameMismatch = errors.New("workspace name mismatch")
|
||||
|
||||
// ErrWorkspaceNotFound reports that no workspace row matched the supplied id.
|
||||
var ErrWorkspaceNotFound = errors.New("workspace not found")
|
||||
|
||||
// ErrTaskNotFound reports that no task row matched the supplied id.
|
||||
var ErrTaskNotFound = errors.New("task not found")
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// DeleteTask, UpdateTaskState, …) when no row matches the supplied id.
|
||||
// Callers should classify via errors.Is rather than substring-matching the
|
||||
// formatted message, which includes the task id and is therefore brittle.
|
||||
var ErrTaskNotFound = errors.New("task not found")
|
||||
var ErrTaskNotFound = repoerrors.ErrTaskNotFound
|
||||
|
||||
// ErrWorkspaceNotFound is returned by Repository workspace methods when no row
|
||||
// matches the supplied id.
|
||||
|
||||
@@ -865,6 +865,102 @@ func (r *Repository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Tas
|
||||
return r.scanTasks(rows)
|
||||
}
|
||||
|
||||
// ListExpiredQuickChatTasks returns quick-chat tasks whose last task/session
|
||||
// activity is older than cutoff. Active sessions are excluded so in-use chats
|
||||
// are never deleted by the idle sweeper.
|
||||
func (r *Repository) ListExpiredQuickChatTasks(ctx context.Context, cutoff time.Time) ([]*models.Task, error) {
|
||||
drv := r.ro.DriverName()
|
||||
sessionActivity := "COALESCE(MAX(ts.updated_at), t.updated_at)"
|
||||
lastActivity := dialect.GreatestTimestamp(drv, "t.updated_at", sessionActivity)
|
||||
query := fmt.Sprintf(`
|
||||
WITH candidates AS (
|
||||
SELECT t.id, %s AS last_activity
|
||||
FROM tasks t
|
||||
LEFT JOIN task_sessions ts ON ts.task_id = t.id
|
||||
WHERE t.is_ephemeral = 1
|
||||
AND COALESCE(t.workflow_id, '') = ''
|
||||
AND COALESCE(t.origin, '') != ?
|
||||
AND %s
|
||||
AND t.archived_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM task_sessions active
|
||||
WHERE active.task_id = t.id
|
||||
AND active.state IN (?, ?)
|
||||
)
|
||||
GROUP BY t.id, t.updated_at
|
||||
HAVING %s < ?
|
||||
)
|
||||
SELECT %s
|
||||
FROM tasks t
|
||||
JOIN candidates c ON c.id = t.id
|
||||
ORDER BY c.last_activity ASC
|
||||
`,
|
||||
lastActivity,
|
||||
excludeConfigModePredicate(drv, "t.metadata"),
|
||||
lastActivity,
|
||||
taskSelectColumns("t"),
|
||||
)
|
||||
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query),
|
||||
models.TaskOriginAutomationRun,
|
||||
models.TaskSessionStateRunning,
|
||||
models.TaskSessionStateIdle,
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
return r.scanTasks(rows)
|
||||
}
|
||||
|
||||
// DeleteExpiredQuickChatTask deletes id only when it still matches the expired
|
||||
// quick-chat predicate at delete time.
|
||||
func (r *Repository) DeleteExpiredQuickChatTask(ctx context.Context, id string, cutoff time.Time) (bool, error) {
|
||||
drv := r.db.DriverName()
|
||||
sessionActivity := "COALESCE(MAX(ts.updated_at), t.updated_at)"
|
||||
lastActivity := dialect.GreatestTimestamp(drv, "t.updated_at", sessionActivity)
|
||||
query := fmt.Sprintf(`
|
||||
WITH candidate AS (
|
||||
SELECT t.id, %s AS last_activity
|
||||
FROM tasks t
|
||||
LEFT JOIN task_sessions ts ON ts.task_id = t.id
|
||||
WHERE t.id = ?
|
||||
AND t.is_ephemeral = 1
|
||||
AND COALESCE(t.workflow_id, '') = ''
|
||||
AND COALESCE(t.origin, '') != ?
|
||||
AND %s
|
||||
AND t.archived_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM task_sessions active
|
||||
WHERE active.task_id = t.id
|
||||
AND active.state IN (?, ?)
|
||||
)
|
||||
GROUP BY t.id, t.updated_at
|
||||
HAVING %s < ?
|
||||
)
|
||||
DELETE FROM tasks
|
||||
WHERE id = ?
|
||||
AND EXISTS (SELECT 1 FROM candidate)
|
||||
`,
|
||||
lastActivity,
|
||||
excludeConfigModePredicate(drv, "t.metadata"),
|
||||
lastActivity,
|
||||
)
|
||||
result, err := r.db.ExecContext(ctx, r.db.Rebind(query),
|
||||
id,
|
||||
models.TaskOriginAutomationRun,
|
||||
models.TaskSessionStateRunning,
|
||||
models.TaskSessionStateIdle,
|
||||
cutoff,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// isSafeMetadataKey reports whether s is a safe JSON metadata key to splice
|
||||
// into a json_extract path. The key is concatenated into the SQL text (it
|
||||
// cannot be a bind parameter inside the '$.<key>' path literal), so it must be
|
||||
|
||||
@@ -182,6 +182,175 @@ func TestSQLiteRepository_UpdateTaskStateIfCurrentIn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepository_ListExpiredQuickChatTasks(t *testing.T) {
|
||||
repo, cleanup := createTestSQLiteRepo(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-quick", Name: "Quick"}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-quick", WorkspaceID: "ws-quick", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("create workflow: %v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
cutoff := now.Add(-7 * 24 * time.Hour)
|
||||
old := cutoff.Add(-time.Hour)
|
||||
older := cutoff.Add(-3 * time.Hour)
|
||||
abandonedCreated := cutoff.Add(-2 * time.Hour)
|
||||
abandonedStarting := cutoff.Add(-90 * time.Minute)
|
||||
recent := cutoff.Add(time.Hour)
|
||||
|
||||
cases := []struct {
|
||||
id string
|
||||
taskUpdated time.Time
|
||||
sessionUpdated time.Time
|
||||
sessionState models.TaskSessionState
|
||||
ephemeral bool
|
||||
workflowID string
|
||||
archived bool
|
||||
origin string
|
||||
metadata map[string]interface{}
|
||||
wantExpired bool
|
||||
}{
|
||||
{id: "expired-older", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, wantExpired: true},
|
||||
{id: "abandoned-created", taskUpdated: abandonedCreated, sessionUpdated: abandonedCreated, sessionState: models.TaskSessionStateCreated, ephemeral: true, wantExpired: true},
|
||||
{id: "abandoned-starting", taskUpdated: abandonedStarting, sessionUpdated: abandonedStarting, sessionState: models.TaskSessionStateStarting, ephemeral: true, wantExpired: true},
|
||||
{id: "expired-old", taskUpdated: old, sessionUpdated: old, sessionState: models.TaskSessionStateCompleted, ephemeral: true, wantExpired: true},
|
||||
{id: "expired-waiting-input", taskUpdated: old, sessionUpdated: old, sessionState: models.TaskSessionStateWaitingForInput, ephemeral: true, wantExpired: true},
|
||||
{id: "recent-task", taskUpdated: recent, sessionUpdated: old, sessionState: models.TaskSessionStateCompleted, ephemeral: true},
|
||||
{id: "recent-session", taskUpdated: old, sessionUpdated: recent, sessionState: models.TaskSessionStateCompleted, ephemeral: true},
|
||||
{id: "running-session", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateRunning, ephemeral: true},
|
||||
{id: "idle-session", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateIdle, ephemeral: true},
|
||||
{id: "config-mode", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, metadata: map[string]interface{}{"config_mode": true}},
|
||||
{id: "automation-run", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, origin: models.TaskOriginAutomationRun},
|
||||
{id: "kanban-task", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: false, workflowID: "wf-quick"},
|
||||
{id: "ephemeral-workflow", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, workflowID: "wf-quick"},
|
||||
{id: "archived", taskUpdated: older, sessionUpdated: older, sessionState: models.TaskSessionStateCompleted, ephemeral: true, archived: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
createQuickChatExpiryFixture(t, repo, ctx, tc.id, tc.workflowID, tc.origin, tc.metadata, tc.ephemeral, tc.archived, tc.taskUpdated, tc.sessionUpdated, tc.sessionState)
|
||||
}
|
||||
|
||||
tasks, err := repo.ListExpiredQuickChatTasks(ctx, cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("ListExpiredQuickChatTasks: %v", err)
|
||||
}
|
||||
|
||||
var got []string
|
||||
for _, task := range tasks {
|
||||
got = append(got, task.ID)
|
||||
}
|
||||
var want []string
|
||||
for _, tc := range cases {
|
||||
if tc.wantExpired {
|
||||
want = append(want, tc.id)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("expired task IDs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepository_DeleteExpiredQuickChatTask(t *testing.T) {
|
||||
repo, cleanup := createTestSQLiteRepo(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-quick", Name: "Quick"}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
cutoff := now.Add(-7 * 24 * time.Hour)
|
||||
old := cutoff.Add(-time.Hour)
|
||||
recent := cutoff.Add(time.Hour)
|
||||
|
||||
createQuickChatExpiryFixture(t, repo, ctx, "expired", "", "", nil, true, false, old, old, models.TaskSessionStateCompleted)
|
||||
createQuickChatExpiryFixture(t, repo, ctx, "active", "", "", nil, true, false, old, old, models.TaskSessionStateRunning)
|
||||
createQuickChatExpiryFixture(t, repo, ctx, "recent", "", "", nil, true, false, old, recent, models.TaskSessionStateCompleted)
|
||||
|
||||
deleted, err := repo.DeleteExpiredQuickChatTask(ctx, "expired", cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteExpiredQuickChatTask expired: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
t.Fatal("expected expired quick chat to be deleted")
|
||||
}
|
||||
if _, err := repo.GetTask(ctx, "expired"); err == nil {
|
||||
t.Fatal("expired quick chat still exists after guarded delete")
|
||||
}
|
||||
|
||||
for _, id := range []string{"active", "recent"} {
|
||||
deleted, err := repo.DeleteExpiredQuickChatTask(ctx, id, cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteExpiredQuickChatTask %s: %v", id, err)
|
||||
}
|
||||
if deleted {
|
||||
t.Fatalf("%s should not be deleted while ineligible", id)
|
||||
}
|
||||
if _, err := repo.GetTask(ctx, id); err != nil {
|
||||
t.Fatalf("%s should still exist: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createQuickChatExpiryFixture(
|
||||
t *testing.T,
|
||||
repo *sqlite.Repository,
|
||||
ctx context.Context,
|
||||
id string,
|
||||
workflowID string,
|
||||
origin string,
|
||||
metadata map[string]interface{},
|
||||
ephemeral bool,
|
||||
archived bool,
|
||||
taskUpdated time.Time,
|
||||
sessionUpdated time.Time,
|
||||
sessionState models.TaskSessionState,
|
||||
) {
|
||||
t.Helper()
|
||||
task := &models.Task{
|
||||
ID: id,
|
||||
WorkspaceID: "ws-quick",
|
||||
WorkflowID: workflowID,
|
||||
Title: id,
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: "medium",
|
||||
IsEphemeral: ephemeral,
|
||||
Origin: origin,
|
||||
Metadata: metadata,
|
||||
CreatedAt: taskUpdated.Add(-time.Hour),
|
||||
UpdatedAt: taskUpdated,
|
||||
}
|
||||
if archived {
|
||||
archivedAt := taskUpdated.Add(time.Minute)
|
||||
task.ArchivedAt = &archivedAt
|
||||
}
|
||||
if err := repo.CreateTask(ctx, task); err != nil {
|
||||
t.Fatalf("CreateTask(%s): %v", id, err)
|
||||
}
|
||||
if _, err := repo.DB().ExecContext(ctx,
|
||||
`UPDATE tasks SET created_at = ?, updated_at = ?, archived_at = ? WHERE id = ?`,
|
||||
task.CreatedAt, taskUpdated, task.ArchivedAt, id,
|
||||
); err != nil {
|
||||
t.Fatalf("backdate task(%s): %v", id, err)
|
||||
}
|
||||
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: id + "-session",
|
||||
TaskID: id,
|
||||
AgentProfileID: "agent-1",
|
||||
State: sessionState,
|
||||
StartedAt: sessionUpdated.Add(-time.Hour),
|
||||
UpdatedAt: sessionUpdated,
|
||||
IsPrimary: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskSession(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepository_ListChildCompletionRows_ActiveChildren(t *testing.T) {
|
||||
repo, cleanup := createTestSQLiteRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -327,6 +327,10 @@ func (r *fakeDeleteRepo) DeleteTask(_ context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeDeleteRepo) DeleteExpiredQuickChatTask(context.Context, string, time.Time) (bool, error) {
|
||||
panic("DeleteExpiredQuickChatTask should not be used by delete cascade tests")
|
||||
}
|
||||
|
||||
// REGRESSION (post-review #4): a parent with already-archived children
|
||||
// must still have those children deleted by the cascade. The original
|
||||
// collectTaskTree used ListChildren which filtered archived rows; the
|
||||
|
||||
@@ -375,6 +375,14 @@ func (r *phase4TaskRepo) ListTasksForAutoArchive(context.Context) ([]*models.Tas
|
||||
r.panicNotUsed("ListTasksForAutoArchive")
|
||||
return nil, nil
|
||||
}
|
||||
func (r *phase4TaskRepo) ListExpiredQuickChatTasks(context.Context, time.Time) ([]*models.Task, error) {
|
||||
r.panicNotUsed("ListExpiredQuickChatTasks")
|
||||
return nil, nil
|
||||
}
|
||||
func (r *phase4TaskRepo) DeleteExpiredQuickChatTask(context.Context, string, time.Time) (bool, error) {
|
||||
r.panicNotUsed("DeleteExpiredQuickChatTask")
|
||||
return false, nil
|
||||
}
|
||||
func (r *phase4TaskRepo) CountOpenWatcherCreatedTasks(context.Context, string, string) (int, error) {
|
||||
r.panicNotUsed("CountOpenWatcherCreatedTasks")
|
||||
return 0, nil
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
quickChatIdleRetention = 7 * 24 * time.Hour
|
||||
quickChatExpirationInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
// StartQuickChatExpirationLoop starts a background goroutine that removes
|
||||
// abandoned quick chats after the retention window.
|
||||
func (s *Service) StartQuickChatExpirationLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(quickChatExpirationInterval)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
s.runQuickChatExpiration(ctx, time.Now())
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
s.runQuickChatExpiration(ctx, now)
|
||||
}
|
||||
}
|
||||
}()
|
||||
s.logger.Info("quick-chat expiration loop started",
|
||||
zap.Duration("interval", quickChatExpirationInterval),
|
||||
zap.Duration("retention", quickChatIdleRetention))
|
||||
}
|
||||
|
||||
func (s *Service) runQuickChatExpiration(ctx context.Context, now time.Time) {
|
||||
cutoff := now.UTC().Add(-quickChatIdleRetention)
|
||||
tasks, err := s.tasks.ListExpiredQuickChatTasks(ctx, cutoff)
|
||||
if err != nil {
|
||||
s.logger.Warn("quick-chat expiration: failed to list candidates", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("quick-chat expiration: found candidates", zap.Int("count", len(tasks)))
|
||||
for _, task := range tasks {
|
||||
deleted, err := s.deleteExpiredQuickChatTask(ctx, task.ID, cutoff)
|
||||
if err != nil {
|
||||
s.logger.Warn("quick-chat expiration: failed to delete task",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if !deleted {
|
||||
continue
|
||||
}
|
||||
s.logger.Info("quick-chat expiration: deleted task",
|
||||
zap.String("task_id", task.ID))
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/gitref"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskrepo "github.com/kandev/kandev/internal/task/repository"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
)
|
||||
|
||||
@@ -1006,12 +1007,37 @@ func (s *Service) DeleteTaskWithReason(ctx context.Context, id, reason string) e
|
||||
}
|
||||
|
||||
func (s *Service) deleteTaskWithReason(ctx context.Context, id, reason string) error {
|
||||
_, err := s.deleteTaskWithReasonAndDBDelete(ctx, id, reason, func(ctx context.Context, id string) (bool, error) {
|
||||
if err := s.tasks.DeleteTask(ctx, id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) deleteExpiredQuickChatTask(ctx context.Context, id string, cutoff time.Time) (bool, error) {
|
||||
deleted, err := s.deleteTaskWithReasonAndDBDelete(ctx, id, "", func(ctx context.Context, id string) (bool, error) {
|
||||
return s.tasks.DeleteExpiredQuickChatTask(ctx, id, cutoff)
|
||||
})
|
||||
if errors.Is(err, taskrepo.ErrTaskNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
func (s *Service) deleteTaskWithReasonAndDBDelete(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
reason string,
|
||||
deleteFromDB func(context.Context, string) (bool, error),
|
||||
) (bool, error) {
|
||||
start := time.Now()
|
||||
|
||||
// 1. Get task (sync, fast)
|
||||
task, err := s.tasks.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 2. Gather data needed for cleanup BEFORE delete (sync, fast)
|
||||
@@ -1037,14 +1063,18 @@ func (s *Service) deleteTaskWithReason(ctx context.Context, id, reason string) e
|
||||
}
|
||||
stopTargets, err = s.buildStopTargets(ctx, id, activeSessions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list runtime cleanup inventory: %w", err)
|
||||
return false, fmt.Errorf("list runtime cleanup inventory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete from DB (sync, fast)
|
||||
if err := s.tasks.DeleteTask(ctx, id); err != nil {
|
||||
deleted, err := deleteFromDB(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err))
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if !deleted {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 5. Publish event (sync, fast) - frontend removes task immediately
|
||||
@@ -1068,7 +1098,7 @@ func (s *Service) deleteTaskWithReason(ctx context.Context, id, reason string) e
|
||||
"task deleted", "failed to stop session on task delete", "task cleanup completed")
|
||||
}
|
||||
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CleanupTaskResources tears down a task's runtime resources (container,
|
||||
|
||||
@@ -1200,6 +1200,114 @@ func TestService_DeleteTaskCleansSuccessfulSessionResourcesOnPartialStopFailure(
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_QuickChatExpirationDeletesExpiredCandidates(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
svc.setCleanupDoneForTestHook(make(chan struct{}, 1))
|
||||
quickChatDir := t.TempDir()
|
||||
svc.SetQuickChatDir(quickChatDir)
|
||||
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-expire", Name: "Expire"}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
expiredTaskID := "quick-expired"
|
||||
recentTaskID := "quick-recent"
|
||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
createQuickChatExpirationServiceFixture(t, repo, ctx, expiredTaskID, now.Add(-8*24*time.Hour))
|
||||
createQuickChatExpirationServiceFixture(t, repo, ctx, recentTaskID, now.Add(-time.Hour))
|
||||
expiredSessionDir := filepath.Join(quickChatDir, expiredTaskID+"-session")
|
||||
if err := os.MkdirAll(expiredSessionDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir expired session dir: %v", err)
|
||||
}
|
||||
|
||||
svc.runQuickChatExpiration(ctx, now)
|
||||
waitForCleanupDone(t, svc)
|
||||
|
||||
if _, err := repo.GetTask(ctx, expiredTaskID); err == nil {
|
||||
t.Fatal("expired quick chat should be deleted")
|
||||
}
|
||||
if _, err := repo.GetTask(ctx, recentTaskID); err != nil {
|
||||
t.Fatalf("recent quick chat should remain: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(expiredSessionDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("expired quick-chat directory should be removed, got %v", err)
|
||||
}
|
||||
if !eventBusHasType(eventBus, "task.deleted") {
|
||||
t.Fatalf("expected task.deleted event, got %#v", eventBus.GetPublishedEvents())
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_QuickChatExpirationLoopRunsOnStartup(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
svc.setCleanupDoneForTestHook(make(chan struct{}, 1))
|
||||
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-expire", Name: "Expire"}); err != nil {
|
||||
t.Fatalf("create workspace: %v", err)
|
||||
}
|
||||
taskID := "quick-expired-startup"
|
||||
createQuickChatExpirationServiceFixture(t, repo, ctx, taskID, time.Now().UTC().Add(-8*24*time.Hour))
|
||||
|
||||
svc.StartQuickChatExpirationLoop(ctx)
|
||||
waitForCleanupDone(t, svc)
|
||||
|
||||
if _, err := repo.GetTask(context.Background(), taskID); err == nil {
|
||||
t.Fatal("expired quick chat should be deleted by startup sweep")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_DeleteExpiredQuickChatTaskIgnoresMissingTask(t *testing.T) {
|
||||
svc, _, _ := createTestService(t)
|
||||
|
||||
deleted, err := svc.deleteExpiredQuickChatTask(context.Background(), "missing", time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("deleteExpiredQuickChatTask missing task: %v", err)
|
||||
}
|
||||
if deleted {
|
||||
t.Fatal("missing task should not be reported as deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func createQuickChatExpirationServiceFixture(
|
||||
t *testing.T,
|
||||
repo *sqliterepo.Repository,
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
updatedAt time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
if err := repo.CreateTask(ctx, &models.Task{
|
||||
ID: taskID,
|
||||
WorkspaceID: "ws-expire",
|
||||
Title: taskID,
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: "medium",
|
||||
IsEphemeral: true,
|
||||
CreatedAt: updatedAt.Add(-time.Hour),
|
||||
UpdatedAt: updatedAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTask(%s): %v", taskID, err)
|
||||
}
|
||||
if _, err := repo.DB().ExecContext(ctx,
|
||||
`UPDATE tasks SET created_at = ?, updated_at = ? WHERE id = ?`,
|
||||
updatedAt.Add(-time.Hour), updatedAt, taskID,
|
||||
); err != nil {
|
||||
t.Fatalf("backdate task(%s): %v", taskID, err)
|
||||
}
|
||||
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: taskID + "-session",
|
||||
TaskID: taskID,
|
||||
State: models.TaskSessionStateCompleted,
|
||||
StartedAt: updatedAt.Add(-time.Hour),
|
||||
UpdatedAt: updatedAt,
|
||||
IsPrimary: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskSession(%s): %v", taskID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_DeleteTaskCleansMissingSessionExecutorRowAfterStop(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
+22
-9
@@ -60,6 +60,27 @@ function readAgentProfileId(
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
type QuickChatTask = Awaited<ReturnType<typeof listQuickChatSessions>>["tasks"][number];
|
||||
|
||||
function mapQuickChatSessions(tasks: QuickChatTask[]): AppState["quickChat"]["sessions"] {
|
||||
const quickChatUpdatedAt = (task: QuickChatTask) => Date.parse(task.updated_at ?? "") || 0;
|
||||
|
||||
return (
|
||||
tasks
|
||||
.filter((task) => task.primary_session_id)
|
||||
.filter((task) => task.origin !== "automation_run")
|
||||
// Legacy Next.js path only receives task.updated_at. The Go boot payload
|
||||
// sorts by max(task/session updated_at) and is the authoritative SPA path.
|
||||
.sort((a, b) => quickChatUpdatedAt(b) - quickChatUpdatedAt(a))
|
||||
.map((task) => ({
|
||||
sessionId: task.primary_session_id!,
|
||||
workspaceId: task.workspace_id,
|
||||
name: task.title !== "Quick Chat" ? task.title : undefined,
|
||||
agentProfileId: readAgentProfileId(task.metadata),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function buildBaseState(
|
||||
workspaces: ListWorkspacesResponse,
|
||||
userSettingsResponse: UserSettingsResponse | null,
|
||||
@@ -181,15 +202,7 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
workspaceWorkflows: workflowList.workflows,
|
||||
});
|
||||
|
||||
// Map quick chat tasks to sessions
|
||||
const quickChatSessions = quickChatResponse.tasks
|
||||
.filter((t) => t.primary_session_id) // Only include tasks with active sessions
|
||||
.map((t) => ({
|
||||
sessionId: t.primary_session_id!,
|
||||
workspaceId: t.workspace_id,
|
||||
name: t.title !== "Quick Chat" ? t.title : undefined,
|
||||
agentProfileId: readAgentProfileId(t.metadata),
|
||||
}));
|
||||
const quickChatSessions = mapQuickChatSessions(quickChatResponse.tasks);
|
||||
|
||||
initialState = {
|
||||
...initialState,
|
||||
|
||||
@@ -121,6 +121,8 @@ export async function listQuickChatSessions(workspaceId: string, options?: ApiRe
|
||||
workspace_id: string;
|
||||
primary_session_id?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
origin?: string | null;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
}>(`/api/v1/workspaces/${workspaceId}/tasks?only_ephemeral=true&exclude_config=true`, options);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
defaultAutomationsState,
|
||||
defaultSystemState,
|
||||
} from "./slices";
|
||||
import { getStoredQuickChatNames } from "@/lib/local-storage";
|
||||
|
||||
export const defaultState = {
|
||||
kanban: defaultKanbanState.kanban,
|
||||
@@ -130,6 +131,20 @@ function mergeGitLabFields(
|
||||
};
|
||||
}
|
||||
|
||||
function mergeQuickChatState(initialState: Partial<DefaultState>): DefaultState["quickChat"] {
|
||||
const quickChat = { ...defaultState.quickChat, ...initialState.quickChat };
|
||||
if (!initialState.quickChat?.sessions) return quickChat;
|
||||
|
||||
const storedNames = getStoredQuickChatNames();
|
||||
return {
|
||||
...quickChat,
|
||||
sessions: initialState.quickChat.sessions.map((session) => {
|
||||
const localName = storedNames[session.sessionId];
|
||||
return localName ? { ...session, name: localName } : session;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeInitialState(initialState?: Partial<DefaultState>): DefaultState {
|
||||
if (!initialState) return defaultState;
|
||||
|
||||
@@ -220,7 +235,7 @@ export function mergeInitialState(initialState?: Partial<DefaultState>): Default
|
||||
chatInput: { ...defaultState.chatInput, ...initialState.chatInput },
|
||||
documentPanel: { ...defaultState.documentPanel, ...initialState.documentPanel },
|
||||
systemHealth: { ...defaultState.systemHealth, ...initialState.systemHealth },
|
||||
quickChat: { ...defaultState.quickChat, ...initialState.quickChat },
|
||||
quickChat: mergeQuickChatState(initialState),
|
||||
sessionFailureNotification:
|
||||
initialState.sessionFailureNotification ?? defaultState.sessionFailureNotification,
|
||||
bottomTerminal: { ...defaultState.bottomTerminal, ...initialState.bottomTerminal },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { produce } from "immer";
|
||||
import type { Draft } from "immer";
|
||||
import { hydrateState, hydrateUI } from "./hydrator";
|
||||
import { defaultUIState } from "@/lib/state/slices/ui/ui-slice";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import { defaultState, mergeInitialState } from "@/lib/state/default-state";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
|
||||
function makeDraft(): AppState {
|
||||
@@ -75,6 +75,56 @@ describe("hydrateUI — quick chat name overlay", () => {
|
||||
|
||||
expect(result.quickChat.sessions.map((s) => s.name)).toEqual(["Renamed A", "Original B"]);
|
||||
});
|
||||
|
||||
it("clears stale quick chat sessions when the backend returns none", () => {
|
||||
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
|
||||
draft.quickChat = {
|
||||
isOpen: true,
|
||||
activeSessionId: "stale-session",
|
||||
sessions: [{ sessionId: "stale-session", workspaceId: "ws-1", name: "Stale" }],
|
||||
};
|
||||
hydrateUI(draft, {
|
||||
quickChat: {
|
||||
isOpen: false,
|
||||
activeSessionId: null,
|
||||
sessions: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.quickChat.sessions).toEqual([]);
|
||||
expect(result.quickChat.isOpen).toBe(false);
|
||||
expect(result.quickChat.activeSessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeInitialState — quick chat name overlay", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("overlays locally-renamed names during boot payload merge", () => {
|
||||
window.localStorage.setItem(
|
||||
"kandev.quickChat.names",
|
||||
JSON.stringify({ "sess-boot": "Local boot name" }),
|
||||
);
|
||||
|
||||
const result = mergeInitialState({
|
||||
quickChat: {
|
||||
isOpen: false,
|
||||
activeSessionId: null,
|
||||
sessions: [
|
||||
{ sessionId: "sess-boot", workspaceId: "ws-1", name: "Backend task title" },
|
||||
{ sessionId: "sess-other", workspaceId: "ws-1", name: "Other title" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.quickChat.sessions.map((s) => s.name)).toEqual([
|
||||
"Local boot name",
|
||||
"Other title",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateState — sidebar views from user settings", () => {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
spec: docs/specs/tasks/quick-chat-expiration.md
|
||||
created: 2026-07-03
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Quick Chat Persistence & Expiration
|
||||
|
||||
## Overview
|
||||
|
||||
Restore quick-chat tabs from backend state during SPA boot, then add a backend-owned idle sweeper that deletes only true quick-chat tasks after 7 days of inactivity. The repository owns candidate selection because the filter depends on task metadata, task origin, workflow absence, active session state, and last activity across task/session timestamps. The service layer owns deletion so expiration reuses `Service.DeleteTask` and preserves existing cleanup and `task.deleted` behavior.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### Quick-chat candidate query
|
||||
|
||||
Files:
|
||||
|
||||
- `apps/backend/internal/task/repository/interface.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/task.go`
|
||||
- `apps/backend/internal/task/repository/task_repository_test.go`
|
||||
- `apps/backend/internal/db/dialect/time.go` and `dialect_test.go` if a small `GreatestTimestamp` helper is needed
|
||||
|
||||
Add a task repository method:
|
||||
|
||||
```go
|
||||
ListExpiredQuickChatTasks(ctx context.Context, cutoff time.Time) ([]*models.Task, error)
|
||||
```
|
||||
|
||||
The SQLite query must return only rows where:
|
||||
|
||||
- `t.is_ephemeral = 1`
|
||||
- `COALESCE(t.workflow_id, '') = ''`
|
||||
- `COALESCE(t.origin, '') != models.TaskOriginAutomationRun`
|
||||
- `excludeConfigModePredicate(driver, "t.metadata")`
|
||||
- `t.archived_at IS NULL`
|
||||
- no session for the task has state in `('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')`
|
||||
- dialect-aware `last_activity = max/greatest(t.updated_at, COALESCE(MAX(ts.updated_at), t.updated_at)) < cutoff`
|
||||
|
||||
Order by computed last activity ascending for deterministic deletion and tests.
|
||||
|
||||
### Expiration service
|
||||
|
||||
Files:
|
||||
|
||||
- New `apps/backend/internal/task/service/quick_chat_expiration.go`
|
||||
- `apps/backend/internal/task/service/service_test.go`
|
||||
- `apps/backend/internal/backendapp/main.go`
|
||||
|
||||
Add constants:
|
||||
|
||||
```go
|
||||
const quickChatIdleRetention = 7 * 24 * time.Hour
|
||||
const quickChatExpirationInterval = 24 * time.Hour
|
||||
```
|
||||
|
||||
Add service methods:
|
||||
|
||||
```go
|
||||
func (s *Service) StartQuickChatExpirationLoop(ctx context.Context)
|
||||
func (s *Service) runQuickChatExpiration(ctx context.Context, now time.Time)
|
||||
```
|
||||
|
||||
`runQuickChatExpiration` computes `cutoff := now.Add(-quickChatIdleRetention)`, calls `ListExpiredQuickChatTasks`, logs and returns without deleting on list errors, then iterates candidates and calls `s.DeleteTask(ctx, task.ID)`. A delete failure logs the task ID/error and continues with the rest.
|
||||
|
||||
Wire `services.Task.StartQuickChatExpirationLoop(ctx)` next to `StartAutoArchiveLoop(ctx)` in `apps/backend/internal/backendapp/main.go`.
|
||||
|
||||
### Boot-state quick-chat hydration
|
||||
|
||||
Files:
|
||||
|
||||
- `apps/backend/internal/backendapp/boot_state.go`
|
||||
- `apps/backend/internal/backendapp/boot_state_routes.go`
|
||||
- `apps/backend/internal/backendapp/helpers_test.go`
|
||||
- `apps/web/lib/api/domains/workspace-api.ts`
|
||||
- `apps/web/app/page.tsx`
|
||||
- `apps/web/lib/state/hydration/hydrator.test.ts`
|
||||
|
||||
Add a boot-state helper that populates:
|
||||
|
||||
```json
|
||||
"quickChat": {
|
||||
"isOpen": false,
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "...",
|
||||
"workspaceId": "...",
|
||||
"name": "...",
|
||||
"agentProfileId": "..."
|
||||
}
|
||||
],
|
||||
"activeSessionId": null
|
||||
}
|
||||
```
|
||||
|
||||
The helper should:
|
||||
|
||||
- resolve the active workspace from already-built boot state when possible, otherwise from query/cookie/user settings/first workspace using existing helpers;
|
||||
- fetch all pages of `ListTasksByWorkspace(ctx, workspaceID, "", "", "", page, pageSize, false, false, true, true)` so there is no count cap;
|
||||
- filter in Go to `workflow_id == ""`, `origin != automation_run`, and `primary_session_id != nil`;
|
||||
- use `BatchGetSessionsForTasks` and `GetPrimarySessionInfoForTasks` through `taskDTOsWithSessionInfo` or a small shared helper rather than adding a new endpoint;
|
||||
- compute last activity as `max(task.UpdatedAt, max(session.UpdatedAt))` and sort restored tabs newest first;
|
||||
- derive `agentProfileId` from `task.Metadata[models.MetaKeyAgentProfileID]`;
|
||||
- preserve local display-name overrides through the existing frontend `hydrateUI` quick-chat name overlay.
|
||||
|
||||
Call this helper from SPA boot-state assembly so quick chats restore on `/`, `/tasks`, task-detail, local context routes (`/github`, `/gitlab`, `/jira`, `/linear`, `/stats`), `/office`, and settings routes when an active workspace can be resolved.
|
||||
|
||||
Update the legacy `listQuickChatSessions` response typing and `app/page.tsx` mapping to include `origin`, `updated_at`, and the same automation-run filtering so stale Next-compatible code stays type-correct.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
No new visible controls are required. The frontend behavior change is boot-time state hydration: `QuickChatProvider` already mounts globally, and `hydrateUI` already overlays local quick-chat names from `localStorage`.
|
||||
|
||||
State and tests:
|
||||
|
||||
- Keep `QuickChatState` in `apps/web/lib/state/slices/ui/types.ts` unchanged unless the boot payload needs an explicit helper type.
|
||||
- Extend `apps/web/lib/state/hydration/hydrator.test.ts` if needed to assert that hydrated sessions replace the in-memory list, preserve local names, and clear the modal when the backend returns no sessions.
|
||||
- Add or update `apps/web/src/boot-payload.test.ts` only if TypeScript parsing/shape guards need to understand a narrower quick-chat payload.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** repository expiration query includes quick chats idle longer than cutoff.
|
||||
**File:** `apps/backend/internal/task/repository/task_repository_test.go`
|
||||
**How:** SQLite integration test with backdated `tasks.updated_at` and `task_sessions.updated_at`.
|
||||
|
||||
- **What:** any new timestamp SQL dialect helper emits SQLite and Postgres fragments correctly.
|
||||
**File:** `apps/backend/internal/db/dialect/dialect_test.go`
|
||||
**How:** unit test for the helper only if Task 01 adds one.
|
||||
|
||||
- **What:** repository query excludes active sessions, config-mode chats, automation-run ephemeral tasks, non-ephemeral tasks, archived tasks, and ephemeral tasks with a workflow.
|
||||
**File:** `apps/backend/internal/task/repository/task_repository_test.go`
|
||||
**How:** table-driven SQLite test seeding each row variant.
|
||||
|
||||
- **What:** session activity keeps an old quick-chat task alive.
|
||||
**File:** `apps/backend/internal/task/repository/task_repository_test.go`
|
||||
**How:** set `tasks.updated_at` older than 7 days and primary `task_sessions.updated_at` newer than cutoff; assert no candidate.
|
||||
|
||||
- **What:** expiration uses the service delete path, publishes `task.deleted`, and cleans quick-chat directories through existing async cleanup.
|
||||
**File:** `apps/backend/internal/task/service/service_test.go`
|
||||
**How:** service test with real SQLite repo, `SetQuickChatDir`, `cleanupDoneForTest`, and event-bus assertions.
|
||||
|
||||
- **What:** expiration fails closed on candidate-list error and continues when a single delete fails.
|
||||
**File:** `apps/backend/internal/task/service/quick_chat_expiration_test.go` or `service_test.go`
|
||||
**How:** use a small fake repository/service seam if the real repo cannot simulate the list/delete errors cleanly.
|
||||
|
||||
- **What:** boot payload contains quick-chat sessions ordered by last activity, excluding automation/config tasks.
|
||||
**File:** `apps/backend/internal/backendapp/helpers_test.go`
|
||||
**How:** use `newBootStateTestHarness`, create multiple ephemeral tasks/sessions, backdate rows with SQL, call `bootPayload`, and unmarshal `initialState.quickChat.sessions`.
|
||||
|
||||
- **What:** frontend hydration overlays local quick-chat names onto backend-restored sessions.
|
||||
**File:** `apps/web/lib/state/hydration/hydrator.test.ts`
|
||||
**How:** existing jsdom localStorage tests extended for multiple restored sessions.
|
||||
|
||||
---
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** GIVEN an open quick chat with a message, WHEN the user reloads the page, THEN the quick-chat tab restores and prior history is visible.
|
||||
**File:** `apps/web/e2e/tests/chat/quick-chat.spec.ts`
|
||||
**What to verify:** create a quick chat with `openQuickChatWithAgent`, send a mock-agent message, reload, reopen quick chat with the shortcut, assert the same response text appears without selecting a new agent.
|
||||
|
||||
- **Scenario:** GIVEN multiple quick-chat tabs, WHEN the user reloads, THEN all tabs restore.
|
||||
**File:** `apps/web/e2e/tests/chat/quick-chat.spec.ts`
|
||||
**What to verify:** extend the existing multi-tab test or add a focused test that counts restored tab buttons after reload.
|
||||
|
||||
Backend expiration is covered by Go integration tests rather than E2E because it has no new visible controls and depends on a 7-day idle clock.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Waves
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [task-01-backend-candidate-query](task-01-backend-candidate-query.md)
|
||||
- [x] [task-03-boot-hydration](task-03-boot-hydration.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [task-02-backend-expiration-service](task-02-backend-expiration-service.md)
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [ ] [task-04-e2e-and-verification](task-04-e2e-and-verification.md)
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Confirm during implementation whether quick-chat messages update `tasks.updated_at`, `task_sessions.updated_at`, or only message/turn rows. The planned candidate query and hydration sort use `MAX(task.updated_at, session.updated_at)`, so the feature remains correct as long as at least one of those rows moves on activity.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: "01-backend-candidate-query"
|
||||
title: "Backend candidate query"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/quick-chat-expiration.md"
|
||||
---
|
||||
|
||||
# Task 01: Backend Candidate Query
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `repository.TaskRepository` exposes `ListExpiredQuickChatTasks(ctx, cutoff)` and SQLite implements it.
|
||||
- The query matches only true quick chats: ephemeral, no workflow, not archived, not config-mode, not automation-run origin, no active sessions.
|
||||
- Last activity uses the greater of `tasks.updated_at` and newest `task_sessions.updated_at`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test ./internal/task/repository -run 'TestSQLiteRepository_ListExpiredQuickChatTasks'`
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/task/repository/interface.go`
|
||||
- `apps/backend/internal/task/repository/sqlite/task.go`
|
||||
- `apps/backend/internal/task/repository/task_repository_test.go`
|
||||
- Optional `apps/backend/internal/db/dialect/time.go`
|
||||
- Optional `apps/backend/internal/db/dialect/dialect_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: Data model, API surface, Failure modes, Scenarios.
|
||||
- Existing patterns: `ListTasksForAutoArchive` in `apps/backend/internal/task/repository/sqlite/task.go`; `excludeConfigModePredicate` in the same file; active session state filters in `apps/backend/internal/task/repository/sqlite/session.go`; SQL portability helpers in `apps/backend/internal/db/dialect/`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Update this task status to `done`, update the Wave 1 checkbox in `plan.md`, and report the files changed, tests run, and any discovered timestamp behavior.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: "02-backend-expiration-service"
|
||||
title: "Backend expiration service"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-backend-candidate-query"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/quick-chat-expiration.md"
|
||||
---
|
||||
|
||||
# Task 02: Backend Expiration Service
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The task service runs a daily quick-chat expiration loop with a hardcoded 7-day idle retention window.
|
||||
- Each expired candidate is deleted through `Service.DeleteTask`, preserving existing workspace directory cleanup and `task.deleted` publishing.
|
||||
- Candidate-list errors delete nothing; individual delete failures are logged and do not stop later candidates.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test ./internal/task/service -run 'TestService_QuickChatExpiration|TestService_DeleteTask'`
|
||||
- `cd apps/backend && go test ./internal/backendapp -run 'Test.*QuickChat'` if startup wiring gets focused coverage there.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- New `apps/backend/internal/task/service/quick_chat_expiration.go`
|
||||
- `apps/backend/internal/task/service/service_test.go`
|
||||
- Optional focused test file `apps/backend/internal/task/service/quick_chat_expiration_test.go`
|
||||
- `apps/backend/internal/backendapp/main.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01 must land first so the service can call `ListExpiredQuickChatTasks`.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: What, State machine, Failure modes, Persistence guarantees.
|
||||
- Existing patterns: `StartAutoArchiveLoop` / `runAutoArchive` in `apps/backend/internal/task/service/auto_archive.go`; `DeleteTask` cleanup path in `apps/backend/internal/task/service/service_tasks.go`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Update this task status to `done`, update the Wave 2 checkbox in `plan.md`, and report the files changed, tests run, and any cleanup/event behavior verified.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: "03-boot-hydration"
|
||||
title: "Boot hydration"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/quick-chat-expiration.md"
|
||||
---
|
||||
|
||||
# Task 03: Boot Hydration
|
||||
|
||||
## Acceptance
|
||||
|
||||
- SPA boot payload includes `initialState.quickChat.sessions` for the active workspace whenever an active workspace can be resolved.
|
||||
- Restored sessions exclude config-mode and automation-run ephemeral tasks, require a primary session, and are ordered by newest last activity first.
|
||||
- Frontend hydration keeps existing local quick-chat rename overlays and remains type-correct.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test ./internal/backendapp -run 'TestBoot.*QuickChat|TestBootPayload'`
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run lib/state/hydration/hydrator.test.ts src/boot-payload.test.ts`
|
||||
- `cd apps/web && pnpm run typecheck`
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/backendapp/boot_state.go`
|
||||
- `apps/backend/internal/backendapp/boot_state_routes.go`
|
||||
- `apps/backend/internal/backendapp/helpers_test.go`
|
||||
- `apps/web/lib/api/domains/workspace-api.ts`
|
||||
- `apps/web/app/page.tsx`
|
||||
- `apps/web/lib/state/hydration/hydrator.test.ts`
|
||||
- Optional `apps/web/src/boot-payload.test.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. This can proceed in parallel with Task 01 because it can use existing `ListTasksByWorkspace` plus session enrichment.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec sections: API surface, Persistence guarantees, Scenarios.
|
||||
- Existing patterns: home/tasks boot-state helpers in `apps/backend/internal/backendapp/boot_state.go` and `boot_state_routes.go`; quick-chat local rename overlay in `apps/web/lib/state/hydration/hydrator.ts`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Update this task status to `done`, update the Wave 1 checkbox in `plan.md`, and report the files changed, tests run, and any routes where quick-chat hydration intentionally no-ops.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: "04-e2e-and-verification"
|
||||
title: "E2E and verification"
|
||||
status: pending
|
||||
wave: 3
|
||||
depends_on: ["02-backend-expiration-service", "03-boot-hydration"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/quick-chat-expiration.md"
|
||||
---
|
||||
|
||||
# Task 04: E2E and Verification
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Quick-chat E2E coverage proves a reload restores at least one existing quick-chat tab with prior history.
|
||||
- Multi-tab reload behavior is covered without adding a count cap.
|
||||
- Final verification runs targeted backend/frontend tests plus format/typecheck/lint commands appropriate for changed files.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/web && pnpm e2e -- tests/chat/quick-chat.spec.ts`
|
||||
- `make -C apps/backend test`
|
||||
- `cd apps/web && pnpm run typecheck`
|
||||
- `cd apps && pnpm --filter @kandev/web lint`
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/chat/quick-chat.spec.ts`
|
||||
- Test helpers only if the reload flow needs a reusable selector/helper.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 02 for backend expiration coverage.
|
||||
- Task 03 for reload hydration behavior.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec scenarios for reload restoration and 12-tab/no-cap behavior.
|
||||
- Existing quick-chat E2E helpers in `apps/web/e2e/tests/chat/quick-chat.spec.ts`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Update this task status to `done`, update the Wave 3 checkbox in `plan.md`, and report tests run, any skipped checks, and residual risks.
|
||||
@@ -49,6 +49,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
|
||||
| [runtime-cleanup](tasks/runtime-cleanup.md) | draft |
|
||||
| [link-existing-task-github-issue](tasks/link-existing-task-github-issue.md) | building |
|
||||
| [multi-branch](tasks/multi-branch/spec.md) | shipped |
|
||||
| [quick-chat-expiration](tasks/quick-chat-expiration.md) | draft |
|
||||
|
||||
## agents/ — agent governance
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
status: draft
|
||||
created: 2026-07-02
|
||||
owner: José Almeida
|
||||
---
|
||||
|
||||
# Quick Chat Persistence & Expiration
|
||||
|
||||
## Why
|
||||
A user reported her quick chats "disappearing much faster." Quick chats are
|
||||
ephemeral tasks whose open-tab list lives only in the in-memory frontend store,
|
||||
so a page reload drops every tab even though the underlying task, session, and
|
||||
workspace directory still exist on the backend. Those now-unreachable rows and
|
||||
directories never get cleaned up, so they leak indefinitely. Users want their
|
||||
recent quick chats to survive a reload, and the system needs a bound so
|
||||
abandoned quick chats don't accumulate forever.
|
||||
|
||||
## What
|
||||
- Open quick-chat tabs SHALL be restored after a full page reload, reconstructed
|
||||
from backend state rather than client-only memory.
|
||||
- A quick chat that has been idle longer than the retention window SHALL be
|
||||
deleted automatically (task row, its sessions, and its workspace directory),
|
||||
the same as an explicit close.
|
||||
- The default retention window is **7 days** of inactivity, applied per quick
|
||||
chat based on its last activity. The window is a hardcoded constant; it is not
|
||||
user-configurable in this iteration.
|
||||
- Expiration SHALL reuse the existing quick-chat delete path so that workspace
|
||||
directory cleanup and task-lifecycle events fire exactly as they do for a
|
||||
user-initiated close.
|
||||
- Only quick chats expire. Config-mode chats, automation-run ephemeral tasks,
|
||||
and non-ephemeral kanban/office tasks are untouched.
|
||||
- A quick chat that is actively in use (recent message or running session)
|
||||
SHALL NOT expire, regardless of how old it is.
|
||||
|
||||
## Data model
|
||||
No new tables. This feature operates on existing `tasks` and `task_sessions`
|
||||
rows. A **quick chat** is precisely the set of tasks matching:
|
||||
|
||||
```
|
||||
tasks
|
||||
is_ephemeral = 1
|
||||
workflow_id = '' -- ephemeral tasks carry no workflow
|
||||
origin != 'automation_run' -- excludes run-mode ephemeral tasks
|
||||
metadata.config_mode IS NOT 1 -- excludes config-mode settings chats
|
||||
archived_at IS NULL
|
||||
```
|
||||
|
||||
This mirrors the existing list filters: `onlyEphemeral = true` plus the
|
||||
`excludeConfigModePredicate` (`json_extract(metadata,'$.config_mode') IS NOT 1`,
|
||||
`internal/task/repository/sqlite/task.go:62`) plus an `origin != 'automation_run'`
|
||||
guard. The `origin` guard distinguishes user-started quick chats (`origin =
|
||||
'manual'`) from automation run-mode ephemeral tasks.
|
||||
|
||||
**Last-activity timestamp** for a quick chat is defined as:
|
||||
|
||||
```
|
||||
last_activity = MAX(task.updated_at, MAX(session.updated_at) over its task_sessions)
|
||||
```
|
||||
|
||||
Using the greater of the task row and its newest session row makes the metric
|
||||
robust to whichever row a message/turn bumps. A quick chat expires when
|
||||
`now - last_activity > 7 days`.
|
||||
|
||||
## API surface
|
||||
- **Hydration (read):** reuse the existing workspace task listing rather than a
|
||||
new endpoint. The frontend fetches quick chats via
|
||||
`ListTasksByWorkspace(workspaceId, workflowID="", …, onlyEphemeral=true,
|
||||
excludeConfig=true)` and filters out `origin == "automation_run"`, then
|
||||
rebuilds the `quickChat.sessions` store slice (one tab per returned task, in
|
||||
most-recently-active-first order). Each restored tab needs
|
||||
`{ sessionId, workspaceId, agentProfileId }`, all resolvable from the task and
|
||||
its primary session. The persisted display name is already available via
|
||||
`getStoredQuickChatName` / `setStoredQuickChatName`.
|
||||
- **Deletion (expiration):** no new external contract. Expiration runs as a
|
||||
backend background sweep that calls the existing `Service.DeleteTask` path
|
||||
(which invokes `cleanupQuickChatDirs`, `internal/task/service/service_tasks.go:1402`,
|
||||
and publishes `task.deleted`). Deleted quick chats propagate to any connected
|
||||
client through the existing `task.deleted` WS handler.
|
||||
|
||||
## State machine
|
||||
A quick chat task is created `active`, and reaches a terminal `deleted` state via
|
||||
one of these triggers (this feature adds only the last one):
|
||||
|
||||
| Trigger | Actor | Existing? |
|
||||
|---|---|---|
|
||||
| User closes a tab (confirm) | user | yes (`use-quick-chat-modal.ts` `handleConfirmClose`) |
|
||||
| Rapid re-pick supersedes an in-flight start | frontend | yes (`useAgentSelection` orphan cleanup) |
|
||||
| Agent profile deleted | user | yes (`DeleteEphemeralTasksByAgentProfile`) |
|
||||
| **Idle longer than retention window** | **background sweep** | **new** |
|
||||
|
||||
All four converge on `Service.DeleteTask`, so directory cleanup and event
|
||||
publishing are identical.
|
||||
|
||||
## Failure modes
|
||||
- **Last-activity query fails / returns error:** the sweep fails closed — it
|
||||
deletes nothing that pass and logs a warning. A transient DB error must never
|
||||
cause an over-broad delete.
|
||||
- **A single quick chat delete fails** (dir busy, session stop error): the sweep
|
||||
logs the failure, leaves that task in place, and continues with the rest. One
|
||||
bad row does not abort the batch. The next sweep retries it.
|
||||
- **Hydration list query fails on boot:** quick chat tabs render empty (current
|
||||
behavior); the app is otherwise unaffected. No error toast is required — this
|
||||
degrades to today's behavior.
|
||||
- **A hydrated tab's backend session is already stopped/gone** (e.g. backend was
|
||||
restarted between reloads): the tab still restores and shows its history;
|
||||
sending a new message follows the normal resume/relaunch path. A tab whose
|
||||
task no longer exists is simply not returned by the list and thus not shown.
|
||||
- **Clock skew / non-monotonic time:** expiration compares stored timestamps to
|
||||
`now`; it does not assume monotonic wall clock beyond the 7-day granularity.
|
||||
|
||||
## Persistence guarantees
|
||||
- **Survives reload:** open quick-chat tabs (restored from backend), quick-chat
|
||||
task rows, sessions, and workspace directories — until they expire or are
|
||||
closed.
|
||||
- **Survives backend restart:** quick-chat task rows and directories (subject to
|
||||
the same 7-day idle expiration). Tab restoration on the client depends only on
|
||||
the backend list, so it works across a backend restart as long as the task row
|
||||
still exists.
|
||||
- **Does NOT survive:** a quick chat idle > 7 days (deleted on the next sweep).
|
||||
The in-memory `quickChat.sessions` slice remains client-only and is rebuilt
|
||||
from the backend on each load rather than persisted to `localStorage`.
|
||||
- **Retention window:** 7 days of inactivity, hardcoded. Sweep cadence is an
|
||||
implementation detail (piggy-backing on an existing periodic job is
|
||||
acceptable) but SHALL run at least daily so expiry is observed within roughly
|
||||
a day of the window elapsing.
|
||||
|
||||
## Scenarios
|
||||
- **GIVEN** an open quick chat with one or more tabs, **WHEN** the user reloads
|
||||
the page, **THEN** the same tabs reappear, ordered most-recently-active first,
|
||||
each showing its prior chat history and persisted name.
|
||||
- **GIVEN** a quick-chat task whose `last_activity` is 8 days ago, **WHEN** the
|
||||
expiration sweep runs, **THEN** the task, its sessions, and its workspace
|
||||
directory are deleted, a `task.deleted` event is published, and the tab
|
||||
disappears from any connected client.
|
||||
- **GIVEN** a quick-chat task with a message sent 10 minutes ago (older than 7
|
||||
days since creation but recently active), **WHEN** the sweep runs, **THEN** the
|
||||
task is retained.
|
||||
- **GIVEN** a config-mode chat (`metadata.config_mode = 1`) idle for 30 days,
|
||||
**WHEN** the sweep runs, **THEN** it is NOT deleted.
|
||||
- **GIVEN** an automation-run ephemeral task (`origin = 'automation_run'`) idle
|
||||
for 30 days, **WHEN** the sweep runs, **THEN** it is NOT deleted.
|
||||
- **GIVEN** a non-ephemeral kanban task idle for a year, **WHEN** the sweep runs,
|
||||
**THEN** it is NOT deleted.
|
||||
- **GIVEN** the last-activity query errors, **WHEN** the sweep runs, **THEN** no
|
||||
quick chats are deleted and a warning is logged.
|
||||
- **GIVEN** two idle quick chats where deleting the first fails, **WHEN** the
|
||||
sweep runs, **THEN** the second is still evaluated and deleted, and the failure
|
||||
on the first is logged.
|
||||
- **GIVEN** the workspace has 12 quick chats within the retention window, **WHEN**
|
||||
the user reloads, **THEN** all 12 tabs restore (no count cap in this
|
||||
iteration).
|
||||
|
||||
## Out of scope
|
||||
- A user-configurable retention window (per-workspace or global setting). Ships
|
||||
as a hardcoded 7-day constant; revisit only if requested.
|
||||
- A hard count cap on number of quick chats (idle TTL only). If clutter becomes
|
||||
a problem, a cap is a follow-up.
|
||||
- Persisting quick-chat tabs to `localStorage`. Backend is the source of truth;
|
||||
no client-side durable store.
|
||||
- Archiving/soft-deleting expired quick chats — they are hard-deleted, matching
|
||||
the existing close behavior.
|
||||
- Changing config-mode chat, automation-run, or kanban/office task lifecycles.
|
||||
|
||||
## Open questions
|
||||
- Does a message/turn in a quick chat currently bump `tasks.updated_at`, or only
|
||||
`task_sessions.updated_at` / turn rows? The `MAX(task, session)` definition is
|
||||
chosen to be correct either way, but the implementation should confirm which
|
||||
row actually moves so the sweep query reads the right column(s).
|
||||
Reference in New Issue
Block a user