diff --git a/.changeset/sse-archive-lane-leak.md b/.changeset/sse-archive-lane-leak.md new file mode 100644 index 0000000000..3747572a77 --- /dev/null +++ b/.changeset/sse-archive-lane-leak.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Archived tasks no longer reappear on boards whose archive lane is renamed. +category: fix +dev: `listTasksModifiedSinceImpl` excluded the literal `archived`; it now excludes the project's resolved archive columns, keeping the literal as the no-resolution fallback. diff --git a/packages/core/src/__tests__/postgres/modified-since-archived-lane.pg.test.ts b/packages/core/src/__tests__/postgres/modified-since-archived-lane.pg.test.ts new file mode 100644 index 0000000000..e4181d3547 --- /dev/null +++ b/packages/core/src/__tests__/postgres/modified-since-archived-lane.pg.test.ts @@ -0,0 +1,110 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-31-09:20 (archived rows leaked into the live stream): + +`listTasksModifiedSinceImpl` backs the SSE watcher and modified-since polling — the incremental feed +the dashboard applies to its live task list. Its `includeArchived: false` branch excluded the LITERAL +`archived`, so on a board whose archive lane is named anything else the predicate matched every row +and excluded nothing. Archived cards arrived in the live feed and reappeared on the board. + +Nothing errors, and a full refetch filters archived rows by another path, so the symptom is archived +work that comes back until the next reload — the kind of thing an operator reports as "the board is +flaky" rather than as a bug. + +The cases are DIFFERENTIAL: the same archived task under two vocabularies whose roles are identical +and only the ids differ. `filed` collides with no legacy id, so a surviving `'archived'` literal +cannot pass by luck. +*/ + +import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { sql } from "drizzle-orm"; +import { + pgDescribe, + createSharedPgTaskStoreTestHarness, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../../index.js"; + +const SINCE = "2026-06-01T00:00:00.000Z"; +const TOUCHED = "2026-06-15T12:00:00.000Z"; + +pgDescribe("the modified-since feed under a renamed archive lane", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_modsince_archived", + }); + + beforeAll(h.beforeAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + /** The builtin coding workflow with only its archive column renamed. */ + async function seedRenamedWorkflow(): Promise { + const ir = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as { + id: string; + nodes?: { column?: string }[]; + columns?: { id: string }[]; + }; + ir.id = "custom:renamed-archive"; + for (const node of ir.nodes ?? []) if (node.column === "archived") node.column = "filed"; + for (const column of ir.columns ?? []) if (column.id === "archived") column.id = "filed"; + + const ids = (ir.columns ?? []).map((column) => column.id); + expect(ids).toContain("filed"); + expect(ids).not.toContain("archived"); + + await h.store().createWorkflowDefinition({ name: "Renamed archive", kind: "workflow", ir } as never); + } + + /** A task parked in whichever lane plays the archive role, touched after `SINCE`. */ + async function seedArchivedTask(lane: string, id = "KB-ARCH"): Promise { + const store = h.store(); + await store.createTaskWithReservedId( + { description: id, column: "todo" }, + { taskId: id, createdAt: SINCE, updatedAt: SINCE, applyDefaultWorkflowSteps: false }, + ); + /* Seeded directly: `moveTask` would stamp `updatedAt` with `now`, and this feed is keyed on it. */ + await h.adminDb().execute(sql` + UPDATE project.tasks SET "column" = ${lane}, updated_at = ${TOUCHED} WHERE id = ${id}`); + store.taskCache.delete(id); + } + + const feed = async (includeArchived: boolean) => { + const { tasks } = await h.store().listTasksModifiedSince(SINCE, 50, { includeArchived }); + return tasks.map((task) => task.id); + }; + + /* Control: the legacy vocabulary already excluded it. Passes before and after the fix. */ + it("default vocabulary: an `archived` task stays out of the live feed", async () => { + await seedArchivedTask("archived"); + + expect(await feed(false)).not.toContain("KB-ARCH"); + }); + + /* The defect: `!= 'archived'` matched every row on this board, so nothing was excluded. */ + it("renamed vocabulary: a task in the RENAMED archive lane stays out of the live feed", async () => { + await seedRenamedWorkflow(); + await seedArchivedTask("filed"); + + expect(await feed(false)).not.toContain("KB-ARCH"); + }); + + /* + The paired negative: resolving the archive role must not start excluding live work. A card in the + renamed WIP lane belongs in the feed — otherwise the fix trades leaked archived rows for missing + live ones, which is the worse direction: the board would silently stop updating for real work. + */ + it("renamed vocabulary: a task in a LIVE lane still reaches the feed", async () => { + await seedRenamedWorkflow(); + await seedArchivedTask("in-progress", "KB-LIVE"); + + expect(await feed(false)).toContain("KB-LIVE"); + }); + + /* `includeArchived: true` is the forensic read and must still surface the renamed archive lane. */ + it("renamed vocabulary: includeArchived still returns the archived task", async () => { + await seedRenamedWorkflow(); + await seedArchivedTask("filed"); + + expect(await feed(true)).toContain("KB-ARCH"); + }); +}); diff --git a/packages/core/src/task-store/reads.ts b/packages/core/src/task-store/reads.ts index ad25b6b58d..35200ef09e 100644 --- a/packages/core/src/task-store/reads.ts +++ b/packages/core/src/task-store/reads.ts @@ -31,6 +31,7 @@ import {computeRetrySummary} from "../retry-summary.js"; // FNXC:TaskLookup404 2026-07-26-11:20: typed miss signal so API boundaries can // answer 404 instead of 500 (see TaskNotFoundError in task-store/errors.ts). import {TaskNotFoundError} from "../task-store/errors.js"; +import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js"; /** Merge storage tiers while preserving primary-source authority and order. */ function mergePrimaryById(primary: T[], secondary: T[]): T[] { @@ -548,14 +549,35 @@ export async function listTasksModifiedSinceImpl(store: TaskStore, since: string }; let disableAgeStalenessHydration = false; - const { and, asc, eq, gt, sql } = await import("drizzle-orm"); + const { and, asc, eq, gt, notInArray, sql } = await import("drizzle-orm"); const schema = await import("../postgres/schema/index.js"); const conditions = [ sql`(${schema.project.tasks.deletedAt} IS NULL)`, gt(schema.project.tasks.updatedAt, since), ]; if (!includeArchived) { - conditions.push(sql`${schema.project.tasks.column} != 'archived'`); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-09:10: + ARCHIVED ROWS LEAKED INTO THE LIVE STREAM ON A RENAMED BOARD. + + This filter backs the SSE watcher and modified-since polling — the incremental feed the + dashboard applies to its live task list. It excluded the literal `archived`, so on a board + whose archive lane is named anything else the predicate matched EVERY row and excluded + nothing: archived cards arrived in the live feed and reappeared on the board. + + Nothing errors, and a full refetch filters archived rows by another path, so the symptom is + archived work that comes back until the next reload. + + `resolveProjectColumnsForRoles` seeds the legacy ids before adding resolved ones, so the set is + never empty and an unconverted board excludes exactly `archived` as before. The fallback covers + a resolution failure, where excluding nothing would be worse than excluding the legacy id. + */ + const archivedColumns = await resolveProjectColumnsForRoles(store, ["archived"]).catch(() => undefined); + if (archivedColumns && archivedColumns.size > 0) { + conditions.push(notInArray(schema.project.tasks.column, [...archivedColumns])); + } else { + conditions.push(sql`${schema.project.tasks.column} != 'archived'`); + } } const layer = store.asyncLayer!; // FNXC:MultiProjectIsolation 2026-07-10: scope the incremental-sync scan