From a1ffd4a20ebed5801227e0bc176eec101fda4802 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:17:02 +0100 Subject: [PATCH 1/5] fix(webapp): show sessions with no live run as Idle instead of Active Session status was derived only from closedAt/expiresAt, so an open session whose run had already finished stayed Active forever and its duration ticked up from createdAt without end. Status is now derived from the current run's liveness: a session with no live run reads Idle, and its duration freezes at the run's completion instead of counting up. Active is reserved for sessions with a run actually executing. Applies to the sessions list and the session detail page. --- .server-changes/sessions-idle-status.md | 6 + .../components/sessions/v1/SessionStatus.tsx | 36 ++- .../components/sessions/v1/SessionsTable.tsx | 16 +- .../v3/SessionListPresenter.server.ts | 26 +- .../presenters/v3/deriveSessionStatus.test.ts | 92 ++++++ .../app/presenters/v3/deriveSessionStatus.ts | 46 +++ .../route.tsx | 38 ++- .../sessionsRepository.server.ts | 9 + .../test/sessionListPresenterStatus.test.ts | 275 ++++++++++++++++++ apps/webapp/vitest.config.ts | 1 + 10 files changed, 512 insertions(+), 33 deletions(-) create mode 100644 .server-changes/sessions-idle-status.md create mode 100644 apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts create mode 100644 apps/webapp/app/presenters/v3/deriveSessionStatus.ts create mode 100644 apps/webapp/test/sessionListPresenterStatus.test.ts diff --git a/.server-changes/sessions-idle-status.md b/.server-changes/sessions-idle-status.md new file mode 100644 index 0000000000..8bc16e6b5d --- /dev/null +++ b/.server-changes/sessions-idle-status.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +The Sessions list no longer shows an abandoned session as Active with a duration that climbs forever. A session whose run has finished now shows as Idle with a duration frozen at when it stopped, and only sessions with a run still executing show as Active. diff --git a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx index 69dfdf5092..27f27dda25 100644 --- a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx +++ b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx @@ -1,26 +1,34 @@ import { CheckCircleIcon, ClockIcon } from "@heroicons/react/20/solid"; import assertNever from "assert-never"; -import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server"; +import { + type SessionDisplayStatus, + type SessionStatus, +} from "~/services/sessionsRepository/sessionsRepository.server"; import { cn } from "~/utils/cn"; +// Filterable statuses only — `IDLE` is display-only and derived from run +// liveness, so it never appears in the filter surface. export const allSessionStatuses = ["ACTIVE", "CLOSED", "EXPIRED"] as const satisfies Readonly< Array >; -const descriptions: Record = { - ACTIVE: "The session is open and can receive input or schedule new runs.", +const descriptions: Record = { + ACTIVE: "The session has a run currently executing.", + IDLE: "The session is open but has no run currently executing.", CLOSED: "The session was closed; no further input or runs can be triggered against it.", EXPIRED: "The session passed its expiry time without being closed explicitly.", }; -export function descriptionForSessionStatus(status: SessionStatus): string { +export function descriptionForSessionStatus(status: SessionDisplayStatus): string { return descriptions[status]; } -export function sessionStatusTitle(status: SessionStatus): string { +export function sessionStatusTitle(status: SessionDisplayStatus): string { switch (status) { case "ACTIVE": return "Active"; + case "IDLE": + return "Idle"; case "CLOSED": return "Closed"; case "EXPIRED": @@ -30,10 +38,12 @@ export function sessionStatusTitle(status: SessionStatus): string { } } -export function sessionStatusColor(status: SessionStatus): string { +export function sessionStatusColor(status: SessionDisplayStatus): string { switch (status) { case "ACTIVE": return "text-pending"; + case "IDLE": + return "text-text-dimmed"; case "CLOSED": return "text-success"; case "EXPIRED": @@ -48,7 +58,7 @@ export function SessionStatusIcon({ className, pulse = true, }: { - status: SessionStatus; + status: SessionDisplayStatus; className: string; pulse?: boolean; }) { @@ -64,6 +74,14 @@ export function SessionStatusIcon({ ); + case "IDLE": + // Open but not live: a static, dimmed dot (no pulse) — distinct from + // ACTIVE's pulsing dot and EXPIRED's clock. + return ( + + + + ); case "CLOSED": return ; case "EXPIRED": @@ -73,7 +91,7 @@ export function SessionStatusIcon({ } } -export function SessionStatusLabel({ status }: { status: SessionStatus }) { +export function SessionStatusLabel({ status }: { status: SessionDisplayStatus }) { // system-mono-label: System themes uncolor the label (see tailwind.css) return ( @@ -88,7 +106,7 @@ export function SessionStatusCombo({ iconClassName, pulse = true, }: { - status: SessionStatus; + status: SessionDisplayStatus; className?: string; iconClassName?: string; pulse?: boolean; diff --git a/apps/webapp/app/components/sessions/v1/SessionsTable.tsx b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx index 4340a97681..d6d3e09952 100644 --- a/apps/webapp/app/components/sessions/v1/SessionsTable.tsx +++ b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx @@ -195,15 +195,20 @@ export function SessionsTable({ } function SessionDuration({ session }: { session: SessionListItem }) { - // Active sessions tick live; closed/expired sessions freeze at the - // moment they ended (closedAt for explicit closes, expiresAt when the - // TTL ran out without a close call). + // Only a genuinely live session ticks. Everything else freezes at the moment + // it stopped being live: closedAt for explicit closes, expiresAt when the TTL + // ran out, or the current run's completedAt for an idle (open, not-running) + // session — so an abandoned session doesn't count up forever. + if (session.status === "ACTIVE") { + return ; + } + const endedAt = session.status === "CLOSED" ? session.closedAt : session.status === "EXPIRED" ? session.expiresAt - : undefined; + : session.currentRunCompletedAt; if (endedAt) { return ( @@ -211,7 +216,8 @@ function SessionDuration({ session }: { session: SessionListItem }) { ); } - return ; + // Idle session that never ran — nothing to measure. + return ; } function SessionActionsCell({ runPath, allRunsPath }: { runPath?: string; allRunsPath: string }) { diff --git a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts index ec2ddd0eeb..c820e44c18 100644 --- a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts @@ -10,6 +10,7 @@ import { LEGACY_PLAYGROUND_TAG, } from "~/services/sessionsRepository/sessionsRepository.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { deriveSessionStatus } from "./deriveSessionStatus"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { runStore } from "~/v3/runStore.server"; import { startActiveSpan } from "~/v3/tracer.server"; @@ -192,7 +193,7 @@ export class SessionListPresenter { projectId, runtimeEnvironmentId: environmentId, }, - select: { id: true, friendlyId: true }, + select: { id: true, friendlyId: true, status: true, completedAt: true }, }, this.replica ) @@ -205,15 +206,19 @@ export class SessionListPresenter { return { sessions: sessions.map((session) => { - const status: SessionStatus = - session.closedAt != null - ? "CLOSED" - : session.expiresAt != null && session.expiresAt.getTime() < now - ? "EXPIRED" - : "ACTIVE"; - const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined; + // A session is only ACTIVE while its current run is genuinely live. + // Open sessions whose run has terminated (or that have no run) read + // IDLE rather than ticking ACTIVE forever. + const status = deriveSessionStatus({ + closedAt: session.closedAt, + expiresAt: session.expiresAt, + currentRunId: session.currentRunId, + currentRunStatus: currentRun?.status, + now, + }); + return { id: session.id, friendlyId: session.friendlyId, @@ -235,6 +240,11 @@ export class SessionListPresenter { updatedAt: session.updatedAt.toISOString(), environment: displayableEnvironment, currentRunFriendlyId: currentRun?.friendlyId, + // Freeze point for an IDLE session's duration — when its current run + // finished. Undefined when the session never ran (renders as a dash). + currentRunCompletedAt: currentRun?.completedAt + ? currentRun.completedAt.toISOString() + : undefined, }; }), pagination: { diff --git a/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts b/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts new file mode 100644 index 0000000000..02da3dbf20 --- /dev/null +++ b/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { deriveSessionStatus } from "./deriveSessionStatus"; + +const NOW = new Date("2026-08-06T12:00:00.000Z").getTime(); +const PAST = new Date("2026-08-01T00:00:00.000Z"); +const FUTURE = new Date("2026-08-10T00:00:00.000Z"); + +describe("deriveSessionStatus", () => { + it("returns CLOSED when closedAt is set, even with a live run", () => { + expect( + deriveSessionStatus({ + closedAt: PAST, + expiresAt: null, + currentRunId: "run_1", + currentRunStatus: "EXECUTING", + now: NOW, + }) + ).toBe("CLOSED"); + }); + + it("prefers CLOSED over an elapsed expiresAt", () => { + expect( + deriveSessionStatus({ + closedAt: PAST, + expiresAt: PAST, + currentRunId: null, + currentRunStatus: undefined, + now: NOW, + }) + ).toBe("CLOSED"); + }); + + it("returns EXPIRED when expiresAt is in the past", () => { + expect( + deriveSessionStatus({ + closedAt: null, + expiresAt: PAST, + currentRunId: "run_1", + currentRunStatus: "EXECUTING", + now: NOW, + }) + ).toBe("EXPIRED"); + }); + + it("returns ACTIVE when the current run is non-final", () => { + expect( + deriveSessionStatus({ + closedAt: null, + expiresAt: FUTURE, + currentRunId: "run_1", + currentRunStatus: "EXECUTING", + now: NOW, + }) + ).toBe("ACTIVE"); + }); + + it("returns IDLE when the current run has reached a terminal state", () => { + expect( + deriveSessionStatus({ + closedAt: null, + expiresAt: null, + currentRunId: "run_1", + currentRunStatus: "EXPIRED", + now: NOW, + }) + ).toBe("IDLE"); + }); + + it("returns IDLE when there is no current run", () => { + expect( + deriveSessionStatus({ + closedAt: null, + expiresAt: null, + currentRunId: null, + currentRunStatus: undefined, + now: NOW, + }) + ).toBe("IDLE"); + }); + + it("returns IDLE when the current run pointer can't be resolved (status unknown)", () => { + expect( + deriveSessionStatus({ + closedAt: null, + expiresAt: null, + currentRunId: "run_missing", + currentRunStatus: undefined, + now: NOW, + }) + ).toBe("IDLE"); + }); +}); diff --git a/apps/webapp/app/presenters/v3/deriveSessionStatus.ts b/apps/webapp/app/presenters/v3/deriveSessionStatus.ts new file mode 100644 index 0000000000..64adac437b --- /dev/null +++ b/apps/webapp/app/presenters/v3/deriveSessionStatus.ts @@ -0,0 +1,46 @@ +import { type TaskRunStatus } from "@trigger.dev/database"; +import { type SessionDisplayStatus } from "~/services/sessionsRepository/sessionsRepository.server"; +import { isFinalRunStatus } from "~/v3/taskStatus"; + +export type DeriveSessionStatusInput = { + /** `Session.closedAt` — set once when the session is explicitly closed. */ + closedAt: Date | null; + /** `Session.expiresAt` — retention deadline, if any. */ + expiresAt: Date | null; + /** `Session.currentRunId` — pointer to the current run (no FK). */ + currentRunId: string | null; + /** + * Status of the run named by `currentRunId`. `undefined` when there is no + * current run, or the pointer couldn't be resolved (stale / cross-env). + */ + currentRunStatus: TaskRunStatus | undefined; + /** `Date.now()` at the time of derivation. */ + now: number; +}; + +/** + * Derives the display status of a session from its terminal markers and the + * liveness of its current run. + * + * Precedence: an explicit close wins, then an elapsed retention deadline. Only + * then do we ask whether the session is genuinely live: it's `ACTIVE` when its + * current run exists and is non-final, otherwise `IDLE` (open but nothing + * running). This is what stops an abandoned session whose run terminated long + * ago from reading `ACTIVE` forever. + */ +export function deriveSessionStatus(input: DeriveSessionStatusInput): SessionDisplayStatus { + if (input.closedAt != null) { + return "CLOSED"; + } + + if (input.expiresAt != null && input.expiresAt.getTime() < input.now) { + return "EXPIRED"; + } + + const hasLiveRun = + input.currentRunId != null && + input.currentRunStatus !== undefined && + !isFinalRunStatus(input.currentRunStatus); + + return hasLiveRun ? "ACTIVE" : "IDLE"; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx index 1de8d7fcbf..4c0612c8ef 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx @@ -55,13 +55,14 @@ import { useHasAdminAccess } from "~/hooks/useUser"; import { redirectWithErrorMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { deriveSessionStatus } from "~/presenters/v3/deriveSessionStatus"; import { SessionPresenter } from "~/presenters/v3/SessionPresenter.server"; import { type StreamChunk, useRealtimeStream, } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route"; import { requireUserId } from "~/services/session.server"; -import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server"; +import { type SessionDisplayStatus } from "~/services/sessionsRepository/sessionsRepository.server"; import { cn } from "~/utils/cn"; import { throwNotFound } from "~/utils/httpErrors"; import { @@ -115,12 +116,13 @@ export default function Page() { const project = useProject(); const environment = useEnvironment(); - const status: SessionStatus = - session.closedAt != null - ? "CLOSED" - : session.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now() - ? "EXPIRED" - : "ACTIVE"; + const status = deriveSessionStatus({ + closedAt: session.closedAt ? new Date(session.closedAt) : null, + expiresAt: session.expiresAt ? new Date(session.expiresAt) : null, + currentRunId: session.currentRun?.friendlyId ?? null, + currentRunStatus: session.currentRun?.status, + now: Date.now(), + }); const displayId = session.externalId ?? session.friendlyId; const sessionsPath = v3SessionsPath(organization, project, environment); @@ -700,7 +702,13 @@ function MergedStreamRow({ ); } -function InspectorPane({ session, status }: { session: LoadedSession; status: SessionStatus }) { +function InspectorPane({ + session, + status, +}: { + session: LoadedSession; + status: SessionDisplayStatus; +}) { const { value, replace } = useSearchParams(); const tab = value("tab") ?? "overview"; const organization = useOrganization(); @@ -760,7 +768,13 @@ function InspectorPane({ session, status }: { session: LoadedSession; status: Se ); } -function OverviewTab({ session, status }: { session: LoadedSession; status: SessionStatus }) { +function OverviewTab({ + session, + status, +}: { + session: LoadedSession; + status: SessionDisplayStatus; +}) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -952,7 +966,7 @@ function RunsTab({ allRunsPath, }: { session: LoadedSession; - status: SessionStatus; + status: SessionDisplayStatus; allRunsPath: string; }) { const organization = useOrganization(); @@ -1019,10 +1033,12 @@ function RunsTab({ ); } -function sessionStatusBlurb(status: SessionStatus): string { +function sessionStatusBlurb(status: SessionDisplayStatus): string { switch (status) { case "ACTIVE": return "Accepting new runs"; + case "IDLE": + return "Open, no run currently executing"; case "CLOSED": return "No longer accepting new runs"; case "EXPIRED": diff --git a/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts b/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts index 4c15d0423b..315e60e8f3 100644 --- a/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts +++ b/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts @@ -24,6 +24,15 @@ export type SessionsRepositoryOptions = { export const SessionStatus = z.enum(["ACTIVE", "CLOSED", "EXPIRED"]); export type SessionStatus = z.infer; +/** + * Display-only status. The list also distinguishes an open session with no + * live run (`IDLE`) from one that's genuinely executing (`ACTIVE`). `IDLE` is + * derived from the current run's liveness and is **not** filterable — the + * filter surface (ClickHouse) only knows `closedAt`/`expiresAt`, so it keeps + * the three-value `SessionStatus`. See `deriveSessionStatus`. + */ +export type SessionDisplayStatus = SessionStatus | "IDLE"; + /** * Legacy marker tag for sessions created from the Test/playground before the * `Session.isTest` boolean existed. New sessions set `isTest` instead; this tag diff --git a/apps/webapp/test/sessionListPresenterStatus.test.ts b/apps/webapp/test/sessionListPresenterStatus.test.ts new file mode 100644 index 0000000000..87254f10ab --- /dev/null +++ b/apps/webapp/test/sessionListPresenterStatus.test.ts @@ -0,0 +1,275 @@ +// Integration guard for SessionListPresenter status + duration derivation (TRI-12687). +// +// Drives the REAL SessionListPresenter.call() against a real Postgres (heteroPostgresTest). +// The ClickHouse session index is stubbed (orthogonal — it only orders ids) so each stub +// session's `currentRunId` points at a REAL run we seed in Postgres with a known status. +// The presenter's `findRuns` read + `deriveSessionStatus` therefore run for real end-to-end, +// which is the wiring the pure unit test can't cover: does the presenter feed the helper the +// current run's status, emit IDLE for an open-but-dead session, and pass the freeze timestamp +// (`currentRunCompletedAt`) through? + +import { heteroPostgresTest } from "@internal/testcontainers"; +import type { PrismaClient, TaskRunStatus } from "@trigger.dev/database"; +import { describe, expect, vi } from "vitest"; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 }); + +// ~/db.server: lazy proxies forwarding to per-test real-container clients (never mocks the DB +// itself). Run-ops split handles left undefined => runStore builds the single-DB passthrough store. +const primaryHolder = vi.hoisted(() => ({ client: undefined as any })); +const replicaHolder = vi.hoisted(() => ({ client: undefined as any })); + +vi.mock("~/db.server", async () => { + const { Prisma } = await import("@trigger.dev/database"); + const lazyProxy = (holder: { client: any }, label: string) => + new Proxy( + {}, + { + get(_t, prop) { + if (!holder.client) throw new Error(`${label} not set for this test`); + const value = holder.client[prop]; + if (value !== null && typeof value === "object") { + return new Proxy(value, { get: (_d, method) => holder.client[prop][method] }); + } + return value; + }, + } + ); + return { + prisma: lazyProxy(primaryHolder, "primaryHolder.client"), + $replica: lazyProxy(replicaHolder, "replicaHolder.client"), + runOpsNewPrismaClient: undefined, + runOpsNewReplicaClient: undefined, + runOpsLegacyPrisma: undefined, + runOpsLegacyReplica: undefined, + sqlDatabaseSchema: Prisma.sql([`public`]), + }; +}); + +// Orthogonal peripherals. +const STUB_ENV = { + id: "env_stub", + type: "DEVELOPMENT" as const, + slug: "dev", + organizationId: "org_stub", + projectId: "proj_stub", + userId: undefined, + branchName: null, + git: null, +}; + +vi.mock("~/models/runtimeEnvironment.server", () => ({ + findDisplayableEnvironment: async () => STUB_ENV, +})); + +vi.mock("~/v3/models/workerDeployment.server", () => ({ + findCurrentWorkerFromEnvironment: async () => null, +})); + +// The session list comes from ClickHouse via SessionsRepository — orthogonal to the run read. +// The stub returns controlled session rows whose currentRunId points at runs we seed for real. +const sessionListHolder = vi.hoisted(() => ({ sessions: [] as any[] })); +vi.mock("~/services/sessionsRepository/sessionsRepository.server", () => ({ + LEGACY_PLAYGROUND_TAG: "__playground__", + SessionsRepository: class { + constructor(_deps: any) {} + async listSessions() { + return { + sessions: sessionListHolder.sessions, + pagination: { nextCursor: null, previousCursor: null }, + }; + } + }, +})); + +import { PostgresRunStore } from "@internal/run-store"; +import type { CreateRunInput } from "@internal/run-store"; +import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server"; + +let seq = 0; + +async function seedTenant(prisma: PrismaClient, suffix: string) { + const organization = await prisma.organization.create({ + data: { title: `Org ${suffix}`, slug: `org-${suffix}` }, + }); + const project = await prisma.project.create({ + data: { + name: `Project ${suffix}`, + slug: `project-${suffix}`, + externalRef: `proj_${suffix}`, + organizationId: organization.id, + }, + }); + const environment = await prisma.runtimeEnvironment.create({ + data: { + type: "DEVELOPMENT", + slug: "dev", + projectId: project.id, + organizationId: organization.id, + apiKey: `tr_dev_${suffix}`, + pkApiKey: `pk_dev_${suffix}`, + shortcode: `short_${suffix}`, + }, + }); + return { organization, project, environment }; +} + +function buildCreateRunInput(p: { + runId: string; + friendlyId: string; + organizationId: string; + projectId: string; + runtimeEnvironmentId: string; +}): CreateRunInput { + return { + data: { + id: p.runId, + engine: "V2", + status: "EXECUTING", + friendlyId: p.friendlyId, + runtimeEnvironmentId: p.runtimeEnvironmentId, + environmentType: "DEVELOPMENT", + organizationId: p.organizationId, + projectId: p.projectId, + taskIdentifier: "my-agent", + payload: '{"hello":"world"}', + payloadType: "application/json", + context: { foo: "bar" }, + traceContext: { trace: "ctx" }, + traceId: `trace_${p.runId}`, + spanId: `span_${p.runId}`, + runTags: [], + queue: "task/my-agent", + isTest: false, + taskEventStore: "taskEvent", + depth: 0, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + }, + snapshot: { + engine: "V2", + executionStatus: "EXECUTING", + description: "Run is executing", + runStatus: "EXECUTING", + environmentId: p.runtimeEnvironmentId, + environmentType: "DEVELOPMENT", + projectId: p.projectId, + organizationId: p.organizationId, + }, + }; +} + +// Seed a run, then mutate it to the target terminal/live status + completedAt for the test shape. +async function seedRun( + prisma: PrismaClient, + seed: { organization: { id: string }; project: { id: string }; environment: { id: string } }, + p: { suffix: string; status: TaskRunStatus; completedAt: Date | null } +) { + const runId = `run_${p.suffix}`; + const writerStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma }); + await writerStore.createRun( + buildCreateRunInput({ + runId, + friendlyId: `run_f_${p.suffix}`, + organizationId: seed.organization.id, + projectId: seed.project.id, + runtimeEnvironmentId: seed.environment.id, + }) + ); + await prisma.taskRun.update({ + where: { id: runId }, + data: { status: p.status, completedAt: p.completedAt }, + }); + return runId; +} + +function stubSession(p: { + suffix: string; + currentRunId: string | null; + closedAt?: Date | null; + expiresAt?: Date | null; +}) { + return { + id: `sess_${p.suffix}`, + friendlyId: `session_${p.suffix}`, + externalId: null, + type: "chat.agent", + taskIdentifier: "my-agent", + isTest: false, + tags: [], + closedAt: p.closedAt ?? null, + closedReason: null, + expiresAt: p.expiresAt ?? null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + currentRunId: p.currentRunId, + }; +} + +describe("SessionListPresenter status + duration derivation", () => { + heteroPostgresTest( + "derives IDLE/ACTIVE/CLOSED/EXPIRED from run liveness and freezes idle duration", + async ({ prisma14 }) => { + const prisma = prisma14 as unknown as PrismaClient; + const suffix = `status_${seq++}`; + const seed = await seedTenant(prisma, suffix); + + const RUN_COMPLETED_AT = new Date("2024-01-01T00:10:00.000Z"); + const PAST = new Date("2020-01-01T00:00:00.000Z"); + + // An open session whose only run has EXPIRED — the reported bug. + const idleRunId = await seedRun(prisma, seed, { + suffix: `idle_${suffix}`, + status: "EXPIRED", + completedAt: RUN_COMPLETED_AT, + }); + // An open session with a genuinely live run. + const activeRunId = await seedRun(prisma, seed, { + suffix: `active_${suffix}`, + status: "EXECUTING", + completedAt: null, + }); + // A closed session (still points at a live run — closed must win). + const closedRunId = await seedRun(prisma, seed, { + suffix: `closed_${suffix}`, + status: "EXECUTING", + completedAt: null, + }); + + sessionListHolder.sessions = [ + stubSession({ suffix: `idle_${suffix}`, currentRunId: idleRunId }), + stubSession({ suffix: `active_${suffix}`, currentRunId: activeRunId }), + stubSession({ + suffix: `closed_${suffix}`, + currentRunId: closedRunId, + closedAt: new Date("2024-01-02T00:00:00.000Z"), + }), + stubSession({ suffix: `expired_${suffix}`, currentRunId: null, expiresAt: PAST }), + stubSession({ suffix: `neverran_${suffix}`, currentRunId: null }), + ]; + + primaryHolder.client = prisma; + replicaHolder.client = prisma; + + const presenter = new SessionListPresenter(prisma as any, {} as any); + const result = await presenter.call(seed.organization.id, seed.environment.id, { + projectId: seed.project.id, + }); + + const byId = new Map(result.sessions.map((s) => [s.id, s] as const)); + + const idle = byId.get(`sess_idle_${suffix}`)!; + expect(idle.status).toBe("IDLE"); + // Duration freezes at the dead run's completedAt rather than ticking. + expect(idle.currentRunCompletedAt).toBe(RUN_COMPLETED_AT.toISOString()); + + expect(byId.get(`sess_active_${suffix}`)!.status).toBe("ACTIVE"); + expect(byId.get(`sess_closed_${suffix}`)!.status).toBe("CLOSED"); + expect(byId.get(`sess_expired_${suffix}`)!.status).toBe("EXPIRED"); + + // Open session that never ran: IDLE with no freeze point (renders as a dash). + const neverRan = byId.get(`sess_neverran_${suffix}`)!; + expect(neverRan.status).toBe("IDLE"); + expect(neverRan.currentRunCompletedAt).toBeUndefined(); + } + ); +}); diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 18f0be4e6b..3f83a7bf50 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ "app/runEngine/concerns/**/*.test.ts", "app/runEngine/services/**/*.test.ts", "app/utils/**/*.test.ts", + "app/presenters/**/*.test.ts", ], // *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts. // *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts From 799dbb4758b968cb5a652335cbe372c5463d7caf Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:17:06 +0100 Subject: [PATCH 2/5] docs(ai-chat): correct the sessions.list tag filter description The tag filter matches the session's own top-level tags, not triggerConfig.tags. --- docs/ai-chat/sessions.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai-chat/sessions.mdx b/docs/ai-chat/sessions.mdx index 041fd3d99e..2b15439d2e 100644 --- a/docs/ai-chat/sessions.mdx +++ b/docs/ai-chat/sessions.mdx @@ -164,7 +164,7 @@ for await (const s of sessions.list({ | Filter | Type | Notes | |---|---|---| | `type` | `string \| string[]` | e.g. `"chat.agent"` | -| `tag` | `string \| string[]` | Matches `triggerConfig.tags` | +| `tag` | `string \| string[]` | Matches the session's own `tags` (the top-level `tags` on `sessions.start`), not `triggerConfig.tags` | | `taskIdentifier` | `string \| string[]` | Filter by task | | `externalId` | `string` | Exact match | | `status` | `"ACTIVE" \| "CLOSED" \| "EXPIRED"` | Lifecycle state | From e67dc2b6eb9ec6ea8a755360f7223d307f329b54 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:31:59 +0100 Subject: [PATCH 3/5] fix(webapp): keep the close action available for idle sessions Address review on the sessions status change: - Keep the "Close session" action on the detail page for Idle sessions; they are open, only Closed and Expired are terminal. - Rename the status helper input from currentRunId to hasCurrentRun, since the detail page passes a run friendlyId, not the session's currentRunId. - Restore the Active tooltip copy so it stays accurate now that the Active filter also returns open, idle sessions. - Align the sessions docs example so the listed tag matches a top-level tag set at start time. --- .../app/components/sessions/v1/SessionStatus.tsx | 2 +- .../presenters/v3/SessionListPresenter.server.ts | 2 +- .../app/presenters/v3/deriveSessionStatus.test.ts | 14 +++++++------- .../app/presenters/v3/deriveSessionStatus.ts | 10 +++++----- .../route.tsx | 4 ++-- docs/ai-chat/sessions.mdx | 5 ++++- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx index 27f27dda25..fdc4bb6b16 100644 --- a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx +++ b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx @@ -13,7 +13,7 @@ export const allSessionStatuses = ["ACTIVE", "CLOSED", "EXPIRED"] as const satis >; const descriptions: Record = { - ACTIVE: "The session has a run currently executing.", + ACTIVE: "The session is open and can receive input or schedule new runs.", IDLE: "The session is open but has no run currently executing.", CLOSED: "The session was closed; no further input or runs can be triggered against it.", EXPIRED: "The session passed its expiry time without being closed explicitly.", diff --git a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts index c820e44c18..74b057efe1 100644 --- a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts @@ -214,7 +214,7 @@ export class SessionListPresenter { const status = deriveSessionStatus({ closedAt: session.closedAt, expiresAt: session.expiresAt, - currentRunId: session.currentRunId, + hasCurrentRun: session.currentRunId != null, currentRunStatus: currentRun?.status, now, }); diff --git a/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts b/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts index 02da3dbf20..04be73ec99 100644 --- a/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts +++ b/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts @@ -11,7 +11,7 @@ describe("deriveSessionStatus", () => { deriveSessionStatus({ closedAt: PAST, expiresAt: null, - currentRunId: "run_1", + hasCurrentRun: true, currentRunStatus: "EXECUTING", now: NOW, }) @@ -23,7 +23,7 @@ describe("deriveSessionStatus", () => { deriveSessionStatus({ closedAt: PAST, expiresAt: PAST, - currentRunId: null, + hasCurrentRun: false, currentRunStatus: undefined, now: NOW, }) @@ -35,7 +35,7 @@ describe("deriveSessionStatus", () => { deriveSessionStatus({ closedAt: null, expiresAt: PAST, - currentRunId: "run_1", + hasCurrentRun: true, currentRunStatus: "EXECUTING", now: NOW, }) @@ -47,7 +47,7 @@ describe("deriveSessionStatus", () => { deriveSessionStatus({ closedAt: null, expiresAt: FUTURE, - currentRunId: "run_1", + hasCurrentRun: true, currentRunStatus: "EXECUTING", now: NOW, }) @@ -59,7 +59,7 @@ describe("deriveSessionStatus", () => { deriveSessionStatus({ closedAt: null, expiresAt: null, - currentRunId: "run_1", + hasCurrentRun: true, currentRunStatus: "EXPIRED", now: NOW, }) @@ -71,7 +71,7 @@ describe("deriveSessionStatus", () => { deriveSessionStatus({ closedAt: null, expiresAt: null, - currentRunId: null, + hasCurrentRun: false, currentRunStatus: undefined, now: NOW, }) @@ -83,7 +83,7 @@ describe("deriveSessionStatus", () => { deriveSessionStatus({ closedAt: null, expiresAt: null, - currentRunId: "run_missing", + hasCurrentRun: true, currentRunStatus: undefined, now: NOW, }) diff --git a/apps/webapp/app/presenters/v3/deriveSessionStatus.ts b/apps/webapp/app/presenters/v3/deriveSessionStatus.ts index 64adac437b..23ab5b5f03 100644 --- a/apps/webapp/app/presenters/v3/deriveSessionStatus.ts +++ b/apps/webapp/app/presenters/v3/deriveSessionStatus.ts @@ -7,11 +7,11 @@ export type DeriveSessionStatusInput = { closedAt: Date | null; /** `Session.expiresAt` — retention deadline, if any. */ expiresAt: Date | null; - /** `Session.currentRunId` — pointer to the current run (no FK). */ - currentRunId: string | null; + /** Whether the session points at a current run at all. */ + hasCurrentRun: boolean; /** - * Status of the run named by `currentRunId`. `undefined` when there is no - * current run, or the pointer couldn't be resolved (stale / cross-env). + * Status of the current run. `undefined` when there is no current run, or the + * pointer couldn't be resolved (stale / cross-env). */ currentRunStatus: TaskRunStatus | undefined; /** `Date.now()` at the time of derivation. */ @@ -38,7 +38,7 @@ export function deriveSessionStatus(input: DeriveSessionStatusInput): SessionDis } const hasLiveRun = - input.currentRunId != null && + input.hasCurrentRun && input.currentRunStatus !== undefined && !isFinalRunStatus(input.currentRunStatus); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx index 4c0612c8ef..426049ada7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx @@ -119,7 +119,7 @@ export default function Page() { const status = deriveSessionStatus({ closedAt: session.closedAt ? new Date(session.closedAt) : null, expiresAt: session.expiresAt ? new Date(session.expiresAt) : null, - currentRunId: session.currentRun?.friendlyId ?? null, + hasCurrentRun: session.currentRun != null, currentRunStatus: session.currentRun?.status, now: Date.now(), }); @@ -791,7 +791,7 @@ function OverviewTab({ - {status === "ACTIVE" && ( + {(status === "ACTIVE" || status === "IDLE") && ( diff --git a/docs/ai-chat/sessions.mdx b/docs/ai-chat/sessions.mdx index 2b15439d2e..bb2a2d84c1 100644 --- a/docs/ai-chat/sessions.mdx +++ b/docs/ai-chat/sessions.mdx @@ -99,7 +99,10 @@ const { id, runId, publicAccessToken, isCached } = await sessions.start({ type: "chat.agent", externalId: chatId, taskIdentifier: "my-chat", + // Top-level tags live on the Session row and are what `sessions.list({ tag })` filters on. + tags: [`chat:${chatId}`], triggerConfig: { + // triggerConfig.tags tag each run the session schedules, not the session row. tags: [`chat:${chatId}`], basePayload: { /* whatever your task's payload shape is */ }, }, @@ -153,7 +156,7 @@ Cursor-paginated list of Sessions in the current environment. Returns a `CursorP ```ts for await (const s of sessions.list({ type: "chat.agent", - tag: `user:${userId}`, + tag: `chat:${chatId}`, status: "ACTIVE", limit: 50, })) { From 200d501a3bc1d0a4029a17b3d341254e9b06035b Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:32:12 +0100 Subject: [PATCH 4/5] fix(webapp): show Idle session status on the run page badge too The run/span panel derived the session badge from closedAt/expiresAt only, so it could read Active where the sessions list and detail page now read Idle. It now uses the same run-liveness derivation as the rest of the sessions surface. --- .../app/presenters/v3/SpanPresenter.server.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index b680a1c6a8..2ace21527b 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -36,6 +36,7 @@ import { runStore } from "~/v3/runStore.server"; import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server"; import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; import { BasePresenter } from "./basePresenter.server"; +import { deriveSessionStatus } from "./deriveSessionStatus"; import { WaitpointPresenter } from "./WaitpointPresenter.server"; import { controlPlaneResolver, @@ -358,25 +359,41 @@ export class SpanPresenter extends BasePresenter { taskIdentifier: true, closedAt: true, expiresAt: true, + currentRunId: true, }, }, }, }) : null; + // Resolve the session's current run so the badge reflects run liveness + // (the same IDLE-vs-ACTIVE distinction as the sessions list/detail), not + // just closedAt/expiresAt. Env-scoped, matching the run reads here. + const sessionCurrentRun = + sessionRun && sessionRun.session.currentRunId + ? await runStore.findRun( + { + id: sessionRun.session.currentRunId, + runtimeEnvironmentId: run.runtimeEnvironmentId, + }, + { select: { status: true } }, + this._replica + ) + : null; + const session = sessionRun ? { friendlyId: sessionRun.session.friendlyId, externalId: sessionRun.session.externalId, type: sessionRun.session.type, taskIdentifier: sessionRun.session.taskIdentifier, - status: - sessionRun.session.closedAt != null - ? ("CLOSED" as const) - : sessionRun.session.expiresAt != null && - sessionRun.session.expiresAt.getTime() < Date.now() - ? ("EXPIRED" as const) - : ("ACTIVE" as const), + status: deriveSessionStatus({ + closedAt: sessionRun.session.closedAt, + expiresAt: sessionRun.session.expiresAt, + hasCurrentRun: sessionRun.session.currentRunId != null, + currentRunStatus: sessionCurrentRun?.status, + now: Date.now(), + }), reason: sessionRun.reason, triggeredAt: sessionRun.triggeredAt, } From 3dfca81532e5d1326dba80c8b43e0ae6c1d016c9 Mon Sep 17 00:00:00 2001 From: D-K-P <8297864+D-K-P@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:44:27 +0100 Subject: [PATCH 5/5] fix(webapp): stop session durations climbing forever when no run is live Sessions kept an ever-growing wall-clock duration because the cell ticked for any open session, even when its run had finished long ago. The duration now ticks only while a run is genuinely executing; otherwise it freezes at the last run's completion (or shows a dash if it never ran). Session status stays the existing filterable ACTIVE/CLOSED/EXPIRED set, so there is nothing new to filter. This drops the earlier display-only IDLE status, which was not filterable. --- .server-changes/sessions-idle-status.md | 2 +- .../components/sessions/v1/SessionStatus.tsx | 34 ++----- .../components/sessions/v1/SessionsTable.tsx | 35 ++++--- .../v3/SessionListPresenter.server.ts | 24 +++-- .../app/presenters/v3/SpanPresenter.server.ts | 31 ++----- .../presenters/v3/deriveSessionStatus.test.ts | 92 ------------------- .../app/presenters/v3/deriveSessionStatus.ts | 46 ---------- .../app/presenters/v3/isSessionLive.test.ts | 24 +++++ .../webapp/app/presenters/v3/isSessionLive.ts | 22 +++++ .../route.tsx | 40 +++----- .../sessionsRepository.server.ts | 9 -- .../test/sessionListPresenterStatus.test.ts | 30 +++--- 12 files changed, 129 insertions(+), 260 deletions(-) delete mode 100644 apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts delete mode 100644 apps/webapp/app/presenters/v3/deriveSessionStatus.ts create mode 100644 apps/webapp/app/presenters/v3/isSessionLive.test.ts create mode 100644 apps/webapp/app/presenters/v3/isSessionLive.ts diff --git a/.server-changes/sessions-idle-status.md b/.server-changes/sessions-idle-status.md index 8bc16e6b5d..8b15a97221 100644 --- a/.server-changes/sessions-idle-status.md +++ b/.server-changes/sessions-idle-status.md @@ -3,4 +3,4 @@ area: webapp type: fix --- -The Sessions list no longer shows an abandoned session as Active with a duration that climbs forever. A session whose run has finished now shows as Idle with a duration frozen at when it stopped, and only sessions with a run still executing show as Active. +The Sessions list no longer shows an ever-growing duration for a session whose run finished long ago. The duration now stops at the last run's activity, and only sessions with a run still executing keep counting up. diff --git a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx index fdc4bb6b16..69dfdf5092 100644 --- a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx +++ b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx @@ -1,34 +1,26 @@ import { CheckCircleIcon, ClockIcon } from "@heroicons/react/20/solid"; import assertNever from "assert-never"; -import { - type SessionDisplayStatus, - type SessionStatus, -} from "~/services/sessionsRepository/sessionsRepository.server"; +import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server"; import { cn } from "~/utils/cn"; -// Filterable statuses only — `IDLE` is display-only and derived from run -// liveness, so it never appears in the filter surface. export const allSessionStatuses = ["ACTIVE", "CLOSED", "EXPIRED"] as const satisfies Readonly< Array >; -const descriptions: Record = { +const descriptions: Record = { ACTIVE: "The session is open and can receive input or schedule new runs.", - IDLE: "The session is open but has no run currently executing.", CLOSED: "The session was closed; no further input or runs can be triggered against it.", EXPIRED: "The session passed its expiry time without being closed explicitly.", }; -export function descriptionForSessionStatus(status: SessionDisplayStatus): string { +export function descriptionForSessionStatus(status: SessionStatus): string { return descriptions[status]; } -export function sessionStatusTitle(status: SessionDisplayStatus): string { +export function sessionStatusTitle(status: SessionStatus): string { switch (status) { case "ACTIVE": return "Active"; - case "IDLE": - return "Idle"; case "CLOSED": return "Closed"; case "EXPIRED": @@ -38,12 +30,10 @@ export function sessionStatusTitle(status: SessionDisplayStatus): string { } } -export function sessionStatusColor(status: SessionDisplayStatus): string { +export function sessionStatusColor(status: SessionStatus): string { switch (status) { case "ACTIVE": return "text-pending"; - case "IDLE": - return "text-text-dimmed"; case "CLOSED": return "text-success"; case "EXPIRED": @@ -58,7 +48,7 @@ export function SessionStatusIcon({ className, pulse = true, }: { - status: SessionDisplayStatus; + status: SessionStatus; className: string; pulse?: boolean; }) { @@ -74,14 +64,6 @@ export function SessionStatusIcon({ ); - case "IDLE": - // Open but not live: a static, dimmed dot (no pulse) — distinct from - // ACTIVE's pulsing dot and EXPIRED's clock. - return ( - - - - ); case "CLOSED": return ; case "EXPIRED": @@ -91,7 +73,7 @@ export function SessionStatusIcon({ } } -export function SessionStatusLabel({ status }: { status: SessionDisplayStatus }) { +export function SessionStatusLabel({ status }: { status: SessionStatus }) { // system-mono-label: System themes uncolor the label (see tailwind.css) return ( @@ -106,7 +88,7 @@ export function SessionStatusCombo({ iconClassName, pulse = true, }: { - status: SessionDisplayStatus; + status: SessionStatus; className?: string; iconClassName?: string; pulse?: boolean; diff --git a/apps/webapp/app/components/sessions/v1/SessionsTable.tsx b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx index d6d3e09952..5e26dece86 100644 --- a/apps/webapp/app/components/sessions/v1/SessionsTable.tsx +++ b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx @@ -195,28 +195,37 @@ export function SessionsTable({ } function SessionDuration({ session }: { session: SessionListItem }) { - // Only a genuinely live session ticks. Everything else freezes at the moment - // it stopped being live: closedAt for explicit closes, expiresAt when the TTL - // ran out, or the current run's completedAt for an idle (open, not-running) - // session — so an abandoned session doesn't count up forever. - if (session.status === "ACTIVE") { - return ; - } - - const endedAt = + // Closed and expired sessions freeze at the moment they ended. + const terminalEnd = session.status === "CLOSED" ? session.closedAt : session.status === "EXPIRED" ? session.expiresAt - : session.currentRunCompletedAt; + : undefined; + + if (terminalEnd) { + return ( + <>{formatDuration(new Date(session.createdAt), new Date(terminalEnd), { style: "short" })} + ); + } + + // An open session ticks only while a run is genuinely executing; otherwise it + // freezes at the last run's completion so the duration doesn't climb forever. + if (session.hasLiveRun) { + return ; + } - if (endedAt) { + if (session.currentRunCompletedAt) { return ( - <>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })} + <> + {formatDuration(new Date(session.createdAt), new Date(session.currentRunCompletedAt), { + style: "short", + })} + ); } - // Idle session that never ran — nothing to measure. + // Open session that never ran — nothing to measure. return ; } diff --git a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts index e3e8d4a3f6..5144599c8c 100644 --- a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts @@ -14,7 +14,7 @@ import { LEGACY_PLAYGROUND_TAG, } from "~/services/sessionsRepository/sessionsRepository.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; -import { deriveSessionStatus } from "./deriveSessionStatus"; +import { isSessionLive } from "./isSessionLive"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { runStore } from "~/v3/runStore.server"; import { startActiveSpan } from "~/v3/tracer.server"; @@ -212,15 +212,18 @@ export class SessionListPresenter { sessions: sessions.map((session) => { const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined; - // A session is only ACTIVE while its current run is genuinely live. - // Open sessions whose run has terminated (or that have no run) read - // IDLE rather than ticking ACTIVE forever. - const status = deriveSessionStatus({ - closedAt: session.closedAt, - expiresAt: session.expiresAt, + const status: SessionStatus = + session.closedAt != null + ? "CLOSED" + : session.expiresAt != null && session.expiresAt.getTime() < now + ? "EXPIRED" + : "ACTIVE"; + + // Whether a run is genuinely executing right now. Drives the duration + // cell (tick vs freeze); it does NOT affect the filterable status. + const hasLiveRun = isSessionLive({ hasCurrentRun: session.currentRunId != null, currentRunStatus: currentRun?.status, - now, }); return { @@ -244,8 +247,9 @@ export class SessionListPresenter { updatedAt: session.updatedAt.toISOString(), environment: displayableEnvironment, currentRunFriendlyId: currentRun?.friendlyId, - // Freeze point for an IDLE session's duration — when its current run - // finished. Undefined when the session never ran (renders as a dash). + hasLiveRun, + // Freeze point for the duration when the session isn't live: when its + // current run finished. Undefined when it never ran (renders a dash). currentRunCompletedAt: currentRun?.completedAt ? currentRun.completedAt.toISOString() : undefined, diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 2ace21527b..b680a1c6a8 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -36,7 +36,6 @@ import { runStore } from "~/v3/runStore.server"; import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server"; import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; import { BasePresenter } from "./basePresenter.server"; -import { deriveSessionStatus } from "./deriveSessionStatus"; import { WaitpointPresenter } from "./WaitpointPresenter.server"; import { controlPlaneResolver, @@ -359,41 +358,25 @@ export class SpanPresenter extends BasePresenter { taskIdentifier: true, closedAt: true, expiresAt: true, - currentRunId: true, }, }, }, }) : null; - // Resolve the session's current run so the badge reflects run liveness - // (the same IDLE-vs-ACTIVE distinction as the sessions list/detail), not - // just closedAt/expiresAt. Env-scoped, matching the run reads here. - const sessionCurrentRun = - sessionRun && sessionRun.session.currentRunId - ? await runStore.findRun( - { - id: sessionRun.session.currentRunId, - runtimeEnvironmentId: run.runtimeEnvironmentId, - }, - { select: { status: true } }, - this._replica - ) - : null; - const session = sessionRun ? { friendlyId: sessionRun.session.friendlyId, externalId: sessionRun.session.externalId, type: sessionRun.session.type, taskIdentifier: sessionRun.session.taskIdentifier, - status: deriveSessionStatus({ - closedAt: sessionRun.session.closedAt, - expiresAt: sessionRun.session.expiresAt, - hasCurrentRun: sessionRun.session.currentRunId != null, - currentRunStatus: sessionCurrentRun?.status, - now: Date.now(), - }), + status: + sessionRun.session.closedAt != null + ? ("CLOSED" as const) + : sessionRun.session.expiresAt != null && + sessionRun.session.expiresAt.getTime() < Date.now() + ? ("EXPIRED" as const) + : ("ACTIVE" as const), reason: sessionRun.reason, triggeredAt: sessionRun.triggeredAt, } diff --git a/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts b/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts deleted file mode 100644 index 04be73ec99..0000000000 --- a/apps/webapp/app/presenters/v3/deriveSessionStatus.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { deriveSessionStatus } from "./deriveSessionStatus"; - -const NOW = new Date("2026-08-06T12:00:00.000Z").getTime(); -const PAST = new Date("2026-08-01T00:00:00.000Z"); -const FUTURE = new Date("2026-08-10T00:00:00.000Z"); - -describe("deriveSessionStatus", () => { - it("returns CLOSED when closedAt is set, even with a live run", () => { - expect( - deriveSessionStatus({ - closedAt: PAST, - expiresAt: null, - hasCurrentRun: true, - currentRunStatus: "EXECUTING", - now: NOW, - }) - ).toBe("CLOSED"); - }); - - it("prefers CLOSED over an elapsed expiresAt", () => { - expect( - deriveSessionStatus({ - closedAt: PAST, - expiresAt: PAST, - hasCurrentRun: false, - currentRunStatus: undefined, - now: NOW, - }) - ).toBe("CLOSED"); - }); - - it("returns EXPIRED when expiresAt is in the past", () => { - expect( - deriveSessionStatus({ - closedAt: null, - expiresAt: PAST, - hasCurrentRun: true, - currentRunStatus: "EXECUTING", - now: NOW, - }) - ).toBe("EXPIRED"); - }); - - it("returns ACTIVE when the current run is non-final", () => { - expect( - deriveSessionStatus({ - closedAt: null, - expiresAt: FUTURE, - hasCurrentRun: true, - currentRunStatus: "EXECUTING", - now: NOW, - }) - ).toBe("ACTIVE"); - }); - - it("returns IDLE when the current run has reached a terminal state", () => { - expect( - deriveSessionStatus({ - closedAt: null, - expiresAt: null, - hasCurrentRun: true, - currentRunStatus: "EXPIRED", - now: NOW, - }) - ).toBe("IDLE"); - }); - - it("returns IDLE when there is no current run", () => { - expect( - deriveSessionStatus({ - closedAt: null, - expiresAt: null, - hasCurrentRun: false, - currentRunStatus: undefined, - now: NOW, - }) - ).toBe("IDLE"); - }); - - it("returns IDLE when the current run pointer can't be resolved (status unknown)", () => { - expect( - deriveSessionStatus({ - closedAt: null, - expiresAt: null, - hasCurrentRun: true, - currentRunStatus: undefined, - now: NOW, - }) - ).toBe("IDLE"); - }); -}); diff --git a/apps/webapp/app/presenters/v3/deriveSessionStatus.ts b/apps/webapp/app/presenters/v3/deriveSessionStatus.ts deleted file mode 100644 index 23ab5b5f03..0000000000 --- a/apps/webapp/app/presenters/v3/deriveSessionStatus.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type TaskRunStatus } from "@trigger.dev/database"; -import { type SessionDisplayStatus } from "~/services/sessionsRepository/sessionsRepository.server"; -import { isFinalRunStatus } from "~/v3/taskStatus"; - -export type DeriveSessionStatusInput = { - /** `Session.closedAt` — set once when the session is explicitly closed. */ - closedAt: Date | null; - /** `Session.expiresAt` — retention deadline, if any. */ - expiresAt: Date | null; - /** Whether the session points at a current run at all. */ - hasCurrentRun: boolean; - /** - * Status of the current run. `undefined` when there is no current run, or the - * pointer couldn't be resolved (stale / cross-env). - */ - currentRunStatus: TaskRunStatus | undefined; - /** `Date.now()` at the time of derivation. */ - now: number; -}; - -/** - * Derives the display status of a session from its terminal markers and the - * liveness of its current run. - * - * Precedence: an explicit close wins, then an elapsed retention deadline. Only - * then do we ask whether the session is genuinely live: it's `ACTIVE` when its - * current run exists and is non-final, otherwise `IDLE` (open but nothing - * running). This is what stops an abandoned session whose run terminated long - * ago from reading `ACTIVE` forever. - */ -export function deriveSessionStatus(input: DeriveSessionStatusInput): SessionDisplayStatus { - if (input.closedAt != null) { - return "CLOSED"; - } - - if (input.expiresAt != null && input.expiresAt.getTime() < input.now) { - return "EXPIRED"; - } - - const hasLiveRun = - input.hasCurrentRun && - input.currentRunStatus !== undefined && - !isFinalRunStatus(input.currentRunStatus); - - return hasLiveRun ? "ACTIVE" : "IDLE"; -} diff --git a/apps/webapp/app/presenters/v3/isSessionLive.test.ts b/apps/webapp/app/presenters/v3/isSessionLive.test.ts new file mode 100644 index 0000000000..3471094e17 --- /dev/null +++ b/apps/webapp/app/presenters/v3/isSessionLive.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isSessionLive } from "./isSessionLive"; + +describe("isSessionLive", () => { + it("is live when the current run is executing", () => { + expect(isSessionLive({ hasCurrentRun: true, currentRunStatus: "EXECUTING" })).toBe(true); + }); + + it("treats any non-final run status as live", () => { + expect(isSessionLive({ hasCurrentRun: true, currentRunStatus: "PENDING" })).toBe(true); + }); + + it("is not live when the current run has reached a terminal state", () => { + expect(isSessionLive({ hasCurrentRun: true, currentRunStatus: "EXPIRED" })).toBe(false); + }); + + it("is not live when there is no current run", () => { + expect(isSessionLive({ hasCurrentRun: false, currentRunStatus: undefined })).toBe(false); + }); + + it("is not live when the current run pointer can't be resolved (status unknown)", () => { + expect(isSessionLive({ hasCurrentRun: true, currentRunStatus: undefined })).toBe(false); + }); +}); diff --git a/apps/webapp/app/presenters/v3/isSessionLive.ts b/apps/webapp/app/presenters/v3/isSessionLive.ts new file mode 100644 index 0000000000..894c27d2da --- /dev/null +++ b/apps/webapp/app/presenters/v3/isSessionLive.ts @@ -0,0 +1,22 @@ +import { type TaskRunStatus } from "@trigger.dev/database"; +import { isFinalRunStatus } from "~/v3/taskStatus"; + +export type IsSessionLiveInput = { + /** Whether the session points at a current run at all. */ + hasCurrentRun: boolean; + /** + * Status of the current run. `undefined` when there is no current run, or the + * pointer couldn't be resolved (stale / cross-env). + */ + currentRunStatus: TaskRunStatus | undefined; +}; + +/** + * A session is "live" when its current run is still executing (a non-final run + * status). This drives whether the session's duration ticks or freezes; it does + * NOT change the session's status, which stays the filterable + * `ACTIVE`/`CLOSED`/`EXPIRED` set derived from `closedAt`/`expiresAt`. + */ +export function isSessionLive({ hasCurrentRun, currentRunStatus }: IsSessionLiveInput): boolean { + return hasCurrentRun && currentRunStatus !== undefined && !isFinalRunStatus(currentRunStatus); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx index 9dd948fc69..9664082092 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx @@ -56,14 +56,13 @@ import { useHasAdminAccess } from "~/hooks/useUser"; import { redirectWithErrorMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { deriveSessionStatus } from "~/presenters/v3/deriveSessionStatus"; import { SessionPresenter } from "~/presenters/v3/SessionPresenter.server"; import { type StreamChunk, useRealtimeStream, } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route"; import { requireUserId } from "~/services/session.server"; -import { type SessionDisplayStatus } from "~/services/sessionsRepository/sessionsRepository.server"; +import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server"; import { cn } from "~/utils/cn"; import { throwNotFound } from "~/utils/httpErrors"; import { EnvironmentParamSchema, v3RunPath, v3RunsPath, v3SessionsPath } from "~/utils/pathBuilder"; @@ -118,13 +117,12 @@ export default function Page() { const project = useProject(); const environment = useEnvironment(); - const status = deriveSessionStatus({ - closedAt: session.closedAt ? new Date(session.closedAt) : null, - expiresAt: session.expiresAt ? new Date(session.expiresAt) : null, - hasCurrentRun: session.currentRun != null, - currentRunStatus: session.currentRun?.status, - now: Date.now(), - }); + const status: SessionStatus = + session.closedAt != null + ? "CLOSED" + : session.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now() + ? "EXPIRED" + : "ACTIVE"; const displayId = session.externalId ?? session.friendlyId; const sessionsPath = v3SessionsPath(organization, project, environment); @@ -695,13 +693,7 @@ function MergedStreamRow({ ); } -function InspectorPane({ - session, - status, -}: { - session: LoadedSession; - status: SessionDisplayStatus; -}) { +function InspectorPane({ session, status }: { session: LoadedSession; status: SessionStatus }) { const { value, replace } = useSearchParams(); const tab = value("tab") ?? "overview"; const organization = useOrganization(); @@ -761,13 +753,7 @@ function InspectorPane({ ); } -function OverviewTab({ - session, - status, -}: { - session: LoadedSession; - status: SessionDisplayStatus; -}) { +function OverviewTab({ session, status }: { session: LoadedSession; status: SessionStatus }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -784,7 +770,7 @@ function OverviewTab({ - {(status === "ACTIVE" || status === "IDLE") && ( + {status === "ACTIVE" && ( @@ -959,7 +945,7 @@ function RunsTab({ allRunsPath, }: { session: LoadedSession; - status: SessionDisplayStatus; + status: SessionStatus; allRunsPath: string; }) { const organization = useOrganization(); @@ -1026,12 +1012,10 @@ function RunsTab({ ); } -function sessionStatusBlurb(status: SessionDisplayStatus): string { +function sessionStatusBlurb(status: SessionStatus): string { switch (status) { case "ACTIVE": return "Accepting new runs"; - case "IDLE": - return "Open, no run currently executing"; case "CLOSED": return "No longer accepting new runs"; case "EXPIRED": diff --git a/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts b/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts index 315e60e8f3..4c15d0423b 100644 --- a/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts +++ b/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts @@ -24,15 +24,6 @@ export type SessionsRepositoryOptions = { export const SessionStatus = z.enum(["ACTIVE", "CLOSED", "EXPIRED"]); export type SessionStatus = z.infer; -/** - * Display-only status. The list also distinguishes an open session with no - * live run (`IDLE`) from one that's genuinely executing (`ACTIVE`). `IDLE` is - * derived from the current run's liveness and is **not** filterable — the - * filter surface (ClickHouse) only knows `closedAt`/`expiresAt`, so it keeps - * the three-value `SessionStatus`. See `deriveSessionStatus`. - */ -export type SessionDisplayStatus = SessionStatus | "IDLE"; - /** * Legacy marker tag for sessions created from the Test/playground before the * `Session.isTest` boolean existed. New sessions set `isTest` instead; this tag diff --git a/apps/webapp/test/sessionListPresenterStatus.test.ts b/apps/webapp/test/sessionListPresenterStatus.test.ts index 87254f10ab..557d318e6b 100644 --- a/apps/webapp/test/sessionListPresenterStatus.test.ts +++ b/apps/webapp/test/sessionListPresenterStatus.test.ts @@ -1,11 +1,11 @@ -// Integration guard for SessionListPresenter status + duration derivation (TRI-12687). +// Integration guard for SessionListPresenter status + duration derivation. // // Drives the REAL SessionListPresenter.call() against a real Postgres (heteroPostgresTest). // The ClickHouse session index is stubbed (orthogonal — it only orders ids) so each stub // session's `currentRunId` points at a REAL run we seed in Postgres with a known status. -// The presenter's `findRuns` read + `deriveSessionStatus` therefore run for real end-to-end, -// which is the wiring the pure unit test can't cover: does the presenter feed the helper the -// current run's status, emit IDLE for an open-but-dead session, and pass the freeze timestamp +// The presenter's `findRuns` read + liveness check therefore run for real end-to-end, which +// is the wiring the pure unit test can't cover: does the presenter derive the filterable +// status correctly, compute `hasLiveRun` from the current run, and pass the freeze timestamp // (`currentRunCompletedAt`) through? import { heteroPostgresTest } from "@internal/testcontainers"; @@ -207,7 +207,7 @@ function stubSession(p: { describe("SessionListPresenter status + duration derivation", () => { heteroPostgresTest( - "derives IDLE/ACTIVE/CLOSED/EXPIRED from run liveness and freezes idle duration", + "keeps the filterable status and drives duration off run liveness", async ({ prisma14 }) => { const prisma = prisma14 as unknown as PrismaClient; const suffix = `status_${seq++}`; @@ -216,7 +216,8 @@ describe("SessionListPresenter status + duration derivation", () => { const RUN_COMPLETED_AT = new Date("2024-01-01T00:10:00.000Z"); const PAST = new Date("2020-01-01T00:00:00.000Z"); - // An open session whose only run has EXPIRED — the reported bug. + // An open session whose only run has terminated — the reported bug. Status + // stays ACTIVE (it is open), but it is not live so its duration freezes. const idleRunId = await seedRun(prisma, seed, { suffix: `idle_${suffix}`, status: "EXPIRED", @@ -257,18 +258,25 @@ describe("SessionListPresenter status + duration derivation", () => { const byId = new Map(result.sessions.map((s) => [s.id, s] as const)); + // Open but not live: still ACTIVE (filterable), not live, and the duration + // freezes at the terminated run's completion instead of climbing forever. const idle = byId.get(`sess_idle_${suffix}`)!; - expect(idle.status).toBe("IDLE"); - // Duration freezes at the dead run's completedAt rather than ticking. + expect(idle.status).toBe("ACTIVE"); + expect(idle.hasLiveRun).toBe(false); expect(idle.currentRunCompletedAt).toBe(RUN_COMPLETED_AT.toISOString()); - expect(byId.get(`sess_active_${suffix}`)!.status).toBe("ACTIVE"); + // Open with a live run: ACTIVE and ticking. + const active = byId.get(`sess_active_${suffix}`)!; + expect(active.status).toBe("ACTIVE"); + expect(active.hasLiveRun).toBe(true); + expect(byId.get(`sess_closed_${suffix}`)!.status).toBe("CLOSED"); expect(byId.get(`sess_expired_${suffix}`)!.status).toBe("EXPIRED"); - // Open session that never ran: IDLE with no freeze point (renders as a dash). + // Open session that never ran: ACTIVE, not live, no freeze point (dash). const neverRan = byId.get(`sess_neverran_${suffix}`)!; - expect(neverRan.status).toBe("IDLE"); + expect(neverRan.status).toBe("ACTIVE"); + expect(neverRan.hasLiveRun).toBe(false); expect(neverRan.currentRunCompletedAt).toBeUndefined(); } );