Skip to content

Commit a1ffd4a

Browse files
committed
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.
1 parent 337dda1 commit a1ffd4a

10 files changed

Lines changed: 512 additions & 33 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
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.

apps/webapp/app/components/sessions/v1/SessionStatus.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
import { CheckCircleIcon, ClockIcon } from "@heroicons/react/20/solid";
22
import assertNever from "assert-never";
3-
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
3+
import {
4+
type SessionDisplayStatus,
5+
type SessionStatus,
6+
} from "~/services/sessionsRepository/sessionsRepository.server";
47
import { cn } from "~/utils/cn";
58

9+
// Filterable statuses only — `IDLE` is display-only and derived from run
10+
// liveness, so it never appears in the filter surface.
611
export const allSessionStatuses = ["ACTIVE", "CLOSED", "EXPIRED"] as const satisfies Readonly<
712
Array<SessionStatus>
813
>;
914

10-
const descriptions: Record<SessionStatus, string> = {
11-
ACTIVE: "The session is open and can receive input or schedule new runs.",
15+
const descriptions: Record<SessionDisplayStatus, string> = {
16+
ACTIVE: "The session has a run currently executing.",
17+
IDLE: "The session is open but has no run currently executing.",
1218
CLOSED: "The session was closed; no further input or runs can be triggered against it.",
1319
EXPIRED: "The session passed its expiry time without being closed explicitly.",
1420
};
1521

16-
export function descriptionForSessionStatus(status: SessionStatus): string {
22+
export function descriptionForSessionStatus(status: SessionDisplayStatus): string {
1723
return descriptions[status];
1824
}
1925

20-
export function sessionStatusTitle(status: SessionStatus): string {
26+
export function sessionStatusTitle(status: SessionDisplayStatus): string {
2127
switch (status) {
2228
case "ACTIVE":
2329
return "Active";
30+
case "IDLE":
31+
return "Idle";
2432
case "CLOSED":
2533
return "Closed";
2634
case "EXPIRED":
@@ -30,10 +38,12 @@ export function sessionStatusTitle(status: SessionStatus): string {
3038
}
3139
}
3240

33-
export function sessionStatusColor(status: SessionStatus): string {
41+
export function sessionStatusColor(status: SessionDisplayStatus): string {
3442
switch (status) {
3543
case "ACTIVE":
3644
return "text-pending";
45+
case "IDLE":
46+
return "text-text-dimmed";
3747
case "CLOSED":
3848
return "text-success";
3949
case "EXPIRED":
@@ -48,7 +58,7 @@ export function SessionStatusIcon({
4858
className,
4959
pulse = true,
5060
}: {
51-
status: SessionStatus;
61+
status: SessionDisplayStatus;
5262
className: string;
5363
pulse?: boolean;
5464
}) {
@@ -64,6 +74,14 @@ export function SessionStatusIcon({
6474
</span>
6575
</span>
6676
);
77+
case "IDLE":
78+
// Open but not live: a static, dimmed dot (no pulse) — distinct from
79+
// ACTIVE's pulsing dot and EXPIRED's clock.
80+
return (
81+
<span className={cn("inline-flex items-center justify-center", className)}>
82+
<span className="size-2 rounded-full bg-text-dimmed" />
83+
</span>
84+
);
6785
case "CLOSED":
6886
return <CheckCircleIcon className={cn(sessionStatusColor(status), className)} />;
6987
case "EXPIRED":
@@ -73,7 +91,7 @@ export function SessionStatusIcon({
7391
}
7492
}
7593

76-
export function SessionStatusLabel({ status }: { status: SessionStatus }) {
94+
export function SessionStatusLabel({ status }: { status: SessionDisplayStatus }) {
7795
// system-mono-label: System themes uncolor the label (see tailwind.css)
7896
return (
7997
<span className={cn("system-mono-label", sessionStatusColor(status))}>
@@ -88,7 +106,7 @@ export function SessionStatusCombo({
88106
iconClassName,
89107
pulse = true,
90108
}: {
91-
status: SessionStatus;
109+
status: SessionDisplayStatus;
92110
className?: string;
93111
iconClassName?: string;
94112
pulse?: boolean;

apps/webapp/app/components/sessions/v1/SessionsTable.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,23 +195,29 @@ export function SessionsTable({
195195
}
196196

197197
function SessionDuration({ session }: { session: SessionListItem }) {
198-
// Active sessions tick live; closed/expired sessions freeze at the
199-
// moment they ended (closedAt for explicit closes, expiresAt when the
200-
// TTL ran out without a close call).
198+
// Only a genuinely live session ticks. Everything else freezes at the moment
199+
// it stopped being live: closedAt for explicit closes, expiresAt when the TTL
200+
// ran out, or the current run's completedAt for an idle (open, not-running)
201+
// session — so an abandoned session doesn't count up forever.
202+
if (session.status === "ACTIVE") {
203+
return <LiveTimer startTime={new Date(session.createdAt)} />;
204+
}
205+
201206
const endedAt =
202207
session.status === "CLOSED"
203208
? session.closedAt
204209
: session.status === "EXPIRED"
205210
? session.expiresAt
206-
: undefined;
211+
: session.currentRunCompletedAt;
207212

208213
if (endedAt) {
209214
return (
210215
<>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })}</>
211216
);
212217
}
213218

214-
return <LiveTimer startTime={new Date(session.createdAt)} />;
219+
// Idle session that never ran — nothing to measure.
220+
return <span className="text-text-dimmed"></span>;
215221
}
216222

217223
function SessionActionsCell({ runPath, allRunsPath }: { runPath?: string; allRunsPath: string }) {

apps/webapp/app/presenters/v3/SessionListPresenter.server.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
LEGACY_PLAYGROUND_TAG,
1111
} from "~/services/sessionsRepository/sessionsRepository.server";
1212
import { ServiceValidationError } from "~/v3/services/baseService.server";
13+
import { deriveSessionStatus } from "./deriveSessionStatus";
1314
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
1415
import { runStore } from "~/v3/runStore.server";
1516
import { startActiveSpan } from "~/v3/tracer.server";
@@ -192,7 +193,7 @@ export class SessionListPresenter {
192193
projectId,
193194
runtimeEnvironmentId: environmentId,
194195
},
195-
select: { id: true, friendlyId: true },
196+
select: { id: true, friendlyId: true, status: true, completedAt: true },
196197
},
197198
this.replica
198199
)
@@ -205,15 +206,19 @@ export class SessionListPresenter {
205206

206207
return {
207208
sessions: sessions.map((session) => {
208-
const status: SessionStatus =
209-
session.closedAt != null
210-
? "CLOSED"
211-
: session.expiresAt != null && session.expiresAt.getTime() < now
212-
? "EXPIRED"
213-
: "ACTIVE";
214-
215209
const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined;
216210

211+
// A session is only ACTIVE while its current run is genuinely live.
212+
// Open sessions whose run has terminated (or that have no run) read
213+
// IDLE rather than ticking ACTIVE forever.
214+
const status = deriveSessionStatus({
215+
closedAt: session.closedAt,
216+
expiresAt: session.expiresAt,
217+
currentRunId: session.currentRunId,
218+
currentRunStatus: currentRun?.status,
219+
now,
220+
});
221+
217222
return {
218223
id: session.id,
219224
friendlyId: session.friendlyId,
@@ -235,6 +240,11 @@ export class SessionListPresenter {
235240
updatedAt: session.updatedAt.toISOString(),
236241
environment: displayableEnvironment,
237242
currentRunFriendlyId: currentRun?.friendlyId,
243+
// Freeze point for an IDLE session's duration — when its current run
244+
// finished. Undefined when the session never ran (renders as a dash).
245+
currentRunCompletedAt: currentRun?.completedAt
246+
? currentRun.completedAt.toISOString()
247+
: undefined,
238248
};
239249
}),
240250
pagination: {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from "vitest";
2+
import { deriveSessionStatus } from "./deriveSessionStatus";
3+
4+
const NOW = new Date("2026-08-06T12:00:00.000Z").getTime();
5+
const PAST = new Date("2026-08-01T00:00:00.000Z");
6+
const FUTURE = new Date("2026-08-10T00:00:00.000Z");
7+
8+
describe("deriveSessionStatus", () => {
9+
it("returns CLOSED when closedAt is set, even with a live run", () => {
10+
expect(
11+
deriveSessionStatus({
12+
closedAt: PAST,
13+
expiresAt: null,
14+
currentRunId: "run_1",
15+
currentRunStatus: "EXECUTING",
16+
now: NOW,
17+
})
18+
).toBe("CLOSED");
19+
});
20+
21+
it("prefers CLOSED over an elapsed expiresAt", () => {
22+
expect(
23+
deriveSessionStatus({
24+
closedAt: PAST,
25+
expiresAt: PAST,
26+
currentRunId: null,
27+
currentRunStatus: undefined,
28+
now: NOW,
29+
})
30+
).toBe("CLOSED");
31+
});
32+
33+
it("returns EXPIRED when expiresAt is in the past", () => {
34+
expect(
35+
deriveSessionStatus({
36+
closedAt: null,
37+
expiresAt: PAST,
38+
currentRunId: "run_1",
39+
currentRunStatus: "EXECUTING",
40+
now: NOW,
41+
})
42+
).toBe("EXPIRED");
43+
});
44+
45+
it("returns ACTIVE when the current run is non-final", () => {
46+
expect(
47+
deriveSessionStatus({
48+
closedAt: null,
49+
expiresAt: FUTURE,
50+
currentRunId: "run_1",
51+
currentRunStatus: "EXECUTING",
52+
now: NOW,
53+
})
54+
).toBe("ACTIVE");
55+
});
56+
57+
it("returns IDLE when the current run has reached a terminal state", () => {
58+
expect(
59+
deriveSessionStatus({
60+
closedAt: null,
61+
expiresAt: null,
62+
currentRunId: "run_1",
63+
currentRunStatus: "EXPIRED",
64+
now: NOW,
65+
})
66+
).toBe("IDLE");
67+
});
68+
69+
it("returns IDLE when there is no current run", () => {
70+
expect(
71+
deriveSessionStatus({
72+
closedAt: null,
73+
expiresAt: null,
74+
currentRunId: null,
75+
currentRunStatus: undefined,
76+
now: NOW,
77+
})
78+
).toBe("IDLE");
79+
});
80+
81+
it("returns IDLE when the current run pointer can't be resolved (status unknown)", () => {
82+
expect(
83+
deriveSessionStatus({
84+
closedAt: null,
85+
expiresAt: null,
86+
currentRunId: "run_missing",
87+
currentRunStatus: undefined,
88+
now: NOW,
89+
})
90+
).toBe("IDLE");
91+
});
92+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { type TaskRunStatus } from "@trigger.dev/database";
2+
import { type SessionDisplayStatus } from "~/services/sessionsRepository/sessionsRepository.server";
3+
import { isFinalRunStatus } from "~/v3/taskStatus";
4+
5+
export type DeriveSessionStatusInput = {
6+
/** `Session.closedAt` — set once when the session is explicitly closed. */
7+
closedAt: Date | null;
8+
/** `Session.expiresAt` — retention deadline, if any. */
9+
expiresAt: Date | null;
10+
/** `Session.currentRunId` — pointer to the current run (no FK). */
11+
currentRunId: string | null;
12+
/**
13+
* Status of the run named by `currentRunId`. `undefined` when there is no
14+
* current run, or the pointer couldn't be resolved (stale / cross-env).
15+
*/
16+
currentRunStatus: TaskRunStatus | undefined;
17+
/** `Date.now()` at the time of derivation. */
18+
now: number;
19+
};
20+
21+
/**
22+
* Derives the display status of a session from its terminal markers and the
23+
* liveness of its current run.
24+
*
25+
* Precedence: an explicit close wins, then an elapsed retention deadline. Only
26+
* then do we ask whether the session is genuinely live: it's `ACTIVE` when its
27+
* current run exists and is non-final, otherwise `IDLE` (open but nothing
28+
* running). This is what stops an abandoned session whose run terminated long
29+
* ago from reading `ACTIVE` forever.
30+
*/
31+
export function deriveSessionStatus(input: DeriveSessionStatusInput): SessionDisplayStatus {
32+
if (input.closedAt != null) {
33+
return "CLOSED";
34+
}
35+
36+
if (input.expiresAt != null && input.expiresAt.getTime() < input.now) {
37+
return "EXPIRED";
38+
}
39+
40+
const hasLiveRun =
41+
input.currentRunId != null &&
42+
input.currentRunStatus !== undefined &&
43+
!isFinalRunStatus(input.currentRunStatus);
44+
45+
return hasLiveRun ? "ACTIVE" : "IDLE";
46+
}

0 commit comments

Comments
 (0)