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

+257
View File
@@ -0,0 +1,257 @@
// The watch's REST API, as described in docs/api.md and typed in contract.ts.
import type { BunRequest } from "bun";
import type {
ErrorResponse,
NewThreadRequest,
NewThreadResponse,
PairRequest,
PairResponse,
ProjectsResponse,
PushRegistration,
RespondRequest,
SendMessageRequest,
} from "./contract.ts";
import type { Db, DeviceRow } from "./db.ts";
import type { Hub } from "./hub.ts";
import { HostError, type T3Host } from "./t3/host.ts";
import { RpcError } from "./t3/rpc.ts";
import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts";
import { buildFeed, buildThreadDetail, hostSummary, type SummaryCache } from "./views.ts";
export interface ApiDeps {
db: Db;
hub: Hub;
cache: SummaryCache;
log?: (message: string) => void;
}
/** Wrong pairing codes allowed before every open code is voided. */
const PAIR_ATTEMPTS = 10;
const PAIR_WINDOW_MS = 15 * 60_000;
const DECISIONS: ReadonlySet<string> = new Set<ApprovalDecision>([
"accept",
"acceptForSession",
"acceptAlways",
"decline",
"cancel",
]);
class HttpError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
const json = (body: unknown, status = 200) => Response.json(body, { status });
const accepted = () => new Response(null, { status: 202 });
export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
let pairFailures: number[] = [];
/** Wraps a handler with device auth and error mapping. */
const authed =
<P extends string>(handler: (req: BunRequest<P>, device: DeviceRow) => Promise<Response> | Response) =>
(req: BunRequest<P>) =>
handle(async () => {
const token = req.headers.get("authorization")?.match(/^Bearer (.+)$/)?.[1];
const device = token ? db.devices.authenticate(token) : null;
if (!device) throw new HttpError(401, "This watch is not paired.");
return handler(req, device);
});
async function handle(run: () => Promise<Response> | Response): Promise<Response> {
try {
return await run();
} catch (error) {
if (error instanceof HttpError) return json({ error: error.message } satisfies ErrorResponse, error.status);
if (error instanceof HostError) {
return json({ error: error.message } satisfies ErrorResponse, error.status === 503 ? 503 : 502);
}
if (error instanceof RpcError) return json({ error: error.message } satisfies ErrorResponse, 502);
log(`unhandled: ${error instanceof Error ? error.stack : String(error)}`);
return json({ error: "Something went wrong in the gateway." } satisfies ErrorResponse, 500);
}
}
const findHost = (hostId: string): T3Host => {
const host = hub.get(hostId);
if (!host) throw new HttpError(404, "Unknown host.");
return host;
};
const findThread = (hostId: string, threadId: string): { host: T3Host; thread: ThreadShell } => {
const host = findHost(hostId);
const thread = host.threads.get(threadId);
if (!thread) throw new HttpError(404, "Unknown thread.");
return { host, thread };
};
const resolveModel = (host: T3Host, projectId: string): ModelSelection | null =>
host.projects.get(projectId)?.defaultModelSelection ?? host.serverConfig?.settings?.defaultModelSelection ?? null;
return {
"/v1/health": {
GET: () => json({ ok: true, hosts: hub.list().map(hostSummary) }),
},
"/v1/pair": {
POST: (req: BunRequest<"/v1/pair">) =>
handle(async () => {
const body = await readBody<PairRequest>(req);
const code = String(body.code ?? "").trim();
const name = String(body.deviceName ?? "").trim() || "Apple Watch";
pairFailures = pairFailures.filter((at) => Date.now() - at < PAIR_WINDOW_MS);
if (pairFailures.length >= PAIR_ATTEMPTS) throw new HttpError(429, "Too many attempts. Make a new code.");
if (!/^\d{6}$/.test(code) || !db.pairing.redeem(code)) {
pairFailures.push(Date.now());
if (pairFailures.length >= PAIR_ATTEMPTS) db.pairing.voidAll();
throw new HttpError(400, "That code is wrong or has expired.");
}
const deviceToken = `wc_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`;
const deviceId = db.devices.create(name, deviceToken);
log(`paired device ${name} (${deviceId})`);
return json({ deviceId, deviceToken } satisfies PairResponse);
}),
},
"/v1/device/push": {
PUT: authed<"/v1/device/push">(async (req, device) => {
const body = await readBody<PushRegistration>(req);
if (!/^[0-9a-fA-F]{32,200}$/.test(body.apnsToken ?? "")) throw new HttpError(400, "apnsToken must be hex.");
if (body.apnsEnvironment !== "sandbox" && body.apnsEnvironment !== "production") {
throw new HttpError(400, "apnsEnvironment must be sandbox or production.");
}
db.devices.setPush(device.id, body.apnsToken.toLowerCase(), body.apnsEnvironment);
return new Response(null, { status: 204 });
}),
},
"/v1/feed": {
GET: authed(async () => json(await buildFeed(hub.list(), cache))),
},
"/v1/projects": {
GET: authed(() =>
json({
hosts: hub.list().map((host) => ({
...hostSummary(host),
projects: [...host.projects.values()]
.sort((a, b) => a.title.localeCompare(b.title))
.map((project) => ({ id: project.id, title: project.title, model: resolveModel(host, project.id)?.model ?? null })),
})),
} satisfies ProjectsResponse),
),
},
"/v1/threads": {
POST: authed<"/v1/threads">(async (req) => {
const body = await readBody<NewThreadRequest>(req);
const text = String(body.text ?? "").trim();
if (!text) throw new HttpError(400, "Say what the thread should do.");
const host = findHost(String(body.hostId));
if (!host.projects.has(String(body.projectId))) throw new HttpError(404, "Unknown project.");
const modelSelection = resolveModel(host, body.projectId);
if (!modelSelection) throw new HttpError(400, "That project has no default model. Set one in T3 Code.");
const result = await host.launchThread({
commandId: crypto.randomUUID(),
creationSource: "mobile",
projectId: body.projectId,
title: titleFrom(text),
generateTitle: true,
modelSelection,
runtimeMode: host.serverConfig?.settings?.defaultRuntimeMode ?? "full-access",
interactionMode: "default",
workspaceStrategy: { type: "root" },
initialMessage: { messageId: crypto.randomUUID(), text, attachments: [] },
});
return json({ hostId: host.id, threadId: result.threadId } satisfies NewThreadResponse, 201);
}),
},
"/v1/threads/:hostId/:threadId": {
GET: authed<"/v1/threads/:hostId/:threadId">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
return json(await buildThreadDetail(host, thread, cache));
}),
},
"/v1/threads/:hostId/:threadId/messages": {
POST: authed<"/v1/threads/:hostId/:threadId/messages">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
const text = String((await readBody<SendMessageRequest>(req)).text ?? "").trim();
if (!text) throw new HttpError(400, "The message is empty.");
await host.dispatch({
type: "message.dispatch",
commandId: crypto.randomUUID(),
threadId: thread.id,
messageId: crypto.randomUUID(),
text,
attachments: [],
createdBy: "user",
creationSource: "mobile",
dispatchMode: { type: "queue_after_active" },
});
return accepted();
}),
},
"/v1/threads/:hostId/:threadId/respond": {
POST: authed<"/v1/threads/:hostId/:threadId/respond">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
const body = await readBody<RespondRequest>(req);
if (thread.pendingRuntimeRequest?.id !== body.requestId) {
throw new HttpError(409, "That request was already answered.");
}
const base = { type: "runtime-request.respond", commandId: crypto.randomUUID(), threadId: thread.id, requestId: body.requestId } as const;
if ("decision" in body) {
if (!DECISIONS.has(body.decision)) throw new HttpError(400, "Unknown decision.");
await host.dispatch({ ...base, decision: body.decision });
} else if ("answers" in body && body.answers && typeof body.answers === "object") {
await host.dispatch({ ...base, answers: body.answers });
} else {
throw new HttpError(400, "Send a decision or answers.");
}
cache.forget(host, thread.id);
return accepted();
}),
},
"/v1/threads/:hostId/:threadId/stop": {
POST: authed<"/v1/threads/:hostId/:threadId/stop">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
if (host.supports("threadStop")) {
await host.dispatch({ type: "thread.stop", commandId: crypto.randomUUID(), threadId: thread.id });
} else if (thread.activeRunId) {
await host.dispatch({
type: "run.interrupt",
commandId: crypto.randomUUID(),
threadId: thread.id,
runId: thread.activeRunId,
});
} else {
throw new HttpError(409, "Nothing is running.");
}
return accepted();
}),
},
};
}
async function readBody<T>(req: Request): Promise<Partial<T> & T> {
try {
return (await req.json()) as Partial<T> & T;
} catch {
throw new HttpError(400, "The body must be JSON.");
}
}
/** A thread title from the first line of the prompt. T3 replaces it with a generated one. */
function titleFrom(text: string): string {
const line = text.split("\n")[0]?.trim() ?? text;
return line.length > 80 ? `${line.slice(0, 79)}…` : line;
}
+114
View File
@@ -0,0 +1,114 @@
// Sends push notifications through APNs with token-based auth: an ES256 JWT signed by the .p8 key
// from the Apple Developer portal, over HTTP/2. No dependencies.
import { createPrivateKey, sign, type KeyObject } from "node:crypto";
import { connect, type ClientHttp2Session } from "node:http2";
export interface ApnsConfig {
keyId: string;
teamId: string;
/** The .p8 key's PEM text. */
key: string;
/** The app's bundle ID. */
topic: string;
}
export interface ApnsNotification {
deviceToken: string;
environment: "sandbox" | "production";
payload: object;
/** Replaces an earlier notification with the same id on the device. */
collapseId?: string;
}
/** Device tokens APNs reported as no longer valid. */
export type ApnsResult = { ok: true } | { ok: false; status: number; reason: string; tokenInvalid: boolean };
const HOSTS = {
sandbox: "https://api.sandbox.push.apple.com",
production: "https://api.push.apple.com",
} as const;
// Apple rejects tokens older than an hour and throttles ones refreshed more often than every 20 minutes.
const JWT_LIFETIME_MS = 40 * 60_000;
export class Apns {
private readonly key: KeyObject;
private jwt: { value: string; issuedAt: number } | null = null;
private readonly sessions = new Map<string, ClientHttp2Session>();
constructor(private readonly config: ApnsConfig) {
this.key = createPrivateKey(config.key);
}
async send(notification: ApnsNotification): Promise<ApnsResult> {
const body = JSON.stringify(notification.payload);
const session = this.session(HOSTS[notification.environment]);
return new Promise((resolve) => {
const request = session.request({
":method": "POST",
":path": `/3/device/${notification.deviceToken}`,
authorization: `bearer ${this.token()}`,
"apns-topic": this.config.topic,
"apns-push-type": "alert",
"apns-priority": "10",
...(notification.collapseId ? { "apns-collapse-id": notification.collapseId } : {}),
"content-type": "application/json",
});
let status = 0;
let response = "";
request.on("response", (headers) => {
status = Number(headers[":status"]);
});
request.setEncoding("utf8");
request.on("data", (chunk: string) => (response += chunk));
request.on("end", () => {
if (status === 200) return resolve({ ok: true });
const reason = safeReason(response);
resolve({
ok: false,
status,
reason,
tokenInvalid: status === 410 || reason === "BadDeviceToken" || reason === "DeviceTokenNotForTopic",
});
});
request.on("error", (error) => resolve({ ok: false, status: 0, reason: error.message, tokenInvalid: false }));
request.end(body);
});
}
close() {
for (const session of this.sessions.values()) session.close();
this.sessions.clear();
}
private session(origin: string): ClientHttp2Session {
const existing = this.sessions.get(origin);
if (existing && !existing.closed && !existing.destroyed) return existing;
const session = connect(origin);
session.on("error", () => this.sessions.delete(origin));
session.on("close", () => this.sessions.delete(origin));
this.sessions.set(origin, session);
return session;
}
private token(): string {
if (this.jwt && Date.now() - this.jwt.issuedAt < JWT_LIFETIME_MS) return this.jwt.value;
const issuedAt = Date.now();
const header = base64url(JSON.stringify({ alg: "ES256", kid: this.config.keyId }));
const claims = base64url(JSON.stringify({ iss: this.config.teamId, iat: Math.floor(issuedAt / 1000) }));
const signature = sign("sha256", Buffer.from(`${header}.${claims}`), { key: this.key, dsaEncoding: "ieee-p1363" });
this.jwt = { value: `${header}.${claims}.${signature.toString("base64url")}`, issuedAt };
return this.jwt.value;
}
}
const base64url = (text: string) => Buffer.from(text).toString("base64url");
function safeReason(body: string): string {
try {
return (JSON.parse(body) as { reason?: string }).reason ?? body;
} catch {
return body;
}
}
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bun
// Operator commands. They edit the same SQLite file the running gateway reads, so changes apply
// within a few seconds. In the container: `docker exec -it wristcode-gateway wristcode <command>`.
import { loadConfig } from "./config.ts";
import { openDb } from "./db.ts";
import { ORCHESTRATION_PROTOCOL_VERSION, type EnvironmentDescriptor } from "./t3/types.ts";
const USAGE = `wristcode <command>
host add <url> <pairing-credential> [--label <name>]
Connect a T3 Code server. Make the credential on that host with:
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
Run it again with a fresh credential to renew the gateway's token.
host list
host remove <id or label>
pair
Print a 6 digit code to enter on the watch. Good for 5 minutes.
device list
device revoke <id or name>`;
const PAIRING_CODE_TTL_MS = 5 * 60_000;
const db = openDb(loadConfig().dbPath);
const [group, command, ...rest] = process.argv.slice(2);
try {
await run();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
} finally {
db.close();
}
async function run() {
switch (`${group} ${command ?? ""}`.trim()) {
case "host add":
return hostAdd(rest);
case "host list":
for (const host of db.hosts.list()) {
console.log(`${host.label}\t${host.url}\t${host.id}\ttoken expires ${host.token_expires_at ?? "unknown"}`);
}
return;
case "host remove":
if (!rest[0]) throw new Error(USAGE);
console.log(db.hosts.remove(rest[0]) ? "Removed." : "No such host.");
return;
case "pair": {
const code = String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1_000_000).padStart(6, "0");
db.pairing.create(code, PAIRING_CODE_TTL_MS);
console.log(`Enter this code on the watch within 5 minutes: ${code}`);
return;
}
case "device list":
for (const device of db.devices.list()) {
const state = device.revoked_at ? "revoked" : device.apns_token ? `push ${device.apns_env}` : "no push";
console.log(`${device.name}\t${device.id}\t${state}\tlast seen ${device.last_seen_at ?? "never"}`);
}
return;
case "device revoke":
if (!rest[0]) throw new Error(USAGE);
console.log(`Revoked ${db.devices.revoke(rest[0])} device(s).`);
return;
default:
console.log(USAGE);
}
}
/** Exchanges a T3 pairing credential for a bearer session and saves the host. */
async function hostAdd(args: string[]) {
const labelFlag = args.indexOf("--label");
const label = labelFlag === -1 ? undefined : args[labelFlag + 1];
const [rawUrl, credential] = args.filter((_, index) => labelFlag === -1 || (index !== labelFlag && index !== labelFlag + 1));
if (!rawUrl || !credential) throw new Error(USAGE);
const url = new URL(rawUrl).origin;
const descriptor = await getJson<EnvironmentDescriptor>(`${url}/.well-known/t3/environment`);
if ((descriptor.orchestrationProtocolVersion ?? 1) !== ORCHESTRATION_PROTOCOL_VERSION) {
throw new Error(`${url} speaks orchestration protocol ${descriptor.orchestrationProtocolVersion ?? 1}, this gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}.`);
}
const response = await fetch(`${url}/oauth/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: credential,
subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap",
requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
scope: "orchestration:read orchestration:operate",
client_label: "WristCode gateway",
client_device_type: "bot",
}),
});
if (!response.ok) throw new Error(`${url} refused the pairing credential (${response.status}): ${await response.text()}`);
const token = (await response.json()) as { access_token: string; expires_in?: number; scope?: string };
const scopes = token.scope?.split(" ") ?? [];
if (!scopes.includes("orchestration:read") || !scopes.includes("orchestration:operate")) {
throw new Error(`The credential only granted "${token.scope}". Make one with orchestration:read and orchestration:operate.`);
}
db.hosts.upsert({
id: descriptor.environmentId,
label: label ?? descriptor.label,
url,
environment_id: descriptor.environmentId,
token: token.access_token,
token_expires_at: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : null,
});
console.log(`Added ${label ?? descriptor.label} (T3 ${descriptor.serverVersion}). The gateway connects within 15 seconds.`);
}
async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json() as Promise<T>;
}
+26
View File
@@ -0,0 +1,26 @@
// Settings from the environment. Only the database path is needed to run. Without the APNs
// settings the gateway still serves the watch, it just logs pushes instead of sending them.
import { readFileSync } from "node:fs";
import type { ApnsConfig } from "./apns.ts";
export interface Config {
port: number;
dbPath: string;
apns: ApnsConfig | null;
}
export function loadConfig(env = process.env): Config {
const keyId = env.APNS_KEY_ID;
const teamId = env.APNS_TEAM_ID;
const key = env.APNS_KEY ?? (env.APNS_KEY_PATH ? readFileSync(env.APNS_KEY_PATH, "utf8") : undefined);
return {
port: Number(env.PORT ?? 8080),
dbPath: env.WRISTCODE_DB ?? "data/wristcode.sqlite",
apns:
keyId && teamId && key
? { keyId, teamId, key: key.replace(/\\n/g, "\n"), topic: env.APNS_TOPIC ?? "org.gbrown.wristcode" }
: null,
};
}
+57
View File
@@ -0,0 +1,57 @@
// The set of T3 hosts, kept in step with the `hosts` table. The CLI edits the table and the running
// gateway picks the change up on its next sync, so adding or renewing a host needs no restart.
import type { Db, HostRow } from "./db.ts";
import { T3Host, type HostOptions } from "./t3/host.ts";
export class Hub {
private readonly hosts = new Map<string, { host: T3Host; row: HostRow }>();
constructor(
private readonly db: Db,
private readonly options: HostOptions,
) {}
/** Starts new hosts, restarts ones whose URL or token changed, and stops removed ones. */
sync() {
const rows = this.db.hosts.list();
const seen = new Set<string>();
for (const row of rows) {
seen.add(row.id);
const current = this.hosts.get(row.id);
if (current && current.row.url === row.url && current.row.token === row.token && current.row.label === row.label) {
current.row = row;
continue;
}
current?.host.stop();
const host = new T3Host(
{ id: row.id, label: row.label, url: row.url, token: row.token, environmentId: row.environment_id },
this.options,
);
this.hosts.set(row.id, { host, row });
host.start();
}
for (const [id, { host }] of this.hosts) {
if (seen.has(id)) continue;
host.stop();
this.hosts.delete(id);
}
}
list(): T3Host[] {
return [...this.hosts.values()].map(({ host }) => host);
}
get(id: string): T3Host | undefined {
return this.hosts.get(id)?.host;
}
rows(): HostRow[] {
return [...this.hosts.values()].map(({ row }) => row);
}
stop() {
for (const { host } of this.hosts.values()) host.stop();
this.hosts.clear();
}
}
+80
View File
@@ -0,0 +1,80 @@
// Starts the gateway: connects to every T3 host in the database, serves the watch API, and pushes
// notifications.
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { Apns } from "./apns.ts";
import { createRoutes } from "./api.ts";
import { loadConfig } from "./config.ts";
import { openDb } from "./db.ts";
import { Hub } from "./hub.ts";
import { Notifier, type SendPush } from "./notify.ts";
import { SummaryCache } from "./views.ts";
const HOST_SYNC_MS = 15_000;
const TOKEN_CHECK_MS = 6 * 60 * 60_000;
const log = (message: string) => console.log(`${new Date().toISOString()} ${message}`);
const config = loadConfig();
mkdirSync(dirname(config.dbPath), { recursive: true });
const db = openDb(config.dbPath);
const cache = new SummaryCache(log);
const apns = config.apns ? new Apns(config.apns) : null;
if (!apns) log("APNs is not configured. Pushes will only be logged.");
const sendPush: SendPush = async (push) => {
const devices = db.devices.active().filter((device) => device.apns_token && device.apns_env);
if (!apns || devices.length === 0) {
log(`push (not sent): [${push.category}] ${push.title}: ${push.body}`);
return;
}
const payload = {
aps: {
alert: { title: push.title, ...(push.subtitle ? { subtitle: push.subtitle } : {}), body: push.body },
sound: "default",
category: push.category,
"thread-id": push.data.threadId || push.data.hostId,
},
...push.data,
};
await Promise.all(
devices.map(async (device) => {
const result = await apns.send({
deviceToken: device.apns_token!,
environment: device.apns_env!,
payload,
collapseId: push.collapseId,
});
if (result.ok) return;
log(`APNs refused a push to ${device.name}: ${result.status} ${result.reason}`);
if (result.tokenInvalid) db.devices.clearPush(device.apns_token!);
}),
);
};
const notifier = new Notifier(sendPush, cache, log);
const hub = new Hub(db, { onThreadChange: notifier.onThreadChange, onStatus: notifier.onHostStatus, log });
hub.sync();
setInterval(() => hub.sync(), HOST_SYNC_MS);
notifier.checkTokenExpiry(hub.rows());
setInterval(() => notifier.checkTokenExpiry(hub.rows()), TOKEN_CHECK_MS);
const server = Bun.serve({
port: config.port,
routes: createRoutes({ db, hub, cache, log }),
fetch: () => Response.json({ error: "Not found." }, { status: 404 }),
});
log(`WristCode gateway listening on :${server.port} with ${hub.list().length} host(s)`);
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
log(`${signal}: shutting down`);
void server.stop();
hub.stop();
apns?.close();
db.close();
process.exit(0);
});
}
+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.";
}
}
+1 -1
View File
@@ -155,7 +155,7 @@ export interface EnvironmentDescriptor {
export interface ServerConfig {
environment?: { capabilities?: Record<string, unknown> };
settings?: { defaultModelSelection?: ModelSelection | null };
settings?: { defaultModelSelection?: ModelSelection | null; defaultRuntimeMode?: string };
}
/** Commands sent through `orchestration.dispatchCommand`. */
+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,
};
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from "bun:test";
import { Notifier, type Push } from "../src/notify.ts";
import type { T3Host } from "../src/t3/host.ts";
import type { ThreadShell } from "../src/t3/types.ts";
import { SummaryCache } from "../src/views.ts";
const host = {
id: "host-1",
label: "Mac mini",
projects: new Map([["project-1", { id: "project-1", title: "wristcode" }]]),
bounded: () => Promise.reject(new Error("offline")),
} as unknown as T3Host;
const shell = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
id: "thread-1",
projectId: "project-1",
title: "Watch app scaffold",
createdAt: "2026-11-01T09:00:00.000Z",
updatedAt: "2026-11-01T09:00:00.000Z",
status: "running",
activeRunId: "run-1",
pendingRuntimeRequest: null,
modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5" },
lineage: { relationshipToParent: null },
...overrides,
});
const request = (id: string) => ({ id, kind: "command", createdAt: "2026-11-01T09:00:00.000Z" });
function setup() {
const pushes: Push[] = [];
const notifier = new Notifier(async (push) => void pushes.push(push), new SummaryCache());
let previous: ThreadShell | undefined;
const change = (next: ThreadShell | undefined, initial = false) => {
notifier.onThreadChange({ host, previous, next, initial });
previous = next;
};
// Pushes are sent after a summary lookup, which fails fast here.
const settle = () => Bun.sleep(5);
return { pushes, change, settle, notifier };
}
describe("Notifier", () => {
it("stays quiet while loading the first snapshot", async () => {
const { pushes, change, settle } = setup();
change(shell({ status: "completed" }), true);
change(shell({ status: "completed" }));
await settle();
expect(pushes).toEqual([]);
});
it("notifies once when a running thread finishes", async () => {
const { pushes, change, settle } = setup();
change(shell(), true);
change(shell({ status: "completed" }));
change(shell({ status: "completed", updatedAt: "2026-11-01T09:01:00.000Z" }));
await settle();
expect(pushes.map((push) => [push.category, push.subtitle, push.data.threadId])).toEqual([
["finished", "Mac mini · wristcode", "thread-1"],
]);
});
it("notifies for each new approval request, with its request id", async () => {
const { pushes, change, settle } = setup();
change(shell(), true);
change(shell({ pendingRuntimeRequest: request("r1") }));
change(shell({ pendingRuntimeRequest: request("r2") }));
await settle();
expect(pushes.map((push) => [push.category, push.data.requestId])).toEqual([
["approval", "r1"],
["approval", "r2"],
]);
});
it("does not announce a turn the user stopped", async () => {
const { pushes, change, settle } = setup();
change(shell(), true);
change(shell({ status: "completed", latestRunStopRequested: true }));
await settle();
expect(pushes).toEqual([]);
});
it("reports failures with the server's error", async () => {
const { pushes, change, settle } = setup();
change(shell(), true);
change(shell({ status: "failed", lastError: "Provider quota exceeded" }));
await settle();
expect(pushes.map((push) => [push.category, push.body])).toEqual([["failed", "Provider quota exceeded"]]);
});
it("warns about a token that expires within a week, once a day", async () => {
const { pushes, notifier } = setup();
const row = {
id: "host-1",
label: "Mac mini",
url: "https://t3mac.gbrown.org",
environment_id: "host-1",
token: "t",
token_expires_at: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(),
created_at: "",
};
notifier.checkTokenExpiry([row]);
notifier.checkTokenExpiry([row]);
expect(pushes.map((push) => [push.category, push.body])).toEqual([
["host", "The gateway's T3 token expires in 3 days. Pair Mac mini again."],
]);
});
});
+90
View File
@@ -0,0 +1,90 @@
// The RPC client against an in-process server that frames messages the way Effect RPC does.
import { afterEach, describe, expect, it } from "bun:test";
import type { Server, ServerWebSocket } from "bun";
import { RpcError, RpcSocket } from "../src/t3/rpc.ts";
type Frame = { _tag: string; id?: string; requestId?: string; tag?: string; payload?: unknown };
let server: Server<undefined> | undefined;
afterEach(() => server?.stop(true));
/** Starts a server whose `onRequest` decides how to answer each Request frame. */
function serve(onRequest: (ws: ServerWebSocket<undefined>, frame: Frame, received: Frame[]) => void) {
const received: Frame[] = [];
server = Bun.serve({
port: 0,
fetch: (req, srv) => (srv.upgrade(req) ? undefined : new Response("no", { status: 400 })),
websocket: {
message(ws, data) {
const frame = JSON.parse(String(data)) as Frame;
received.push(frame);
if (frame._tag === "Request") onRequest(ws, frame, received);
},
},
});
return { url: `ws://localhost:${server.port}`, received };
}
const send = (ws: ServerWebSocket<undefined>, frame: object) => ws.send(JSON.stringify(frame));
describe("RpcSocket", () => {
it("resolves a unary call with its success value", async () => {
const { url } = serve((ws, frame) =>
send(ws, { _tag: "Exit", requestId: frame.id, exit: { _tag: "Success", value: { echoed: frame.payload } } }),
);
const socket = await RpcSocket.connect(url);
expect(await socket.call("server.probe", { a: 1 })).toEqual({ echoed: { a: 1 } });
socket.close();
});
it("rejects with the server's typed error", async () => {
const error = { _tag: "EnvironmentAuthorizationError", message: "missing scope", requiredScope: "orchestration:operate" };
const { url } = serve((ws, frame) =>
send(ws, { _tag: "Exit", requestId: frame.id, exit: { _tag: "Failure", cause: [{ _tag: "Fail", error }] } }),
);
const socket = await RpcSocket.connect(url);
const failure = await socket.call("orchestration.dispatchCommand", {}).catch((e: unknown) => e);
expect(failure).toBeInstanceOf(RpcError);
expect((failure as RpcError).error).toEqual(error);
socket.close();
});
it("acks every chunk so the server sends the next one", async () => {
const { url, received } = serve((ws, frame) => {
// Like Effect RPC, send one chunk, then wait for its Ack before the next.
const values = [[1, 2], [3]];
const next = () => {
const value = values.shift();
if (value) send(ws, { _tag: "Chunk", requestId: frame.id, values: value });
else send(ws, { _tag: "Exit", requestId: frame.id, exit: { _tag: "Success", value: null } });
};
const waitForAck = setInterval(() => {
const acks = received.filter((f) => f._tag === "Ack").length;
if (acks === 2 - values.length) next();
if (values.length === 0 && acks === 2) clearInterval(waitForAck);
}, 2);
next();
});
const socket = await RpcSocket.connect(url);
const seen: unknown[] = [];
await socket.stream("orchestration.subscribeShell", {}, (value) => seen.push(value)).done;
expect(seen).toEqual([1, 2, 3]);
expect(received.filter((f) => f._tag === "Ack")).toHaveLength(2);
socket.close();
});
it("fails pending calls when the socket closes", async () => {
const { url } = serve((ws) => ws.close());
const socket = await RpcSocket.connect(url);
expect(socket.call("server.probe", {})).rejects.toThrow("socket closed");
await socket.closed;
});
it("closes a silent socket", async () => {
// This server never answers Pings.
const { url } = serve(() => {});
const socket = await RpcSocket.connect(url, { pingIntervalMs: 10, silenceTimeoutMs: 40 });
await socket.closed;
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "bun:test";
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
import { summarize, trimText } from "../src/views.ts";
const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
id: "thread-1",
projectId: "project-1",
title: "Receipt OCR",
createdAt: "2026-11-01T09:00:00.000Z",
updatedAt: "2026-11-01T09:00:00.000Z",
status: "running",
activeRunId: "run-1",
pendingRuntimeRequest: null,
modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5" },
lineage: { relationshipToParent: null },
...overrides,
});
const message = (role: string, text: string, id = text) => ({
id,
role,
text,
createdAt: "2026-11-01T09:00:00.000Z",
updatedAt: "2026-11-01T09:00:00.000Z",
});
const bounded = (turnItems: BoundedThread["projection"]["turnItems"], messages = [message("assistant", "Tests written.")]) =>
({ snapshotSequence: 1, projection: { thread: thread(), messages, turnItems } }) as BoundedThread;
describe("trimText", () => {
it("collapses whitespace and cuts at a word", () => {
expect(trimText("one two\n\nthree", 50)).toBe("one two three");
expect(trimText("alpha beta gamma delta", 15)).toBe("alpha beta…");
});
});
describe("summarize", () => {
it("takes the last assistant message and skips empty or system ones", () => {
const summary = summarize(
bounded([], [message("user", "Add OCR"), message("assistant", "Done, all green."), message("system", "noise"), message("assistant", " ")]),
thread(),
);
expect(summary.lastAssistantMessage).toBe("Done, all green.");
expect(summary.messages.map((m) => m.role)).toEqual(["user", "assistant"]);
});
it("reads the pending approval's prompt and options", () => {
const summary = summarize(
bounded([
{
type: "approval_request",
requestId: "r1",
requestKind: "command",
prompt: "bun test --filter ocr",
options: [
{ decision: "accept", label: "Allow once" },
{ decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" },
{ decision: "decline", label: "Deny" },
],
} as BoundedThread["projection"]["turnItems"][number],
]),
thread({ pendingRuntimeRequest: { id: "r1", kind: "command", createdAt: "2026-11-01T09:00:00.000Z" } }),
);
expect(summary.request).toEqual({
id: "r1",
kind: "command",
prompt: "bun test --filter ocr",
reason: null,
options: [
{ decision: "accept", label: "Allow once" },
{ decision: "acceptAlways", label: "Always allow" },
{ decision: "decline", label: "Deny" },
],
questions: [],
});
});
it("offers Allow and Deny when the approval's turn item is not loaded yet", () => {
const summary = summarize(
bounded([]),
thread({ pendingRuntimeRequest: { id: "r9", kind: "file-change", createdAt: "2026-11-01T09:00:00.000Z" } }),
);
expect(summary.request?.options.map((o) => o.decision)).toEqual(["accept", "decline"]);
});
it("reads questions, using the label when an option has no value", () => {
const summary = summarize(
bounded([
{
type: "user_input_request",
requestId: "q1",
questions: [
{ id: "db", header: "Database", question: "Which database?", options: [{ label: "SQLite", description: "" }] },
],
} as BoundedThread["projection"]["turnItems"][number],
]),
thread({ pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: "2026-11-01T09:00:00.000Z" } }),
);
expect(summary.request?.questions).toEqual([
{ id: "db", header: "Database", question: "Which database?", options: [{ label: "SQLite", value: "SQLite" }], allowCustomAnswer: false },
]);
});
});