diff --git a/.server-changes/sessions-idle-status.md b/.server-changes/sessions-idle-status.md new file mode 100644 index 0000000000..8b15a97221 --- /dev/null +++ b/.server-changes/sessions-idle-status.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +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/SessionsTable.tsx b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx index 4340a97681..5e26dece86 100644 --- a/apps/webapp/app/components/sessions/v1/SessionsTable.tsx +++ b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx @@ -195,23 +195,38 @@ 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). - const endedAt = + // Closed and expired sessions freeze at the moment they ended. + const terminalEnd = session.status === "CLOSED" ? session.closedAt : session.status === "EXPIRED" ? session.expiresAt : undefined; - if (endedAt) { + if (terminalEnd) { return ( - <>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })} + <>{formatDuration(new Date(session.createdAt), new Date(terminalEnd), { style: "short" })} ); } - return ; + // 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 (session.currentRunCompletedAt) { + return ( + <> + {formatDuration(new Date(session.createdAt), new Date(session.currentRunCompletedAt), { + style: "short", + })} + + ); + } + + // Open 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 1e6d1fa239..5144599c8c 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 { isSessionLive } from "./isSessionLive"; 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,6 +210,8 @@ export class SessionListPresenter { return { sessions: sessions.map((session) => { + const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined; + const status: SessionStatus = session.closedAt != null ? "CLOSED" @@ -216,7 +219,12 @@ export class SessionListPresenter { ? "EXPIRED" : "ACTIVE"; - const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined; + // 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, + }); return { id: session.id, @@ -239,6 +247,12 @@ export class SessionListPresenter { updatedAt: session.updatedAt.toISOString(), environment: displayableEnvironment, currentRunFriendlyId: currentRun?.friendlyId, + 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, }; }), pagination: { 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/test/sessionListPresenterStatus.test.ts b/apps/webapp/test/sessionListPresenterStatus.test.ts new file mode 100644 index 0000000000..557d318e6b --- /dev/null +++ b/apps/webapp/test/sessionListPresenterStatus.test.ts @@ -0,0 +1,283 @@ +// 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 + 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"; +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( + "keeps the filterable status and drives duration off run liveness", + 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 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", + 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)); + + // 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("ACTIVE"); + expect(idle.hasLiveRun).toBe(false); + expect(idle.currentRunCompletedAt).toBe(RUN_COMPLETED_AT.toISOString()); + + // 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: ACTIVE, not live, no freeze point (dash). + const neverRan = byId.get(`sess_neverran_${suffix}`)!; + expect(neverRan.status).toBe("ACTIVE"); + expect(neverRan.hasLiveRun).toBe(false); + expect(neverRan.currentRunCompletedAt).toBeUndefined(); + } + ); +}); diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 80ce4c0a27..ccdc4db627 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ "app/components/dashboard-agent/**/*.test.ts", "app/components/queues/**/*.test.ts", "app/routes/storybook.agent-ui/*.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 041fd3d99e..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, })) { @@ -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 |