From e50f23d76c09fc67abca243db03140c626a574d2 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 01:48:19 +0200 Subject: [PATCH 01/21] feat: add activity event types --- lib/types.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/lib/types.ts b/lib/types.ts index ee23686a..a4eaadaa 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -50,6 +50,56 @@ export type HistoryEntry = { actor: "user" | "ai"; }; +/** Discrete, append-only activity event kinds for the audit log. */ +export type ActivityEventType = + | "task_created" + | "title_changed" + | "description_changed" + | "status_changed" + | "priority_changed" + | "estimate_changed" + | "category_changed" + | "moved" + | "tag_added" + | "tag_removed" + | "plan_set" + | "record_set" + | "files_changed" + | "assignee_added" + | "assignee_removed" + | "criterion_added" + | "criterion_removed" + | "criterion_checked" + | "criterion_unchecked" + | "decision_added" + | "decision_removed" + | "link_added" + | "link_removed" + | "edge_added" + | "edge_removed" + | "edge_updated" + | "project_created"; + +/** Origin of an activity event. */ +export type ActivitySource = "web" | "mcp" | "system"; + +/** Read-model row for the activity panel and audit feeds. */ +export type ActivityEvent = { + id: string; + projectId: string; + taskId: string | null; + type: ActivityEventType; + createdAt: string; + actorUserId: string | null; + actorName: string | null; + actorAvatar: string | null; + source: ActivitySource; + agent: string | null; + summary: string; + targetRef: string | null; + metadata: Record | null; +}; + /** A verifiable acceptance criterion for a task. */ export type AcceptanceCriterion = { id: string; From 8c61f417e137af64098bc7fb84cb247b55276e8e Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 01:52:02 +0200 Subject: [PATCH 02/21] feat: add activity_events table --- lib/db/schema.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index a3013c26..553fd49a 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -20,6 +20,8 @@ import type { HistoryEntry, Priority, Estimate, + ActivityEventType, + ActivitySource, } from "@/lib/types"; // --------------------------------------------------------------------------- @@ -258,6 +260,41 @@ export const taskLinks = pgTable( export type TaskLink = typeof taskLinks.$inferSelect; export type NewTaskLink = typeof taskLinks.$inferInsert; +// --------------------------------------------------------------------------- +// Activity Events (append-only audit log for tasks + projects) +// --------------------------------------------------------------------------- + +export const activityEvents = pgTable( + "activity_events", + { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + taskId: uuid("task_id").references(() => tasks.id, { onDelete: "cascade" }), + type: text("type").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + actorUserId: uuid("actor_user_id").references(() => user.id, { + onDelete: "set null", + }), + source: text("source").$type().notNull(), + actorClientId: text("actor_client_id"), + summary: text("summary").notNull(), + targetRef: text("target_ref"), + metadata: jsonb("metadata").$type>(), + }, + (t) => [ + index("activity_events_task_id_created_idx").on(t.taskId, t.createdAt), + index("activity_events_project_id_created_idx").on(t.projectId, t.createdAt), + index("activity_events_actor_user_id_idx").on(t.actorUserId), + ], +).enableRLS(); + +export type ActivityEventRow = typeof activityEvents.$inferSelect; +export type NewActivityEvent = typeof activityEvents.$inferInsert; + // --------------------------------------------------------------------------- // Team Invite Codes (separate file, re-exported here for drizzle-kit) // --------------------------------------------------------------------------- From a1d0a3973d5bba2e53713569bfc4c63ecd3ae92f Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 01:56:35 +0200 Subject: [PATCH 03/21] feat: add activity_events rls policy, grant, and identity sdfs --- docker/grants.sql | 5 +++++ docker/rls-functions.sql | 43 ++++++++++++++++++++++++++++++++++++++++ docker/rls-policies.sql | 8 ++++++++ 3 files changed, 56 insertions(+) diff --git a/docker/grants.sql b/docker/grants.sql index 9d8701b3..c378b7b0 100644 --- a/docker/grants.sql +++ b/docker/grants.sql @@ -25,6 +25,11 @@ GRANT CREATE ON SCHEMA public TO service_role; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user, service_role; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user, service_role; +-- Explicit per-table grant for activity_events (the documented convention for +-- new public tables; the schema-wide grant above only covers tables that +-- exist when it runs). +GRANT SELECT, INSERT, UPDATE, DELETE ON "activity_events" TO app_user, service_role; + -- neon_auth: app_user reaches it only via SECURITY DEFINER functions. -- Explicit REVOKEs make re-runs idempotent on pre-lockdown installs. GRANT USAGE ON SCHEMA neon_auth TO service_role, auth_role; diff --git a/docker/rls-functions.sql b/docker/rls-functions.sql index c643cfad..6be4bce8 100644 --- a/docker/rls-functions.sql +++ b/docker/rls-functions.sql @@ -376,6 +376,49 @@ $$; REVOKE EXECUTE ON FUNCTION public.task_assignees_visible(uuid) FROM public; GRANT EXECUTE ON FUNCTION public.task_assignees_visible(uuid) TO app_user; +-- Resolve the distinct actor profiles for a task's activity events. Gated on +-- the caller's membership of the task's org, like task_assignees_visible. +CREATE OR REPLACE FUNCTION public.activity_actors_visible(p_task_id uuid) +RETURNS TABLE (user_id uuid, name text, image text) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = public, neon_auth, pg_catalog, pg_temp +AS $$ +BEGIN + RETURN QUERY + SELECT DISTINCT u.id, u.name, u.image + FROM public.activity_events ae + INNER JOIN neon_auth."user" u ON u.id = ae.actor_user_id + WHERE ae.task_id = p_task_id + AND EXISTS ( + SELECT 1 + FROM public.tasks t + INNER JOIN public.projects pj ON pj.id = t.project_id + INNER JOIN neon_auth."member" caller + ON caller."organizationId" = pj.organization_id + WHERE t.id = p_task_id + AND caller."userId" = NULLIF(current_setting('app.user_id', TRUE), '')::uuid + ); +END; +$$; +REVOKE EXECUTE ON FUNCTION public.activity_actors_visible(uuid) FROM public; +GRANT EXECUTE ON FUNCTION public.activity_actors_visible(uuid) TO app_user; + +-- Resolve a single OAuth client display name (not secret). Scalar to avoid +-- text[] array binding on the read path; callers loop the page's few ids. +CREATE OR REPLACE FUNCTION public.oauth_client_name(p_client_id text) +RETURNS text +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public, neon_auth, pg_catalog, pg_temp +AS $$ + SELECT c.name FROM neon_auth."oauthClient" c WHERE c."clientId" = p_client_id; +$$; +REVOKE EXECUTE ON FUNCTION public.oauth_client_name(text) FROM public; +GRANT EXECUTE ON FUNCTION public.oauth_client_name(text) TO app_user; + -- Per-project sibling of task_assignees_visible: one membership probe -- for the whole project instead of N (old LATERAL pattern). Probing a -- foreign project UUID is indistinguishable from a missing one. diff --git a/docker/rls-policies.sql b/docker/rls-policies.sql index 07de64af..2f2a2432 100644 --- a/docker/rls-policies.sql +++ b/docker/rls-policies.sql @@ -60,6 +60,12 @@ CREATE POLICY "task_links_member_access" ON "task_links" AS PERMISSIVE FOR ALL T USING (task_id IN (SELECT id FROM public.tasks)) WITH CHECK (task_id IN (SELECT id FROM public.tasks)); +-- activity_events — 2-hop via projects' RLS, mirroring tasks. +DROP POLICY IF EXISTS "activity_events_member_access" ON "activity_events"; +CREATE POLICY "activity_events_member_access" ON "activity_events" AS PERMISSIVE FOR ALL TO app_user + USING (project_id IN (SELECT id FROM public.projects)) + WITH CHECK (project_id IN (SELECT id FROM public.projects)); + -- RESTRICTIVE write floor on task_edges. RESTRICTIVE AND's with the OR of -- permissives, so a future stray permissive cannot OR-relax both-endpoints -- -visible. Scoped per-command to leave SELECT on the permissive policy. @@ -136,6 +142,7 @@ ALTER TABLE "task_acceptance_criteria" ENABLE ROW LEVEL SECURITY; ALTER TABLE "task_decisions" ENABLE ROW LEVEL SECURITY; ALTER TABLE "task_links" ENABLE ROW LEVEL SECURITY; ALTER TABLE "team_invite_code" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "activity_events" ENABLE ROW LEVEL SECURITY; -- FORCE subjects the table owner to RLS. BYPASSRLS roles and real -- superusers still sidestep. @@ -147,3 +154,4 @@ ALTER TABLE "task_acceptance_criteria" FORCE ROW LEVEL SECURITY; ALTER TABLE "task_decisions" FORCE ROW LEVEL SECURITY; ALTER TABLE "task_links" FORCE ROW LEVEL SECURITY; ALTER TABLE "team_invite_code" FORCE ROW LEVEL SECURITY; +ALTER TABLE "activity_events" FORCE ROW LEVEL SECURITY; From 76813fd0c78a975b547d7d6a5e85fb0e7405d522 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:10:12 +0200 Subject: [PATCH 04/21] feat: add resolved actor to auth context --- lib/auth/context.ts | 22 +++++++++++++++++++--- tests/auth/auth-context-actor.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tests/auth/auth-context-actor.test.ts diff --git a/lib/auth/context.ts b/lib/auth/context.ts index 6d9c28d3..493c0290 100644 --- a/lib/auth/context.ts +++ b/lib/auth/context.ts @@ -17,8 +17,15 @@ declare const authContextBrand: unique symbol; * `organizationId` arg (MCP create); both paths re-verify membership on * every request. */ +/** Durable identity keys behind a request; display is resolved at read time. */ +export type ActorDescriptor = + | { source: "web"; userId: string } + | { source: "mcp"; userId: string; clientId: string | null } + | { source: "system"; userId: string }; + export type AuthContext = { readonly userId: string; + readonly actor: ActorDescriptor; readonly [authContextBrand]: true; }; @@ -27,10 +34,16 @@ export type AuthContext = { * code paths that have validated the principal (session, JWT). Application * code should depend on `AuthContext` values handed in, not construct them. * @param userId - Verified user id (e.g. `session.user.id`, JWT `sub`). + * @param actor - Resolved actor descriptor; defaults to a `system` actor + * bound to `userId` for callers (tests, internal jobs) that do not carry + * surface identity. * @returns Branded auth context. */ -export function makeAuthContext(userId: string): AuthContext { - return { userId } as unknown as AuthContext; +export function makeAuthContext( + userId: string, + actor: ActorDescriptor = { source: "system", userId }, +): AuthContext { + return { userId, actor } as unknown as AuthContext; } /** @@ -44,5 +57,8 @@ export function makeAuthContext(userId: string): AuthContext { */ export async function getAuthContext(): Promise { const session = await requireSession(); - return makeAuthContext(session.user.id); + return makeAuthContext(session.user.id, { + source: "web", + userId: session.user.id, + }); } diff --git a/tests/auth/auth-context-actor.test.ts b/tests/auth/auth-context-actor.test.ts new file mode 100644 index 00000000..c33b07d1 --- /dev/null +++ b/tests/auth/auth-context-actor.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { makeAuthContext } from "@/lib/auth/context"; + +describe("makeAuthContext actor", () => { + test("defaults to a system actor when none supplied", () => { + const ctx = makeAuthContext("u-1"); + expect(ctx.userId).toBe("u-1"); + expect(ctx.actor).toEqual({ source: "system", userId: "u-1" }); + }); + + test("carries an explicit mcp actor", () => { + const ctx = makeAuthContext("u-1", { + source: "mcp", + userId: "u-1", + clientId: "client-abc", + }); + expect(ctx.actor).toEqual({ + source: "mcp", + userId: "u-1", + clientId: "client-abc", + }); + }); +}); From afed13a68569693b17b526e034d94c7c9f97d836 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:16:35 +0200 Subject: [PATCH 05/21] feat: mint mcp actor from azp claim --- app/api/mcp/route.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index d7282e78..05409c77 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -67,6 +67,7 @@ function payloadTooLarge() { /** Shape we require from a verified MCP access token payload. */ const accessTokenClaimsSchema = z.looseObject({ sub: z.uuid(), + azp: z.string().optional(), }); /** @@ -173,7 +174,11 @@ async function verifyMcpAuth(request: Request) { function authContextFromPayload(payload: unknown): AuthContext | null { const parsed = accessTokenClaimsSchema.safeParse(payload); if (!parsed.success) return null; - return makeAuthContext(parsed.data.sub); + return makeAuthContext(parsed.data.sub, { + source: "mcp", + userId: parsed.data.sub, + clientId: parsed.data.azp ?? null, + }); } /** From 1c51502c16c8a15d4c08d22664690009a7de0da9 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:17:47 +0200 Subject: [PATCH 06/21] feat: derive durable activity actor columns --- lib/data/activity.ts | 26 ++++++++++++++++++++++++++ tests/data/activity-columns.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 lib/data/activity.ts create mode 100644 tests/data/activity-columns.test.ts diff --git a/lib/data/activity.ts b/lib/data/activity.ts new file mode 100644 index 00000000..bdaacc7e --- /dev/null +++ b/lib/data/activity.ts @@ -0,0 +1,26 @@ +import "server-only"; + +import type { ActorDescriptor } from "@/lib/auth/context"; + +/** The three durable actor columns written onto every event row. */ +export type ActorColumns = { + actorUserId: string; + source: "web" | "mcp" | "system"; + actorClientId: string | null; +}; + +/** + * Derive the durable actor columns from a request's actor descriptor. Pure — + * no DB read, never touches `neon_auth`. Display name/avatar/harness are + * resolved at read time via SECURITY DEFINER functions, never written here. + * + * @param actor - The request's resolved actor descriptor. + * @returns The actor columns to persist on each event row. + */ +export function actorColumns(actor: ActorDescriptor): ActorColumns { + return { + actorUserId: actor.userId, + source: actor.source, + actorClientId: actor.source === "mcp" ? actor.clientId : null, + }; +} diff --git a/tests/data/activity-columns.test.ts b/tests/data/activity-columns.test.ts new file mode 100644 index 00000000..a1d176fc --- /dev/null +++ b/tests/data/activity-columns.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { actorColumns } from "@/lib/data/activity"; + +describe("actorColumns", () => { + test("web actor → durable keys only", () => { + expect(actorColumns({ source: "web", userId: "u-1" })).toEqual({ + actorUserId: "u-1", + source: "web", + actorClientId: null, + }); + }); + + test("mcp actor carries the client id", () => { + expect( + actorColumns({ source: "mcp", userId: "u-1", clientId: "client-abc" }), + ).toEqual({ + actorUserId: "u-1", + source: "mcp", + actorClientId: "client-abc", + }); + }); +}); From b0836a93fd2456147207506524ba7f45e18c0275 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:20:17 +0200 Subject: [PATCH 07/21] feat: insert activity events in transaction --- lib/data/activity.ts | 44 ++++++++++++++++++++++++ tests/data/activity-insert.test.ts | 55 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/data/activity-insert.test.ts diff --git a/lib/data/activity.ts b/lib/data/activity.ts index bdaacc7e..28329f97 100644 --- a/lib/data/activity.ts +++ b/lib/data/activity.ts @@ -1,6 +1,9 @@ import "server-only"; +import { activityEvents } from "@/lib/db/schema"; +import type { Tx } from "@/lib/db/rls"; import type { ActorDescriptor } from "@/lib/auth/context"; +import type { ActivityEventType } from "@/lib/types"; /** The three durable actor columns written onto every event row. */ export type ActorColumns = { @@ -24,3 +27,44 @@ export function actorColumns(actor: ActorDescriptor): ActorColumns { actorClientId: actor.source === "mcp" ? actor.clientId : null, }; } + +/** Caller-supplied fields for a single event; actor + id + date are filled in. */ +export type ActivityEventInput = { + projectId: string; + taskId: string | null; + type: ActivityEventType; + summary: string; + targetRef?: string | null; + metadata?: Record | null; +}; + +/** + * Insert activity events within an existing `app_user` transaction. Durable + * actor columns are derived from the descriptor (pure, no `neon_auth` read) + * and applied to every row. No-op for an empty list. + * + * @param tx - Active RLS-scoped transaction handle. + * @param actor - The request's resolved actor descriptor. + * @param events - Events to insert (constant-size rows). + */ +export async function insertActivityEvents( + tx: Tx, + actor: ActorDescriptor, + events: ActivityEventInput[], +): Promise { + if (events.length === 0) return; + const cols = actorColumns(actor); + await tx.insert(activityEvents).values( + events.map((e) => ({ + projectId: e.projectId, + taskId: e.taskId, + type: e.type, + actorUserId: cols.actorUserId, + source: cols.source, + actorClientId: cols.actorClientId, + summary: e.summary, + targetRef: e.targetRef ?? null, + metadata: e.metadata ?? null, + })), + ); +} diff --git a/tests/data/activity-insert.test.ts b/tests/data/activity-insert.test.ts new file mode 100644 index 00000000..1005454a --- /dev/null +++ b/tests/data/activity-insert.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { truncateAll } from "@/tests/setup/schema"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { withUserContext } from "@/lib/db/rls"; +import { insertActivityEvents } from "@/lib/data/activity"; + +afterEach(async () => { + await truncateAll(); +}); + +describe("insertActivityEvents", () => { + test("writes rows under the caller's transaction", async () => { + const fx = await seedUserOrgProject("ins-1"); + const sr = serviceRoleConnect(); + let taskId: string; + try { + const [t] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number) + VALUES (${fx.projectId}, 'T', 1) RETURNING id`; + taskId = t.id; + } finally { + await sr.end({ timeout: 5 }); + } + + await withUserContext(fx.userId, async (tx) => { + await insertActivityEvents( + tx, + { source: "web", userId: fx.userId }, + [ + { + projectId: fx.projectId, + taskId, + type: "status_changed", + summary: "moved to done", + metadata: { from: "draft", to: "done" }, + }, + ], + ); + }); + + const sr2 = serviceRoleConnect(); + try { + const rows = await sr2` + SELECT type, summary, actor_user_id, source, metadata + FROM activity_events WHERE task_id = ${taskId}`; + expect(rows.length).toBe(1); + expect(rows[0].type).toBe("status_changed"); + expect(rows[0].actor_user_id).toBe(fx.userId); + expect(rows[0].source).toBe("web"); + expect(rows[0].metadata).toEqual({ from: "draft", to: "done" }); + } finally { + await sr2.end({ timeout: 5 }); + } + }); +}); From 31668c09fee8242f8d3cefbafb5d6b977122cf47 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:29:16 +0200 Subject: [PATCH 08/21] feat: list task activity with keyset pagination --- lib/data/activity.ts | 99 ++++++++++++++++++++++++++++++- lib/db/raw/fetch-task-activity.ts | 92 ++++++++++++++++++++++++++++ lib/db/schema.ts | 5 +- tests/data/activity-list.test.ts | 90 ++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 lib/db/raw/fetch-task-activity.ts create mode 100644 tests/data/activity-list.test.ts diff --git a/lib/data/activity.ts b/lib/data/activity.ts index 28329f97..2c3f5a2c 100644 --- a/lib/data/activity.ts +++ b/lib/data/activity.ts @@ -1,9 +1,15 @@ import "server-only"; import { activityEvents } from "@/lib/db/schema"; -import type { Tx } from "@/lib/db/rls"; -import type { ActorDescriptor } from "@/lib/auth/context"; -import type { ActivityEventType } from "@/lib/types"; +import { withUserContextRead, type Tx } from "@/lib/db/rls"; +import { normalizeExecuteResult } from "@/lib/db/raw"; +import { + taskActivityStmt, + type ActivityCursor, + type ActivityRawRow, +} from "@/lib/db/raw/fetch-task-activity"; +import type { ActorDescriptor, AuthContext } from "@/lib/auth/context"; +import type { ActivityEvent, ActivityEventType } from "@/lib/types"; /** The three durable actor columns written onto every event row. */ export type ActorColumns = { @@ -68,3 +74,90 @@ export async function insertActivityEvents( })), ); } + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 50; + +/** Encode a keyset cursor from the last row of a page. */ +function encodeCursor(createdAt: Date, id: string): string { + return Buffer.from(`${createdAt.toISOString()}|${id}`).toString("base64url"); +} + +/** Decode a keyset cursor; returns null on malformed input. */ +function decodeCursor(cursor: string): ActivityCursor | null { + try { + const [iso, id] = Buffer.from(cursor, "base64url") + .toString("utf8") + .split("|"); + const createdAt = new Date(iso); + if (Number.isNaN(createdAt.getTime()) || !id) return null; + return { createdAt, id }; + } catch { + return null; + } +} + +/** Coerce a driver timestamp (Date or ISO string) to a Date. */ +function toDate(value: Date | string): Date { + return value instanceof Date ? value : new Date(value); +} + +/** + * Map a raw page row to the API read model. Identity is already hydrated by + * the SDF join (null when the actor cannot be resolved). + * + * @param r - Raw event row. + * @returns The hydrated read model. + */ +function toActivityEvent(r: ActivityRawRow): ActivityEvent { + return { + id: r.id, + projectId: r.project_id, + taskId: r.task_id, + type: r.type, + createdAt: toDate(r.created_at).toISOString(), + actorUserId: r.actor_user_id, + actorName: r.actor_name, + actorAvatar: r.actor_image, + source: r.source, + agent: r.agent_name, + summary: r.summary, + targetRef: r.target_ref, + metadata: r.metadata, + }; +} + +/** + * List a task's activity newest-first, keyset-paginated. One RLS-scoped read: + * the page and its read-time identity are resolved in a single statement that + * joins the `activity_actors_visible` / `oauth_client_name` SECURITY DEFINER + * functions — no `service_role`. A non-member transparently sees an empty page. + * + * @param ctx - Caller auth context. + * @param taskId - Task whose events to read. + * @param opts - `limit` (clamped to {@link MAX_LIMIT}) and an opaque `cursor`. + * @returns A page of events plus the next cursor (null when exhausted). + */ +export async function listTaskActivity( + ctx: AuthContext, + taskId: string, + opts: { cursor?: string; limit?: number }, +): Promise<{ events: ActivityEvent[]; nextCursor: string | null }> { + const limit = Math.min(Math.max(opts.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); + const cur = opts.cursor ? decodeCursor(opts.cursor) : null; + + const [raw] = await withUserContextRead(ctx.userId, (read) => [ + taskActivityStmt(read, taskId, cur, limit + 1), + ]); + const rows = normalizeExecuteResult(raw); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + if (page.length === 0) return { events: [], nextCursor: null }; + + const last = page[page.length - 1]; + return { + events: page.map(toActivityEvent), + nextCursor: hasMore ? encodeCursor(toDate(last.created_at), last.id) : null, + }; +} diff --git a/lib/db/raw/fetch-task-activity.ts b/lib/db/raw/fetch-task-activity.ts new file mode 100644 index 00000000..54b9c233 --- /dev/null +++ b/lib/db/raw/fetch-task-activity.ts @@ -0,0 +1,92 @@ +import { sql, type SQL } from "drizzle-orm"; +import { type ReadConn } from "@/lib/db/raw"; +import type { ActivityEventType, ActivitySource } from "@/lib/types"; + +/** Opaque keyset cursor decoded to its ordering pair. */ +export type ActivityCursor = { createdAt: Date; id: string }; + +/** + * Raw snake-case row returned by {@link taskActivityStmt}. Read-time display + * identity (`actor_name`, `actor_image`, `agent_name`) is joined from the + * `activity_actors_visible` / `oauth_client_name` SECURITY DEFINER functions. + */ +export type ActivityRawRow = { + id: string; + project_id: string; + task_id: string | null; + type: ActivityEventType; + created_at: Date | string; + actor_user_id: string | null; + source: ActivitySource; + actor_client_id: string | null; + summary: string; + target_ref: string | null; + metadata: Record | null; + actor_name: string | null; + actor_image: string | null; + agent_name: string | null; +}; + +/** + * Build the keyset page SQL for a task's activity. Pages newest-first and + * hydrates display identity inline via the `activity_actors_visible` / + * `oauth_client_name` SECURITY DEFINER functions — the same idiom as the + * task+assignees read, no `service_role`. A non-member sees an empty page + * because the `activity_events` RLS policy hides the rows. + * + * @param taskId - Task whose events to read. + * @param cur - Decoded keyset cursor, or null for the first page. + * @param limit - Row cap (already includes the +1 look-ahead). + * @returns Parameterized read-only SQL. + */ +function activityPageSql( + taskId: string, + cur: ActivityCursor | null, + limit: number, +): SQL { + const keyset = cur + ? sql`AND (ae.created_at < ${cur.createdAt.toISOString()}::timestamptz + OR (ae.created_at = ${cur.createdAt.toISOString()}::timestamptz + AND ae.id < ${cur.id}::uuid))` + : sql``; + return sql` + SELECT + ae.id, ae.project_id, ae.task_id, ae.type, ae.created_at, + ae.actor_user_id, ae.source, ae.actor_client_id, + ae.summary, ae.target_ref, ae.metadata, + a.name AS actor_name, + a.image AS actor_image, + CASE + WHEN ae.actor_client_id IS NOT NULL + THEN COALESCE( + public.oauth_client_name(ae.actor_client_id), + ae.actor_client_id + ) + END AS agent_name + FROM public.activity_events ae + LEFT JOIN public.activity_actors_visible(${taskId}::uuid) a + ON a.user_id = ae.actor_user_id + WHERE ae.task_id = ${taskId}::uuid + ${keyset} + ORDER BY ae.created_at DESC, ae.id DESC + LIMIT ${limit}`; +} + +/** + * Lazy read statement for one keyset page of a task's activity, for the + * `withUserContextRead` batch. Mirrors `taskFullStmt`. + * + * @param read - RLS-scoped read connection from `withUserContextRead`. + * @param taskId - Task whose events to read. + * @param cur - Decoded keyset cursor, or null for the first page. + * @param limit - Row cap (already includes the +1 look-ahead). + * @returns Lazy raw-SQL read statement. + */ +export function taskActivityStmt( + read: ReadConn, + taskId: string, + cur: ActivityCursor | null, + limit: number, +) { + return read.execute(activityPageSql(taskId, cur, limit)); +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 553fd49a..14974440 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -287,7 +287,10 @@ export const activityEvents = pgTable( }, (t) => [ index("activity_events_task_id_created_idx").on(t.taskId, t.createdAt), - index("activity_events_project_id_created_idx").on(t.projectId, t.createdAt), + index("activity_events_project_id_created_idx").on( + t.projectId, + t.createdAt, + ), index("activity_events_actor_user_id_idx").on(t.actorUserId), ], ).enableRLS(); diff --git a/tests/data/activity-list.test.ts b/tests/data/activity-list.test.ts new file mode 100644 index 00000000..eac449c6 --- /dev/null +++ b/tests/data/activity-list.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { truncateAll } from "@/tests/setup/schema"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { makeAuthContext } from "@/lib/auth/context"; +import { listTaskActivity } from "@/lib/data/activity"; + +afterEach(async () => { + await truncateAll(); +}); + +describe("listTaskActivity", () => { + test("returns newest-first and paginates by cursor", async () => { + const fx = await seedUserOrgProject("list-1"); + const sr = serviceRoleConnect(); + let taskId: string; + try { + const [t] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number) + VALUES (${fx.projectId}, 'T', 1) RETURNING id`; + taskId = t.id; + for (let i = 0; i < 5; i++) { + await sr` + INSERT INTO activity_events + (project_id, task_id, type, source, summary, created_at) + VALUES (${fx.projectId}, ${taskId}, 'title_changed', 'web', + ${"e" + i}, now() + (${i} || ' seconds')::interval)`; + } + } finally { + await sr.end({ timeout: 5 }); + } + + const ctx = makeAuthContext(fx.userId); + const page1 = await listTaskActivity(ctx, taskId, { limit: 3 }); + expect(page1.events.map((e) => e.summary)).toEqual(["e4", "e3", "e2"]); + expect(page1.nextCursor).not.toBeNull(); + + const page2 = await listTaskActivity(ctx, taskId, { + limit: 3, + cursor: page1.nextCursor!, + }); + expect(page2.events.map((e) => e.summary)).toEqual(["e1", "e0"]); + expect(page2.nextCursor).toBeNull(); + }); + + test("hydrates actor name at read time via the SDF", async () => { + const fx = await seedUserOrgProject("list-hydrate"); + const sr = serviceRoleConnect(); + let taskId: string; + try { + const [t] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number) + VALUES (${fx.projectId}, 'T', 1) RETURNING id`; + taskId = t.id; + await sr` + INSERT INTO activity_events + (project_id, task_id, type, source, actor_user_id, summary) + VALUES (${fx.projectId}, ${taskId}, 'title_changed', 'web', + ${fx.userId}, 'x')`; + } finally { + await sr.end({ timeout: 5 }); + } + + const page = await listTaskActivity(makeAuthContext(fx.userId), taskId, {}); + expect(page.events[0].actorUserId).toBe(fx.userId); + expect(typeof page.events[0].actorName).toBe("string"); + expect(page.events[0].actorName).not.toBeNull(); + }); + + test("a non-member cannot read another project's events", async () => { + const owner = await seedUserOrgProject("list-owner"); + const stranger = await seedUserOrgProject("list-stranger"); + const sr = serviceRoleConnect(); + let taskId: string; + try { + const [t] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number) + VALUES (${owner.projectId}, 'T', 1) RETURNING id`; + taskId = t.id; + await sr` + INSERT INTO activity_events (project_id, task_id, type, source, summary) + VALUES (${owner.projectId}, ${taskId}, 'title_changed', 'web', 'secret')`; + } finally { + await sr.end({ timeout: 5 }); + } + + const ctx = makeAuthContext(stranger.userId); + const page = await listTaskActivity(ctx, taskId, { limit: 10 }); + expect(page.events).toEqual([]); + }); +}); From 983cafd5e126bcf9be5d0c29b38a2174695371bf Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:30:55 +0200 Subject: [PATCH 09/21] style: format activity insert test --- tests/data/activity-insert.test.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/tests/data/activity-insert.test.ts b/tests/data/activity-insert.test.ts index 1005454a..ed4f9f21 100644 --- a/tests/data/activity-insert.test.ts +++ b/tests/data/activity-insert.test.ts @@ -23,19 +23,15 @@ describe("insertActivityEvents", () => { } await withUserContext(fx.userId, async (tx) => { - await insertActivityEvents( - tx, - { source: "web", userId: fx.userId }, - [ - { - projectId: fx.projectId, - taskId, - type: "status_changed", - summary: "moved to done", - metadata: { from: "draft", to: "done" }, - }, - ], - ); + await insertActivityEvents(tx, { source: "web", userId: fx.userId }, [ + { + projectId: fx.projectId, + taskId, + type: "status_changed", + summary: "moved to done", + metadata: { from: "draft", to: "done" }, + }, + ]); }); const sr2 = serviceRoleConnect(); From 08eb9b7871d7a714b167ed6d11e592e782156fa0 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:34:51 +0200 Subject: [PATCH 10/21] feat: diff task changes into discrete events --- lib/data/task.ts | 139 ++++++++++++++++++++++++++ tests/data/activity-task-diff.test.ts | 43 ++++++++ 2 files changed, 182 insertions(+) create mode 100644 tests/data/activity-task-diff.test.ts diff --git a/lib/data/task.ts b/lib/data/task.ts index d8f3c604..5c969e0a 100644 --- a/lib/data/task.ts +++ b/lib/data/task.ts @@ -19,6 +19,7 @@ import { taskDecisions, taskLinks, type NewTask, + type Task, type Project, type TaskLink, } from "@/lib/db/schema"; @@ -48,6 +49,7 @@ import { } from "@/lib/graph/effective-deps"; import { projectDependsOnEdgesStmt } from "@/lib/data/edge"; import { projectAccessGateStmt } from "@/lib/data/access"; +import type { ActivityEventInput } from "@/lib/data/activity"; import { fetchMyTaskDepStats } from "@/lib/db/raw/fetch-my-task-dep-stats"; import { normalizeTags } from "@/lib/graph/tag-similarity"; import { ProjectNotFoundError, TaskLimitError } from "@/lib/graph/errors"; @@ -97,6 +99,143 @@ function makeHistoryEntry( }; } +/** + * Compute one discrete activity event per changed task field. Scalars compare + * by value; tags diff per element. Unchanged fields produce nothing. + * + * @param projectId - Owning project id. + * @param taskId - Task being updated. + * @param current - The row before the update. + * @param changes - The partial column changes being applied. + * @returns Discrete events to insert for this update. + */ +export function diffTaskChanges( + projectId: string, + taskId: string, + current: Task, + changes: Partial, +): ActivityEventInput[] { + const events: ActivityEventInput[] = []; + const base = { projectId, taskId }; + + if (changes.title !== undefined && changes.title !== current.title) { + events.push({ + ...base, + type: "title_changed", + summary: `renamed to "${changes.title}"`, + }); + } + if ( + changes.description !== undefined && + changes.description !== current.description + ) { + events.push({ + ...base, + type: "description_changed", + summary: "updated the description", + }); + } + if (changes.status !== undefined && changes.status !== current.status) { + events.push({ + ...base, + type: "status_changed", + summary: `moved to ${changes.status}`, + metadata: { from: current.status, to: changes.status }, + }); + } + if (changes.priority !== undefined && changes.priority !== current.priority) { + events.push({ + ...base, + type: "priority_changed", + summary: changes.priority + ? `set priority to ${changes.priority}` + : "cleared priority", + metadata: { from: current.priority, to: changes.priority }, + }); + } + if (changes.estimate !== undefined && changes.estimate !== current.estimate) { + events.push({ + ...base, + type: "estimate_changed", + summary: + changes.estimate != null + ? `set estimate to ${changes.estimate}` + : "cleared estimate", + metadata: { from: current.estimate, to: changes.estimate }, + }); + } + if (changes.category !== undefined && changes.category !== current.category) { + events.push({ + ...base, + type: "category_changed", + summary: changes.category + ? `set category to ${changes.category}` + : "cleared category", + }); + } + if ( + changes.implementationPlan !== undefined && + changes.implementationPlan !== current.implementationPlan + ) { + events.push({ + ...base, + type: "plan_set", + summary: "updated the implementation plan", + }); + } + if ( + changes.executionRecord !== undefined && + changes.executionRecord !== current.executionRecord + ) { + events.push({ + ...base, + type: "record_set", + summary: "updated the execution record", + }); + } + if (changes.order !== undefined && changes.order !== current.order) { + events.push({ + ...base, + type: "moved", + summary: "reordered the task", + metadata: { from: current.order, to: changes.order }, + }); + } + if (changes.tags !== undefined) { + const before = new Set(current.tags); + const after = new Set(changes.tags); + for (const tag of changes.tags) { + if (!before.has(tag)) + events.push({ + ...base, + type: "tag_added", + summary: `added tag ${tag}`, + targetRef: tag, + }); + } + for (const tag of current.tags) { + if (!after.has(tag)) + events.push({ + ...base, + type: "tag_removed", + summary: `removed tag ${tag}`, + targetRef: tag, + }); + } + } + if ( + changes.files !== undefined && + JSON.stringify(changes.files) !== JSON.stringify(current.files) + ) { + events.push({ + ...base, + type: "files_changed", + summary: "updated linked files", + }); + } + return events; +} + /** * Append the same history entry to multiple tasks in a single UPDATE. * Used by edge mutations to log "edge created/updated/deleted" on both diff --git a/tests/data/activity-task-diff.test.ts b/tests/data/activity-task-diff.test.ts new file mode 100644 index 00000000..84087956 --- /dev/null +++ b/tests/data/activity-task-diff.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { diffTaskChanges } from "@/lib/data/task"; +import type { Task } from "@/lib/db/schema"; + +const base = { + id: "t-1", + projectId: "p-1", + title: "Old", + status: "draft", + priority: null, + estimate: null, + category: null, + implementationPlan: null, + executionRecord: null, + order: 0, + tags: ["a"], + files: [], +} as unknown as Task; + +describe("diffTaskChanges", () => { + test("emits a discrete event per changed scalar", () => { + const events = diffTaskChanges("p-1", "t-1", base, { + title: "New", + status: "done", + }); + expect(events.map((e) => e.type).sort()).toEqual([ + "status_changed", + "title_changed", + ]); + const status = events.find((e) => e.type === "status_changed")!; + expect(status.metadata).toEqual({ from: "draft", to: "done" }); + }); + + test("emits tag_added / tag_removed per element", () => { + const events = diffTaskChanges("p-1", "t-1", base, { tags: ["a", "b"] }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "tag_added", targetRef: "b" }); + }); + + test("returns nothing when values are unchanged", () => { + expect(diffTaskChanges("p-1", "t-1", base, { title: "Old" })).toEqual([]); + }); +}); From fa01c821a525ed3e9cae59b93b51024dd54aec6a Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:37:59 +0200 Subject: [PATCH 11/21] feat: diff task collections into discrete events --- lib/data/activity.ts | 137 ++++++++++++++++++++ tests/data/activity-collection-diff.test.ts | 42 ++++++ 2 files changed, 179 insertions(+) create mode 100644 tests/data/activity-collection-diff.test.ts diff --git a/lib/data/activity.ts b/lib/data/activity.ts index 2c3f5a2c..bdc581f1 100644 --- a/lib/data/activity.ts +++ b/lib/data/activity.ts @@ -75,6 +75,143 @@ export async function insertActivityEvents( ); } +/** Minimal criterion shape needed to diff. */ +type CriterionLike = { id: string; text: string; checked: boolean }; +/** Minimal decision shape needed to diff. */ +type DecisionLike = { id: string; text: string }; + +/** + * Diff acceptance criteria into add/remove/check/uncheck events. + * + * @param projectId - Owning project id. + * @param taskId - Task id. + * @param before - Criteria prior to the write. + * @param after - Criteria after the write. + * @returns Discrete events. + */ +export function diffCriteria( + projectId: string, + taskId: string, + before: CriterionLike[], + after: CriterionLike[], +): ActivityEventInput[] { + const events: ActivityEventInput[] = []; + const base = { projectId, taskId }; + const beforeById = new Map(before.map((c) => [c.id, c])); + const afterById = new Map(after.map((c) => [c.id, c])); + for (const c of after) { + const prev = beforeById.get(c.id); + if (!prev) { + events.push({ + ...base, + type: "criterion_added", + summary: `added criterion "${c.text}"`, + targetRef: c.id, + }); + } else if (prev.checked !== c.checked) { + events.push({ + ...base, + type: c.checked ? "criterion_checked" : "criterion_unchecked", + summary: `${c.checked ? "checked" : "unchecked"} "${c.text}"`, + targetRef: c.id, + }); + } + } + for (const c of before) { + if (!afterById.has(c.id)) { + events.push({ + ...base, + type: "criterion_removed", + summary: `removed criterion "${c.text}"`, + targetRef: c.id, + }); + } + } + return events; +} + +/** + * Diff decisions into add/remove events. + * + * @param projectId - Owning project id. + * @param taskId - Task id. + * @param before - Decisions prior to the write. + * @param after - Decisions after the write. + * @returns Discrete events. + */ +export function diffDecisions( + projectId: string, + taskId: string, + before: DecisionLike[], + after: DecisionLike[], +): ActivityEventInput[] { + const events: ActivityEventInput[] = []; + const base = { projectId, taskId }; + const beforeIds = new Set(before.map((d) => d.id)); + const afterIds = new Set(after.map((d) => d.id)); + for (const d of after) { + if (!beforeIds.has(d.id)) { + events.push({ + ...base, + type: "decision_added", + summary: `recorded decision "${d.text}"`, + targetRef: d.id, + }); + } + } + for (const d of before) { + if (!afterIds.has(d.id)) { + events.push({ + ...base, + type: "decision_removed", + summary: `removed decision "${d.text}"`, + targetRef: d.id, + }); + } + } + return events; +} + +/** + * Diff assignee id sets into add/remove events. + * + * @param projectId - Owning project id. + * @param taskId - Task id. + * @param before - Assignee user ids prior to the write. + * @param after - Assignee user ids after the write. + * @returns Discrete events. + */ +export function diffAssignees( + projectId: string, + taskId: string, + before: string[], + after: string[], +): ActivityEventInput[] { + const events: ActivityEventInput[] = []; + const base = { projectId, taskId }; + const beforeSet = new Set(before); + const afterSet = new Set(after); + for (const id of after) { + if (!beforeSet.has(id)) + events.push({ + ...base, + type: "assignee_added", + summary: "added an assignee", + targetRef: id, + }); + } + for (const id of before) { + if (!afterSet.has(id)) + events.push({ + ...base, + type: "assignee_removed", + summary: "removed an assignee", + targetRef: id, + }); + } + return events; +} + const DEFAULT_LIMIT = 20; const MAX_LIMIT = 50; diff --git a/tests/data/activity-collection-diff.test.ts b/tests/data/activity-collection-diff.test.ts new file mode 100644 index 00000000..5440b7d7 --- /dev/null +++ b/tests/data/activity-collection-diff.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { + diffCriteria, + diffDecisions, + diffAssignees, +} from "@/lib/data/activity"; + +const b = { projectId: "p-1", taskId: "t-1" }; + +describe("collection diffs", () => { + test("criteria add + check", () => { + const before = [{ id: "c1", text: "x", checked: false }]; + const after = [ + { id: "c1", text: "x", checked: true }, + { id: "c2", text: "y", checked: false }, + ]; + const events = diffCriteria(b.projectId, b.taskId, before, after); + expect(events.map((e) => e.type).sort()).toEqual([ + "criterion_added", + "criterion_checked", + ]); + }); + + test("decisions remove", () => { + const before = [ + { id: "d1", text: "keep" }, + { id: "d2", text: "drop" }, + ]; + const after = [{ id: "d1", text: "keep" }]; + const events = diffDecisions(b.projectId, b.taskId, before, after); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "decision_removed" }); + }); + + test("assignees add + remove", () => { + const events = diffAssignees(b.projectId, b.taskId, ["u1"], ["u2"]); + expect(events.map((e) => e.type).sort()).toEqual([ + "assignee_added", + "assignee_removed", + ]); + }); +}); From e2d43d82bf7cd739958c0ef02a50714452a459fa Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:44:47 +0200 Subject: [PATCH 12/21] feat: record task_created activity event --- lib/data/task.ts | 22 +++++++++------- tests/data/activity-create-task.test.ts | 35 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 tests/data/activity-create-task.test.ts diff --git a/lib/data/task.ts b/lib/data/task.ts index 5c969e0a..3fc5f8bd 100644 --- a/lib/data/task.ts +++ b/lib/data/task.ts @@ -49,7 +49,10 @@ import { } from "@/lib/graph/effective-deps"; import { projectDependsOnEdgesStmt } from "@/lib/data/edge"; import { projectAccessGateStmt } from "@/lib/data/access"; -import type { ActivityEventInput } from "@/lib/data/activity"; +import { + insertActivityEvents, + type ActivityEventInput, +} from "@/lib/data/activity"; import { fetchMyTaskDepStats } from "@/lib/db/raw/fetch-my-task-dep-stats"; import { normalizeTags } from "@/lib/graph/tag-similarity"; import { ProjectNotFoundError, TaskLimitError } from "@/lib/graph/errors"; @@ -2207,14 +2210,6 @@ export async function createTask(ctx: AuthContext, data: CreateTaskInput) { ...taskFields, order, sequenceNumber, - history: [ - makeHistoryEntry({ - type: "created", - label: "Task created", - description: `Task "${taskFields.title}" created.`, - actor: "ai", - }), - ], }) .returning(); @@ -2246,6 +2241,15 @@ export async function createTask(ctx: AuthContext, data: CreateTaskInput) { }); } + await insertActivityEvents(tx, ctx.actor, [ + { + projectId: task.projectId, + taskId: task.id, + type: "task_created", + summary: `created task "${task.title}"`, + }, + ]); + return { id: task.id, title: task.title, diff --git a/tests/data/activity-create-task.test.ts b/tests/data/activity-create-task.test.ts new file mode 100644 index 00000000..43cb587b --- /dev/null +++ b/tests/data/activity-create-task.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { truncateAll } from "@/tests/setup/schema"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { makeAuthContext } from "@/lib/auth/context"; +import { createTask } from "@/lib/data/task"; + +afterEach(async () => { + await truncateAll(); +}); + +describe("createTask activity", () => { + test("records a task_created event attributed to the actor", async () => { + const fx = await seedUserOrgProject("ct-1"); + const ctx = makeAuthContext(fx.userId, { + source: "mcp", + userId: fx.userId, + clientId: null, + }); + const task = await createTask(ctx, { + projectId: fx.projectId, + title: "First", + } as Parameters[1]); + + const sr = serviceRoleConnect(); + try { + const rows = await sr` + SELECT type, source FROM activity_events WHERE task_id = ${task.id}`; + expect(rows.length).toBe(1); + expect(rows[0].type).toBe("task_created"); + expect(rows[0].source).toBe("mcp"); + } finally { + await sr.end({ timeout: 5 }); + } + }); +}); From 51c962ccc58cf270616df7e25b57af76c557ecca Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:50:05 +0200 Subject: [PATCH 13/21] feat: emit discrete activity events from updateTask --- lib/data/task.ts | 120 +++++++++++++++++++----- tests/data/activity-update-task.test.ts | 39 ++++++++ tests/data/task.test.ts | 20 +++- 3 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 tests/data/activity-update-task.test.ts diff --git a/lib/data/task.ts b/lib/data/task.ts index 3fc5f8bd..f4627933 100644 --- a/lib/data/task.ts +++ b/lib/data/task.ts @@ -51,6 +51,9 @@ import { projectDependsOnEdgesStmt } from "@/lib/data/edge"; import { projectAccessGateStmt } from "@/lib/data/access"; import { insertActivityEvents, + diffCriteria, + diffDecisions, + diffAssignees, type ActivityEventInput, } from "@/lib/data/activity"; import { fetchMyTaskDepStats } from "@/lib/db/raw/fetch-my-task-dep-stats"; @@ -2471,43 +2474,23 @@ export async function updateTask( changes.files = [...merged]; } - const isStatusChange = - "status" in changes && current.status !== changes.status; - const fieldList = [ - ...Object.keys(changes), - ...(assigneeIds !== undefined ? ["assigneeIds"] : []), - ...(formattedCriteria !== undefined ? ["acceptanceCriteria"] : []), - ...(formattedDecisions !== undefined ? ["decisions"] : []), - ...(hasPrUrl ? ["prUrl"] : []), - ]; - const entry = makeHistoryEntry({ - type: isStatusChange ? "status_change" : "refined", - label: isStatusChange - ? `Status: ${current.status} → ${changes.status}` - : "Task updated", - description: `Updated task fields: ${fieldList.join(", ")}.`, - actor: "ai", - }); - let row = current; if (Object.keys(changes).length > 0) { const [updatedRow] = await tx .update(tasks) .set({ ...changes, - history: sql`${tasks.history} || ${JSON.stringify([entry])}::jsonb`, updatedAt: new Date(), }) .where(eq(tasks.id, taskId)) .returning(); row = updatedRow; } else { - // No `tasks` row column changed; still append the history entry and - // bump updated_at so the cache validator advances on this turn. + // No `tasks` row column changed; still bump updated_at so the cache + // validator advances on this turn. const [updatedRow] = await tx .update(tasks) .set({ - history: sql`${tasks.history} || ${JSON.stringify([entry])}::jsonb`, updatedAt: new Date(), }) .where(eq(tasks.id, taskId)) @@ -2515,6 +2498,28 @@ export async function updateTask( row = updatedRow; } + // Discrete activity: scalar/tag diff now, collection diffs after the + // child-table writes below. Snapshot child + assignee state pre-write. + const eventInputs: ActivityEventInput[] = diffTaskChanges( + current.projectId, + taskId, + current, + changes as Partial, + ); + const childrenBefore = + formattedCriteria !== undefined || formattedDecisions !== undefined + ? await fetchTaskChildren(tx, taskId) + : null; + const assigneesBefore = + assigneeIds !== undefined + ? ( + await tx + .select({ userId: taskAssignees.userId }) + .from(taskAssignees) + .where(eq(taskAssignees.taskId, taskId)) + ).map((a) => a.userId) + : null; + if (formattedCriteria !== undefined) { await applyCriteriaWrite( tx, @@ -2542,6 +2547,60 @@ export async function updateTask( ); } + if (childrenBefore) { + const childrenAfter = await fetchTaskChildren(tx, taskId); + if (formattedCriteria !== undefined) { + eventInputs.push( + ...diffCriteria( + current.projectId, + taskId, + (childrenBefore.acceptance_criteria ?? []).map((c) => ({ + id: c.id, + text: c.text, + checked: c.checked, + })), + (childrenAfter.acceptance_criteria ?? []).map((c) => ({ + id: c.id, + text: c.text, + checked: c.checked, + })), + ), + ); + } + if (formattedDecisions !== undefined) { + eventInputs.push( + ...diffDecisions( + current.projectId, + taskId, + (childrenBefore.decisions ?? []).map((d) => ({ + id: d.id, + text: d.text, + })), + (childrenAfter.decisions ?? []).map((d) => ({ + id: d.id, + text: d.text, + })), + ), + ); + } + } + if (assigneesBefore && assigneeIds !== undefined) { + const assigneesAfter = ( + await tx + .select({ userId: taskAssignees.userId }) + .from(taskAssignees) + .where(eq(taskAssignees.taskId, taskId)) + ).map((a) => a.userId); + eventInputs.push( + ...diffAssignees( + current.projectId, + taskId, + assigneesBefore, + assigneesAfter, + ), + ); + } + if (hasPrUrl) { if (typeof prUrl !== "string" || prUrl.length === 0) { await tx @@ -2552,6 +2611,12 @@ export async function updateTask( eq(taskLinks.kind, "pull_request"), ), ); + eventInputs.push({ + projectId: current.projectId, + taskId, + type: "link_removed", + summary: "removed the pull request link", + }); } else { const classified = classifyLink(prUrl); await tx @@ -2566,8 +2631,19 @@ export async function updateTask( .onConflictDoNothing({ target: [taskLinks.taskId, taskLinks.url], }); + eventInputs.push({ + projectId: current.projectId, + taskId, + type: "link_added", + summary: `linked ${classified.label ?? classified.kind}`, + targetRef: classified.url, + }); } } + + if (eventInputs.length > 0) { + await insertActivityEvents(tx, ctx.actor, eventInputs); + } let criteriaResult: AcceptanceCriterion[] | null = null; let decisionsResult: Decision[] | null = null; if (refetchNeeded) { diff --git a/tests/data/activity-update-task.test.ts b/tests/data/activity-update-task.test.ts new file mode 100644 index 00000000..af9bc87c --- /dev/null +++ b/tests/data/activity-update-task.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { truncateAll } from "@/tests/setup/schema"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { makeAuthContext } from "@/lib/auth/context"; +import { createTask, updateTask } from "@/lib/data/task"; + +afterEach(async () => { + await truncateAll(); +}); + +describe("updateTask activity", () => { + test("emits one event per changed field", async () => { + const fx = await seedUserOrgProject("ut-1"); + const ctx = makeAuthContext(fx.userId, { + source: "web", + userId: fx.userId, + }); + const task = await createTask(ctx, { + projectId: fx.projectId, + title: "Old", + } as Parameters[1]); + + await updateTask(ctx, task.id, { title: "New", status: "in_progress" }); + + const sr = serviceRoleConnect(); + try { + const rows = await sr` + SELECT type FROM activity_events + WHERE task_id = ${task.id} AND type <> 'task_created' + ORDER BY type`; + expect(rows.map((r) => r.type)).toEqual([ + "status_changed", + "title_changed", + ]); + } finally { + await sr.end({ timeout: 5 }); + } + }); +}); diff --git a/tests/data/task.test.ts b/tests/data/task.test.ts index 591ce083..e2ccb157 100644 --- a/tests/data/task.test.ts +++ b/tests/data/task.test.ts @@ -36,20 +36,30 @@ test("concurrent createTask calls allocate distinct sequenceNumbers", async () = expect(seqs).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); }); -test("concurrent updateTask calls preserve every history entry", async () => { +test("concurrent updateTask calls record every activity event", async () => { const f = await seedUserOrgProject("histrace"); const ctx = makeAuthContext(f.userId); const task = await createTask(ctx, { projectId: f.projectId, title: "T" }); - // Fire 5 concurrent updates on the same task; each appends one history entry. + // Fire 5 concurrent updates on the same task; each inserts its own event + // row, so none can be lost to a read-modify-write race. const calls = Array.from({ length: 5 }, (_, i) => updateTask(ctx, task.id, { description: `desc-${i}` }), ); await Promise.all(calls); - const final = await updateTask(ctx, task.id, { description: "final" }); - // 1 created + 5 concurrent updates + 1 final = 7 total entries. - expect(final.history.length).toBe(7); + await updateTask(ctx, task.id, { description: "final" }); + + const sr = serviceRoleConnect(); + try { + const [{ count }] = await sr<{ count: number }[]>` + SELECT count(*)::int AS count + FROM activity_events WHERE task_id = ${task.id}`; + // 1 created + 5 concurrent updates + 1 final = 7 total events. + expect(count).toBe(7); + } finally { + await sr.end({ timeout: 5 }); + } }); test("concurrent updateTask calls preserve every appended decision", async () => { From fd712ff983dc179ee6934d313ab932d707f787a9 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:55:03 +0200 Subject: [PATCH 14/21] feat: emit edge activity events on both endpoints --- lib/data/edge.ts | 105 +++++++++++++++---------------- tests/data/activity-edge.test.ts | 47 ++++++++++++++ 2 files changed, 97 insertions(+), 55 deletions(-) create mode 100644 tests/data/activity-edge.test.ts diff --git a/lib/data/edge.ts b/lib/data/edge.ts index 1731df91..04f6ada8 100644 --- a/lib/data/edge.ts +++ b/lib/data/edge.ts @@ -4,10 +4,10 @@ import { and, eq, or, sql } from "drizzle-orm"; import { uuidArray, type Conn, type ReadConn } from "@/lib/db/raw"; import { withUserContext, withUserContextRead, type Tx } from "@/lib/db/rls"; import { projects, tasks, taskEdges, type NewTaskEdge } from "@/lib/db/schema"; -import type { EdgeType, HistoryEntry } from "@/lib/types"; +import type { EdgeType } from "@/lib/types"; import { asIdentifier, composeTaskRef } from "@/lib/graph/identifier"; import { fetchDependencyChain } from "@/lib/db/raw/fetch-dependency-chain"; -import { appendTaskHistoryMany } from "@/lib/data/task"; +import { insertActivityEvents } from "@/lib/data/activity"; import { formatMarkdown } from "@/lib/markdown/format"; import type { AuthContext } from "@/lib/auth/context"; import { @@ -20,21 +20,6 @@ import { import { taskAccessGateStmt } from "@/lib/data/access"; import { emitEdgeMutation } from "@/lib/realtime/events"; -/** - * Build a timestamped history entry. - * @param entry - Partial entry without id/date. - * @returns Complete history entry with generated id and current date. - */ -function makeHistoryEntry( - entry: Omit, -): HistoryEntry { - return { - ...entry, - id: crypto.randomUUID(), - date: new Date().toISOString(), - }; -} - // --------------------------------------------------------------------------- // Edge queries // --------------------------------------------------------------------------- @@ -415,18 +400,22 @@ export async function createEdge( const [created] = await tx.insert(taskEdges).values(data).returning(); - const historyEntry = makeHistoryEntry({ - type: "edge_added", - label: `Edge: ${data.edgeType}`, - description: `${data.edgeType} edge created.`, - actor: "ai", - }); - - await appendTaskHistoryMany( - [data.sourceTaskId, data.targetTaskId], - historyEntry, - { tx }, - ); + await insertActivityEvents(tx, ctx.actor, [ + { + projectId: sourceTask.projectId, + taskId: data.sourceTaskId, + type: "edge_added", + summary: `added ${data.edgeType} → target`, + targetRef: data.targetTaskId, + }, + { + projectId: sourceTask.projectId, + taskId: data.targetTaskId, + type: "edge_added", + summary: `added ${data.edgeType} ← source`, + targetRef: data.sourceTaskId, + }, + ]); return { edge: created, projectId: sourceTask.projectId }; }); @@ -554,20 +543,22 @@ export async function updateEdge( .where(eq(taskEdges.id, edgeId)) .returning(); - const historyEntry = makeHistoryEntry({ - type: "edge_updated", - label: `Edge updated: ${row.edgeType}`, - description: `Edge updated${updates.edgeType ? ` to ${updates.edgeType}` : ""}${ - updates.note !== undefined ? " with new note" : "" - }.`, - actor: "ai", - }); - - await appendTaskHistoryMany( - [existing.sourceTaskId, existing.targetTaskId], - historyEntry, - { tx }, - ); + await insertActivityEvents(tx, ctx.actor, [ + { + projectId, + taskId: existing.sourceTaskId, + type: "edge_updated", + summary: `updated the ${row.edgeType} edge → target`, + targetRef: existing.targetTaskId, + }, + { + projectId, + taskId: existing.targetTaskId, + type: "edge_updated", + summary: `updated the ${row.edgeType} edge ← source`, + targetRef: existing.sourceTaskId, + }, + ]); return { updated: row, existing, projectId }; }, @@ -594,18 +585,22 @@ export async function removeEdge(ctx: AuthContext, edgeId: string) { await tx.delete(taskEdges).where(eq(taskEdges.id, edgeId)); - const historyEntry = makeHistoryEntry({ - type: "edge_removed", - label: `Edge removed: ${edge.edgeType}`, - description: `${edge.edgeType} edge removed.`, - actor: "user", - }); - - await appendTaskHistoryMany( - [edge.sourceTaskId, edge.targetTaskId], - historyEntry, - { tx }, - ); + await insertActivityEvents(tx, ctx.actor, [ + { + projectId, + taskId: edge.sourceTaskId, + type: "edge_removed", + summary: `removed the ${edge.edgeType} edge → target`, + targetRef: edge.targetTaskId, + }, + { + projectId, + taskId: edge.targetTaskId, + type: "edge_removed", + summary: `removed the ${edge.edgeType} edge ← source`, + targetRef: edge.sourceTaskId, + }, + ]); return { edge, projectId }; }); diff --git a/tests/data/activity-edge.test.ts b/tests/data/activity-edge.test.ts new file mode 100644 index 00000000..7e03d175 --- /dev/null +++ b/tests/data/activity-edge.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { truncateAll } from "@/tests/setup/schema"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { makeAuthContext } from "@/lib/auth/context"; +import { createEdge } from "@/lib/data/edge"; + +afterEach(async () => { + await truncateAll(); +}); + +describe("edge activity", () => { + test("createEdge records one edge_added row per endpoint", async () => { + const fx = await seedUserOrgProject("edge-1"); + const sr = serviceRoleConnect(); + let aId: string; + let bId: string; + try { + const [a] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number) + VALUES (${fx.projectId}, 'A', 1) RETURNING id`; + const [b] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number) + VALUES (${fx.projectId}, 'B', 2) RETURNING id`; + aId = a.id; + bId = b.id; + } finally { + await sr.end({ timeout: 5 }); + } + + const ctx = makeAuthContext(fx.userId); + await createEdge(ctx, { + sourceTaskId: aId, + targetTaskId: bId, + edgeType: "depends_on", + }); + + const sr2 = serviceRoleConnect(); + try { + const rows = await sr2` + SELECT task_id FROM activity_events WHERE type = 'edge_added' ORDER BY task_id`; + expect(rows.length).toBe(2); + expect(new Set(rows.map((r) => r.task_id))).toEqual(new Set([aId, bId])); + } finally { + await sr2.end({ timeout: 5 }); + } + }); +}); From d58b0ef4ed7c24fe555976d1c6205c8cda043a13 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:58:11 +0200 Subject: [PATCH 15/21] feat: record project_created activity event --- lib/data/project.ts | 35 +++++++++-------------------- tests/data/activity-project.test.ts | 31 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 24 deletions(-) create mode 100644 tests/data/activity-project.test.ts diff --git a/lib/data/project.ts b/lib/data/project.ts index 41305248..7b9bd4c0 100644 --- a/lib/data/project.ts +++ b/lib/data/project.ts @@ -24,7 +24,7 @@ import { } from "@/lib/db/raw/aggregate-project-tags"; import { getProjectListMaxUpdatedAtRaw } from "@/lib/db/raw/get-project-list-max-updated-at"; import { getProjectMaxUpdatedAtRaw } from "@/lib/db/raw/get-project-max-updated-at"; -import type { HistoryEntry } from "@/lib/types"; +import { insertActivityEvents } from "@/lib/data/activity"; import { asIdentifier, deriveIdentifier, @@ -66,21 +66,6 @@ import { } from "@/lib/realtime/events"; import { decodeCursor, encodeCursor, type Cursor } from "@/lib/data/cursor"; -/** - * Build a timestamped history entry. - * @param entry - Partial entry without id/date. - * @returns Complete history entry with generated id and current date. - */ -function makeHistoryEntry( - entry: Omit, -): HistoryEntry { - return { - ...entry, - id: crypto.randomUUID(), - date: new Date().toISOString(), - }; -} - // --------------------------------------------------------------------------- // Single-entity queries // --------------------------------------------------------------------------- @@ -1084,16 +1069,18 @@ export async function createProject( ...data, identifier, organizationId: targetOrgId, - history: [ - makeHistoryEntry({ - type: "created", - label: "Project created", - description: `Project "${data.title}" created.`, - actor: "user", - }), - ], }) .returning(); + + await insertActivityEvents(tx, ctx.actor, [ + { + projectId: row.id, + taskId: null, + type: "project_created", + summary: `created project "${row.title}"`, + }, + ]); + return { project: row, targetOrgId }; }, ); diff --git a/tests/data/activity-project.test.ts b/tests/data/activity-project.test.ts new file mode 100644 index 00000000..80235966 --- /dev/null +++ b/tests/data/activity-project.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { truncateAll } from "@/tests/setup/schema"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { makeAuthContext } from "@/lib/auth/context"; +import { createProject } from "@/lib/data/project"; + +afterEach(async () => { + await truncateAll(); +}); + +describe("project activity", () => { + test("createProject records a project_created event", async () => { + const fx = await seedUserOrgProject("proj-1"); + const ctx = makeAuthContext(fx.userId); + const project = await createProject(ctx, { + organizationId: fx.organizationId, + title: "P", + } as Parameters[1]); + + const sr = serviceRoleConnect(); + try { + const rows = await sr` + SELECT type, task_id FROM activity_events WHERE project_id = ${project.id}`; + expect(rows.length).toBe(1); + expect(rows[0].type).toBe("project_created"); + expect(rows[0].task_id).toBeNull(); + } finally { + await sr.end({ timeout: 5 }); + } + }); +}); From b765c8af8854999c3d8e98d405723b1069e4e16d Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 02:59:44 +0200 Subject: [PATCH 16/21] refactor: drop dead history-writer helpers --- lib/data/task.ts | 57 ------------------------------------------------ 1 file changed, 57 deletions(-) diff --git a/lib/data/task.ts b/lib/data/task.ts index f4627933..cde90e77 100644 --- a/lib/data/task.ts +++ b/lib/data/task.ts @@ -90,21 +90,6 @@ import { projectColor } from "@/lib/ui/project-color"; import { emitTaskEvent } from "@/lib/realtime/events"; import { classifyLink, MalformedLinkError } from "@/lib/links/classify"; -/** - * Build a timestamped history entry. - * @param entry - Partial entry without id/date. - * @returns Complete history entry with generated id and current date. - */ -function makeHistoryEntry( - entry: Omit, -): HistoryEntry { - return { - ...entry, - id: crypto.randomUUID(), - date: new Date().toISOString(), - }; -} - /** * Compute one discrete activity event per changed task field. Scalars compare * by value; tags diff per element. Unchanged fields produce nothing. @@ -242,48 +227,6 @@ export function diffTaskChanges( return events; } -/** - * Append the same history entry to multiple tasks in a single UPDATE. - * Used by edge mutations to log "edge created/updated/deleted" on both - * endpoints with one wire round-trip instead of two serial UPDATEs - * inside the transaction. - * - * Runs under RLS: callers must supply either an active transaction handle - * (`opts.tx`, when the append participates in a larger same-transaction - * mutation) or a `userId` to drive a fresh `withUserContext` frame. The - * discriminated union prevents a bare call from silently default-denying - * under `app_user`. Caller is responsible for asserting access to every - * task in `taskIds`. Duplicates and empty arrays are handled gracefully - * (no-op for empty input). - * - * @param taskIds - UUIDs of the tasks to append to. Duplicates dedup'd. - * @param entry - The history entry to append to every supplied task. - * @param opts - Either `{ tx }` (run inside the supplied transaction) or - * `{ userId }` (open a fresh `withUserContext` frame). - */ -export async function appendTaskHistoryMany( - taskIds: string[], - entry: HistoryEntry, - opts: { tx: Tx } | { userId: string }, -): Promise { - const dedup = [...new Set(taskIds)]; - if (dedup.length === 0) return; - const run = async (handle: Tx) => { - await handle - .update(tasks) - .set({ - history: sql`${tasks.history} || ${JSON.stringify([entry])}::jsonb`, - updatedAt: new Date(), - }) - .where(inArray(tasks.id, dedup)); - }; - if ("tx" in opts) { - await run(opts.tx); - return; - } - await withUserContext(opts.userId, run); -} - /** * Normalize a criteria input array (strings or partial objects) into the * canonical `AcceptanceCriterion[]` shape, minting ids where missing. From 9e8d42c89ec98bd5c9a151ed45ede7ec8ad2439d Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 03:07:35 +0200 Subject: [PATCH 17/21] perf: drop history from task fetch payload --- .../_components/WorkspaceClient.tsx | 1 - lib/data/task.ts | 2 -- lib/data/views.ts | 2 +- lib/db/raw/fetch-task-full.ts | 25 ++++++++++++++----- tests/data/task.test.ts | 1 - 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/app/project/[projectId]/_components/WorkspaceClient.tsx b/app/project/[projectId]/_components/WorkspaceClient.tsx index cf74be2c..c3431d74 100644 --- a/app/project/[projectId]/_components/WorkspaceClient.tsx +++ b/app/project/[projectId]/_components/WorkspaceClient.tsx @@ -448,7 +448,6 @@ function useSelectedTaskBody( priority: slim.priority ?? null, estimate: slim.estimate ?? null, files: [], - history: [], createdAt: new Date(), updatedAt: slim.updatedAt, taskRef: slim.taskRef, diff --git a/lib/data/task.ts b/lib/data/task.ts index cde90e77..a23f1e18 100644 --- a/lib/data/task.ts +++ b/lib/data/task.ts @@ -32,7 +32,6 @@ import { fetchTaskChildren } from "@/lib/db/raw/fetch-task-children"; import type { AcceptanceCriterion, Decision, - HistoryEntry, TaskStatus, Priority, Estimate, @@ -618,7 +617,6 @@ function mapTaskFullRow(r: TaskFullRawRow): TaskFull { priority: r.priority as Priority | null, estimate: r.estimate as Estimate | null, files: r.files ?? [], - history: (r.history ?? []) as HistoryEntry[], createdAt: r.created_at instanceof Date ? r.created_at : new Date(r.created_at), updatedAt: diff --git a/lib/data/views.ts b/lib/data/views.ts index 16cda445..e8c5af98 100644 --- a/lib/data/views.ts +++ b/lib/data/views.ts @@ -244,7 +244,7 @@ export type TaskSlim = { * this type carries them via join so consumers read * `task.acceptanceCriteria` and `task.decisions` directly. */ -export type TaskFull = Task & { +export type TaskFull = Omit & { taskRef: string; assignees: AssigneeRef[]; acceptanceCriteria: AcceptanceCriterion[]; diff --git a/lib/db/raw/fetch-task-full.ts b/lib/db/raw/fetch-task-full.ts index 0ce787fa..289a172a 100644 --- a/lib/db/raw/fetch-task-full.ts +++ b/lib/db/raw/fetch-task-full.ts @@ -21,7 +21,6 @@ export type TaskFullRawRow = { priority: string | null; estimate: number | null; files: string[]; - history: unknown[]; created_at: string | Date; updated_at: string | Date; project_identifier: string; @@ -192,9 +191,9 @@ function depthAggregate(keep: boolean, agg: SQL, alias: string): SQL { /** * Build the depth-projected task-row SQL shared by the interactive and - * batch read paths. Columns no depth reads (`category`, `history`) and - * columns this depth omits are returned as type-stable `NULL` literals so - * the {@link TaskFullRawRow} shape is identical across depths. + * batch read paths. Columns no depth reads (`category`) and columns this + * depth omits are returned as type-stable `NULL` literals so the + * {@link TaskFullRawRow} shape is identical across depths. * * @param taskId - UUID of the task. * @param depth - Context depth selecting the column projection. @@ -218,7 +217,6 @@ function taskForDepthSql(taskId: string, depth: TaskFetchDepth): SQL { t.priority, t.estimate, ${depthColumn(p.files, sql`t.files`, "files", "jsonb")}, - '[]'::jsonb AS history, t.created_at, t.updated_at, p.identifier AS project_identifier, @@ -263,7 +261,22 @@ export function taskForDepthStmt( function taskFullSql(taskId: string): SQL { return sql` SELECT - t.*, + t.id, + t.project_id, + t.title, + t.sequence_number, + t.description, + t.status, + t."order", + t.category, + t.implementation_plan, + t.execution_record, + t.tags, + t.priority, + t.estimate, + t.files, + t.created_at, + t.updated_at, p.identifier AS project_identifier, (SELECT json_agg(json_build_object('userId', a.user_id, 'name', a.name, 'email', a.email) ORDER BY a.name) FROM public.task_assignees_visible(t.id) a) AS assignees, diff --git a/tests/data/task.test.ts b/tests/data/task.test.ts index e2ccb157..e2bb384d 100644 --- a/tests/data/task.test.ts +++ b/tests/data/task.test.ts @@ -972,7 +972,6 @@ test("getTaskFull returns the full row with composed taskRef", async () => { expect(t.id).toBe(created.id); expect(t.title).toBe("T2"); expect(t.taskRef).toMatch(/^[A-Za-z0-9]+-\d+$/); - expect(Array.isArray(t.history)).toBe(true); expect(Array.isArray(t.acceptanceCriteria)).toBe(true); }); From e3522c2257a8667dc6d069ee42a1d12ae959838d Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 03:12:00 +0200 Subject: [PATCH 18/21] feat: add activity query key --- lib/query/keys.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/query/keys.ts b/lib/query/keys.ts index ea834706..63c123fd 100644 --- a/lib/query/keys.ts +++ b/lib/query/keys.ts @@ -24,6 +24,9 @@ export const taskKeys = { /** Three-bundle markdown for the MD toggle. */ context: (projectId: string, taskId: string) => ["task", projectId, taskId, "context"] as const, + /** Paginated activity log for the detail panel. */ + activity: (projectId: string, taskId: string) => + ["task", projectId, taskId, "activity"] as const, } as const; /** Team-scoped query keys (member roster). */ From ed4989ec21651dfaf15fe11304faa82aa573327d Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 03:13:54 +0200 Subject: [PATCH 19/21] feat: add task activity read endpoint --- app/api/task/[taskId]/events/route.ts | 42 +++++++++++++++++++++++++++ tests/api/task-events.test.ts | 34 ++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 app/api/task/[taskId]/events/route.ts create mode 100644 tests/api/task-events.test.ts diff --git a/app/api/task/[taskId]/events/route.ts b/app/api/task/[taskId]/events/route.ts new file mode 100644 index 00000000..141f859f --- /dev/null +++ b/app/api/task/[taskId]/events/route.ts @@ -0,0 +1,42 @@ +import { getAuthContext } from "@/lib/auth/context"; +import { ForbiddenError, assertTaskAccess } from "@/lib/auth/authorization"; +import { listTaskActivity } from "@/lib/data/activity"; +import { internalError } from "@/lib/api/error"; +import { error, ok } from "@/lib/api/response"; + +/** + * GET handler — paginated activity for a task, newest-first. + * + * @param req - Incoming request; reads `?cursor` and `?limit`. + * @param params - Route params with `taskId`. + * @returns 200 with `{ events, nextCursor }`, or 401/404/500. + */ +export async function GET( + req: Request, + { params }: { params: Promise<{ taskId: string }> }, +): Promise { + const { taskId } = await params; + + let ctx; + try { + ctx = await getAuthContext(); + } catch { + return error("Unauthorized", 401); + } + + try { + await assertTaskAccess(taskId, ctx); + const url = new URL(req.url); + const cursor = url.searchParams.get("cursor") ?? undefined; + const limitRaw = url.searchParams.get("limit"); + const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined; + const page = await listTaskActivity(ctx, taskId, { + cursor, + limit: limit !== undefined && Number.isFinite(limit) ? limit : undefined, + }); + return ok(page); + } catch (err) { + if (err instanceof ForbiddenError) return error("Task not found", 404); + return internalError("task-events", err); + } +} diff --git a/tests/api/task-events.test.ts b/tests/api/task-events.test.ts new file mode 100644 index 00000000..63af6d95 --- /dev/null +++ b/tests/api/task-events.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { truncateAll } from "@/tests/setup/schema"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { listTaskActivity } from "@/lib/data/activity"; +import { makeAuthContext } from "@/lib/auth/context"; + +afterEach(async () => { + await truncateAll(); +}); + +// Endpoint wiring is thin; assert the data function it delegates to returns a +// shaped page (the route adds only auth + JSON serialization). +describe("task activity endpoint contract", () => { + test("listTaskActivity returns events + nextCursor shape", async () => { + const fx = await seedUserOrgProject("ep-1"); + const sr = serviceRoleConnect(); + let taskId: string; + try { + const [t] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number) + VALUES (${fx.projectId}, 'T', 1) RETURNING id`; + taskId = t.id; + await sr` + INSERT INTO activity_events (project_id, task_id, type, source, summary) + VALUES (${fx.projectId}, ${taskId}, 'title_changed', 'web', 'x')`; + } finally { + await sr.end({ timeout: 5 }); + } + const page = await listTaskActivity(makeAuthContext(fx.userId), taskId, {}); + expect(page).toHaveProperty("events"); + expect(page).toHaveProperty("nextCursor"); + expect(page.events[0]?.summary).toBe("x"); + }); +}); From 63387541759a1bb214fb1e084fa6b080ac493751 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 03:17:01 +0200 Subject: [PATCH 20/21] feat: render activity panel from events endpoint --- .../workspace/detail/ActivitySection.tsx | 117 +++++++++++++----- components/workspace/detail/DetailView.tsx | 2 +- 2 files changed, 85 insertions(+), 34 deletions(-) diff --git a/components/workspace/detail/ActivitySection.tsx b/components/workspace/detail/ActivitySection.tsx index f72040d7..3d9a0aaf 100644 --- a/components/workspace/detail/ActivitySection.tsx +++ b/components/workspace/detail/ActivitySection.tsx @@ -1,60 +1,105 @@ "use client"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { Avatar } from "@/components/shared/Avatar"; -import type { HistoryEntry } from "@/lib/types"; +import type { ActivityEvent } from "@/lib/types"; +import { taskKeys } from "@/lib/query/keys"; import { SectionHeader } from "./SectionHeader"; interface ActivitySectionProps { - /** History entries from the schema. */ - history: HistoryEntry[] | null | undefined; + /** Owning project id (for the query key). */ + projectId: string; + /** Task whose activity to show. */ + taskId: string; +} + +interface ActivityPage { + events: ActivityEvent[]; + nextCursor: string | null; +} + +/** + * Fetch one page of activity for a task. + * @param taskId - Task id. + * @param cursor - Opaque keyset cursor or null for the first page. + * @returns The page payload. + * @throws Error when the request fails. + */ +async function fetchActivity( + taskId: string, + cursor: string | null, +): Promise { + const qs = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""; + const res = await fetch(`/api/task/${taskId}/events${qs}`); + if (!res.ok) throw new Error(`activity ${res.status}`); + return res.json(); } /** - * Vertical activity timeline matching the prototype — avatar per row plus a - * thin connector running through the avatar centers. One-line entries with a - * mono relative date pinned to the right. + * Activity timeline — avatar + actor name + harness badge + entity-referencing + * summary + relative time (absolute on hover). Lazy-loaded and paginated. * - * @param props - Section configuration. - * @returns Section element or null when there is no history. + * @param props - Project + task identifiers. + * @returns Section element, or null while empty. */ -export function ActivitySection({ history }: ActivitySectionProps) { - if (!history || history.length === 0) return null; +export function ActivitySection({ projectId, taskId }: ActivitySectionProps) { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery({ + queryKey: taskKeys.activity(projectId, taskId), + queryFn: ({ pageParam }) => fetchActivity(taskId, pageParam), + initialPageParam: null as string | null, + getNextPageParam: (last) => last.nextCursor, + }); + + const events = data?.pages.flatMap((p) => p.events) ?? []; + if (events.length === 0) return null; return (
- +
    - {history.map((entry, i) => ( - + {events.map((e, i) => ( + ))}
+ {hasNextPage && ( + + )}
); } interface ActivityRowProps { - /** The single history entry to render. */ - entry: HistoryEntry; - /** Whether this is the last row — controls the trailing connector line. */ + /** Event to render. */ + event: ActivityEvent; + /** Whether this is the last row — controls the trailing connector. */ isLast: boolean; } /** - * Single timeline row — avatar + author/verb sentence + relative date. - * + * Single timeline row. * @param props - Row configuration. * @returns List item element. */ -function ActivityRow({ entry, isLast }: ActivityRowProps) { - const author = entry.actor === "ai" ? "agent" : "user"; +function ActivityRow({ event, isLast }: ActivityRowProps) { + const name = event.actorName ?? (event.source === "web" ? "user" : "agent"); + const isAgent = event.source === "mcp"; return (
  • - + {!isLast && (
  • ); } /** - * Compact relative-time formatter — picks the largest unit that fits and - * appends the unit suffix (`12m`, `2h`, `3d`, `2w`). - * + * Compact relative-time formatter. * @param iso - ISO date string. - * @returns Two-character relative label, or `—` if unparseable. + * @returns Short relative label, or `—` if unparseable. */ function formatRelative(iso: string): string { const ts = Date.parse(iso); diff --git a/components/workspace/detail/DetailView.tsx b/components/workspace/detail/DetailView.tsx index e7adb8dc..448e97ea 100644 --- a/components/workspace/detail/DetailView.tsx +++ b/components/workspace/detail/DetailView.tsx @@ -218,7 +218,7 @@ export function DetailView({ - + )} From 2a72e701af31860369310508c2c2ad7a15953e72 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Sat, 13 Jun 2026 03:32:58 +0200 Subject: [PATCH 21/21] feat: backfill activity events from legacy history --- scripts/backfill-activity-events.sql | 62 ++++++++++++++++++++++++++++ tests/data/activity-backfill.test.ts | 62 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 scripts/backfill-activity-events.sql create mode 100644 tests/data/activity-backfill.test.ts diff --git a/scripts/backfill-activity-events.sql b/scripts/backfill-activity-events.sql new file mode 100644 index 00000000..0b19b040 --- /dev/null +++ b/scripts/backfill-activity-events.sql @@ -0,0 +1,62 @@ +-- One-time backfill of activity_events from legacy tasks.history / +-- projects.history JSONB. Idempotent: skips any task/project that already has +-- an event row. Identity is unknown in legacy data (actor columns null); +-- source is inferred from the old binary `actor`. Run once with migration +-- credentials (never the runtime app_user / serviceRoleDb path): +-- psql "$DATABASE_SERVICE_ROLE_URL" -f scripts/backfill-activity-events.sql + +INSERT INTO activity_events + (project_id, task_id, type, created_at, actor_user_id, source, + actor_client_id, summary, target_ref, metadata) +SELECT + t.project_id, + t.id, + CASE elem->>'type' + WHEN 'created' THEN 'task_created' + WHEN 'status_change' THEN 'status_changed' + WHEN 'planned' THEN 'plan_set' + WHEN 'decision' THEN 'decision_added' + WHEN 'edge_added' THEN 'edge_added' + WHEN 'edge_removed' THEN 'edge_removed' + WHEN 'edge_updated' THEN 'edge_updated' + WHEN 'moved' THEN 'moved' + ELSE 'description_changed' + END, + (elem->>'date')::timestamptz, + NULL, + CASE elem->>'actor' WHEN 'ai' THEN 'mcp' ELSE 'web' END, + NULL, + COALESCE(elem->>'label', ''), + NULL, + NULL +FROM tasks t +CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(t.history) = 'array' THEN t.history ELSE '[]'::jsonb END +) AS elem +WHERE NOT EXISTS (SELECT 1 FROM activity_events e WHERE e.task_id = t.id); + +INSERT INTO activity_events + (project_id, task_id, type, created_at, actor_user_id, source, + actor_client_id, summary, target_ref, metadata) +SELECT + p.id, + NULL, + CASE elem->>'type' + WHEN 'created' THEN 'project_created' + ELSE 'description_changed' + END, + (elem->>'date')::timestamptz, + NULL, + CASE elem->>'actor' WHEN 'ai' THEN 'mcp' ELSE 'web' END, + NULL, + COALESCE(elem->>'label', ''), + NULL, + NULL +FROM projects p +CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(p.history) = 'array' THEN p.history ELSE '[]'::jsonb END +) AS elem +WHERE NOT EXISTS ( + SELECT 1 FROM activity_events e + WHERE e.project_id = p.id AND e.task_id IS NULL + ); diff --git a/tests/data/activity-backfill.test.ts b/tests/data/activity-backfill.test.ts new file mode 100644 index 00000000..6186a207 --- /dev/null +++ b/tests/data/activity-backfill.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { truncateAll } from "@/tests/setup/schema"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; + +afterEach(async () => { + await truncateAll(); +}); + +const BACKFILL_SQL = readFileSync( + "scripts/backfill-activity-events.sql", + "utf8", +); + +describe("activity backfill SQL", () => { + test("maps legacy JSONB entries to event rows and is idempotent", async () => { + const fx = await seedUserOrgProject("bf-1"); + const sr = serviceRoleConnect(); + try { + const [t] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number, history) + VALUES (${fx.projectId}, 'T', 1, ${sr.json([ + { + id: "h1", + type: "created", + date: "2025-01-01T00:00:00.000Z", + label: "Task created", + description: "", + actor: "ai", + }, + { + id: "h2", + type: "status_change", + date: "2025-01-02T00:00:00.000Z", + label: "Status: draft → done", + description: "", + actor: "user", + }, + ])}) RETURNING id`; + const taskId = t.id; + + await sr.unsafe(BACKFILL_SQL); + const rows = await sr` + SELECT type, source, actor_user_id FROM activity_events + WHERE task_id = ${taskId} ORDER BY created_at`; + expect(rows.map((r) => r.type)).toEqual([ + "task_created", + "status_changed", + ]); + expect(rows.map((r) => r.source)).toEqual(["mcp", "web"]); + expect(rows.every((r) => r.actor_user_id === null)).toBe(true); + + // Idempotent: a second run inserts nothing. + await sr.unsafe(BACKFILL_SQL); + const [{ count }] = await sr<{ count: number }[]>` + SELECT count(*)::int AS count FROM activity_events WHERE task_id = ${taskId}`; + expect(count).toBe(2); + } finally { + await sr.end({ timeout: 5 }); + } + }); +});