import { z } from "zod"; import { sha256, type JsonObject } from "../core/json.js"; /** * Working-document workbench contracts. * * A working document is an operator-owned editable document that lives in the * shared `documentVersions`/`documents` tables under its own source namespace. * It is a separate kind from observed filesystem versions: editing it never * touches a file on disk. See `spec/documents.md`. */ export const WORKBENCH_DOCUMENT_SOURCE = "workbench:documents"; export const WORKBENCH_CONTEXT_SOURCE = "context:workbench"; export const WORKBENCH_DOCUMENT_CONTENT_TYPE = "text/markdown"; export const WORKBENCH_SCHEMA_VERSION = 1; export const WORKBENCH_RUNTIME = "thoughtstream-workbench-v1"; export const DOCUMENT_CREATED_EVENT_TYPE = "stream.thought.workbench.document.created"; export const DOCUMENT_VERSION_EVENT_TYPE = "stream.thought.workbench.document.version"; export const CONTEXT_SELECTED_EVENT_TYPE = "stream.thought.workbench.context.selected"; export const PROPOSAL_REQUESTED_EVENT_TYPE = "stream.thought.workbench.proposal.requested"; export const PROPOSAL_PROPOSED_EVENT_TYPE = "stream.thought.workbench.proposal.proposed"; export const PROPOSAL_DECISION_EVENT_TYPE = "stream.thought.workbench.proposal.decision"; export const WORKBENCH_EVENT_TYPES = [ DOCUMENT_CREATED_EVENT_TYPE, DOCUMENT_VERSION_EVENT_TYPE, CONTEXT_SELECTED_EVENT_TYPE, PROPOSAL_REQUESTED_EVENT_TYPE, PROPOSAL_PROPOSED_EVENT_TYPE, PROPOSAL_DECISION_EVENT_TYPE, ] as const; export const WORKBENCH_JUDGMENT_CRITERION = "workbench-document-proposal"; export const WORKBENCH_JUDGMENT_CRITERION_VERSION = 1; export const MAX_DOCUMENT_TITLE_CHARS = 200; export const MAX_DOCUMENT_BODY_CHARS = 64_000; export const MAX_PROPOSED_TEXT_CHARS = MAX_DOCUMENT_BODY_CHARS; export const MAX_SELECTED_EVENTS = 32; export const MAX_SELECTED_VERSIONS = 16; export const MAX_EXCERPT_CHARS = 2_000; export const MAX_EVIDENCE_IDS = 16; const idSchema = z.string().min(1).max(500); const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/); const requestIdSchema = z.string().regex(/^[A-Za-z0-9_-]{8,128}$/); const uniqueIds = (max: number) => z.array(idSchema).max(max).superRefine((ids, context) => { if (new Set(ids).size !== ids.length) context.addIssue({ code: "custom", message: "Ids must be unique" }); }); export const VERSION_REASONS = ["created", "operator-edit", "proposal-accepted"] as const; export type VersionReason = typeof VERSION_REASONS[number]; export const documentCreatedPayloadSchema = z.object({ documentId: idSchema, title: z.string().min(1).max(MAX_DOCUMENT_TITLE_CHARS), originEventId: idSchema, versionId: idSchema, sha256: sha256Schema, requestId: requestIdSchema, }).strict(); export const documentVersionPayloadSchema = z.object({ documentId: idSchema, versionId: idSchema, baseVersionId: idSchema.nullable(), sha256: sha256Schema, sizeBytes: z.number().int().nonnegative(), reason: z.enum(VERSION_REASONS), proposalEventId: idSchema.optional(), decisionEventId: idSchema.optional(), requestId: requestIdSchema, }).strict().superRefine((value, context) => { if ((value.reason === "proposal-accepted") !== (value.proposalEventId !== undefined && value.decisionEventId !== undefined)) { context.addIssue({ code: "custom", path: ["reason"], message: "Only proposal-accepted versions carry proposal and decision references" }); } }); export const contextSelectedPayloadSchema = z.object({ documentId: idSchema, selectionId: idSchema, snapshotVersionId: idSchema, selectedEventIds: uniqueIds(MAX_SELECTED_EVENTS), selectedVersionIds: uniqueIds(MAX_SELECTED_VERSIONS), requestId: requestIdSchema, }).strict(); export const proposalRequestedPayloadSchema = z.object({ documentId: idSchema, baseVersionId: idSchema, baseSha256: sha256Schema, selectionId: idSchema, snapshotVersionId: idSchema, runnerId: z.string().min(1).max(200), requestId: requestIdSchema, }).strict(); const proposerSchema = z.object({ runnerId: z.string().min(1).max(200), runnerRevision: z.string().min(1).max(200), requestId: requestIdSchema, runId: idSchema.optional(), contextSnapshotId: idSchema, }).strict(); const proposalTargetSchema = z.object({ documentId: idSchema, baseVersionId: idSchema, baseSha256: sha256Schema, }).strict(); export const proposalProposedPayloadSchema = z.object({ proposalState: z.literal("runner-proposed"), proposer: proposerSchema, target: proposalTargetSchema, operation: z.literal("replace-document"), proposedText: z.string().min(1).max(MAX_PROPOSED_TEXT_CHARS), proposedTextChars: z.number().int().positive().max(MAX_PROPOSED_TEXT_CHARS), proposedTextSha256: sha256Schema, reason: z.string().min(1).max(1_000), evidenceEventIds: uniqueIds(MAX_EVIDENCE_IDS), publicationEligible: z.literal(false), }).strict().superRefine((value, context) => { if (value.proposedText.length !== value.proposedTextChars) { context.addIssue({ code: "custom", path: ["proposedTextChars"], message: "Proposal text length does not match" }); } if (sha256(value.proposedText) !== value.proposedTextSha256) { context.addIssue({ code: "custom", path: ["proposedTextSha256"], message: "Proposal text hash does not match" }); } }); export const proposalDecisionPayloadSchema = z.object({ proposalEventId: idSchema, disposition: z.enum(["accept", "reject"]), submissionId: requestIdSchema, authority: z.literal("human"), resultVersionId: idSchema.optional(), }).strict().superRefine((value, context) => { if ((value.disposition === "accept") !== (value.resultVersionId !== undefined)) { context.addIssue({ code: "custom", path: ["resultVersionId"], message: "Only accepted decisions name a result version" }); } }); export type DocumentCreatedPayload = z.infer; export type DocumentVersionPayload = z.infer; export type ContextSelectedPayload = z.infer; export type ProposalRequestedPayload = z.infer; export type ProposalProposedPayload = z.infer; export type WorkbenchProposalDecisionPayload = z.infer; /** Browser/API request bodies. Every mutation carries a client-generated request or submission id. */ export const createDocumentRequestSchema = z.object({ originEventId: idSchema, title: z.string().min(1).max(MAX_DOCUMENT_TITLE_CHARS), body: z.string().max(MAX_DOCUMENT_BODY_CHARS), requestId: requestIdSchema, }).strict(); export const saveEditRequestSchema = z.object({ baseVersionId: idSchema, title: z.string().min(1).max(MAX_DOCUMENT_TITLE_CHARS), body: z.string().max(MAX_DOCUMENT_BODY_CHARS), requestId: requestIdSchema, }).strict(); export const selectContextRequestSchema = z.object({ eventIds: uniqueIds(MAX_SELECTED_EVENTS), versionIds: uniqueIds(MAX_SELECTED_VERSIONS), requestId: requestIdSchema, }).strict(); export const requestProposalRequestSchema = z.object({ selectionId: idSchema, runnerId: z.string().min(1).max(200), requestId: requestIdSchema, }).strict(); export const decideProposalRequestSchema = z.object({ disposition: z.enum(["accept", "reject"]), submissionId: requestIdSchema, }).strict(); /** Snapshot content stored as one immutable JSON document version under `context:workbench`. */ export interface WorkbenchContextSnapshot { documentId: string; baseVersionId: string; selectedEvents: Array<{ eventId: string; type: string; source: string; occurredAt: string; payloadHash: string; excerpt: string; }>; selectedVersions: Array<{ documentId: string; versionId: string; sha256: string; path: string; }>; } export const workbenchContextSnapshotSchema = z.object({ documentId: idSchema, baseVersionId: idSchema, selectedEvents: z.array(z.object({ eventId: idSchema, type: z.string().min(1), source: z.string().min(1), occurredAt: z.string(), payloadHash: sha256Schema, excerpt: z.string().max(MAX_EXCERPT_CHARS), }).strict()).max(MAX_SELECTED_EVENTS), selectedVersions: z.array(z.object({ documentId: idSchema, versionId: idSchema, sha256: sha256Schema, path: z.string(), }).strict()).max(MAX_SELECTED_VERSIONS), }).strict(); /** * A version's content is self-describing: the title travels in frontmatter so * a version can be read without the mutable head projection. */ export function encodeDocumentContent(title: string, body: string): string { return `---\ntitle: ${JSON.stringify(title)}\n---\n${body}`; } export function decodeDocumentContent(content: string): { title: string; body: string } { const match = content.match(/^---\ntitle: (.*)\n---\n([\s\S]*)$/); if (!match) return { title: "Untitled", body: content }; let title: string; try { const parsed: unknown = JSON.parse(match[1]!); title = typeof parsed === "string" && parsed.length > 0 ? parsed : "Untitled"; } catch { title = "Untitled"; } return { title, body: match[2]! }; } /** Bounded plain-text rendering of an event payload for context snapshots. Never HTML. */ export function plainExcerpt(payload: JsonObject, maxChars = MAX_EXCERPT_CHARS): string { const parts: string[] = []; for (const key of ["subject", "title", "text", "body", "summary", "preview", "path"]) { const value = payload[key]; if (typeof value === "string" && value.trim()) parts.push(value.trim()); } const record = payload.record; if (record && typeof record === "object" && !Array.isArray(record) && typeof record.text === "string" && record.text.trim()) { parts.push(record.text.trim()); } const text = parts.length > 0 ? parts.join("\n") : JSON.stringify(payload); const normalized = text.replace(/\r\n?/g, "\n").replace(/[- -]/g, ""); return normalized.length > maxChars ? `${normalized.slice(0, maxChars - 1)}…` : normalized; }