mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
merge: slice 20 — For You priority windowing (Option 3 server-side attention query) (release-0.3.2)
This commit is contained in:
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user