From 02b93196f68384a9a474d5021b131befd52fb74b Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 02:42:33 -0400 Subject: [PATCH 1/4] 169: persist passive blocker projections --- docs/architecture.md | 3 + src/server/blockers/index.ts | 12 +- src/server/blockers/repository.ts | 179 ++++++++++++++++++ src/server/blockers/types.ts | 2 + .../20260827020000_durable_blockers.sql | 30 +++ src/server/db/sqlite-schema.ts | 1 + src/server/recon/index.ts | 5 + src/server/recon/passive-auth-blockers.ts | 147 ++++++++++++++ src/server/recon/passive-auth-surface.ts | 17 +- .../discovery-artifact-normalizer.test.ts | 15 ++ .../integration/passive-auth-blockers.test.ts | 156 +++++++++++++++ 11 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 src/server/db/migrations/20260827020000_durable_blockers.sql create mode 100644 src/server/recon/passive-auth-blockers.ts create mode 100644 tests/integration/passive-auth-blockers.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 411374ef9..cd549fa1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,6 +79,7 @@ src/ │ └── index.ts Mastra singleton ├── server/ Product domain services │ ├── approvals/ Durable approval logic +│ ├── blockers/ Durable safety and workflow blockers │ ├── chat/ Turn runner, Mastra adapter, stream projection │ ├── projects/ Project and thread service layer │ ├── targets/ Target inventory and authorization @@ -132,6 +133,8 @@ Generated evidence and uploads should flow through `src/server/evidence/artifact The artifact service writes or references object content, records metadata, links evidence to project/thread/target/task/finding scope, computes stable hashes, indexes text evidence for retrieval when possible, and notifies the project UI. Findings link back to evidence artifact IDs instead of relying on chat prose. +Blockers are canonical project records in the app database. Passive-auth normalization upserts one blocker per project, target, task, and reason, retaining the current report and raw source Artifact IDs. Its suggested actions are review-only labels: projecting a passive signal never retries a tool, broadens network scope, requests approval, or interacts with a target. The in-memory repository remains available only for explicitly isolated tests. + Evidence ingestion keeps format-aware segmentation for source symbols, log windows, record-oriented data, and diff hunks, then delegates final chunking to Mastra's document chunker. Vector and GraphRAG queries share one versioned index configuration and preserve canonical citation metadata. Both retrieval tools require project context; thread filtering is optional. When the embedding or chunk/filter schema changes, `pnpm rag:reindex` rebuilds readable artifact vectors into the current index. The v3 artifact index adds LanceDB's flattened metadata columns used by replacement deletes; reindexing succeeds before stale indexes are pruned. The pinned `@mastra/lance` package carries a local patch for three adapter defects exercised by retrieval tests: camelCase metadata columns must be quoted for DataFusion filters, memory-style indexes must target their table when deleting multiple vectors, and local IVFFlat creation must use LanceDB's IVFFlat implementation. Remove each patch hunk when the corresponding upstream behavior is available in the pinned release. diff --git a/src/server/blockers/index.ts b/src/server/blockers/index.ts index 367728758..46f2c2906 100644 --- a/src/server/blockers/index.ts +++ b/src/server/blockers/index.ts @@ -1,5 +1,7 @@ export { + createDatabaseBlockerRepository, createInMemoryBlockerRepository, + DatabaseBlockerRepository, InMemoryBlockerRepository, } from "./repository"; export { @@ -19,7 +21,7 @@ export type { ListBlockersQuery, } from "./types"; -import { createInMemoryBlockerRepository } from "./repository"; +import { createDatabaseBlockerRepository, createInMemoryBlockerRepository } from "./repository"; import { createBlockerService } from "./service"; import type { BlockerService } from "./types"; @@ -27,7 +29,13 @@ let defaultBlockerService: BlockerService | undefined; export function getDefaultBlockerService(): BlockerService { if (!defaultBlockerService) { - defaultBlockerService = createBlockerService(createInMemoryBlockerRepository()); + // Product runtime must survive restart. Tests keep their existing isolated + // repository unless they explicitly exercise the database implementation. + const repository = + process.env.NODE_ENV === "test" + ? createInMemoryBlockerRepository() + : createDatabaseBlockerRepository(); + defaultBlockerService = createBlockerService(repository); } return defaultBlockerService; } diff --git a/src/server/blockers/repository.ts b/src/server/blockers/repository.ts index b7285f44a..dd7ded69d 100644 --- a/src/server/blockers/repository.ts +++ b/src/server/blockers/repository.ts @@ -1,4 +1,5 @@ import { createId } from "../../lib/ids"; +import { type Queryable, withDatabase } from "../db/client"; import type { BlockerRecord, BlockerRepository, @@ -77,6 +78,184 @@ export function createInMemoryBlockerRepository(): BlockerRepository { return new InMemoryBlockerRepository(); } +type BlockerRow = { + id: string; + project_id: string; + thread_id: string | null; + target_id: string | null; + task_id: string | null; + reason: string; + title: string; + detail: string; + severity: BlockerRecord["severity"]; + next_actions: unknown; + source: string; + source_ref_id: string | null; + metadata: unknown; + created_at: string; + resolved_at: string | null; +}; + +export class DatabaseBlockerRepository implements BlockerRepository { + constructor(private readonly database?: Queryable) {} + + private run(operation: (db: Queryable) => Promise): Promise { + return this.database ? operation(this.database) : withDatabase(operation); + } + + async create(input: CreateBlockerInput): Promise { + return this.run(async (db) => { + const id = createId("blocker"); + const now = input.createdAt ?? new Date().toISOString(); + const values = [ + id, + input.projectId, + input.threadId ?? null, + input.targetId ?? null, + input.taskId ?? null, + input.reason, + input.title, + input.detail, + input.severity, + JSON.stringify(input.nextActions ?? []), + input.source ?? "manual", + input.sourceRefId ?? null, + JSON.stringify(input.metadata ?? {}), + input.dedupeKey ?? null, + now, + ]; + const result = await db.query( + `INSERT INTO blockers + (id, project_id, thread_id, target_id, task_id, reason, title, detail, + severity, next_actions, source, source_ref_id, metadata, dedupe_key, + created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11,$12,$13::jsonb,$14,$15,$15) + ON CONFLICT (project_id, dedupe_key) DO UPDATE SET + thread_id = excluded.thread_id, + target_id = excluded.target_id, + task_id = excluded.task_id, + reason = excluded.reason, + title = excluded.title, + detail = excluded.detail, + severity = excluded.severity, + next_actions = excluded.next_actions, + source = excluded.source, + source_ref_id = excluded.source_ref_id, + metadata = excluded.metadata, + resolved_at = NULL, + updated_at = excluded.updated_at + RETURNING *`, + values, + ); + return mapBlockerRow(requireBlockerRow(result.rows[0], "record")); + }); + } + + async get(id: string): Promise { + return this.run(async (db) => { + const result = await db.query("SELECT * FROM blockers WHERE id = $1 LIMIT 1", [ + id, + ]); + return result.rows[0] ? mapBlockerRow(result.rows[0]) : undefined; + }); + } + + async list(query: ListBlockersQuery): Promise { + return this.run(async (db) => { + const values: unknown[] = [query.projectId]; + const where = ["project_id = $1"]; + const add = (column: string, value: unknown) => { + values.push(value); + where.push(`${column} = $${values.length}`); + }; + if (query.threadId) add("thread_id", query.threadId); + if (query.targetId) add("target_id", query.targetId); + if (query.taskId) add("task_id", query.taskId); + if (query.reason) add("reason", query.reason); + if (query.resolved === true) where.push("resolved_at IS NOT NULL"); + if (query.resolved === false) where.push("resolved_at IS NULL"); + values.push(Math.max(0, Math.min(500, Math.trunc(query.limit ?? 100)))); + const result = await db.query( + `SELECT * FROM blockers WHERE ${where.join(" AND ")} + ORDER BY created_at ASC, id ASC LIMIT $${values.length}`, + values, + ); + return result.rows.map(mapBlockerRow); + }); + } + + async resolve(id: string, resolvedAt?: string): Promise { + return this.run(async (db) => { + const now = resolvedAt ?? new Date().toISOString(); + const result = await db.query( + "UPDATE blockers SET resolved_at = $2, updated_at = $2 WHERE id = $1 RETURNING *", + [id, now], + ); + return mapBlockerRow(requireBlockerRow(result.rows[0], id)); + }); + } + + async listActive(projectId: string): Promise { + return this.list({ projectId, resolved: false }); + } +} + +export function createDatabaseBlockerRepository(database?: Queryable): BlockerRepository { + return new DatabaseBlockerRepository(database); +} + +function requireBlockerRow(row: BlockerRow | undefined, id: string): BlockerRow { + if (!row) throw new Error(`Blocker ${id} was not found.`); + return row; +} + +function mapBlockerRow(row: BlockerRow): BlockerRecord { + const nextActions = parseJson(row.next_actions, []); + const metadata = parseJson(row.metadata, {}); + return { + id: row.id, + projectId: row.project_id, + reason: row.reason, + title: row.title, + detail: row.detail, + severity: row.severity, + nextActions: Array.isArray(nextActions) + ? nextActions.filter(isBlockerAction).map((action) => ({ ...action })) + : [], + source: row.source, + createdAt: row.created_at, + ...(row.thread_id ? { threadId: row.thread_id } : {}), + ...(row.target_id ? { targetId: row.target_id } : {}), + ...(row.task_id ? { taskId: row.task_id } : {}), + ...(row.source_ref_id ? { sourceRefId: row.source_ref_id } : {}), + ...(row.resolved_at ? { resolvedAt: row.resolved_at } : {}), + ...(isPlainRecord(metadata) ? { metadata } : {}), + }; +} + +function isBlockerAction(value: unknown): value is BlockerRecord["nextActions"][number] { + return ( + isPlainRecord(value) && + typeof value.label === "string" && + typeof value.action === "string" && + (value.params === undefined || isPlainRecord(value.params)) && + (value.requiresApproval === undefined || typeof value.requiresApproval === "boolean") + ); +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function parseJson(value: unknown, fallback: unknown): unknown { + if (typeof value !== "string") return value ?? fallback; + try { + return JSON.parse(value) as unknown; + } catch { + return fallback; + } +} + function cloneBlocker(blocker: BlockerRecord): BlockerRecord { return { ...blocker, diff --git a/src/server/blockers/types.ts b/src/server/blockers/types.ts index 06075f413..c906f13f9 100644 --- a/src/server/blockers/types.ts +++ b/src/server/blockers/types.ts @@ -53,6 +53,8 @@ export type CreateBlockerInput = { sourceRefId?: string; createdAt?: string; metadata?: Record; + /** Stable identity for sources that should update one blocker instead of appending duplicates. */ + dedupeKey?: string; }; export type ListBlockersQuery = { diff --git a/src/server/db/migrations/20260827020000_durable_blockers.sql b/src/server/db/migrations/20260827020000_durable_blockers.sql new file mode 100644 index 000000000..821d6bfe0 --- /dev/null +++ b/src/server/db/migrations/20260827020000_durable_blockers.sql @@ -0,0 +1,30 @@ +-- migrate:up +CREATE TABLE IF NOT EXISTS blockers ( + id text PRIMARY KEY, + project_id text NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + thread_id text REFERENCES chat_threads(id) ON DELETE SET NULL, + target_id text REFERENCES targets(id) ON DELETE SET NULL, + task_id text REFERENCES tasks(id) ON DELETE SET NULL, + reason text NOT NULL, + title text NOT NULL, + detail text NOT NULL, + severity text NOT NULL CHECK (severity IN ('info', 'warning', 'danger')), + next_actions text NOT NULL DEFAULT '[]', + source text NOT NULL, + source_ref_id text, + metadata text NOT NULL DEFAULT '{}', + dedupe_key text, + created_at text NOT NULL, + updated_at text NOT NULL, + resolved_at text, + UNIQUE(project_id, dedupe_key) +); +CREATE INDEX IF NOT EXISTS blockers_project_active_idx + ON blockers(project_id, resolved_at, created_at, id); +CREATE INDEX IF NOT EXISTS blockers_target_active_idx + ON blockers(project_id, target_id, resolved_at, reason); +CREATE INDEX IF NOT EXISTS blockers_thread_active_idx + ON blockers(project_id, thread_id, resolved_at, created_at); + +-- migrate:down +DROP TABLE IF EXISTS blockers; diff --git a/src/server/db/sqlite-schema.ts b/src/server/db/sqlite-schema.ts index a3781866b..f674aa4c7 100644 --- a/src/server/db/sqlite-schema.ts +++ b/src/server/db/sqlite-schema.ts @@ -74,6 +74,7 @@ export const SQLITE_JSON_COLUMNS = new Set([ "raw_diagnostic", "provenance", "resources", + "next_actions", ]); export const SQLITE_BOOLEAN_COLUMNS = new Set([ diff --git a/src/server/recon/index.ts b/src/server/recon/index.ts index fd5cd7b44..ad51bc886 100644 --- a/src/server/recon/index.ts +++ b/src/server/recon/index.ts @@ -1,3 +1,8 @@ +export { + describePassiveBlocker, + persistPassiveAuthBlockers, +} from "./passive-auth-blockers"; +export type { PersistPassiveAuthBlockersInput } from "./passive-auth-blockers"; export { type AuthSurfaceCandidate, type AuthSurfaceCategory, diff --git a/src/server/recon/passive-auth-blockers.ts b/src/server/recon/passive-auth-blockers.ts new file mode 100644 index 000000000..ad655b589 --- /dev/null +++ b/src/server/recon/passive-auth-blockers.ts @@ -0,0 +1,147 @@ +import type { + BlockerAction, + BlockerRecord, + BlockerService, + BlockerSeverity, +} from "../blockers"; +import type { DiscoveryBlockerReason } from "./discovery-artifact-normalizer"; +import type { PassiveAuthSurfaceSummary } from "./passive-auth-surface"; + +export type PersistPassiveAuthBlockersInput = { + projectId: string; + threadId?: string; + targetId: string; + taskId?: string; + summaryArtifactId: string; + summary: Pick; +}; + +type PassiveBlockerPresentation = { + title: string; + detail: string; + severity: BlockerSeverity; + nextActions: BlockerAction[]; +}; + +export async function persistPassiveAuthBlockers( + input: PersistPassiveAuthBlockersInput, + blockerService: BlockerService, +): Promise { + const records: BlockerRecord[] = []; + for (const signal of input.summary.blockers) { + const presentation = describePassiveBlocker(signal.reason, signal.evidence); + records.push( + await blockerService.record({ + projectId: input.projectId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + ...(input.taskId ? { taskId: input.taskId } : {}), + reason: signal.reason, + ...presentation, + source: "passive-auth-surface", + sourceRefId: input.summaryArtifactId, + dedupeKey: passiveBlockerDedupeKey(input, signal.reason), + metadata: { + workflow: "passive-auth-surface-v1", + summaryArtifactId: input.summaryArtifactId, + sourceArtifactIds: [...signal.evidenceArtifactIds].sort(), + passiveOnly: true, + }, + }), + ); + } + return records; +} + +function passiveBlockerDedupeKey( + input: Pick, + reason: DiscoveryBlockerReason, +) { + return [ + "passive-auth-surface-v1", + input.targetId, + input.taskId ?? "project", + reason, + ].join(":"); +} + +export function describePassiveBlocker( + reason: DiscoveryBlockerReason, + evidence: string, +): PassiveBlockerPresentation { + const review = (label: string): BlockerAction[] => [ + { label, action: "queue_action", params: { label } }, + ]; + switch (reason) { + case "captcha-detected": + return { + title: "Captcha observed in stored evidence", + detail: evidence, + severity: "warning", + nextActions: review("Review captcha evidence"), + }; + case "bot-block-detected": + return { + title: "Bot protection observed in stored evidence", + detail: evidence, + severity: "warning", + nextActions: review("Review bot-protection evidence"), + }; + case "waf-denied": + return { + title: "WAF denial observed in stored evidence", + detail: evidence, + severity: "warning", + nextActions: review("Review WAF evidence"), + }; + case "rate-limited": + return { + title: "Rate limiting observed in stored evidence", + detail: evidence, + severity: "warning", + nextActions: review("Review rate-limit evidence"), + }; + case "auth-required": + return { + title: "Authentication is required", + detail: evidence, + severity: "warning", + nextActions: review("Review authentication boundary"), + }; + case "approval-required": + return { + title: "Approval is required", + detail: evidence, + severity: "warning", + nextActions: review("Review approval requirements"), + }; + case "target-authorization-required": + return { + title: "Target authorization is required", + detail: evidence, + severity: "danger", + nextActions: review("Review target scope and authorization"), + }; + case "workspace-locked": + return { + title: "Workspace is locked", + detail: evidence, + severity: "warning", + nextActions: review("Review workspace owner"), + }; + case "network-profile-blocked": + return { + title: "Network profile blocked the prior work", + detail: evidence, + severity: "warning", + nextActions: review("Review authorized network scope"), + }; + case "tool-unavailable": + return { + title: "Required tool is unavailable", + detail: evidence, + severity: "warning", + nextActions: review("Review tool availability"), + }; + } +} diff --git a/src/server/recon/passive-auth-surface.ts b/src/server/recon/passive-auth-surface.ts index cd6af7101..a76bd712d 100644 --- a/src/server/recon/passive-auth-surface.ts +++ b/src/server/recon/passive-auth-surface.ts @@ -2,6 +2,7 @@ import type { ArtifactServiceInstance, CreateArtifactResult, } from "../evidence"; +import { getDefaultBlockerService, type BlockerRecord, type BlockerService } from "../blockers"; import { type AuthSurfaceCategory, type DiscoveryArtifactInput, @@ -9,6 +10,7 @@ import { type NormalizedDiscovery, normalizeDiscoveryArtifacts, } from "./discovery-artifact-normalizer"; +import { persistPassiveAuthBlockers } from "./passive-auth-blockers"; export type PassiveAuthSurfaceSummary = { targetId: string; @@ -44,6 +46,7 @@ export type PersistPassiveAuthSurfaceResult = { normalized: NormalizedDiscovery; summary: PassiveAuthSurfaceSummary; artifact: CreateArtifactResult; + blockers: BlockerRecord[]; }; type ArtifactWriter = Pick; @@ -51,6 +54,7 @@ type ArtifactWriter = Pick; export async function persistPassiveAuthSurface( input: PersistPassiveAuthSurfaceInput, artifactWriter: ArtifactWriter, + blockerService: BlockerService = getDefaultBlockerService(), ): Promise { const normalized = normalizeDiscoveryArtifacts(input.artifacts); const summary = buildPassiveAuthSurfaceSummary(input.targetId, normalized); @@ -75,8 +79,19 @@ export async function persistPassiveAuthSurface( authCategories: summary.authRoutes.map((route) => route.category), }, }); + const blockers = await persistPassiveAuthBlockers( + { + projectId: input.projectId, + ...(input.threadId ? { threadId: input.threadId } : {}), + targetId: input.targetId, + ...(input.taskId ? { taskId: input.taskId } : {}), + summaryArtifactId: artifact.id, + summary, + }, + blockerService, + ); - return { normalized, summary, artifact }; + return { normalized, summary, artifact, blockers }; } export function buildPassiveAuthSurfaceSummary( diff --git a/tests/integration/discovery-artifact-normalizer.test.ts b/tests/integration/discovery-artifact-normalizer.test.ts index 11cd5e9f6..80fa803f0 100644 --- a/tests/integration/discovery-artifact-normalizer.test.ts +++ b/tests/integration/discovery-artifact-normalizer.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { createBlockerService, createInMemoryBlockerRepository } from "../../src/server/blockers"; import { normalizeDiscoveryArtifacts, persistPassiveAuthSurface, @@ -109,6 +110,7 @@ describe("discovery artifact normalization", () => { ], }, { createArtifact } as never, + createBlockerService(createInMemoryBlockerRepository()), ); expect(result.summary.authRoutes).toEqual([ @@ -123,6 +125,19 @@ describe("discovery artifact normalization", () => { reason: "rate-limited", evidenceArtifactIds: ["artifact-raw"], }); + expect(result.blockers).toEqual([ + expect.objectContaining({ + projectId: "project-1", + threadId: "thread-1", + targetId: "target-1", + taskId: "task-1", + reason: "rate-limited", + sourceRefId: "artifact-summary", + nextActions: [ + expect.objectContaining({ action: "queue_action" }), + ], + }), + ]); expect(createArtifact).toHaveBeenCalledWith( expect.objectContaining({ projectId: "project-1", diff --git a/tests/integration/passive-auth-blockers.test.ts b/tests/integration/passive-auth-blockers.test.ts new file mode 100644 index 000000000..5cb1efddb --- /dev/null +++ b/tests/integration/passive-auth-blockers.test.ts @@ -0,0 +1,156 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + createBlockerService, + createDatabaseBlockerRepository, +} from "../../src/server/blockers"; +import { getProjectStore } from "../../src/server/chat/projectAdapter"; +import { + describePassiveBlocker, + persistPassiveAuthBlockers, +} from "../../src/server/recon"; +import { upsertProjectTarget } from "../../src/server/targets"; +import { upsertResearchTasks } from "../../src/server/tasks/tracker"; + +describe("durable passive-auth blockers", () => { + let databaseRoot: string; + let previousDatabaseUrl: string | undefined; + + beforeEach(async () => { + previousDatabaseUrl = process.env.EH_APP_DB_URL; + databaseRoot = await mkdtemp(join(tmpdir(), "passive-auth-blockers-")); + process.env.EH_APP_DB_URL = `sqlite://${join(databaseRoot, "app.sqlite")}`; + }); + + afterEach(async () => { + if (previousDatabaseUrl === undefined) delete process.env.EH_APP_DB_URL; + else process.env.EH_APP_DB_URL = previousDatabaseUrl; + await rm(databaseRoot, { recursive: true, force: true }); + }); + + it("updates one target-bound blocker and reconstructs it from a fresh service", async () => { + const store = await getProjectStore(); + const project = await store.createProject({ + name: "Durable passive blockers", + }); + const thread = await store.createThread(project.id, { + title: "Map authentication", + }); + await upsertProjectTarget(project.id, { + id: "target-app", + threadId: thread.id, + kind: "web", + label: "Authorized app", + locator: "https://app.example.test", + }); + await upsertResearchTasks( + project.id, + [ + { + id: "task-map-auth", + threadId: thread.id, + targetId: "target-app", + title: "Map authentication", + }, + ], + { threadId: thread.id }, + ); + + const firstService = createBlockerService( + createDatabaseBlockerRepository(), + ); + const baseInput = { + projectId: project.id, + threadId: thread.id, + targetId: "target-app", + taskId: "task-map-auth", + summary: { + blockers: [ + { + reason: "rate-limited" as const, + evidenceArtifactIds: ["artifact-source-a"], + evidence: "HTTP 429 Too Many Requests", + }, + ], + }, + }; + const first = await persistPassiveAuthBlockers( + { ...baseInput, summaryArtifactId: "artifact-summary-a" }, + firstService, + ); + const second = await persistPassiveAuthBlockers( + { + ...baseInput, + summaryArtifactId: "artifact-summary-b", + summary: { + blockers: [ + { + reason: "rate-limited", + evidenceArtifactIds: ["artifact-source-a", "artifact-source-b"], + evidence: "A later stored response also reports rate limiting", + }, + ], + }, + }, + firstService, + ); + + expect(second[0]?.id).toBe(first[0]?.id); + const reloaded = await createBlockerService( + createDatabaseBlockerRepository(), + ).listActive(project.id); + expect(reloaded).toEqual([ + expect.objectContaining({ + id: first[0]?.id, + projectId: project.id, + threadId: thread.id, + targetId: "target-app", + taskId: "task-map-auth", + reason: "rate-limited", + detail: "A later stored response also reports rate limiting", + source: "passive-auth-surface", + sourceRefId: "artifact-summary-b", + metadata: expect.objectContaining({ + passiveOnly: true, + summaryArtifactId: "artifact-summary-b", + sourceArtifactIds: ["artifact-source-a", "artifact-source-b"], + }), + }), + ]); + }); + + it("maps every normalized signal to review-only next actions", () => { + const reasons = [ + "captcha-detected", + "bot-block-detected", + "waf-denied", + "rate-limited", + "auth-required", + "approval-required", + "target-authorization-required", + "workspace-locked", + "network-profile-blocked", + "tool-unavailable", + ] as const; + + for (const reason of reasons) { + const blocker = describePassiveBlocker( + reason, + `Stored evidence for ${reason}`, + ); + expect(blocker.nextActions).toEqual([ + expect.objectContaining({ action: "queue_action" }), + ]); + expect(blocker.nextActions).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ action: "approve_request" }), + expect.objectContaining({ action: "confirm_target_authorization" }), + ]), + ); + } + }); +}); From 00ebabbb661b98dc0dc969c310a24f6d089571ec Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 02:51:43 -0400 Subject: [PATCH 2/4] 169: close blocker durability review gaps --- src/components/chat/WorkspaceDashboard.tsx | 66 ++++++++++ src/components/chat/messageUtils.ts | 21 ++++ src/server/blockers/repository.ts | 20 ++- src/server/chat/research-cockpit.ts | 21 +++- src/server/db/postgres-migrate.ts | 1 + src/server/recon/index.ts | 10 +- src/server/recon/passive-auth-blockers.ts | 6 +- src/server/recon/passive-auth-surface.ts | 6 +- src/styles/chat.css | 59 ++++++++- .../integration/passive-auth-blockers.test.ts | 115 ++++++++++++++++++ 10 files changed, 311 insertions(+), 14 deletions(-) diff --git a/src/components/chat/WorkspaceDashboard.tsx b/src/components/chat/WorkspaceDashboard.tsx index e270612a3..3f99b8d33 100644 --- a/src/components/chat/WorkspaceDashboard.tsx +++ b/src/components/chat/WorkspaceDashboard.tsx @@ -56,6 +56,14 @@ function getCockpitSignal(cockpit: ResearchCockpitView | null) { if (!cockpit) { return null; } + const activeBlockers = cockpit.summary.blockers.total; + if (activeBlockers > 0) { + return { + label: `${activeBlockers} blocker${activeBlockers === 1 ? "" : "s"}`, + detail: "Review durable blocker evidence before continuing research.", + icon: , + }; + } const pendingApprovals = cockpit.summary.approvals.byStatus.pending ?? 0; if (pendingApprovals > 0) { return { @@ -178,6 +186,7 @@ export function WorkspaceDashboard({ targetAuthorizationDecisions, }); const attackPaths = cockpit?.attackPaths ?? []; + const activeBlockers = cockpit?.activeBlockers ?? []; const completeSignals = planItems.filter((item) => item.status === "completed").length + evidenceItems.filter((item) => item.done).length; @@ -194,6 +203,7 @@ export function WorkspaceDashboard({ const hasDashboardContent = planItems.length > 0 || evidenceItems.length > 0 || + activeBlockers.length > 0 || attackPaths.length > 0 || Boolean(latestFinding) || Boolean(latestApproval); @@ -308,6 +318,62 @@ export function WorkspaceDashboard({
+ {activeBlockers.length > 0 ? ( +
+
+
+ + + Active blockers + +

+ Active blockers +

+
+ + {activeBlockers.length < (cockpit?.summary.blockers.total ?? 0) + ? `${activeBlockers.length} of ${cockpit?.summary.blockers.total}` + : activeBlockers.length}{" "} + blocker{(cockpit?.summary.blockers.total ?? activeBlockers.length) === 1 ? "" : "s"} + +
+
    + {activeBlockers.map((blocker) => ( +
  • +
    + {blocker.title} + {blocker.severity} +
    +

    {blocker.detail}

    + + {blocker.reason} + {blocker.targetId ? ` · target ${blocker.targetId}` : ""} + {blocker.taskId ? ` · task ${blocker.taskId}` : ""} + + {blocker.summaryArtifactId || blocker.sourceRefId ? ( + + Summary Artifact: {blocker.summaryArtifactId ?? blocker.sourceRefId} + + ) : null} + {blocker.evidenceArtifactIds?.length ? ( + Source Artifacts: {blocker.evidenceArtifactIds.join(", ")} + ) : null} + {blocker.nextActions.length > 0 ? ( +
    + {blocker.nextActions.map((action) => ( + {action.label} + ))} +
    + ) : null} +
  • + ))} +
+
+ ) : null} + {planItems.length > 0 ? (
}; attackPaths: { total: number; byStatus: Record }; schedulerTasks: { total: number; byStatus: Record }; + blockers: { + total: number; + byReason: Record; + bySeverity: Record; + }; codeReviewRuns?: { total: number; byStatus: Record }; artifacts: { total: number; @@ -300,6 +305,22 @@ export type ResearchCockpitView = { blockerCount: number; updatedAt: string; }>; + activeBlockers: Array<{ + id: string; + threadId?: string; + targetId?: string; + taskId?: string; + reason: string; + title: string; + detail: string; + severity: "info" | "warning" | "danger"; + source: string; + sourceRefId?: string; + summaryArtifactId?: string; + evidenceArtifactIds?: string[]; + nextActions: Array<{ label: string; action: string }>; + createdAt: string; + }>; recentCodeReviewRuns?: Array<{ id: string; threadId?: string; diff --git a/src/server/blockers/repository.ts b/src/server/blockers/repository.ts index dd7ded69d..d7ff1b96c 100644 --- a/src/server/blockers/repository.ts +++ b/src/server/blockers/repository.ts @@ -129,7 +129,19 @@ export class DatabaseBlockerRepository implements BlockerRepository { (id, project_id, thread_id, target_id, task_id, reason, title, detail, severity, next_actions, source, source_ref_id, metadata, dedupe_key, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11,$12,$13::jsonb,$14,$15,$15) + SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11,$12,$13::jsonb,$14,$15,$15 + WHERE ($3::text IS NULL OR EXISTS ( + SELECT 1 FROM chat_threads WHERE project_id = $2 AND id = $3 + )) + AND ($4::text IS NULL OR EXISTS ( + SELECT 1 FROM targets WHERE project_id = $2 AND id = $4 + )) + AND ($5::text IS NULL OR EXISTS ( + SELECT 1 FROM tasks + WHERE project_id = $2 AND id = $5 + AND ($3::text IS NULL OR thread_id = $3) + AND ($4::text IS NULL OR metadata->>'targetId' = $4) + )) ON CONFLICT (project_id, dedupe_key) DO UPDATE SET thread_id = excluded.thread_id, target_id = excluded.target_id, @@ -144,10 +156,14 @@ export class DatabaseBlockerRepository implements BlockerRepository { metadata = excluded.metadata, resolved_at = NULL, updated_at = excluded.updated_at + WHERE blockers.thread_id IS NOT DISTINCT FROM excluded.thread_id + AND blockers.target_id IS NOT DISTINCT FROM excluded.target_id + AND blockers.task_id IS NOT DISTINCT FROM excluded.task_id + AND blockers.reason = excluded.reason RETURNING *`, values, ); - return mapBlockerRow(requireBlockerRow(result.rows[0], "record")); + return mapBlockerRow(requireBlockerRow(result.rows[0], "attribution")); }); } diff --git a/src/server/chat/research-cockpit.ts b/src/server/chat/research-cockpit.ts index 1bc657bb5..16c8a24ef 100644 --- a/src/server/chat/research-cockpit.ts +++ b/src/server/chat/research-cockpit.ts @@ -167,6 +167,8 @@ export type ResearchCockpitBlocker = { severity: BlockerSeverity; source: string; sourceRefId?: string; + summaryArtifactId?: string; + evidenceArtifactIds?: string[]; nextActions: BlockerAction[]; createdAt: string; }; @@ -433,7 +435,7 @@ async function buildProjectResearchCockpitWithDb( }, recentFindings, attackPaths, - activeBlockers, + activeBlockers: activeBlockers.slice(0, 5), recentSchedulerTasks, recentValidationPlans, recentCodeReviewRuns, @@ -472,7 +474,6 @@ async function readActiveBlockers( return blockers .filter((blocker) => !threadId || blocker.threadId === threadId || !blocker.threadId) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, 5) .map((blocker) => ({ id: blocker.id, ...(blocker.threadId ? { threadId: blocker.threadId } : {}), @@ -484,11 +485,27 @@ async function readActiveBlockers( severity: blocker.severity, source: blocker.source, ...(blocker.sourceRefId ? { sourceRefId: blocker.sourceRefId } : {}), + ...readBlockerArtifactLinks(blocker.metadata), nextActions: blocker.nextActions.map((action) => ({ ...action })), createdAt: blocker.createdAt, })); } +function readBlockerArtifactLinks(metadata: Record | undefined) { + if (!metadata) return {}; + const summaryArtifactId = + typeof metadata.summaryArtifactId === "string" ? metadata.summaryArtifactId : undefined; + const evidenceArtifactIds = Array.isArray(metadata.sourceArtifactIds) + ? metadata.sourceArtifactIds + .filter((value): value is string => typeof value === "string") + .slice(0, 12) + : []; + return { + ...(summaryArtifactId ? { summaryArtifactId } : {}), + ...(evidenceArtifactIds.length > 0 ? { evidenceArtifactIds } : {}), + }; +} + function summarizeActiveBlockers( blockers: ResearchCockpitBlocker[], ): ResearchCockpitBlockerSummary { diff --git a/src/server/db/postgres-migrate.ts b/src/server/db/postgres-migrate.ts index 40f847975..bbc42cfc2 100644 --- a/src/server/db/postgres-migrate.ts +++ b/src/server/db/postgres-migrate.ts @@ -176,6 +176,7 @@ const APP_TABLES = [ "policy_learning_trajectories", "policy_trajectory_feedback", "passive_policy_shadow_records", + "blockers", ] as const; const POSTGRES_JSON_TEXT_EXCEPTIONS = new Set([ // This is a constrained workflow enum, despite sharing a legacy SQLite JSON-column name. diff --git a/src/server/recon/index.ts b/src/server/recon/index.ts index ad51bc886..a2a752667 100644 --- a/src/server/recon/index.ts +++ b/src/server/recon/index.ts @@ -1,8 +1,3 @@ -export { - describePassiveBlocker, - persistPassiveAuthBlockers, -} from "./passive-auth-blockers"; -export type { PersistPassiveAuthBlockersInput } from "./passive-auth-blockers"; export { type AuthSurfaceCandidate, type AuthSurfaceCategory, @@ -15,6 +10,11 @@ export { type NormalizedDiscoveryUrl, normalizeDiscoveryArtifacts, } from "./discovery-artifact-normalizer"; +export { + describePassiveBlocker, + type PersistPassiveAuthBlockersInput, + persistPassiveAuthBlockers, +} from "./passive-auth-blockers"; export { buildPassiveAuthSurfaceSummary, type PassiveAuthSurfaceSummary, diff --git a/src/server/recon/passive-auth-blockers.ts b/src/server/recon/passive-auth-blockers.ts index ad655b589..b2bda7e86 100644 --- a/src/server/recon/passive-auth-blockers.ts +++ b/src/server/recon/passive-auth-blockers.ts @@ -57,12 +57,12 @@ function passiveBlockerDedupeKey( input: Pick, reason: DiscoveryBlockerReason, ) { - return [ + return JSON.stringify([ "passive-auth-surface-v1", input.targetId, - input.taskId ?? "project", + input.taskId ?? null, reason, - ].join(":"); + ]); } export function describePassiveBlocker( diff --git a/src/server/recon/passive-auth-surface.ts b/src/server/recon/passive-auth-surface.ts index a76bd712d..6624aa5d2 100644 --- a/src/server/recon/passive-auth-surface.ts +++ b/src/server/recon/passive-auth-surface.ts @@ -1,8 +1,12 @@ +import { + type BlockerRecord, + type BlockerService, + getDefaultBlockerService, +} from "../blockers"; import type { ArtifactServiceInstance, CreateArtifactResult, } from "../evidence"; -import { getDefaultBlockerService, type BlockerRecord, type BlockerService } from "../blockers"; import { type AuthSurfaceCategory, type DiscoveryArtifactInput, diff --git a/src/styles/chat.css b/src/styles/chat.css index 84f8e7f14..a417a0588 100644 --- a/src/styles/chat.css +++ b/src/styles/chat.css @@ -2851,10 +2851,67 @@ .dashboard-finding-content, .dashboard-empty-panel, .dashboard-attack-path-list, -.dashboard-evidence-list { +.dashboard-evidence-list, +.dashboard-blocker-list { min-width: 0; } +.dashboard-blocker-list { + display: grid; + gap: 0.7rem; + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; + max-height: 40vh; +} + +.dashboard-blocker-list li { + display: grid; + gap: 0.35rem; + border-left: 2px solid rgb(244 199 106 / 65%); + padding-left: 0.65rem; +} + +.dashboard-blocker-list li > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; +} + +.dashboard-blocker-list p, +.dashboard-blocker-list small { + margin: 0; + overflow-wrap: anywhere; +} + +.dashboard-blocker-list p { + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.45; +} + +.dashboard-blocker-list small { + color: var(--text-soft); + font-size: 0.7rem; +} + +.dashboard-blocker-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.dashboard-blocker-actions span { + border: 1px solid var(--line); + border-radius: 999px; + background: rgb(255 255 255 / 4%); + color: var(--text-muted); + padding: 0.2rem 0.45rem; + font-size: 0.68rem; +} + .dashboard-approval-content { display: grid; gap: 0.7rem; diff --git a/tests/integration/passive-auth-blockers.test.ts b/tests/integration/passive-auth-blockers.test.ts index 5cb1efddb..a4f6a3ff8 100644 --- a/tests/integration/passive-auth-blockers.test.ts +++ b/tests/integration/passive-auth-blockers.test.ts @@ -9,6 +9,7 @@ import { createDatabaseBlockerRepository, } from "../../src/server/blockers"; import { getProjectStore } from "../../src/server/chat/projectAdapter"; +import { buildProjectResearchCockpit } from "../../src/server/chat/research-cockpit"; import { describePassiveBlocker, persistPassiveAuthBlockers, @@ -121,6 +122,120 @@ describe("durable passive-auth blockers", () => { }), }), ]); + const cockpit = await buildProjectResearchCockpit(project.id, { + threadId: thread.id, + blockerService: createBlockerService(createDatabaseBlockerRepository()), + }); + expect(cockpit.summary.blockers).toEqual({ + total: 1, + byReason: { "rate-limited": 1 }, + bySeverity: { warning: 1 }, + }); + expect(cockpit.activeBlockers).toEqual([ + expect.objectContaining({ + id: first[0]?.id, + summaryArtifactId: "artifact-summary-b", + evidenceArtifactIds: ["artifact-source-a", "artifact-source-b"], + }), + ]); + + await upsertProjectTarget(project.id, { + id: "a:b", + threadId: thread.id, + kind: "web", + label: "Colon target one", + locator: "https://one.example.test", + }); + await upsertProjectTarget(project.id, { + id: "a", + threadId: thread.id, + kind: "web", + label: "Colon target two", + locator: "https://two.example.test", + }); + await upsertResearchTasks( + project.id, + [ + { id: "c", threadId: thread.id, targetId: "a:b", title: "First key" }, + { id: "b:c", threadId: thread.id, targetId: "a", title: "Second key" }, + ], + { threadId: thread.id }, + ); + const collisionInput = { + projectId: project.id, + threadId: thread.id, + summaryArtifactId: "artifact-summary-colon", + summary: { + blockers: [ + { + reason: "captcha-detected" as const, + evidenceArtifactIds: ["artifact-source-colon"], + evidence: "Captcha present", + }, + ], + }, + }; + const colonFirst = await persistPassiveAuthBlockers( + { ...collisionInput, targetId: "a:b", taskId: "c" }, + firstService, + ); + const colonSecond = await persistPassiveAuthBlockers( + { ...collisionInput, targetId: "a", taskId: "b:c" }, + firstService, + ); + expect(colonSecond[0]?.id).not.toBe(colonFirst[0]?.id); + expect( + await firstService.list({ + projectId: project.id, + reason: "captcha-detected", + }), + ).toHaveLength(2); + }); + + it("rejects cross-project thread, target, and task attribution", async () => { + const store = await getProjectStore(); + const project = await store.createProject({ name: "Blocker owner" }); + const other = await store.createProject({ + name: "Foreign blocker records", + }); + const foreignThread = await store.createThread(other.id, { + title: "Foreign thread", + }); + await upsertProjectTarget(other.id, { + id: "foreign-target", + threadId: foreignThread.id, + kind: "web", + label: "Foreign target", + locator: "https://foreign.example.test", + }); + await upsertResearchTasks( + other.id, + [ + { + id: "foreign-task", + threadId: foreignThread.id, + targetId: "foreign-target", + title: "Foreign task", + }, + ], + { threadId: foreignThread.id }, + ); + + const service = createBlockerService(createDatabaseBlockerRepository()); + await expect( + service.record({ + projectId: project.id, + threadId: foreignThread.id, + targetId: "foreign-target", + taskId: "foreign-task", + reason: "auth-required", + title: "Wrong project", + detail: "Must not persist", + severity: "warning", + dedupeKey: "foreign-attribution", + }), + ).rejects.toThrow(/attribution/u); + expect(await service.listActive(project.id)).toEqual([]); }); it("maps every normalized signal to review-only next actions", () => { From fbe43c8d479f142f3c888d9c6f2038322ba9bc71 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 02:55:46 -0400 Subject: [PATCH 3/4] 169: preserve complete blocker totals --- src/server/blockers/repository.ts | 10 ++++++- .../integration/passive-auth-blockers.test.ts | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/server/blockers/repository.ts b/src/server/blockers/repository.ts index d7ff1b96c..29eeba475 100644 --- a/src/server/blockers/repository.ts +++ b/src/server/blockers/repository.ts @@ -212,7 +212,15 @@ export class DatabaseBlockerRepository implements BlockerRepository { } async listActive(projectId: string): Promise { - return this.list({ projectId, resolved: false }); + return this.run(async (db) => { + const result = await db.query( + `SELECT * FROM blockers + WHERE project_id = $1 AND resolved_at IS NULL + ORDER BY created_at DESC, id ASC`, + [projectId], + ); + return result.rows.map(mapBlockerRow); + }); } } diff --git a/tests/integration/passive-auth-blockers.test.ts b/tests/integration/passive-auth-blockers.test.ts index a4f6a3ff8..63689b530 100644 --- a/tests/integration/passive-auth-blockers.test.ts +++ b/tests/integration/passive-auth-blockers.test.ts @@ -10,6 +10,7 @@ import { } from "../../src/server/blockers"; import { getProjectStore } from "../../src/server/chat/projectAdapter"; import { buildProjectResearchCockpit } from "../../src/server/chat/research-cockpit"; +import { withDatabase } from "../../src/server/db/client"; import { describePassiveBlocker, persistPassiveAuthBlockers, @@ -238,6 +239,35 @@ describe("durable passive-auth blockers", () => { expect(await service.listActive(project.id)).toEqual([]); }); + it("does not cap canonical active-blocker totals at the list page size", async () => { + const store = await getProjectStore(); + const project = await store.createProject({ name: "Large blocker ledger" }); + const service = createBlockerService(createDatabaseBlockerRepository()); + await withDatabase((db) => + db.query( + `WITH RECURSIVE sequence(value) AS ( + SELECT 0 + UNION ALL SELECT value + 1 FROM sequence WHERE value < 100 + ) + INSERT INTO blockers + (id, project_id, reason, title, detail, severity, next_actions, source, + metadata, created_at, updated_at) + SELECT 'blocker-page-' || value, $1, 'record-' || value, + 'Blocker ' || value, 'Durable blocker total regression', 'info', + '[]', 'manual', '{}', '2026-08-27T00:00:00.000Z', + '2026-08-27T00:00:00.000Z' + FROM sequence`, + [project.id], + ), + ); + + const cockpit = await buildProjectResearchCockpit(project.id, { + blockerService: service, + }); + expect(cockpit.summary.blockers.total).toBe(101); + expect(cockpit.activeBlockers).toHaveLength(5); + }); + it("maps every normalized signal to review-only next actions", () => { const reasons = [ "captcha-detected", From 8a70e1d6dab9eebfa8d06bfbbb086dd68b9f5997 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Fri, 28 Aug 2026 11:03:49 -0400 Subject: [PATCH 4/4] Format durable blocker repository --- src/server/blockers/repository.ts | 422 +++++++++++++++--------------- 1 file changed, 216 insertions(+), 206 deletions(-) diff --git a/src/server/blockers/repository.ts b/src/server/blockers/repository.ts index 29eeba475..493165e7d 100644 --- a/src/server/blockers/repository.ts +++ b/src/server/blockers/repository.ts @@ -1,131 +1,132 @@ import { createId } from "../../lib/ids"; import { type Queryable, withDatabase } from "../db/client"; import type { - BlockerRecord, - BlockerRepository, - CreateBlockerInput, - ListBlockersQuery, + BlockerRecord, + BlockerRepository, + CreateBlockerInput, + ListBlockersQuery, } from "./types"; export class InMemoryBlockerRepository implements BlockerRepository { - private readonly blockers = new Map(); + private readonly blockers = new Map(); - async create(input: CreateBlockerInput): Promise { - const now = input.createdAt ?? new Date().toISOString(); - const record: BlockerRecord = { - id: createId("blocker"), - projectId: input.projectId, - reason: input.reason, - title: input.title, - detail: input.detail, - severity: input.severity, - nextActions: input.nextActions ? [...input.nextActions] : [], - source: input.source ?? "manual", - createdAt: now, - ...(input.threadId ? { threadId: input.threadId } : {}), - ...(input.targetId ? { targetId: input.targetId } : {}), - ...(input.taskId ? { taskId: input.taskId } : {}), - ...(input.sourceRefId ? { sourceRefId: input.sourceRefId } : {}), - ...(input.metadata ? { metadata: { ...input.metadata } } : {}), - }; - this.blockers.set(record.id, cloneBlocker(record)); - return cloneBlocker(record); - } + async create(input: CreateBlockerInput): Promise { + const now = input.createdAt ?? new Date().toISOString(); + const record: BlockerRecord = { + id: createId("blocker"), + projectId: input.projectId, + reason: input.reason, + title: input.title, + detail: input.detail, + severity: input.severity, + nextActions: input.nextActions ? [...input.nextActions] : [], + source: input.source ?? "manual", + createdAt: now, + ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.targetId ? { targetId: input.targetId } : {}), + ...(input.taskId ? { taskId: input.taskId } : {}), + ...(input.sourceRefId ? { sourceRefId: input.sourceRefId } : {}), + ...(input.metadata ? { metadata: { ...input.metadata } } : {}), + }; + this.blockers.set(record.id, cloneBlocker(record)); + return cloneBlocker(record); + } - async get(id: string): Promise { - const record = this.blockers.get(id); - return record ? cloneBlocker(record) : undefined; - } + async get(id: string): Promise { + const record = this.blockers.get(id); + return record ? cloneBlocker(record) : undefined; + } - async list(query: ListBlockersQuery): Promise { - const items = [...this.blockers.values()].filter((blocker) => { - if (query.projectId && blocker.projectId !== query.projectId) return false; - if (query.threadId && blocker.threadId !== query.threadId) return false; - if (query.targetId && blocker.targetId !== query.targetId) return false; - if (query.taskId && blocker.taskId !== query.taskId) return false; - if (query.reason && blocker.reason !== query.reason) return false; - if (query.resolved !== undefined) { - const isResolved = Boolean(blocker.resolvedAt); - if (query.resolved !== isResolved) return false; - } - return true; - }); - items.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); - const limit = query.limit ?? items.length; - return items.slice(0, limit).map(cloneBlocker); - } + async list(query: ListBlockersQuery): Promise { + const items = [...this.blockers.values()].filter((blocker) => { + if (query.projectId && blocker.projectId !== query.projectId) + return false; + if (query.threadId && blocker.threadId !== query.threadId) return false; + if (query.targetId && blocker.targetId !== query.targetId) return false; + if (query.taskId && blocker.taskId !== query.taskId) return false; + if (query.reason && blocker.reason !== query.reason) return false; + if (query.resolved !== undefined) { + const isResolved = Boolean(blocker.resolvedAt); + if (query.resolved !== isResolved) return false; + } + return true; + }); + items.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + const limit = query.limit ?? items.length; + return items.slice(0, limit).map(cloneBlocker); + } - async resolve(id: string, resolvedAt?: string): Promise { - const record = this.blockers.get(id); - if (!record) { - throw new Error(`Blocker ${id} was not found.`); - } - const now = resolvedAt ?? new Date().toISOString(); - const updated: BlockerRecord = { - ...record, - resolvedAt: now, - }; - this.blockers.set(updated.id, updated); - return cloneBlocker(updated); - } + async resolve(id: string, resolvedAt?: string): Promise { + const record = this.blockers.get(id); + if (!record) { + throw new Error(`Blocker ${id} was not found.`); + } + const now = resolvedAt ?? new Date().toISOString(); + const updated: BlockerRecord = { + ...record, + resolvedAt: now, + }; + this.blockers.set(updated.id, updated); + return cloneBlocker(updated); + } - async listActive(projectId: string): Promise { - return this.list({ projectId, resolved: false }); - } + async listActive(projectId: string): Promise { + return this.list({ projectId, resolved: false }); + } } export function createInMemoryBlockerRepository(): BlockerRepository { - return new InMemoryBlockerRepository(); + return new InMemoryBlockerRepository(); } type BlockerRow = { - id: string; - project_id: string; - thread_id: string | null; - target_id: string | null; - task_id: string | null; - reason: string; - title: string; - detail: string; - severity: BlockerRecord["severity"]; - next_actions: unknown; - source: string; - source_ref_id: string | null; - metadata: unknown; - created_at: string; - resolved_at: string | null; + id: string; + project_id: string; + thread_id: string | null; + target_id: string | null; + task_id: string | null; + reason: string; + title: string; + detail: string; + severity: BlockerRecord["severity"]; + next_actions: unknown; + source: string; + source_ref_id: string | null; + metadata: unknown; + created_at: string; + resolved_at: string | null; }; export class DatabaseBlockerRepository implements BlockerRepository { - constructor(private readonly database?: Queryable) {} + constructor(private readonly database?: Queryable) {} - private run(operation: (db: Queryable) => Promise): Promise { - return this.database ? operation(this.database) : withDatabase(operation); - } + private run(operation: (db: Queryable) => Promise): Promise { + return this.database ? operation(this.database) : withDatabase(operation); + } - async create(input: CreateBlockerInput): Promise { - return this.run(async (db) => { - const id = createId("blocker"); - const now = input.createdAt ?? new Date().toISOString(); - const values = [ - id, - input.projectId, - input.threadId ?? null, - input.targetId ?? null, - input.taskId ?? null, - input.reason, - input.title, - input.detail, - input.severity, - JSON.stringify(input.nextActions ?? []), - input.source ?? "manual", - input.sourceRefId ?? null, - JSON.stringify(input.metadata ?? {}), - input.dedupeKey ?? null, - now, - ]; - const result = await db.query( - `INSERT INTO blockers + async create(input: CreateBlockerInput): Promise { + return this.run(async (db) => { + const id = createId("blocker"); + const now = input.createdAt ?? new Date().toISOString(); + const values = [ + id, + input.projectId, + input.threadId ?? null, + input.targetId ?? null, + input.taskId ?? null, + input.reason, + input.title, + input.detail, + input.severity, + JSON.stringify(input.nextActions ?? []), + input.source ?? "manual", + input.sourceRefId ?? null, + JSON.stringify(input.metadata ?? {}), + input.dedupeKey ?? null, + now, + ]; + const result = await db.query( + `INSERT INTO blockers (id, project_id, thread_id, target_id, task_id, reason, title, detail, severity, next_actions, source, source_ref_id, metadata, dedupe_key, created_at, updated_at) @@ -161,129 +162,138 @@ export class DatabaseBlockerRepository implements BlockerRepository { AND blockers.task_id IS NOT DISTINCT FROM excluded.task_id AND blockers.reason = excluded.reason RETURNING *`, - values, - ); - return mapBlockerRow(requireBlockerRow(result.rows[0], "attribution")); - }); - } + values, + ); + return mapBlockerRow(requireBlockerRow(result.rows[0], "attribution")); + }); + } - async get(id: string): Promise { - return this.run(async (db) => { - const result = await db.query("SELECT * FROM blockers WHERE id = $1 LIMIT 1", [ - id, - ]); - return result.rows[0] ? mapBlockerRow(result.rows[0]) : undefined; - }); - } + async get(id: string): Promise { + return this.run(async (db) => { + const result = await db.query( + "SELECT * FROM blockers WHERE id = $1 LIMIT 1", + [id], + ); + return result.rows[0] ? mapBlockerRow(result.rows[0]) : undefined; + }); + } - async list(query: ListBlockersQuery): Promise { - return this.run(async (db) => { - const values: unknown[] = [query.projectId]; - const where = ["project_id = $1"]; - const add = (column: string, value: unknown) => { - values.push(value); - where.push(`${column} = $${values.length}`); - }; - if (query.threadId) add("thread_id", query.threadId); - if (query.targetId) add("target_id", query.targetId); - if (query.taskId) add("task_id", query.taskId); - if (query.reason) add("reason", query.reason); - if (query.resolved === true) where.push("resolved_at IS NOT NULL"); - if (query.resolved === false) where.push("resolved_at IS NULL"); - values.push(Math.max(0, Math.min(500, Math.trunc(query.limit ?? 100)))); - const result = await db.query( - `SELECT * FROM blockers WHERE ${where.join(" AND ")} + async list(query: ListBlockersQuery): Promise { + return this.run(async (db) => { + const values: unknown[] = [query.projectId]; + const where = ["project_id = $1"]; + const add = (column: string, value: unknown) => { + values.push(value); + where.push(`${column} = $${values.length}`); + }; + if (query.threadId) add("thread_id", query.threadId); + if (query.targetId) add("target_id", query.targetId); + if (query.taskId) add("task_id", query.taskId); + if (query.reason) add("reason", query.reason); + if (query.resolved === true) where.push("resolved_at IS NOT NULL"); + if (query.resolved === false) where.push("resolved_at IS NULL"); + values.push(Math.max(0, Math.min(500, Math.trunc(query.limit ?? 100)))); + const result = await db.query( + `SELECT * FROM blockers WHERE ${where.join(" AND ")} ORDER BY created_at ASC, id ASC LIMIT $${values.length}`, - values, - ); - return result.rows.map(mapBlockerRow); - }); - } + values, + ); + return result.rows.map(mapBlockerRow); + }); + } - async resolve(id: string, resolvedAt?: string): Promise { - return this.run(async (db) => { - const now = resolvedAt ?? new Date().toISOString(); - const result = await db.query( - "UPDATE blockers SET resolved_at = $2, updated_at = $2 WHERE id = $1 RETURNING *", - [id, now], - ); - return mapBlockerRow(requireBlockerRow(result.rows[0], id)); - }); - } + async resolve(id: string, resolvedAt?: string): Promise { + return this.run(async (db) => { + const now = resolvedAt ?? new Date().toISOString(); + const result = await db.query( + "UPDATE blockers SET resolved_at = $2, updated_at = $2 WHERE id = $1 RETURNING *", + [id, now], + ); + return mapBlockerRow(requireBlockerRow(result.rows[0], id)); + }); + } - async listActive(projectId: string): Promise { - return this.run(async (db) => { - const result = await db.query( - `SELECT * FROM blockers + async listActive(projectId: string): Promise { + return this.run(async (db) => { + const result = await db.query( + `SELECT * FROM blockers WHERE project_id = $1 AND resolved_at IS NULL ORDER BY created_at DESC, id ASC`, - [projectId], - ); - return result.rows.map(mapBlockerRow); - }); - } + [projectId], + ); + return result.rows.map(mapBlockerRow); + }); + } } -export function createDatabaseBlockerRepository(database?: Queryable): BlockerRepository { - return new DatabaseBlockerRepository(database); +export function createDatabaseBlockerRepository( + database?: Queryable, +): BlockerRepository { + return new DatabaseBlockerRepository(database); } -function requireBlockerRow(row: BlockerRow | undefined, id: string): BlockerRow { - if (!row) throw new Error(`Blocker ${id} was not found.`); - return row; +function requireBlockerRow( + row: BlockerRow | undefined, + id: string, +): BlockerRow { + if (!row) throw new Error(`Blocker ${id} was not found.`); + return row; } function mapBlockerRow(row: BlockerRow): BlockerRecord { - const nextActions = parseJson(row.next_actions, []); - const metadata = parseJson(row.metadata, {}); - return { - id: row.id, - projectId: row.project_id, - reason: row.reason, - title: row.title, - detail: row.detail, - severity: row.severity, - nextActions: Array.isArray(nextActions) - ? nextActions.filter(isBlockerAction).map((action) => ({ ...action })) - : [], - source: row.source, - createdAt: row.created_at, - ...(row.thread_id ? { threadId: row.thread_id } : {}), - ...(row.target_id ? { targetId: row.target_id } : {}), - ...(row.task_id ? { taskId: row.task_id } : {}), - ...(row.source_ref_id ? { sourceRefId: row.source_ref_id } : {}), - ...(row.resolved_at ? { resolvedAt: row.resolved_at } : {}), - ...(isPlainRecord(metadata) ? { metadata } : {}), - }; + const nextActions = parseJson(row.next_actions, []); + const metadata = parseJson(row.metadata, {}); + return { + id: row.id, + projectId: row.project_id, + reason: row.reason, + title: row.title, + detail: row.detail, + severity: row.severity, + nextActions: Array.isArray(nextActions) + ? nextActions.filter(isBlockerAction).map((action) => ({ ...action })) + : [], + source: row.source, + createdAt: row.created_at, + ...(row.thread_id ? { threadId: row.thread_id } : {}), + ...(row.target_id ? { targetId: row.target_id } : {}), + ...(row.task_id ? { taskId: row.task_id } : {}), + ...(row.source_ref_id ? { sourceRefId: row.source_ref_id } : {}), + ...(row.resolved_at ? { resolvedAt: row.resolved_at } : {}), + ...(isPlainRecord(metadata) ? { metadata } : {}), + }; } -function isBlockerAction(value: unknown): value is BlockerRecord["nextActions"][number] { - return ( - isPlainRecord(value) && - typeof value.label === "string" && - typeof value.action === "string" && - (value.params === undefined || isPlainRecord(value.params)) && - (value.requiresApproval === undefined || typeof value.requiresApproval === "boolean") - ); +function isBlockerAction( + value: unknown, +): value is BlockerRecord["nextActions"][number] { + return ( + isPlainRecord(value) && + typeof value.label === "string" && + typeof value.action === "string" && + (value.params === undefined || isPlainRecord(value.params)) && + (value.requiresApproval === undefined || + typeof value.requiresApproval === "boolean") + ); } function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); + return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function parseJson(value: unknown, fallback: unknown): unknown { - if (typeof value !== "string") return value ?? fallback; - try { - return JSON.parse(value) as unknown; - } catch { - return fallback; - } + if (typeof value !== "string") return value ?? fallback; + try { + return JSON.parse(value) as unknown; + } catch { + return fallback; + } } function cloneBlocker(blocker: BlockerRecord): BlockerRecord { - return { - ...blocker, - nextActions: blocker.nextActions.map((action) => ({ ...action })), - ...(blocker.metadata ? { metadata: { ...blocker.metadata } } : {}), - }; + return { + ...blocker, + nextActions: blocker.nextActions.map((action) => ({ ...action })), + ...(blocker.metadata ? { metadata: { ...blocker.metadata } } : {}), + }; }