diff --git a/.server-changes/sessions-idle-status.md b/.server-changes/sessions-idle-status.md new file mode 100644 index 00000000000..8bc16e6b5d9 --- /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 69dfdf5092d..fdc4bb6b16f 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 = { +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: 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 4340a976813..d6d3e09952e 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 1e6d1fa2391..e3e8d4a3f6f 100644 --- a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts @@ -14,6 +14,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"; @@ -196,7 +197,7 @@ export class SessionListPresenter { projectId, runtimeEnvironmentId: environmentId, }, - select: { id: true, friendlyId: true }, + select: { id: true, friendlyId: true, status: true, completedAt: true }, }, this.replica ) @@ -209,15 +210,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, + hasCurrentRun: session.currentRunId != null, + currentRunStatus: currentRun?.status, + now, + }); + return { id: session.id, friendlyId: session.friendlyId, @@ -239,6 +244,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 00000000000..04be73ec99b --- /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, + 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 new file mode 100644 index 00000000000..23ab5b5f035 --- /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; + /** 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/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 1de8d7fcbfa..426049ada75 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, + hasCurrentRun: session.currentRun != 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(); @@ -777,7 +791,7 @@ function OverviewTab({ session, status }: { session: LoadedSession; status: Sess - {status === "ACTIVE" && ( + {(status === "ACTIVE" || status === "IDLE") && ( @@ -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 4c15d0423b0..315e60e8f36 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 00000000000..87254f10abc --- /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 3c58240472e..f812b59fb5c 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ "app/utils/**/*.test.ts", "app/components/code/**/*.test.ts", "app/components/dashboard-agent/**/*.test.ts", - "app/presenters/v3/reports/**/*.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 diff --git a/docs/ai-chat/sessions.mdx b/docs/ai-chat/sessions.mdx index 041fd3d99ee..bb2a2d84c1c 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, })) { @@ -164,7 +167,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 |