1import type {
2 RelayAgentActivityAggregateRow,
3 RelayAgentActivityAggregateState,
4 RelayAgentActivityState,
5} from "@t3tools/contracts/relay";
6import * as DateTime from "effect/DateTime";
7import * as Option from "effect/Option";
8
9import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
10
11export function isTerminalPhase(state: RelayAgentActivityState): boolean {
12 return state.phase === "completed" || state.phase === "failed";
13}
14
15// Rows are only removed when their environment publishes a terminal state. An
16// environment that dies mid-run (machine off, process killed) never does, so
17// without an age cutoff its threads inflate activeCount forever. Actively
18// running phases expire quickly; waiting phases can legitimately sit for hours
19// while a user ignores an approval prompt, so they get a longer window. The
20// underlying database row is left in place: a late publish for the thread
21// refreshes updatedAt and the row becomes visible again.
22const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000;
23const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000;
24
25export function agentActivityExpiresAt(
26 state: Pick<RelayAgentActivityState, "phase" | "updatedAt">,
27): number {
28 const updatedAtMs = Option.match(DateTime.make(state.updatedAt), {
29 onNone: () => Number.NaN,
30 onSome: (dt) => dt.epochMilliseconds,
31 });
32 if (Number.isNaN(updatedAtMs)) {
33 return Number.NaN;
34 }
35 const ttlMs =
36 state.phase === "running" || state.phase === "starting"
37 ? RUNNING_AGENT_ACTIVITY_ROW_TTL_MS
38 : WAITING_AGENT_ACTIVITY_ROW_TTL_MS;
39 return updatedAtMs + ttlMs;
40}
41
42export function isExpiredAgentActivityState(
43 state: RelayAgentActivityState,
44 nowMs: number,
45): boolean {
46 const expiresAt = agentActivityExpiresAt(state);
47 return !Number.isFinite(expiresAt) || nowMs > expiresAt;
48}
49
50const MAX_SUMMARY_TEXT_LENGTH = 120;
51const MAX_STATUS_TEXT_LENGTH = 40;
52const MAX_DEEP_LINK_LENGTH = 512;
53// The Live Activity banner (lock screen / Notification Center) renders up to
54// five rows; the expanded Dynamic Island shows the top three of these.
55export const MAX_ACTIVITY_ROWS = 5;
56
57function truncateText(value: string, maxLength: number): string {
58 const trimmed = value.trim();
59 if (trimmed.length <= maxLength) {
60 return trimmed;
61 }
62 return trimmed.slice(0, maxLength - 3).trimEnd() + "...";
63}
64
65function sanitizeDeepLink(value: string): string {
66 const trimmed = value.trim();
67 if (!trimmed.startsWith("/") || trimmed.startsWith("//")) {
68 return "/";
69 }
70 return truncateText(trimmed, MAX_DEEP_LINK_LENGTH);
71}
72
73export function sanitizeAgentActivityAggregateRow(
74 row: RelayAgentActivityAggregateRow,
75): RelayAgentActivityAggregateRow {
76 return {
77 ...row,
78 projectTitle: truncateText(row.projectTitle, MAX_SUMMARY_TEXT_LENGTH),
79 threadTitle: truncateText(row.threadTitle, MAX_SUMMARY_TEXT_LENGTH),
80 modelTitle: truncateText(row.modelTitle, MAX_SUMMARY_TEXT_LENGTH),
81 status: truncateText(row.status, MAX_STATUS_TEXT_LENGTH),
82 deepLink: sanitizeDeepLink(row.deepLink),
83 };
84}
85
86export function sanitizeAgentActivityAggregateState(
87 aggregate: RelayAgentActivityAggregateState,
88): RelayAgentActivityAggregateState {
89 return {
90 ...aggregate,
91 title: truncateText(aggregate.title, MAX_SUMMARY_TEXT_LENGTH),
92 subtitle: truncateText(aggregate.subtitle, MAX_SUMMARY_TEXT_LENGTH),
93 activities: aggregate.activities
94 .slice(0, MAX_ACTIVITY_ROWS)
95 .map(sanitizeAgentActivityAggregateRow),
96 };
97}
98
99export function sanitizeApnsNotificationPayload(
100 notification: ApnsNotificationPayload,
101): ApnsNotificationPayload {
102 return {
103 ...notification,
104 title: truncateText(notification.title, MAX_SUMMARY_TEXT_LENGTH),
105 body: truncateText(notification.body, MAX_SUMMARY_TEXT_LENGTH),
106 deepLink: sanitizeDeepLink(notification.deepLink),
107 };
108}
109
110export function notificationForActivity(
111 row: RelayAgentActivityAggregateRow,
112): ApnsNotificationPayload {
113 const activity = sanitizeAgentActivityAggregateRow(row);
114 return sanitizeApnsNotificationPayload({
115 title: activity.threadTitle,
116 body: `${activity.status}: ${activity.projectTitle}`,
117 environmentId: activity.environmentId,
118 threadId: activity.threadId,
119 deepLink: activity.deepLink,
120 phase: activity.phase,
121 updatedAt: activity.updatedAt,
122 });
123}