Gateway: watch API, pairing, notifications, APNs sender, CLI, tests

This commit is contained in:
gib committed 2026-11-01 02:09:18 -05:00
1 parent 972f6e4be5
commit 6ca098b701
12 files changed
+1297 -1

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
// Decides when to push. A thread notifies when its phase becomes one the user cares about
// (waiting for them, finished, failed), and again only after it moves on. A new approval on a
// thread already waiting counts as a change, so each request notifies once. Nothing notifies while
// a host loads its first snapshot, so a restart never replays old news.
import { threadPhase, type Phase } from "./awareness.ts";
import type { PushCategory, PushPayload } from "./contract.ts";
import type { HostRow } from "./db.ts";
import type { T3Host, ThreadChange } from "./t3/host.ts";
import { trimText, type SummaryCache, type ThreadSummary } from "./views.ts";
export interface Push {
title: string;
subtitle?: string;
body: string;
category: PushCategory;
data: PushPayload;
/** Replaces an earlier push about the same thing on the watch. */
collapseId: string;
}
export type SendPush = (push: Push) => Promise<void>;
const NOTIFYING: ReadonlySet<Phase> = new Set(["waiting_for_approval", "waiting_for_input", "completed", "failed"]);
const BODY_CHARS = 180;
const SUMMARY_WAIT_MS = 3_000;
const TOKEN_WARNING_MS = 7 * 24 * 60 * 60_000;
export class Notifier {
/** The last phase key seen per thread, `phase:requestId`. */
private readonly keys = new Map<string, string>();
/** Hosts already warned about, so each warning goes out once a day at most. */
private readonly warned = new Map<string, number>();
constructor(
private readonly send: SendPush,
private readonly cache: SummaryCache,
private readonly log: (message: string) => void = () => {},
) {}
onThreadChange = ({ host, previous, next, initial }: ThreadChange) => {
const id = `${host.id}:${(next ?? previous)?.id}`;
if (!next) {
this.keys.delete(id);
return;
}
const phase = threadPhase(next);
const key = `${phase}:${next.pendingRuntimeRequest?.id ?? ""}`;
const before = this.keys.get(id);
this.keys.set(id, key);
if (initial || phase === null || !NOTIFYING.has(phase) || key === before) return;
void this.notify(host, next, phase).catch((error: unknown) =>
this.log(`push for ${id} failed: ${error instanceof Error ? error.message : String(error)}`),
);
};
/** Warns once when a host starts rejecting the gateway's token. */
onHostStatus = (host: T3Host) => {
if (host.status !== "auth_invalid") return;
this.warnHost(host.id, host.label, `T3 rejected the gateway's token. Pair ${host.label} again.`);
};
/** Warns about tokens that expire within a week. Run it daily. */
checkTokenExpiry(rows: HostRow[], now = Date.now()) {
for (const row of rows) {
if (!row.token_expires_at) continue;
const left = Date.parse(row.token_expires_at) - now;
if (left > TOKEN_WARNING_MS) continue;
const days = Math.max(0, Math.ceil(left / (24 * 60 * 60_000)));
this.warnHost(row.id, row.label, `The gateway's T3 token expires in ${days} day${days === 1 ? "" : "s"}. Pair ${row.label} again.`);
}
}
private warnHost(hostId: string, label: string, body: string) {
const last = this.warned.get(hostId) ?? 0;
if (Date.now() - last < 24 * 60 * 60_000) return;
this.warned.set(hostId, Date.now());
void this.send({
title: label,
body,
category: "host",
data: { hostId, threadId: "" },
collapseId: `host:${hostId}`,
}).catch(() => {});
}
private async notify(host: T3Host, thread: NonNullable<ThreadChange["next"]>, phase: Phase) {
const summary = await Promise.race([
this.cache.get(host, thread).catch(() => undefined),
Bun.sleep(SUMMARY_WAIT_MS).then(() => undefined),
]);
const project = host.projects.get(thread.projectId)?.title;
const requestId = thread.pendingRuntimeRequest?.id;
await this.send({
title: thread.title,
subtitle: project ? `${host.label} · ${project}` : host.label,
body: body(phase, thread.lastError ?? null, summary),
category: category(phase),
data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}) },
collapseId: `${host.id}:${thread.id}`.slice(0, 64),
});
}
}
function category(phase: Phase): PushCategory {
switch (phase) {
case "waiting_for_approval":
return "approval";
case "waiting_for_input":
return "input";
case "failed":
return "failed";
default:
return "finished";
}
}
function body(phase: Phase, lastError: string | null, summary: ThreadSummary | undefined): string {
switch (phase) {
case "waiting_for_approval": {
const prompt = summary?.request?.prompt;
return prompt ? `Wants to run: ${trimText(prompt, BODY_CHARS)}` : "Needs your approval.";
}
case "waiting_for_input": {
const question = summary?.request?.questions[0]?.question;
return question ? trimText(question, BODY_CHARS) : "Has a question for you.";
}
case "failed":
return lastError ? trimText(lastError, BODY_CHARS) : "The run failed.";
default:
return summary?.lastAssistantMessage ? trimText(summary.lastAssistantMessage, BODY_CHARS) : "Finished.";
}
}