diff --git a/backend/domain/graph.py b/backend/domain/graph.py index 317da424..810c9287 100644 --- a/backend/domain/graph.py +++ b/backend/domain/graph.py @@ -5,6 +5,10 @@ _content_parts_wire helper scene_payload calls) with exactly ONE deliberate edit, called out below; everything else is verbatim. +The per-node wire row builder (_node_wire's body, _content_parts_wire and +the Review Lens row builders) has since moved to backend/domain/node_wire.py, +where the row became sparse - see that module's docstring. + The one edit: SceneDocument.add_session_tokens used to call backend.token_counter.estimate_tokens, but backend/token_counter.py imports backend.events at module level, which tests/test_domain_purity.py @@ -34,7 +38,6 @@ from collections import deque import itertools -import json import logging from dataclasses import dataclass, field from typing import Any, Callable @@ -49,7 +52,6 @@ from backend.domain.branches import BranchOps from backend.domain.commands import CommandOps -from backend.domain.content_codec import _content_codec from backend.domain.groups import GroupOps from backend.domain.layout import LayoutOps from backend.domain.model import ( @@ -65,25 +67,8 @@ SceneNode, ) from backend.domain.node_access import is_node_of, require_node -from backend.domain.node_states import ( - ArtifactState, - ChartState, - ChatState, - CodeReviewState, - CodeSandboxState, - CodeState, - ContainerState, - DocumentState, - FrameState, - GitlinkState, - HarnessState, - HtmlState, - ImageState, - NodeState, - NoteState, - PlanState, - WebResearchState, -) +from backend.domain.node_states import CodeState, FrameState, NodeState +from backend.domain.node_wire import node_wire from backend.domain.nodes_agent_runs import AgentRunOps from backend.domain.nodes_code_review import CodeReviewOps from backend.domain.nodes_content import ContentOps @@ -100,104 +85,6 @@ def _estimate_tokens(text: str) -> int: return TokenEstimator().count_tokens(text) -# -- Review Lens nested wire rows ------------------------------------------ -# -# These five builders exist because the Review Lens node stores its nested -# rows as the review engine's own snake_case dicts (graphlink_plugins/ -# review_lens/review_engine.py) while the wire contract - and the generated -# client validator built from it - is camelCase, like every other nested -# row on SceneNodeRow. scene_payload used to forward them with a bare -# `dict(row)`, which shipped `patch_truncated`/`previous_path`/ -# `group_title` where CodeReviewFileRow and CodeReviewWalkthroughGroupRow -# declare `patchTruncated`/`previousPath`/`groupTitle`. -# -# That was not a cosmetic mismatch. validateSceneState treats a missing -# required field as a hard error, and web_ui/src/lib/api-contract/ -# bindTopic.ts DROPS a snapshot that fails validation, so from the first -# successful PR fetch onward every scene snapshot for the whole session -# was rejected client-side - the canvas froze for every node, not just -# this one. Building each row explicitly (the `toolCalls` precedent -# below) fixes the casing AND guarantees each row carries exactly the -# contract's fields with the contract's types, so a row that reached the -# state from an old save file cannot put an unexpected key on the wire. -# -# The domain keeps snake_case on purpose: it is what the engine emits and -# what session_save.py has already written to every existing save file. -# The conversion belongs at the wire builder, the same place -# codeReviewScores is coerced to dict[str, str]. - - -def _code_review_file_wire(row: dict[str, Any]) -> dict[str, Any]: - wire: dict[str, Any] = { - "path": str(row.get("path", "")), - "status": str(row.get("status", "modified")), - "additions": _non_negative_wire_int(row.get("additions")), - "deletions": _non_negative_wire_int(row.get("deletions")), - # `patch` is DELIBERATELY not forwarded - see the caller's comment. - "patch": "", - "patchTruncated": bool(row.get("patch_truncated", False)), - } - # Genuinely absent (not "") for every non-rename, which is why - # CodeReviewFileRow.previousPath is the one Optional field on the row. - previous = str(row.get("previous_path", "") or "") - if previous: - wire["previousPath"] = previous - return wire - - -def _code_review_walkthrough_wire(row: dict[str, Any]) -> dict[str, Any]: - raw_paths = row.get("paths") - return { - "groupTitle": str(row.get("group_title", "")), - "paths": [str(path) for path in raw_paths] if isinstance(raw_paths, list) else [], - "explanation": str(row.get("explanation", "")), - } - - -def _code_review_finding_wire(row: dict[str, Any]) -> dict[str, Any]: - return { - "id": str(row.get("id", "")), - "severity": str(row.get("severity", "")), - "tier": str(row.get("tier", "")), - "category": str(row.get("category", "")), - "path": str(row.get("path", "")), - "line": _non_negative_wire_int(row.get("line")), - "title": str(row.get("title", "")), - "evidence": str(row.get("evidence", "")), - "impact": str(row.get("impact", "")), - "recommendation": str(row.get("recommendation", "")), - } - - -def _code_review_error_wire(row: dict[str, Any]) -> dict[str, Any]: - return { - "id": str(row.get("id", "")), - "severity": str(row.get("severity", "")), - "tier": str(row.get("tier", "")), - "kind": str(row.get("kind", "")), - "path": str(row.get("path", "")), - "line": _non_negative_wire_int(row.get("line")), - "title": str(row.get("title", "")), - "evidence": str(row.get("evidence", "")), - "fix": str(row.get("fix", "")), - } - - -def _code_review_qa_wire(row: dict[str, Any]) -> dict[str, Any]: - return { - "question": str(row.get("question", "")), - "answer": str(row.get("answer", "")), - } - - -def _non_negative_wire_int(value: Any) -> int: - """int for the wire, never raising. A row can reach the wire from a - hand-edited save file as well as from the engine, and a ValueError - here would fail the whole scene republish, not just one row.""" - try: - return max(0, int(value)) # type: ignore[call-overload] - except (TypeError, ValueError, OverflowError): - return 0 @@ -1000,490 +887,16 @@ def organize(self) -> None: # -- snapshots --------------------------------------------------------- def _node_wire(self, n: SceneNode) -> dict[str, Any]: - """The per-node wire shape - ADR-003 stage 3.4 factored this out of - scene_payload() verbatim (byte-identical output) so both the full - snapshot AND take_dirty_patch_ops's upsertNode ops build a node's - payload from exactly ONE place, rather than a second copy drifting - out of sync with this one field by field.""" - return { - "id": n.id, - "x": n.x, - "y": n.y, - "title": n.title, - "kind": n.kind, - "content": n.content, - "isUser": n.state.is_user if isinstance(n.state, ChatState) else False, - "isCollapsed": n.is_collapsed, - "code": n.state.code if isinstance(n.state, CodeState) else "", - "language": n.state.language if isinstance(n.state, CodeState) else "", - "attachmentKind": n.state.attachment_kind if isinstance(n.state, DocumentState) else "", - "filePath": n.state.file_path if isinstance(n.state, DocumentState) else "", - "mimeType": n.state.mime_type if isinstance(n.state, DocumentState) else "", - "durationSeconds": n.state.duration_seconds if isinstance(n.state, DocumentState) else None, - "byteSize": n.state.byte_size if isinstance(n.state, DocumentState) else None, - "previewLabel": n.state.preview_label if isinstance(n.state, DocumentState) else "", - "isDocked": n.is_docked, - "imageAssetId": n.state.image_asset_id if isinstance(n.state, ImageState) else "", - "history": [ - # ADR-006 stage 6.4: the projection stays a strict allow-list - # (never spread the raw dict - legacy entries can carry - # arbitrary keys), widened by exactly one optional marker: - # "incomplete" flags a partial assistant reply whose stream - # died (see append_conversation_assistant_message). - { - "role": m["role"], - "content": m["content"], - "incomplete": bool(m.get("incomplete", False)), - } - for m in n.history - ], - "pendingRequestId": n.pending_request_id, - # ADR-002 Workstream 1 ("Synthesize Branches") - see - # ChatState's own comment, backend/domain/node_states.py. - "provider": n.state.provider if isinstance(n.state, ChatState) else None, - "model": n.state.model if isinstance(n.state, ChatState) else None, - # ADR-018 stage 18.3: see ChatState's own comment on override_ - # provider/override_model_id for why this is a distinct pair - # from provider/model directly above (output provenance vs. - # input routing pin). - "overrideProvider": n.state.override_provider if isinstance(n.state, ChatState) else "", - "overrideModelId": n.state.override_model_id if isinstance(n.state, ChatState) else "", - # ADR-017 stage 17.5: see ChatState's own comment on - # index_into_knowledge. - "indexIntoKnowledge": n.state.index_into_knowledge if isinstance(n.state, ChatState) else False, - "isBranchSynthesis": n.state.is_branch_synthesis if isinstance(n.state, ChatState) else False, - "synthesisInstructions": ( - n.state.synthesis_instructions if isinstance(n.state, ChatState) else "" - ), - # ADR-002 Workstream 1 ("Branch status and lifecycle") - - # see ChatState's own comment/SceneDocument. - # final_deliverable_node_id's own comment. "active" (not - # "") is the correct non-chat fallback - it is - # branch_status's own real pre-migration default, not an - # empty placeholder. - "branchStatus": n.state.branch_status if isinstance(n.state, ChatState) else "active", - # ADR-006 stage 6.4 (H5): partial reply preserved after a dead - # stream - see ChatState.response_incomplete's own comment. - "responseIncomplete": ( - n.state.response_incomplete if isinstance(n.state, ChatState) else False - ), - # ADR-006 stage 6.8: provider-reported usage stamped on chat - # replies - see ChatState's own comment. Nullable, like - # provider/model above. - "promptTokens": n.state.prompt_tokens if isinstance(n.state, ChatState) else None, - "completionTokens": ( - n.state.completion_tokens if isinstance(n.state, ChatState) else None - ), - # ADR-016 stage 16.2: the cost snapshot taken when usage was - # stamped - see ChatState.estimated_cost_usd's own comment. - "estimatedCostUsd": ( - n.state.estimated_cost_usd if isinstance(n.state, ChatState) else None - ), - # ADR-007 stage 7.4: see ChatState.tool_invocations' own comment - # and ToolInvocationRow's own docstring (contracts/graphlink_ - # scene_payload.py) for why `arguments` is JSON-encoded here - # rather than passed through as a nested object. - "toolCalls": ( - [ - { - "id": str(call.get("id", "")), - "name": str(call.get("name", "")), - "argumentsJson": json.dumps(call.get("arguments") or {}, sort_keys=True), - "result": str(call.get("result", "")), - "isError": bool(call.get("is_error", False)), - } - for call in n.state.tool_invocations - ] - if isinstance(n.state, ChatState) - else [] - ), - "isFinalDeliverable": n.id == self.final_deliverable_node_id, - "researchStage": n.state.research_stage if isinstance(n.state, WebResearchState) else "", - "researchCompleted": n.state.research_completed if isinstance(n.state, WebResearchState) else 0, - "researchTotal": n.state.research_total if isinstance(n.state, WebResearchState) else 0, - "researchActiveSourceId": ( - n.state.research_active_source_id if isinstance(n.state, WebResearchState) else None - ), - "researchError": n.state.research_error if isinstance(n.state, WebResearchState) else "", - "researchResult": n.state.research_result if isinstance(n.state, WebResearchState) else None, - "researchRetainToKnowledge": ( - n.state.research_retain_to_knowledge if isinstance(n.state, WebResearchState) else False - ), - "artifactContent": n.state.artifact_content if isinstance(n.state, ArtifactState) else "", - "artifactError": n.state.artifact_error if isinstance(n.state, ArtifactState) else "", - "gitlinkRepo": n.state.gitlink_repo if isinstance(n.state, GitlinkState) else "", - "gitlinkBranch": n.state.gitlink_branch if isinstance(n.state, GitlinkState) else "", - "gitlinkScopeMode": ( - n.state.gitlink_scope_mode if isinstance(n.state, GitlinkState) else "selected" - ), - "gitlinkLocalRoot": n.state.gitlink_local_root if isinstance(n.state, GitlinkState) else "", - "gitlinkRepoFilePaths": ( - list(n.state.gitlink_repo_file_paths) if isinstance(n.state, GitlinkState) else [] - ), - "gitlinkSelectedPaths": ( - list(n.state.gitlink_selected_paths) if isinstance(n.state, GitlinkState) else [] - ), - "gitlinkTaskPrompt": n.state.gitlink_task_prompt if isinstance(n.state, GitlinkState) else "", - # gitlinkContextXml is DELIBERATELY OMITTED - see - # GitlinkState's own comment. Served on demand via the - # fetchGitlinkContext intent instead. - "gitlinkContextStats": ( - dict(n.state.gitlink_context_stats) if isinstance(n.state, GitlinkState) else {} - ), - "gitlinkContextSummary": ( - n.state.gitlink_context_summary if isinstance(n.state, GitlinkState) else "" - ), - # R5.3 post-review FIX 6: UNLIKE gitlinkContextXml (and - # unlike gitlink_change_local_root, never on the wire at - # all), this genuinely needs to be here - see - # GitlinkState's own comment for why gitlinkContextSummary - # alone cannot be trusted as a lazy-fetch-once cache key. - "gitlinkContextVersion": ( - n.state.gitlink_context_version if isinstance(n.state, GitlinkState) else 0 - ), - "gitlinkProposalMarkdown": ( - n.state.gitlink_proposal_markdown if isinstance(n.state, GitlinkState) else "" - ), - "gitlinkPendingChanges": ( - [dict(c) for c in n.state.gitlink_pending_changes] if isinstance(n.state, GitlinkState) else [] - ), - "gitlinkPreviewText": n.state.gitlink_preview_text if isinstance(n.state, GitlinkState) else "", - "gitlinkChangeFingerprint": ( - n.state.gitlink_change_fingerprint if isinstance(n.state, GitlinkState) else None - ), - "gitlinkChangeState": ( - n.state.gitlink_change_state if isinstance(n.state, GitlinkState) else "draft" - ), - "gitlinkError": n.state.gitlink_error if isinstance(n.state, GitlinkState) else "", - "codeReviewPrUrl": n.state.code_review_pr_url if isinstance(n.state, CodeReviewState) else "", - "codeReviewRepo": n.state.code_review_repo if isinstance(n.state, CodeReviewState) else "", - "codeReviewPrNumber": ( - n.state.code_review_pr_number if isinstance(n.state, CodeReviewState) else 0 - ), - "codeReviewPrTitle": ( - n.state.code_review_pr_title if isinstance(n.state, CodeReviewState) else "" - ), - "codeReviewPrState": ( - n.state.code_review_pr_state if isinstance(n.state, CodeReviewState) else "" - ), - "codeReviewPrHtmlUrl": ( - n.state.code_review_pr_html_url if isinstance(n.state, CodeReviewState) else "" - ), - "codeReviewBaseRef": ( - n.state.code_review_base_ref if isinstance(n.state, CodeReviewState) else "" - ), - "codeReviewHeadRef": ( - n.state.code_review_head_ref if isinstance(n.state, CodeReviewState) else "" - ), - "codeReviewAdditions": ( - n.state.code_review_additions if isinstance(n.state, CodeReviewState) else 0 - ), - "codeReviewDeletions": ( - n.state.code_review_deletions if isinstance(n.state, CodeReviewState) else 0 - ), - "codeReviewChangedFiles": ( - n.state.code_review_changed_files if isinstance(n.state, CodeReviewState) else 0 - ), - # Rebuilt row by row rather than forwarded as `dict(f)` - see - # _code_review_file_wire's own comment for the casing bug that - # cost every scene snapshot after a PR fetch. - # - # `patch` rides as "" on purpose. The per-file patches are - # capped at MAX_FILE_PATCH_CHARS (6000) each and MAX_PR_FILES - # (100) of them, so forwarding them put up to ~600KB of diff - # text on EVERY scene republish - roughly ten times the - # 60KB codeReviewDiffText that was excluded from this payload - # for exactly that reason (see CodeReviewState's own comment). - # No frontend code reads it: CodeReviewNodeView renders the - # unified diff it lazily fetches via fetchCodeReviewDiffText, - # never these per-file patches. The field stays on the row - # because the contract declares it required; the engine still - # reads the real patches from node.state, which is where the - # fallback pre-screen scans them. - "codeReviewFiles": ( - [_code_review_file_wire(f) for f in n.state.code_review_files] - if isinstance(n.state, CodeReviewState) - else [] - ), - "codeReviewFilesTruncated": ( - n.state.code_review_files_truncated if isinstance(n.state, CodeReviewState) else False - ), - # codeReviewDiffText is DELIBERATELY OMITTED - see - # CodeReviewState's own comment (the gitlinkContextXml - # precedent). Served on demand via fetchCodeReviewDiffText. - "codeReviewDiffTruncated": ( - n.state.code_review_diff_truncated if isinstance(n.state, CodeReviewState) else False - ), - "codeReviewDiffChars": ( - n.state.code_review_diff_chars if isinstance(n.state, CodeReviewState) else 0 - ), - # The lazy-diff cache key (the R5.3 post-review FIX 6 - # precedent): bumped by every successful fetch, so the - # frontend never serves a previous fetch's text for this one. - "codeReviewDiffVersion": ( - n.state.code_review_diff_version if isinstance(n.state, CodeReviewState) else 0 - ), - "codeReviewWalkthrough": ( - [_code_review_walkthrough_wire(g) for g in n.state.code_review_walkthrough] - if isinstance(n.state, CodeReviewState) - else [] - ), - "codeReviewFindings": ( - [_code_review_finding_wire(f) for f in n.state.code_review_findings] - if isinstance(n.state, CodeReviewState) - else [] - ), - "codeReviewErrors": ( - [_code_review_error_wire(e) for e in n.state.code_review_errors] - if isinstance(n.state, CodeReviewState) - else [] - ), - "codeReviewDismissedIds": ( - list(n.state.code_review_dismissed_ids) - if isinstance(n.state, CodeReviewState) - else [] - ), - "codeReviewTitle": ( - n.state.code_review_title if isinstance(n.state, CodeReviewState) else "" - ), - "codeReviewOverview": ( - n.state.code_review_overview if isinstance(n.state, CodeReviewState) else "" - ), - "codeReviewConfidence": ( - n.state.code_review_confidence if isinstance(n.state, CodeReviewState) else "" - ), - # Scores ride the wire as dict[str, str] - coerced here, the - # store_gitlink_context str-coercion precedent for - # gitlinkContextStats (contracts only admit string-valued - # dicts on SceneNodeRow). - "codeReviewScores": ( - {str(k): str(v) for k, v in n.state.code_review_scores.items()} - if isinstance(n.state, CodeReviewState) - else {} - ), - "codeReviewQualityScore": ( - n.state.code_review_quality_score if isinstance(n.state, CodeReviewState) else 0 - ), - "codeReviewVerdict": ( - n.state.code_review_verdict if isinstance(n.state, CodeReviewState) else "none" - ), - "codeReviewRisk": n.state.code_review_risk if isinstance(n.state, CodeReviewState) else "", - "codeReviewQualitySummary": ( - n.state.code_review_quality_summary if isinstance(n.state, CodeReviewState) else "" - ), - "codeReviewQa": ( - [_code_review_qa_wire(entry) for entry in n.state.code_review_qa] - if isinstance(n.state, CodeReviewState) - else [] - ), - "codeReviewState": ( - n.state.code_review_state if isinstance(n.state, CodeReviewState) else "draft" - ), - "codeReviewError": n.state.code_review_error if isinstance(n.state, CodeReviewState) else "", - # codeSandboxSandboxId is DELIBERATELY OMITTED - see - # CodeSandboxState's own comment (pure internal - # directory-naming key, mirrors gitlink_imported_root's - # own "server-side bookkeeping only" precedent). - "codeSandboxRequirements": ( - n.state.code_sandbox_requirements if isinstance(n.state, CodeSandboxState) else "" - ), - "codeSandboxPrompt": ( - n.state.code_sandbox_prompt if isinstance(n.state, CodeSandboxState) else "" - ), - "codeSandboxCode": n.state.code_sandbox_code if isinstance(n.state, CodeSandboxState) else "", - "codeSandboxOutput": ( - n.state.code_sandbox_output if isinstance(n.state, CodeSandboxState) else "" - ), - "codeSandboxAnalysis": ( - n.state.code_sandbox_analysis if isinstance(n.state, CodeSandboxState) else "" - ), - "codeSandboxAwaitingApproval": ( - n.state.code_sandbox_awaiting_approval if isinstance(n.state, CodeSandboxState) else False - ), - # R5.4 CODESANDBOX FIX: the frozen-at-approval-time - # snapshot, deliberately distinct from - # codeSandboxRequirements above (that one is the user's - # still-live, still-editable draft for the NEXT run) - - # see CodeSandboxState's own comment. - "codeSandboxApprovalRequirements": ( - n.state.code_sandbox_approval_requirements if isinstance(n.state, CodeSandboxState) else "" - ), - # ADR-005 stage 5.5: the user's live source-build opt-in - # for the CURRENT pending approval - see CodeSandboxState. - # code_sandbox_approval_allow_source_builds's own comment. - "codeSandboxApprovalAllowSourceBuilds": ( - n.state.code_sandbox_approval_allow_source_builds - if isinstance(n.state, CodeSandboxState) - else False - ), - # ADR-005 stage 5.5 review-fix: True only during a - # repair-loop re-gate - see CodeSandboxState. - # code_sandbox_approval_is_repair's own comment. - "codeSandboxApprovalIsRepair": ( - n.state.code_sandbox_approval_is_repair - if isinstance(n.state, CodeSandboxState) - else False - ), - "codeSandboxError": n.state.code_sandbox_error if isinstance(n.state, CodeSandboxState) else "", - # R6.1: Notes/Frames/Containers. groupManualWidth/Height - # are DELIBERATELY OMITTED, same "server-side bookkeeping - # only" posture as codeSandboxSandboxId/gitlinkImportedRoot - # above - see those fields' own comments on - # CodeSandboxState/GitlinkState. - "color": n.color, - "headerColor": n.header_color, - "isSystemPrompt": n.state.is_system_prompt if isinstance(n.state, NoteState) else False, - "isSummaryNote": n.state.is_summary_note if isinstance(n.state, NoteState) else False, - # ADR-002 Workstream 1 ("Compare Branches") - see - # NoteState's own comment, backend/domain/node_states.py. - "isBranchComparison": n.state.is_branch_comparison if isinstance(n.state, NoteState) else False, - "itemIds": list(n.item_ids), - "isLocked": n.state.is_locked if isinstance(n.state, FrameState) else True, - "groupWidth": n.state.group_width if isinstance(n.state, (FrameState, ContainerState)) else None, - "groupHeight": ( - n.state.group_height if isinstance(n.state, (FrameState, ContainerState)) else None - ), - # R6.2: Chart node. - "chartType": n.state.chart_type if isinstance(n.state, ChartState) else "", - "chartData": dict(n.state.chart_data) if isinstance(n.state, ChartState) else {}, - "chartError": n.state.chart_error if isinstance(n.state, ChartState) else "", - "chartWidth": n.state.chart_width if isinstance(n.state, ChartState) else 480.0, - "chartHeight": n.state.chart_height if isinstance(n.state, ChartState) else 340.0, - "chartAspectLocked": n.state.chart_aspect_locked if isinstance(n.state, ChartState) else True, - "chartSourceNodeId": n.state.chart_source_node_id if isinstance(n.state, ChartState) else "", - # R6.3: HTML splitter + chat scroll gaps. - "htmlSplitterState": n.state.html_splitter_state if isinstance(n.state, HtmlState) else None, - "chatScrollValue": n.state.chat_scroll_value if isinstance(n.state, ChatState) else 0.0, - # R6.3: null (not []) when content_parts is None - "no - # multimodal content" must stay distinguishable from - # "multimodal content that happens to be empty". Any - # part carrying raw bytes under "data" gets that key - # base64-encoded for the wire via content_codec's own - # encode_image_bytes - the WIRE shape this produces - # matches content_codec.process_content_for_serialization's - # own output shape exactly, while n.content_parts itself - # (in memory) keeps holding real bytes, per the field's - # own contract. - "contentParts": _content_parts_wire(n.state.content_parts if isinstance(n.state, ChatState) else None), - # ADR-008 stage 8.3: plan node (the Builder's checklist). Steps - # cross as typed PlanStepRow dicts - see PlanState's own - # docstring for the state machine these render. - "planGoal": n.state.plan_goal if isinstance(n.state, PlanState) else "", - "planSteps": ( - [ - { - "id": s["id"], "title": s["title"], - "status": s["status"], "detail": s["detail"], - } - for s in n.state.plan_steps - ] - if isinstance(n.state, PlanState) else [] - ), - # ADR-008 stage 8.7: the run's own activity log - see PlanState's - # docstring for why this is untouched by undo. - "builderActivity": ( - [ - { - "tool": a["tool"], "summary": a["summary"], - "outcome": a["outcome"], "stepId": a["stepId"], - "elapsedMs": a["elapsedMs"], - } - for a in n.state.builder_activity - ] - if isinstance(n.state, PlanState) else [] - ), - "builderStatus": n.state.builder_status if isinstance(n.state, PlanState) else "", - "builderMode": n.state.builder_mode if isinstance(n.state, PlanState) else "", - "builderRunId": n.state.builder_run_id if isinstance(n.state, PlanState) else "", - "builderMaxSteps": n.state.builder_max_steps if isinstance(n.state, PlanState) else 0, - "builderMaxTokens": n.state.builder_max_tokens if isinstance(n.state, PlanState) else 0, - "builderMaxWallSeconds": n.state.builder_max_wall_seconds if isinstance(n.state, PlanState) else 0, - "builderSpentSteps": n.state.builder_spent_steps if isinstance(n.state, PlanState) else 0, - "builderSpentTokens": n.state.builder_spent_tokens if isinstance(n.state, PlanState) else 0, - "builderSpentWallSeconds": ( - n.state.builder_spent_wall_seconds if isinstance(n.state, PlanState) else 0 - ), - "builderAwaitingToolApproval": ( - n.state.builder_awaiting_tool_approval if isinstance(n.state, PlanState) else False - ), - "builderApprovalToolName": ( - n.state.builder_approval_tool_name if isinstance(n.state, PlanState) else "" - ), - "builderApprovalSummary": ( - n.state.builder_approval_summary if isinstance(n.state, PlanState) else "" - ), - "builderStatusDetail": n.state.builder_status_detail if isinstance(n.state, PlanState) else "", - # PLAN-2026-08-24 H1: harness node (the workspace agent). - # Conversation history deliberately does NOT cross the wire - - # the transcript lives in the workspace; only the render - # surface does (see HarnessState's own docstring). - "harnessGoal": n.state.harness_goal if isinstance(n.state, HarnessState) else "", - "harnessReply": n.state.harness_reply if isinstance(n.state, HarnessState) else "", - "harnessStatus": n.state.harness_status if isinstance(n.state, HarnessState) else "", - "harnessStatusDetail": ( - n.state.harness_status_detail if isinstance(n.state, HarnessState) else "" - ), - "harnessRunId": n.state.harness_run_id if isinstance(n.state, HarnessState) else "", - "harnessActivity": ( - [ - { - "tool": a["tool"], "summary": a["summary"], - "outcome": a["outcome"], "elapsedMs": a["elapsedMs"], - } - for a in n.state.harness_activity - ] - if isinstance(n.state, HarnessState) else [] - ), - "harnessAwaitingApproval": ( - n.state.harness_awaiting_approval if isinstance(n.state, HarnessState) else False - ), - "harnessApprovalToolName": ( - n.state.harness_approval_tool_name if isinstance(n.state, HarnessState) else "" - ), - "harnessApprovalSummary": ( - n.state.harness_approval_summary if isinstance(n.state, HarnessState) else "" - ), - "harnessApprovalSessionOffered": ( - n.state.harness_approval_session_offered - if isinstance(n.state, HarnessState) else False - ), - "harnessPlan": ( - [{"text": s["text"], "status": s["status"]} for s in n.state.harness_plan] - if isinstance(n.state, HarnessState) else [] - ), - "harnessAwaitingQuestion": ( - n.state.harness_awaiting_question if isinstance(n.state, HarnessState) else False - ), - "harnessQuestion": ( - n.state.harness_question if isinstance(n.state, HarnessState) else "" - ), - "harnessContextTokens": ( - n.state.harness_context_tokens if isinstance(n.state, HarnessState) else 0 - ), - "harnessMaxContextTokens": ( - n.state.harness_max_context_tokens if isinstance(n.state, HarnessState) else 0 - ), - "harnessCompactions": ( - n.state.harness_compactions if isinstance(n.state, HarnessState) else 0 - ), - "harnessWorkspacePath": ( - n.state.harness_workspace_path if isinstance(n.state, HarnessState) else "" - ), - "harnessWorkspaceActive": ( - n.state.harness_workspace_active if isinstance(n.state, HarnessState) else "" - ), - "harnessMaxTurns": n.state.harness_max_turns if isinstance(n.state, HarnessState) else 0, - "harnessSpentTurns": ( - n.state.harness_spent_turns if isinstance(n.state, HarnessState) else 0 - ), - "harnessSpentTokens": ( - n.state.harness_spent_tokens if isinstance(n.state, HarnessState) else 0 - ), - # ADR-014 stage 14.2: the Plugin SDK's generic live-wire - # fallback - see plugin_node_serializers' own field comment and - # _plugin_state_wire's own docstring below. - "pluginState": self._plugin_state_wire(n), - } + """The per-node wire row - the ONE place both the full snapshot and + take_dirty_patch_ops's upsertNode ops build a node's payload from + (ADR-003 stage 3.4), so the two can never drift field by field. + Sparse: see backend/domain/node_wire.py for the encoding and the + client-side restore that keeps every reader seeing the full row.""" + return node_wire( + n, + is_final_deliverable=n.id == self.final_deliverable_node_id, + plugin_state=self._plugin_state_wire(n), + ) def _plugin_state_wire(self, n: SceneNode) -> dict[str, str]: """ADR-014 stage 14.2: the live-WS-wire analog of session_save.py's @@ -1593,31 +1006,3 @@ def grid_payload(self) -> dict[str, Any]: "stylePresets": list(GRID_STYLE_PRESETS), "colorPresets": list(GRID_COLOR_PRESETS), } - - -def _content_parts_wire(parts: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None: - """R6.3: scene_payload()'s wire-side transform for ChatState's own - content_parts (backend/domain/node_states.py) - a pure mapping - function, NOT a SceneDocument method (same posture as - _research_result_wire below). None stays None (never []), so "no - multimodal content" and "multimodal content that happens to be empty" - remain distinguishable on the wire. Any part that is a dict carrying raw - bytes under its "data" key gets that key base64-encoded as a string via - content_codec.encode_image_bytes - matching content_codec. - process_content_for_serialization's own output shape exactly - while - leaving every other key/part untouched. Builds fresh dicts throughout; - never mutates the SceneNode's own in-memory parts (which must keep - holding real bytes, per content_parts's own field contract).""" - if parts is None: - return None - wire_parts: list[dict[str, Any]] = [] - for part in parts: - if isinstance(part, dict) and isinstance(part.get("data"), (bytes, bytearray)): - wire_part = dict(part) - wire_part["data"] = _content_codec.encode_image_bytes(bytes(part["data"])) - wire_parts.append(wire_part) - elif isinstance(part, dict): - wire_parts.append(dict(part)) - else: - wire_parts.append(part) - return wire_parts diff --git a/backend/domain/node_states.py b/backend/domain/node_states.py index 1ef40b37..748762c3 100644 --- a/backend/domain/node_states.py +++ b/backend/domain/node_states.py @@ -7,11 +7,13 @@ own relocation note); a kind not yet migrated still keeps its fields directly on SceneNode. -Wire-compatibility constraint: SceneDocument.scene_payload() must keep -emitting the exact same flat per-node shape it does today. This module -introduces no wire-layer change by itself - it only relocates where a -field lives in memory; scene_payload's own per-kind read expressions are -updated in lockstep with each kind's migration, in backend/domain/graph.py. +Wire-compatibility constraint: the client must keep reading the exact same +flat per-node shape. This module introduces no wire-layer change by itself - +it only relocates where a field lives in memory. The row itself is built by +backend/domain/node_wire.py from one field table per state class here (sent +sparse, restored to the flat shape client-side - see that module), so a +field added to a state class that should reach the frontend needs its entry +in that table too. kind values with no state class (no kind-specific fields at all, so node.state stays None for them permanently): placeholder, thinking, diff --git a/backend/domain/node_wire.py b/backend/domain/node_wire.py new file mode 100644 index 00000000..4461015a --- /dev/null +++ b/backend/domain/node_wire.py @@ -0,0 +1,715 @@ +"""The per-node wire row - sent sparse. + +Every node used to cross the wire as the FULL flat superset of every node +kind's fields: 157 keys, of which a chat node carries 13. Each new node kind +widened every node on the canvas, whatever its kind - measured against the +2026-08-11 perf baseline, the per-node payload had grown 32-47% and the +nightly perf check had failed every night since 2026-08-23. + +A node now sends its five identity keys (id/x/y/title/kind) plus only the +fields whose value differs from the wire default. The default for every key +is the one contracts/graphlink_scene_payload.py's SceneNodeRow declares, and +the client restores every omitted key from that same declaration: codegen +emits it as hydrateSceneNodeRow() (web_ui/src/lib/bridge-core/generated/ +scene-state.ts), which validateSceneState and the scene store's patch path +apply before anything else reads a row. The row the rest of the frontend sees +is therefore exactly the full row it always saw; only the bytes changed. + +WIRE_DEFAULTS below restates those contract defaults because contracts/ is +deliberately not shipped in the wheel, so the running backend cannot import +it. backend/tests/test_node_wire.py pins the two tables equal - a default +changed on one side only fails that test, never silently diverges the wire. + +"Differs from the default" is strict: same class AND equal. `0` and `False` +compare equal in Python but serialize differently, so a value of the wrong +type is always sent rather than folded into a default of another type. + +Only the fields a node's own state class owns are even computed: the +per-state tables below replace 150 isinstance checks per node, per publish, +with one type lookup - the build cost the perf baseline tracks as +payload_build_ms, paid on every publish because take_dirty_patch_ops diffs +every node's row. +""" + +from __future__ import annotations + +import json +from typing import Any, Callable + +from backend.domain.content_codec import _content_codec +from backend.domain.model import SceneNode +from backend.domain.node_states import ( + ArtifactState, + ChartState, + ChatState, + CodeReviewState, + CodeSandboxState, + CodeState, + ContainerState, + DocumentState, + FrameState, + GitlinkState, + HarnessState, + HtmlState, + ImageState, + NoteState, + PlanState, + WebResearchState, +) + +# The wire value of every non-identity key when a node does not set it - +# SceneNodeRow's own defaults (see the module docstring), plus `contentParts`, +# a real wire field the contract deliberately does not declare (no frontend +# code reads it; see contracts/graphlink_scene_payload.py's docstring). +# Containers here are never sent or mutated - they are comparison targets only. +WIRE_DEFAULTS: dict[str, Any] = { + "content": "", + "isUser": False, + "isCollapsed": False, + "code": "", + "language": "", + "attachmentKind": "", + "filePath": "", + "mimeType": "", + "durationSeconds": None, + "byteSize": None, + "previewLabel": "", + "isDocked": False, + "imageAssetId": "", + "history": [], + "pendingRequestId": None, + "provider": None, + "model": None, + "overrideProvider": "", + "overrideModelId": "", + "indexIntoKnowledge": False, + "isBranchSynthesis": False, + "synthesisInstructions": "", + "branchStatus": "active", + "responseIncomplete": False, + "promptTokens": None, + "completionTokens": None, + "estimatedCostUsd": None, + "toolCalls": [], + "isFinalDeliverable": False, + "researchStage": "", + "researchCompleted": 0, + "researchTotal": 0, + "researchActiveSourceId": None, + "researchError": "", + "researchResult": None, + "researchRetainToKnowledge": False, + "artifactContent": "", + "artifactError": "", + "gitlinkRepo": "", + "gitlinkBranch": "", + "gitlinkScopeMode": "selected", + "gitlinkLocalRoot": "", + "gitlinkRepoFilePaths": [], + "gitlinkSelectedPaths": [], + "gitlinkTaskPrompt": "", + "gitlinkContextStats": {}, + "gitlinkContextSummary": "", + "gitlinkContextVersion": 0, + "gitlinkProposalMarkdown": "", + "gitlinkPendingChanges": [], + "gitlinkPreviewText": "", + "gitlinkChangeFingerprint": None, + "gitlinkChangeState": "draft", + "gitlinkError": "", + "codeReviewPrUrl": "", + "codeReviewRepo": "", + "codeReviewPrNumber": 0, + "codeReviewPrTitle": "", + "codeReviewPrState": "", + "codeReviewPrHtmlUrl": "", + "codeReviewBaseRef": "", + "codeReviewHeadRef": "", + "codeReviewAdditions": 0, + "codeReviewDeletions": 0, + "codeReviewChangedFiles": 0, + "codeReviewFiles": [], + "codeReviewFilesTruncated": False, + "codeReviewDiffTruncated": False, + "codeReviewDiffChars": 0, + "codeReviewDiffVersion": 0, + "codeReviewWalkthrough": [], + "codeReviewFindings": [], + "codeReviewErrors": [], + "codeReviewDismissedIds": [], + "codeReviewTitle": "", + "codeReviewOverview": "", + "codeReviewConfidence": "", + "codeReviewScores": {}, + "codeReviewQualityScore": 0, + "codeReviewVerdict": "none", + "codeReviewRisk": "", + "codeReviewQualitySummary": "", + "codeReviewQa": [], + "codeReviewState": "draft", + "codeReviewError": "", + "codeSandboxRequirements": "", + "codeSandboxPrompt": "", + "codeSandboxCode": "", + "codeSandboxOutput": "", + "codeSandboxAnalysis": "", + "codeSandboxAwaitingApproval": False, + "codeSandboxApprovalRequirements": "", + "codeSandboxApprovalAllowSourceBuilds": False, + "codeSandboxApprovalIsRepair": False, + "codeSandboxError": "", + "color": None, + "headerColor": None, + "isSystemPrompt": False, + "isSummaryNote": False, + "isBranchComparison": False, + "itemIds": [], + "isLocked": True, + "groupWidth": None, + "groupHeight": None, + "chartType": "", + "chartData": {}, + "chartError": "", + "chartWidth": 480.0, + "chartHeight": 340.0, + "chartAspectLocked": True, + "chartSourceNodeId": "", + "htmlSplitterState": None, + "chatScrollValue": 0.0, + "contentParts": None, + "planGoal": "", + "planSteps": [], + "builderActivity": [], + "builderStatus": "", + "builderMode": "", + "builderRunId": "", + "builderMaxSteps": 0, + "builderMaxTokens": 0, + "builderMaxWallSeconds": 0, + "builderSpentSteps": 0, + "builderSpentTokens": 0, + "builderSpentWallSeconds": 0, + "builderAwaitingToolApproval": False, + "builderApprovalToolName": "", + "builderApprovalSummary": "", + "builderStatusDetail": "", + "harnessGoal": "", + "harnessReply": "", + "harnessStatus": "", + "harnessStatusDetail": "", + "harnessRunId": "", + "harnessActivity": [], + "harnessAwaitingApproval": False, + "harnessApprovalToolName": "", + "harnessApprovalSummary": "", + "harnessApprovalSessionOffered": False, + "harnessPlan": [], + "harnessAwaitingQuestion": False, + "harnessQuestion": "", + "harnessContextTokens": 0, + "harnessMaxContextTokens": 0, + "harnessCompactions": 0, + "harnessWorkspacePath": "", + "harnessWorkspaceActive": "", + "harnessMaxTurns": 0, + "harnessSpentTurns": 0, + "harnessSpentTokens": 0, + "pluginState": {}, +} + +# Always sent, never defaulted: SceneNodeRow's five fields with no default. +IDENTITY_KEYS = ("id", "x", "y", "title", "kind") + + +# -- Review Lens nested wire rows ------------------------------------------ +# +# These five builders exist because the Review Lens node stores its nested +# rows as the review engine's own snake_case dicts (graphlink_plugins/ +# review_lens/review_engine.py) while the wire contract - and the generated +# client validator built from it - is camelCase, like every other nested +# row on SceneNodeRow. scene_payload used to forward them with a bare +# `dict(row)`, which shipped `patch_truncated`/`previous_path`/ +# `group_title` where CodeReviewFileRow and CodeReviewWalkthroughGroupRow +# declare `patchTruncated`/`previousPath`/`groupTitle`. +# +# That was not a cosmetic mismatch. validateSceneState treats a missing +# required field as a hard error, and web_ui/src/lib/api-contract/ +# bindTopic.ts DROPS a snapshot that fails validation, so from the first +# successful PR fetch onward every scene snapshot for the whole session +# was rejected client-side - the canvas froze for every node, not just +# this one. Building each row explicitly (the `toolCalls` precedent +# below) fixes the casing AND guarantees each row carries exactly the +# contract's fields with the contract's types, so a row that reached the +# state from an old save file cannot put an unexpected key on the wire. +# +# The domain keeps snake_case on purpose: it is what the engine emits and +# what session_save.py has already written to every existing save file. +# The conversion belongs at the wire builder, the same place +# codeReviewScores is coerced to dict[str, str]. + + +def _code_review_file_wire(row: dict[str, Any]) -> dict[str, Any]: + wire: dict[str, Any] = { + "path": str(row.get("path", "")), + "status": str(row.get("status", "modified")), + "additions": _non_negative_wire_int(row.get("additions")), + "deletions": _non_negative_wire_int(row.get("deletions")), + # `patch` rides as "" on purpose. The per-file patches are capped at + # MAX_FILE_PATCH_CHARS (6000) each and MAX_PR_FILES (100) of them, so + # forwarding them put up to ~600KB of diff text on EVERY scene + # republish - roughly ten times the 60KB codeReviewDiffText that was + # excluded from this payload for exactly that reason (see + # CodeReviewState's own comment). No frontend code reads it: + # CodeReviewNodeView renders the unified diff it lazily fetches via + # fetchCodeReviewDiffText, never these per-file patches. The field + # stays on the row because the contract declares it required; the + # engine still reads the real patches from node.state, which is where + # the fallback pre-screen scans them. + "patch": "", + "patchTruncated": bool(row.get("patch_truncated", False)), + } + # Genuinely absent (not "") for every non-rename, which is why + # CodeReviewFileRow.previousPath is the one Optional field on the row. + previous = str(row.get("previous_path", "") or "") + if previous: + wire["previousPath"] = previous + return wire + + +def _code_review_walkthrough_wire(row: dict[str, Any]) -> dict[str, Any]: + raw_paths = row.get("paths") + return { + "groupTitle": str(row.get("group_title", "")), + "paths": [str(path) for path in raw_paths] if isinstance(raw_paths, list) else [], + "explanation": str(row.get("explanation", "")), + } + + +def _code_review_finding_wire(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": str(row.get("id", "")), + "severity": str(row.get("severity", "")), + "tier": str(row.get("tier", "")), + "category": str(row.get("category", "")), + "path": str(row.get("path", "")), + "line": _non_negative_wire_int(row.get("line")), + "title": str(row.get("title", "")), + "evidence": str(row.get("evidence", "")), + "impact": str(row.get("impact", "")), + "recommendation": str(row.get("recommendation", "")), + } + + +def _code_review_error_wire(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": str(row.get("id", "")), + "severity": str(row.get("severity", "")), + "tier": str(row.get("tier", "")), + "kind": str(row.get("kind", "")), + "path": str(row.get("path", "")), + "line": _non_negative_wire_int(row.get("line")), + "title": str(row.get("title", "")), + "evidence": str(row.get("evidence", "")), + "fix": str(row.get("fix", "")), + } + + +def _code_review_qa_wire(row: dict[str, Any]) -> dict[str, Any]: + return { + "question": str(row.get("question", "")), + "answer": str(row.get("answer", "")), + } + + +def _non_negative_wire_int(value: Any) -> int: + """int for the wire, never raising. A row can reach the wire from a + hand-edited save file as well as from the engine, and a ValueError + here would fail the whole scene republish, not just one row.""" + try: + return max(0, int(value)) # type: ignore[call-overload] + except (TypeError, ValueError, OverflowError): + return 0 + + +def _content_parts_wire(parts: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None: + """R6.3: the wire-side transform for ChatState's own content_parts + (backend/domain/node_states.py) - a pure mapping function (same posture + as backend/canvas.py's _research_result_wire). None stays None (never + []), so "no multimodal content" and "multimodal content that happens to + be empty" remain distinguishable on the wire. Any part that is a dict + carrying raw bytes under its "data" key gets that key base64-encoded as + a string via content_codec.encode_image_bytes - matching content_codec. + process_content_for_serialization's own output shape exactly - while + leaving every other key/part untouched. Builds fresh dicts throughout; + never mutates the SceneNode's own in-memory parts (which must keep + holding real bytes, per content_parts's own field contract).""" + if parts is None: + return None + wire_parts: list[dict[str, Any]] = [] + for part in parts: + if isinstance(part, dict) and isinstance(part.get("data"), (bytes, bytearray)): + wire_part = dict(part) + wire_part["data"] = _content_codec.encode_image_bytes(bytes(part["data"])) + wire_parts.append(wire_part) + elif isinstance(part, dict): + wire_parts.append(dict(part)) + else: + wire_parts.append(part) + return wire_parts + + +# -- Other nested-row transforms -------------------------------------------- + + +def _history_wire(history: list[dict[str, Any]]) -> list[dict[str, Any]]: + # ADR-006 stage 6.4: the projection stays a strict allow-list (never + # spread the raw dict - legacy entries can carry arbitrary keys), widened + # by exactly one optional marker: "incomplete" flags a partial assistant + # reply whose stream died (see append_conversation_assistant_message). + return [ + {"role": m["role"], "content": m["content"], "incomplete": bool(m.get("incomplete", False))} + for m in history + ] + + +def _tool_calls_wire(calls: list[dict[str, Any]]) -> list[dict[str, Any]]: + # ADR-007 stage 7.4: see ChatState.tool_invocations' own comment and + # ToolInvocationRow's docstring (contracts/graphlink_scene_payload.py) + # for why `arguments` is JSON-encoded rather than passed as an object. + return [ + { + "id": str(call.get("id", "")), + "name": str(call.get("name", "")), + "argumentsJson": json.dumps(call.get("arguments") or {}, sort_keys=True), + "result": str(call.get("result", "")), + "isError": bool(call.get("is_error", False)), + } + for call in calls + ] + + +def _dict_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [dict(row) for row in rows] + + +def _string_dict(values: dict[Any, Any]) -> dict[str, str]: + # Scores ride the wire as dict[str, str] - the store_gitlink_context + # str-coercion precedent (contracts only admit string-valued dicts on + # SceneNodeRow). + return {str(k): str(v) for k, v in values.items()} + + +def _plan_steps_wire(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: + # ADR-008 stage 8.3: the Builder's checklist - see PlanState's docstring. + return [ + {"id": s["id"], "title": s["title"], "status": s["status"], "detail": s["detail"]} + for s in steps + ] + + +def _builder_activity_wire(activity: list[dict[str, Any]]) -> list[dict[str, Any]]: + # ADR-008 stage 8.7: the run's own activity log - see PlanState's + # docstring for why this is untouched by undo. + return [ + { + "tool": a["tool"], "summary": a["summary"], + "outcome": a["outcome"], "stepId": a["stepId"], + "elapsedMs": a["elapsedMs"], + } + for a in activity + ] + + +def _harness_activity_wire(activity: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + {"tool": a["tool"], "summary": a["summary"], "outcome": a["outcome"], "elapsedMs": a["elapsedMs"]} + for a in activity + ] + + +def _harness_plan_wire(plan: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [{"text": s["text"], "status": s["status"]} for s in plan] + + +def _code_review_rows(builder: Callable[[dict[str, Any]], dict[str, Any]]) -> Callable[[Any], Any]: + return lambda rows: [builder(row) for row in rows] + + +# -- Field tables ------------------------------------------------------------- +# +# (wire key, attribute, transform or None, default). Built once from compact +# specs; the default is looked up in WIRE_DEFAULTS so there is exactly one +# place a default is written down on this side. + +_Entry = tuple[str, str, Callable[[Any], Any] | None, Any] + + +def _table(*specs: tuple[Any, ...]) -> tuple[_Entry, ...]: + return tuple( + (spec[0], spec[1], spec[2] if len(spec) > 2 else None, WIRE_DEFAULTS[spec[0]]) + for spec in specs + ) + + +# Fields on SceneNode itself, sent for every kind. +_NODE_FIELDS = _table( + ("content", "content"), + ("isCollapsed", "is_collapsed"), + ("isDocked", "is_docked"), + ("history", "history", _history_wire), + # R4.3: generic across any kind with its own dispatch slot. + ("pendingRequestId", "pending_request_id"), + # R6.1: core fields on every kind (see SceneNodeRow's own comment). + ("color", "color"), + ("headerColor", "header_color"), + ("itemIds", "item_ids", list), +) + +_GROUP_SIZE_FIELDS = (("groupWidth", "group_width"), ("groupHeight", "group_height")) + +# Each state class's own fields. Keyed by the exact classes the old flat +# builder tested with isinstance; _fields_for() resolves a state's type +# against these the same way, subclasses included. +_STATE_FIELDS: dict[type, tuple[_Entry, ...]] = { + ChatState: _table( + ("isUser", "is_user"), + # ADR-002 Workstream 1 ("Synthesize Branches") - output provenance. + ("provider", "provider"), + ("model", "model"), + # ADR-018 stage 18.3: the input routing pin, distinct from the pair + # above - see ChatState's own comment. + ("overrideProvider", "override_provider"), + ("overrideModelId", "override_model_id"), + ("indexIntoKnowledge", "index_into_knowledge"), + ("isBranchSynthesis", "is_branch_synthesis"), + ("synthesisInstructions", "synthesis_instructions"), + ("branchStatus", "branch_status"), + ("responseIncomplete", "response_incomplete"), + ("promptTokens", "prompt_tokens"), + ("completionTokens", "completion_tokens"), + ("estimatedCostUsd", "estimated_cost_usd"), + ("toolCalls", "tool_invocations", _tool_calls_wire), + ("chatScrollValue", "chat_scroll_value"), + ("contentParts", "content_parts", _content_parts_wire), + ), + CodeState: _table(("code", "code"), ("language", "language")), + DocumentState: _table( + ("attachmentKind", "attachment_kind"), + ("filePath", "file_path"), + ("mimeType", "mime_type"), + ("durationSeconds", "duration_seconds"), + ("byteSize", "byte_size"), + ("previewLabel", "preview_label"), + ), + ImageState: _table(("imageAssetId", "image_asset_id")), + WebResearchState: _table( + ("researchStage", "research_stage"), + ("researchCompleted", "research_completed"), + ("researchTotal", "research_total"), + ("researchActiveSourceId", "research_active_source_id"), + ("researchError", "research_error"), + ("researchResult", "research_result"), + ("researchRetainToKnowledge", "research_retain_to_knowledge"), + ), + ArtifactState: _table(("artifactContent", "artifact_content"), ("artifactError", "artifact_error")), + # gitlinkContextXml is DELIBERATELY absent - see GitlinkState's own + # comment; served on demand via the fetchGitlinkContext intent. + GitlinkState: _table( + ("gitlinkRepo", "gitlink_repo"), + ("gitlinkBranch", "gitlink_branch"), + ("gitlinkScopeMode", "gitlink_scope_mode"), + ("gitlinkLocalRoot", "gitlink_local_root"), + ("gitlinkRepoFilePaths", "gitlink_repo_file_paths", list), + ("gitlinkSelectedPaths", "gitlink_selected_paths", list), + ("gitlinkTaskPrompt", "gitlink_task_prompt"), + ("gitlinkContextStats", "gitlink_context_stats", dict), + ("gitlinkContextSummary", "gitlink_context_summary"), + # R5.3 post-review FIX 6: the lazy-fetch cache key - see + # GitlinkState's own comment for why the summary alone cannot be. + ("gitlinkContextVersion", "gitlink_context_version"), + ("gitlinkProposalMarkdown", "gitlink_proposal_markdown"), + ("gitlinkPendingChanges", "gitlink_pending_changes", _dict_rows), + ("gitlinkPreviewText", "gitlink_preview_text"), + ("gitlinkChangeFingerprint", "gitlink_change_fingerprint"), + ("gitlinkChangeState", "gitlink_change_state"), + ("gitlinkError", "gitlink_error"), + ), + # codeReviewDiffText is DELIBERATELY absent - see CodeReviewState's own + # comment; served on demand via fetchCodeReviewDiffText. + CodeReviewState: _table( + ("codeReviewPrUrl", "code_review_pr_url"), + ("codeReviewRepo", "code_review_repo"), + ("codeReviewPrNumber", "code_review_pr_number"), + ("codeReviewPrTitle", "code_review_pr_title"), + ("codeReviewPrState", "code_review_pr_state"), + ("codeReviewPrHtmlUrl", "code_review_pr_html_url"), + ("codeReviewBaseRef", "code_review_base_ref"), + ("codeReviewHeadRef", "code_review_head_ref"), + ("codeReviewAdditions", "code_review_additions"), + ("codeReviewDeletions", "code_review_deletions"), + ("codeReviewChangedFiles", "code_review_changed_files"), + ("codeReviewFiles", "code_review_files", _code_review_rows(_code_review_file_wire)), + ("codeReviewFilesTruncated", "code_review_files_truncated"), + ("codeReviewDiffTruncated", "code_review_diff_truncated"), + ("codeReviewDiffChars", "code_review_diff_chars"), + # The lazy-diff cache key, bumped by every successful fetch. + ("codeReviewDiffVersion", "code_review_diff_version"), + ("codeReviewWalkthrough", "code_review_walkthrough", _code_review_rows(_code_review_walkthrough_wire)), + ("codeReviewFindings", "code_review_findings", _code_review_rows(_code_review_finding_wire)), + ("codeReviewErrors", "code_review_errors", _code_review_rows(_code_review_error_wire)), + ("codeReviewDismissedIds", "code_review_dismissed_ids", list), + ("codeReviewTitle", "code_review_title"), + ("codeReviewOverview", "code_review_overview"), + ("codeReviewConfidence", "code_review_confidence"), + ("codeReviewScores", "code_review_scores", _string_dict), + ("codeReviewQualityScore", "code_review_quality_score"), + ("codeReviewVerdict", "code_review_verdict"), + ("codeReviewRisk", "code_review_risk"), + ("codeReviewQualitySummary", "code_review_quality_summary"), + ("codeReviewQa", "code_review_qa", _code_review_rows(_code_review_qa_wire)), + ("codeReviewState", "code_review_state"), + ("codeReviewError", "code_review_error"), + ), + # codeSandboxSandboxId is DELIBERATELY absent - pure internal + # directory-naming key, see CodeSandboxState's own comment. + CodeSandboxState: _table( + ("codeSandboxRequirements", "code_sandbox_requirements"), + ("codeSandboxPrompt", "code_sandbox_prompt"), + ("codeSandboxCode", "code_sandbox_code"), + ("codeSandboxOutput", "code_sandbox_output"), + ("codeSandboxAnalysis", "code_sandbox_analysis"), + ("codeSandboxAwaitingApproval", "code_sandbox_awaiting_approval"), + # R5.4: the frozen-at-approval-time snapshot, distinct from the live + # draft above - see CodeSandboxState's own comment. + ("codeSandboxApprovalRequirements", "code_sandbox_approval_requirements"), + ("codeSandboxApprovalAllowSourceBuilds", "code_sandbox_approval_allow_source_builds"), + ("codeSandboxApprovalIsRepair", "code_sandbox_approval_is_repair"), + ("codeSandboxError", "code_sandbox_error"), + ), + NoteState: _table( + ("isSystemPrompt", "is_system_prompt"), + ("isSummaryNote", "is_summary_note"), + ("isBranchComparison", "is_branch_comparison"), + ), + # groupManualWidth/Height are DELIBERATELY absent - server-side + # bookkeeping only (R6.1). + FrameState: _table(("isLocked", "is_locked"), *_GROUP_SIZE_FIELDS), + ContainerState: _table(*_GROUP_SIZE_FIELDS), + ChartState: _table( + ("chartType", "chart_type"), + ("chartData", "chart_data", dict), + ("chartError", "chart_error"), + ("chartWidth", "chart_width"), + ("chartHeight", "chart_height"), + ("chartAspectLocked", "chart_aspect_locked"), + ("chartSourceNodeId", "chart_source_node_id"), + ), + HtmlState: _table(("htmlSplitterState", "html_splitter_state")), + PlanState: _table( + ("planGoal", "plan_goal"), + ("planSteps", "plan_steps", _plan_steps_wire), + ("builderActivity", "builder_activity", _builder_activity_wire), + ("builderStatus", "builder_status"), + ("builderMode", "builder_mode"), + ("builderRunId", "builder_run_id"), + ("builderMaxSteps", "builder_max_steps"), + ("builderMaxTokens", "builder_max_tokens"), + ("builderMaxWallSeconds", "builder_max_wall_seconds"), + ("builderSpentSteps", "builder_spent_steps"), + ("builderSpentTokens", "builder_spent_tokens"), + ("builderSpentWallSeconds", "builder_spent_wall_seconds"), + ("builderAwaitingToolApproval", "builder_awaiting_tool_approval"), + ("builderApprovalToolName", "builder_approval_tool_name"), + ("builderApprovalSummary", "builder_approval_summary"), + ("builderStatusDetail", "builder_status_detail"), + ), + # PLAN-2026-08-24 H1: the conversation history deliberately does NOT + # cross the wire - the transcript lives in the workspace; only the render + # surface does (see HarnessState's own docstring). + HarnessState: _table( + ("harnessGoal", "harness_goal"), + ("harnessReply", "harness_reply"), + ("harnessStatus", "harness_status"), + ("harnessStatusDetail", "harness_status_detail"), + ("harnessRunId", "harness_run_id"), + ("harnessActivity", "harness_activity", _harness_activity_wire), + ("harnessAwaitingApproval", "harness_awaiting_approval"), + ("harnessApprovalToolName", "harness_approval_tool_name"), + ("harnessApprovalSummary", "harness_approval_summary"), + ("harnessApprovalSessionOffered", "harness_approval_session_offered"), + ("harnessPlan", "harness_plan", _harness_plan_wire), + ("harnessAwaitingQuestion", "harness_awaiting_question"), + ("harnessQuestion", "harness_question"), + ("harnessContextTokens", "harness_context_tokens"), + ("harnessMaxContextTokens", "harness_max_context_tokens"), + ("harnessCompactions", "harness_compactions"), + ("harnessWorkspacePath", "harness_workspace_path"), + ("harnessWorkspaceActive", "harness_workspace_active"), + ("harnessMaxTurns", "harness_max_turns"), + ("harnessSpentTurns", "harness_spent_turns"), + ("harnessSpentTokens", "harness_spent_tokens"), + ), +} + +_FIELDS_BY_TYPE: dict[type, tuple[_Entry, ...]] = {} + + +def _fields_for(state_type: type) -> tuple[_Entry, ...]: + """The state-owned entries for one concrete state type, resolved like the + isinstance checks they replace (a subclass inherits its bases' fields) + and cached per type. A plugin's own NodeState subclass matches nothing + and gets the node-level fields only - exactly what the flat builder + gave it.""" + fields = _FIELDS_BY_TYPE.get(state_type) + if fields is None: + fields = tuple( + entry + for owner, entries in _STATE_FIELDS.items() + if issubclass(state_type, owner) + for entry in entries + ) + _FIELDS_BY_TYPE[state_type] = fields + return fields + + +def _put_non_default(wire: dict[str, Any], source: object, entries: tuple[_Entry, ...]) -> None: + for key, attr, transform, default in entries: + value = getattr(source, attr) + if transform is not None: + value = transform(value) + if value is default or (value.__class__ is default.__class__ and value == default): + continue + wire[key] = value + + +def node_wire(node: SceneNode, *, is_final_deliverable: bool, plugin_state: dict[str, str]) -> dict[str, Any]: + """One node's sparse wire row: identity keys, then every field whose + value differs from its wire default. See the module docstring for the + contract this relies on and the tests that pin it.""" + wire: dict[str, Any] = { + "id": node.id, + "x": node.x, + "y": node.y, + "title": node.title, + "kind": node.kind, + } + _put_non_default(wire, node, _NODE_FIELDS) + if is_final_deliverable: + wire["isFinalDeliverable"] = True + if node.state is not None: + _put_non_default(wire, node.state, _fields_for(type(node.state))) + if plugin_state: + # ADR-014 stage 14.2: the Plugin SDK's generic live-wire fallback - + # see SceneDocument._plugin_state_wire. + wire["pluginState"] = plugin_state + return wire + + +def wire_keys() -> frozenset[str]: + """Every key node_wire can ever emit - for the drift tests.""" + keys = set(IDENTITY_KEYS) + keys.update(entry[0] for entry in _NODE_FIELDS) + for entries in _STATE_FIELDS.values(): + keys.update(entry[0] for entry in entries) + keys.update(("isFinalDeliverable", "pluginState")) + return frozenset(keys) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 27a52c82..1e8155e6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,3 +1,4 @@ +import json import os from pathlib import Path @@ -265,3 +266,27 @@ def busy_count(dispatcher, kind): awaited, single-shot" kind (chart, note) now living in AgentDispatcher._runs.""" return sum(1 for handle in dispatcher._runs.values() if handle.kind == kind) + + +def client_row(row): + """One scene node row as the CLIENT reads it. Rows cross the wire sparse + (backend/domain/node_wire.py): every field at its contract default is + omitted, and the generated hydrateSceneNodeRow restores it before any + frontend code reads the row. Tests about what a node looks like to the + frontend read this; tests about the bytes read scene_payload() raw. + `contentParts` is the one wire key the contract does not declare, so - + like the client - this never restores it.""" + from backend.domain.node_wire import WIRE_DEFAULTS + + restored = { + key: json.loads(json.dumps(default)) + for key, default in WIRE_DEFAULTS.items() + if key != "contentParts" + } + restored.update(row) + return restored + + +def client_rows(doc): + """scene_payload()'s node rows as the client reads them - see client_row.""" + return [client_row(row) for row in doc.scene_payload()["nodes"]] diff --git a/backend/tests/perf/baseline.json b/backend/tests/perf/baseline.json index f04d8c67..2054006e 100644 --- a/backend/tests/perf/baseline.json +++ b/backend/tests/perf/baseline.json @@ -1,51 +1,51 @@ { "_comment": "ADR-019 stage 19.3: the committed wall-clock baseline check_baseline.py compares fresh measure_baselines.py output against. Regenerate deliberately with `python -m backend.tests.perf.measure_baselines`, review the diff, and commit - never auto-overwritten by the nightly job itself (that would let a real regression quietly become the new normal).", - "measured_at": "2026-08-11", - "measured_on": "windows, Python 3.12.6, coverage-uninstrumented (see backend/tests/perf/test_loop_watchdog.py's own note on why coverage tracing corrupts these numbers)", + "measured_at": "2026-09-19", + "measured_on": "GitHub windows-latest - the nightly job's own runner - Python 3.12, coverage-uninstrumented. Each timing is the median of 7 publishes (measure_baselines.TIMING_REPEATS), and the value recorded is the SLOWEST of three workflow_dispatch runs of the same commit (runs 35464350837, 35464989364, 35464993252): runner-to-runner spread on one commit was up to 26% (stress.serialize_ms), so a baseline taken from one fast runner would fail the next slow one on noise. Sizes were identical across all three, as a deterministic metric should be.", "workloads": [ { "name": "small", "node_count": 25, "edge_count": 27, - "fixture_build_ms": 0.15, - "payload_build_ms": 0.37, - "serialize_ms": 0.44, - "publish_total_ms": 0.81, - "full_snapshot_kib": 76.2, - "single_node_payload_kib": 2.984 + "fixture_build_ms": 0.26, + "payload_build_ms": 0.1, + "serialize_ms": 0.07, + "publish_total_ms": 0.17, + "full_snapshot_kib": 15.1, + "single_node_payload_kib": 0.529 }, { "name": "typical", "node_count": 100, "edge_count": 137, - "fixture_build_ms": 0.7, - "payload_build_ms": 1.41, - "serialize_ms": 1.72, - "publish_total_ms": 3.14, - "full_snapshot_kib": 380.2, - "single_node_payload_kib": 2.604 + "fixture_build_ms": 2.64, + "payload_build_ms": 0.39, + "serialize_ms": 0.47, + "publish_total_ms": 0.83, + "full_snapshot_kib": 135.6, + "single_node_payload_kib": 0.168 }, { "name": "large", "node_count": 500, "edge_count": 697, - "fixture_build_ms": 8.75, - "payload_build_ms": 7.07, - "serialize_ms": 8.88, - "publish_total_ms": 15.95, - "full_snapshot_kib": 1902.3, - "single_node_payload_kib": 2.604 + "fixture_build_ms": 35.54, + "payload_build_ms": 1.99, + "serialize_ms": 2.67, + "publish_total_ms": 4.59, + "full_snapshot_kib": 679.3, + "single_node_payload_kib": 0.168 }, { "name": "stress", "node_count": 2000, "edge_count": 1999, - "fixture_build_ms": 61.85, - "payload_build_ms": 28.3, - "serialize_ms": 34.59, - "publish_total_ms": 62.89, - "full_snapshot_kib": 7645.0, - "single_node_payload_kib": 3.769 + "fixture_build_ms": 131.82, + "payload_build_ms": 7.34, + "serialize_ms": 11.13, + "publish_total_ms": 18.29, + "full_snapshot_kib": 2751.5, + "single_node_payload_kib": 1.33 } ] } diff --git a/backend/tests/perf/measure_baselines.py b/backend/tests/perf/measure_baselines.py index 3179fd55..057ba272 100644 --- a/backend/tests/perf/measure_baselines.py +++ b/backend/tests/perf/measure_baselines.py @@ -16,23 +16,41 @@ from __future__ import annotations import json +import statistics import time from backend.tests.perf.graph_factory import ALL_WORKLOADS, Workload +# Each timing is the MEDIAN of this many publishes of the same document, not +# one sample. A single sample of a 2-8ms operation on a shared CI runner +# swings past check_baseline.py's 20% threshold on scheduling jitter alone - +# the same noise that made the ungated fixture_build_ms fail 5 of 13 nightly +# runs - while the median of several moves only when the code does. The first +# publish also pays one-off warm-up (per-type lookups cached on first use), +# which the median discards rather than bills to every run. +TIMING_REPEATS = 7 + def _measure(workload: Workload) -> dict: t0 = time.perf_counter() doc = workload.build() build_s = time.perf_counter() - t0 - t0 = time.perf_counter() - payload = doc.scene_payload() - payload_build_s = time.perf_counter() - t0 + payload_samples: list[float] = [] + serialize_samples: list[float] = [] + total_samples: list[float] = [] + for _ in range(TIMING_REPEATS): + t0 = time.perf_counter() + payload = doc.scene_payload() + payload_build_s = time.perf_counter() - t0 - t0 = time.perf_counter() - serialized = json.dumps(payload) - serialize_s = time.perf_counter() - t0 + t0 = time.perf_counter() + serialized = json.dumps(payload) + serialize_s = time.perf_counter() - t0 + + payload_samples.append(payload_build_s) + serialize_samples.append(serialize_s) + total_samples.append(payload_build_s + serialize_s) total_bytes = len(serialized.encode("utf-8")) one_node_bytes = len(json.dumps(payload["nodes"][-1]).encode("utf-8")) if payload["nodes"] else 0 @@ -42,9 +60,9 @@ def _measure(workload: Workload) -> dict: "node_count": len(doc.nodes), "edge_count": len(doc.edges), "fixture_build_ms": round(build_s * 1000, 2), - "payload_build_ms": round(payload_build_s * 1000, 2), - "serialize_ms": round(serialize_s * 1000, 2), - "publish_total_ms": round((payload_build_s + serialize_s) * 1000, 2), + "payload_build_ms": round(statistics.median(payload_samples) * 1000, 2), + "serialize_ms": round(statistics.median(serialize_samples) * 1000, 2), + "publish_total_ms": round(statistics.median(total_samples) * 1000, 2), "full_snapshot_kib": round(total_bytes / 1024, 1), "single_node_payload_kib": round(one_node_bytes / 1024, 3), } diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index e469c616..1bb93fce 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -39,6 +39,7 @@ from backend.notifications import NotificationState from backend.tests.conftest import ( chat_slots, + client_rows, code_sandbox_slots, drain_runs, gitlink_run_slots, @@ -315,7 +316,7 @@ def test_scene_payload_includes_chat_fields_defaulted_for_placeholders(): doc = SceneDocument() doc.add_node(0, 0, "plain") doc.add_chat_node(1, 1, "real content", True) - rows = {n["title"]: n for n in doc.scene_payload()["nodes"]} + rows = {n["title"]: n for n in client_rows(doc)} assert rows["plain"]["kind"] == "placeholder" assert rows["plain"]["content"] == "" assert rows["plain"]["isUser"] is False @@ -427,12 +428,12 @@ def test_set_node_docked_toggles_true_then_false(): doc.set_node_docked(node.id, True) assert doc.nodes[node.id].is_docked is True - row = {n["id"]: n for n in doc.scene_payload()["nodes"]}[node.id] + row = {n["id"]: n for n in client_rows(doc)}[node.id] assert row["isDocked"] is True doc.set_node_docked(node.id, False) assert doc.nodes[node.id].is_docked is False - row = {n["id"]: n for n in doc.scene_payload()["nodes"]}[node.id] + row = {n["id"]: n for n in client_rows(doc)}[node.id] assert row["isDocked"] is False @@ -706,7 +707,7 @@ def blocking_chat(task, messages, **kwargs): # Mid-flight: pendingRequestId surfaces as non-None both on the # domain node and in scene_payload. assert document.nodes[node_id].pending_request_id is not None - rows = {n["id"]: n for n in document.scene_payload()["nodes"]} + rows = {n["id"]: n for n in client_rows(document)} assert rows[node_id]["pendingRequestId"] is not None assert rows[node_id]["pendingRequestId"] == document.nodes[node_id].pending_request_id @@ -722,7 +723,7 @@ def blocking_chat(task, messages, **kwargs): notice = await bus.publish("notification") assert notice["visible"] is False, "a real reply landing is not a deferral - no notification fires" assert document.nodes[node_id].pending_request_id is None, "cleared again once the reply lands" - rows = {n["id"]: n for n in document.scene_payload()["nodes"]} + rows = {n["id"]: n for n in client_rows(document)} assert rows[node_id]["pendingRequestId"] is None asyncio.run(run()) @@ -3445,11 +3446,11 @@ def test_scene_payload_includes_artifact_content_for_artifact_kind_rows(): parent = doc.add_node(1, 1, "parent") node = doc.add_artifact_node(2, 2, parent.id) - rows = {n["id"]: n for n in doc.scene_payload()["nodes"]} + rows = {n["id"]: n for n in client_rows(doc)} assert rows["n0"]["artifactContent"] == "" doc.complete_artifact_generation(node.id, "the drafted document", "done") - row = {n["id"]: n for n in doc.scene_payload()["nodes"]}[node.id] + row = {n["id"]: n for n in client_rows(doc)}[node.id] assert row["kind"] == "artifact" assert row["artifactContent"] == "the drafted document" @@ -3611,7 +3612,7 @@ def test_store_gitlink_context_excluded_from_scene_payload(): assert node.state.gitlink_context_xml == huge_xml, "the domain object DOES hold the full text" - row = {n["id"]: n for n in doc.scene_payload()["nodes"]}[node.id] + row = {n["id"]: n for n in client_rows(doc)}[node.id] assert "gitlinkContextXml" not in row assert row["gitlinkContextSummary"] == "Scanned 3 files." assert row["gitlinkScopeMode"] == "selected" @@ -4258,7 +4259,7 @@ def test_scene_payload_code_sandbox_fields_default_correctly_and_excludes_sandbo doc = SceneDocument() parent = doc.add_node(0, 0, "parent") node = doc.add_code_sandbox_node(0, 0, parent.id) - row = {n["id"]: n for n in doc.scene_payload()["nodes"]}[node.id] + row = {n["id"]: n for n in client_rows(doc)}[node.id] assert "codeSandboxSandboxId" not in row, ( "the internal sandbox directory-naming key must never reach the wire" ) @@ -5800,7 +5801,7 @@ def test_scene_payload_exposes_note_frame_and_container_fields(): frame = doc.create_frame([m1.id]) doc.set_group_color(frame.id, "#4a7c59", "#2f5b3c") - payload_by_id = {n["id"]: n for n in doc.scene_payload()["nodes"]} + payload_by_id = {n["id"]: n for n in client_rows(doc)} note_row = payload_by_id[note.id] assert note_row["kind"] == "note" @@ -5986,8 +5987,7 @@ def test_scene_payload_exposes_all_chart_fields(): parent = doc.add_node(0, 0, "parent") chart = doc.add_chart_node(0, 0, parent.id, "bar", dict(_CHART_DATA), chart_error="degraded") - payload = doc.scene_payload() - row = next(n for n in payload["nodes"] if n["id"] == chart.id) + row = next(n for n in client_rows(doc) if n["id"] == chart.id) assert row["chartType"] == "bar" assert row["chartData"] == _CHART_DATA @@ -6354,17 +6354,20 @@ def test_scene_payload_round_trips_content_parts_with_base64_encoded_image_bytes assert isinstance(chat_node.state.content_parts[1]["data"], bytes) -def test_scene_payload_content_parts_is_none_not_empty_list_when_unset(): +def test_scene_payload_omits_content_parts_rather_than_sending_an_empty_list_when_unset(): # R6.3: "no multimodal content" (None) must stay distinguishable on the # wire from "multimodal content that happens to be empty" ([]) - the - # overwhelmingly common plain-text chat node must get contentParts: - # null, never []. + # overwhelmingly common plain-text chat node must not get contentParts: + # [] - it is left off the row entirely. doc = SceneDocument() chat_node = doc.add_chat_node(0, 0, "plain text only", True) assert chat_node.state.content_parts is None payload_node = next(n for n in doc.scene_payload()["nodes"] if n["id"] == chat_node.id) - assert payload_node["contentParts"] is None + # Sparse wire (backend/domain/node_wire.py): None is the default, so the + # key is left out - which still distinguishes it from an empty list, + # the one value that IS sent. + assert "contentParts" not in payload_node # -- R7.5e: Collapse All / Expand All ----------------------------------------- @@ -7034,7 +7037,7 @@ def test_scene_payload_includes_branch_status_and_final_deliverable(): doc.set_final_deliverable(node.id, True) other = doc.add_chat_node(0, 160, "other", True) - rows = {n["id"]: n for n in doc.scene_payload()["nodes"]} + rows = {n["id"]: n for n in client_rows(doc)} assert rows[node.id]["branchStatus"] == "accepted" assert rows[node.id]["isFinalDeliverable"] is True assert rows[other.id]["branchStatus"] == "active" diff --git a/backend/tests/test_node_wire.py b/backend/tests/test_node_wire.py new file mode 100644 index 00000000..0cd2b119 --- /dev/null +++ b/backend/tests/test_node_wire.py @@ -0,0 +1,242 @@ +"""The sparse per-node wire row (backend/domain/node_wire.py). + +A node sends its identity keys plus every field that differs from the +contract default, and the client restores the rest from the same defaults +(the generated hydrateSceneNodeRow). These tests pin the three things that +arrangement depends on: the backend's defaults ARE the contract's, every +contract field has a route onto the wire, and "differs from the default" is +decided strictly enough that restoring a row never changes a value. +""" + +from __future__ import annotations + +import dataclasses +import json +import sys +from pathlib import Path + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +import backend.domain.node_states as node_states +from backend.domain.graph import SceneDocument +from backend.domain.model import SceneNode +from backend.domain.node_wire import IDENTITY_KEYS, WIRE_DEFAULTS, _NODE_FIELDS, _fields_for, wire_keys +from backend.tests.conftest import client_row +from graphlink_wire_schema import NO_DEFAULT, wire_default + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "contracts")) + +from graphlink_scene_payload import SceneNodeRow # noqa: E402 + +STATE_CLASSES = ( + node_states.ArtifactState, + node_states.ChartState, + node_states.ChatState, + node_states.CodeReviewState, + node_states.CodeSandboxState, + node_states.CodeState, + node_states.ContainerState, + node_states.DocumentState, + node_states.FrameState, + node_states.GitlinkState, + node_states.HarnessState, + node_states.HtmlState, + node_states.ImageState, + node_states.NoteState, + node_states.PlanState, + node_states.WebResearchState, +) + + +def _node(state=None, **fields) -> tuple[SceneDocument, SceneNode]: + doc = SceneDocument() + node = SceneNode(id="n1", x=1.0, y=2.0, title="t", kind="k", state=state, **fields) + doc.nodes[node.id] = node + return doc, node + + +# -- the contract ------------------------------------------------------------ + + +def test_the_backend_defaults_are_the_contract_defaults(): + # The client restores omitted fields from SceneNodeRow's declared + # defaults, the backend omits by WIRE_DEFAULTS - they must be one table. + contract = { + field.name: default + for field in dataclasses.fields(SceneNodeRow) + if (default := wire_default(field)) is not NO_DEFAULT + } + backend = {key: value for key, value in WIRE_DEFAULTS.items() if key != "contentParts"} + assert backend == contract + # Equal is not enough: 0 == False and 480 == 480.0 in Python, and the + # omission test is type-strict, so a default of the wrong type would + # send values the client already has. + assert {k: type(v) for k, v in backend.items()} == {k: type(v) for k, v in contract.items()} + + +def test_every_contract_field_has_a_route_onto_the_wire(): + contract_keys = {field.name for field in dataclasses.fields(SceneNodeRow)} + assert wire_keys() == contract_keys | {"contentParts"} + no_default = {f.name for f in dataclasses.fields(SceneNodeRow) if wire_default(f) is NO_DEFAULT} + assert set(IDENTITY_KEYS) == no_default + + +def test_no_field_is_owned_twice(): + node_level = {entry[0] for entry in _NODE_FIELDS} + for state_class in STATE_CLASSES: + keys = [entry[0] for entry in _fields_for(state_class)] + assert len(keys) == len(set(keys)), state_class.__name__ + assert not node_level & set(keys), state_class.__name__ + + +# -- omission ------------------------------------------------------------------ + + +@pytest.mark.parametrize("state_class", [None, *STATE_CLASSES], ids=lambda c: getattr(c, "__name__", "None")) +def test_a_node_at_its_defaults_sends_little_more_than_its_identity(state_class): + doc, node = _node(state_class() if state_class else None) + row = doc._node_wire(node) + sent = set(row) - set(IDENTITY_KEYS) + # Two state classes start away from the contract default on purpose: a + # new Builder plan and a new harness node begin with real budgets and a + # real status, so those fields are information, not padding. + expected = { + node_states.PlanState: {"builderMaxSteps", "builderMaxTokens", "builderMaxWallSeconds", "builderMode", "builderStatus"}, + node_states.HarnessState: {"harnessMaxContextTokens", "harnessMaxTurns", "harnessStatus"}, + }.get(state_class, set()) + assert sent == expected + + +def test_a_field_moved_off_its_default_is_sent_and_left_out_again_when_it_returns(): + doc, node = _node(node_states.ChatState()) + node.state.branch_status = "accepted" + node.pending_request_id = "req-1" + row = doc._node_wire(node) + assert row["branchStatus"] == "accepted" + assert row["pendingRequestId"] == "req-1" + + node.state.branch_status = "active" + node.pending_request_id = None + row = doc._node_wire(node) + assert "branchStatus" not in row + assert "pendingRequestId" not in row + assert client_row(row)["branchStatus"] == "active" + assert client_row(row)["pendingRequestId"] is None + + +def test_omission_is_type_strict(): + # Each of these equals its default under Python's ==, but not as JSON: + # False is not 0, 1 is not true, 480 is not the float default. They + # must be sent as-is, never folded into the default. + doc, node = _node(node_states.WebResearchState()) + node.state.research_completed = False + assert doc._node_wire(node)["researchCompleted"] is False + + doc, node = _node(node_states.ChatState()) + node.state.is_user = 0 + assert doc._node_wire(node)["isUser"] == 0 + + doc, node = _node(node_states.FrameState()) + node.state.is_locked = 1 + assert doc._node_wire(node)["isLocked"] == 1 + + +def test_content_parts_none_is_left_out_but_an_empty_list_is_sent(): + doc, node = _node(node_states.ChatState()) + assert "contentParts" not in doc._node_wire(node) + node.state.content_parts = [] + assert doc._node_wire(node)["contentParts"] == [] + + +def test_a_field_reset_to_its_default_still_produces_a_patch(): + # take_dirty_patch_ops diffs whole rows. A field returning to its default + # REMOVES a key, and the upsert that carries the shorter row is what tells + # the client (which replaces the row and restores the default) to drop + # the old value - e.g. a node that finished running must stop looking busy. + doc = SceneDocument() + node = doc.add_node(0, 0, "n") + assert doc.take_dirty_patch_ops() is None # first publish: full snapshot + node.pending_request_id = "req-1" + ops = doc.take_dirty_patch_ops() + assert ops == [{"op": "upsertNode", "node": doc._node_wire(node)}] + assert ops[0]["node"]["pendingRequestId"] == "req-1" + + node.pending_request_id = None + ops = doc.take_dirty_patch_ops() + assert ops is not None and len(ops) == 1 + assert "pendingRequestId" not in ops[0]["node"] + assert client_row(ops[0]["node"])["pendingRequestId"] is None + + +# -- restoring a row changes nothing ------------------------------------------- + + +def _dense_row(doc: SceneDocument, node: SceneNode) -> dict: + """The row with NOTHING omitted - what every node used to send.""" + row = {key: getattr(node, key) for key in IDENTITY_KEYS} + for key, default in WIRE_DEFAULTS.items(): + row[key] = default + for key, attr, transform, _ in _NODE_FIELDS: + value = getattr(node, attr) + row[key] = transform(value) if transform else value + if node.state is not None: + for key, attr, transform, _ in _fields_for(type(node.state)): + value = getattr(node.state, attr) + row[key] = transform(value) if transform else value + row["isFinalDeliverable"] = node.id == doc.final_deliverable_node_id + row["pluginState"] = doc._plugin_state_wire(node) + return row + + +_SCALARS = st.one_of( + st.none(), st.booleans(), st.integers(-3, 3), st.sampled_from([0.0, 480.0, 340.0, 0.5]), + st.sampled_from(["", "active", "selected", "draft", "none", "x"]), +) + + +def _value_for(field: dataclasses.Field) -> st.SearchStrategy: + default = field.default_factory() if field.default_factory is not dataclasses.MISSING else field.default + if isinstance(default, list) or field.name == "content_parts": + # Every list the builders read holds dicts they index by name; an + # empty list is the case the omission logic has to get right. + return st.just([]) + if isinstance(default, dict): + return st.sampled_from([{}, {"k": "v"}]) + return _SCALARS + + +@st.composite +def _nodes(draw): + state_class = draw(st.sampled_from([None, *STATE_CLASSES])) + state = state_class() if state_class else None + if state is not None: + for field in dataclasses.fields(state): + if draw(st.booleans()): + setattr(state, field.name, draw(_value_for(field))) + doc, node = _node(state) + node.content = draw(st.sampled_from(["", "hello"])) + node.is_collapsed = draw(st.sampled_from([False, True, 0, 1])) + node.pending_request_id = draw(st.sampled_from([None, "", "req"])) + node.color = draw(st.sampled_from([None, "", "#fff"])) + node.item_ids = draw(st.sampled_from([[], ["a"]])) + if draw(st.booleans()): + doc.final_deliverable_node_id = node.id + return doc, node + + +@given(_nodes()) +def test_a_restored_row_is_exactly_the_row_that_used_to_be_sent(case): + doc, node = case + sparse = doc._node_wire(node) + dense = _dense_row(doc, node) + # Compared as JSON TEXT - what actually crosses the wire and what the + # client holds after restoring. Comparing the dicts would be blind to + # exactly the bug worth catching here: False == 0 in Python, but a row + # restored as `false` where `0` was sent is a changed value. + restored = client_row(json.loads(json.dumps(sparse))) + expected = json.loads(json.dumps(dense)) + if dense["contentParts"] is None: + del expected["contentParts"] + assert json.dumps(restored, sort_keys=True) == json.dumps(expected, sort_keys=True) diff --git a/backend/tests/test_plugin_grants.py b/backend/tests/test_plugin_grants.py index 16e1eafc..303763cc 100644 --- a/backend/tests/test_plugin_grants.py +++ b/backend/tests/test_plugin_grants.py @@ -43,6 +43,7 @@ ) from backend.plugins import plugins_payload, register_plugins from backend.session_load import restore_chat_into_document +from backend.tests.conftest import client_row from backend.session_save import build_chat_data from graphlink_settings_store import SettingsManager @@ -293,14 +294,14 @@ def test_revoking_a_plugins_grant_stops_its_live_wire_serializer_from_running(tm # Granted: the live wire carries the plugin's own custom state. wire = canvas_document.scene_payload() - node_wire = next(n for n in wire["nodes"] if n["id"] == node_id) + node_wire = next(client_row(n) for n in wire["nodes"] if n["id"] == node_id) assert node_wire["pluginState"] == {"clicks": "3"} # Revoke - the SAME live node, no re-registration, no session restart. settings_manager.set_plugin_grant("statefulgrant1", False) wire2 = canvas_document.scene_payload() - node_wire2 = next(n for n in wire2["nodes"] if n["id"] == node_id) + node_wire2 = next(client_row(n) for n in wire2["nodes"] if n["id"] == node_id) # Degrades to {} (the same "no plugin state to show" shape a plugin # with no serializer at all produces) - proving the wrapper re-checks # the CURRENT grant on every publish, not just at registration time. diff --git a/backend/tests/test_plugin_sdk.py b/backend/tests/test_plugin_sdk.py index 3e25821c..ecc2152b 100644 --- a/backend/tests/test_plugin_sdk.py +++ b/backend/tests/test_plugin_sdk.py @@ -28,6 +28,7 @@ from backend.plugins import register_plugins from backend.session_load import restore_chat_into_document from backend.session_save import build_chat_data +from backend.tests.conftest import client_row from graphlink_settings_store import SettingsManager # The three demo plugins (hello_node, counter_node, sandboxed_demo) live in @@ -743,13 +744,13 @@ def test_live_wire_scene_payload_includes_plugin_state_when_serializer_registere ) wire = canvas_document.scene_payload() - node_wire = next(n for n in wire["nodes"] if n["id"] == node_id) + node_wire = next(client_row(n) for n in wire["nodes"] if n["id"] == node_id) # Coerced to str on the wire (SceneNodeRow.pluginState is dict[str, str] # - see contracts/graphlink_scene_payload.py's own comment) even though # `clicks` is a real int in memory and in the save file. assert node_wire["pluginState"] == {"clicks": "3", "label": "initial"} - parent_wire = next(n for n in wire["nodes"] if n["id"] == parent.id) + parent_wire = next(client_row(n) for n in wire["nodes"] if n["id"] == parent.id) assert parent_wire["pluginState"] == {} @@ -764,7 +765,7 @@ def test_live_wire_plugin_state_is_empty_for_a_plugin_kind_with_no_serializer(tm ) wire = canvas_document.scene_payload() - node_wire = next(n for n in wire["nodes"] if n["id"] == node_id) + node_wire = next(client_row(n) for n in wire["nodes"] if n["id"] == node_id) assert node_wire["pluginState"] == {} @@ -800,7 +801,7 @@ def register(host: HostContext) -> None: # Live wire: a raising serializer degrades to {}, never crashes the # whole scene publish. wire = canvas_document.scene_payload() - node_wire = next(n for n in wire["nodes"] if n["id"] == node_id) + node_wire = next(client_row(n) for n in wire["nodes"] if n["id"] == node_id) assert node_wire["pluginState"] == {} # Save file: same degrade-not-crash posture - the node's universal diff --git a/backend/tests/test_wire_schema_validation.py b/backend/tests/test_wire_schema_validation.py index 24ace38b..4ace0a4d 100644 --- a/backend/tests/test_wire_schema_validation.py +++ b/backend/tests/test_wire_schema_validation.py @@ -270,8 +270,9 @@ def test_a_sankey_flow_with_a_wrong_typed_field_is_reported_at_its_own_indexed_n def test_a_non_chart_node_s_all_optional_default_chart_data_row_still_validates_with_no_errors(): - # The non-chart-node majority path: chartData is ALWAYS present on the - # wire (never omitted - see SceneNodeRow's own field comment) but every + # The non-chart-node majority path: a sparse row omits chartData for a + # non-chart node and the client restores it as `{}` (backend/domain/ + # node_wire.py) - the shape validated here - so it is present, but every # one of its fields is None for a node that was never a chart. payload = _chart_node_payload(kind="chat", chartType="") assert validate_payload(payload, SceneNodeRow) == [] diff --git a/contracts/codegen.py b/contracts/codegen.py index ba36b984..bd1f4e2b 100644 --- a/contracts/codegen.py +++ b/contracts/codegen.py @@ -41,7 +41,7 @@ # added explicitly rather than relying on cwd or an editable install. sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from graphlink_wire_schema import json_schema_for +from graphlink_wire_schema import NO_DEFAULT, json_schema_for, omits_defaults, wire_default __all__ = ["schema_json_for", "typescript_for", "GENERATED_ARTIFACTS"] @@ -153,11 +153,29 @@ def _annotation_to_ts(annotation: Any) -> str: def typescript_for(dataclass_type: type, *, source: str) -> str: - """Emit TS interfaces plus a runtime validator for the payload graph.""" + """Emit TS interfaces plus a runtime validator for the payload graph. + + The validator is DATA: one `[name, type, optional]` table per dataclass, + compiled into a checker once at module load by web_ui/src/lib/bridge-core/ + wireCheck.ts. It used to be emitted as unrolled code, one block per field - + ~190 minified bytes a field, 87 KB across every topic, all of it in the + initial chunk. A table row is ~25 bytes; wireCheck.ts's own docstring + covers why the compiled checker behaves (and performs) like the unrolled + code did.""" collected: dict[str, dict[str, Any]] = {} _collect_object_types(dataclass_type, out=collected) - - lines: list[str] = [_HEADER.format(source=source)] + sparse_fields = _root_sparse_fields(dataclass_type) + + runtime = ["compileFields", "type ValidationResult", "type WireFields"] + if sparse_fields: + runtime += ["hydrateRow", "isRecord", "wireDefaults"] + lines: list[str] = [ + _HEADER.format(source=source), + f'import {{ {", ".join(sorted(runtime, key=lambda n: n.removeprefix("type ")))} }} from "../wireCheck";', + "", + "export type { ValidationResult };", + "", + ] # Interfaces, root last so the file reads bottom-up like the payload nests. root_name = _ts_type_name(dataclass_type.__name__) @@ -181,141 +199,218 @@ def typescript_for(dataclass_type: type, *, source: str) -> str: lines.append("}") lines.append("") - lines.append(_VALIDATOR_PREAMBLE) + # Field tables, each after every table it references - a const cannot be + # read before its declaration runs. + for row_type in _dataclasses_dependencies_first(dataclass_type): + lines.append(_field_table_for(row_type)) - for name in ordered: - lines.append(_validator_for(name, collected[name])) - - lines.append( - f"export function validate{root_name}(value: unknown): ValidationResult<{root_name}> {{\n" - f" const errors: string[] = [];\n" - f" check{root_name}(value, \"$\", errors);\n" - f" return errors.length === 0\n" - f" ? {{ ok: true, value: value as {root_name} }}\n" - f" : {{ ok: false, errors }};\n" - f"}}\n" - ) + for row_type in dict.fromkeys(row for row, _ in sparse_fields.values()): + lines.append(_hydrator_for(row_type)) + if sparse_fields: + lines.append(_root_hydrator_for(root_name, sparse_fields)) + + table = _field_table_name(dataclass_type) + lines.append(f"const check{root_name} = compileFields({table});") + lines.append("") + if sparse_fields: + # The one change a sparse row makes to its root's validator: restore + # the omitted fields FIRST, then validate - and hand back the restored + # value, so every reader downstream sees the full row. + lines.append( + f"export function validate{root_name}(value: unknown): ValidationResult<{root_name}> {{\n" + f" const hydrated = hydrate{root_name}(value);\n" + f" const errors: string[] = [];\n" + f" check{root_name}(hydrated, \"$\", errors);\n" + f" return errors.length === 0\n" + f" ? {{ ok: true, value: hydrated as {root_name} }}\n" + f" : {{ ok: false, errors }};\n" + f"}}\n" + ) + else: + lines.append( + f"export function validate{root_name}(value: unknown): ValidationResult<{root_name}> {{\n" + f" const errors: string[] = [];\n" + f" check{root_name}(value, \"$\", errors);\n" + f" return errors.length === 0\n" + f" ? {{ ok: true, value: value as {root_name} }}\n" + f" : {{ ok: false, errors }};\n" + f"}}\n" + ) return "\n".join(lines) -_VALIDATOR_PREAMBLE = '''export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; +def _field_table_name(dataclass_type: type) -> str: + """SceneNodeRow -> SCENE_NODE_ROW_FIELDS.""" + return f"{_screaming_snake(_ts_type_name(dataclass_type.__name__))}_FIELDS" -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. -''' +def _dataclasses_dependencies_first(root: type) -> list[type]: + """Every dataclass in the payload graph, each after the ones it nests.""" + import dataclasses + import typing + order: list[type] = [] -def _ts_check_expr(annotation: Any, *, value_expr: str, path_expr: str) -> str: - """Inline runtime check for one value, appending to `errors`. + def visit(dataclass_type: type, stack: tuple[type, ...]) -> None: + if dataclass_type in order: + return + if dataclass_type in stack: + raise ValueError(f"{dataclass_type.__name__} nests itself; a field table cannot express that") + hints = typing.get_type_hints(dataclass_type) + for field in dataclasses.fields(dataclass_type): + for nested in _nested_dataclasses(_strip_optional(hints[field.name])): + visit(nested, (*stack, dataclass_type)) + order.append(dataclass_type) - `path_expr` is a complete JS expression evaluating to a string. Error - messages concatenate onto it rather than interpolating it into another - template literal, which would nest backticks and emit valid-but-ugly - `${`${path}.id`}` generated code. - """ + visit(root, ()) + return order + + +def _wire_type(annotation: Any) -> str: + """One field's type as a wireCheck.ts WireType literal.""" import dataclasses from typing import Literal, get_args, get_origin if dataclasses.is_dataclass(annotation) and isinstance(annotation, type): - name = _ts_type_name(annotation.__name__) - return f"check{name}({value_expr}, {path_expr}, errors);" - - origin = get_origin(annotation) - - if origin is Literal: - allowed = list(get_args(annotation)) - allowed_ts = ", ".join(json.dumps(v) for v in allowed) - allowed_msg = json.dumps(", ".join(allowed)) - return ( - f"if (![{allowed_ts}].includes({value_expr} as string)) " - f"errors.push({path_expr} + `: ${{JSON.stringify({value_expr})}} is not one of " - f"[` + {allowed_msg} + `]`);" - ) - + return f"{{ o: {_field_table_name(annotation)} }}" if annotation is str: - return ( - f'if (typeof {value_expr} !== "string") ' - f'errors.push({path_expr} + ": expected string");' - ) + return '"s"' if annotation is bool: - return ( - f'if (typeof {value_expr} !== "boolean") ' - f'errors.push({path_expr} + ": expected boolean");' - ) + return '"b"' if annotation in (int, float): - return ( - f'if (typeof {value_expr} !== "number") ' - f'errors.push({path_expr} + ": expected number");' - ) + return '"n"' + origin = get_origin(annotation) + if origin is Literal: + return "{ e: [" + ", ".join(json.dumps(v) for v in get_args(annotation)) + "] }" if origin is list: (item,) = get_args(annotation) - inner = _ts_check_expr( - item, value_expr="item", path_expr=f"{path_expr} + `[${{i}}]`" - ) - return ( - f"if (!Array.isArray({value_expr})) " - f'errors.push({path_expr} + ": expected array");\n' - f" else ({value_expr} as unknown[]).forEach((item, i) => {{ {inner} }});" - ) - + return f"{{ a: {_wire_type(item)} }}" if origin is dict: - _, value_type = get_args(annotation) - # Bracket notation with a JSON-stringified key, not `.${k}` - a dict - # key is arbitrary string data (e.g. --gl-* custom property names - # today, but this generator has no way to know that won't ever be a - # key containing "." or "[0]"), and dot-appending it would make an - # error path indistinguishable from a genuinely deeper nested path. - # JSON.stringify also makes the key visible verbatim in the message - # rather than silently truncated at a special character. - inner = _ts_check_expr( - value_type, value_expr="v", path_expr=f"{path_expr} + `[${{JSON.stringify(k)}}]`" - ) - return ( - f"if (!isRecord({value_expr})) " - f'errors.push({path_expr} + ": expected object");\n' - f" else Object.entries({value_expr} as Record)" - f".forEach(([k, v]) => {{ {inner} }});" - ) + _, value = get_args(annotation) + return f"{{ d: {_wire_type(value)} }}" + raise ValueError(f"cannot render a wire type for: {annotation!r}") + + +def _field_table_for(dataclass_type: type) -> str: + """`[name, type, optional]` per field - plus, for a sparse row, the wire + default its sender may leave the field out at.""" + import dataclasses + import typing - raise ValueError(f"cannot render TS runtime check for: {annotation!r}") + hints = typing.get_type_hints(dataclass_type) + sparse = omits_defaults(dataclass_type) + rows: list[str] = [] + for field in dataclasses.fields(dataclass_type): + annotation = _strip_optional(hints[field.name]) + optional = 1 if annotation is not hints[field.name] else 0 + row = f"{json.dumps(field.name)}, {_wire_type(annotation)}, {optional}" + default = wire_default(field) if sparse else NO_DEFAULT + if default is not NO_DEFAULT: + row += f", {json.dumps(default)}" + rows.append(f" [{row}],") + return "\n".join([f"const {_field_table_name(dataclass_type)}: WireFields = [", *rows, "];", ""]) + + +def _root_sparse_fields(root: type) -> dict[str, tuple[type, bool]]: + """{root field name: (sparse row type, held as a list)} for every root + field that holds a sparse row (graphlink_wire_schema.py's "SPARSE ROWS") + directly or as a list. Restoring is only generated at that depth; a sparse + row anywhere deeper raises here, so one can never ship with nothing + restoring it.""" + import dataclasses + import typing + from typing import get_args, get_origin + + hints = typing.get_type_hints(root) + direct: dict[str, tuple[type, bool]] = {} + for field in dataclasses.fields(root): + annotation = hints[field.name] + is_list = get_origin(annotation) is list + if is_list: + (annotation,) = get_args(annotation) + if isinstance(annotation, type) and dataclasses.is_dataclass(annotation) and omits_defaults(annotation): + direct[field.name] = (annotation, is_list) + + for dataclass_type in _dataclasses_dependencies_first(root): + nested_hints = typing.get_type_hints(dataclass_type) + for field in dataclasses.fields(dataclass_type): + for nested in _nested_dataclasses(_strip_optional(nested_hints[field.name])): + if omits_defaults(nested) and not (dataclass_type is root and field.name in direct): + raise ValueError( + f"{nested.__name__} omits defaults but sits below {root.__name__}.{field.name}; " + "codegen only restores sparse rows held directly by the root payload" + ) + return direct + + +def _strip_optional(annotation: Any) -> Any: + import types as _types + import typing + from typing import get_args, get_origin + + if get_origin(annotation) in (_types.UnionType, typing.Union): + args = [a for a in get_args(annotation) if a is not type(None)] + if len(args) != 1: + raise ValueError(f"cannot render a wire type for a multi-type union: {annotation!r}") + return args[0] + return annotation + + +def _screaming_snake(name: str) -> str: + """SceneNodeRow -> SCENE_NODE_ROW.""" + return "".join(f"_{c}" if c.isupper() and i else c for i, c in enumerate(name)).upper() + + +def _hydrator_for(row_type: type) -> str: + """The restore half of a sparse row: its defaults, read off its own field + table, and hydrate() - see wireCheck.ts's hydrateRow.""" + name = _ts_type_name(row_type.__name__) + defaults = f"{_screaming_snake(name)}_WIRE_DEFAULTS" + return "\n".join([ + f"/** {name} crosses the wire sparse: the sender omits every field at its", + " * declared default (contracts: WIRE_OMITS_DEFAULTS). These are those", + " * defaults - the value every omitted field is restored to. */", + f"export const {defaults} = wireDefaults({_field_table_name(row_type)});", + "", + f"export function hydrate{name}(value: unknown): unknown {{", + f" return hydrateRow({_field_table_name(row_type)}, {defaults}, value);", + "}", + "", + ]) -def _validator_for(name: str, fields: dict[str, Any]) -> str: - body: list[str] = [ - f"function check{name}(value: unknown, path: string, errors: string[]): void {{", - " if (!isRecord(value)) { errors.push(`${path}: expected object`); return; }", +def _root_hydrator_for(root_name: str, sparse_fields: dict[str, tuple[type, bool]]) -> str: + body = [ + f"function hydrate{root_name}(value: unknown): unknown {{", + " if (!isRecord(value)) return value;", + " let hydrated: Record | null = null;", ] - for field_name, info in fields.items(): - annotation = info["annotation"] - optional = info["optional"] - access = f'value[{json.dumps(field_name)}]' - field_path = "`${path}." + field_name + "`" - check = _ts_check_expr(annotation, value_expr="fieldValue", path_expr=field_path) - body.append(" {") - body.append(f" const fieldValue = {access};") - if optional: - body.append(f" if (fieldValue !== undefined && fieldValue !== null) {{ {check} }}") + # Only well-shaped fields are restored; anything else is left exactly as + # it arrived, for the validator to reject. + for field_name, (row_type, is_list) in sparse_fields.items(): + row = _ts_type_name(row_type.__name__) + key = json.dumps(field_name) + if is_list: + body += [ + " {", + f" const rows = value[{key}];", + " if (Array.isArray(rows)) {", + f" const restored = rows.map(hydrate{row});", + f" if (restored.some((row, i) => row !== rows[i])) (hydrated ??= {{ ...value }})[{key}] = restored;", + " }", + " }", + ] else: - body.append( - f" if (fieldValue === undefined || fieldValue === null) " - f"errors.push(`${{path}}.{field_name}: missing required field`);" - ) - body.append(f" else {{ {check} }}") - body.append(" }") - body.append("}") - body.append("") + body += [ + " {", + f" const row = value[{key}];", + f" const restored = hydrate{row}(row);", + f" if (restored !== row) (hydrated ??= {{ ...value }})[{key}] = restored;", + " }", + ] + body += [" return hydrated ?? value;", "}", ""] return "\n".join(body) diff --git a/contracts/graphlink_scene_payload.py b/contracts/graphlink_scene_payload.py index b9f49299..18105a99 100644 --- a/contracts/graphlink_scene_payload.py +++ b/contracts/graphlink_scene_payload.py @@ -143,7 +143,7 @@ fields (Source/Preview splitter position, chat scroll position) - populated for kind=="html"/kind=="chat" respectively, defaulted for every other kind. `contentParts` is DELIBERATELY NOT one of these 26: -it is a real wire field (backend/domain/graph.py's _content_parts_wire) +it is a real wire field (backend/domain/node_wire.py's _content_parts_wire) but SceneCanvas.tsx never reads it today (a backend-only multimodal round-trip capability, not a rendered feature) - C9 is specifically about fields an unsafe CAST reaches for, and no cast reaches for this @@ -189,7 +189,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal +from typing import ClassVar, Literal @dataclass @@ -464,6 +464,15 @@ class ChartDataRow: @dataclass class SceneNodeRow: + # Sparse on the wire: the sender (backend/domain/node_wire.py) leaves out + # every field that sits at its default below, and the client restores + # them from these same defaults - codegen emits hydrateSceneNodeRow(), + # which validateSceneState and the scene store's patch path run before + # anything reads a row. So every default here is ALSO the value a node + # of another kind reads for the field; change one deliberately. + # (graphlink_wire_schema.py: "SPARSE ROWS".) + WIRE_OMITS_DEFAULTS: ClassVar[bool] = True + id: str x: float y: float diff --git a/graphlink_wire_schema.py b/graphlink_wire_schema.py index a9cea228..47e5c8ed 100644 --- a/graphlink_wire_schema.py +++ b/graphlink_wire_schema.py @@ -39,6 +39,14 @@ The refusal to guess is the point: a future payload field typed as something outside this set fails loudly at generation time instead of producing a schema that quietly disagrees with the real payload. + +SPARSE ROWS: a payload dataclass that sets `WIRE_OMITS_DEFAULTS = True` (a +ClassVar, so never a field) is sent with every field that sits at its declared +default left out, and the receiver restores those from the same declaration - +the scene topic's SceneNodeRow, whose sender is backend/domain/node_wire.py and +whose client-side restore is generated by contracts/codegen.py. For such a row +a defaulted field is not required on the wire; its schema property carries the +restored value as `default`. Every other dataclass is unaffected. """ from __future__ import annotations @@ -51,6 +59,9 @@ __all__ = [ "json_schema_for", "validate_payload", + "omits_defaults", + "wire_default", + "NO_DEFAULT", "SchemaGenerationError", ] @@ -63,6 +74,36 @@ def _is_dataclass_type(annotation: Any) -> bool: return dataclasses.is_dataclass(annotation) and isinstance(annotation, type) +def omits_defaults(dataclass_type: type) -> bool: + """True for a sparse row - see this module's docstring.""" + return bool(getattr(dataclass_type, "WIRE_OMITS_DEFAULTS", False)) + + +NO_DEFAULT = object() + + +def wire_default(field: dataclasses.Field[Any]) -> Any: + """The JSON value a receiver restores for `field` when a sparse row omits + it, or NO_DEFAULT when the field declares none (always sent). A nested + dataclass default becomes its dict with every None field left out - the + shape the sender uses for an unset nested row (SceneNodeRow.chartData is + `{}` for every non-chart node). Container defaults must be EMPTY: a + receiver restores them as fresh empty values, never shared instances.""" + if field.default is not dataclasses.MISSING: + value = field.default + elif field.default_factory is not dataclasses.MISSING: + value = field.default_factory() + else: + return NO_DEFAULT + if dataclasses.is_dataclass(value) and not isinstance(value, type): + value = {k: v for k, v in dataclasses.asdict(value).items() if v is not None} + if isinstance(value, (list, dict)) and value: + raise SchemaGenerationError( + f"{field.name}: a sparse row's container default must be empty, got {value!r}" + ) + return value + + def _unwrap_optional(annotation: Any) -> tuple[Any, bool]: """Return (inner_type, is_optional) for `X | None` / Optional[X].""" origin = get_origin(annotation) @@ -145,12 +186,16 @@ def json_schema_for(dataclass_type: type, *, _path: str = "") -> dict[str, Any]: properties: dict[str, Any] = {} required: list[str] = [] + sparse = omits_defaults(dataclass_type) for field in dataclasses.fields(dataclass_type): annotation = hints[field.name] field_path = f"{_path}.{field.name}" if _path else f"{dataclass_type.__name__}.{field.name}" _, is_optional = _unwrap_optional(annotation) properties[field.name] = _schema_for_annotation(annotation, path=field_path) - if not is_optional: + default = wire_default(field) if sparse else NO_DEFAULT + if default is not NO_DEFAULT: + properties[field.name]["default"] = default + elif not is_optional: required.append(field.name) schema: dict[str, Any] = { @@ -181,6 +226,7 @@ def validate_payload(payload: Any, dataclass_type: type, *, _path: str = "$") -> hints = typing.get_type_hints(dataclass_type) known: set[str] = set() + sparse = omits_defaults(dataclass_type) for field in dataclasses.fields(dataclass_type): annotation = hints[field.name] @@ -189,7 +235,7 @@ def validate_payload(payload: Any, dataclass_type: type, *, _path: str = "$") -> field_path = f"{_path}.{field.name}" if field.name not in payload: - if not is_optional: + if not is_optional and not (sparse and wire_default(field) is not NO_DEFAULT): errors.append(f"{field_path}: missing required field") continue diff --git a/requirements.txt b/requirements.txt index efca2474..a2dd9abc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,9 +16,9 @@ anthropic==0.116.0 \ --hash=sha256:5fc248fbb9fe03ef686f8a774f81586bca31a043260aab88b387ea3660f4a396 \ --hash=sha256:6c0a7698e8d652455da3499978279bb2588c7264d0a35be3666009a4258c8256 # via -r requirements.in -anyio==4.14.1 \ - --hash=sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72 \ - --hash=sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f # via # anthropic # httpx @@ -1443,9 +1443,9 @@ socksio==1.0.0 \ --hash=sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3 \ --hash=sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac # via httpx -soupsieve==2.8.4 \ - --hash=sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e \ - --hash=sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65 +soupsieve==2.9 \ + --hash=sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12 \ + --hash=sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc # via beautifulsoup4 starlette==1.3.1 \ --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ diff --git a/tests/test_node_state_migration.py b/tests/test_node_state_migration.py index 950b021f..2e239352 100644 --- a/tests/test_node_state_migration.py +++ b/tests/test_node_state_migration.py @@ -48,6 +48,8 @@ from pathlib import Path from backend.domain.graph import SceneDocument +from backend.domain.node_wire import wire_keys +from backend.tests.conftest import client_row from backend.domain.model import SceneNode REPO_ROOT = Path(__file__).resolve().parents[1] @@ -421,10 +423,16 @@ def _non_owning_kind_node(doc): def test_scene_payload_key_set_is_unchanged_by_the_migration(): + # Rows are sparse on the wire (backend/domain/node_wire.py) - a field at + # its default is left out - so the pinned set is every key the row + # builder can emit, not the keys of any one node's raw row... + assert sorted(wire_keys()) == _EXPECTED_SCENE_NODE_WIRE_KEYS + # ...and what the client reads once it restores a non-owning node's row + # is all of them, bar contentParts, which the contract never declared. doc = SceneDocument() _non_owning_kind_node(doc) - row = doc.scene_payload()["nodes"][-1] - assert sorted(row.keys()) == _EXPECTED_SCENE_NODE_WIRE_KEYS + row = client_row(doc.scene_payload()["nodes"][-1]) + assert sorted(row.keys()) == [key for key in _EXPECTED_SCENE_NODE_WIRE_KEYS if key != "contentParts"] # Every migrated field's wire VALUE for a node of a kind that does NOT own @@ -570,7 +578,8 @@ def test_scene_payload_key_set_is_unchanged_by_the_migration(): def test_migrated_field_wire_fallbacks_match_pre_migration_defaults(): doc = SceneDocument() _non_owning_kind_node(doc) - row = doc.scene_payload()["nodes"][-1] + # As the client reads it: a sparse row's omitted fields restored. + row = client_row(doc.scene_payload()["nodes"][-1]) mismatches = [ f"{key}: got {row[key]!r}, expected {expected!r}" for key, expected in _EXPECTED_NON_OWNING_KIND_WIRE_DEFAULTS.items() diff --git a/web_ui/scripts/check-bundle-size.mjs b/web_ui/scripts/check-bundle-size.mjs index 22dd800c..cb2b1083 100644 --- a/web_ui/scripts/check-bundle-size.mjs +++ b/web_ui/scripts/check-bundle-size.mjs @@ -1,10 +1,11 @@ // ADR-019 stage 19.2: the bundle-size CI counting gate. // // Fails the build when the built JS grows past a ratchet ceiling. This is a -// REGRESSION ratchet pinned ~5% above measured reality, NOT the ADR-019 -// budget itself (initial chunk <= 500 KiB, rest lazy-loaded) - this gate's -// job is to make sure the number only ever moves DOWN: nobody can add half a -// megabyte of dependencies without CI saying so. +// REGRESSION ratchet pinned a few percent above measured reality - and since +// 2026-09-19, when the initial chunk first fit, that ceiling IS the ADR-019 +// budget (initial chunk <= 500 KiB, rest lazy-loaded; see the last +// amendment). This gate's job is to make sure the number only ever moves +// DOWN: nobody can add half a megabyte of dependencies without CI saying so. // // ADR-011 stage 11.6 landed the code-splitting this file's own prior comment // was waiting on (React.lazy for SettingsDialog/ChatLibraryDialog/HelpDialog @@ -193,7 +194,27 @@ const ASSETS_DIR = join(HERE, "..", "dist", "app", "assets"); // lib/bridge-core (~84,000), app/canvas (~82,000) and app/chrome (~78,000). // Closing the last ~83,500 bytes means splitting app source, not removing a // dependency - which is a real option now that it is the only one left. -const LARGEST_CHUNK_CEILING_BYTES = 613_000; +// +// Deliberate, commented amendment - 2026-09-19 (ADR-019 section 4). +// +// The initial chunk is UNDER the ADR-019 budget, and this ceiling is now the +// budget itself: 512,000 bytes, ~3.4% over the measured 495,013 - the same +// headroom posture as every amendment above, except that from here the +// ratchet and the budget are the same number. +// +// Measured from 599,947 (this branch's starting point: 595,926 on main, plus +// ~4,000 for the sparse-row defaults table the scene contract now carries). +// A sourcemap attribution put the largest block of app source in the chunk in +// one place nobody had looked: the generated wire validators, ~87,000 bytes, +// 47,908 of them scene-state.ts alone - codegen emitted one unrolled block +// per field, ~190 bytes each. contracts/codegen.py now emits each payload's +// shape as a field table (~25 bytes a field) that +// src/lib/bridge-core/wireCheck.ts compiles into a checker once at load: +// 599,947 -> 529,180. The remaining ~34,000 came from deferring, on the +// existing LazySurface terms, every chrome surface only a click or shortcut +// opens (About, Builder/Harness launch, Command palette, Diagnostics, Global +// and Knowledge search, Quick switcher, View): 529,180 -> 495,013. +const LARGEST_CHUNK_CEILING_BYTES = 512_000; // Post-11.6 reality: six chunks (main + katex + highlight.js + the three // lazy dialogs) total 1,288,075 bytes - essentially unchanged from the // pre-split single-chunk total, as expected: splitting redistributes code @@ -214,7 +235,10 @@ const LARGEST_CHUNK_CEILING_BYTES = 613_000; // 2026-09-04: re-anchored to 1,456,011 with the same ~3% headroom, for the // per-chunk overhead the node-view split added. Recorded as a raise, small // as it is - the amendment above is what pays for it. -const TOTAL_JS_CEILING_BYTES = 1_500_000; +// 2026-09-19: moved DOWN to ~3% over the measured 1,397,864 (from +// 1,461,180) - the validator tables shrank the total, not just moved bytes +// between chunks, so the ratchet takes the gain. +const TOTAL_JS_CEILING_BYTES = 1_440_000; // Below this much headroom, say so. Both prior amendments record the same // story: the ceiling was set with ~3-5% room, ordinary week-to-week growth diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx index b72177f7..118155ac 100644 --- a/web_ui/src/app/App.tsx +++ b/web_ui/src/app/App.tsx @@ -25,23 +25,14 @@ import { resolveTreeNavigationTarget, type TreeNavigationDirection } from "./can import { requestNewChat } from "./chrome/commands"; import { isGatedWhileTyping, resolveShortcut, type ShortcutId } from "./chrome/shortcuts"; import { DocumentViewPanel } from "./canvas/DocumentViewPanel"; -import { AboutDialog } from "./chrome/AboutDialog"; import { AppBar } from "./chrome/AppBar"; -import { CommandPalette } from "./chrome/CommandPalette"; import { Composer } from "./chrome/Composer"; import { ComposerStore } from "./chrome/composerStore"; -import { DiagnosticsDialog } from "./chrome/DiagnosticsDialog"; -import { GlobalSearchDialog } from "./chrome/GlobalSearchDialog"; -import { KnowledgeSearchDialog } from "./chrome/KnowledgeSearchDialog"; -import { QuickSwitcherDialog } from "./chrome/QuickSwitcherDialog"; -import { BuilderLaunchDialog } from "./chrome/BuilderLaunchDialog"; -import { HarnessLaunchDialog } from "./chrome/HarnessLaunchDialog"; import { NotificationBanner } from "./chrome/NotificationBanner"; import { OnboardingDialog } from "./chrome/OnboardingDialog"; import { PinOverlay } from "./chrome/PinOverlay"; import { PluginPicker } from "./chrome/PluginPicker"; import { SearchOverlay } from "./chrome/SearchOverlay"; -import { ViewPopover } from "./chrome/ViewPopover"; import { OverlayProvider, useOverlays } from "./overlays/overlays"; // ADR-011 stage 11.6: ChatLibraryDialog, HelpDialog (+ its 76-item static @@ -56,6 +47,35 @@ const ChatLibraryDialog = lazy(() => ); const HelpDialog = lazy(() => import("./chrome/HelpDialog").then((m) => ({ default: m.HelpDialog }))); const SettingsDialog = lazy(() => import("./chrome/SettingsDialog").then((m) => ({ default: m.SettingsDialog }))); +// ADR-019's 512,000-byte initial-chunk budget: every other surface that only +// a click or a shortcut opens, on the same LazySurface terms. Each was +// checked for work it does while CLOSED, which a deferred mount would +// silently stop: none has any. OnboardingDialog is the one that does (it +// opens itself on a fresh machine), so it stays eager - see its mount below. +// The topic-subscribing ones are safe mounted late: About and Diagnostics +// are their topic's only subscriber, so their first subscribe still fetches +// a snapshot, and QuickSwitcher already resubscribes for exactly that reason. +const AboutDialog = lazy(() => import("./chrome/AboutDialog").then((m) => ({ default: m.AboutDialog }))); +const BuilderLaunchDialog = lazy(() => + import("./chrome/BuilderLaunchDialog").then((m) => ({ default: m.BuilderLaunchDialog })), +); +const CommandPalette = lazy(() => import("./chrome/CommandPalette").then((m) => ({ default: m.CommandPalette }))); +const DiagnosticsDialog = lazy(() => + import("./chrome/DiagnosticsDialog").then((m) => ({ default: m.DiagnosticsDialog })), +); +const GlobalSearchDialog = lazy(() => + import("./chrome/GlobalSearchDialog").then((m) => ({ default: m.GlobalSearchDialog })), +); +const HarnessLaunchDialog = lazy(() => + import("./chrome/HarnessLaunchDialog").then((m) => ({ default: m.HarnessLaunchDialog })), +); +const KnowledgeSearchDialog = lazy(() => + import("./chrome/KnowledgeSearchDialog").then((m) => ({ default: m.KnowledgeSearchDialog })), +); +const QuickSwitcherDialog = lazy(() => + import("./chrome/QuickSwitcherDialog").then((m) => ({ default: m.QuickSwitcherDialog })), +); +const ViewPopover = lazy(() => import("./chrome/ViewPopover").then((m) => ({ default: m.ViewPopover }))); /** * The single-app shell (Qt-removal plan R0-R2). @@ -221,7 +241,18 @@ function GlobalShortcuts({ store }: { store: SceneStore }) { * (draft form fields, list scroll position, search text) survives being * closed and reopened exactly as it did when these were eagerly mounted. */ -function LazySurface({ overlayName, children }: { overlayName: string; children: ReactNode }) { +function LazySurface({ + overlayName, + children, + fallback = , +}: { + overlayName: string; + children: ReactNode; + /** What shows while the chunk loads. The dialog-shaped default suits a + * dialog; a popover passes null rather than flash a full scrim for the + * few milliseconds a local chunk takes. */ + fallback?: ReactNode; +}) { const overlays = useOverlays(); const isOpen = overlays.isOpen(overlayName); const [hasOpened, setHasOpened] = useState(isOpen); @@ -234,7 +265,7 @@ function LazySurface({ overlayName, children }: { overlayName: string; children: setHasOpened(true); } if (!hasOpened) return null; - return }>{children}; + return {children}; } /** Minimal Suspense fallback for the first-open chunk fetch above - same @@ -457,7 +488,9 @@ function App() { - + + +
@@ -469,9 +502,15 @@ function App() { showTokenCounter={settingsVisibility.showTokenCounter !== false} />
- - - + + + + + + + + + {/* ADR-012 stage 12.6: not lazy, unlike Help/Settings/ Library just below - it has to be mounted from the start so its own app-settings subscription can decide @@ -482,11 +521,21 @@ function App() { - - - - - + + + + + + + + + + + + + + + diff --git a/web_ui/src/app/canvas/DocumentViewPanel.test.tsx b/web_ui/src/app/canvas/DocumentViewPanel.test.tsx index c6fbfb71..aa8089af 100644 --- a/web_ui/src/app/canvas/DocumentViewPanel.test.tsx +++ b/web_ui/src/app/canvas/DocumentViewPanel.test.tsx @@ -32,6 +32,12 @@ beforeEach(() => { // the thing that broke. const LAZY_BODY_TIMEOUT = { timeout: 15_000 }; +// ...and each test must be allowed to outlive that wait. Raising only the +// wait left vitest's own 5000ms per-test timeout in charge, so a first import +// slower than 5s still failed the test ("Test timed out in 5000ms", a full +// `npm run check` on 2026-09-19) - the 15s wait could never actually be used. +vi.setConfig({ testTimeout: 20_000 }); + function renderPanel(overrides: Partial> = {}) { const props = { isOpen: true, diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index a27c0eb8..ddd96b5c 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -1273,9 +1273,10 @@ export function toFlowNodes( mimeType: n.mimeType, // Generated SceneNodeRow marks these `?: number | null` (optional // Python field), so the read can be `undefined`; DocumentNodeData - // is strictly `number | null` (matches the wire value, which is - // always present - the backend dataclass always serializes both - // keys). Coalesce here rather than loosening DocumentNodeData. + // is strictly `number | null` (matches the restored row, where + // both keys are always present - null when the sparse wire row + // left them out). Coalesce here rather than loosening + // DocumentNodeData. durationSeconds: n.durationSeconds ?? null, byteSize: n.byteSize ?? null, previewLabel: n.previewLabel, diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts index b9ffcec6..39c6deff 100644 --- a/web_ui/src/app/canvas/sceneStore.test.ts +++ b/web_ui/src/app/canvas/sceneStore.test.ts @@ -124,7 +124,9 @@ function validScenePayload(overrides: Record = {}) { // WsTransport.handleMessage entirely (see makeFakeTransport above), so // nothing here actually enforces version compatibility, but keeping the // fixture honest matters for anyone reading it as "what scene actually - // looks like on the wire". + // looks like on the wire". One exception, on purpose: the node below is + // written out WHOLE, as the store holds it. On the wire rows are sparse + // (fields at their default left out) - see the SPARSE snapshot test. schemaVersion: 2, minCompatibleSchemaVersion: 2, revision: 3, @@ -312,6 +314,25 @@ describe("SceneStore", () => { expect(store.getScene().dragFactor).toBe(0.5); }); + it("accepts a SPARSE snapshot - rows carrying only what differs from the defaults", () => { + // What the backend actually sends (backend/domain/node_wire.py): every + // field at its contract default is left off the row, and the generated + // validator restores it before the store ever sees the row. + const { transport, listeners } = makeFakeTransport(); + const store = new SceneStore(transport); + store.connect(); + + listeners.get("scene")!( + validScenePayload({ nodes: [{ id: "n0", x: 1, y: 2, title: "A", kind: "chat", isUser: true }] }), + ); + const node = store.getScene().nodes[0]; + expect(node.isUser).toBe(true); + expect(node.content).toBe(""); + expect(node.branchStatus).toBe("active"); + expect(node.pendingRequestId).toBeNull(); + expect(node.history).toEqual([]); + }); + // ADR-003 stage 3.3 (C9) review-fix: every other test that touches // chartData only ever exercises the ALL-FIELDS-absent default ({}) the // base validScenePayload() node uses - the runtime validator's @@ -442,6 +463,45 @@ describe("SceneStore", () => { expect(store.getScene().revision).toBe(4); }); + it("restores a sparse upsertNode row's omitted fields before storing it", () => { + const { store, patchListeners } = connectedStoreAtRevision3(); + + patchListeners.get("scene")!({ + revision: 4, + baseRevision: 3, + ops: [{ op: "upsertNode", node: { id: "n1", x: 5, y: 6, title: "sparse", kind: "chat", content: "hi" } }], + }); + + const node = store.getScene().nodes.find((n) => n.id === "n1")!; + expect(node.content).toBe("hi"); + expect(node.isUser).toBe(false); + expect(node.isLocked).toBe(true); + expect(node.toolCalls).toEqual([]); + expect(store.getScene().revision).toBe(4); + }); + + it("an upsert that leaves a field out resets it to its default, never keeps the old value", () => { + // An upsert REPLACES the row. A node that finishes running arrives + // without pendingRequestId (null is its default) - if the old value + // survived, the node would look busy forever. + const { store, patchListeners } = connectedStoreAtRevision3(); + const existing = store.getScene().nodes[0]; + patchListeners.get("scene")!({ + revision: 4, + baseRevision: 3, + ops: [{ op: "upsertNode", node: { ...existing, pendingRequestId: "req-1" } }], + }); + expect(store.getScene().nodes[0].pendingRequestId).toBe("req-1"); + + const { id, x, y, title, kind } = existing; + patchListeners.get("scene")!({ + revision: 5, + baseRevision: 4, + ops: [{ op: "upsertNode", node: { id, x, y, title, kind } }], + }); + expect(store.getScene().nodes[0].pendingRequestId).toBeNull(); + }); + it("adds a node it has never seen, and removes one via removeNodes", () => { const { store, patchListeners } = connectedStoreAtRevision3(); const existing = store.getScene().nodes[0]; diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts index b0b4e155..688a920a 100644 --- a/web_ui/src/app/canvas/sceneStore.ts +++ b/web_ui/src/app/canvas/sceneStore.ts @@ -18,7 +18,12 @@ import { TOPIC_VALIDATORS, type TopicName } from "../../lib/api-contract/topics"; import { bindTopic } from "../../lib/api-contract/bindTopic"; -import type { SceneEdgeRow, SceneNodeRow, SceneState } from "../../lib/bridge-core/generated/scene-state"; +import { + hydrateSceneNodeRow, + type SceneEdgeRow, + type SceneNodeRow, + type SceneState, +} from "../../lib/bridge-core/generated/scene-state"; import type { GridControlState } from "../../lib/bridge-core/generated/grid-control-state"; import type { DragSpeedState } from "../../lib/bridge-core/generated/drag-speed-state"; import type { FontControlState } from "../../lib/bridge-core/generated/font-control-state"; @@ -285,7 +290,12 @@ export class SceneStore { switch (op.op) { case "upsertNode": { if (!isWireRow(op.node)) return this.refusePatch("upsertNode.node is not a row", op); - const node = op.node; + // Rows arrive sparse - every field at its default is omitted + // (backend/domain/node_wire.py). Restored here, before anything + // reads the row, exactly as validateSceneState restores a + // snapshot's; an upsert REPLACES the old row, so a field reset to + // its default must come back as the default, not the old value. + const node = hydrateSceneNodeRow(op.node) as SceneNodeRow; const index = nodes.findIndex((n) => n.id === node.id); // ADR-012 stage 12.3: diffed against the row this patch is about // to REPLACE (undefined for a genuinely new node, which diff --git a/web_ui/src/lib/bridge-core/generated/app-about-state.ts b/web_ui/src/lib/bridge-core/generated/app-about-state.ts index f497fdad..5ec20396 100644 --- a/web_ui/src/lib/bridge-core/generated/app-about-state.ts +++ b/web_ui/src/lib/bridge-core/generated/app-about-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface AppAboutState { schemaVersion: number; revision: number; @@ -15,73 +19,20 @@ export interface AppAboutState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const APP_ABOUT_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["appName", "s", 0], + ["appVersion", "s", 0], + ["repositoryUrl", "s", 0], + ["developerName", "s", 0], + ["developerWebsiteUrl", "s", 0], + ["developerGithubUrl", "s", 0], + ["copyrightText", "s", 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkAppAboutState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["appName"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.appName: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.appName` + ": expected string"); } - } - { - const fieldValue = value["appVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.appVersion: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.appVersion` + ": expected string"); } - } - { - const fieldValue = value["repositoryUrl"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.repositoryUrl: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.repositoryUrl` + ": expected string"); } - } - { - const fieldValue = value["developerName"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.developerName: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.developerName` + ": expected string"); } - } - { - const fieldValue = value["developerWebsiteUrl"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.developerWebsiteUrl: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.developerWebsiteUrl` + ": expected string"); } - } - { - const fieldValue = value["developerGithubUrl"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.developerGithubUrl: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.developerGithubUrl` + ": expected string"); } - } - { - const fieldValue = value["copyrightText"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.copyrightText: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.copyrightText` + ": expected string"); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkAppAboutState = compileFields(APP_ABOUT_STATE_FIELDS); export function validateAppAboutState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/app-chat-library-state.ts b/web_ui/src/lib/bridge-core/generated/app-chat-library-state.ts index 159165cc..52c8df48 100644 --- a/web_ui/src/lib/bridge-core/generated/app-chat-library-state.ts +++ b/web_ui/src/lib/bridge-core/generated/app-chat-library-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface AppChatLibraryRow { id: number; title: string; @@ -35,151 +39,40 @@ export interface AppChatLibraryState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const APP_CHAT_LIBRARY_ROW_FIELDS: WireFields = [ + ["id", "n", 0], + ["title", "s", 0], + ["createdLabel", "s", 0], + ["updatedLabel", "s", 0], + ["createdAtIso", "s", 1], + ["updatedAtIso", "s", 1], + ["preview", "s", 0], + ["messageCount", "n", 0], + ["workspaceId", "n", 0], + ["favorite", "b", 0], + ["archived", "b", 0], + ["tags", { a: "s" }, 0], +]; -function checkAppChatLibraryRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.id` + ": expected number"); } - } - { - const fieldValue = value["title"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } - } - { - const fieldValue = value["createdLabel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.createdLabel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.createdLabel` + ": expected string"); } - } - { - const fieldValue = value["updatedLabel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.updatedLabel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.updatedLabel` + ": expected string"); } - } - { - const fieldValue = value["createdAtIso"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.createdAtIso` + ": expected string"); } - } - { - const fieldValue = value["updatedAtIso"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.updatedAtIso` + ": expected string"); } - } - { - const fieldValue = value["preview"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.preview: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.preview` + ": expected string"); } - } - { - const fieldValue = value["messageCount"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.messageCount: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.messageCount` + ": expected number"); } - } - { - const fieldValue = value["workspaceId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.workspaceId: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.workspaceId` + ": expected number"); } - } - { - const fieldValue = value["favorite"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.favorite: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.favorite` + ": expected boolean"); } - } - { - const fieldValue = value["archived"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.archived: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.archived` + ": expected boolean"); } - } - { - const fieldValue = value["tags"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.tags: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.tags` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.tags` + `[${i}]` + ": expected string"); }); } - } -} +const APP_WORKSPACE_ROW_FIELDS: WireFields = [ + ["id", "n", 0], + ["name", "s", 0], + ["icon", "s", 0], + ["archived", "b", 0], + ["defaultModelProvider", "s", 0], + ["defaultModelId", "s", 0], +]; -function checkAppWorkspaceRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.id` + ": expected number"); } - } - { - const fieldValue = value["name"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.name: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.name` + ": expected string"); } - } - { - const fieldValue = value["icon"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.icon: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.icon` + ": expected string"); } - } - { - const fieldValue = value["archived"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.archived: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.archived` + ": expected boolean"); } - } - { - const fieldValue = value["defaultModelProvider"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.defaultModelProvider: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.defaultModelProvider` + ": expected string"); } - } - { - const fieldValue = value["defaultModelId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.defaultModelId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.defaultModelId` + ": expected string"); } - } -} +const APP_CHAT_LIBRARY_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["rows", { a: { o: APP_CHAT_LIBRARY_ROW_FIELDS } }, 0], + ["workspaces", { a: { o: APP_WORKSPACE_ROW_FIELDS } }, 0], + ["notice", "s", 1], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkAppChatLibraryState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["rows"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.rows: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.rows` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkAppChatLibraryRow(item, `${path}.rows` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["workspaces"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.workspaces: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.workspaces` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkAppWorkspaceRow(item, `${path}.workspaces` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["notice"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.notice` + ": expected string"); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkAppChatLibraryState = compileFields(APP_CHAT_LIBRARY_STATE_FIELDS); export function validateAppChatLibraryState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/app-composer-state.ts b/web_ui/src/lib/bridge-core/generated/app-composer-state.ts index a16b7a89..076a2832 100644 --- a/web_ui/src/lib/bridge-core/generated/app-composer-state.ts +++ b/web_ui/src/lib/bridge-core/generated/app-composer-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface AppComposerDraft { id: string; text: string; @@ -91,343 +95,96 @@ export interface AppComposerState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. - -function checkAppComposerDraft(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["text"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.text: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.text` + ": expected string"); } - } - { - const fieldValue = value["contextMode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.contextMode: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.contextMode` + ": expected string"); } - } - { - const fieldValue = value["sendMode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sendMode: missing required field`); - else { if (!["enter_to_send", "ctrl_enter_to_send"].includes(fieldValue as string)) errors.push(`${path}.sendMode` + `: ${JSON.stringify(fieldValue)} is not one of [` + "enter_to_send, ctrl_enter_to_send" + `]`); } - } -} - -function checkAppComposerContext(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["anchor"]; - if (fieldValue !== undefined && fieldValue !== null) { checkAppComposerContextAnchor(fieldValue, `${path}.anchor`, errors); } - } - { - const fieldValue = value["items"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.items: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.items` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkAppComposerAttachment(item, `${path}.items` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["totalTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.totalTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.totalTokens` + ": expected number"); } - } - { - const fieldValue = value["reviewAvailable"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.reviewAvailable: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.reviewAvailable` + ": expected boolean"); } - } -} - -function checkAppComposerContextAnchor(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["label"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.label: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.label` + ": expected string"); } - } - { - const fieldValue = value["type"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.type: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.type` + ": expected string"); } - } -} - -function checkAppComposerAttachment(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["name"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.name: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.name` + ": expected string"); } - } - { - const fieldValue = value["kind"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.kind: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.kind` + ": expected string"); } - } - { - const fieldValue = value["byteSize"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.byteSize: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.byteSize` + ": expected number"); } - } - { - const fieldValue = value["contextLabel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.contextLabel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.contextLabel` + ": expected string"); } - } - { - const fieldValue = value["tokenCount"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.tokenCount: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.tokenCount` + ": expected number"); } - } -} - -function checkAppComposerRoute(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["mode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.mode: missing required field`); - else { if (!["ollama", "api", "llama_cpp"].includes(fieldValue as string)) errors.push(`${path}.mode` + `: ${JSON.stringify(fieldValue)} is not one of [` + "ollama, api, llama_cpp" + `]`); } - } - { - const fieldValue = value["provider"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.provider: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.provider` + ": expected string"); } - } - { - const fieldValue = value["modelId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.modelId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.modelId` + ": expected string"); } - } - { - const fieldValue = value["modelLabel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.modelLabel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.modelLabel` + ": expected string"); } - } - { - const fieldValue = value["modelOptions"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.modelOptions: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.modelOptions` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkAppComposerModelOption(item, `${path}.modelOptions` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["reasoning"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.reasoning: missing required field`); - else { checkAppComposerReasoning(fieldValue, `${path}.reasoning`, errors); } - } - { - const fieldValue = value["label"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.label: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.label` + ": expected string"); } - } - { - const fieldValue = value["available"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.available: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.available` + ": expected boolean"); } - } - { - const fieldValue = value["canChange"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.canChange: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.canChange` + ": expected boolean"); } - } - { - const fieldValue = value["modelValue"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.modelValue` + ": expected string"); } - } -} - -function checkAppComposerModelOption(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["label"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.label: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.label` + ": expected string"); } - } -} - -function checkAppComposerReasoning(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["level"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.level: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.level` + ": expected string"); } - } - { - const fieldValue = value["label"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.label: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.label` + ": expected string"); } - } - { - const fieldValue = value["options"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.options: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.options` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkAppComposerReasoningOption(item, `${path}.options` + `[${i}]`, errors); }); } - } -} - -function checkAppComposerReasoningOption(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["label"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.label: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.label` + ": expected string"); } - } - { - const fieldValue = value["description"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.description: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.description` + ": expected string"); } - } -} - -function checkAppComposerRequest(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["state"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.state: missing required field`); - else { if (!["idle", "preparing", "uploading", "waiting", "generating", "finalizing", "canceled", "failed", "succeeded"].includes(fieldValue as string)) errors.push(`${path}.state` + `: ${JSON.stringify(fieldValue)} is not one of [` + "idle, preparing, uploading, waiting, generating, finalizing, canceled, failed, succeeded" + `]`); } - } - { - const fieldValue = value["message"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.message: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.message` + ": expected string"); } - } - { - const fieldValue = value["canSend"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.canSend: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.canSend` + ": expected boolean"); } - } - { - const fieldValue = value["canCancel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.canCancel: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.canCancel` + ": expected boolean"); } - } - { - const fieldValue = value["canRetry"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.canRetry: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.canRetry` + ": expected boolean"); } - } -} - -function checkAppComposerCapabilities(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["attachments"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.attachments: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.attachments` + ": expected boolean"); } - } - { - const fieldValue = value["contextReview"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.contextReview: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.contextReview` + ": expected boolean"); } - } - { - const fieldValue = value["routeSelection"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.routeSelection: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.routeSelection` + ": expected boolean"); } - } - { - const fieldValue = value["modelSelection"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.modelSelection: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.modelSelection` + ": expected boolean"); } - } - { - const fieldValue = value["reasoningSelection"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.reasoningSelection: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.reasoningSelection` + ": expected boolean"); } - } - { - const fieldValue = value["settingsShortcut"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.settingsShortcut: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.settingsShortcut` + ": expected boolean"); } - } - { - const fieldValue = value["cancellation"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.cancellation: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.cancellation` + ": expected boolean"); } - } -} - -function checkAppComposerState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["draft"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.draft: missing required field`); - else { checkAppComposerDraft(fieldValue, `${path}.draft`, errors); } - } - { - const fieldValue = value["context"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.context: missing required field`); - else { checkAppComposerContext(fieldValue, `${path}.context`, errors); } - } - { - const fieldValue = value["route"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.route: missing required field`); - else { checkAppComposerRoute(fieldValue, `${path}.route`, errors); } - } - { - const fieldValue = value["request"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.request: missing required field`); - else { checkAppComposerRequest(fieldValue, `${path}.request`, errors); } - } - { - const fieldValue = value["capabilities"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.capabilities: missing required field`); - else { checkAppComposerCapabilities(fieldValue, `${path}.capabilities`, errors); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const APP_COMPOSER_DRAFT_FIELDS: WireFields = [ + ["id", "s", 0], + ["text", "s", 0], + ["contextMode", "s", 0], + ["sendMode", { e: ["enter_to_send", "ctrl_enter_to_send"] }, 0], +]; + +const APP_COMPOSER_CONTEXT_ANCHOR_FIELDS: WireFields = [ + ["id", "s", 0], + ["label", "s", 0], + ["type", "s", 0], +]; + +const APP_COMPOSER_ATTACHMENT_FIELDS: WireFields = [ + ["id", "s", 0], + ["name", "s", 0], + ["kind", "s", 0], + ["byteSize", "n", 0], + ["contextLabel", "s", 0], + ["tokenCount", "n", 0], +]; + +const APP_COMPOSER_CONTEXT_FIELDS: WireFields = [ + ["anchor", { o: APP_COMPOSER_CONTEXT_ANCHOR_FIELDS }, 1], + ["items", { a: { o: APP_COMPOSER_ATTACHMENT_FIELDS } }, 0], + ["totalTokens", "n", 0], + ["reviewAvailable", "b", 0], +]; + +const APP_COMPOSER_MODEL_OPTION_FIELDS: WireFields = [ + ["id", "s", 0], + ["label", "s", 0], +]; + +const APP_COMPOSER_REASONING_OPTION_FIELDS: WireFields = [ + ["id", "s", 0], + ["label", "s", 0], + ["description", "s", 0], +]; + +const APP_COMPOSER_REASONING_FIELDS: WireFields = [ + ["level", "s", 0], + ["label", "s", 0], + ["options", { a: { o: APP_COMPOSER_REASONING_OPTION_FIELDS } }, 0], +]; + +const APP_COMPOSER_ROUTE_FIELDS: WireFields = [ + ["mode", { e: ["ollama", "api", "llama_cpp"] }, 0], + ["provider", "s", 0], + ["modelId", "s", 0], + ["modelLabel", "s", 0], + ["modelOptions", { a: { o: APP_COMPOSER_MODEL_OPTION_FIELDS } }, 0], + ["reasoning", { o: APP_COMPOSER_REASONING_FIELDS }, 0], + ["label", "s", 0], + ["available", "b", 0], + ["canChange", "b", 0], + ["modelValue", "s", 1], +]; + +const APP_COMPOSER_REQUEST_FIELDS: WireFields = [ + ["id", "s", 1], + ["state", { e: ["idle", "preparing", "uploading", "waiting", "generating", "finalizing", "canceled", "failed", "succeeded"] }, 0], + ["message", "s", 0], + ["canSend", "b", 0], + ["canCancel", "b", 0], + ["canRetry", "b", 0], +]; + +const APP_COMPOSER_CAPABILITIES_FIELDS: WireFields = [ + ["attachments", "b", 0], + ["contextReview", "b", 0], + ["routeSelection", "b", 0], + ["modelSelection", "b", 0], + ["reasoningSelection", "b", 0], + ["settingsShortcut", "b", 0], + ["cancellation", "b", 0], +]; + +const APP_COMPOSER_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["draft", { o: APP_COMPOSER_DRAFT_FIELDS }, 0], + ["context", { o: APP_COMPOSER_CONTEXT_FIELDS }, 0], + ["route", { o: APP_COMPOSER_ROUTE_FIELDS }, 0], + ["request", { o: APP_COMPOSER_REQUEST_FIELDS }, 0], + ["capabilities", { o: APP_COMPOSER_CAPABILITIES_FIELDS }, 0], + ["minCompatibleSchemaVersion", "n", 1], +]; + +const checkAppComposerState = compileFields(APP_COMPOSER_STATE_FIELDS); export function validateAppComposerState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/app-plugins-state.ts b/web_ui/src/lib/bridge-core/generated/app-plugins-state.ts index 8a635a0d..6f869f9c 100644 --- a/web_ui/src/lib/bridge-core/generated/app-plugins-state.ts +++ b/web_ui/src/lib/bridge-core/generated/app-plugins-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface AppPluginCategory { name: string; description: string; @@ -28,109 +32,33 @@ export interface AppPluginsState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; +const APP_PLUGIN_ENTRY_FIELDS: WireFields = [ + ["name", "s", 0], + ["description", "s", 0], +]; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +const APP_PLUGIN_CATEGORY_FIELDS: WireFields = [ + ["name", "s", 0], + ["description", "s", 0], + ["plugins", { a: { o: APP_PLUGIN_ENTRY_FIELDS } }, 0], +]; -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const APP_PLUGIN_GRANT_FIELDS: WireFields = [ + ["pluginId", "s", 0], + ["name", "s", 0], + ["scopes", { a: "s" }, 0], + ["granted", "b", 0], +]; -function checkAppPluginCategory(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["name"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.name: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.name` + ": expected string"); } - } - { - const fieldValue = value["description"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.description: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.description` + ": expected string"); } - } - { - const fieldValue = value["plugins"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.plugins: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.plugins` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkAppPluginEntry(item, `${path}.plugins` + `[${i}]`, errors); }); } - } -} +const APP_PLUGINS_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["categories", { a: { o: APP_PLUGIN_CATEGORY_FIELDS } }, 0], + ["grants", { a: { o: APP_PLUGIN_GRANT_FIELDS } }, 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkAppPluginEntry(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["name"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.name: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.name` + ": expected string"); } - } - { - const fieldValue = value["description"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.description: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.description` + ": expected string"); } - } -} - -function checkAppPluginGrant(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["pluginId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.pluginId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.pluginId` + ": expected string"); } - } - { - const fieldValue = value["name"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.name: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.name` + ": expected string"); } - } - { - const fieldValue = value["scopes"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.scopes: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.scopes` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.scopes` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["granted"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.granted: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.granted` + ": expected boolean"); } - } -} - -function checkAppPluginsState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["categories"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.categories: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.categories` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkAppPluginCategory(item, `${path}.categories` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["grants"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.grants: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.grants` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkAppPluginGrant(item, `${path}.grants` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkAppPluginsState = compileFields(APP_PLUGINS_STATE_FIELDS); export function validateAppPluginsState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts index b9762271..50781c97 100644 --- a/web_ui/src/lib/bridge-core/generated/app-settings-state.ts +++ b/web_ui/src/lib/bridge-core/generated/app-settings-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface ApiModelDescriptor { modelId: string; provider: string; @@ -71,347 +75,76 @@ export interface AppSettingsState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const API_MODEL_DESCRIPTOR_FIELDS: WireFields = [ + ["modelId", "s", 0], + ["provider", "s", 0], + ["capabilities", { a: "s" }, 0], + ["ready", "b", 0], + ["available", "b", 0], +]; -function checkApiModelDescriptor(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["modelId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.modelId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.modelId` + ": expected string"); } - } - { - const fieldValue = value["provider"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.provider: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.provider` + ": expected string"); } - } - { - const fieldValue = value["capabilities"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.capabilities: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.capabilities` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.capabilities` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["ready"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ready: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.ready` + ": expected boolean"); } - } - { - const fieldValue = value["available"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.available: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.available` + ": expected boolean"); } - } -} +const MCP_SERVER_CONFIG_FIELDS: WireFields = [ + ["id", "s", 0], + ["name", "s", 0], + ["command", "s", 0], + ["args", { a: "s" }, 0], + ["scopes", { a: "s" }, 0], + ["approval", "s", 0], + ["enabledTools", { a: "s" }, 0], + ["enabled", "b", 0], + ["timeout", "n", 0], + ["envKeys", { a: "s" }, 0], +]; -function checkMcpServerConfig(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["name"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.name: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.name` + ": expected string"); } - } - { - const fieldValue = value["command"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.command: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.command` + ": expected string"); } - } - { - const fieldValue = value["args"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.args: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.args` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.args` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["scopes"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.scopes: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.scopes` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.scopes` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["approval"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.approval: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.approval` + ": expected string"); } - } - { - const fieldValue = value["enabledTools"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.enabledTools: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.enabledTools` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.enabledTools` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["enabled"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.enabled: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.enabled` + ": expected boolean"); } - } - { - const fieldValue = value["timeout"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.timeout: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.timeout` + ": expected number"); } - } - { - const fieldValue = value["envKeys"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.envKeys: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.envKeys` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.envKeys` + `[${i}]` + ": expected string"); }); } - } -} +const APP_SETTINGS_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["activeSection", "s", 0], + ["showTokenCounter", "b", 0], + ["enableSystemPrompt", "b", 0], + ["notificationPreferences", { d: "b" }, 0], + ["githubTokenConfigured", "b", 0], + ["secretsEncryptedAtRest", "b", 0], + ["logLevel", "s", 0], + ["autoModelPolicy", "s", 0], + ["theme", "s", 0], + ["hasCompletedOnboarding", "b", 0], + ["providerMode", "s", 0], + ["activeApiProvider", "s", 0], + ["viewingApiProvider", "s", 0], + ["apiBaseUrl", "s", 0], + ["apiKeyConfigured", { d: "b" }, 0], + ["apiKeySource", { d: "s" }, 0], + ["apiModels", { d: "s" }, 0], + ["apiModelCatalog", { a: { o: API_MODEL_DESCRIPTOR_FIELDS } }, 0], + ["apiCatalogStatus", "s", 0], + ["apiCatalogMessage", "s", 0], + ["geminiStaticModels", { a: "s" }, 0], + ["geminiStaticImageModels", { a: "s" }, 0], + ["ollamaReasoningLevel", "s", 0], + ["ollamaCurrentModel", "s", 0], + ["ollamaModelAssignments", { d: "s" }, 0], + ["ollamaScannedModels", { a: "s" }, 0], + ["ollamaScanSummary", "s", 0], + ["ollamaScanStatus", "s", 0], + ["ollamaPullStatus", "s", 0], + ["ollamaNotice", "s", 0], + ["llamaCppReasoningLevel", "s", 0], + ["llamaCppChatModelPath", "s", 0], + ["llamaCppTitleModelPath", "s", 0], + ["llamaCppChatFormat", "s", 0], + ["llamaCppNCtx", "n", 0], + ["llamaCppNGpuLayers", "n", 0], + ["llamaCppNThreads", "n", 0], + ["llamaCppScannedModels", { a: "s" }, 0], + ["llamaCppScanSummary", "s", 0], + ["llamaCppScanStatus", "s", 0], + ["llamaCppNotice", "s", 0], + ["mcpServers", { a: { o: MCP_SERVER_CONFIG_FIELDS } }, 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkAppSettingsState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["activeSection"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.activeSection: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.activeSection` + ": expected string"); } - } - { - const fieldValue = value["showTokenCounter"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.showTokenCounter: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.showTokenCounter` + ": expected boolean"); } - } - { - const fieldValue = value["enableSystemPrompt"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.enableSystemPrompt: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.enableSystemPrompt` + ": expected boolean"); } - } - { - const fieldValue = value["notificationPreferences"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.notificationPreferences: missing required field`); - else { if (!isRecord(fieldValue)) errors.push(`${path}.notificationPreferences` + ": expected object"); - else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "boolean") errors.push(`${path}.notificationPreferences` + `[${JSON.stringify(k)}]` + ": expected boolean"); }); } - } - { - const fieldValue = value["githubTokenConfigured"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.githubTokenConfigured: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.githubTokenConfigured` + ": expected boolean"); } - } - { - const fieldValue = value["secretsEncryptedAtRest"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.secretsEncryptedAtRest: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.secretsEncryptedAtRest` + ": expected boolean"); } - } - { - const fieldValue = value["logLevel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.logLevel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.logLevel` + ": expected string"); } - } - { - const fieldValue = value["autoModelPolicy"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.autoModelPolicy: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.autoModelPolicy` + ": expected string"); } - } - { - const fieldValue = value["theme"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.theme: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.theme` + ": expected string"); } - } - { - const fieldValue = value["hasCompletedOnboarding"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.hasCompletedOnboarding: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.hasCompletedOnboarding` + ": expected boolean"); } - } - { - const fieldValue = value["providerMode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.providerMode: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.providerMode` + ": expected string"); } - } - { - const fieldValue = value["activeApiProvider"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.activeApiProvider: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.activeApiProvider` + ": expected string"); } - } - { - const fieldValue = value["viewingApiProvider"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.viewingApiProvider: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.viewingApiProvider` + ": expected string"); } - } - { - const fieldValue = value["apiBaseUrl"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiBaseUrl: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.apiBaseUrl` + ": expected string"); } - } - { - const fieldValue = value["apiKeyConfigured"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiKeyConfigured: missing required field`); - else { if (!isRecord(fieldValue)) errors.push(`${path}.apiKeyConfigured` + ": expected object"); - else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "boolean") errors.push(`${path}.apiKeyConfigured` + `[${JSON.stringify(k)}]` + ": expected boolean"); }); } - } - { - const fieldValue = value["apiKeySource"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiKeySource: missing required field`); - else { if (!isRecord(fieldValue)) errors.push(`${path}.apiKeySource` + ": expected object"); - else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "string") errors.push(`${path}.apiKeySource` + `[${JSON.stringify(k)}]` + ": expected string"); }); } - } - { - const fieldValue = value["apiModels"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiModels: missing required field`); - else { if (!isRecord(fieldValue)) errors.push(`${path}.apiModels` + ": expected object"); - else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "string") errors.push(`${path}.apiModels` + `[${JSON.stringify(k)}]` + ": expected string"); }); } - } - { - const fieldValue = value["apiModelCatalog"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiModelCatalog: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.apiModelCatalog` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkApiModelDescriptor(item, `${path}.apiModelCatalog` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["apiCatalogStatus"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiCatalogStatus: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.apiCatalogStatus` + ": expected string"); } - } - { - const fieldValue = value["apiCatalogMessage"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.apiCatalogMessage: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.apiCatalogMessage` + ": expected string"); } - } - { - const fieldValue = value["geminiStaticModels"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.geminiStaticModels: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.geminiStaticModels` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.geminiStaticModels` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["geminiStaticImageModels"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.geminiStaticImageModels: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.geminiStaticImageModels` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.geminiStaticImageModels` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["ollamaReasoningLevel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaReasoningLevel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaReasoningLevel` + ": expected string"); } - } - { - const fieldValue = value["ollamaCurrentModel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaCurrentModel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaCurrentModel` + ": expected string"); } - } - { - const fieldValue = value["ollamaModelAssignments"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaModelAssignments: missing required field`); - else { if (!isRecord(fieldValue)) errors.push(`${path}.ollamaModelAssignments` + ": expected object"); - else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "string") errors.push(`${path}.ollamaModelAssignments` + `[${JSON.stringify(k)}]` + ": expected string"); }); } - } - { - const fieldValue = value["ollamaScannedModels"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaScannedModels: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.ollamaScannedModels` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.ollamaScannedModels` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["ollamaScanSummary"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaScanSummary: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaScanSummary` + ": expected string"); } - } - { - const fieldValue = value["ollamaScanStatus"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaScanStatus: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaScanStatus` + ": expected string"); } - } - { - const fieldValue = value["ollamaPullStatus"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaPullStatus: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaPullStatus` + ": expected string"); } - } - { - const fieldValue = value["ollamaNotice"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.ollamaNotice: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.ollamaNotice` + ": expected string"); } - } - { - const fieldValue = value["llamaCppReasoningLevel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppReasoningLevel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppReasoningLevel` + ": expected string"); } - } - { - const fieldValue = value["llamaCppChatModelPath"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppChatModelPath: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppChatModelPath` + ": expected string"); } - } - { - const fieldValue = value["llamaCppTitleModelPath"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppTitleModelPath: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppTitleModelPath` + ": expected string"); } - } - { - const fieldValue = value["llamaCppChatFormat"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppChatFormat: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppChatFormat` + ": expected string"); } - } - { - const fieldValue = value["llamaCppNCtx"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppNCtx: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.llamaCppNCtx` + ": expected number"); } - } - { - const fieldValue = value["llamaCppNGpuLayers"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppNGpuLayers: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.llamaCppNGpuLayers` + ": expected number"); } - } - { - const fieldValue = value["llamaCppNThreads"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppNThreads: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.llamaCppNThreads` + ": expected number"); } - } - { - const fieldValue = value["llamaCppScannedModels"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppScannedModels: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.llamaCppScannedModels` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.llamaCppScannedModels` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["llamaCppScanSummary"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppScanSummary: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppScanSummary` + ": expected string"); } - } - { - const fieldValue = value["llamaCppScanStatus"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppScanStatus: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppScanStatus` + ": expected string"); } - } - { - const fieldValue = value["llamaCppNotice"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.llamaCppNotice: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.llamaCppNotice` + ": expected string"); } - } - { - const fieldValue = value["mcpServers"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.mcpServers: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.mcpServers` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkMcpServerConfig(item, `${path}.mcpServers` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkAppSettingsState = compileFields(APP_SETTINGS_STATE_FIELDS); export function validateAppSettingsState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/diagnostics-state.ts b/web_ui/src/lib/bridge-core/generated/diagnostics-state.ts index e082d850..aa957df6 100644 --- a/web_ui/src/lib/bridge-core/generated/diagnostics-state.ts +++ b/web_ui/src/lib/bridge-core/generated/diagnostics-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface DiagnosticsRunRow { runId: string; kind: string; @@ -30,123 +34,35 @@ export interface DiagnosticsState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const DIAGNOSTICS_RUN_ROW_FIELDS: WireFields = [ + ["runId", "s", 0], + ["kind", "s", 0], + ["nodeId", "s", 1], + ["outcome", "s", 0], + ["durationSeconds", "n", 1], +]; -function checkDiagnosticsRunRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["runId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.runId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.runId` + ": expected string"); } - } - { - const fieldValue = value["kind"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.kind: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.kind` + ": expected string"); } - } - { - const fieldValue = value["nodeId"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.nodeId` + ": expected string"); } - } - { - const fieldValue = value["outcome"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.outcome: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.outcome` + ": expected string"); } - } - { - const fieldValue = value["durationSeconds"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.durationSeconds` + ": expected number"); } - } -} +const DIAGNOSTICS_PROVIDER_ERROR_FIELDS: WireFields = [ + ["provider", "s", 0], + ["message", "s", 0], + ["at", "n", 0], +]; -function checkDiagnosticsProviderError(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["provider"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.provider: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.provider` + ": expected string"); } - } - { - const fieldValue = value["message"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.message: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.message` + ": expected string"); } - } - { - const fieldValue = value["at"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.at: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.at` + ": expected number"); } - } -} +const DIAGNOSTICS_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["recentRuns", { a: { o: DIAGNOSTICS_RUN_ROW_FIELDS } }, 0], + ["publishCount", "n", 0], + ["publishBytesTotal", "n", 0], + ["lastPublishBytes", "n", 1], + ["lastPublishTopic", "s", 1], + ["publishBytesPerSecond", "n", 0], + ["sessionCount", "n", 1], + ["providerErrors", { a: { o: DIAGNOSTICS_PROVIDER_ERROR_FIELDS } }, 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkDiagnosticsState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["recentRuns"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.recentRuns: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.recentRuns` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkDiagnosticsRunRow(item, `${path}.recentRuns` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["publishCount"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.publishCount: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.publishCount` + ": expected number"); } - } - { - const fieldValue = value["publishBytesTotal"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.publishBytesTotal: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.publishBytesTotal` + ": expected number"); } - } - { - const fieldValue = value["lastPublishBytes"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.lastPublishBytes` + ": expected number"); } - } - { - const fieldValue = value["lastPublishTopic"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.lastPublishTopic` + ": expected string"); } - } - { - const fieldValue = value["publishBytesPerSecond"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.publishBytesPerSecond: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.publishBytesPerSecond` + ": expected number"); } - } - { - const fieldValue = value["sessionCount"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.sessionCount` + ": expected number"); } - } - { - const fieldValue = value["providerErrors"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.providerErrors: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.providerErrors` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkDiagnosticsProviderError(item, `${path}.providerErrors` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkDiagnosticsState = compileFields(DIAGNOSTICS_STATE_FIELDS); export function validateDiagnosticsState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/drag-speed-state.ts b/web_ui/src/lib/bridge-core/generated/drag-speed-state.ts index f646aa7e..d955f645 100644 --- a/web_ui/src/lib/bridge-core/generated/drag-speed-state.ts +++ b/web_ui/src/lib/bridge-core/generated/drag-speed-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface DragSpeedState { schemaVersion: number; revision: number; @@ -11,54 +15,16 @@ export interface DragSpeedState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const DRAG_SPEED_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["percentPresets", { a: "n" }, 0], + ["percentMin", "n", 0], + ["percentMax", "n", 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkDragSpeedState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["percentPresets"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.percentPresets: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.percentPresets` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "number") errors.push(`${path}.percentPresets` + `[${i}]` + ": expected number"); }); } - } - { - const fieldValue = value["percentMin"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.percentMin: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.percentMin` + ": expected number"); } - } - { - const fieldValue = value["percentMax"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.percentMax: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.percentMax` + ": expected number"); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkDragSpeedState = compileFields(DRAG_SPEED_STATE_FIELDS); export function validateDragSpeedState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/execution-limits-state.ts b/web_ui/src/lib/bridge-core/generated/execution-limits-state.ts index 2101e1e5..9218cfb2 100644 --- a/web_ui/src/lib/bridge-core/generated/execution-limits-state.ts +++ b/web_ui/src/lib/bridge-core/generated/execution-limits-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface ExecutionLimitsState { schemaVersion: number; revision: number; @@ -9,43 +13,14 @@ export interface ExecutionLimitsState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const EXECUTION_LIMITS_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["codeSandboxResourceLimitsText", "s", 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkExecutionLimitsState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["codeSandboxResourceLimitsText"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxResourceLimitsText: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeSandboxResourceLimitsText` + ": expected string"); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkExecutionLimitsState = compileFields(EXECUTION_LIMITS_STATE_FIELDS); export function validateExecutionLimitsState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/font-control-state.ts b/web_ui/src/lib/bridge-core/generated/font-control-state.ts index d50b272a..f7109bc0 100644 --- a/web_ui/src/lib/bridge-core/generated/font-control-state.ts +++ b/web_ui/src/lib/bridge-core/generated/font-control-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface FontControlState { schemaVersion: number; revision: number; @@ -12,60 +16,17 @@ export interface FontControlState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const FONT_CONTROL_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["fontFamilies", { a: "s" }, 0], + ["colorPresets", { a: "s" }, 0], + ["sizeMin", "n", 0], + ["sizeMax", "n", 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkFontControlState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["fontFamilies"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.fontFamilies: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.fontFamilies` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.fontFamilies` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["colorPresets"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.colorPresets: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.colorPresets` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.colorPresets` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["sizeMin"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sizeMin: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.sizeMin` + ": expected number"); } - } - { - const fieldValue = value["sizeMax"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sizeMax: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.sizeMax` + ": expected number"); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkFontControlState = compileFields(FONT_CONTROL_STATE_FIELDS); export function validateFontControlState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/grid-control-state.ts b/web_ui/src/lib/bridge-core/generated/grid-control-state.ts index 6eeccbb1..69a686c8 100644 --- a/web_ui/src/lib/bridge-core/generated/grid-control-state.ts +++ b/web_ui/src/lib/bridge-core/generated/grid-control-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface GridControlState { schemaVersion: number; revision: number; @@ -15,76 +19,20 @@ export interface GridControlState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const GRID_CONTROL_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["gridSize", "n", 0], + ["gridOpacityPercent", "n", 0], + ["gridStyle", "s", 0], + ["gridColor", "s", 0], + ["sizePresets", { a: "n" }, 0], + ["stylePresets", { a: "s" }, 0], + ["colorPresets", { a: "s" }, 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkGridControlState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["gridSize"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gridSize: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.gridSize` + ": expected number"); } - } - { - const fieldValue = value["gridOpacityPercent"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gridOpacityPercent: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.gridOpacityPercent` + ": expected number"); } - } - { - const fieldValue = value["gridStyle"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gridStyle: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gridStyle` + ": expected string"); } - } - { - const fieldValue = value["gridColor"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gridColor: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gridColor` + ": expected string"); } - } - { - const fieldValue = value["sizePresets"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sizePresets: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.sizePresets` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "number") errors.push(`${path}.sizePresets` + `[${i}]` + ": expected number"); }); } - } - { - const fieldValue = value["stylePresets"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.stylePresets: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.stylePresets` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.stylePresets` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["colorPresets"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.colorPresets: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.colorPresets` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.colorPresets` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkGridControlState = compileFields(GRID_CONTROL_STATE_FIELDS); export function validateGridControlState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/notification-state.ts b/web_ui/src/lib/bridge-core/generated/notification-state.ts index 6457ba30..82503acf 100644 --- a/web_ui/src/lib/bridge-core/generated/notification-state.ts +++ b/web_ui/src/lib/bridge-core/generated/notification-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface NotificationState { schemaVersion: number; revision: number; @@ -11,53 +15,16 @@ export interface NotificationState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const NOTIFICATION_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["visible", "b", 0], + ["message", "s", 0], + ["msgType", { e: ["info", "success", "warning", "error"] }, 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkNotificationState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["visible"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.visible: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.visible` + ": expected boolean"); } - } - { - const fieldValue = value["message"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.message: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.message` + ": expected string"); } - } - { - const fieldValue = value["msgType"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.msgType: missing required field`); - else { if (!["info", "success", "warning", "error"].includes(fieldValue as string)) errors.push(`${path}.msgType` + `: ${JSON.stringify(fieldValue)} is not one of [` + "info, success, warning, error" + `]`); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkNotificationState = compileFields(NOTIFICATION_STATE_FIELDS); export function validateNotificationState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json index 3d160260..78b49545 100644 --- a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json +++ b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json @@ -57,18 +57,23 @@ "additionalProperties": false, "properties": { "artifactContent": { + "default": "", "type": "string" }, "artifactError": { + "default": "", "type": "string" }, "attachmentKind": { + "default": "", "type": "string" }, "branchStatus": { + "default": "active", "type": "string" }, "builderActivity": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -100,52 +105,68 @@ "type": "array" }, "builderApprovalSummary": { + "default": "", "type": "string" }, "builderApprovalToolName": { + "default": "", "type": "string" }, "builderAwaitingToolApproval": { + "default": false, "type": "boolean" }, "builderMaxSteps": { + "default": 0, "type": "integer" }, "builderMaxTokens": { + "default": 0, "type": "integer" }, "builderMaxWallSeconds": { + "default": 0, "type": "integer" }, "builderMode": { + "default": "", "type": "string" }, "builderRunId": { + "default": "", "type": "string" }, "builderSpentSteps": { + "default": 0, "type": "integer" }, "builderSpentTokens": { + "default": 0, "type": "integer" }, "builderSpentWallSeconds": { + "default": 0, "type": "integer" }, "builderStatus": { + "default": "", "type": "string" }, "builderStatusDetail": { + "default": "", "type": "string" }, "byteSize": { + "default": null, "type": "integer" }, "chartAspectLocked": { + "default": true, "type": "boolean" }, "chartData": { "additionalProperties": false, + "default": {}, "properties": { "bins": { "type": "integer" @@ -212,60 +233,78 @@ "type": "object" }, "chartError": { + "default": "", "type": "string" }, "chartHeight": { + "default": 340.0, "type": "number" }, "chartSourceNodeId": { + "default": "", "type": "string" }, "chartType": { + "default": "", "type": "string" }, "chartWidth": { + "default": 480.0, "type": "number" }, "chatScrollValue": { + "default": 0.0, "type": "number" }, "code": { + "default": "", "type": "string" }, "codeReviewAdditions": { + "default": 0, "type": "integer" }, "codeReviewBaseRef": { + "default": "", "type": "string" }, "codeReviewChangedFiles": { + "default": 0, "type": "integer" }, "codeReviewConfidence": { + "default": "", "type": "string" }, "codeReviewDeletions": { + "default": 0, "type": "integer" }, "codeReviewDiffChars": { + "default": 0, "type": "integer" }, "codeReviewDiffTruncated": { + "default": false, "type": "boolean" }, "codeReviewDiffVersion": { + "default": 0, "type": "integer" }, "codeReviewDismissedIds": { + "default": [], "items": { "type": "string" }, "type": "array" }, "codeReviewError": { + "default": "", "type": "string" }, "codeReviewErrors": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -313,6 +352,7 @@ "type": "array" }, "codeReviewFiles": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -351,9 +391,11 @@ "type": "array" }, "codeReviewFilesTruncated": { + "default": false, "type": "boolean" }, "codeReviewFindings": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -405,27 +447,35 @@ "type": "array" }, "codeReviewHeadRef": { + "default": "", "type": "string" }, "codeReviewOverview": { + "default": "", "type": "string" }, "codeReviewPrHtmlUrl": { + "default": "", "type": "string" }, "codeReviewPrNumber": { + "default": 0, "type": "integer" }, "codeReviewPrState": { + "default": "", "type": "string" }, "codeReviewPrTitle": { + "default": "", "type": "string" }, "codeReviewPrUrl": { + "default": "", "type": "string" }, "codeReviewQa": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -445,33 +495,42 @@ "type": "array" }, "codeReviewQualityScore": { + "default": 0, "type": "integer" }, "codeReviewQualitySummary": { + "default": "", "type": "string" }, "codeReviewRepo": { + "default": "", "type": "string" }, "codeReviewRisk": { + "default": "", "type": "string" }, "codeReviewScores": { "additionalProperties": { "type": "string" }, + "default": {}, "type": "object" }, "codeReviewState": { + "default": "draft", "type": "string" }, "codeReviewTitle": { + "default": "", "type": "string" }, "codeReviewVerdict": { + "default": "none", "type": "string" }, "codeReviewWalkthrough": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -498,81 +557,106 @@ "type": "array" }, "codeSandboxAnalysis": { + "default": "", "type": "string" }, "codeSandboxApprovalAllowSourceBuilds": { + "default": false, "type": "boolean" }, "codeSandboxApprovalIsRepair": { + "default": false, "type": "boolean" }, "codeSandboxApprovalRequirements": { + "default": "", "type": "string" }, "codeSandboxAwaitingApproval": { + "default": false, "type": "boolean" }, "codeSandboxCode": { + "default": "", "type": "string" }, "codeSandboxError": { + "default": "", "type": "string" }, "codeSandboxOutput": { + "default": "", "type": "string" }, "codeSandboxPrompt": { + "default": "", "type": "string" }, "codeSandboxRequirements": { + "default": "", "type": "string" }, "color": { + "default": null, "type": "string" }, "completionTokens": { + "default": null, "type": "integer" }, "content": { + "default": "", "type": "string" }, "durationSeconds": { + "default": null, "type": "number" }, "estimatedCostUsd": { + "default": null, "type": "number" }, "filePath": { + "default": "", "type": "string" }, "gitlinkBranch": { + "default": "", "type": "string" }, "gitlinkChangeFingerprint": { + "default": null, "type": "string" }, "gitlinkChangeState": { + "default": "draft", "type": "string" }, "gitlinkContextStats": { "additionalProperties": { "type": "string" }, + "default": {}, "type": "object" }, "gitlinkContextSummary": { + "default": "", "type": "string" }, "gitlinkContextVersion": { + "default": 0, "type": "integer" }, "gitlinkError": { + "default": "", "type": "string" }, "gitlinkLocalRoot": { + "default": "", "type": "string" }, "gitlinkPendingChanges": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -599,39 +683,49 @@ "type": "array" }, "gitlinkPreviewText": { + "default": "", "type": "string" }, "gitlinkProposalMarkdown": { + "default": "", "type": "string" }, "gitlinkRepo": { + "default": "", "type": "string" }, "gitlinkRepoFilePaths": { + "default": [], "items": { "type": "string" }, "type": "array" }, "gitlinkScopeMode": { + "default": "selected", "type": "string" }, "gitlinkSelectedPaths": { + "default": [], "items": { "type": "string" }, "type": "array" }, "gitlinkTaskPrompt": { + "default": "", "type": "string" }, "groupHeight": { + "default": null, "type": "number" }, "groupWidth": { + "default": null, "type": "number" }, "harnessActivity": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -659,36 +753,47 @@ "type": "array" }, "harnessApprovalSessionOffered": { + "default": false, "type": "boolean" }, "harnessApprovalSummary": { + "default": "", "type": "string" }, "harnessApprovalToolName": { + "default": "", "type": "string" }, "harnessAwaitingApproval": { + "default": false, "type": "boolean" }, "harnessAwaitingQuestion": { + "default": false, "type": "boolean" }, "harnessCompactions": { + "default": 0, "type": "integer" }, "harnessContextTokens": { + "default": 0, "type": "integer" }, "harnessGoal": { + "default": "", "type": "string" }, "harnessMaxContextTokens": { + "default": 0, "type": "integer" }, "harnessMaxTurns": { + "default": 0, "type": "integer" }, "harnessPlan": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -708,36 +813,47 @@ "type": "array" }, "harnessQuestion": { + "default": "", "type": "string" }, "harnessReply": { + "default": "", "type": "string" }, "harnessRunId": { + "default": "", "type": "string" }, "harnessSpentTokens": { + "default": 0, "type": "integer" }, "harnessSpentTurns": { + "default": 0, "type": "integer" }, "harnessStatus": { + "default": "", "type": "string" }, "harnessStatusDetail": { + "default": "", "type": "string" }, "harnessWorkspaceActive": { + "default": "", "type": "string" }, "harnessWorkspacePath": { + "default": "", "type": "string" }, "headerColor": { + "default": null, "type": "string" }, "history": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -765,45 +881,58 @@ "type": "array" }, "htmlSplitterState": { + "default": null, "type": "number" }, "id": { "type": "string" }, "imageAssetId": { + "default": "", "type": "string" }, "indexIntoKnowledge": { + "default": false, "type": "boolean" }, "isBranchComparison": { + "default": false, "type": "boolean" }, "isBranchSynthesis": { + "default": false, "type": "boolean" }, "isCollapsed": { + "default": false, "type": "boolean" }, "isDocked": { + "default": false, "type": "boolean" }, "isFinalDeliverable": { + "default": false, "type": "boolean" }, "isLocked": { + "default": true, "type": "boolean" }, "isSummaryNote": { + "default": false, "type": "boolean" }, "isSystemPrompt": { + "default": false, "type": "boolean" }, "isUser": { + "default": false, "type": "boolean" }, "itemIds": { + "default": [], "items": { "type": "string" }, @@ -813,27 +942,35 @@ "type": "string" }, "language": { + "default": "", "type": "string" }, "mimeType": { + "default": "", "type": "string" }, "model": { + "default": null, "type": "string" }, "overrideModelId": { + "default": "", "type": "string" }, "overrideProvider": { + "default": "", "type": "string" }, "pendingRequestId": { + "default": null, "type": "string" }, "planGoal": { + "default": "", "type": "string" }, "planSteps": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -864,28 +1001,36 @@ "additionalProperties": { "type": "string" }, + "default": {}, "type": "object" }, "previewLabel": { + "default": "", "type": "string" }, "promptTokens": { + "default": null, "type": "integer" }, "provider": { + "default": null, "type": "string" }, "researchActiveSourceId": { + "default": null, "type": "string" }, "researchCompleted": { + "default": 0, "type": "integer" }, "researchError": { + "default": "", "type": "string" }, "researchResult": { "additionalProperties": false, + "default": null, "properties": { "answerMarkdown": { "type": "string" @@ -1015,24 +1160,30 @@ "type": "object" }, "researchRetainToKnowledge": { + "default": false, "type": "boolean" }, "researchStage": { + "default": "", "type": "string" }, "researchTotal": { + "default": 0, "type": "integer" }, "responseIncomplete": { + "default": false, "type": "boolean" }, "synthesisInstructions": { + "default": "", "type": "string" }, "title": { "type": "string" }, "toolCalls": { + "default": [], "items": { "additionalProperties": false, "properties": { @@ -1075,142 +1226,7 @@ "x", "y", "title", - "kind", - "content", - "isUser", - "isCollapsed", - "code", - "language", - "attachmentKind", - "filePath", - "mimeType", - "previewLabel", - "isDocked", - "imageAssetId", - "history", - "researchStage", - "researchCompleted", - "researchTotal", - "researchError", - "researchRetainToKnowledge", - "artifactContent", - "artifactError", - "gitlinkRepo", - "gitlinkBranch", - "gitlinkScopeMode", - "gitlinkLocalRoot", - "gitlinkRepoFilePaths", - "gitlinkSelectedPaths", - "gitlinkTaskPrompt", - "gitlinkContextStats", - "gitlinkContextSummary", - "gitlinkContextVersion", - "gitlinkProposalMarkdown", - "gitlinkPendingChanges", - "gitlinkPreviewText", - "gitlinkChangeState", - "gitlinkError", - "codeReviewPrUrl", - "codeReviewRepo", - "codeReviewPrNumber", - "codeReviewPrTitle", - "codeReviewPrState", - "codeReviewPrHtmlUrl", - "codeReviewBaseRef", - "codeReviewHeadRef", - "codeReviewAdditions", - "codeReviewDeletions", - "codeReviewChangedFiles", - "codeReviewFiles", - "codeReviewFilesTruncated", - "codeReviewDiffTruncated", - "codeReviewDiffChars", - "codeReviewDiffVersion", - "codeReviewWalkthrough", - "codeReviewFindings", - "codeReviewErrors", - "codeReviewDismissedIds", - "codeReviewTitle", - "codeReviewOverview", - "codeReviewConfidence", - "codeReviewScores", - "codeReviewQualityScore", - "codeReviewVerdict", - "codeReviewRisk", - "codeReviewQualitySummary", - "codeReviewQa", - "codeReviewState", - "codeReviewError", - "codeSandboxRequirements", - "codeSandboxPrompt", - "codeSandboxCode", - "codeSandboxOutput", - "codeSandboxAnalysis", - "codeSandboxAwaitingApproval", - "codeSandboxApprovalRequirements", - "codeSandboxApprovalAllowSourceBuilds", - "codeSandboxApprovalIsRepair", - "codeSandboxError", - "isBranchSynthesis", - "synthesisInstructions", - "branchStatus", - "responseIncomplete", - "isFinalDeliverable", - "isSystemPrompt", - "isSummaryNote", - "isBranchComparison", - "itemIds", - "isLocked", - "chartType", - "chartData", - "chartError", - "chartWidth", - "chartHeight", - "chartAspectLocked", - "chartSourceNodeId", - "chatScrollValue", - "toolCalls", - "overrideProvider", - "overrideModelId", - "indexIntoKnowledge", - "planGoal", - "planSteps", - "builderActivity", - "builderStatus", - "builderMode", - "builderRunId", - "builderMaxSteps", - "builderMaxTokens", - "builderMaxWallSeconds", - "builderSpentSteps", - "builderSpentTokens", - "builderSpentWallSeconds", - "builderAwaitingToolApproval", - "builderApprovalToolName", - "builderApprovalSummary", - "builderStatusDetail", - "harnessGoal", - "harnessReply", - "harnessStatus", - "harnessStatusDetail", - "harnessRunId", - "harnessActivity", - "harnessContextTokens", - "harnessMaxContextTokens", - "harnessCompactions", - "harnessAwaitingApproval", - "harnessApprovalToolName", - "harnessApprovalSummary", - "harnessApprovalSessionOffered", - "harnessPlan", - "harnessAwaitingQuestion", - "harnessQuestion", - "harnessWorkspacePath", - "harnessWorkspaceActive", - "harnessMaxTurns", - "harnessSpentTurns", - "harnessSpentTokens", - "pluginState" + "kind" ], "type": "object" }, diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.ts b/web_ui/src/lib/bridge-core/generated/scene-state.ts index 21fe9038..45eee379 100644 --- a/web_ui/src/lib/bridge-core/generated/scene-state.ts +++ b/web_ui/src/lib/bridge-core/generated/scene-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields, hydrateRow, isRecord, wireDefaults } from "../wireCheck"; + +export type { ValidationResult }; + export interface SceneNodeRow { id: string; x: number; @@ -343,1501 +347,376 @@ export interface SceneState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. - -function checkSceneNodeRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["x"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.x: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.x` + ": expected number"); } - } - { - const fieldValue = value["y"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.y: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.y` + ": expected number"); } - } - { - const fieldValue = value["title"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } - } - { - const fieldValue = value["kind"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.kind: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.kind` + ": expected string"); } - } - { - const fieldValue = value["content"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.content: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.content` + ": expected string"); } - } - { - const fieldValue = value["isUser"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isUser: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isUser` + ": expected boolean"); } - } - { - const fieldValue = value["isCollapsed"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isCollapsed: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isCollapsed` + ": expected boolean"); } - } - { - const fieldValue = value["code"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.code: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.code` + ": expected string"); } - } - { - const fieldValue = value["language"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.language: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.language` + ": expected string"); } - } - { - const fieldValue = value["attachmentKind"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.attachmentKind: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.attachmentKind` + ": expected string"); } - } - { - const fieldValue = value["filePath"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.filePath: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.filePath` + ": expected string"); } - } - { - const fieldValue = value["mimeType"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.mimeType: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.mimeType` + ": expected string"); } - } - { - const fieldValue = value["durationSeconds"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.durationSeconds` + ": expected number"); } - } - { - const fieldValue = value["byteSize"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.byteSize` + ": expected number"); } - } - { - const fieldValue = value["previewLabel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.previewLabel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.previewLabel` + ": expected string"); } - } - { - const fieldValue = value["isDocked"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isDocked: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isDocked` + ": expected boolean"); } - } - { - const fieldValue = value["imageAssetId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.imageAssetId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.imageAssetId` + ": expected string"); } - } - { - const fieldValue = value["history"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.history: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.history` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkConversationMessageRow(item, `${path}.history` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["pendingRequestId"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.pendingRequestId` + ": expected string"); } - } - { - const fieldValue = value["researchStage"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.researchStage: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.researchStage` + ": expected string"); } - } - { - const fieldValue = value["researchCompleted"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.researchCompleted: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.researchCompleted` + ": expected number"); } - } - { - const fieldValue = value["researchTotal"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.researchTotal: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.researchTotal` + ": expected number"); } - } - { - const fieldValue = value["researchActiveSourceId"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.researchActiveSourceId` + ": expected string"); } - } - { - const fieldValue = value["researchError"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.researchError: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.researchError` + ": expected string"); } - } - { - const fieldValue = value["researchResult"]; - if (fieldValue !== undefined && fieldValue !== null) { checkResearchResultRow(fieldValue, `${path}.researchResult`, errors); } - } - { - const fieldValue = value["researchRetainToKnowledge"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.researchRetainToKnowledge: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.researchRetainToKnowledge` + ": expected boolean"); } - } - { - const fieldValue = value["artifactContent"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.artifactContent: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.artifactContent` + ": expected string"); } - } - { - const fieldValue = value["artifactError"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.artifactError: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.artifactError` + ": expected string"); } - } - { - const fieldValue = value["gitlinkRepo"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkRepo: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkRepo` + ": expected string"); } - } - { - const fieldValue = value["gitlinkBranch"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkBranch: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkBranch` + ": expected string"); } - } - { - const fieldValue = value["gitlinkScopeMode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkScopeMode: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkScopeMode` + ": expected string"); } - } - { - const fieldValue = value["gitlinkLocalRoot"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkLocalRoot: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkLocalRoot` + ": expected string"); } - } - { - const fieldValue = value["gitlinkRepoFilePaths"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkRepoFilePaths: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.gitlinkRepoFilePaths` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.gitlinkRepoFilePaths` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["gitlinkSelectedPaths"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkSelectedPaths: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.gitlinkSelectedPaths` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.gitlinkSelectedPaths` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["gitlinkTaskPrompt"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkTaskPrompt: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkTaskPrompt` + ": expected string"); } - } - { - const fieldValue = value["gitlinkContextStats"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkContextStats: missing required field`); - else { if (!isRecord(fieldValue)) errors.push(`${path}.gitlinkContextStats` + ": expected object"); - else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "string") errors.push(`${path}.gitlinkContextStats` + `[${JSON.stringify(k)}]` + ": expected string"); }); } - } - { - const fieldValue = value["gitlinkContextSummary"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkContextSummary: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkContextSummary` + ": expected string"); } - } - { - const fieldValue = value["gitlinkContextVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkContextVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.gitlinkContextVersion` + ": expected number"); } - } - { - const fieldValue = value["gitlinkProposalMarkdown"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkProposalMarkdown: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkProposalMarkdown` + ": expected string"); } - } - { - const fieldValue = value["gitlinkPendingChanges"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkPendingChanges: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.gitlinkPendingChanges` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkGitlinkPendingChangeRow(item, `${path}.gitlinkPendingChanges` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["gitlinkPreviewText"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkPreviewText: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkPreviewText` + ": expected string"); } - } - { - const fieldValue = value["gitlinkChangeFingerprint"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkChangeFingerprint` + ": expected string"); } - } - { - const fieldValue = value["gitlinkChangeState"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkChangeState: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkChangeState` + ": expected string"); } - } - { - const fieldValue = value["gitlinkError"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.gitlinkError: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.gitlinkError` + ": expected string"); } - } - { - const fieldValue = value["codeReviewPrUrl"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewPrUrl: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewPrUrl` + ": expected string"); } - } - { - const fieldValue = value["codeReviewRepo"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewRepo: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewRepo` + ": expected string"); } - } - { - const fieldValue = value["codeReviewPrNumber"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewPrNumber: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.codeReviewPrNumber` + ": expected number"); } - } - { - const fieldValue = value["codeReviewPrTitle"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewPrTitle: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewPrTitle` + ": expected string"); } - } - { - const fieldValue = value["codeReviewPrState"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewPrState: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewPrState` + ": expected string"); } - } - { - const fieldValue = value["codeReviewPrHtmlUrl"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewPrHtmlUrl: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewPrHtmlUrl` + ": expected string"); } - } - { - const fieldValue = value["codeReviewBaseRef"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewBaseRef: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewBaseRef` + ": expected string"); } - } - { - const fieldValue = value["codeReviewHeadRef"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewHeadRef: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewHeadRef` + ": expected string"); } - } - { - const fieldValue = value["codeReviewAdditions"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewAdditions: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.codeReviewAdditions` + ": expected number"); } - } - { - const fieldValue = value["codeReviewDeletions"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewDeletions: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.codeReviewDeletions` + ": expected number"); } - } - { - const fieldValue = value["codeReviewChangedFiles"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewChangedFiles: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.codeReviewChangedFiles` + ": expected number"); } - } - { - const fieldValue = value["codeReviewFiles"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewFiles: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.codeReviewFiles` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkCodeReviewFileRow(item, `${path}.codeReviewFiles` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["codeReviewFilesTruncated"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewFilesTruncated: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.codeReviewFilesTruncated` + ": expected boolean"); } - } - { - const fieldValue = value["codeReviewDiffTruncated"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewDiffTruncated: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.codeReviewDiffTruncated` + ": expected boolean"); } - } - { - const fieldValue = value["codeReviewDiffChars"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewDiffChars: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.codeReviewDiffChars` + ": expected number"); } - } - { - const fieldValue = value["codeReviewDiffVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewDiffVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.codeReviewDiffVersion` + ": expected number"); } - } - { - const fieldValue = value["codeReviewWalkthrough"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewWalkthrough: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.codeReviewWalkthrough` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkCodeReviewWalkthroughGroupRow(item, `${path}.codeReviewWalkthrough` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["codeReviewFindings"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewFindings: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.codeReviewFindings` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkCodeReviewFindingRow(item, `${path}.codeReviewFindings` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["codeReviewErrors"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewErrors: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.codeReviewErrors` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkCodeReviewErrorRow(item, `${path}.codeReviewErrors` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["codeReviewDismissedIds"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewDismissedIds: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.codeReviewDismissedIds` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.codeReviewDismissedIds` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["codeReviewTitle"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewTitle: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewTitle` + ": expected string"); } - } - { - const fieldValue = value["codeReviewOverview"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewOverview: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewOverview` + ": expected string"); } - } - { - const fieldValue = value["codeReviewConfidence"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewConfidence: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewConfidence` + ": expected string"); } - } - { - const fieldValue = value["codeReviewScores"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewScores: missing required field`); - else { if (!isRecord(fieldValue)) errors.push(`${path}.codeReviewScores` + ": expected object"); - else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "string") errors.push(`${path}.codeReviewScores` + `[${JSON.stringify(k)}]` + ": expected string"); }); } - } - { - const fieldValue = value["codeReviewQualityScore"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewQualityScore: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.codeReviewQualityScore` + ": expected number"); } - } - { - const fieldValue = value["codeReviewVerdict"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewVerdict: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewVerdict` + ": expected string"); } - } - { - const fieldValue = value["codeReviewRisk"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewRisk: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewRisk` + ": expected string"); } - } - { - const fieldValue = value["codeReviewQualitySummary"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewQualitySummary: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewQualitySummary` + ": expected string"); } - } - { - const fieldValue = value["codeReviewQa"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewQa: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.codeReviewQa` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkCodeReviewQaRow(item, `${path}.codeReviewQa` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["codeReviewState"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewState: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewState` + ": expected string"); } - } - { - const fieldValue = value["codeReviewError"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeReviewError: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeReviewError` + ": expected string"); } - } - { - const fieldValue = value["codeSandboxRequirements"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxRequirements: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeSandboxRequirements` + ": expected string"); } - } - { - const fieldValue = value["codeSandboxPrompt"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxPrompt: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeSandboxPrompt` + ": expected string"); } - } - { - const fieldValue = value["codeSandboxCode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxCode: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeSandboxCode` + ": expected string"); } - } - { - const fieldValue = value["codeSandboxOutput"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxOutput: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeSandboxOutput` + ": expected string"); } - } - { - const fieldValue = value["codeSandboxAnalysis"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxAnalysis: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeSandboxAnalysis` + ": expected string"); } - } - { - const fieldValue = value["codeSandboxAwaitingApproval"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxAwaitingApproval: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.codeSandboxAwaitingApproval` + ": expected boolean"); } - } - { - const fieldValue = value["codeSandboxApprovalRequirements"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxApprovalRequirements: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeSandboxApprovalRequirements` + ": expected string"); } - } - { - const fieldValue = value["codeSandboxApprovalAllowSourceBuilds"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxApprovalAllowSourceBuilds: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.codeSandboxApprovalAllowSourceBuilds` + ": expected boolean"); } - } - { - const fieldValue = value["codeSandboxApprovalIsRepair"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxApprovalIsRepair: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.codeSandboxApprovalIsRepair` + ": expected boolean"); } - } - { - const fieldValue = value["codeSandboxError"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.codeSandboxError: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.codeSandboxError` + ": expected string"); } - } - { - const fieldValue = value["provider"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.provider` + ": expected string"); } - } - { - const fieldValue = value["model"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.model` + ": expected string"); } - } - { - const fieldValue = value["isBranchSynthesis"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isBranchSynthesis: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isBranchSynthesis` + ": expected boolean"); } - } - { - const fieldValue = value["synthesisInstructions"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.synthesisInstructions: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.synthesisInstructions` + ": expected string"); } - } - { - const fieldValue = value["branchStatus"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.branchStatus: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.branchStatus` + ": expected string"); } - } - { - const fieldValue = value["responseIncomplete"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.responseIncomplete: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.responseIncomplete` + ": expected boolean"); } - } - { - const fieldValue = value["promptTokens"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.promptTokens` + ": expected number"); } - } - { - const fieldValue = value["completionTokens"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.completionTokens` + ": expected number"); } - } - { - const fieldValue = value["estimatedCostUsd"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.estimatedCostUsd` + ": expected number"); } - } - { - const fieldValue = value["isFinalDeliverable"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isFinalDeliverable: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isFinalDeliverable` + ": expected boolean"); } - } - { - const fieldValue = value["color"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.color` + ": expected string"); } - } - { - const fieldValue = value["headerColor"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.headerColor` + ": expected string"); } - } - { - const fieldValue = value["isSystemPrompt"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isSystemPrompt: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isSystemPrompt` + ": expected boolean"); } - } - { - const fieldValue = value["isSummaryNote"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isSummaryNote: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isSummaryNote` + ": expected boolean"); } - } - { - const fieldValue = value["isBranchComparison"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isBranchComparison: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isBranchComparison` + ": expected boolean"); } - } - { - const fieldValue = value["itemIds"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.itemIds: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.itemIds` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.itemIds` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["isLocked"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isLocked: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isLocked` + ": expected boolean"); } - } - { - const fieldValue = value["groupWidth"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.groupWidth` + ": expected number"); } - } - { - const fieldValue = value["groupHeight"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.groupHeight` + ": expected number"); } - } - { - const fieldValue = value["chartType"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.chartType: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.chartType` + ": expected string"); } - } - { - const fieldValue = value["chartData"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.chartData: missing required field`); - else { checkChartDataRow(fieldValue, `${path}.chartData`, errors); } - } - { - const fieldValue = value["chartError"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.chartError: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.chartError` + ": expected string"); } - } - { - const fieldValue = value["chartWidth"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.chartWidth: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.chartWidth` + ": expected number"); } - } - { - const fieldValue = value["chartHeight"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.chartHeight: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.chartHeight` + ": expected number"); } - } - { - const fieldValue = value["chartAspectLocked"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.chartAspectLocked: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.chartAspectLocked` + ": expected boolean"); } - } - { - const fieldValue = value["chartSourceNodeId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.chartSourceNodeId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.chartSourceNodeId` + ": expected string"); } - } - { - const fieldValue = value["htmlSplitterState"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.htmlSplitterState` + ": expected number"); } - } - { - const fieldValue = value["chatScrollValue"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.chatScrollValue: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.chatScrollValue` + ": expected number"); } - } - { - const fieldValue = value["toolCalls"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.toolCalls: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.toolCalls` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkToolInvocationRow(item, `${path}.toolCalls` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["overrideProvider"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.overrideProvider: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.overrideProvider` + ": expected string"); } - } - { - const fieldValue = value["overrideModelId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.overrideModelId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.overrideModelId` + ": expected string"); } - } - { - const fieldValue = value["indexIntoKnowledge"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.indexIntoKnowledge: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.indexIntoKnowledge` + ": expected boolean"); } - } - { - const fieldValue = value["planGoal"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.planGoal: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.planGoal` + ": expected string"); } - } - { - const fieldValue = value["planSteps"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.planSteps: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.planSteps` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkPlanStepRow(item, `${path}.planSteps` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["builderActivity"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderActivity: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.builderActivity` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkBuilderActivityRow(item, `${path}.builderActivity` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["builderStatus"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderStatus: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.builderStatus` + ": expected string"); } - } - { - const fieldValue = value["builderMode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderMode: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.builderMode` + ": expected string"); } - } - { - const fieldValue = value["builderRunId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderRunId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.builderRunId` + ": expected string"); } - } - { - const fieldValue = value["builderMaxSteps"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderMaxSteps: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.builderMaxSteps` + ": expected number"); } - } - { - const fieldValue = value["builderMaxTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderMaxTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.builderMaxTokens` + ": expected number"); } - } - { - const fieldValue = value["builderMaxWallSeconds"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderMaxWallSeconds: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.builderMaxWallSeconds` + ": expected number"); } - } - { - const fieldValue = value["builderSpentSteps"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderSpentSteps: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.builderSpentSteps` + ": expected number"); } - } - { - const fieldValue = value["builderSpentTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderSpentTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.builderSpentTokens` + ": expected number"); } - } - { - const fieldValue = value["builderSpentWallSeconds"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderSpentWallSeconds: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.builderSpentWallSeconds` + ": expected number"); } - } - { - const fieldValue = value["builderAwaitingToolApproval"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderAwaitingToolApproval: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.builderAwaitingToolApproval` + ": expected boolean"); } - } - { - const fieldValue = value["builderApprovalToolName"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderApprovalToolName: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.builderApprovalToolName` + ": expected string"); } - } - { - const fieldValue = value["builderApprovalSummary"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderApprovalSummary: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.builderApprovalSummary` + ": expected string"); } - } - { - const fieldValue = value["builderStatusDetail"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.builderStatusDetail: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.builderStatusDetail` + ": expected string"); } - } - { - const fieldValue = value["harnessGoal"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessGoal: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessGoal` + ": expected string"); } - } - { - const fieldValue = value["harnessReply"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessReply: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessReply` + ": expected string"); } - } - { - const fieldValue = value["harnessStatus"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessStatus: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessStatus` + ": expected string"); } - } - { - const fieldValue = value["harnessStatusDetail"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessStatusDetail: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessStatusDetail` + ": expected string"); } - } - { - const fieldValue = value["harnessRunId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessRunId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessRunId` + ": expected string"); } - } - { - const fieldValue = value["harnessActivity"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessActivity: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.harnessActivity` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkHarnessActivityRow(item, `${path}.harnessActivity` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["harnessContextTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessContextTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.harnessContextTokens` + ": expected number"); } - } - { - const fieldValue = value["harnessMaxContextTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessMaxContextTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.harnessMaxContextTokens` + ": expected number"); } - } - { - const fieldValue = value["harnessCompactions"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessCompactions: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.harnessCompactions` + ": expected number"); } - } - { - const fieldValue = value["harnessAwaitingApproval"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessAwaitingApproval: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.harnessAwaitingApproval` + ": expected boolean"); } - } - { - const fieldValue = value["harnessApprovalToolName"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessApprovalToolName: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessApprovalToolName` + ": expected string"); } - } - { - const fieldValue = value["harnessApprovalSummary"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessApprovalSummary: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessApprovalSummary` + ": expected string"); } - } - { - const fieldValue = value["harnessApprovalSessionOffered"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessApprovalSessionOffered: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.harnessApprovalSessionOffered` + ": expected boolean"); } - } - { - const fieldValue = value["harnessPlan"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessPlan: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.harnessPlan` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkHarnessPlanStepRow(item, `${path}.harnessPlan` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["harnessAwaitingQuestion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessAwaitingQuestion: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.harnessAwaitingQuestion` + ": expected boolean"); } - } - { - const fieldValue = value["harnessQuestion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessQuestion: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessQuestion` + ": expected string"); } - } - { - const fieldValue = value["harnessWorkspacePath"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessWorkspacePath: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessWorkspacePath` + ": expected string"); } - } - { - const fieldValue = value["harnessWorkspaceActive"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessWorkspaceActive: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.harnessWorkspaceActive` + ": expected string"); } - } - { - const fieldValue = value["harnessMaxTurns"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessMaxTurns: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.harnessMaxTurns` + ": expected number"); } - } - { - const fieldValue = value["harnessSpentTurns"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessSpentTurns: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.harnessSpentTurns` + ": expected number"); } - } - { - const fieldValue = value["harnessSpentTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.harnessSpentTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.harnessSpentTokens` + ": expected number"); } - } - { - const fieldValue = value["pluginState"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.pluginState: missing required field`); - else { if (!isRecord(fieldValue)) errors.push(`${path}.pluginState` + ": expected object"); - else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "string") errors.push(`${path}.pluginState` + `[${JSON.stringify(k)}]` + ": expected string"); }); } - } -} - -function checkConversationMessageRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["role"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.role: missing required field`); - else { if (!["user", "assistant"].includes(fieldValue as string)) errors.push(`${path}.role` + `: ${JSON.stringify(fieldValue)} is not one of [` + "user, assistant" + `]`); } - } - { - const fieldValue = value["content"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.content: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.content` + ": expected string"); } - } - { - const fieldValue = value["incomplete"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.incomplete: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.incomplete` + ": expected boolean"); } - } -} - -function checkResearchResultRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["requestId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.requestId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.requestId` + ": expected string"); } - } - { - const fieldValue = value["originalQuery"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.originalQuery: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.originalQuery` + ": expected string"); } - } - { - const fieldValue = value["effectiveQuery"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.effectiveQuery: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.effectiveQuery` + ": expected string"); } - } - { - const fieldValue = value["answerMarkdown"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.answerMarkdown: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.answerMarkdown` + ": expected string"); } - } - { - const fieldValue = value["sources"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sources: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.sources` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkResearchSourceRow(item, `${path}.sources` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["citations"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.citations: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.citations` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkResearchCitationRow(item, `${path}.citations` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["warnings"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.warnings: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.warnings` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.warnings` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["providerSnapshot"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.providerSnapshot: missing required field`); - else { if (!isRecord(fieldValue)) errors.push(`${path}.providerSnapshot` + ": expected object"); - else Object.entries(fieldValue as Record).forEach(([k, v]) => { if (typeof v !== "string") errors.push(`${path}.providerSnapshot` + `[${JSON.stringify(k)}]` + ": expected string"); }); } - } -} - -function checkResearchSourceRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["sourceId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sourceId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.sourceId` + ": expected string"); } - } - { - const fieldValue = value["title"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } - } - { - const fieldValue = value["url"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.url: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.url` + ": expected string"); } - } - { - const fieldValue = value["canonicalUrl"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.canonicalUrl: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.canonicalUrl` + ": expected string"); } - } - { - const fieldValue = value["snippet"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.snippet: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.snippet` + ": expected string"); } - } - { - const fieldValue = value["rank"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.rank: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.rank` + ": expected number"); } - } - { - const fieldValue = value["provider"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.provider: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.provider` + ": expected string"); } - } - { - const fieldValue = value["finalUrl"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.finalUrl: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.finalUrl` + ": expected string"); } - } - { - const fieldValue = value["status"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.status: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.status` + ": expected string"); } - } - { - const fieldValue = value["errorCode"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.errorCode: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.errorCode` + ": expected string"); } - } - { - const fieldValue = value["errorMessage"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.errorMessage: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.errorMessage` + ": expected string"); } - } - { - const fieldValue = value["truncated"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.truncated: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.truncated` + ": expected boolean"); } - } - { - const fieldValue = value["contentHash"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.contentHash: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.contentHash` + ": expected string"); } - } - { - const fieldValue = value["citationCount"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.citationCount: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.citationCount` + ": expected number"); } - } -} - -function checkResearchCitationRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["sourceId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sourceId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.sourceId` + ": expected string"); } - } - { - const fieldValue = value["marker"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.marker: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.marker` + ": expected string"); } - } - { - const fieldValue = value["claimContext"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.claimContext: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.claimContext` + ": expected string"); } - } -} - -function checkGitlinkPendingChangeRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["path"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.path: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.path` + ": expected string"); } - } - { - const fieldValue = value["operation"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.operation: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.operation` + ": expected string"); } - } - { - const fieldValue = value["reason"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.reason: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.reason` + ": expected string"); } - } - { - const fieldValue = value["content"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.content` + ": expected string"); } - } -} - -function checkCodeReviewFileRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["path"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.path: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.path` + ": expected string"); } - } - { - const fieldValue = value["status"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.status: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.status` + ": expected string"); } - } - { - const fieldValue = value["additions"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.additions: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.additions` + ": expected number"); } - } - { - const fieldValue = value["deletions"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.deletions: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.deletions` + ": expected number"); } - } - { - const fieldValue = value["patch"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.patch: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.patch` + ": expected string"); } - } - { - const fieldValue = value["patchTruncated"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.patchTruncated: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.patchTruncated` + ": expected boolean"); } - } - { - const fieldValue = value["previousPath"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.previousPath` + ": expected string"); } - } -} - -function checkCodeReviewWalkthroughGroupRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["groupTitle"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.groupTitle: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.groupTitle` + ": expected string"); } - } - { - const fieldValue = value["paths"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.paths: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.paths` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.paths` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["explanation"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.explanation: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.explanation` + ": expected string"); } - } -} - -function checkCodeReviewFindingRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["severity"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.severity: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.severity` + ": expected string"); } - } - { - const fieldValue = value["tier"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.tier: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.tier` + ": expected string"); } - } - { - const fieldValue = value["category"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.category: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.category` + ": expected string"); } - } - { - const fieldValue = value["path"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.path: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.path` + ": expected string"); } - } - { - const fieldValue = value["line"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.line: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.line` + ": expected number"); } - } - { - const fieldValue = value["title"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } - } - { - const fieldValue = value["evidence"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.evidence: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.evidence` + ": expected string"); } - } - { - const fieldValue = value["impact"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.impact: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.impact` + ": expected string"); } - } - { - const fieldValue = value["recommendation"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.recommendation: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.recommendation` + ": expected string"); } - } -} - -function checkCodeReviewErrorRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["severity"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.severity: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.severity` + ": expected string"); } - } - { - const fieldValue = value["tier"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.tier: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.tier` + ": expected string"); } - } - { - const fieldValue = value["kind"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.kind: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.kind` + ": expected string"); } - } - { - const fieldValue = value["path"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.path: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.path` + ": expected string"); } - } - { - const fieldValue = value["line"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.line: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.line` + ": expected number"); } - } - { - const fieldValue = value["title"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } - } - { - const fieldValue = value["evidence"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.evidence: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.evidence` + ": expected string"); } - } - { - const fieldValue = value["fix"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.fix: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.fix` + ": expected string"); } - } -} - -function checkCodeReviewQaRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["question"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.question: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.question` + ": expected string"); } - } - { - const fieldValue = value["answer"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.answer: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.answer` + ": expected string"); } - } -} - -function checkChartDataRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["version"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.version` + ": expected number"); } - } - { - const fieldValue = value["type"]; - if (fieldValue !== undefined && fieldValue !== null) { if (!["bar", "line", "pie", "histogram", "sankey"].includes(fieldValue as string)) errors.push(`${path}.type` + `: ${JSON.stringify(fieldValue)} is not one of [` + "bar, line, pie, histogram, sankey" + `]`); } - } - { - const fieldValue = value["title"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } - } - { - const fieldValue = value["labels"]; - if (fieldValue !== undefined && fieldValue !== null) { if (!Array.isArray(fieldValue)) errors.push(`${path}.labels` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "string") errors.push(`${path}.labels` + `[${i}]` + ": expected string"); }); } - } - { - const fieldValue = value["values"]; - if (fieldValue !== undefined && fieldValue !== null) { if (!Array.isArray(fieldValue)) errors.push(`${path}.values` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { if (typeof item !== "number") errors.push(`${path}.values` + `[${i}]` + ": expected number"); }); } - } - { - const fieldValue = value["xAxis"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.xAxis` + ": expected string"); } - } - { - const fieldValue = value["yAxis"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "string") errors.push(`${path}.yAxis` + ": expected string"); } - } - { - const fieldValue = value["bins"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.bins` + ": expected number"); } - } - { - const fieldValue = value["flows"]; - if (fieldValue !== undefined && fieldValue !== null) { if (!Array.isArray(fieldValue)) errors.push(`${path}.flows` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkChartFlowRow(item, `${path}.flows` + `[${i}]`, errors); }); } - } -} - -function checkChartFlowRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["source"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.source: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.source` + ": expected string"); } - } - { - const fieldValue = value["target"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.target: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.target` + ": expected string"); } - } - { - const fieldValue = value["value"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.value: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.value` + ": expected number"); } - } -} - -function checkToolInvocationRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["name"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.name: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.name` + ": expected string"); } - } - { - const fieldValue = value["argumentsJson"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.argumentsJson: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.argumentsJson` + ": expected string"); } - } - { - const fieldValue = value["result"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.result: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.result` + ": expected string"); } - } - { - const fieldValue = value["isError"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.isError: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.isError` + ": expected boolean"); } - } -} - -function checkPlanStepRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["title"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } - } - { - const fieldValue = value["status"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.status: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.status` + ": expected string"); } - } - { - const fieldValue = value["detail"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.detail: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.detail` + ": expected string"); } - } -} - -function checkBuilderActivityRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["tool"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.tool: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.tool` + ": expected string"); } - } - { - const fieldValue = value["summary"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.summary: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.summary` + ": expected string"); } - } - { - const fieldValue = value["outcome"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.outcome: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.outcome` + ": expected string"); } - } - { - const fieldValue = value["stepId"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.stepId: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.stepId` + ": expected string"); } - } - { - const fieldValue = value["elapsedMs"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.elapsedMs: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.elapsedMs` + ": expected number"); } - } -} - -function checkHarnessActivityRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["tool"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.tool: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.tool` + ": expected string"); } - } - { - const fieldValue = value["summary"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.summary: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.summary` + ": expected string"); } - } - { - const fieldValue = value["outcome"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.outcome: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.outcome` + ": expected string"); } - } - { - const fieldValue = value["elapsedMs"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.elapsedMs: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.elapsedMs` + ": expected number"); } - } -} - -function checkHarnessPlanStepRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["text"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.text: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.text` + ": expected string"); } - } - { - const fieldValue = value["status"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.status: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.status` + ": expected string"); } - } -} - -function checkSceneEdgeRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["source"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.source: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.source` + ": expected string"); } - } - { - const fieldValue = value["target"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.target: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.target` + ": expected string"); } - } -} - -function checkScenePinRow(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["id"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } - } - { - const fieldValue = value["title"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } - } - { - const fieldValue = value["note"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.note: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.note` + ": expected string"); } - } - { - const fieldValue = value["x"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.x: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.x` + ": expected number"); } - } - { - const fieldValue = value["y"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.y: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.y` + ": expected number"); } - } -} - -function checkSceneState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["nodes"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.nodes: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.nodes` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkSceneNodeRow(item, `${path}.nodes` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["edges"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.edges: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.edges` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkSceneEdgeRow(item, `${path}.edges` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["pins"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.pins: missing required field`); - else { if (!Array.isArray(fieldValue)) errors.push(`${path}.pins` + ": expected array"); - else (fieldValue as unknown[]).forEach((item, i) => { checkScenePinRow(item, `${path}.pins` + `[${i}]`, errors); }); } - } - { - const fieldValue = value["snapToGrid"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.snapToGrid: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.snapToGrid` + ": expected boolean"); } - } - { - const fieldValue = value["fadeConnectionsEnabled"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.fadeConnectionsEnabled: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.fadeConnectionsEnabled` + ": expected boolean"); } - } - { - const fieldValue = value["orthogonalRouting"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.orthogonalRouting: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.orthogonalRouting` + ": expected boolean"); } - } - { - const fieldValue = value["smartGuides"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.smartGuides: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.smartGuides` + ": expected boolean"); } - } - { - const fieldValue = value["hasSavedChat"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.hasSavedChat: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.hasSavedChat` + ": expected boolean"); } - } - { - const fieldValue = value["dragFactor"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.dragFactor: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.dragFactor` + ": expected number"); } - } - { - const fieldValue = value["fontFamily"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.fontFamily: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.fontFamily` + ": expected string"); } - } - { - const fieldValue = value["fontSizePt"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.fontSizePt: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.fontSizePt` + ": expected number"); } - } - { - const fieldValue = value["fontColor"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.fontColor: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.fontColor` + ": expected string"); } - } - { - const fieldValue = value["canUndo"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.canUndo: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.canUndo` + ": expected boolean"); } - } - { - const fieldValue = value["canRedo"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.canRedo: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.canRedo` + ": expected boolean"); } - } - { - const fieldValue = value["undoLabel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.undoLabel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.undoLabel` + ": expected string"); } - } - { - const fieldValue = value["redoLabel"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.redoLabel: missing required field`); - else { if (typeof fieldValue !== "string") errors.push(`${path}.redoLabel` + ": expected string"); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const CONVERSATION_MESSAGE_ROW_FIELDS: WireFields = [ + ["role", { e: ["user", "assistant"] }, 0], + ["content", "s", 0], + ["incomplete", "b", 0], +]; + +const RESEARCH_SOURCE_ROW_FIELDS: WireFields = [ + ["sourceId", "s", 0], + ["title", "s", 0], + ["url", "s", 0], + ["canonicalUrl", "s", 0], + ["snippet", "s", 0], + ["rank", "n", 0], + ["provider", "s", 0], + ["finalUrl", "s", 0], + ["status", "s", 0], + ["errorCode", "s", 0], + ["errorMessage", "s", 0], + ["truncated", "b", 0], + ["contentHash", "s", 0], + ["citationCount", "n", 0], +]; + +const RESEARCH_CITATION_ROW_FIELDS: WireFields = [ + ["sourceId", "s", 0], + ["marker", "s", 0], + ["claimContext", "s", 0], +]; + +const RESEARCH_RESULT_ROW_FIELDS: WireFields = [ + ["requestId", "s", 0], + ["originalQuery", "s", 0], + ["effectiveQuery", "s", 0], + ["answerMarkdown", "s", 0], + ["sources", { a: { o: RESEARCH_SOURCE_ROW_FIELDS } }, 0], + ["citations", { a: { o: RESEARCH_CITATION_ROW_FIELDS } }, 0], + ["warnings", { a: "s" }, 0], + ["providerSnapshot", { d: "s" }, 0], +]; + +const GITLINK_PENDING_CHANGE_ROW_FIELDS: WireFields = [ + ["path", "s", 0], + ["operation", "s", 0], + ["reason", "s", 0], + ["content", "s", 1], +]; + +const CODE_REVIEW_FILE_ROW_FIELDS: WireFields = [ + ["path", "s", 0], + ["status", "s", 0], + ["additions", "n", 0], + ["deletions", "n", 0], + ["patch", "s", 0], + ["patchTruncated", "b", 0], + ["previousPath", "s", 1], +]; + +const CODE_REVIEW_WALKTHROUGH_GROUP_ROW_FIELDS: WireFields = [ + ["groupTitle", "s", 0], + ["paths", { a: "s" }, 0], + ["explanation", "s", 0], +]; + +const CODE_REVIEW_FINDING_ROW_FIELDS: WireFields = [ + ["id", "s", 0], + ["severity", "s", 0], + ["tier", "s", 0], + ["category", "s", 0], + ["path", "s", 0], + ["line", "n", 0], + ["title", "s", 0], + ["evidence", "s", 0], + ["impact", "s", 0], + ["recommendation", "s", 0], +]; + +const CODE_REVIEW_ERROR_ROW_FIELDS: WireFields = [ + ["id", "s", 0], + ["severity", "s", 0], + ["tier", "s", 0], + ["kind", "s", 0], + ["path", "s", 0], + ["line", "n", 0], + ["title", "s", 0], + ["evidence", "s", 0], + ["fix", "s", 0], +]; + +const CODE_REVIEW_QA_ROW_FIELDS: WireFields = [ + ["question", "s", 0], + ["answer", "s", 0], +]; + +const CHART_FLOW_ROW_FIELDS: WireFields = [ + ["source", "s", 0], + ["target", "s", 0], + ["value", "n", 0], +]; + +const CHART_DATA_ROW_FIELDS: WireFields = [ + ["version", "n", 1], + ["type", { e: ["bar", "line", "pie", "histogram", "sankey"] }, 1], + ["title", "s", 1], + ["labels", { a: "s" }, 1], + ["values", { a: "n" }, 1], + ["xAxis", "s", 1], + ["yAxis", "s", 1], + ["bins", "n", 1], + ["flows", { a: { o: CHART_FLOW_ROW_FIELDS } }, 1], +]; + +const TOOL_INVOCATION_ROW_FIELDS: WireFields = [ + ["id", "s", 0], + ["name", "s", 0], + ["argumentsJson", "s", 0], + ["result", "s", 0], + ["isError", "b", 0], +]; + +const PLAN_STEP_ROW_FIELDS: WireFields = [ + ["id", "s", 0], + ["title", "s", 0], + ["status", "s", 0], + ["detail", "s", 0], +]; + +const BUILDER_ACTIVITY_ROW_FIELDS: WireFields = [ + ["tool", "s", 0], + ["summary", "s", 0], + ["outcome", "s", 0], + ["stepId", "s", 0], + ["elapsedMs", "n", 0], +]; + +const HARNESS_ACTIVITY_ROW_FIELDS: WireFields = [ + ["tool", "s", 0], + ["summary", "s", 0], + ["outcome", "s", 0], + ["elapsedMs", "n", 0], +]; + +const HARNESS_PLAN_STEP_ROW_FIELDS: WireFields = [ + ["text", "s", 0], + ["status", "s", 0], +]; + +const SCENE_NODE_ROW_FIELDS: WireFields = [ + ["id", "s", 0], + ["x", "n", 0], + ["y", "n", 0], + ["title", "s", 0], + ["kind", "s", 0], + ["content", "s", 0, ""], + ["isUser", "b", 0, false], + ["isCollapsed", "b", 0, false], + ["code", "s", 0, ""], + ["language", "s", 0, ""], + ["attachmentKind", "s", 0, ""], + ["filePath", "s", 0, ""], + ["mimeType", "s", 0, ""], + ["durationSeconds", "n", 1, null], + ["byteSize", "n", 1, null], + ["previewLabel", "s", 0, ""], + ["isDocked", "b", 0, false], + ["imageAssetId", "s", 0, ""], + ["history", { a: { o: CONVERSATION_MESSAGE_ROW_FIELDS } }, 0, []], + ["pendingRequestId", "s", 1, null], + ["researchStage", "s", 0, ""], + ["researchCompleted", "n", 0, 0], + ["researchTotal", "n", 0, 0], + ["researchActiveSourceId", "s", 1, null], + ["researchError", "s", 0, ""], + ["researchResult", { o: RESEARCH_RESULT_ROW_FIELDS }, 1, null], + ["researchRetainToKnowledge", "b", 0, false], + ["artifactContent", "s", 0, ""], + ["artifactError", "s", 0, ""], + ["gitlinkRepo", "s", 0, ""], + ["gitlinkBranch", "s", 0, ""], + ["gitlinkScopeMode", "s", 0, "selected"], + ["gitlinkLocalRoot", "s", 0, ""], + ["gitlinkRepoFilePaths", { a: "s" }, 0, []], + ["gitlinkSelectedPaths", { a: "s" }, 0, []], + ["gitlinkTaskPrompt", "s", 0, ""], + ["gitlinkContextStats", { d: "s" }, 0, {}], + ["gitlinkContextSummary", "s", 0, ""], + ["gitlinkContextVersion", "n", 0, 0], + ["gitlinkProposalMarkdown", "s", 0, ""], + ["gitlinkPendingChanges", { a: { o: GITLINK_PENDING_CHANGE_ROW_FIELDS } }, 0, []], + ["gitlinkPreviewText", "s", 0, ""], + ["gitlinkChangeFingerprint", "s", 1, null], + ["gitlinkChangeState", "s", 0, "draft"], + ["gitlinkError", "s", 0, ""], + ["codeReviewPrUrl", "s", 0, ""], + ["codeReviewRepo", "s", 0, ""], + ["codeReviewPrNumber", "n", 0, 0], + ["codeReviewPrTitle", "s", 0, ""], + ["codeReviewPrState", "s", 0, ""], + ["codeReviewPrHtmlUrl", "s", 0, ""], + ["codeReviewBaseRef", "s", 0, ""], + ["codeReviewHeadRef", "s", 0, ""], + ["codeReviewAdditions", "n", 0, 0], + ["codeReviewDeletions", "n", 0, 0], + ["codeReviewChangedFiles", "n", 0, 0], + ["codeReviewFiles", { a: { o: CODE_REVIEW_FILE_ROW_FIELDS } }, 0, []], + ["codeReviewFilesTruncated", "b", 0, false], + ["codeReviewDiffTruncated", "b", 0, false], + ["codeReviewDiffChars", "n", 0, 0], + ["codeReviewDiffVersion", "n", 0, 0], + ["codeReviewWalkthrough", { a: { o: CODE_REVIEW_WALKTHROUGH_GROUP_ROW_FIELDS } }, 0, []], + ["codeReviewFindings", { a: { o: CODE_REVIEW_FINDING_ROW_FIELDS } }, 0, []], + ["codeReviewErrors", { a: { o: CODE_REVIEW_ERROR_ROW_FIELDS } }, 0, []], + ["codeReviewDismissedIds", { a: "s" }, 0, []], + ["codeReviewTitle", "s", 0, ""], + ["codeReviewOverview", "s", 0, ""], + ["codeReviewConfidence", "s", 0, ""], + ["codeReviewScores", { d: "s" }, 0, {}], + ["codeReviewQualityScore", "n", 0, 0], + ["codeReviewVerdict", "s", 0, "none"], + ["codeReviewRisk", "s", 0, ""], + ["codeReviewQualitySummary", "s", 0, ""], + ["codeReviewQa", { a: { o: CODE_REVIEW_QA_ROW_FIELDS } }, 0, []], + ["codeReviewState", "s", 0, "draft"], + ["codeReviewError", "s", 0, ""], + ["codeSandboxRequirements", "s", 0, ""], + ["codeSandboxPrompt", "s", 0, ""], + ["codeSandboxCode", "s", 0, ""], + ["codeSandboxOutput", "s", 0, ""], + ["codeSandboxAnalysis", "s", 0, ""], + ["codeSandboxAwaitingApproval", "b", 0, false], + ["codeSandboxApprovalRequirements", "s", 0, ""], + ["codeSandboxApprovalAllowSourceBuilds", "b", 0, false], + ["codeSandboxApprovalIsRepair", "b", 0, false], + ["codeSandboxError", "s", 0, ""], + ["provider", "s", 1, null], + ["model", "s", 1, null], + ["isBranchSynthesis", "b", 0, false], + ["synthesisInstructions", "s", 0, ""], + ["branchStatus", "s", 0, "active"], + ["responseIncomplete", "b", 0, false], + ["promptTokens", "n", 1, null], + ["completionTokens", "n", 1, null], + ["estimatedCostUsd", "n", 1, null], + ["isFinalDeliverable", "b", 0, false], + ["color", "s", 1, null], + ["headerColor", "s", 1, null], + ["isSystemPrompt", "b", 0, false], + ["isSummaryNote", "b", 0, false], + ["isBranchComparison", "b", 0, false], + ["itemIds", { a: "s" }, 0, []], + ["isLocked", "b", 0, true], + ["groupWidth", "n", 1, null], + ["groupHeight", "n", 1, null], + ["chartType", "s", 0, ""], + ["chartData", { o: CHART_DATA_ROW_FIELDS }, 0, {}], + ["chartError", "s", 0, ""], + ["chartWidth", "n", 0, 480.0], + ["chartHeight", "n", 0, 340.0], + ["chartAspectLocked", "b", 0, true], + ["chartSourceNodeId", "s", 0, ""], + ["htmlSplitterState", "n", 1, null], + ["chatScrollValue", "n", 0, 0.0], + ["toolCalls", { a: { o: TOOL_INVOCATION_ROW_FIELDS } }, 0, []], + ["overrideProvider", "s", 0, ""], + ["overrideModelId", "s", 0, ""], + ["indexIntoKnowledge", "b", 0, false], + ["planGoal", "s", 0, ""], + ["planSteps", { a: { o: PLAN_STEP_ROW_FIELDS } }, 0, []], + ["builderActivity", { a: { o: BUILDER_ACTIVITY_ROW_FIELDS } }, 0, []], + ["builderStatus", "s", 0, ""], + ["builderMode", "s", 0, ""], + ["builderRunId", "s", 0, ""], + ["builderMaxSteps", "n", 0, 0], + ["builderMaxTokens", "n", 0, 0], + ["builderMaxWallSeconds", "n", 0, 0], + ["builderSpentSteps", "n", 0, 0], + ["builderSpentTokens", "n", 0, 0], + ["builderSpentWallSeconds", "n", 0, 0], + ["builderAwaitingToolApproval", "b", 0, false], + ["builderApprovalToolName", "s", 0, ""], + ["builderApprovalSummary", "s", 0, ""], + ["builderStatusDetail", "s", 0, ""], + ["harnessGoal", "s", 0, ""], + ["harnessReply", "s", 0, ""], + ["harnessStatus", "s", 0, ""], + ["harnessStatusDetail", "s", 0, ""], + ["harnessRunId", "s", 0, ""], + ["harnessActivity", { a: { o: HARNESS_ACTIVITY_ROW_FIELDS } }, 0, []], + ["harnessContextTokens", "n", 0, 0], + ["harnessMaxContextTokens", "n", 0, 0], + ["harnessCompactions", "n", 0, 0], + ["harnessAwaitingApproval", "b", 0, false], + ["harnessApprovalToolName", "s", 0, ""], + ["harnessApprovalSummary", "s", 0, ""], + ["harnessApprovalSessionOffered", "b", 0, false], + ["harnessPlan", { a: { o: HARNESS_PLAN_STEP_ROW_FIELDS } }, 0, []], + ["harnessAwaitingQuestion", "b", 0, false], + ["harnessQuestion", "s", 0, ""], + ["harnessWorkspacePath", "s", 0, ""], + ["harnessWorkspaceActive", "s", 0, ""], + ["harnessMaxTurns", "n", 0, 0], + ["harnessSpentTurns", "n", 0, 0], + ["harnessSpentTokens", "n", 0, 0], + ["pluginState", { d: "s" }, 0, {}], +]; + +const SCENE_EDGE_ROW_FIELDS: WireFields = [ + ["id", "s", 0], + ["source", "s", 0], + ["target", "s", 0], +]; + +const SCENE_PIN_ROW_FIELDS: WireFields = [ + ["id", "s", 0], + ["title", "s", 0], + ["note", "s", 0], + ["x", "n", 0], + ["y", "n", 0], +]; + +const SCENE_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["nodes", { a: { o: SCENE_NODE_ROW_FIELDS } }, 0], + ["edges", { a: { o: SCENE_EDGE_ROW_FIELDS } }, 0], + ["pins", { a: { o: SCENE_PIN_ROW_FIELDS } }, 0], + ["snapToGrid", "b", 0], + ["fadeConnectionsEnabled", "b", 0], + ["orthogonalRouting", "b", 0], + ["smartGuides", "b", 0], + ["hasSavedChat", "b", 0], + ["dragFactor", "n", 0], + ["fontFamily", "s", 0], + ["fontSizePt", "n", 0], + ["fontColor", "s", 0], + ["canUndo", "b", 0], + ["canRedo", "b", 0], + ["undoLabel", "s", 0], + ["redoLabel", "s", 0], + ["minCompatibleSchemaVersion", "n", 1], +]; + +/** SceneNodeRow crosses the wire sparse: the sender omits every field at its + * declared default (contracts: WIRE_OMITS_DEFAULTS). These are those + * defaults - the value every omitted field is restored to. */ +export const SCENE_NODE_ROW_WIRE_DEFAULTS = wireDefaults(SCENE_NODE_ROW_FIELDS); + +export function hydrateSceneNodeRow(value: unknown): unknown { + return hydrateRow(SCENE_NODE_ROW_FIELDS, SCENE_NODE_ROW_WIRE_DEFAULTS, value); +} + +function hydrateSceneState(value: unknown): unknown { + if (!isRecord(value)) return value; + let hydrated: Record | null = null; + { + const rows = value["nodes"]; + if (Array.isArray(rows)) { + const restored = rows.map(hydrateSceneNodeRow); + if (restored.some((row, i) => row !== rows[i])) (hydrated ??= { ...value })["nodes"] = restored; + } + } + return hydrated ?? value; +} + +const checkSceneState = compileFields(SCENE_STATE_FIELDS); export function validateSceneState(value: unknown): ValidationResult { + const hydrated = hydrateSceneState(value); const errors: string[] = []; - checkSceneState(value, "$", errors); + checkSceneState(hydrated, "$", errors); return errors.length === 0 - ? { ok: true, value: value as SceneState } + ? { ok: true, value: hydrated as SceneState } : { ok: false, errors }; } diff --git a/web_ui/src/lib/bridge-core/generated/token-counter-state.ts b/web_ui/src/lib/bridge-core/generated/token-counter-state.ts index 041b0909..1b56e9ec 100644 --- a/web_ui/src/lib/bridge-core/generated/token-counter-state.ts +++ b/web_ui/src/lib/bridge-core/generated/token-counter-state.ts @@ -2,6 +2,10 @@ * Regenerate with codegen.py; a pytest fails if this file * drifts from what regenerating it now would produce. */ +import { type ValidationResult, type WireFields, compileFields } from "../wireCheck"; + +export type { ValidationResult }; + export interface TokenCounterState { schemaVersion: number; revision: number; @@ -19,90 +23,24 @@ export interface TokenCounterState { minCompatibleSchemaVersion?: number | null; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; errors: string[] }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -// Unknown keys are tolerated on purpose. The JSON Schema marks the contract -// additionalProperties:false because Python and the schema must not drift, but -// an incoming payload carrying a field this build has never heard of is the -// normal, expected shape of a NEWER compatible sender - rejecting it here would -// defeat the additive-forward-compatibility the version negotiation exists to -// provide. Missing or wrongly-typed KNOWN fields are still hard errors. +const TOKEN_COUNTER_STATE_FIELDS: WireFields = [ + ["schemaVersion", "n", 0], + ["revision", "n", 0], + ["inputTokens", "n", 0], + ["outputTokens", "n", 0], + ["contextTokens", "n", 0], + ["totalTokens", "n", 0], + ["promptTokens", "n", 1], + ["completionTokens", "n", 1], + ["usageIsReal", "b", 0], + ["estimatedCostUsd", "n", 1], + ["sessionPromptTokens", "n", 0], + ["sessionCompletionTokens", "n", 0], + ["sessionEstimatedCostUsd", "n", 0], + ["minCompatibleSchemaVersion", "n", 1], +]; -function checkTokenCounterState(value: unknown, path: string, errors: string[]): void { - if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } - { - const fieldValue = value["schemaVersion"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } - } - { - const fieldValue = value["revision"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } - } - { - const fieldValue = value["inputTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.inputTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.inputTokens` + ": expected number"); } - } - { - const fieldValue = value["outputTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.outputTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.outputTokens` + ": expected number"); } - } - { - const fieldValue = value["contextTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.contextTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.contextTokens` + ": expected number"); } - } - { - const fieldValue = value["totalTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.totalTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.totalTokens` + ": expected number"); } - } - { - const fieldValue = value["promptTokens"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.promptTokens` + ": expected number"); } - } - { - const fieldValue = value["completionTokens"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.completionTokens` + ": expected number"); } - } - { - const fieldValue = value["usageIsReal"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.usageIsReal: missing required field`); - else { if (typeof fieldValue !== "boolean") errors.push(`${path}.usageIsReal` + ": expected boolean"); } - } - { - const fieldValue = value["estimatedCostUsd"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.estimatedCostUsd` + ": expected number"); } - } - { - const fieldValue = value["sessionPromptTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sessionPromptTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.sessionPromptTokens` + ": expected number"); } - } - { - const fieldValue = value["sessionCompletionTokens"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sessionCompletionTokens: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.sessionCompletionTokens` + ": expected number"); } - } - { - const fieldValue = value["sessionEstimatedCostUsd"]; - if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.sessionEstimatedCostUsd: missing required field`); - else { if (typeof fieldValue !== "number") errors.push(`${path}.sessionEstimatedCostUsd` + ": expected number"); } - } - { - const fieldValue = value["minCompatibleSchemaVersion"]; - if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } - } -} +const checkTokenCounterState = compileFields(TOKEN_COUNTER_STATE_FIELDS); export function validateTokenCounterState(value: unknown): ValidationResult { const errors: string[] = []; diff --git a/web_ui/src/lib/bridge-core/sceneRowHydration.test.ts b/web_ui/src/lib/bridge-core/sceneRowHydration.test.ts new file mode 100644 index 00000000..89a8793b --- /dev/null +++ b/web_ui/src/lib/bridge-core/sceneRowHydration.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + SCENE_NODE_ROW_WIRE_DEFAULTS, + hydrateSceneNodeRow, + validateSceneState, + type SceneNodeRow, +} from "./generated/scene-state"; + +// Scene node rows cross the wire sparse: the backend (backend/domain/ +// node_wire.py) leaves out every field at its contract default, and the +// generated hydrateSceneNodeRow restores them - inside validateSceneState for +// snapshots, and in SceneStore.applyScenePatch for upserts. Everything past +// those two points reads full rows, exactly as before the wire went sparse. + +const IDENTITY = { id: "n7", x: 10, y: 20, title: "Seven", kind: "chat" } as const; + +function scene(nodes: unknown[]) { + return { + schemaVersion: 2, + minCompatibleSchemaVersion: 2, + revision: 1, + nodes, + edges: [], + pins: [], + snapToGrid: false, + fadeConnectionsEnabled: false, + orthogonalRouting: false, + smartGuides: true, + hasSavedChat: false, + dragFactor: 0.5, + fontFamily: "Segoe UI", + fontSizePt: 10, + fontColor: "#ffffff", + canUndo: false, + canRedo: false, + undoLabel: "", + redoLabel: "", + }; +} + +describe("hydrateSceneNodeRow", () => { + it("restores every omitted field to its contract default", () => { + const row = hydrateSceneNodeRow({ ...IDENTITY }) as Record; + for (const [key, fallback] of SCENE_NODE_ROW_WIRE_DEFAULTS) { + expect(row[key], key).toEqual(fallback); + } + expect(row).toMatchObject(IDENTITY); + }); + + it("keeps every field the sender did send", () => { + const row = hydrateSceneNodeRow({ + ...IDENTITY, + branchStatus: "accepted", + isUser: true, + history: [{ role: "user", content: "hi", incomplete: false }], + }) as SceneNodeRow; + expect(row.branchStatus).toBe("accepted"); + expect(row.isUser).toBe(true); + expect(row.history).toEqual([{ role: "user", content: "hi", incomplete: false }]); + expect(row.chartWidth).toBe(480); + }); + + it("leaves an explicit null alone - only an ABSENT field is restored", () => { + // A null the sender actually sent is data; whether it is legal is the + // validator's call, not something hydration may paper over. + const row = hydrateSceneNodeRow({ ...IDENTITY, branchStatus: null }) as Record; + expect(row.branchStatus).toBeNull(); + }); + + it("returns the same object when nothing is missing", () => { + const whole = hydrateSceneNodeRow({ ...IDENTITY }); + expect(hydrateSceneNodeRow(whole)).toBe(whole); + }); + + it("never shares a restored container between rows", () => { + const a = hydrateSceneNodeRow({ ...IDENTITY }) as SceneNodeRow; + const b = hydrateSceneNodeRow({ ...IDENTITY, id: "n8" }) as SceneNodeRow; + expect(a.history).not.toBe(b.history); + expect(a.chartData).not.toBe(b.chartData); + }); + + it("passes a non-object through untouched for the validator to reject", () => { + expect(hydrateSceneNodeRow(null)).toBeNull(); + expect(hydrateSceneNodeRow("row")).toBe("row"); + }); +}); + +describe("validateSceneState on a sparse snapshot", () => { + it("accepts rows carrying only their identity and hands back restored rows", () => { + const result = validateSceneState(scene([{ ...IDENTITY }, { ...IDENTITY, id: "n8", kind: "note", isSystemPrompt: true }])); + expect(result.ok).toBe(true); + if (!result.ok) return; + const [chat, note] = result.value.nodes; + expect(chat.content).toBe(""); + expect(chat.pendingRequestId).toBeNull(); + expect(chat.isLocked).toBe(true); + expect(note.isSystemPrompt).toBe(true); + expect(note.gitlinkScopeMode).toBe("selected"); + }); + + it("still rejects a row missing a field that has no default", () => { + const { id: _id, ...noId } = IDENTITY; + expect(validateSceneState(scene([noId])).ok).toBe(false); + }); + + it("still rejects a sent field of the wrong type", () => { + expect(validateSceneState(scene([{ ...IDENTITY, isUser: "yes" }])).ok).toBe(false); + }); + + it("returns the very same payload when every row is already whole", () => { + const whole = scene([hydrateSceneNodeRow({ ...IDENTITY })]); + const result = validateSceneState(whole); + expect(result.ok && result.value).toBe(whole); + }); +}); diff --git a/web_ui/src/lib/bridge-core/wireCheck.test.ts b/web_ui/src/lib/bridge-core/wireCheck.test.ts new file mode 100644 index 00000000..6f28a973 --- /dev/null +++ b/web_ui/src/lib/bridge-core/wireCheck.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { compileFields, hydrateRow, wireDefaults, type WireFields } from "./wireCheck"; + +// The generated validators (./generated/*.ts) are field tables compiled by +// this runtime. It replaced unrolled per-field code that had been checked in +// for every topic, and was proven against it input-for-input (52,000 fuzzed +// payloads across all 13 topics, identical results and messages) before that +// code was deleted - these tests pin the behaviour that proof established. + +const ROW: WireFields = [["label", "s", 0]]; +const SHAPE: WireFields = [ + ["name", "s", 0], + ["on", "b", 0], + ["count", "n", 0], + ["mode", { e: ["a", "b"] }, 0], + ["tags", { a: "s" }, 0], + ["stats", { d: "s" }, 0], + ["rows", { a: { o: ROW } }, 0], + ["nested", { o: ROW }, 1], + ["note", "s", 1], +]; + +function run(fields: WireFields, value: unknown): string[] { + const errors: string[] = []; + compileFields(fields)(value, "$", errors); + return errors; +} + +const VALID = { name: "n", on: true, count: 1, mode: "a", tags: ["x"], stats: { k: "v" }, rows: [{ label: "r" }] }; + +describe("compileFields", () => { + it("accepts a valid payload, with or without its optional fields", () => { + expect(run(SHAPE, VALID)).toEqual([]); + expect(run(SHAPE, { ...VALID, nested: { label: "x" }, note: "y" })).toEqual([]); + }); + + it("reports each wrong scalar type at the field's path", () => { + expect(run(SHAPE, { ...VALID, name: 1, on: "yes", count: "2" })).toEqual([ + "$.name: expected string", + "$.on: expected boolean", + "$.count: expected number", + ]); + }); + + it("treats a missing and a null required field alike, and skips a missing or null optional one", () => { + const { name: _name, ...noName } = VALID; + expect(run(SHAPE, noName)).toEqual(["$.name: missing required field"]); + expect(run(SHAPE, { ...VALID, name: null })).toEqual(["$.name: missing required field"]); + expect(run(SHAPE, { ...VALID, note: null, nested: null })).toEqual([]); + }); + + it("names the allowed values when a literal is off the list", () => { + expect(run(SHAPE, { ...VALID, mode: "c" })).toEqual(['$.mode: "c" is not one of [a, b]']); + }); + + it("checks arrays, records and nested rows at indexed paths", () => { + expect( + run(SHAPE, { ...VALID, tags: ["x", 2], stats: { "a.b": 3 }, rows: [{ label: "ok" }, { label: 5 }, 7] }), + ).toEqual([ + "$.tags[1]: expected string", + '$.stats["a.b"]: expected string', + "$.rows[1].label: expected string", + "$.rows[2]: expected object", + ]); + expect(run(SHAPE, { ...VALID, tags: "x", stats: [], rows: {} })).toEqual([ + "$.tags: expected array", + "$.stats: expected object", + "$.rows: expected array", + ]); + }); + + it("rejects a non-object outright and tolerates keys it does not know", () => { + expect(run(SHAPE, [])).toEqual(["$: expected object"]); + expect(run(SHAPE, { ...VALID, fromANewerSender: 1 })).toEqual([]); + }); +}); + +describe("sparse rows", () => { + const SPARSE: WireFields = [ + ["id", "s", 0], + ["title", "s", 0, ""], + ["items", { a: "s" }, 0, []], + ["meta", { d: "s" }, 0, {}], + ["owner", "s", 1, null], + ]; + const DEFAULTS = wireDefaults(SPARSE); + + it("restores absent fields, in table order, keeping unknown keys after them", () => { + const row = hydrateRow(SPARSE, DEFAULTS, { extra: 1, id: "r1" }) as Record; + expect(row).toEqual({ id: "r1", title: "", items: [], meta: {}, owner: null, extra: 1 }); + expect(Object.keys(row)).toEqual(["id", "title", "items", "meta", "owner", "extra"]); + }); + + it("returns a row that is already whole as the same object", () => { + const whole = { id: "r1", title: "t", items: [], meta: {}, owner: null }; + expect(hydrateRow(SPARSE, DEFAULTS, whole)).toBe(whole); + }); + + it("does not treat an explicit null as absent", () => { + const row = hydrateRow(SPARSE, DEFAULTS, { id: "r1", title: null }) as Record; + expect(row.title).toBeNull(); + expect(run(SPARSE, row)).toEqual(["$.title: missing required field"]); + }); + + it("gives every restored row its own containers", () => { + const a = hydrateRow(SPARSE, DEFAULTS, { id: "a" }) as { items: unknown[] }; + const b = hydrateRow(SPARSE, DEFAULTS, { id: "b" }) as { items: unknown[] }; + expect(a.items).not.toBe(b.items); + }); + + it("re-validating a row object that already passed costs nothing, but a failing row is always re-checked", () => { + const check = compileFields(SPARSE); + const good = { id: "r", title: "", items: [], meta: {}, owner: null }; + const errors: string[] = []; + check(good, "$", errors); + expect(errors).toEqual([]); + // Stored rows are replaced, never edited - the memo trusts that. This + // edit exists only to prove the second call really was skipped. + (good as Record).title = 5; + check(good, "$", errors); + expect(errors).toEqual([]); + + const bad = { id: "r", title: 5, items: [], meta: {}, owner: null }; + check(bad, "$", errors); + check(bad, "$", errors); + expect(errors).toEqual(["$.title: expected string", "$.title: expected string"]); + }); + + it("only sparse rows are memoized - any other shape is checked every time", () => { + const check = compileFields(ROW); + const row: Record = { label: "x" }; + const errors: string[] = []; + check(row, "$", errors); + row.label = 5; + check(row, "$", errors); + expect(errors).toEqual(["$.label: expected string"]); + }); +}); diff --git a/web_ui/src/lib/bridge-core/wireCheck.ts b/web_ui/src/lib/bridge-core/wireCheck.ts new file mode 100644 index 00000000..2a250f9c --- /dev/null +++ b/web_ui/src/lib/bridge-core/wireCheck.ts @@ -0,0 +1,236 @@ +/** + * The runtime half of the wire validators contracts/codegen.py generates. + * + * Each generated file (./generated/*.ts) holds its payload's shape as DATA - + * one `[name, type, optional]` row per field - and builds its validator from + * that table with `compileFields` once, at module load. The validators used to + * be emitted as unrolled code, one block per field: ~190 minified bytes a + * field, 87 KB across every topic, all of it in the initial chunk (every topic + * is subscribed at startup), the largest single piece of app code there. A + * table row costs ~25 bytes, and the checker compiled from it keeps the + * unrolled code's shape at run time: one closure per field, no per-value type + * dispatch. + * + * Behaviour is exactly the unrolled validators', message for message: a + * missing or null required field is "missing required field", a present one + * is type-checked, an absent or null optional field is skipped, and unknown + * keys are tolerated on purpose - the JSON Schema marks the contract + * additionalProperties:false because Python and the schema must not drift, + * but an incoming payload carrying a field this build has never heard of is + * the normal, expected shape of a NEWER compatible sender, and rejecting it + * would defeat the additive-forward-compatibility the version negotiation + * exists to provide. + */ + +export type ValidationResult = + | { ok: true; value: T } + | { ok: false; errors: string[] }; + +/** A field's type: "s" string, "b" boolean, "n" number; `e` a string + * literal union, `a` an array of, `d` a string-keyed record of, `o` a nested + * object with its own field table. */ +export type WireType = + | "s" + | "b" + | "n" + | { readonly e: readonly string[] } + | { readonly a: WireType } + | { readonly d: WireType } + | { readonly o: WireFields }; + +/** [name, type, optional (1) or required (0), wire default - sparse rows only] */ +export type WireField = readonly [string, WireType, 0 | 1, unknown?]; +export type WireFields = readonly WireField[]; + +type Check = (value: unknown, path: string, errors: string[]) => void; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function compileType(type: WireType): Check { + if (type === "s") { + return (value, path, errors) => { + if (typeof value !== "string") errors.push(path + ": expected string"); + }; + } + if (type === "b") { + return (value, path, errors) => { + if (typeof value !== "boolean") errors.push(path + ": expected boolean"); + }; + } + if (type === "n") { + return (value, path, errors) => { + if (typeof value !== "number") errors.push(path + ": expected number"); + }; + } + if ("e" in type) { + const allowed = type.e; + const listed = allowed.join(", "); + return (value, path, errors) => { + if (!allowed.includes(value as string)) { + errors.push(path + `: ${JSON.stringify(value)} is not one of [` + listed + `]`); + } + }; + } + if ("a" in type) { + const item = compileType(type.a); + return (value, path, errors) => { + if (!Array.isArray(value)) errors.push(path + ": expected array"); + else value.forEach((entry: unknown, i) => item(entry, path + `[${i}]`, errors)); + }; + } + if ("d" in type) { + const entryCheck = compileType(type.d); + return (value, path, errors) => { + if (!isRecord(value)) errors.push(path + ": expected object"); + // Bracket notation with a JSON-stringified key: a dict key is + // arbitrary data, and dot-appending one containing "." or "[0]" would + // make its error path look like a deeper nested field. + else Object.entries(value).forEach(([k, v]) => entryCheck(v, path + `[${JSON.stringify(k)}]`, errors)); + }; + } + return compileFields(type.o); +} + +// Field kinds the per-object loop checks inline - no call, and no path string +// built unless the check fails (a valid payload builds none at all for its +// scalar fields, the overwhelming majority). +const KIND_STRING = 1; +const KIND_BOOLEAN = 2; +const KIND_NUMBER = 3; +const KIND_OTHER = 0; + +interface CompiledField { + readonly name: string; + readonly optional: boolean; + readonly kind: number; + readonly check: Check | null; + readonly missing: string; + readonly suffix: string; +} + +/** The checker for one object shape - build it once, call it per payload. */ +export function compileFields(fields: WireFields): Check { + const compiled: CompiledField[] = fields.map(([name, type, optional]) => { + const kind = type === "s" ? KIND_STRING : type === "b" ? KIND_BOOLEAN : type === "n" ? KIND_NUMBER : KIND_OTHER; + return { + name, + optional: optional === 1, + kind, + check: kind === KIND_OTHER ? compileType(type) : null, + missing: `.${name}: missing required field`, + suffix: `.${name}: expected ${type === "s" ? "string" : type === "b" ? "boolean" : "number"}`, + }; + }); + const count = compiled.length; + const check: Check = (value, path, errors) => { + if (!isRecord(value)) { + errors.push(`${path}: expected object`); + return; + } + for (let i = 0; i < count; i++) { + const field = compiled[i]; + const fieldValue = value[field.name]; + if (fieldValue === undefined || fieldValue === null) { + if (!field.optional) errors.push(path + field.missing); + continue; + } + switch (field.kind) { + case KIND_STRING: + if (typeof fieldValue !== "string") errors.push(path + field.suffix); + break; + case KIND_BOOLEAN: + if (typeof fieldValue !== "boolean") errors.push(path + field.suffix); + break; + case KIND_NUMBER: + if (typeof fieldValue !== "number") errors.push(path + field.suffix); + break; + default: + field.check!(fieldValue, `${path}.${field.name}`, errors); + } + } + }; + if (!fields.some((field) => field.length > 3)) return check; + // A sparse row - the scene's node rows. The scene store re-validates the + // WHOLE scene after every patch, so without this each patch re-checked + // every row on the canvas, though a patch replaces only the rows it + // touches: a row object that has passed once is unchanged by construction + // (stored rows are replaced, never edited in place). Remembering the rows + // that passed makes a patch's validation cost the rows it carries. + return (value, path, errors) => { + if (isRecord(value) && validRows.has(value)) return; + const before = errors.length; + check(value, path, errors); + if (errors.length === before && isRecord(value)) validRows.add(value); + }; +} + +// Sparse rows that have passed their check - see the end of compileFields. +const validRows = new WeakSet(); + +/** The `[name, default]` pairs of a sparse row's table (graphlink_wire_schema + * .py's "SPARSE ROWS"): every field its sender may leave out. */ +export function wireDefaults(fields: WireFields): ReadonlyArray { + return fields.filter((field) => field.length > 3).map((field) => [field[0], field[3]] as const); +} + +/** Restore every field a sparse row's sender left out. + * + * Only an ABSENT field is restored - a present one, explicit null included, + * is data and is left for the validator to judge. Containers restore as + * fresh empty values (every container default is empty - wire_default + * enforces it), never a shared instance. + * + * A row with nothing missing comes back as the SAME object, so re-validating + * rows that are already whole (the scene store re-checks the whole scene + * after every patch) allocates nothing and keeps each untouched row's + * identity. A row that needs restoring is rebuilt with every field in TABLE + * order, whichever ones its sender happened to include: every restored row of + * a kind then has one key order - one hidden class to the JS engine - as the + * full rows the wire used to carry did, so property reads across the app stay + * monomorphic. A key the table does not know (a newer sender's field) is + * kept, after the known ones. + * + * Rows this function has already produced or found whole are remembered, so + * the scene store's re-check of every stored row after each patch skips the + * per-key scan for them. That is sound because stored rows are replaced, + * never edited in place - and if one ever were, a defaulted field deleted + * from it would still be caught: those fields are required by the + * validator that runs right after. */ +const wholeRows = new WeakSet(); + +export function hydrateRow( + fields: WireFields, + defaults: ReadonlyArray, + value: unknown, +): unknown { + if (!isRecord(value)) return value; + if (wholeRows.has(value)) return value; + let whole = true; + for (const [key] of defaults) { + if (value[key] === undefined) { + whole = false; + break; + } + } + if (whole) { + wholeRows.add(value); + return value; + } + const hydrated: Record = {}; + for (const field of fields) { + const key = field[0]; + const sent = value[key]; + if (sent !== undefined) hydrated[key] = sent; + else if (field.length > 3) { + const fallback = field[3]; + hydrated[key] = Array.isArray(fallback) ? [] : isRecord(fallback) ? {} : fallback; + } + } + for (const key of Object.keys(value)) { + if (!(key in hydrated)) hydrated[key] = value[key]; + } + wholeRows.add(hydrated); + return hydrated; +}