merge: slice 20 — For You priority windowing (Option 3 server-side attention query) (release-0.3.2)

This commit is contained in:
mvschwarz committed 2026-05-18 13:09:44 -07:00
commit becce5e932
13 files changed
+1596 -29

No files matched your search

@@ -256,6 +256,19 @@ export class QueueRepository {
this.validateRig = opts?.validateRig ?? (() => true);
this.transport = opts?.transport;
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
// OPR.0.3.2.20 — register the EXACT human-seat regex predicate as
// a SQLite function so the attention query can apply the strict
// check BEFORE LIMIT. LIKE / GLOB patterns are supersets that
// would let malformed rows (e.g., 'human-@kernel' — empty name
// segment) occupy the LIMIT window and hide valid attention items
// behind them (guard re-verify-3 qitem-20260518193005 BLOCKER 1).
// better-sqlite3 db.function is idempotent; safe to call once at
// construction.
db.function("is_human_seat_session", { deterministic: true }, (value: unknown) => {
if (typeof value !== "string") return 0;
return /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/.test(value) ? 1 : 0;
});
}
/**
@@ -979,6 +992,92 @@ export class QueueRepository {
return rows.map((r) => this.rowToItem(r));
}
/**
* OPR.0.3.2.20 — durable attention-class query.
*
* Returns OPEN attention-class qitems (the source of truth for the
* For You Action-required + Approval lenses) by pushing the
* attention predicate INTO the SQL WHERE clause so the LIMIT
* applies AFTER attention filtering. This makes the result
* window-INDEPENDENT by construction: an old human-gate item
* cannot be evicted past LIMIT by routine open qitems even when
* there are >>LIMIT of them. (Guard verdict qitem-20260518190827
* BLOCKER 1 — the prior fetch-then-filter approach in the route
* could still hide attention items behind ATTENTION_FETCH_BOUND
* newer routine open qitems.)
*
* Attention predicate in SQL (mirror of mission-control read
* layer + the route-level `isAttentionItem`):
* tier = 'human-gate' (approval)
* OR destination_session matches human-seat regex (action-required)
*
* SQLite has no native regex; LIKE patterns are used as a
* SUPER-SET (every regex match also matches one of the LIKE
* patterns). Callers can refine in JS with isAttentionItem if
* they need strict regex semantics — but for the LIMIT-pushdown
* guarantee, the SQL superset is what matters: NO attention item
* is filtered out by the SQL stage.
*
* Default open state set: pending|in-progress|blocked. Caller may
* override via `state`.
*/
listAttention(opts?: {
limit?: number;
state?: QueueState | QueueState[];
destinationSession?: string;
sourceSession?: string;
targetRepo?: string;
}): QueueItem[] {
const limit = opts?.limit ?? 100;
const states = opts?.state
? Array.isArray(opts.state) ? opts.state : [opts.state]
: ["pending" as QueueState, "in-progress" as QueueState, "blocked" as QueueState];
// Compose the WHERE clause: state-set + attention predicate +
// optional scope filters (mirrors list() composition so
// `attention=1` query params remain composable with
// destinationSession/sourceSession/targetRepo — guard re-verify
// qitem-20260518192210 BLOCKER 1).
const statePlaceholders = states.map(() => "?").join(", ");
// The attention predicate is EXACT in SQL (guard re-verify-3
// qitem-20260518193005 BLOCKER 1): is_human_seat_session evaluates
// the strict regex registered in the QueueRepository constructor.
// Malformed rows that would have slipped through a LIKE superset
// (e.g., 'human-@kernel' — empty name segment) are rejected at
// the SQL stage, BEFORE LIMIT, so they cannot saturate the LIMIT
// window and hide valid attention items.
const conditions: string[] = [
`state IN (${statePlaceholders})`,
`(
tier = 'human-gate'
OR is_human_seat_session(destination_session) = 1
)`,
];
const params: unknown[] = [...states];
if (opts?.destinationSession) {
conditions.push("destination_session = ?");
params.push(opts.destinationSession);
}
if (opts?.sourceSession) {
conditions.push("source_session = ?");
params.push(opts.sourceSession);
}
if (opts?.targetRepo && this.hasTargetRepoColumn) {
conditions.push("target_repo = ?");
params.push(opts.targetRepo);
}
params.push(limit);
const sql = `
SELECT * FROM queue_items
WHERE ${conditions.join(" AND ")}
ORDER BY ts_created DESC
LIMIT ?
`;
const rows = this.db.prepare(sql).all(...params) as QueueItemRow[];
return rows.map((r) => this.rowToItem(r));
}
/**
* Find qitems whose `closure_required_at` is past now. Used by watchdog;
* does NOT itself emit events — callers decide whether to nudge or escalate.
+79 -4
View File
@@ -18,6 +18,27 @@ import type { OutboxHandler } from "../domain/outbox-handler.js";
* Hot-potato strict-rejection happens in the domain layer; routes surface
* structured errors with the validReasons enum so CLIs can render help.
*/
/**
* OPR.0.3.2.20 — attention-class predicate for the `/list?attention=1`
* filter. Mirrors the mission-control read layer's semantics so the
* For You Action-required + Approval lenses agree with the
* single-pane view:
*
* - approval class → tier === "human-gate"
* - action-required → destinationSession matches
* /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/
*
* Exported so the predicate is a discrete, testable surface. The route
* layer composes this with the open-state default so only unresolved
* items appear — closed/done attention items are not surfaced.
*/
export function isAttentionItem(q: { tier: string | null; destinationSession: string }): boolean {
if (q.tier === "human-gate") return true;
return /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/.test(q.destinationSession ?? "");
}
export function queueRoutes(): Hono {
const app = new Hono();
@@ -302,15 +323,69 @@ export function queueRoutes(): Hono {
});
// GET /list — list with filters. MUST precede /:qitemId so the literal path wins.
//
// OPR.0.3.2.20 — `?attention=1` filter for the For You priority
// windowing slice. Returns OPEN attention-class qitems (the durable
// source of truth for the UI Action-required + Approval lenses) so
// those surfaces don't depend on the lossy ephemeral client event
// FIFO. Class membership matches the mission-control read layer
// semantics: tier='human-gate' OR destination matches
// /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/. Open state defaults
// to pending|in-progress|blocked (callers can still override via
// `state=...`). Composable with destinationSession/sourceSession/
// targetRepo/limit.
app.get("/list", (c) => {
const destinationSession = c.req.query("destinationSession") || undefined;
const sourceSession = c.req.query("sourceSession") || undefined;
const stateRaw = c.req.query("state") || undefined;
const targetRepo = c.req.query("targetRepo") || undefined;
const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
const state = stateRaw ? (stateRaw.split(",") as QueueState[]) : undefined;
const items = getRepo(c).list({ destinationSession, sourceSession, state, targetRepo, limit });
return c.json(items);
const userLimit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
const attention = c.req.query("attention") === "1";
const state: QueueState[] | undefined = stateRaw
? (stateRaw.split(",") as QueueState[])
: attention
? ["pending", "in-progress", "blocked"]
: undefined;
if (!attention) {
const items = getRepo(c).list({
destinationSession,
sourceSession,
state,
targetRepo,
limit: userLimit,
});
return c.json(items);
}
// OPR.0.3.2.20 — attention path goes through
// QueueRepository.listAttention, which pushes the attention
// predicate INTO the SQL WHERE clause so the LIMIT applies AFTER
// attention filtering. Window-independent by construction: an
// old human-gate item is never evicted by routine open qitems,
// however many of them land after it (guard re-verify
// qitem-20260518190827 BLOCKER 1). The earlier fetch-then-filter
// shape (ATTENTION_FETCH_BOUND) is gone — the LIMIT bound is the
// user-facing one only, applied at the SQL layer post-predicate.
//
// destinationSession/sourceSession/targetRepo are composable with
// the attention predicate at the SQL layer (guard re-verify
// qitem-20260518192210 BLOCKER 1 — the previous forward-fix
// dropped composition). Scoped attention queries (e.g.,
// attention=1&destinationSession=...) return ONLY the matching
// attention items.
const items = getRepo(c).listAttention({
limit: userLimit,
state,
destinationSession,
sourceSession,
targetRepo,
});
// Defense-in-depth: refine with the JS predicate so the SQL
// LIKE superset cannot leak a malformed destination through.
const filtered = items.filter(isAttentionItem);
return c.json(filtered);
});
// GET /overdue — surfaces in-progress qitems past closure_required_at.
+425
View File
@@ -9,6 +9,7 @@ import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
import { inboxEntriesSchema } from "../src/db/migrations/026_inbox_entries.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { queueTargetRepoSchema } from "../src/db/migrations/039_queue_target_repo.js";
import { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository } from "../src/domain/queue-repository.js";
import { InboxHandler } from "../src/domain/inbox-handler.js";
@@ -51,6 +52,7 @@ describe("queue routes", () => {
queueTransitionsSchema,
inboxEntriesSchema,
outboxEntriesSchema,
queueTargetRepoSchema, // OPR.0.3.2.20: required for attention=1&targetRepo=X composition tests
]);
bus = new EventBus(db);
queueRepo = new QueueRepository(db, bus);
@@ -236,6 +238,429 @@ describe("queue routes", () => {
expect(data).toHaveLength(1);
});
// OPR.0.3.2.20 — `?attention=1` filter for the For You priority
// windowing slice. Returns OPEN attention-class qitems (the durable
// source of truth) so the UI Action-required + Approval lenses don't
// depend on the lossy ephemeral event FIFO. HG-4 verified against
// the mission-control read layer's canonical attention semantics:
// - approval class: tier === "human-gate"
// - action-required class: destinationSession is human-*@kernel|host
// - open state: pending | in-progress | blocked
describe("OPR.0.3.2.20 GET /api/queue/list?attention=1 — open attention-class items", () => {
it("HG-4 positive (approval class): tier='human-gate' open qitem is returned", async () => {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "b@r",
body: "approve please",
tier: "human-gate",
}),
});
const res = await app.request("/api/queue/list?attention=1");
expect(res.status).toBe(200);
const data = (await res.json()) as Array<{ tier: string | null; body: string }>;
expect(data).toHaveLength(1);
expect(data[0]!.tier).toBe("human-gate");
});
it("HG-4 positive (action-required class): destination=human-foo@kernel open qitem is returned", async () => {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "human-bob@kernel",
body: "needs human",
}),
});
const res = await app.request("/api/queue/list?attention=1");
const data = (await res.json()) as Array<{ destinationSession: string }>;
expect(data).toHaveLength(1);
expect(data[0]!.destinationSession).toBe("human-bob@kernel");
});
it("HG-4 positive: destination=human@host (bare human prefix) open qitem is returned", async () => {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "human@host",
body: "needs human attention",
}),
});
const res = await app.request("/api/queue/list?attention=1");
const data = (await res.json()) as unknown[];
expect(data).toHaveLength(1);
});
it("HG-4 negative: routine pending qitem (non-attention tier + non-human destination) is NOT returned", async () => {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "b@r",
body: "routine work",
}),
});
const res = await app.request("/api/queue/list?attention=1");
const data = (await res.json()) as unknown[];
expect(data).toHaveLength(0);
});
it("HG-4 negative: closed attention qitem (state=done) is NOT returned", async () => {
const create = await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "human-x@kernel",
body: "done already",
}),
});
const item = (await create.json()) as { qitemId: string };
await app.request(`/api/queue/${item.qitemId}/update`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
actorSession: "human-x@kernel",
state: "done",
closureReason: "no-follow-on",
}),
});
const res = await app.request("/api/queue/list?attention=1");
const data = (await res.json()) as unknown[];
expect(data).toHaveLength(0);
});
it("HG-5/HG-7 sane bound: ?attention=1&limit=N caps the result", async () => {
// Seed 5 attention-class qitems
for (let i = 0; i < 5; i++) {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: `human-${i}@kernel`,
body: `attn ${i}`,
}),
});
}
const res = await app.request("/api/queue/list?attention=1&limit=3");
const data = (await res.json()) as unknown[];
expect(data.length).toBeLessThanOrEqual(3);
});
it("HG-2 (the headline): attention-class items survive >100 unrelated routine qitems being created (queue is durable source)", async () => {
// Seed ONE attention-class item first
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "b@r",
body: "approve me",
tier: "human-gate",
}),
});
// Then create >100 routine qitems (no attention markers); these
// would saturate any FIFO window in the UI but the queue is the
// durable source — the attention filter must still surface the
// human-gate item.
for (let i = 0; i < 110; i++) {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: `routine-${i}@r`,
destinationSession: `other-${i}@r`,
body: `noise ${i}`,
}),
});
}
const res = await app.request("/api/queue/list?attention=1");
const data = (await res.json()) as Array<{ tier: string | null }>;
// Attention item still present despite 110 routine qitems written
// after it.
expect(data.length).toBeGreaterThanOrEqual(1);
expect(data.some((q) => q.tier === "human-gate")).toBe(true);
});
// Guard re-verify-2 (qitem-20260518192210) BLOCKER-1: the prior
// forward-fix dropped destinationSession/sourceSession/targetRepo
// composition. The fix routes those params through listAttention
// into the SQL WHERE so scoped attention queries return only the
// matching attention items.
it("BLOCKER re-verify-2: attention=1 + destinationSession=X returns only X-scoped attention items", async () => {
// Seed 2 attention items at different destinations.
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "advisor@r1",
destinationSession: "human-alice@kernel",
body: "for alice",
}),
});
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "advisor@r2",
destinationSession: "human-bob@kernel",
body: "for bob",
tier: "human-gate",
}),
});
// Also create a non-attention routine qitem destined to alice;
// it must not appear.
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "advisor@r1",
destinationSession: "human-alice@kernel",
state: "pending",
body: "another for alice",
}),
});
const res = await app.request("/api/queue/list?attention=1&destinationSession=human-alice@kernel");
const data = (await res.json()) as Array<{ destinationSession: string; body: string }>;
expect(data.length).toBeGreaterThanOrEqual(1);
for (const item of data) {
expect(item.destinationSession).toBe("human-alice@kernel");
}
// None of them should be for bob.
expect(data.some((q) => q.destinationSession === "human-bob@kernel")).toBe(false);
});
it("BLOCKER re-verify-2: attention=1 + sourceSession=X returns only X-sourced attention items", async () => {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "advisor-a@r",
destinationSession: "human-x@kernel",
body: "from advisor-a",
}),
});
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "advisor-b@r",
destinationSession: "human-y@kernel",
body: "from advisor-b",
}),
});
const res = await app.request("/api/queue/list?attention=1&sourceSession=advisor-a@r");
const data = (await res.json()) as Array<{ sourceSession: string }>;
expect(data.length).toBeGreaterThanOrEqual(1);
for (const item of data) {
expect(item.sourceSession).toBe("advisor-a@r");
}
});
it("BLOCKER re-verify-2: attention=1 unscoped still returns the global attention set (composition is OPT-IN, not required)", async () => {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "human-x@kernel",
body: "global x",
}),
});
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "b@r",
destinationSession: "human-y@kernel",
body: "global y",
}),
});
const res = await app.request("/api/queue/list?attention=1");
const data = (await res.json()) as unknown[];
expect(data.length).toBeGreaterThanOrEqual(2);
});
// Guard re-verify BLOCKER 1 (qitem-20260518190827): the prior
// fetch-then-filter approach (ATTENTION_FETCH_BOUND=1000 then
// JS filter) would have hidden an attention item behind 1001+
// newer routine open qitems. The fix pushes the attention
// predicate INTO SQL so the LIMIT applies AFTER attention
// filtering. This test scales the routine churn well past the
// old ATTENTION_FETCH_BOUND to prove window-independence by
// construction.
// Guard re-verify-3 (qitem-20260518193005) BLOCKER 1: SQL LIKE
// was a superset of the regex. Malformed rows like
// `destination_session='human-@kernel'` (empty name segment)
// match LIKE `human-%@kernel` but FAIL the strict regex. >LIMIT
// such rows could fill the SQL window pre-JS-filter and hide
// valid attention items behind them.
//
// Fix: SQLite function `is_human_seat_session` evaluates the
// exact regex in SQL — malformed rows are rejected BEFORE LIMIT.
it("BLOCKER re-verify-3: malformed superset rows ('human-@kernel') do NOT evict valid attention items", async () => {
// Seed 1 valid attention item.
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "advisor@r",
destinationSession: "human-alice@kernel",
body: "valid attention",
}),
});
// Seed 1100 malformed rows that match LIKE 'human-%@kernel' or
// similar but FAIL the strict regex (empty name segment;
// forbidden chars). Mix forms to ensure no single LIKE branch
// is the leak.
for (let i = 0; i < 1100; i++) {
const variant = i % 4;
const dest = variant === 0
? "human-@kernel" // empty segment between hyphen and @
: variant === 1
? "human- @kernel" // space (forbidden char) — would match LIKE 'human-%@kernel'
: variant === 2
? "human-x:@kernel" // colon (forbidden char)
: "human-x/@kernel"; // slash (forbidden char)
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: `garbage-${i}@r`,
destinationSession: dest,
body: `malformed ${i}`,
}),
});
}
const res = await app.request("/api/queue/list?attention=1&limit=100");
const data = (await res.json()) as Array<{ destinationSession: string; body: string }>;
// Valid item must surface — it is the ONLY row matching the
// strict regex. Malformed rows must NOT appear.
const valid = data.find((q) => q.destinationSession === "human-alice@kernel");
expect(valid).toBeDefined();
expect(valid!.body).toBe("valid attention");
// No malformed rows in the result set.
for (const q of data) {
expect(q.destinationSession).not.toBe("human-@kernel");
expect(q.destinationSession).not.toContain(" ");
expect(q.destinationSession).not.toContain(":");
expect(q.destinationSession).not.toContain("/");
}
});
// Guard re-verify-3 (qitem-20260518193005) BLOCKER 2: targetRepo
// composition was implemented in the previous forward-fix but
// never pinned by a test. This discriminator proves
// attention=1&targetRepo=X scopes the result + composes with the
// attention predicate at the SQL stage (LIMIT applies AFTER).
it("BLOCKER re-verify-3: attention=1 + targetRepo=X scopes attention to repo X", async () => {
// Seed attention items in different repos.
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "human-bob@kernel",
body: "for repo-a",
targetRepo: "repo-a",
}),
});
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "human-carol@kernel",
body: "for repo-b",
targetRepo: "repo-b",
}),
});
const res = await app.request("/api/queue/list?attention=1&targetRepo=repo-a");
const data = (await res.json()) as Array<{ targetRepo: string | null; body: string }>;
expect(data.length).toBeGreaterThanOrEqual(1);
for (const item of data) {
expect(item.targetRepo).toBe("repo-a");
}
});
it("BLOCKER re-verify-3: targetRepo composition preserves the >1100-routine-open durability guarantee", async () => {
// Seed 1 attention item with targetRepo=repo-X.
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "a@r",
destinationSession: "human-z@kernel",
body: "repo-X attention",
targetRepo: "repo-X",
}),
});
// 1100 newer routine open qitems in OTHER repos that would
// otherwise dominate the window if the targetRepo predicate
// were applied post-LIMIT.
for (let i = 0; i < 1100; i++) {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: `routine-${i}@r`,
destinationSession: `other-${i}@r`,
body: `noise ${i}`,
targetRepo: "repo-other",
}),
});
}
const res = await app.request("/api/queue/list?attention=1&targetRepo=repo-X");
const data = (await res.json()) as Array<{ targetRepo: string | null; body: string }>;
expect(data.length).toBe(1);
expect(data[0]!.targetRepo).toBe("repo-X");
expect(data[0]!.body).toBe("repo-X attention");
});
it("BLOCKER-1: attention item surfaces even when >1100 newer routine OPEN qitems exist (SQL predicate pushdown)", async () => {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: "old@r",
destinationSession: "b@r",
body: "approve me — oldest",
tier: "human-gate",
}),
});
// 1100 routine OPEN qitems land AFTER the attention item. They
// each get a newer ts_created than the attention item; a
// fetch-then-filter with LIMIT 1000 would never return the
// attention item.
for (let i = 0; i < 1100; i++) {
await app.request("/api/queue/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceSession: `routine-${i}@r`,
destinationSession: `other-${i}@r`,
body: `noise ${i}`,
}),
});
}
const res = await app.request("/api/queue/list?attention=1");
const data = (await res.json()) as Array<{ tier: string | null; body: string }>;
expect(data.length).toBeGreaterThanOrEqual(1);
const found = data.find((q) => q.tier === "human-gate");
expect(found).toBeDefined();
expect(found!.body).toContain("oldest");
});
});
// ---- PL-004 Phase A revision (R1) route tests ----
describe("R1 cross-rig validation rejection", () => {
+71 -16
View File
@@ -16,11 +16,18 @@ import { cn } from "../../lib/utils.js";
import { SectionHeader } from "../ui/section-header.js";
import { EmptyState } from "../ui/empty-state.js";
import { useActivityFeed } from "../../hooks/useActivityFeed.js";
import { useAttentionItems } from "../../hooks/useAttentionItems.js";
import {
classifyFeed,
type FeedCard as FeedCardModel,
type FeedCardKind,
} from "../../lib/feed-classifier.js";
import {
attentionItemToFeedCard,
eventDerivedSeqsForPrune,
isQueueDerivedFeedCard,
mergeAttentionIntoFeed,
} from "../../lib/attention-feed.js";
import {
useQueueItemMap,
useSliceDetails,
@@ -34,6 +41,7 @@ import {
isCardKindSubscribed,
} from "../../hooks/useFeedSubscriptions.js";
import { useDismissedSeqs } from "../../hooks/useDismissedSeqs.js";
import { useDismissedCardIds } from "../../hooks/useDismissedCardIds.js";
import { useCompletedMissions } from "../../hooks/useCompletedMissions.js";
import { useMissionStatuses } from "../../hooks/useMissionStatuses.js";
import { FeedCard } from "./FeedCard.js";
@@ -235,26 +243,70 @@ export function Feed() {
[],
);
const rawCards = useMemo(() => classifyFeed(events).slice(0, HISTORY_LIMIT), [events]);
const rawCardSeqs = useMemo(() => rawCards.map((c) => c.source.seq), [rawCards]);
const { dismissedSeqs, dismiss, undismiss } = useDismissedSeqs(rawCardSeqs);
const [pendingUndoSeq, setPendingUndoSeq] = useState<number | null>(null);
// OPR.0.3.2.20 — For You priority windowing.
// Action-required + Approval lenses source from the daemon's
// durable open-attention query (window-independent), then merge
// with event-derived cards. Queue-derived attention cards
// SUPERSEDE event-derived cards with the same qitemId. Other
// kinds (shipped/progress/observation) stay event-derived
// (HG-6 no regression).
const attentionQuery = useAttentionItems();
const queueDerivedAttention = useMemo<FeedCardModel[]>(
() => (attentionQuery.data ?? []).map(attentionItemToFeedCard),
[attentionQuery.data],
);
const eventDerivedCards = useMemo(() => classifyFeed(events).slice(0, HISTORY_LIMIT), [events]);
const rawCards = useMemo(
() => mergeAttentionIntoFeed(eventDerivedCards, queueDerivedAttention),
[eventDerivedCards, queueDerivedAttention],
);
// OPR.0.3.2.20 — useDismissedSeqs auto-prunes by min-seq across
// currentSeqs. Queue-derived synthetic cards carry seq=-1, which
// would pin min-seq at -1 and prevent the auto-prune for
// event-derived dismissals (guard re-verify-2
// qitem-20260518192210 CLEANUP-1). eventDerivedSeqsForPrune
// filters them OUT — their dismissal lives in useDismissedCardIds.
const rawCardSeqs = useMemo(() => eventDerivedSeqsForPrune(rawCards), [rawCards]);
const rawCardIds = useMemo(() => rawCards.map((c) => c.id), [rawCards]);
const { dismissedSeqs, dismiss: dismissSeq, undismiss: undismissSeq } = useDismissedSeqs(rawCardSeqs);
// OPR.0.3.2.20 — string-keyed dismissal parallel to event-seq
// dismissal. Queue-derived attention cards share synthetic
// ActivityEvent.seq = -1 (no real event), so seq-keyed dismissal
// collides across them; routing those dismissals to a string-id
// set (FeedCard.id is stable + unique) keeps each queue-derived
// card's dismissal independent. Auto-prune is membership-based:
// when the qitem closes the card disappears from rawCardIds and
// the dismissal is dropped.
const { dismissedIds, dismiss: dismissId, undismiss: undismissId } = useDismissedCardIds(rawCardIds);
const [pendingUndo, setPendingUndo] = useState<{ kind: "seq"; seq: number } | { kind: "id"; id: string } | null>(null);
// Routes a dismiss call: queue-derived synthetic cards
// (isQueueDerivedFeedCard) use the string-keyed dismissedIds set;
// event-derived cards continue using the seq-keyed dismissedSeqs
// set. Avoids collision on the synthetic seq=-1 (banked guard
// verdict qitem-20260518190827 BLOCKER 2).
const handleDismiss = useCallback(
(seq: number) => {
dismiss(seq);
setPendingUndoSeq(seq);
(card: FeedCardModel) => {
if (isQueueDerivedFeedCard(card)) {
dismissId(card.id);
setPendingUndo({ kind: "id", id: card.id });
} else {
dismissSeq(card.source.seq);
setPendingUndo({ kind: "seq", seq: card.source.seq });
}
},
[dismiss],
[dismissSeq, dismissId],
);
const handleUndo = useCallback(() => {
if (pendingUndoSeq !== null) undismiss(pendingUndoSeq);
setPendingUndoSeq(null);
}, [pendingUndoSeq, undismiss]);
if (pendingUndo === null) return;
if (pendingUndo.kind === "seq") undismissSeq(pendingUndo.seq);
else undismissId(pendingUndo.id);
setPendingUndo(null);
}, [pendingUndo, undismissSeq, undismissId]);
const handleUndoExpire = useCallback(() => {
setPendingUndoSeq(null);
setPendingUndo(null);
}, []);
const qitemIds = useMemo(
() => rawCards.map(qitemIdForCard).filter((id): id is string => Boolean(id)),
@@ -281,8 +333,11 @@ export function Feed() {
isCardKindSubscribed(c.kind, subs.state),
);
const lensFiltered = lens === "all" ? subscribed : subscribed.filter((c) => c.kind === lens);
return lensFiltered.filter((c) => !dismissedSeqs.has(c.source.seq));
}, [rawCards, lens, queueItems.itemsById, actionOutcomes, subs.state, dismissedSeqs]);
return lensFiltered.filter((c) => {
if (isQueueDerivedFeedCard(c)) return !dismissedIds.has(c.id);
return !dismissedSeqs.has(c.source.seq);
});
}, [rawCards, lens, queueItems.itemsById, actionOutcomes, subs.state, dismissedSeqs, dismissedIds]);
const slicesQuery = useSlices("all");
const sliceRows = useMemo(() => {
if (!slicesQuery.data || "unavailable" in slicesQuery.data) return [];
@@ -440,9 +495,9 @@ export function Feed() {
})}
</div>
)}
{pendingUndoSeq !== null ? (
{pendingUndo !== null ? (
<UndoToast
key={pendingUndoSeq}
key={pendingUndo.kind === "seq" ? `seq-${pendingUndo.seq}` : `id-${pendingUndo.id}`}
label="Card dismissed"
onUndo={handleUndo}
onExpire={handleUndoExpire}
@@ -323,7 +323,15 @@ export function FeedCard({
queueItem?: QueueItemDetail;
proofPreview?: FeedProofPreview | null;
actionOutcome?: FeedActionOutcome | null;
onDismiss?: (seq: number) => void;
/**
* OPR.0.3.2.20 — receives the FULL card on dismiss so the parent
* can route the dismissal to the right dismissal-set (event-seq
* for event-derived cards, string-id for queue-derived attention
* cards). The prior `(seq: number)` signature collided across all
* queue-derived cards (all share synthetic seq=-1) — banked guard
* BLOCKER qitem-20260518190827.
*/
onDismiss?: (card: FeedCardModel) => void;
/**
* 0.3.1 demo-bug fix — fired by VerbActions on mutation success
* so the parent (Feed.tsx) can render the ActionOutcomePanel
@@ -339,7 +347,7 @@ export function FeedCard({
if (event.target !== event.currentTarget) return;
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
onDismiss(card.source.seq);
onDismiss(card);
}
};
@@ -362,14 +370,14 @@ export function FeedCard({
const rect = target.getBoundingClientRect();
if (rect.width <= 0) return;
if (deltaX / rect.width >= SWIPE_DISMISS_THRESHOLD) {
onDismiss(card.source.seq);
onDismiss(card);
}
};
const handleDismissClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
event.stopPropagation();
if (!onDismiss) return;
onDismiss(card.source.seq);
onDismiss(card);
};
const qitemViewerData = qitemViewerDataFromItem(card, queueItem);
const body = compactQueueBody(queueItem?.body || card.body);
+32
View File
@@ -90,6 +90,38 @@ export function useActivityFeed(): UseActivityFeedResult {
queryClient.invalidateQueries({ queryKey: ["rigs", "summary"] });
queryClient.invalidateQueries({ queryKey: ["ps"] });
}
// OPR.0.3.2.20 — keep the For You attention surface live without
// hard reload. Any queue/qitem/inbox event can change the open-
// attention set (item created at human-gate tier, destination
// routed to a human seat, item claimed/closed/denied, fallback
// route added, closure overdue). Invalidate the durable query
// so useAttentionItems refetches and the lens updates within
// the same browser session.
//
// QA BLOCKING-A qitem-20260518195533: attention API returned the
// newly-created qitem immediately, but the open Approval lens
// did not show it until a hard reload — react-query cache
// wasn't invalidated on queue.created.
//
// The string-match pattern mirrors the feed-classifier's
// isQueueVisibilityEvent + closed-state branch. Broad-by-prefix
// means a future new event type (e.g., `qitem.escalated`)
// auto-invalidates without a code edit.
if (
event.type.startsWith("queue.")
|| event.type.startsWith("qitem.")
|| event.type.startsWith("inbox.")
) {
queryClient.invalidateQueries({ queryKey: ["attention-items"] });
const qitemId = (event.payload["qitemId"] as string | undefined)
?? (event.payload["qitem_id"] as string | undefined);
if (qitemId) {
// Already-fetched detail (useQueueItem*); invalidate so the
// hydrated FeedCard picks up the new state too.
queryClient.invalidateQueries({ queryKey: ["queue", "item", qitemId] });
}
}
}, [queryClient]);
useEffect(() => {
@@ -0,0 +1,60 @@
// OPR.0.3.2.20 — For You priority windowing.
//
// useAttentionItems wraps GET /api/queue/list?attention=1 — the daemon
// query that returns OPEN attention-class qitems (the durable source
// of truth) so the For You Action-required + Approval lenses don't
// depend on the lossy ephemeral client event FIFO at
// `useActivityFeed.MAX_ACTIVITY_EVENTS=100`.
//
// HG-8 freshness: react-query refetchOnWindowFocus is set to the
// string variant 'always' (NOT boolean `true`). With a non-trivial
// staleTime, boolean `true` is gated by the staleness predicate and
// can skip refetches inside the stale window (banked
// feedback_refetchOnWindowFocus_staleness_gated_use_always_string).
// 'always' bypasses the gate so the attention surface refreshes on
// every focus.
import { useQuery } from "@tanstack/react-query";
export interface AttentionQueueItem {
qitemId: string;
tsCreated: string;
tsUpdated: string;
sourceSession: string;
destinationSession: string;
state: string;
priority: string;
tier: string | null;
tags: string[] | null;
blockedOn: string | null;
handedOffTo: string | null;
handedOffFrom: string | null;
body: string;
}
async function fetchAttentionItems(limit?: number): Promise<AttentionQueueItem[]> {
const params = new URLSearchParams({ attention: "1" });
if (limit !== undefined) params.set("limit", String(limit));
const res = await fetch(`/api/queue/list?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as AttentionQueueItem[];
}
/**
* Open attention-class qitems (tier="human-gate" OR destination
* matches /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/). State default
* is pending|in-progress|blocked — closed/done items are NOT surfaced.
*
* `limit` (optional, default 50) caps the rendered set so a
* pathological backlog can't unbounded-render. The attention set is
* small by nature.
*/
export function useAttentionItems(limit: number = 50) {
return useQuery({
queryKey: ["attention-items", limit],
queryFn: () => fetchAttentionItems(limit),
staleTime: 15_000,
// HG-8: 'always' (not `true`) — see file header comment.
refetchOnWindowFocus: "always",
});
}
@@ -0,0 +1,95 @@
// OPR.0.3.2.20 — string-keyed dismissal for queue-derived attention
// cards. Parallel to useDismissedSeqs (which is event-seq keyed and
// auto-prunes by monotonic min-seq for the activity FIFO). The
// queue-derived synthetic FeedCards don't have unique event seqs
// (the synthetic ActivityEvent.seq = -1), so the existing seq-keyed
// dismissal would collide across all queue-derived cards — dismissing
// one would hide every other queue-derived attention card too.
//
// This hook keys on the stable string FeedCard.id (e.g.,
// `queue-attention-<qitemId>`), prunes by membership in the current
// id set (when the qitem closes the card disappears, the dismissal
// is dropped), and persists in a distinct localStorage namespace
// so event-seq dismissals are untouched.
import { useCallback, useEffect, useMemo, useState } from "react";
export const DISMISSED_CARD_IDS_STORAGE_KEY = "forYou.dismissedCardIds";
function readDismissedFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_CARD_IDS_STORAGE_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
const strings = parsed.filter((v): v is string => typeof v === "string" && v.length > 0);
return new Set(strings);
} catch {
return new Set();
}
}
function writeDismissedToStorage(ids: Set<string>): void {
try {
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, JSON.stringify(Array.from(ids)));
} catch {
// localStorage may be unavailable; swallow.
}
}
export interface UseDismissedCardIdsResult {
dismissedIds: Set<string>;
dismiss(id: string): void;
undismiss(id: string): void;
}
/**
* @param currentIds The ids of all cards currently in the rendered
* set. Used to auto-prune: when a card's id is no longer present
* (qitem closed/handed off), the dismissal is dropped so the
* localStorage set doesn't grow forever.
*/
export function useDismissedCardIds(currentIds: string[]): UseDismissedCardIdsResult {
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => readDismissedFromStorage());
const currentSet = useMemo(() => new Set(currentIds), [currentIds]);
useEffect(() => {
if (currentSet.size === 0) return;
let changed = false;
const next = new Set<string>();
for (const id of dismissedIds) {
if (currentSet.has(id)) {
next.add(id);
} else {
changed = true;
}
}
if (changed) {
setDismissedIds(next);
writeDismissedToStorage(next);
}
}, [currentSet, dismissedIds]);
const dismiss = useCallback((id: string) => {
setDismissedIds((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
next.add(id);
writeDismissedToStorage(next);
return next;
});
}, []);
const undismiss = useCallback((id: string) => {
setDismissedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
writeDismissedToStorage(next);
return next;
});
}, []);
return { dismissedIds, dismiss, undismiss };
}
+150
View File
@@ -0,0 +1,150 @@
// OPR.0.3.2.20 — adapter: open attention-class queue items → FeedCard[].
//
// The For You Action-required + Approval lenses previously sourced
// from a flat client-side FIFO (useActivityFeed.MAX_ACTIVITY_EVENTS=100)
// that routine queue churn evicts. This adapter projects the daemon's
// durable open-attention set (GET /api/queue/list?attention=1) into
// the same FeedCard shape the feed already renders.
//
// Merge rule:
// - For action-required + approval kinds, queue-derived cards
// SUPERSEDE event-derived cards keyed by the same qitemId.
// - When the queue has an attention item NOT seen in the events
// (the eviction defect case), it still surfaces because the queue
// is the source of truth.
// - Other kinds (shipped, progress, observation) are unaffected;
// they continue to come from useActivityFeed/classifyFeed.
import type { FeedCard } from "./feed-classifier.js";
import type { ActivityEvent } from "../hooks/useActivityFeed.js";
import type { AttentionQueueItem } from "../hooks/useAttentionItems.js";
const HUMAN_SEAT_PATTERN = /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/;
/**
* Classify a queue item's attention kind. Mirrors the mission-control
* read layer + the classifier's queueKind logic so the For You lenses
* and the mission-control human-gate view agree.
*
* - approval → tier === "human-gate"
* - action-required → destinationSession matches the human-seat regex
*
* NOT exported as a generic predicate to avoid drift with the daemon
* isAttentionItem; this adapter narrows the daemon's combined
* attention set into the two UI-visible kinds.
*/
function attentionKindFor(item: AttentionQueueItem): "approval" | "action-required" {
if (item.tier === "human-gate") return "approval";
if (HUMAN_SEAT_PATTERN.test(item.destinationSession ?? "")) return "action-required";
// Fallback — daemon guarantees one of the two for items it returns
// with attention=1; defensive default keeps the type union tight.
return "action-required";
}
/**
* Project an attention queue item into a synthetic FeedCard. The
* synthetic `source` ActivityEvent carries the qitemId so downstream
* code (qitemIdForCard, action handlers) keeps working uniformly.
*/
export function attentionItemToFeedCard(item: AttentionQueueItem): FeedCard {
const kind = attentionKindFor(item);
// Deterministic FeedCard.id — events use `${evt.type}-${seq}`. The
// queue-derived card uses a stable prefix so consumers can detect
// queue-sourced cards if needed and so the React key is stable
// across re-fetches.
const id = `queue-attention-${item.qitemId}`;
const receivedAt = Date.parse(item.tsUpdated) || Date.now();
const createdAt = item.tsUpdated || item.tsCreated || new Date().toISOString();
const syntheticEvent: ActivityEvent = {
seq: -1,
type: "queue.attention.synthetic",
payload: {
qitemId: item.qitemId,
destinationSession: item.destinationSession,
sourceSession: item.sourceSession,
tier: item.tier,
state: item.state,
body: item.body,
},
createdAt,
receivedAt,
};
const title =
kind === "approval"
? `Approval needed: ${item.qitemId.slice(0, 24)}`
: `Action required: ${item.qitemId.slice(0, 24)}`;
return {
id,
kind,
title,
body: item.body,
authorSession: item.sourceSession,
receivedAt,
createdAt,
source: syntheticEvent,
};
}
/**
* Merge queue-derived attention cards into the event-derived feed.
* - Removes any event-derived card with kind ∈ {action-required, approval}
* whose qitemId matches a queue-derived card (queue wins; eviction
* defect fix).
* - Appends all queue-derived cards.
* - Non-attention kinds are passed through unchanged (HG-6 no
* regression).
*
* Caller is expected to sort/dedupe-by-id downstream as usual.
*/
export function mergeAttentionIntoFeed(
eventDerived: FeedCard[],
queueDerived: FeedCard[],
): FeedCard[] {
const queueQitemIds = new Set<string>();
for (const card of queueDerived) {
const qitemId = qitemIdFromCard(card);
if (qitemId) queueQitemIds.add(qitemId);
}
const filtered = eventDerived.filter((c) => {
if (c.kind !== "action-required" && c.kind !== "approval") return true;
const qitemId = qitemIdFromCard(c);
if (!qitemId) return true;
return !queueQitemIds.has(qitemId);
});
return [...queueDerived, ...filtered];
}
/**
* OPR.0.3.2.20 — queue-derived card identifier prefix. Used by
* Feed.tsx to route dismissal (queue → string-keyed dismissedIds;
* event → numeric dismissedSeqs) AND to filter queue-derived cards
* out of the seq-prune input (queue cards have synthetic seq=-1
* and would otherwise pin min-seq at -1, breaking the
* useDismissedSeqs auto-prune for event-derived dismissals — guard
* re-verify-2 qitem-20260518192210 CLEANUP-1).
*/
export const QUEUE_DERIVED_CARD_ID_PREFIX = "queue-attention-";
export function isQueueDerivedFeedCard(card: FeedCard): boolean {
return card.id.startsWith(QUEUE_DERIVED_CARD_ID_PREFIX);
}
/**
* Filter rawCards to ONLY event-derived cards' seqs for useDismissedSeqs.
* Queue-derived cards (synthetic seq=-1) are excluded; their dismissal
* lives in useDismissedCardIds.
*/
export function eventDerivedSeqsForPrune(rawCards: FeedCard[]): number[] {
return rawCards.filter((c) => !isQueueDerivedFeedCard(c)).map((c) => c.source.seq);
}
function qitemIdFromCard(card: FeedCard): string | null {
const payload = (card.source.payload ?? {}) as Record<string, unknown>;
const fromPayload =
(typeof payload.qitemId === "string" && payload.qitemId.length > 0)
? payload.qitemId
: (typeof payload.qitem_id === "string" && payload.qitem_id.length > 0)
? (payload.qitem_id as string)
: null;
return fromPayload;
}
+254
View File
@@ -0,0 +1,254 @@
// OPR.0.3.2.20 — For You priority windowing adapter tests.
//
// HG-3 fail-first discriminator: the event-FIFO source loses
// attention items past MAX_ACTIVITY_EVENTS=100; the queue-sourced
// path is window-independent. These tests pin the queue-source
// shape and the merge rule.
import { describe, it, expect } from "vitest";
import {
attentionItemToFeedCard,
eventDerivedSeqsForPrune,
isQueueDerivedFeedCard,
mergeAttentionIntoFeed,
QUEUE_DERIVED_CARD_ID_PREFIX,
} from "../src/lib/attention-feed.js";
import type { AttentionQueueItem } from "../src/hooks/useAttentionItems.js";
import type { FeedCard } from "../src/lib/feed-classifier.js";
import type { ActivityEvent } from "../src/hooks/useActivityFeed.js";
function makeAttention(overrides: Partial<AttentionQueueItem> = {}): AttentionQueueItem {
return {
qitemId: "qitem-20260518000000-aaaa",
tsCreated: "2026-05-18T00:00:00.000Z",
tsUpdated: "2026-05-18T00:00:00.000Z",
sourceSession: "advisor@rig",
destinationSession: "human-bob@kernel",
state: "pending",
priority: "routine",
tier: null,
tags: null,
blockedOn: null,
handedOffTo: null,
handedOffFrom: null,
body: "needs attention",
...overrides,
};
}
function makeEvent(qitemId: string, type: string = "queue.created"): ActivityEvent {
return {
seq: 1,
type,
payload: { qitemId },
createdAt: "2026-05-18T00:00:00.000Z",
receivedAt: Date.parse("2026-05-18T00:00:00.000Z"),
};
}
function makeFeedCard(opts: { kind: FeedCard["kind"]; qitemId?: string; id?: string }): FeedCard {
const evt = opts.qitemId ? makeEvent(opts.qitemId) : { ...makeEvent("none"), payload: {} };
return {
id: opts.id ?? `${evt.type}-1`,
kind: opts.kind,
title: `${opts.kind} card`,
body: "x",
receivedAt: evt.receivedAt,
createdAt: evt.createdAt,
source: evt,
};
}
describe("attentionItemToFeedCard — kind classification", () => {
it("HG-4 approval class: tier='human-gate' → kind:'approval'", () => {
const card = attentionItemToFeedCard(makeAttention({ tier: "human-gate", destinationSession: "advisor@rig" }));
expect(card.kind).toBe("approval");
});
it("HG-4 action-required class: destination matches human-*@kernel → kind:'action-required'", () => {
const card = attentionItemToFeedCard(makeAttention({ destinationSession: "human-bob@kernel" }));
expect(card.kind).toBe("action-required");
});
it("HG-4 action-required class: destination matches human@host (bare) → kind:'action-required'", () => {
const card = attentionItemToFeedCard(makeAttention({ destinationSession: "human@host" }));
expect(card.kind).toBe("action-required");
});
it("HG-4: tier='human-gate' wins over human-seat destination (approval takes priority for classification)", () => {
const card = attentionItemToFeedCard(makeAttention({ tier: "human-gate", destinationSession: "human-x@kernel" }));
expect(card.kind).toBe("approval");
});
it("synthetic event payload carries qitemId so downstream qitemId lookups work", () => {
const card = attentionItemToFeedCard(makeAttention({ qitemId: "qitem-test-1" }));
const payload = card.source.payload as Record<string, unknown>;
expect(payload.qitemId).toBe("qitem-test-1");
});
it("FeedCard.id is stable + queue-prefixed so re-fetches produce identical keys", () => {
const a = attentionItemToFeedCard(makeAttention({ qitemId: "qitem-stable-1" }));
const b = attentionItemToFeedCard(makeAttention({ qitemId: "qitem-stable-1" }));
expect(a.id).toBe(b.id);
expect(a.id).toMatch(/^queue-attention-/);
});
});
describe("mergeAttentionIntoFeed — queue supersedes events for attention kinds", () => {
it("HG-2 headline: queue-derived attention card surfaces even when no matching event exists (eviction defect)", () => {
const eventDerived: FeedCard[] = [
makeFeedCard({ kind: "shipped", qitemId: "q-other" }),
makeFeedCard({ kind: "progress", qitemId: "q-other-2" }),
];
const queueDerived: FeedCard[] = [
attentionItemToFeedCard(makeAttention({ qitemId: "qitem-evicted", destinationSession: "human-x@kernel" })),
];
const merged = mergeAttentionIntoFeed(eventDerived, queueDerived);
expect(merged.some((c) => c.kind === "action-required" && c.id.includes("qitem-evicted"))).toBe(true);
});
it("HG-3 dedup: event-derived action-required card with same qitemId is replaced by queue version", () => {
const sameQitem = "qitem-shared-1";
const eventDerived: FeedCard[] = [
makeFeedCard({ kind: "action-required", qitemId: sameQitem, id: "queue.created-99" }),
];
const queueDerived: FeedCard[] = [
attentionItemToFeedCard(makeAttention({ qitemId: sameQitem, destinationSession: "human@host" })),
];
const merged = mergeAttentionIntoFeed(eventDerived, queueDerived);
// The event-derived card MUST be evicted from the merge result;
// only the queue-derived version remains.
expect(merged.filter((c) => c.kind === "action-required")).toHaveLength(1);
expect(merged.find((c) => c.kind === "action-required")!.id).toMatch(/^queue-attention-/);
// The event card id is NOT present.
expect(merged.some((c) => c.id === "queue.created-99")).toBe(false);
});
it("HG-3 dedup: event-derived approval card with same qitemId is replaced by queue version", () => {
const sameQitem = "qitem-approval-1";
const eventDerived: FeedCard[] = [
makeFeedCard({ kind: "approval", qitemId: sameQitem }),
];
const queueDerived: FeedCard[] = [
attentionItemToFeedCard(makeAttention({ qitemId: sameQitem, tier: "human-gate" })),
];
const merged = mergeAttentionIntoFeed(eventDerived, queueDerived);
expect(merged.filter((c) => c.kind === "approval")).toHaveLength(1);
expect(merged.find((c) => c.kind === "approval")!.id).toMatch(/^queue-attention-/);
});
it("HG-6 no regression: non-attention event cards (shipped/progress/observation) are passed through unchanged", () => {
const eventDerived: FeedCard[] = [
makeFeedCard({ kind: "shipped", qitemId: "q-shipped" }),
makeFeedCard({ kind: "progress", qitemId: "q-progress" }),
makeFeedCard({ kind: "observation", qitemId: "q-obs" }),
];
const queueDerived: FeedCard[] = [
attentionItemToFeedCard(makeAttention({ qitemId: "qitem-fresh", destinationSession: "human-y@kernel" })),
];
const merged = mergeAttentionIntoFeed(eventDerived, queueDerived);
expect(merged.filter((c) => c.kind === "shipped")).toHaveLength(1);
expect(merged.filter((c) => c.kind === "progress")).toHaveLength(1);
expect(merged.filter((c) => c.kind === "observation")).toHaveLength(1);
expect(merged.filter((c) => c.kind === "action-required")).toHaveLength(1);
});
it("HG-5 graceful-empty: no queue items → merged result equals event-derived (no error)", () => {
const eventDerived: FeedCard[] = [
makeFeedCard({ kind: "shipped", qitemId: "q-shipped" }),
makeFeedCard({ kind: "approval", qitemId: "q-event-approval" }),
];
const merged = mergeAttentionIntoFeed(eventDerived, []);
expect(merged).toHaveLength(2);
expect(merged.some((c) => c.kind === "approval")).toBe(true);
});
it("HG-3: event-derived attention card WITHOUT a qitemId (anomalous payload) is preserved if queue has no matching item", () => {
const eventDerived: FeedCard[] = [
makeFeedCard({ kind: "action-required" /* no qitemId */ }),
];
const merged = mergeAttentionIntoFeed(eventDerived, []);
expect(merged).toHaveLength(1);
});
// HG-2 stress-test simulation: even when 200 unrelated event cards
// would have evicted the attention item from the 100-FIFO, the merge
// surfaces it because the queue is the source of truth.
it("HG-2 stress: 200 routine event cards + 1 queue-derived attention card → attention card present after merge", () => {
const eventDerived: FeedCard[] = Array.from({ length: 200 }, (_, i) =>
makeFeedCard({ kind: "shipped", qitemId: `q-routine-${i}` }),
);
const queueDerived: FeedCard[] = [
attentionItemToFeedCard(makeAttention({ qitemId: "qitem-survives", destinationSession: "human-z@kernel" })),
];
const merged = mergeAttentionIntoFeed(eventDerived, queueDerived);
const attentionCards = merged.filter((c) => c.kind === "action-required");
expect(attentionCards).toHaveLength(1);
expect(attentionCards[0]!.id).toContain("qitem-survives");
});
});
// Guard re-verify-2 (qitem-20260518192210) CLEANUP-1: useDismissedSeqs
// auto-prunes by min-seq across currentSeqs. Queue-derived synthetic
// cards carry seq=-1 (no real event). If those -1 sentinels reach
// useDismissedSeqs, min-seq pins at -1 → event-seq dismissals never
// auto-prune. eventDerivedSeqsForPrune filters queue-derived cards
// OUT before they reach the seq input.
describe("CLEANUP-1: eventDerivedSeqsForPrune isolates seq-prune from queue-derived sentinels", () => {
it("isQueueDerivedFeedCard returns true for queue-attention-* ids, false otherwise", () => {
const eventCard = makeFeedCard({ kind: "approval", qitemId: "q1" });
const queueCard = attentionItemToFeedCard(makeAttention({ qitemId: "qitem-Q", destinationSession: "human-bob@kernel" }));
expect(isQueueDerivedFeedCard(eventCard)).toBe(false);
expect(isQueueDerivedFeedCard(queueCard)).toBe(true);
expect(QUEUE_DERIVED_CARD_ID_PREFIX).toBe("queue-attention-");
});
it("returns only event-derived seqs when input mixes event + queue cards", () => {
const cards: FeedCard[] = [
makeFeedCard({ kind: "approval", qitemId: "q1", id: "queue.created-42" }),
attentionItemToFeedCard(makeAttention({ qitemId: "qitem-Q1", destinationSession: "human-x@kernel" })),
makeFeedCard({ kind: "shipped", qitemId: "q2", id: "queue.created-50" }),
attentionItemToFeedCard(makeAttention({ qitemId: "qitem-Q2", tier: "human-gate" })),
];
const seqs = eventDerivedSeqsForPrune(cards);
// Event-derived seqs are positive (real events); queue-derived
// synthetic seq=-1 must be filtered out.
expect(seqs).toEqual([1, 1]); // makeFeedCard uses seq=1 by default
expect(seqs).not.toContain(-1);
});
it("returns empty array when every input is queue-derived", () => {
const cards: FeedCard[] = [
attentionItemToFeedCard(makeAttention({ qitemId: "qitem-a", destinationSession: "human-a@kernel" })),
attentionItemToFeedCard(makeAttention({ qitemId: "qitem-b", destinationSession: "human-b@kernel" })),
];
expect(eventDerivedSeqsForPrune(cards)).toEqual([]);
});
it("CLEANUP-1 regression: queue-derived synthetic seq=-1 does NOT pin min-seq when fed into useDismissedSeqs", async () => {
// Discriminator: import useDismissedSeqs in isolation and prove
// that with the FILTERED seq input (only event-derived seqs),
// auto-prune correctly drops a dismissed seq once it falls
// below the min. With the UNFILTERED input (mixed -1 sentinel +
// real seqs), min would be -1 and the prune would never fire.
const { renderHook, act } = await import("@testing-library/react");
const { useDismissedSeqs } = await import("../src/hooks/useDismissedSeqs.js");
localStorage.clear();
// Initial render: dismissed seq 50 is in current set.
const { result, rerender } = renderHook(({ seqs }) => useDismissedSeqs(seqs), {
initialProps: { seqs: [50, 100, 200] },
});
act(() => result.current.dismiss(50));
expect(result.current.dismissedSeqs.has(50)).toBe(true);
// The FIFO drops the older events: only newer ones remain.
// With CLEANUP-1 in place (queue-derived sentinels filtered),
// min-seq advances to 150 → dismissed 50 is correctly pruned.
rerender({ seqs: [150, 200, 300] });
// Wait for the useEffect prune to run.
await act(() => Promise.resolve());
expect(result.current.dismissedSeqs.has(50)).toBe(false);
});
});
+8 -5
View File
@@ -35,11 +35,14 @@ describe("FeedCard dismiss surfaces", () => {
expect(screen.getByTestId("feed-card-dismiss")).toBeTruthy();
});
it("clicking dismiss button calls onDismiss with card.source.seq", () => {
// OPR.0.3.2.20 — onDismiss now receives the full card (not just the
// seq) so the parent can route to the right dismissal-set
// (event-seq vs card-id for queue-derived synthetic cards).
it("clicking dismiss button calls onDismiss with the full card (includes source.seq)", () => {
const onDismiss = vi.fn();
render(<FeedCard card={makeCard({ source: { seq: 42, type: "queue.enqueued", payload: {} } as unknown as FeedCardModel["source"] })} onDismiss={onDismiss} />);
fireEvent.click(screen.getByTestId("feed-card-dismiss"));
expect(onDismiss).toHaveBeenCalledWith(42);
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ seq: 42 }) }));
});
it("Backspace key on focused card calls onDismiss", () => {
@@ -47,7 +50,7 @@ describe("FeedCard dismiss surfaces", () => {
render(<FeedCard card={makeCard()} onDismiss={onDismiss} />);
const article = screen.getByTestId("feed-card-progress");
fireEvent.keyDown(article, { key: "Backspace" });
expect(onDismiss).toHaveBeenCalledWith(42);
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ seq: 42 }) }));
});
it("Delete key on focused card calls onDismiss", () => {
@@ -55,7 +58,7 @@ describe("FeedCard dismiss surfaces", () => {
render(<FeedCard card={makeCard()} onDismiss={onDismiss} />);
const article = screen.getByTestId("feed-card-progress");
fireEvent.keyDown(article, { key: "Delete" });
expect(onDismiss).toHaveBeenCalledWith(42);
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ seq: 42 }) }));
});
it("other keys (e.g. Enter, Escape) do NOT call onDismiss", () => {
@@ -105,7 +108,7 @@ describe("FeedCard dismiss surfaces", () => {
fireEvent.touchStart(article, { touches: [{ clientX: 50, clientY: 50, identifier: 1 }] });
fireEvent.touchEnd(article, { changedTouches: [{ clientX: 300, clientY: 50, identifier: 1 }] });
expect(onDismiss).toHaveBeenCalledWith(42);
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ seq: 42 }) }));
});
it("touch swipe-right BELOW threshold does NOT trigger onDismiss", () => {
@@ -0,0 +1,207 @@
// OPR.0.3.2.20 — QA BLOCKING-A (qitem-20260518195533) regression
// test. The For You attention surface MUST refetch when queue
// events arrive over SSE; without invalidation, useAttentionItems
// keeps its react-query cache and the open lens shows stale data
// until hard reload or window-focus refetch.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useActivityFeed } from "../src/hooks/useActivityFeed.js";
import { createMockEventSourceClass, instances } from "./helpers/mock-event-source.js";
let OriginalEventSource: typeof EventSource | undefined;
function createTestQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
}
beforeEach(() => {
OriginalEventSource = globalThis.EventSource;
// @ts-expect-error — replacing the global for the test
globalThis.EventSource = createMockEventSourceClass();
});
afterEach(() => {
cleanup();
if (OriginalEventSource) {
globalThis.EventSource = OriginalEventSource;
}
});
function wrapper(client: QueryClient) {
// eslint-disable-next-line react/display-name
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
describe("useActivityFeed — attention-items invalidation on queue events (QA BLOCKING-A fix)", () => {
it("invalidates ['attention-items'] on queue.created SSE event", async () => {
const client = createTestQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
renderHook(() => useActivityFeed(), { wrapper: wrapper(client) });
// Wait for the EventSource subscription to be created.
await act(() => Promise.resolve());
const source = instances[0];
expect(source).toBeDefined();
// Simulate the daemon emitting a queue.created event.
act(() => {
source!.simulateMessage(JSON.stringify({
seq: 1,
type: "queue.created",
qitemId: "qitem-test-live-1",
destinationSession: "human-bob@kernel",
createdAt: new Date().toISOString(),
}));
});
// The cache key for useAttentionItems is ["attention-items", <limit>].
// invalidateQueries with key ["attention-items"] is a prefix match in
// react-query — verify we called it with that exact prefix.
expect(spy).toHaveBeenCalledWith({ queryKey: ["attention-items"] });
});
it("invalidates ['attention-items'] on queue.handed_off, queue.updated, queue.claimed, queue.unclaimed", async () => {
const client = createTestQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
renderHook(() => useActivityFeed(), { wrapper: wrapper(client) });
await act(() => Promise.resolve());
const source = instances[0]!;
for (const type of ["queue.handed_off", "queue.updated", "queue.claimed", "queue.unclaimed"]) {
act(() => {
source.simulateMessage(JSON.stringify({
seq: 100,
type,
qitemId: "qitem-test-X",
createdAt: new Date().toISOString(),
}));
});
}
// attention-items invalidated for each of the 4 event types.
const attentionCalls = spy.mock.calls.filter(
([arg]) => arg && (arg as { queryKey?: unknown[] }).queryKey?.[0] === "attention-items",
);
expect(attentionCalls.length).toBeGreaterThanOrEqual(4);
});
it("invalidates ['attention-items'] on qitem.* events (fallback_routed, closure_overdue)", async () => {
const client = createTestQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
renderHook(() => useActivityFeed(), { wrapper: wrapper(client) });
await act(() => Promise.resolve());
const source = instances[0]!;
for (const type of ["qitem.fallback_routed", "qitem.closure_overdue"]) {
act(() => {
source.simulateMessage(JSON.stringify({
seq: 200,
type,
qitemId: "qitem-test-Y",
createdAt: new Date().toISOString(),
}));
});
}
const attentionCalls = spy.mock.calls.filter(
([arg]) => arg && (arg as { queryKey?: unknown[] }).queryKey?.[0] === "attention-items",
);
expect(attentionCalls.length).toBeGreaterThanOrEqual(2);
});
it("invalidates ['attention-items'] on inbox.absorbed / inbox.denied", async () => {
const client = createTestQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
renderHook(() => useActivityFeed(), { wrapper: wrapper(client) });
await act(() => Promise.resolve());
const source = instances[0]!;
for (const type of ["inbox.absorbed", "inbox.denied"]) {
act(() => {
source.simulateMessage(JSON.stringify({
seq: 300,
type,
createdAt: new Date().toISOString(),
}));
});
}
const attentionCalls = spy.mock.calls.filter(
([arg]) => arg && (arg as { queryKey?: unknown[] }).queryKey?.[0] === "attention-items",
);
expect(attentionCalls.length).toBeGreaterThanOrEqual(2);
});
it("invalidates ['queue','item',qitemId] when payload includes qitemId so already-fetched detail refreshes", async () => {
const client = createTestQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
renderHook(() => useActivityFeed(), { wrapper: wrapper(client) });
await act(() => Promise.resolve());
const source = instances[0]!;
act(() => {
source.simulateMessage(JSON.stringify({
seq: 400,
type: "queue.updated",
qitemId: "qitem-abc-123",
createdAt: new Date().toISOString(),
}));
});
expect(spy).toHaveBeenCalledWith({ queryKey: ["queue", "item", "qitem-abc-123"] });
});
it("accepts qitem_id snake-case payload key for queue detail invalidation", async () => {
const client = createTestQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
renderHook(() => useActivityFeed(), { wrapper: wrapper(client) });
await act(() => Promise.resolve());
const source = instances[0]!;
act(() => {
source.simulateMessage(JSON.stringify({
seq: 401,
type: "queue.created",
qitem_id: "qitem-snake-1",
createdAt: new Date().toISOString(),
}));
});
expect(spy).toHaveBeenCalledWith({ queryKey: ["queue", "item", "qitem-snake-1"] });
});
it("does NOT invalidate ['attention-items'] for non-queue events (no over-invalidation)", async () => {
const client = createTestQueryClient();
const spy = vi.spyOn(client, "invalidateQueries");
renderHook(() => useActivityFeed(), { wrapper: wrapper(client) });
await act(() => Promise.resolve());
const source = instances[0]!;
for (const type of ["rig.imported", "snapshot.created", "session.discovered", "chat.message"]) {
act(() => {
source.simulateMessage(JSON.stringify({
seq: 500,
type,
createdAt: new Date().toISOString(),
}));
});
}
const attentionCalls = spy.mock.calls.filter(
([arg]) => arg && (arg as { queryKey?: unknown[] }).queryKey?.[0] === "attention-items",
);
expect(attentionCalls.length).toBe(0);
});
});
@@ -0,0 +1,104 @@
// OPR.0.3.2.20 — string-keyed dismissal hook tests. Pins BLOCKER-2
// fix from guard verdict qitem-20260518190827: queue-derived
// synthetic FeedCards share ActivityEvent.seq=-1, so the old
// seq-keyed dismissal would collide across all queue-derived cards.
// useDismissedCardIds keys on the unique string FeedCard.id so each
// queue-derived card has independent dismissal state.
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
useDismissedCardIds,
DISMISSED_CARD_IDS_STORAGE_KEY,
} from "../src/hooks/useDismissedCardIds.js";
describe("useDismissedCardIds — string-keyed dismissal", () => {
beforeEach(() => {
localStorage.clear();
});
it("initializes empty when localStorage is empty", () => {
const { result } = renderHook(() => useDismissedCardIds(["a", "b", "c"]));
expect(result.current.dismissedIds.size).toBe(0);
});
it("reads existing dismissed ids from localStorage on mount", () => {
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, JSON.stringify(["queue-attention-q1", "queue-attention-q2"]));
const { result } = renderHook(() =>
useDismissedCardIds(["queue-attention-q1", "queue-attention-q2", "queue-attention-q3"]),
);
expect(result.current.dismissedIds.has("queue-attention-q1")).toBe(true);
expect(result.current.dismissedIds.has("queue-attention-q2")).toBe(true);
expect(result.current.dismissedIds.has("queue-attention-q3")).toBe(false);
});
it("tolerates malformed localStorage value (non-JSON)", () => {
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, "{not json[");
const { result } = renderHook(() => useDismissedCardIds(["a"]));
expect(result.current.dismissedIds.size).toBe(0);
});
it("tolerates non-array localStorage value", () => {
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, JSON.stringify({ not: "array" }));
const { result } = renderHook(() => useDismissedCardIds(["a"]));
expect(result.current.dismissedIds.size).toBe(0);
});
it("dismiss(id) adds id to the set and persists", () => {
const { result } = renderHook(() => useDismissedCardIds(["a", "b"]));
act(() => result.current.dismiss("a"));
expect(result.current.dismissedIds.has("a")).toBe(true);
const stored = JSON.parse(localStorage.getItem(DISMISSED_CARD_IDS_STORAGE_KEY) ?? "[]") as string[];
expect(stored).toContain("a");
});
it("undismiss(id) removes id from the set and persists", () => {
localStorage.setItem(DISMISSED_CARD_IDS_STORAGE_KEY, JSON.stringify(["a", "b"]));
const { result } = renderHook(() => useDismissedCardIds(["a", "b"]));
act(() => result.current.undismiss("a"));
expect(result.current.dismissedIds.has("a")).toBe(false);
expect(result.current.dismissedIds.has("b")).toBe(true);
});
// BLOCKER-2 discriminator: each card.id is its own dismissal key.
// Dismissing one queue-derived card MUST NOT hide others.
it("BLOCKER-2 fix: dismissing one card id does NOT affect other cards (independent dismissal state)", () => {
const { result } = renderHook(() =>
useDismissedCardIds(["queue-attention-q1", "queue-attention-q2", "queue-attention-q3"]),
);
act(() => result.current.dismiss("queue-attention-q1"));
expect(result.current.dismissedIds.has("queue-attention-q1")).toBe(true);
expect(result.current.dismissedIds.has("queue-attention-q2")).toBe(false);
expect(result.current.dismissedIds.has("queue-attention-q3")).toBe(false);
});
it("auto-prune: a dismissed id no longer in currentIds is dropped from the set", () => {
localStorage.setItem(
DISMISSED_CARD_IDS_STORAGE_KEY,
JSON.stringify(["queue-attention-evicted", "queue-attention-still-here"]),
);
// Render with currentIds that excludes "queue-attention-evicted"
// (the qitem has closed and the card disappeared from the feed).
const { result, rerender } = renderHook(
({ ids }) => useDismissedCardIds(ids),
{ initialProps: { ids: ["queue-attention-still-here"] } },
);
rerender({ ids: ["queue-attention-still-here"] });
// After the prune effect runs, the evicted id is gone.
expect(result.current.dismissedIds.has("queue-attention-evicted")).toBe(false);
expect(result.current.dismissedIds.has("queue-attention-still-here")).toBe(true);
});
it("dismiss is idempotent — calling twice doesn't double-add or churn storage", () => {
const { result } = renderHook(() => useDismissedCardIds(["a"]));
act(() => result.current.dismiss("a"));
act(() => result.current.dismiss("a"));
expect(result.current.dismissedIds.size).toBe(1);
});
it("uses a distinct localStorage namespace from useDismissedSeqs (no cross-contamination)", () => {
// Storage key must be different from the seq-keyed hook's key.
expect(DISMISSED_CARD_IDS_STORAGE_KEY).toBe("forYou.dismissedCardIds");
expect(DISMISSED_CARD_IDS_STORAGE_KEY).not.toBe("forYou.dismissedSeqs");
});
});