From 8c05e164cb53de60ff33a38bb8142b9a17dc08ba Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Jul 2026 18:03:55 -0500 Subject: [PATCH] feat(tasks): thread scheduled task titles onto GET /api/tasks/runs list runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger.dev list runs carry only taskIdentifier (every scheduled prompt shares customer-prompt-task), so chat's homepage rendered identical "Scheduled Task" rows. List mode now annotates each run with the originating scheduled_actions.title, resolved by matching run ids against each schedule's runs (filter[schedule] on trigger_schedule_id — list runs expose no schedule reference, verified against the live v1 API). title is null when a run cannot be mapped; retrieve mode and all existing fields are unchanged. Fails open at both layers: a failed Trigger.dev schedule fetch skips that schedule's titles, a failed scheduled_actions lookup yields null titles — the runs list never breaks. Contract: recoupable/docs#268. Tracking: recoupable/chat#1850 (video-parity item 1). Co-Authored-By: Claude Fable 5 --- lib/tasks/__tests__/attachRunTitles.test.ts | 77 ++++++++++++++++ lib/tasks/__tests__/buildRunTitleMap.test.ts | 87 +++++++++++++++++++ lib/tasks/__tests__/getTaskRunHandler.test.ts | 32 +++++++ lib/tasks/attachRunTitles.ts | 38 ++++++++ lib/tasks/buildRunTitleMap.ts | 43 +++++++++ lib/tasks/getTaskRunHandler.ts | 12 ++- 6 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 lib/tasks/__tests__/attachRunTitles.test.ts create mode 100644 lib/tasks/__tests__/buildRunTitleMap.test.ts create mode 100644 lib/tasks/attachRunTitles.ts create mode 100644 lib/tasks/buildRunTitleMap.ts diff --git a/lib/tasks/__tests__/attachRunTitles.test.ts b/lib/tasks/__tests__/attachRunTitles.test.ts new file mode 100644 index 000000000..24c901726 --- /dev/null +++ b/lib/tasks/__tests__/attachRunTitles.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { attachRunTitles } from "../attachRunTitles"; +import { buildRunTitleMap } from "../buildRunTitleMap"; +import { selectScheduledActions } from "@/lib/supabase/scheduled_actions/selectScheduledActions"; +import type { TriggerRun } from "@/lib/trigger/fetchTriggerRuns"; +import type { Tables } from "@/types/database.types"; + +vi.mock("@/lib/supabase/scheduled_actions/selectScheduledActions", () => ({ + selectScheduledActions: vi.fn(), +})); + +vi.mock("../buildRunTitleMap", () => ({ + buildRunTitleMap: vi.fn(), +})); + +const makeRun = (id: string): TriggerRun => ({ + id, + status: "COMPLETED", + createdAt: "2026-07-07T00:00:00.000Z", + startedAt: null, + finishedAt: null, + durationMs: 1000, +}); + +const mockAction = { + id: "action_1", + title: "Weekly valuation + streams report", + trigger_schedule_id: "sched_1", +} as Tables<"scheduled_actions">; + +describe("attachRunTitles", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("annotates runs with the resolved title, null when unmapped", async () => { + vi.mocked(selectScheduledActions).mockResolvedValue([mockAction]); + vi.mocked(buildRunTitleMap).mockResolvedValue( + new Map([["run_a", "Weekly valuation + streams report"]]), + ); + + const result = await attachRunTitles([makeRun("run_a"), makeRun("run_b")], "acc_1", 20); + + expect(selectScheduledActions).toHaveBeenCalledWith({ account_id: "acc_1" }); + expect(buildRunTitleMap).toHaveBeenCalledWith([mockAction], 20); + expect(result[0].title).toBe("Weekly valuation + streams report"); + expect(result[1].title).toBeNull(); + }); + + it("preserves all existing run fields", async () => { + vi.mocked(selectScheduledActions).mockResolvedValue([mockAction]); + vi.mocked(buildRunTitleMap).mockResolvedValue(new Map()); + + const run = { ...makeRun("run_a"), tags: ["account:acc_1"], costInCents: 0.5 }; + const result = await attachRunTitles([run], "acc_1", 20); + + expect(result[0]).toEqual({ ...run, title: null }); + }); + + it("returns an empty array without querying when there are no runs", async () => { + const result = await attachRunTitles([], "acc_1", 20); + + expect(result).toEqual([]); + expect(selectScheduledActions).not.toHaveBeenCalled(); + }); + + it("fails open with null titles when scheduled_actions lookup throws", async () => { + vi.mocked(selectScheduledActions).mockRejectedValue(new Error("db down")); + + const result = await attachRunTitles([makeRun("run_a")], "acc_1", 20); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("run_a"); + expect(result[0].title).toBeNull(); + }); +}); diff --git a/lib/tasks/__tests__/buildRunTitleMap.test.ts b/lib/tasks/__tests__/buildRunTitleMap.test.ts new file mode 100644 index 000000000..dae59ed7c --- /dev/null +++ b/lib/tasks/__tests__/buildRunTitleMap.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { buildRunTitleMap } from "../buildRunTitleMap"; +import { fetchTriggerRuns } from "@/lib/trigger/fetchTriggerRuns"; + +vi.mock("@/lib/trigger/fetchTriggerRuns", () => ({ + fetchTriggerRuns: vi.fn(), +})); + +const baseRun = { + status: "COMPLETED", + createdAt: "2026-07-07T00:00:00.000Z", + startedAt: null, + finishedAt: null, + durationMs: 1000, +}; + +describe("buildRunTitleMap", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps run ids from schedule-filtered runs to the action's title", async () => { + vi.mocked(fetchTriggerRuns).mockResolvedValueOnce([ + { ...baseRun, id: "run_a" }, + { ...baseRun, id: "run_b" }, + ]); + + const map = await buildRunTitleMap( + [{ title: "Weekly valuation + streams report", trigger_schedule_id: "sched_1" }], + 20, + ); + + expect(fetchTriggerRuns).toHaveBeenCalledWith({ "filter[schedule]": "sched_1" }, 20); + expect(map.get("run_a")).toBe("Weekly valuation + streams report"); + expect(map.get("run_b")).toBe("Weekly valuation + streams report"); + }); + + it("fetches once per schedule and merges entries across actions", async () => { + vi.mocked(fetchTriggerRuns) + .mockResolvedValueOnce([{ ...baseRun, id: "run_a" }]) + .mockResolvedValueOnce([{ ...baseRun, id: "run_b" }]); + + const map = await buildRunTitleMap( + [ + { title: "Task One", trigger_schedule_id: "sched_1" }, + { title: "Task Two", trigger_schedule_id: "sched_2" }, + ], + 5, + ); + + expect(fetchTriggerRuns).toHaveBeenCalledTimes(2); + expect(map.get("run_a")).toBe("Task One"); + expect(map.get("run_b")).toBe("Task Two"); + }); + + it("skips actions without a trigger_schedule_id", async () => { + const map = await buildRunTitleMap([{ title: "Unlinked task", trigger_schedule_id: null }], 20); + + expect(fetchTriggerRuns).not.toHaveBeenCalled(); + expect(map.size).toBe(0); + }); + + it("fails open per action when a Trigger.dev fetch throws", async () => { + vi.mocked(fetchTriggerRuns) + .mockRejectedValueOnce(new Error("Trigger.dev API error: 500")) + .mockResolvedValueOnce([{ ...baseRun, id: "run_b" }]); + + const map = await buildRunTitleMap( + [ + { title: "Broken schedule", trigger_schedule_id: "sched_1" }, + { title: "Working schedule", trigger_schedule_id: "sched_2" }, + ], + 20, + ); + + expect(map.size).toBe(1); + expect(map.get("run_b")).toBe("Working schedule"); + }); + + it("returns an empty map for an empty actions list", async () => { + const map = await buildRunTitleMap([], 20); + + expect(fetchTriggerRuns).not.toHaveBeenCalled(); + expect(map.size).toBe(0); + }); +}); diff --git a/lib/tasks/__tests__/getTaskRunHandler.test.ts b/lib/tasks/__tests__/getTaskRunHandler.test.ts index 9f17fffce..b205c553b 100644 --- a/lib/tasks/__tests__/getTaskRunHandler.test.ts +++ b/lib/tasks/__tests__/getTaskRunHandler.test.ts @@ -6,11 +6,16 @@ import { getTaskRunHandler } from "../getTaskRunHandler"; import { validateGetTaskRunQuery } from "../validateGetTaskRunQuery"; import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; import { fetchTriggerRuns } from "@/lib/trigger/fetchTriggerRuns"; +import { attachRunTitles } from "../attachRunTitles"; vi.mock("../validateGetTaskRunQuery", () => ({ validateGetTaskRunQuery: vi.fn(), })); +vi.mock("../attachRunTitles", () => ({ + attachRunTitles: vi.fn(), +})); + vi.mock("@/lib/trigger/retrieveTaskRun", () => ({ retrieveTaskRun: vi.fn(), })); @@ -45,6 +50,11 @@ const mockRun = { error: null, }; +const mockListRun = { + ...mockRun, + createdAt: "2025-01-01T00:00:00.000Z", +}; + describe("getTaskRunHandler", () => { beforeEach(() => { vi.clearAllMocks(); @@ -100,6 +110,7 @@ describe("getTaskRunHandler", () => { limit: 20, }); vi.mocked(fetchTriggerRuns).mockResolvedValue([]); + vi.mocked(attachRunTitles).mockResolvedValue([]); const response = await getTaskRunHandler(createMockRequest()); const json = await response.json(); @@ -115,6 +126,7 @@ describe("getTaskRunHandler", () => { limit: 20, }); vi.mocked(fetchTriggerRuns).mockResolvedValue([mockRun]); + vi.mocked(attachRunTitles).mockResolvedValue([{ ...mockListRun, title: null }]); const response = await getTaskRunHandler(createMockRequest()); const json = await response.json(); @@ -123,6 +135,25 @@ describe("getTaskRunHandler", () => { expect(json.runs).toHaveLength(1); }); + it("annotates list runs with titles via attachRunTitles", async () => { + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ + mode: "list", + accountId: "acc_123", + limit: 20, + }); + vi.mocked(fetchTriggerRuns).mockResolvedValue([mockListRun]); + vi.mocked(attachRunTitles).mockResolvedValue([ + { ...mockListRun, title: "Weekly valuation + streams report" }, + ]); + + const response = await getTaskRunHandler(createMockRequest()); + const json = await response.json(); + + expect(attachRunTitles).toHaveBeenCalledWith([mockListRun], "acc_123", 20); + expect(json.runs[0].title).toBe("Weekly valuation + streams report"); + expect(json.runs[0].id).toBe("run_123"); + }); + it("calls fetchTriggerRuns with accountId and limit", async () => { vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "list", @@ -130,6 +161,7 @@ describe("getTaskRunHandler", () => { limit: 50, }); vi.mocked(fetchTriggerRuns).mockResolvedValue([]); + vi.mocked(attachRunTitles).mockResolvedValue([]); await getTaskRunHandler(createMockRequest()); diff --git a/lib/tasks/attachRunTitles.ts b/lib/tasks/attachRunTitles.ts new file mode 100644 index 000000000..167d7f441 --- /dev/null +++ b/lib/tasks/attachRunTitles.ts @@ -0,0 +1,38 @@ +import { selectScheduledActions } from "@/lib/supabase/scheduled_actions/selectScheduledActions"; +import type { TriggerRun } from "@/lib/trigger/fetchTriggerRuns"; +import { buildRunTitleMap } from "./buildRunTitleMap"; + +export type TaskRunWithTitle = TriggerRun & { title: string | null }; + +/** + * Annotates Trigger.dev runs with the title of the scheduled action that + * triggered them. Titles are resolved by matching run ids against each of + * the account's schedules (see buildRunTitleMap); runs that cannot be + * mapped (e.g. non-scheduled runs) get title null. + * + * Fails open: if the scheduled_actions lookup or title resolution fails, + * every run is returned with title null so the runs list never breaks. + * + * @param runs - Runs from the account-tag list fetch + * @param accountId - The account whose scheduled actions to resolve against + * @param limit - Runs to fetch per schedule (match the list page size) + * @returns The same runs, each with a title field (null when unresolvable) + */ +export async function attachRunTitles( + runs: TriggerRun[], + accountId: string, + limit: number, +): Promise { + if (runs.length === 0) { + return []; + } + + try { + const actions = await selectScheduledActions({ account_id: accountId }); + const titleByRunId = await buildRunTitleMap(actions, limit); + return runs.map(run => ({ ...run, title: titleByRunId.get(run.id) ?? null })); + } catch (error) { + console.error("Error resolving task run titles:", error); + return runs.map(run => ({ ...run, title: null })); + } +} diff --git a/lib/tasks/buildRunTitleMap.ts b/lib/tasks/buildRunTitleMap.ts new file mode 100644 index 000000000..2c5981a0b --- /dev/null +++ b/lib/tasks/buildRunTitleMap.ts @@ -0,0 +1,43 @@ +import { fetchTriggerRuns } from "@/lib/trigger/fetchTriggerRuns"; + +export type RunTitleSource = { + title: string; + trigger_schedule_id: string | null; +}; + +/** + * Builds a run-id → task-title map by fetching each scheduled action's + * recent Trigger.dev runs via the schedule filter. Trigger.dev list runs + * carry no schedule reference, so this reverse lookup is how a run is + * linked back to its originating scheduled action. + * + * Fails open per action: a failed Trigger.dev fetch contributes no + * entries instead of breaking the whole map. + * + * @param actions - Scheduled actions (title + trigger_schedule_id) + * @param limit - Runs to fetch per schedule (match the list page size) + * @returns Map of Trigger.dev run id to the originating task's title + */ +export async function buildRunTitleMap( + actions: RunTitleSource[], + limit: number, +): Promise> { + const entriesPerAction = await Promise.all( + actions + .filter(action => action.trigger_schedule_id) + .map(async (action): Promise<(readonly [string, string])[]> => { + try { + const runs = await fetchTriggerRuns( + { "filter[schedule]": action.trigger_schedule_id as string }, + limit, + ); + return runs.map(run => [run.id, action.title] as const); + } catch { + // Trigger.dev fetch failed for this schedule — skip its titles + return []; + } + }), + ); + + return new Map(entriesPerAction.flat()); +} diff --git a/lib/tasks/getTaskRunHandler.ts b/lib/tasks/getTaskRunHandler.ts index f988c6b6a..7689b2ee0 100644 --- a/lib/tasks/getTaskRunHandler.ts +++ b/lib/tasks/getTaskRunHandler.ts @@ -4,12 +4,15 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetTaskRunQuery } from "./validateGetTaskRunQuery"; import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; import { fetchTriggerRuns } from "@/lib/trigger/fetchTriggerRuns"; +import { attachRunTitles } from "./attachRunTitles"; /** * Handles GET /api/tasks/runs requests. * Always returns { status: "success", runs: [...] }. * When runId is provided, runs contains a single element. - * When omitted, runs contains recent runs for the authenticated account. + * When omitted, runs contains recent runs for the authenticated account, + * each annotated with the originating scheduled task's title (null when + * the run cannot be mapped to a scheduled task). * * @param request - The NextRequest object * @returns A NextResponse with the runs array @@ -26,8 +29,13 @@ export async function getTaskRunHandler(request: NextRequest): Promise