import type { RelayAgentActivityAggregateRow, RelayAgentActivityAggregateState, RelayAgentActivityState,} from "@t3tools/contracts/relay";import * as DateTime from "effect/DateTime";import * as Option from "effect/Option"; import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; export function isTerminalPhase(state: RelayAgentActivityState): boolean { return state.phase === "completed" || state.phase === "failed";} // Rows are only removed when their environment publishes a terminal state. An// environment that dies mid-run (machine off, process killed) never does, so// without an age cutoff its threads inflate activeCount forever. Actively// running phases expire quickly; waiting phases can legitimately sit for hours// while a user ignores an approval prompt, so they get a longer window. The// underlying database row is left in place: a late publish for the thread// refreshes updatedAt and the row becomes visible again.const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000;const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000; export function isExpiredAgentActivityState( state: RelayAgentActivityState, nowMs: number,): boolean { const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { onNone: () => Number.NaN, onSome: (dt) => dt.epochMilliseconds, }); if (Number.isNaN(updatedAtMs)) { return true; } const ttlMs = state.phase === "running" || state.phase === "starting" ? RUNNING_AGENT_ACTIVITY_ROW_TTL_MS : WAITING_AGENT_ACTIVITY_ROW_TTL_MS; return nowMs - updatedAtMs > ttlMs;} const MAX_SUMMARY_TEXT_LENGTH = 120;const MAX_STATUS_TEXT_LENGTH = 40;const MAX_DEEP_LINK_LENGTH = 512;// The Live Activity banner (lock screen / Notification Center) renders up to// five rows; the expanded Dynamic Island shows the top three of these.export const MAX_ACTIVITY_ROWS = 5; function truncateText(value: string, maxLength: number): string { const trimmed = value.trim(); if (trimmed.length <= maxLength) { return trimmed; } return trimmed.slice(0, maxLength - 3).trimEnd() + "...";} function sanitizeDeepLink(value: string): string { const trimmed = value.trim(); if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { return "/"; } return truncateText(trimmed, MAX_DEEP_LINK_LENGTH);} export function sanitizeAgentActivityAggregateRow( row: RelayAgentActivityAggregateRow,): RelayAgentActivityAggregateRow { return { ...row, projectTitle: truncateText(row.projectTitle, MAX_SUMMARY_TEXT_LENGTH), threadTitle: truncateText(row.threadTitle, MAX_SUMMARY_TEXT_LENGTH), modelTitle: truncateText(row.modelTitle, MAX_SUMMARY_TEXT_LENGTH), status: truncateText(row.status, MAX_STATUS_TEXT_LENGTH), deepLink: sanitizeDeepLink(row.deepLink), };} export function sanitizeAgentActivityAggregateState( aggregate: RelayAgentActivityAggregateState,): RelayAgentActivityAggregateState { return { ...aggregate, title: truncateText(aggregate.title, MAX_SUMMARY_TEXT_LENGTH), subtitle: truncateText(aggregate.subtitle, MAX_SUMMARY_TEXT_LENGTH), activities: aggregate.activities .slice(0, MAX_ACTIVITY_ROWS) .map(sanitizeAgentActivityAggregateRow), };} export function sanitizeApnsNotificationPayload( notification: ApnsNotificationPayload,): ApnsNotificationPayload { return { ...notification, title: truncateText(notification.title, MAX_SUMMARY_TEXT_LENGTH), body: truncateText(notification.body, MAX_SUMMARY_TEXT_LENGTH), deepLink: sanitizeDeepLink(notification.deepLink), };}
import type {
RelayAgentActivityAggregateRow,
RelayAgentActivityAggregateState,
RelayAgentActivityState,
} from "@t3tools/contracts/relay";
import * as DateTime from "effect/DateTime";
import * as Option from "effect/Option";
import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
export function isTerminalPhase(state: RelayAgentActivityState): boolean {
return state.phase === "completed" || state.phase === "failed";
}
// Rows are only removed when their environment publishes a terminal state. An
// environment that dies mid-run (machine off, process killed) never does, so
// without an age cutoff its threads inflate activeCount forever. Actively
// running phases expire quickly; waiting phases can legitimately sit for hours
// while a user ignores an approval prompt, so they get a longer window. The
// underlying database row is left in place: a late publish for the thread
// refreshes updatedAt and the row becomes visible again.
const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000;
const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000;
export function isExpiredAgentActivityState(
state: RelayAgentActivityState,
nowMs: number,
): boolean {
const updatedAtMs = Option.match(DateTime.make(state.updatedAt), {
onNone: () => Number.NaN,
onSome: (dt) => dt.epochMilliseconds,
});
if (Number.isNaN(updatedAtMs)) {
return true;
}
const ttlMs =
state.phase === "running" || state.phase === "starting"
? RUNNING_AGENT_ACTIVITY_ROW_TTL_MS
: WAITING_AGENT_ACTIVITY_ROW_TTL_MS;
return nowMs - updatedAtMs > ttlMs;
}
const MAX_SUMMARY_TEXT_LENGTH = 120;
const MAX_STATUS_TEXT_LENGTH = 40;
const MAX_DEEP_LINK_LENGTH = 512;
// The Live Activity banner (lock screen / Notification Center) renders up to
// five rows; the expanded Dynamic Island shows the top three of these.
export const MAX_ACTIVITY_ROWS = 5;
function truncateText(value: string, maxLength: number): string {
const trimmed = value.trim();
if (trimmed.length <= maxLength) {
return trimmed;
}
return trimmed.slice(0, maxLength - 3).trimEnd() + "...";
}
function sanitizeDeepLink(value: string): string {
const trimmed = value.trim();
if (!trimmed.startsWith("/") || trimmed.startsWith("//")) {
return "/";
}
return truncateText(trimmed, MAX_DEEP_LINK_LENGTH);
}
export function sanitizeAgentActivityAggregateRow(
row: RelayAgentActivityAggregateRow,
): RelayAgentActivityAggregateRow {
return {
...row,
projectTitle: truncateText(row.projectTitle, MAX_SUMMARY_TEXT_LENGTH),
threadTitle: truncateText(row.threadTitle, MAX_SUMMARY_TEXT_LENGTH),
modelTitle: truncateText(row.modelTitle, MAX_SUMMARY_TEXT_LENGTH),
status: truncateText(row.status, MAX_STATUS_TEXT_LENGTH),
deepLink: sanitizeDeepLink(row.deepLink),
};
}
export function sanitizeAgentActivityAggregateState(
aggregate: RelayAgentActivityAggregateState,
): RelayAgentActivityAggregateState {
return {
...aggregate,
title: truncateText(aggregate.title, MAX_SUMMARY_TEXT_LENGTH),
subtitle: truncateText(aggregate.subtitle, MAX_SUMMARY_TEXT_LENGTH),
activities: aggregate.activities
.slice(0, MAX_ACTIVITY_ROWS)
.map(sanitizeAgentActivityAggregateRow),
};
}
export function sanitizeApnsNotificationPayload(
notification: ApnsNotificationPayload,
): ApnsNotificationPayload {
return {
...notification,
title: truncateText(notification.title, MAX_SUMMARY_TEXT_LENGTH),
body: truncateText(notification.body, MAX_SUMMARY_TEXT_LENGTH),
deepLink: sanitizeDeepLink(notification.deepLink),
};
}