/** * Questions, as a room offers them. * * Two things have to stay in step: the record that owns an answer, and the * plan document that shows it. The record is authoritative — an agent * rewriting the prose around a decision cannot change the decision — but a * plan that does not show its own answers is not much of a plan. * * So resolving is ordered rather than parallel. The answer is claimed, written * into the document, and only then committed. If the document write fails the * claim is rolled back and the questionnaire is still open, which is a state * everyone already knows how to render. */ import { ulid } from "@chopin/dialect"; import * as Question from "@chopin/question"; import * as Anchors from "./anchors"; import * as room from "../plan/room"; import * as Store from "./store"; import { broadcast, relay, reply, tell } from "../wire"; import type { Server } from "bun"; // `Plan` is the room's plan here; the protocol namespace of the same name is // aliased so the two cannot be confused at a glance. import type { Plan as Wired, Question as Wire, Request } from "@chopin/protocol"; import type { Answer, Definition } from "@chopin/question"; import * as Service from "../plan/service"; import type { Plan } from "../plan/service"; import type { Socket, SocketData } from "../wire"; import type { Ended, Questions } from "./store"; export type { Questions } from "./store"; export const create = Store.create; /** * Validate a questionnaire and give it durable identity. * * The domain assigns positional ids — `q0`, `o1` — which are fine while a * questionnaire is a single tool result and useless the moment it is a node in * a document the agent will rewrite around. Identity is minted once, here, * before anything references it. */ export function identify(raw: unknown): Definition { let definition = Question.normalize(raw); return { questions: definition.questions.map(question => ({ ...question, id: ulid(), options: question.options.map(option => ({ ...option, id: ulid() })), })), }; } /** A questionnaire as it is stored beside the plan, so it survives a restart. */ export type Record = { id: string; definition: Definition; status: "open" | "answered" | "cancelled"; /** Question id to the answer as it reads, for projection into the plan. */ answers?: { [question: string]: string }; resolver?: string; /** When it was settled, Unix seconds. Absent on one settled before we recorded it. */ at?: number; /** Where in the prose each of its decisions lives. */ anchors?: Wired.WidgetAnchors; }; function decide( entry: { status: "answered"; answers: Answer[] } | { status: "cancelled" }, definition: Definition, ): { [question: string]: string } { if (entry.status !== "answered") return {}; let out: { [question: string]: string } = {}; definition.questions.forEach((question, index) => { let answer = entry.answers[index]; if (answer) out[question.id] = Question.summarize(answer); }); return out; } /** * Ask a questionnaire, and put it in the plan. * * The record is registered before the node exists, because a client receiving * the plan update will immediately try to open a questionnaire by id and must * not be told there is no such thing. */ export async function ask( plan: Plan, server: Server, roomId: string, definition: Definition, ): Promise { if (definition.questions.length === 0) { Question.reject("A questionnaire needs at least one question"); } // Its own identity, not borrowed from its first question. They are // different things, and a lookup that matched either would be a bug // waiting for a questionnaire with two questions. let id = ulid(); let waiting = Store.ask(plan.questions, id, definition, id); // The dialect calls the question text `prompt`; the domain calls it // `question`. Translating here keeps the document's vocabulary its own. let mutation = room.insertQuestionnaire(plan.document, { id, questions: definition.questions.map(question => ({ id: question.id, header: question.header, prompt: question.question, multiple: question.multiple, options: question.options.map(option => ({ id: option.id, label: option.label, ...(option.description ? { description: option.description } : {}), })), })), }); plan.records.set(id, { id, definition, status: "open" }); if (mutation) Service.publish(plan, server, roomId, mutation); broadcast(server, roomId, { kind: "question:asked", ts: 0, id, definition, widget: id }); return waiting; } /** Everything still unanswered, for a client that has just joined. */ export function greet(plan: Plan, ws: Socket): void { let open = Store.outstanding(plan.questions); if (open.length === 0) return; tell(ws, { kind: "question:sync", ts: 0, open }); } export function open(plan: Plan, ws: Socket, msg: Request): void { reply(ws, msg.rid, { kind: "question:open", ts: 0, ...Store.snapshot(plan.questions, msg.id) }); } export function edit(plan: Plan, ws: Socket, msg: Request): void { let outcome = Store.edit(plan.questions, msg.id, msg.patch); reply(ws, msg.rid, { kind: "question:edit", ts: 0, id: msg.id, ...outcome }); // A no-op patch is acknowledged but not relayed: peers have nothing to do // with it and it did not move the revision. if (!outcome.open || !outcome.accepted || !outcome.applied) return; relay(ws, { kind: "question:edit", ts: 0, id: msg.id, open: true, accepted: true, applied: true, revision: outcome.revision, patch: msg.patch, editor: ws.data.handle, }); } export function focus(plan: Plan, ws: Socket, msg: Wire.Presence.Input): void { let person = { client: ws.data.client, handle: ws.data.handle, ...(msg.question ? { question: msg.question } : {}), ...(msg.field ? { field: msg.field } : {}), }; if (!Store.focus(plan.questions, msg.id, person)) return; relay(ws, { kind: "question:presence", ts: 0, ...person, id: msg.id }); } export function away(plan: Plan, ws: Socket): void { for (let id of Store.away(plan.questions, ws.data.client)) { relay(ws, { kind: "question:presence", ts: 0, id, client: ws.data.client, handle: ws.data.handle, }); } } export async function submit( plan: Plan, server: Server, roomId: string, ws: Socket, msg: Request, ): Promise { let claimed = Store.claimSubmit(plan.questions, msg.id, msg.revision, ws.data.handle); if (!claimed.ok) { return reply(ws, msg.rid, { kind: "question:submit", ts: 0, id: msg.id, ...claimed }); } let record = plan.records.get(msg.id); let answers = record ? decide({ status: "answered", answers: claimed.answers }, record.definition) : {}; // Stamped once and used in both places, so the record and the plan cannot // disagree about when the room decided. let at = Math.floor(Date.now() / 1_000); let settled = { by: ws.data.handle, at: new Date(at * 1_000).toISOString() }; try { let mutation = room.projectAnswer(plan.document, msg.id, answers, settled); if (mutation) Service.publish(plan, server, roomId, mutation); } catch (err) { // The decision is not final if the plan could not be told about it. Store.rollback(plan.questions, claimed.claim); return reply(ws, msg.rid, { kind: "question:submit", ts: 0, id: msg.id, ok: false, reason: "invalid", message: err instanceof Error ? err.message : "could not record the answer", }); } Store.commit(plan.questions, claimed.claim); if (record) { plan.records.set(msg.id, { ...record, status: "answered", answers, resolver: ws.data.handle, at, }); } plan.sink.touch(); reply(ws, msg.rid, { kind: "question:submit", ts: 0, id: msg.id, ok: true, answers: claimed.answers, resolver: ws.data.handle, }); broadcast(server, roomId, { kind: "question:resolved", ts: 0, id: msg.id, status: "answered", resolver: ws.data.handle, answers: claimed.answers, }); } /** * Decline to answer. * * Terminal, and the node leaves the plan: a questionnaire cannot say it was * cancelled, so left in place it would read as one still waiting. The record * survives as history. */ export async function cancel( plan: Plan, server: Server, roomId: string, ws: Socket, msg: Request, ): Promise { let claimed = Store.claimCancel(plan.questions, msg.id, ws.data.handle); if (!claimed.ok) { return reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...claimed }); } try { let mutation = room.removeQuestionnaire(plan.document, msg.id); if (mutation) Service.publish(plan, server, roomId, mutation); } catch (err) { // The questionnaire stays open and answerable, which is a state every // client already renders. Saying "resolving" is honest: the attempt is // over, and trying again is the right move. console.error("[questions] could not remove the node:", err); Store.rollback(plan.questions, claimed.claim); return reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ok: false, reason: "resolving", }); } Store.commit(plan.questions, claimed.claim); let record = plan.records.get(msg.id); if (record) { plan.records.set(msg.id, { ...record, status: "cancelled", resolver: ws.data.handle }); } plan.sink.touch(); reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ok: true, resolver: ws.data.handle, }); broadcast(server, roomId, { kind: "question:resolved", ts: 0, id: msg.id, status: "cancelled", resolver: ws.data.handle, }); } export function shutdown(questions: Questions): void { Store.shutdown(questions); } // -- relationships --------------------------------------------------------- /** Every questionnaire's relationships, as an authoritative snapshot. */ export function anchors(plan: Plan): Wired.WidgetAnchors[] { return [...plan.records.values()].map(record => Anchors.read(record)); } /** * Bring every relationship forward onto the document as it is now. * * Called after anything that moves prose around, which is most things. An * anchor whose block survived is re-expressed against it; one whose block was * rewritten is orphaned, and the agent is told it owes a review. */ export function rebase(plan: Plan): void { for (let [id, record] of plan.records) { let value = Anchors.read(record); let questions: { [question: string]: Wired.AnchorSet } = {}; for (let [question, set] of Object.entries(value.questions)) { questions[question] = carry(plan, set); } plan.records.set(id, { ...record, anchors: { widget: value.widget, questions } }); } } function carry(plan: Plan, set: Wired.AnchorSet): Wired.AnchorSet { let anchors = room.rebase(plan.document, set.anchors); let lost = anchors.some(anchor => anchor.orphaned); return { anchors, pending: set.pending || lost, ...(lost ? { reason: "orphaned" as const } : set.reason ? { reason: set.reason } : {}), }; } /** Mark answered results as needing review, after the plan changed beneath them. */ export function invalidate(plan: Plan, reason: Wired.AnchorReason): void { for (let [id, record] of plan.records) { plan.records.set(id, { ...record, anchors: Anchors.invalidate(record, reason) }); } } /** What the agent still owes a review on. */ export function outstanding(plan: Plan): Anchors.Pending[] { return [...plan.records.values()].flatMap(record => Anchors.pending(record)); } /** Replace where a decision lives with the blocks the agent reviewed it against. */ export function relate( plan: Plan, widget: string, question: string, blocks: Array<{ index: number; digest: string }>, ): string | undefined { let record = plan.records.get(widget); if (!record) return `no questionnaire ${widget}`; let current = room.digests(plan.document); let anchors: Wired.Anchor[] = []; for (let block of blocks) { let hash = current[block.index]; if (!hash) return `no block at index ${block.index}`; // The digest is how the agent says which block it meant. If it does not // match, the plan moved between reading and anchoring, and quietly // anchoring the block that happens to be there now would be worse than // saying so. if (hash !== block.digest) return `block ${block.index} has changed; read the plan again`; anchors.push(room.anchorAt(plan.document, block.index, hash)); } plan.records.set(widget, { ...record, anchors: Anchors.set(Anchors.read(record), question, anchors), }); return undefined; }