From 830bf4f139823214bd2bb8e8a6a7eba6290f2d32 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sun, 1 Nov 2026 10:49:36 -0500 Subject: [PATCH] Gateway: multi-select answers and a summary endpoint for the complication --- docs/api.md | 18 +++++++++++++++++- gateway/src/api.ts | 15 +++++++++++++-- gateway/src/contract.ts | 14 +++++++++++++- gateway/src/views.ts | 27 ++++++++++++++++++++++++++- gateway/test/views.test.ts | 31 +++++++++++++++++++++++++++++-- 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/docs/api.md b/docs/api.md index 1d4230f..dcd164f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -89,7 +89,7 @@ PendingRequest { An option may also carry `warning`, a short note about a risky choice such as "Applies to every thread". For questions (`kind: "user_input"`), `options` is empty and `questions` lists -`{ id, header, question, options: [{ label, value }], allowCustomAnswer }`. +`{ id, header, question, options: [{ label, value }], allowCustomAnswer, multiSelect }`. ## Thread @@ -118,11 +118,27 @@ or 202 ``` +Each answer is an option `value` or a typed answer. For a question with `multiSelect: true`, send an +array of option values instead, for example `{ "": ["sqlite", "postgres"] }`. + ``` POST /v1/threads/:hostId/:threadId/stop 202 ``` +## Summary + +The counts behind the watch face complication. It reads no thread pages, so it is cheap to call. + +``` +GET /v1/summary + +200 { "needsYou": 1, "working": 3, "hostsOffline": 0, + "next": { "hostId": "...", "threadId": "...", "title": "Receipt OCR", "hostLabel": "Mac mini" } } +``` + +`next` is the thread that has waited longest for you, or null. + ## New thread ``` diff --git a/gateway/src/api.ts b/gateway/src/api.ts index 8ba7467..431cc56 100644 --- a/gateway/src/api.ts +++ b/gateway/src/api.ts @@ -18,7 +18,7 @@ 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"; +import { buildFeed, buildSummary, buildThreadDetail, hostSummary, type SummaryCache } from "./views.ts"; export interface ApiDeps { db: Db; @@ -135,6 +135,10 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) { GET: authed(async () => json(await buildFeed(hub.list(), cache))), }, + "/v1/summary": { + GET: authed(() => json(buildSummary(hub.list()))), + }, + "/v1/projects": { GET: authed(() => json({ @@ -211,7 +215,7 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) { 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") { + } else if ("answers" in body && isAnswers(body.answers)) { await host.dispatch({ ...base, answers: body.answers }); } else { throw new HttpError(400, "Send a decision or answers."); @@ -242,6 +246,13 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) { }; } +const isAnswers = (value: unknown): value is Record => + typeof value === "object" && + value !== null && + Object.values(value).every( + (answer) => typeof answer === "string" || (Array.isArray(answer) && answer.every((item) => typeof item === "string")), + ); + async function readBody(req: Request): Promise & T> { try { return (await req.json()) as Partial & T; diff --git a/gateway/src/contract.ts b/gateway/src/contract.ts index 7ab2a61..6f8d58e 100644 --- a/gateway/src/contract.ts +++ b/gateway/src/contract.ts @@ -30,6 +30,8 @@ export interface PendingRequest { question: string; options: Array<{ label: string; value: string }>; allowCustomAnswer: boolean; + /** When true, answer with an array of option values. */ + multiSelect: boolean; }>; } @@ -47,6 +49,15 @@ export interface FeedItem { request: PendingRequest | null; } +/** Counts for the watch face complication. Cheap: no thread pages are read. */ +export interface Summary { + needsYou: number; + working: number; + /** The thread that has waited longest for you, if any. */ + next: { hostId: string; threadId: string; title: string; hostLabel: string } | null; + hostsOffline: number; +} + export interface Feed { /** Waiting for approval or input. Oldest request first. */ needsYou: FeedItem[]; @@ -106,7 +117,8 @@ export interface SendMessageRequest { export type RespondRequest = | { requestId: string; decision: ApprovalDecision } - | { requestId: string; answers: Record }; + /** Per question id: an option value, a typed answer, or for multi-select questions an array of option values. */ + | { requestId: string; answers: Record }; export interface NewThreadRequest { hostId: string; diff --git a/gateway/src/views.ts b/gateway/src/views.ts index e2097a5..82e836f 100644 --- a/gateway/src/views.ts +++ b/gateway/src/views.ts @@ -4,7 +4,7 @@ // 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 { Feed, FeedItem, HostSummary, Message, PendingRequest, Summary, ThreadDetail } from "./contract.ts"; import type { T3Host } from "./t3/host.ts"; import { isApprovalRequest, @@ -80,6 +80,7 @@ function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projectio question: question.question, options: question.options.map((option) => ({ label: option.label, value: option.value ?? option.label })), allowCustomAnswer: question.allowCustomAnswer ?? false, + multiSelect: question.multiSelect ?? false, })), }; } @@ -155,6 +156,30 @@ function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: Thre }; } +/** Counts for the complication, from thread shells alone. */ +export function buildSummary(hosts: T3Host[]): Summary { + let needsYou = 0; + let working = 0; + let next: { host: T3Host; thread: ThreadShell } | null = null; + for (const host of hosts) { + for (const thread of host.threads.values()) { + if (thread.archivedAt || thread.deletedAt) continue; + const phase = threadPhase(thread); + if (phase === "starting" || phase === "running") working++; + if (phase !== "waiting_for_approval" && phase !== "waiting_for_input") continue; + needsYou++; + const waitingSince = thread.pendingRuntimeRequest?.createdAt ?? ""; + if (!next || waitingSince < (next.thread.pendingRuntimeRequest?.createdAt ?? "")) next = { host, thread }; + } + } + return { + needsYou, + working, + next: next && { hostId: next.host.id, threadId: next.thread.id, title: next.thread.title, hostLabel: next.host.label }, + hostsOffline: hosts.filter((host) => host.status !== "online").length, + }; +} + /** 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 { const candidates: Array<{ host: T3Host; thread: ThreadShell; phase: Phase }> = []; diff --git a/gateway/test/views.test.ts b/gateway/test/views.test.ts index 3effe33..0fd303d 100644 --- a/gateway/test/views.test.ts +++ b/gateway/test/views.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "bun:test"; import type { BoundedThread, ThreadShell } from "../src/t3/types.ts"; -import { summarize, trimText } from "../src/views.ts"; +import type { T3Host } from "../src/t3/host.ts"; +import { buildSummary, summarize, trimText } from "../src/views.ts"; const thread = (overrides: Partial = {}): ThreadShell => ({ id: "thread-1", @@ -98,7 +99,33 @@ describe("summarize", () => { 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 }, + { id: "db", header: "Database", question: "Which database?", options: [{ label: "SQLite", value: "SQLite" }], allowCustomAnswer: false, multiSelect: false }, ]); }); }); + +describe("buildSummary", () => { + const at = (minute: number) => `2026-11-01T09:${String(minute).padStart(2, "0")}:00.000Z`; + const host = (label: string, status: string, threads: ThreadShell[]) => + ({ id: label, label, status, threads: new Map(threads.map((t) => [t.id, t])) }) as unknown as T3Host; + + it("counts waiting and working threads and names the one waiting longest", () => { + const summary = buildSummary([ + host("Mac mini", "online", [ + thread({ id: "a", title: "Newer ask", pendingRuntimeRequest: { id: "r1", kind: "command", createdAt: at(30) } }), + thread({ id: "b", status: "running" }), + thread({ id: "c", status: "completed" }), + thread({ id: "d", status: "running", lineage: { relationshipToParent: "subagent" } }), + ]), + host("LittleMan", "offline", [ + thread({ id: "e", title: "Older ask", pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: at(5) } }), + ]), + ]); + expect(summary).toEqual({ + needsYou: 2, + working: 1, + next: { hostId: "LittleMan", threadId: "e", title: "Older ask", hostLabel: "LittleMan" }, + hostsOffline: 1, + }); + }); +});