import { UpdateQuestionPayloadSchema, type LocalQuestionImage, type StagedQuestionImage, type AskBatchReceipt, type AskOption, type AskReceipt, type AskResult, type OwnerIdentity, type UpdateQuestionPayload } from "../protocol.js"; import type { AskPostboxBatchInput, AskPostboxInput } from "./askPostbox.js"; export const WRITE_QUESTION_ACTIONS = [ "create", "create_batch", "revise", "cancel", "supersede", "reparent", "transfer", "takeover" ] as const; export type WriteQuestionAction = typeof WRITE_QUESTION_ACTIONS[number]; export interface WriteQuestionDraftInput { images?: LocalQuestionImage[]; localRef: string; question: string; ambiguity: string; options: AskOption[]; mode?: "single" | "multi"; requestId?: string; parentQuestionId?: string; parentLocalRef?: string; } export interface WriteQuestionInput { images?: LocalQuestionImage[]; action: WriteQuestionAction; questionId?: string; question?: string; ambiguity?: string; options?: AskOption[]; mode?: "single" | "multi"; requestId?: string; parentQuestionId?: string | null; questions?: WriteQuestionDraftInput[]; expectedRevision?: number; expectedOwnerRevision?: number; note?: string; replacementQuestionId?: string; expectedOwner?: OwnerIdentity; owner?: OwnerIdentity; } const optionParameters = { type: "object", additionalProperties: false, required: ["value", "label"], properties: { value: { type: "string", minLength: 1 }, label: { type: "string", minLength: 1 }, description: { type: "string", minLength: 1 }, impact: { type: "string", minLength: 1 } } } as const; const ownerParameters = { type: "object", additionalProperties: false, required: ["harness", "ownerId"], properties: { harness: { type: "string", minLength: 1 }, ownerId: { type: "string", minLength: 1 } } } as const; const imageParameters = { type: "array", maxItems: 8, description: "Local gallery. Relative paths use Pi cwd. Revise: omitted preserves, [] removes.", items: { type: "object", additionalProperties: false, required: ["path", "alt"], properties: { path: { type: "string", minLength: 1, maxLength: 4000 }, alt: { type: "string", minLength: 1, maxLength: 2000, pattern: "\\S" }, caption: { type: "string", maxLength: 2000 } } } } as const; const draftProperties = { images: imageParameters, question: { type: "string", minLength: 1 }, ambiguity: { type: "string", minLength: 1 }, options: { type: "array", minItems: 1, maxItems: 20, items: optionParameters, description: "Create requires; revise optionally replaces." }, mode: { type: "string", enum: ["single", "multi"] }, requestId: { type: "string", minLength: 1 }, parentQuestionId: { type: ["string", "null"] } } as const; export const writeQuestionParameters = { type: "object", additionalProperties: false, required: ["action"], description: "Create or change Postbox Questions. Create actions return current Question handles. Existing-Question actions require questionId plus both current revision tokens; action-specific fields are strictly validated.", properties: { action: { type: "string", enum: WRITE_QUESTION_ACTIONS, description: "Operation to perform. create_batch uses questions; every other action targets one Question." }, questionId: { type: "string", minLength: 1 }, ...draftProperties, questions: { type: "array", minItems: 1, items: { type: "object", additionalProperties: false, required: ["localRef", "question", "ambiguity", "options"], properties: { images: imageParameters, question: draftProperties.question, ambiguity: draftProperties.ambiguity, options: draftProperties.options, mode: draftProperties.mode, requestId: draftProperties.requestId, parentQuestionId: { type: "string", minLength: 1 }, localRef: { type: "string", minLength: 1 }, parentLocalRef: { type: "string", minLength: 1 } } } }, expectedRevision: { type: "integer", minimum: 1 }, expectedOwnerRevision: { type: "integer", minimum: 1 }, note: { type: "string" }, replacementQuestionId: { type: "string", minLength: 1 }, expectedOwner: { ...ownerParameters }, owner: { ...ownerParameters } } } as const; const CREATE_FIELDS = ["action", "images", "question", "ambiguity", "options", "mode", "requestId", "parentQuestionId"] as const; const BATCH_FIELDS = ["action", "questions"] as const; const UPDATE_BASE_FIELDS = ["action", "questionId", "expectedRevision", "expectedOwnerRevision"] as const; const BATCH_DRAFT_FIELDS = ["localRef", "images", "question", "ambiguity", "options", "mode", "requestId", "parentQuestionId", "parentLocalRef"] as const; function assertOnlyFields(value: Record, allowed: readonly string[], action: string): void { const invalid = Object.keys(value).find((field) => !allowed.includes(field)); if (invalid) throw new Error(`write_question action ${action} does not accept ${invalid}`); } function requireField(value: unknown, field: string, action: string): void { if (value === undefined || value === null || (typeof value === "string" && value.trim().length === 0)) { throw new Error(`write_question action ${action} requires ${field}`); } } export function toAskPostboxInput(input: WriteQuestionInput): AskPostboxInput | AskPostboxBatchInput { if (input.action === "create") { assertOnlyFields(input as unknown as Record, CREATE_FIELDS, input.action); requireField(input.question, "question", input.action); requireField(input.ambiguity, "ambiguity", input.action); requireField(input.options, "options", input.action); return { images: input.images, question: input.question!, ambiguity: input.ambiguity!, options: input.options!, ...(input.mode ? { mode: input.mode } : {}), ...(input.requestId ? { requestId: input.requestId } : {}), ...(typeof input.parentQuestionId === "string" ? { parentQuestionId: input.parentQuestionId } : {}) }; } if (input.action !== "create_batch") throw new Error(`write_question action ${input.action} is not a create action`); assertOnlyFields(input as unknown as Record, BATCH_FIELDS, input.action); if (!Array.isArray(input.questions) || input.questions.length === 0) requireField(input.questions, "questions", input.action); input.questions!.forEach((draft, index) => { assertOnlyFields(draft as unknown as Record, BATCH_DRAFT_FIELDS, `${input.action} questions[${index}]`); for (const field of ["localRef", "question", "ambiguity", "options"] as const) requireField(draft[field], field, `${input.action} questions[${index}]`); }); return { mode: "batch", questions: input.questions! }; } export function toQuestionUpdateRequest(input: WriteQuestionInput, images?: StagedQuestionImage[]): { questionId: string; update: UpdateQuestionPayload } { if (input.action === "create" || input.action === "create_batch") { throw new Error(`write_question action ${input.action} is not an existing-Question action`); } requireField(input.questionId, "questionId", input.action); requireField(input.expectedRevision, "expectedRevision", input.action); requireField(input.expectedOwnerRevision, "expectedOwnerRevision", input.action); let update: unknown; if (input.action === "revise") { assertOnlyFields(input as unknown as Record, [...UPDATE_BASE_FIELDS, "question", "ambiguity", "options", "images"], input.action); requireField(input.question, "question", input.action); requireField(input.ambiguity, "ambiguity", input.action); update = { action: input.action, expectedRevision: input.expectedRevision, expectedOwnerRevision: input.expectedOwnerRevision, question: { prompt: input.question, ambiguity: input.ambiguity }, ...(images !== undefined ? { images } : {}), ...(input.options ? { options: input.options } : {}) }; } else if (input.action === "cancel") { assertOnlyFields(input as unknown as Record, [...UPDATE_BASE_FIELDS, "note"], input.action); update = { action: input.action, expectedRevision: input.expectedRevision, expectedOwnerRevision: input.expectedOwnerRevision, ...(input.note ? { note: input.note } : {}) }; } else if (input.action === "supersede") { assertOnlyFields(input as unknown as Record, [...UPDATE_BASE_FIELDS, "replacementQuestionId"], input.action); requireField(input.replacementQuestionId, "replacementQuestionId", input.action); update = { action: input.action, expectedRevision: input.expectedRevision, expectedOwnerRevision: input.expectedOwnerRevision, replacementQuestionId: input.replacementQuestionId }; } else if (input.action === "reparent") { assertOnlyFields(input as unknown as Record, [...UPDATE_BASE_FIELDS, "parentQuestionId"], input.action); if (input.parentQuestionId === undefined) requireField(input.parentQuestionId, "parentQuestionId", input.action); update = { action: input.action, expectedRevision: input.expectedRevision, expectedOwnerRevision: input.expectedOwnerRevision, parentQuestionId: input.parentQuestionId }; } else if (input.action === "transfer") { assertOnlyFields(input as unknown as Record, [...UPDATE_BASE_FIELDS, "expectedOwner", "owner"], input.action); requireField(input.expectedOwner, "expectedOwner", input.action); requireField(input.owner, "owner", input.action); update = { action: input.action, expectedRevision: input.expectedRevision, expectedOwnerRevision: input.expectedOwnerRevision, expectedOwner: input.expectedOwner, owner: input.owner }; } else { assertOnlyFields(input as unknown as Record, [...UPDATE_BASE_FIELDS, "expectedOwner"], input.action); requireField(input.expectedOwner, "expectedOwner", input.action); update = { action: input.action, expectedRevision: input.expectedRevision, expectedOwnerRevision: input.expectedOwnerRevision, expectedOwner: input.expectedOwner }; } return { questionId: input.questionId!, update: UpdateQuestionPayloadSchema.parse(update) }; } export function normalizeWriteQuestionResult( action: WriteQuestionAction, result: AskReceipt | AskBatchReceipt | AskResult | Record ): Record { if (action === "create_batch") { const receipt = result as AskBatchReceipt; return { action, batchStatus: receipt.status, items: receipt.items.map((item) => item.status === "created" ? { localRef: item.localRef, questionId: item.questionId, revision: item.revision, ownerRevision: item.ownerRevision, status: item.questionStatus, disposition: item.disposition } : { localRef: item.localRef, status: "rejected", reason: item.reason.code }) }; } if (action === "create") { if ("questionId" in result) { const receipt = result as AskReceipt; return { action, questionId: receipt.questionId, revision: receipt.revision, ownerRevision: receipt.ownerRevision, status: receipt.status, disposition: receipt.disposition }; } return { action, ...(result as Record) }; } const value = result as Record; const normalized: Record = { action }; for (const key of [ "questionId", "revision", "ownerRevision", "status", "parentQuestionId", "replacementQuestionId", "owner", "creator" ]) { if (value[key] !== undefined) normalized[key] = value[key]; } return normalized; }