diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 0343027a..f286b230 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -29,8 +29,17 @@ export type AgentDefinition = typeof WorkflowDefinitionResponse.infer; export type AgentInstance = typeof WorkflowRunResponse.infer; export type CatalogModel = typeof ModelResponse.infer; +const RunFireResponse = WorkflowRunResponse.and( + type({ routineId: "string | null", routineName: "string | null" }), +); +/** A `feed=fires` row: every `AgentInstance` field plus the routine that + * fired it (both `null` for a directly-triggered deployment with no + * routine parent). */ +export type RunFire = typeof RunFireResponse.infer; + const DefinitionsPage = paginatedSchema(WorkflowDefinitionResponse); const InstancesPage = paginatedSchema(WorkflowRunResponse); +const RunFiresPage = paginatedSchema(RunFireResponse); const ModelsPage = paginatedSchema(ModelResponse); // The REST pagination ceiling (see `vendor/intx/hub-api/src/pagination.ts`). @@ -135,12 +144,13 @@ export function listAgentInstances( /** * The tenant's genuine top-level deployment runs — every folded run - * (workbench host, invited agent) excluded server-side by the hub's - * own `folded_run` marker table (see `@corbits/folded-runs`'s + * (workbench host, invited agent, routine fire, task) excluded server-side + * by the hub's own `folded_run` marker table (see `@corbits/folded-runs`'s * `scope-routes.ts`), not derived client-side from a tenant's workbenches * the way `foldedRunIdsFromWorkbenches` used to. Used wherever a page needs - * "real deployments only" — the Agent Directory and the shell's - * "Running" activity band alike. + * "real deployments only" — the Agent Directory. A routine fire IS a + * folded run, so this feed structurally never carries one; a caller that + * needs routine activity wants `listRoutineRunFires` below instead. */ export function listTopLevelRuns( tenantId: string, @@ -151,6 +161,26 @@ export function listTopLevelRuns( ).then((page) => page.data); } +/** + * `feed=fires` (CL-6249): the tenant's genuine *executed* runs — unlike + * `listTopLevelRuns`, a routine's fire is kept even though it is a folded + * run, tagged with the routine that fired it (see + * `@corbits/folded-runs`'s `scope-routes.ts`'s `listTopLevelRunFires`). + * The shell's "Running" activity band (CL-6595) reads this, not + * `listTopLevelRuns`, so a routine's own run is actually visible here — + * `listTopLevelRuns`'s `notExists(folded_run)` filter drops every routine + * fire by construction, which left Mission Control's active-run count + * permanently desynced from the Routines page's own "Running now" pill. + */ +export function listRoutineRunFires( + tenantId: string, +): Promise { + return getJSON( + `/api/tenants/${tenantId}/top-level-runs?limit=${PAGE_LIMIT}&feed=fires`, + RunFiresPage, + ).then((page) => page.data); +} + /** The tenant's visible, enabled catalog models for the create-agent form's * model picker. Uses `/catalog/models` (paginated `ModelResponse`), not the * bare-array discovery route at `/models` (`ModelInfo[]`) — those are diff --git a/apps/web/src/shell/routine-activity.ts b/apps/web/src/shell/routine-activity.ts index 7be4169b..9b764628 100644 --- a/apps/web/src/shell/routine-activity.ts +++ b/apps/web/src/shell/routine-activity.ts @@ -1,15 +1,17 @@ -// A seam for `@corbits/routines`, which is not on `main` yet: the second -// column's "Running" section depends only on `RoutineActivityItem` and -// `listRoutineActivity`, never on where the data actually comes from. Today -// it is filled from `./agents-api.ts`'s `listTopLevelRuns` — the tenant's -// genuine top-level deployment runs, folded runs already excluded -// server-side (see `@corbits/folded-runs`'s `scope-routes.ts`) — so the -// section shows real, bench-scoped activity rather than nothing. Once -// `@corbits/routines` publishes its own richer listing, only this file's -// body changes. - -import { listTopLevelRuns } from "../agents-api"; -import type { AgentInstance } from "../agents-api"; +// The second column's "Running" section and Mission Control's active-run +// count (CL-6595) both depend only on `RoutineActivityItem` and +// `listRoutineActivity`, never on where the data actually comes from. +// Filled from `./agents-api.ts`'s `listRoutineRunFires` — the `feed=fires` +// listing, the one top-level-runs view that keeps a routine's fire despite +// it being a folded run (see that function's own comment). The plain +// `listTopLevelRuns` feed looks tempting here but is wrong: its +// `notExists(folded_run)` filter drops every routine fire by construction, +// so a routine genuinely running would never show up in this band or count +// toward Mission Control's "Active runs" — exactly CL-6595's desync +// between the Routines page's own "Running now" pill and Mission Control's +// "0 / nothing running". +import { listRoutineRunFires } from "../agents-api"; +import type { RunFire } from "../agents-api"; export type RoutineActivityItem = { readonly id: string; @@ -18,10 +20,10 @@ export type RoutineActivityItem = { readonly startedAt: string; }; -function toRoutineActivityItem(run: AgentInstance): RoutineActivityItem { +function toRoutineActivityItem(run: RunFire): RoutineActivityItem { return { id: run.id, - name: run.definitionName, + name: run.routineName ?? run.definitionName, status: run.status, startedAt: run.createdAt, }; @@ -30,7 +32,7 @@ function toRoutineActivityItem(run: AgentInstance): RoutineActivityItem { export function listRoutineActivity( tenantId: string, ): Promise { - return listTopLevelRuns(tenantId).then((runs) => - runs.map(toRoutineActivityItem), + return listRoutineRunFires(tenantId).then((runs) => + runs.filter((run) => run.routineId !== null).map(toRoutineActivityItem), ); } diff --git a/apps/web/test/agents-api.test.ts b/apps/web/test/agents-api.test.ts index 24415a91..3255dc55 100644 --- a/apps/web/test/agents-api.test.ts +++ b/apps/web/test/agents-api.test.ts @@ -7,6 +7,7 @@ import { clearAgentModel, getAgentDefinitionBySlug, listCatalogModels, + listRoutineRunFires, loadAgentDirectory, updateAgentSkills, } from "../src/agents-api"; @@ -270,6 +271,31 @@ describe("getAgentDefinitionBySlug", () => { }); }); +describe("listRoutineRunFires", () => { + test("requests the fires feed of top-level-runs, not the plain feed", async () => { + const calls = stubFetch(() => + json({ + data: [ + { + ...instanceFixture, + routineId: "rtn_1", + routineName: "Weekly digest", + }, + ], + nextCursor: null, + }), + ); + + const fires = await listRoutineRunFires("tnt_1"); + + expect(calls[0]?.path).toContain("/api/tenants/tnt_1/top-level-runs"); + expect(calls[0]?.path).toContain("feed=fires"); + expect(fires).toEqual([ + { ...instanceFixture, routineId: "rtn_1", routineName: "Weekly digest" }, + ]); + }); +}); + describe("clearAgentModel", () => { test("DELETEs the model capability rather than posting an empty name", async () => { const calls = stubFetch((path) => { diff --git a/apps/web/test/bench-activity.test.tsx b/apps/web/test/bench-activity.test.tsx index 9d49f18e..4d5cf724 100644 --- a/apps/web/test/bench-activity.test.tsx +++ b/apps/web/test/bench-activity.test.tsx @@ -115,7 +115,7 @@ describe("useBenchActivity", () => { container.remove(); }); - test("splits workbenches by kind and shows only genuine top-level runs", async () => { + test("splits workbenches by kind and shows only genuine routine fires (CL-6595)", async () => { const calls: string[] = []; stubTenantFetch(calls, { workbenches: [ @@ -139,21 +139,37 @@ describe("useBenchActivity", () => { }, ], // The workbench host and the invited agent never appear here: the - // hub's `/top-level-runs` route excludes every folded run - // server-side (see `@corbits/folded-runs`'s `scope-routes.ts`), - // so this mock reflects exactly what that route returns — only - // the genuine deployment. + // hub's `/top-level-runs?feed=fires` route excludes every folded + // run that isn't a routine fire (see `@corbits/folded-runs`'s + // `scope-routes.ts`). A routine's own fire IS a folded run, so it + // must still show up here (`run_routine1`, tagged with its + // `routineId`) -- that is the CL-6595 fix; a directly-triggered + // deployment with no routine parent (`run_deployment1`) is not + // routine activity and must not appear. runs: [ { id: "run_deployment1", definitionId: "def_researcher", - workbenchId: "ch_1", definitionName: "researcher", tenantId: "tnt_1", address: "run_deployment1@tnt1.example", status: "running", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", + routineId: null, + routineName: null, + }, + { + id: "run_routine1", + definitionId: "def_digest", + definitionName: "Digest agent", + tenantId: "tnt_1", + address: "run_routine1@tnt1.example", + status: "running", + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + routineId: "rtn_1", + routineName: "Weekly digest", }, ], }); @@ -163,7 +179,8 @@ describe("useBenchActivity", () => { if (state.kind !== "ready") throw new Error(`not ready: ${state.kind}`); expect(state.workbenches.map((c) => c.id)).toEqual(["run_host1"]); expect(state.chats.map((c) => c.id)).toEqual(["run_chat1"]); - expect(state.routines.map((r) => r.id)).toEqual(["run_deployment1"]); + expect(state.routines.map((r) => r.id)).toEqual(["run_routine1"]); + expect(state.routines.map((r) => r.name)).toEqual(["Weekly digest"]); root.unmount(); container.remove(); }); diff --git a/apps/web/test/routine-activity.test.ts b/apps/web/test/routine-activity.test.ts index 9747ad73..86a0cc29 100644 --- a/apps/web/test/routine-activity.test.ts +++ b/apps/web/test/routine-activity.test.ts @@ -1,10 +1,11 @@ -// The seam standing in for `@corbits/routines` (not on `main` yet): today it -// maps `listTopLevelRuns`'s server-scoped run listing into -// `RoutineActivityItem`, so the second column gets real bench-scoped -// activity instead of nothing. Folded/chat/task runs never reach this -// module at all — the hub's `/top-level-runs` route already excludes them -// (see `@corbits/folded-runs`'s `scope-routes.ts`), so there is nothing -// left for this seam to filter. +// CL-6595: the shell's "Running" section and Mission Control's active-run +// count both read `listRoutineActivity`, which must source the `feed=fires` +// listing — the one top-level-runs view that keeps a routine's fire (see +// `@corbits/folded-runs`'s `scope-routes.ts`). The plain `listTopLevelRuns` +// feed excludes every folded run, and a routine fire IS a folded run, so a +// routine genuinely running would never appear here at all -- Mission +// Control would read "0 active" while the Routines page's own "Running +// now" pill (driven by the same run's `workflow_run.status`) disagreed. import { afterEach, describe, expect, test } from "bun:test"; @@ -16,43 +17,95 @@ afterEach(() => { globalThis.fetch = realFetch; }); +let lastRequestedPath: string | null = null; + function stubTopLevelRunsFetch(runs: readonly unknown[]): void { - globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => - Promise.resolve( + globalThis.fetch = ((input: RequestInfo | URL) => { + lastRequestedPath = typeof input === "string" ? input : input.toString(); + return Promise.resolve( new Response(JSON.stringify({ data: runs, nextCursor: null }), { status: 200, headers: { "content-type": "application/json" }, }), - )) as typeof fetch; + ); + }) as typeof fetch; } -const deploymentRun = { +const runningFire = { id: "run_1", definitionId: "wfd_1", - definitionName: "Researcher", + definitionName: "Weekly digest", tenantId: "tnt_1", address: "run_1@tnt1.example", status: "running", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", + routineId: "rtn_1", + routineName: "Weekly digest", +}; + +const completedFire = { + ...runningFire, + id: "run_2", + status: "stopped", + routineId: "rtn_1", +}; + +const nonRoutineFire = { + id: "run_3", + definitionId: "wfd_2", + definitionName: "Directly triggered deployment", + tenantId: "tnt_1", + address: "run_3@tnt1.example", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + routineId: null, + routineName: null, }; describe("listRoutineActivity", () => { - test("maps a workflow run into a routine activity item", async () => { - stubTopLevelRunsFetch([deploymentRun]); + test("reads the fires feed, not the plain top-level-runs feed", async () => { + stubTopLevelRunsFetch([runningFire]); + await listRoutineActivity("tnt_1"); + expect(lastRequestedPath).toContain("feed=fires"); + }); + + test("maps a running routine fire into a routine activity item", async () => { + stubTopLevelRunsFetch([runningFire]); const items = await listRoutineActivity("tnt_1"); expect(items).toEqual([ { id: "run_1", - name: "Researcher", + name: "Weekly digest", status: "running", startedAt: "2026-01-01T00:00:00.000Z", }, ]); }); + // The bug this ticket reports: a run that genuinely finished must not + // keep counting toward "active" just because it once fired. Mission + // Control's active-run count filters on `status === "running"`, so this + // item leaving that status here is what makes the two surfaces agree. + test("a completed routine fire no longer reads as running", async () => { + stubTopLevelRunsFetch([completedFire]); + + const [item] = await listRoutineActivity("tnt_1"); + + expect(item?.status).not.toBe("running"); + }); + + // A directly-triggered deployment run is also a `feed=fires` row (it is + // not a folded run at all), but it has no routine parent -- it must not + // be counted as routine activity. + test("drops a fires-feed row with no routine parent", async () => { + stubTopLevelRunsFetch([nonRoutineFire]); + expect(await listRoutineActivity("tnt_1")).toEqual([]); + }); + test("an empty run list is an empty routine list", async () => { stubTopLevelRunsFetch([]); expect(await listRoutineActivity("tnt_1")).toEqual([]);