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

+208
View File
@@ -0,0 +1,208 @@
// Turns T3 state into the shapes in contract.ts. Thread shells carry status but not text, so the
// last message and the pending request's details come from each thread's bounded snapshot. Those
// are large (hundreds of KB), so they are reduced to a small summary and cached per thread until
// the thread's `updatedAt` moves.
import { threadPhase, type Phase } from "./awareness.ts";
import type { Feed, FeedItem, HostSummary, Message, PendingRequest, ThreadDetail } from "./contract.ts";
import type { T3Host } from "./t3/host.ts";
import {
isApprovalRequest,
isUserInputRequest,
type BoundedThread,
type ThreadShell,
} from "./t3/types.ts";
const LAST_MESSAGE_CHARS = 280;
const MESSAGE_CHARS = 2000;
const MESSAGES_PER_THREAD = 20;
const RECENT_WINDOW_MS = 24 * 60 * 60_000;
const RECENT_LIMIT = 10;
/** How long a feed request waits for summaries it does not have yet. */
const SUMMARY_WAIT_MS = 2_000;
export interface ThreadSummary {
messages: Message[];
lastAssistantMessage: string | null;
request: PendingRequest | null;
}
/** Trims text for a small screen: collapses whitespace and cuts at a word near `max`. */
export function trimText(text: string, max: number): string {
const flat = text.replace(/\s+/g, " ").trim();
if (flat.length <= max) return flat;
const cut = flat.slice(0, max);
const space = cut.lastIndexOf(" ");
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
}
/** Reduces a bounded thread snapshot to what the watch shows. */
export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSummary {
const { messages, turnItems } = bounded.projection;
const visible = messages.filter(
(message) => message.text.trim().length > 0 && (message.role === "user" || message.role === "assistant"),
);
const lastAssistant = visible.findLast((message) => message.role === "assistant");
return {
messages: visible.slice(-MESSAGES_PER_THREAD).map((message) => ({
id: message.id,
role: message.role as Message["role"],
text: message.text.length > MESSAGE_CHARS ? `${message.text.slice(0, MESSAGE_CHARS)}…` : message.text,
createdAt: message.createdAt,
})),
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
request: pendingRequest(thread, turnItems),
};
}
function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projection"]["turnItems"]): PendingRequest | null {
const pending = thread.pendingRuntimeRequest;
if (!pending || pending.kind === "auth_refresh") return null;
const base = { id: pending.id, kind: pending.kind, prompt: null, reason: null, options: [], questions: [] };
const item = turnItems.findLast(
(candidate) => (isApprovalRequest(candidate) || isUserInputRequest(candidate)) && candidate.requestId === pending.id,
);
if (item && isApprovalRequest(item)) {
const options = item.options?.length
? item.options.map(({ decision, label }) => ({ decision, label }))
: [
{ decision: "accept" as const, label: "Allow" },
{ decision: "decline" as const, label: "Deny" },
];
return { ...base, prompt: item.prompt ?? item.title ?? null, reason: item.reason ?? null, options };
}
if (item && isUserInputRequest(item)) {
return {
...base,
questions: item.questions.map((question) => ({
id: question.id,
header: question.header,
question: question.question,
options: question.options.map((option) => ({ label: option.label, value: option.value ?? option.label })),
allowCustomAnswer: question.allowCustomAnswer ?? false,
})),
};
}
// The turn item may not be in the bounded window yet. Offer the plain choices for approvals.
return pending.kind === "user_input"
? base
: {
...base,
options: [
{ decision: "accept", label: "Allow" },
{ decision: "decline", label: "Deny" },
],
};
}
/** Per-thread summaries, refetched when a thread's `updatedAt` changes. */
export class SummaryCache {
private readonly entries = new Map<string, { updatedAt: string; summary: ThreadSummary }>();
private readonly inflight = new Map<string, Promise<ThreadSummary>>();
constructor(private readonly log: (message: string) => void = () => {}) {}
/** The cached summary, if it matches the thread's current version. */
peek(host: T3Host, thread: ThreadShell): ThreadSummary | undefined {
const entry = this.entries.get(key(host, thread.id));
return entry?.updatedAt === thread.updatedAt ? entry.summary : undefined;
}
get(host: T3Host, thread: ThreadShell): Promise<ThreadSummary> {
const cached = this.peek(host, thread);
if (cached) return Promise.resolve(cached);
const k = `${key(host, thread.id)}@${thread.updatedAt}`;
const running = this.inflight.get(k);
if (running) return running;
const promise = host
.bounded(thread.id)
.then((bounded) => {
const summary = summarize(bounded, thread);
this.entries.set(key(host, thread.id), { updatedAt: thread.updatedAt, summary });
return summary;
})
.finally(() => this.inflight.delete(k));
this.inflight.set(k, promise);
return promise;
}
forget(host: T3Host, threadId: string) {
this.entries.delete(key(host, threadId));
}
}
const key = (host: T3Host, threadId: string) => `${host.id}:${threadId}`;
export const hostSummary = (host: T3Host): HostSummary => ({
id: host.id,
label: host.label,
status: host.status,
error: host.lastError,
});
function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: ThreadSummary | undefined): FeedItem {
return {
hostId: host.id,
hostLabel: host.label,
threadId: thread.id,
projectId: thread.projectId,
projectTitle: host.projects.get(thread.projectId)?.title ?? "",
title: thread.title,
phase,
updatedAt: thread.updatedAt,
lastMessage: summary?.lastAssistantMessage ?? null,
request: summary?.request ?? null,
};
}
/** Builds the feed across every host, waiting briefly for summaries it has not read yet. */
export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date.now()): Promise<Feed> {
const candidates: Array<{ host: T3Host; thread: ThreadShell; phase: Phase }> = [];
for (const host of hosts) {
for (const thread of host.threads.values()) {
const phase = threadPhase(thread);
if (phase === null || thread.archivedAt || thread.deletedAt) continue;
if ((phase === "completed" || phase === "failed") && now - Date.parse(thread.updatedAt) > RECENT_WINDOW_MS) continue;
candidates.push({ host, thread, phase });
}
}
const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) =>
b.thread.updatedAt.localeCompare(a.thread.updatedAt);
const needsYou = candidates
.filter(({ phase }) => phase === "waiting_for_approval" || phase === "waiting_for_input")
.sort((a, b) =>
(a.thread.pendingRuntimeRequest?.createdAt ?? "").localeCompare(b.thread.pendingRuntimeRequest?.createdAt ?? ""),
);
const working = candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest);
const recent = candidates
.filter(({ phase }) => phase === "completed" || phase === "failed")
.sort(byNewest)
.slice(0, RECENT_LIMIT);
const shown = [...needsYou, ...working, ...recent];
const missing = shown
.filter(({ host, thread }) => !cache.peek(host, thread))
.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined));
if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]);
const toItem = ({ host, thread, phase }: (typeof candidates)[number]) =>
feedItem(host, thread, phase, cache.peek(host, thread));
return {
needsYou: needsYou.map(toItem),
working: working.map(toItem),
recent: recent.map(toItem),
hosts: hosts.map(hostSummary),
};
}
/** One thread with its recent messages. Fetches the summary if it is not cached. */
export async function buildThreadDetail(host: T3Host, thread: ThreadShell, cache: SummaryCache): Promise<ThreadDetail> {
const summary = await cache.get(host, thread);
const phase = threadPhase(thread) ?? (thread.status === "failed" ? "failed" : "completed");
return {
item: feedItem(host, thread, phase, summary),
messages: summary.messages,
canStop: thread.activeRunId !== null,
};
}