Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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,
Expand All @@ -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<readonly RunFire[]> {
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
Expand Down
34 changes: 18 additions & 16 deletions apps/web/src/shell/routine-activity.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
};
Expand All @@ -30,7 +32,7 @@ function toRoutineActivityItem(run: AgentInstance): RoutineActivityItem {
export function listRoutineActivity(
tenantId: string,
): Promise<readonly RoutineActivityItem[]> {
return listTopLevelRuns(tenantId).then((runs) =>
runs.map(toRoutineActivityItem),
return listRoutineRunFires(tenantId).then((runs) =>
runs.filter((run) => run.routineId !== null).map(toRoutineActivityItem),
);
}
26 changes: 26 additions & 0 deletions apps/web/test/agents-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
clearAgentModel,
getAgentDefinitionBySlug,
listCatalogModels,
listRoutineRunFires,
loadAgentDirectory,
updateAgentSkills,
} from "../src/agents-api";
Expand Down Expand Up @@ -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) => {
Expand Down
31 changes: 24 additions & 7 deletions apps/web/test/bench-activity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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",
},
],
});
Expand All @@ -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();
});
Expand Down
83 changes: 68 additions & 15 deletions apps/web/test/routine-activity.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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([]);
Expand Down
Loading