Gateway: watch API, pairing, notifications, APNs sender, CLI, tests
This commit is contained in:
1 parent
972f6e4be5
commit
6ca098b701
12 files changed
+1297
-1
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user