Files
kandev/apps/web/hooks/domains/session/use-queue.ts
T
José Almeida 0743477d48 feat: persist N-entry FIFO message queue (#864)
* feat(orchestrator): persist N-entry FIFO message queue

Replaces the single-slot, latest-wins, in-memory queue with a SQLite-backed
FIFO queue (default cap 10 per session, configurable via
KANDEV_QUEUE_MAX_PER_SESSION). Drain stays one-per-turn so each entry gets its
own user message and `on_turn_*` workflow cycle. Inter-task `message_task_kandev`
returns a structured `queue_full` error with sender attribution when the
target session is saturated, so the calling LLM can decide whether to retry.

Surfaces a new WS action `message.queue.remove` for single-entry deletion;
`message.queue.cancel` now means clear-all. Edit/remove are keyed on the
entry's UUID, returning `entry_not_found` when atomic-take won the drain race.

* feat(web): N-entry queue state, API, and optimistic hook

Replaces the single-slot QueueStatus with an ordered `entries[]` per session
plus capacity meta (`count`, `max`). API exposes typed `QueueFullError` and
`QueueEntryNotFoundError`; the hook surfaces optimistic edit/remove with
drain-race rollback (refetch on `entry_not_found`).

* feat(web): inline ghost-message queue UI with sender chips

Replaces the floating QueuedMessageIndicator overlay with a stack of ghost
messages rendered above the chat input. Each ghost shows a sender chip
(user / agent) and per-entry edit/remove. A small "n/max queued · clear all"
pill summarizes capacity. Inter-task entries are visually distinct and edit
is gated to the original sender.

* fix(queue): address review — auth guard, TOCTOU, error UX

- Add `messagequeue.QueuedByUser` / `QueuedByAgent` constants and centralize
  the `queue_full` error code so the WS and MCP handlers stay in sync.
- WS `message.queue.{add,append,update}` default empty user_id to QueuedByUser
  so the UpdateContent ownership guard always runs against a non-empty owner;
  inter-task entries (`queued_by="agent"`) can no longer be overwritten by
  user-initiated edits even when `user_id` is omitted from the payload.
- memoryRepository: hold the mutex through the whole append-or-insert path
  (prior split released the lock between tail-check and Insert, allowing two
  same-sender callers to race past coalescing). TransferSession now shifts
  source positions past the destination's max so transferred entries always
  drain after pre-existing destination entries, matching SQLite semantics.
- Frontend: surface edit/clear/remove failures via `sonner` toasts (drain race
  → "message already sent"); exit edit mode on failure instead of leaving the
  textarea silently stuck. Hide the remove button on read-only (agent) ghosts.
- `useQueue.clearAll` no longer derives `max` from pre-clear entry count;
  resets to `metaMax ?? 0` so the next status_changed event lands authoritative
  capacity.
- `senderKindOf` drops the unreachable metadata fallback; route on `queued_by`
  alone since `dispatchTaskMessage` always sets it.
- Test loggers now write to stderr per CLAUDE.md "stderr for logs" guideline.

* test(web): align queue e2e selectors with new ghost-message UI

The ghost-message rewrite renamed button titles ("Cancel queued message"
→ "Remove queued message", "Edit message" → "Edit queued message") and
introduced a `data-testid="queued-ghost-list"` container. The
`message-queue.spec.ts` Playwright tests still pointed at the old titles.

* fix(queue): address second review pass — auth, drain race, tests

- WS handlers (`add` / `append` / `update`) now reject `user_id="agent"`
  outright, closing the gap a hostile WS client could exploit by
  impersonating the inter-task sender identity to overwrite agent
  entries (greptile P1).
- WS `update` / `remove` now require `session_id` so the post-mutation
  `MessageQueueStatusChanged` broadcast can never be silently skipped.
- `sqliteRepository.TakeHead` checks `RowsAffected()` after DELETE;
  when two concurrent take callers race past the busy-timeout the
  loser sees 0 affected rows and now returns `nil, nil` instead of
  the already-drained message (would otherwise dispatch the same
  prompt twice).
- `Service.GetStatus` returns `Entries: []` instead of `nil` on the
  error path so the wire shape stays consistent (frontend already
  guards but strict consumers wouldn't tolerate the type mismatch).
- New tests: `queue-api.test.ts` covers `rethrowQueueError` mappings
  (queue_full / entry_not_found / generic / Error pass-through);
  `session-slice.upsert.test.ts` covers `setQueueEntries`,
  `removeQueueEntry`, and `clearQueueStatus`. Backend gains an
  agent-impersonation handler test.
- `rethrowQueueError` now surfaces `WSError.message` for non-queue
  errors instead of stringifying the whole object.
- Cleanup: replace unused `a, c := ...` discards in service_test
  with `_, _ = ...`.

* fix(queue): scope update/remove by session_id (cross-session deletion guard)

The MCP `queue_full` error payload deliberately discloses sibling-task
entry IDs (so the calling agent's LLM can reason about the wedge state).
Without a session_id filter on `DeleteByID` / `UpdateContent`, a hostile
agent that received that payload could call `wsRemoveEntry` /
`wsUpdateMessage` against another task's entries and delete or rewrite
them — only the per-entry UUID was being checked.

- `Repository.UpdateContent` and `Repository.DeleteByID` now require
  `sessionID` and add `AND session_id = ?` to the SQL guard. The memory
  repo mirrors the scope (lookup keyed on session before the entry scan).
- `Service.UpdateMessage` / `Service.RemoveEntry` take `sessionID`; the
  WS handlers thread `req.SessionID` through (already validated as
  non-empty in the previous fixup).
- TypeScript: `session_id` is now required on `updateQueuedMessage` /
  `removeQueuedEntry` params so callers fail at compile time, matching
  the backend contract.
- Tests cover the cross-session attack: `RemoveEntry`/`UpdateMessage`
  with an attacker-supplied session_id but a real entry id from another
  session must return `ErrEntryNotFound` and leave the victim entry
  untouched. SQLite repo gets parallel coverage.

* fix(queue): propagate transfer error + tighten WSError narrowing

- `Service.TransferSession` now returns an error so callers can fail
  closed instead of silently leaving entries orphaned on the previous
  session. The two existing call sites pick the right policy:
  `switchSessionForStep` (reusing an existing session) propagates the
  error so the workflow switch aborts; the new-session path logs and
  continues, since the new session is already committed and rolling
  back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
  without a `code` field are still mapped to a meaningful `Error.message`
  via `rethrowQueueError` (instead of "[object Object]"). Native
  `Error` instances explicitly skip the WS-error branch so they pass
  through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
  so a missing throw fails the test instead of silently passing.

* fix(queue): check TransferSession error in service tests

The TransferSession signature change in 83abe6d7 broke errcheck on two
test sites that hadn't been updated. Wrap them in require.NoError.
2026-05-11 22:40:36 +01:00

192 lines
5.7 KiB
TypeScript

import { useEffect, useCallback } from "react";
import { useAppStore } from "@/components/state-provider";
import {
queueMessage,
clearQueue,
getQueueStatus,
updateQueuedMessage,
removeQueuedEntry,
QueueEntryNotFoundError,
} from "@/lib/api/domains/queue-api";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
const EMPTY_ENTRIES: QueuedMessage[] = [];
export type MessageAttachment = {
type: string;
data: string;
mime_type: string;
};
/** Selectors over the queue slice for one session. */
function useQueueState(sessionId: string | null) {
const entries = useAppStore((state) =>
sessionId ? (state.queue.bySessionId[sessionId] ?? EMPTY_ENTRIES) : EMPTY_ENTRIES,
);
const meta = useAppStore((state) =>
sessionId ? state.queue.metaBySessionId[sessionId] : undefined,
);
const isLoading = useAppStore((state) =>
sessionId ? (state.queue.isLoading[sessionId] ?? false) : false,
);
const setQueueEntries = useAppStore((state) => state.setQueueEntries);
const removeQueueEntry = useAppStore((state) => state.removeQueueEntry);
const setQueueLoading = useAppStore((state) => state.setQueueLoading);
return { entries, meta, isLoading, setQueueEntries, removeQueueEntry, setQueueLoading };
}
type QueueActionsArgs = {
sessionId: string | null;
setQueueEntries: ReturnType<typeof useQueueState>["setQueueEntries"];
removeQueueEntry: ReturnType<typeof useQueueState>["removeQueueEntry"];
setQueueLoading: ReturnType<typeof useQueueState>["setQueueLoading"];
metaMax: number | undefined;
};
/** Build an action set bound to the supplied session + slice setters. */
function useQueueActions({
sessionId,
setQueueEntries,
removeQueueEntry,
setQueueLoading,
metaMax,
}: QueueActionsArgs) {
const refetch = useCallback(
async (sid: string) => {
try {
setQueueLoading(sid, true);
const status = await getQueueStatus(sid);
setQueueEntries(sid, status.entries ?? [], { count: status.count, max: status.max });
} finally {
setQueueLoading(sid, false);
}
},
[setQueueEntries, setQueueLoading],
);
const queue = useCallback(
async (
taskId: string,
content: string,
model?: string,
planMode?: boolean,
attachments?: MessageAttachment[],
) => {
if (!sessionId) return;
setQueueLoading(sessionId, true);
try {
await queueMessage({
session_id: sessionId,
task_id: taskId,
content,
model,
plan_mode: planMode,
attachments,
});
await refetch(sessionId);
} finally {
setQueueLoading(sessionId, false);
}
},
[sessionId, refetch, setQueueLoading],
);
const clearAll = useCallback(async () => {
if (!sessionId) return;
setQueueLoading(sessionId, true);
try {
await clearQueue(sessionId);
// Reset to a neutral capacity snapshot; the next status_changed event
// will replace it with the authoritative server value. Using the
// pre-clear entry count as a fallback for `max` was wrong (it would
// pretend the cap equals "however many were queued").
setQueueEntries(sessionId, [], { count: 0, max: metaMax ?? 0 });
} finally {
setQueueLoading(sessionId, false);
}
}, [sessionId, setQueueEntries, setQueueLoading, metaMax]);
const editEntry = useCallback(
async (entryId: string, content: string, attachments?: MessageAttachment[]) => {
if (!sessionId) return;
try {
await updateQueuedMessage({
session_id: sessionId,
entry_id: entryId,
content,
attachments,
});
await refetch(sessionId);
} catch (err) {
if (err instanceof QueueEntryNotFoundError) {
await refetch(sessionId);
}
throw err;
}
},
[sessionId, refetch],
);
const removeEntry = useCallback(
async (entryId: string) => {
if (!sessionId) return;
removeQueueEntry(sessionId, entryId);
try {
await removeQueuedEntry({ session_id: sessionId, entry_id: entryId });
} catch (err) {
if (err instanceof QueueEntryNotFoundError) return;
await refetch(sessionId);
throw err;
}
},
[sessionId, refetch, removeQueueEntry],
);
return { refetch, queue, clearAll, editEntry, removeEntry };
}
/**
* Reactive view over the per-session message queue plus optimistic mutators.
*
* - `entries` — ordered FIFO list (head at index 0) drained one-per-turn
* - `count` / `max` — capacity snapshot from server
* - Edit and remove rely on entry-level UUIDs: when a drain wins the race, the
* server returns `entry_not_found` and we refetch to resync the local list.
*/
export function useQueue(sessionId: string | null) {
const state = useQueueState(sessionId);
const { entries, meta, isLoading } = state;
const { refetch, queue, clearAll, editEntry, removeEntry } = useQueueActions({
sessionId,
setQueueEntries: state.setQueueEntries,
removeQueueEntry: state.removeQueueEntry,
setQueueLoading: state.setQueueLoading,
metaMax: meta?.max,
});
useEffect(() => {
if (!sessionId) return;
void refetch(sessionId).catch((err) => {
console.error("Failed to fetch queue status:", err);
});
}, [sessionId, refetch]);
const refetchBound = useCallback(
() => (sessionId ? refetch(sessionId) : Promise.resolve()),
[sessionId, refetch],
);
return {
entries,
count: meta?.count ?? entries.length,
max: meta?.max ?? 0,
isFull: meta ? meta.count >= meta.max && meta.max > 0 : false,
isLoading,
queue,
clearAll,
editEntry,
removeEntry,
refetch: refetchBound,
};
}