Gateway: multi-select answers and a summary endpoint for the complication
This commit is contained in:
1 parent
69858d4719
commit
830bf4f139
5 files changed
+98
-7
No files matched your search
+13
-2
@@ -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<string, string | string[]> =>
|
||||
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<T>(req: Request): Promise<Partial<T> & T> {
|
||||
try {
|
||||
return (await req.json()) as Partial<T> & T;
|
||||
|
||||
+13
-1
@@ -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<string, string> };
|
||||
/** Per question id: an option value, a typed answer, or for multi-select questions an array of option values. */
|
||||
| { requestId: string; answers: Record<string, string | string[]> };
|
||||
|
||||
export interface NewThreadRequest {
|
||||
hostId: string;
|
||||
|
||||
+26
-1
@@ -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<Feed> {
|
||||
const candidates: Array<{ host: T3Host; thread: ThreadShell; phase: Phase }> = [];
|
||||
|
||||
@@ -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> = {}): 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user