From 6c164f31f82c8787f73c9d7aac29589f90d5b7f4 Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sat, 8 Aug 2026 18:15:38 -0400 Subject: [PATCH] feat(console): land ARD Parts 1/4/6 UI remaining (excl WorkOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the data-model settings register to /rest/metadata, gate program Run on validate, bind commands gallery to substrate gallery/gallery_fork, and close D20–D23 / D33–D36 with named blockers. WorkOS stays deferred. --- .full-stack-feature/00-research-map.md | 173 ++++++ .../EXECUTE-CHECKLIST-WAVE-A.md | 31 + .../EXECUTE-CHECKLIST-WAVE-B.md | 21 + .../EXECUTE-REPORT-REMAINING.md | 55 ++ .full-stack-feature/EXECUTE-REPORT-WAVE-A.md | 33 ++ .full-stack-feature/EXECUTE-REPORT-WAVE-B.md | 37 ++ .full-stack-feature/SPEC-REVIEW.md | 167 ++++++ ...rd-ui-remaining-1.0--fallback-20260808a.md | 35 ++ .../ard-ui-remaining-checklist.json | 446 +++++++++++++++ .full-stack-feature/research-d20-d23-seams.md | 14 + .full-stack-feature/research-d32-d36-seams.md | 16 + .full-stack-feature/state.json | 22 + apps/console/AGENTS.md | 5 +- apps/console/CLAUDE.md | 1 + apps/console/scripts/smoke-metadata-rest.mjs | 51 ++ .../src/app/Data-model/settings/page.tsx | 2 + .../api/rest/metadata/[[...path]]/route.ts | 17 + apps/console/src/app/commands/page.tsx | 2 + apps/console/src/app/goals/page.tsx | 2 + .../lib/rail/block-surface-targets.test.ts | 36 ++ .../src/lib/rail/block-surface-targets.ts | 74 +++ .../server/local-dev-metadata-store.test.ts | 72 +++ .../lib/server/local-dev-metadata-store.ts | 489 ++++++++++++++++ apps/console/src/lib/server/metadata-rest.ts | 83 +++ apps/console/src/lib/surface-routes.ts | 5 + .../src/lib/view-routing-retirement.test.ts | 6 + apps/console/src/lib/workspace-seed.ts | 55 +- .../console/src/views/CommandsGalleryView.tsx | 149 ++--- apps/console/src/views/RecordTableView.tsx | 11 +- apps/console/src/views/model/ModelView.tsx | 402 ++++++------- .../model/settings/FieldSettingsPanels.tsx | 530 ++++++++++++++++++ .../model/settings/ModelSettingsView.tsx | 443 +++++++++++++++ .../views/model/settings/metadataClient.ts | 149 +++++ .../console/src/views/program/ProgramView.tsx | 58 ++ apps/console/src/views/program/RunRail.tsx | 47 +- .../program/programClient.gallery.test.ts | 119 ++++ .../src/views/program/programClient.ts | 207 +++++++ apps/console/src/views/registry.tsx | 32 +- packages/data-model-contracts/src/index.ts | 28 + .../src/twenty-metadata.test.ts | 58 ++ .../src/twenty-metadata.ts | 347 ++++++++++++ 41 files changed, 4210 insertions(+), 320 deletions(-) create mode 100644 .full-stack-feature/00-research-map.md create mode 100644 .full-stack-feature/EXECUTE-CHECKLIST-WAVE-A.md create mode 100644 .full-stack-feature/EXECUTE-CHECKLIST-WAVE-B.md create mode 100644 .full-stack-feature/EXECUTE-REPORT-REMAINING.md create mode 100644 .full-stack-feature/EXECUTE-REPORT-WAVE-A.md create mode 100644 .full-stack-feature/EXECUTE-REPORT-WAVE-B.md create mode 100644 .full-stack-feature/SPEC-REVIEW.md create mode 100644 .full-stack-feature/ard-ui-remaining-1.0--fallback-20260808a.md create mode 100644 .full-stack-feature/ard-ui-remaining-checklist.json create mode 100644 .full-stack-feature/research-d20-d23-seams.md create mode 100644 .full-stack-feature/research-d32-d36-seams.md create mode 100644 .full-stack-feature/state.json create mode 100644 apps/console/scripts/smoke-metadata-rest.mjs create mode 100644 apps/console/src/app/Data-model/settings/page.tsx create mode 100644 apps/console/src/app/api/rest/metadata/[[...path]]/route.ts create mode 100644 apps/console/src/app/commands/page.tsx create mode 100644 apps/console/src/app/goals/page.tsx create mode 100644 apps/console/src/lib/rail/block-surface-targets.test.ts create mode 100644 apps/console/src/lib/rail/block-surface-targets.ts create mode 100644 apps/console/src/lib/server/local-dev-metadata-store.test.ts create mode 100644 apps/console/src/lib/server/local-dev-metadata-store.ts create mode 100644 apps/console/src/lib/server/metadata-rest.ts create mode 100644 apps/console/src/views/model/settings/FieldSettingsPanels.tsx create mode 100644 apps/console/src/views/model/settings/ModelSettingsView.tsx create mode 100644 apps/console/src/views/model/settings/metadataClient.ts create mode 100644 apps/console/src/views/program/programClient.gallery.test.ts create mode 100644 packages/data-model-contracts/src/twenty-metadata.test.ts create mode 100644 packages/data-model-contracts/src/twenty-metadata.ts diff --git a/.full-stack-feature/00-research-map.md b/.full-stack-feature/00-research-map.md new file mode 100644 index 00000000..58c15a6a --- /dev/null +++ b/.full-stack-feature/00-research-map.md @@ -0,0 +1,173 @@ +# Research map: ARD-THEOREM-2026-08-04 (Parts 1, 4, 6) → UI + +**Mode:** research only (no implementation this round). +**Source ARD:** `/Users/travisgilbert/Downloads/ARD-THEOREM-2026-08-04 (1).md` +**Repos:** CommonPlace (product UI) + Theorem (substrate / REST / crates). +**Design palette (mandatory for new UI):** `twenty-ui` · `@base-ui/react` · jal-co (structure extraction) · keenthemes/reui · 21st.dev installs. Enforced by `gate:twenty`, `gate:sourcing`, and `.cursor/rules/no-hand-roll-components.mdc`. + +**Transport note (temporary):** Models console → `/api/observed-model` → MCP `graphql_query`/`graphql_mutate` today because deployed harness has no public `/graphql`. Side chat owns the HTTP GraphQL door. UI work must not invent a second invoke path; prefer REST `/rest/metadata/*` where the meta-model already exposes it, and keep console same-origin adapters. + +--- + +## Design palette inventory (what to build with) + +| Layer | Status | Paths / notes | +|---|---|---| +| `packages/twenty-ui` | **DONE** (MIT fork) | data-display, input, feedback, navigation, surfaces, json-visualizer, icon | +| `@base-ui/react` | **DONE** (dep) | Used by reui kanban + twenty-ui primitives | +| reui | **PARTIAL** | `ui/kanban.tsx`, `ui/kanban-board-5.tsx`, `ui/app-shell-8.tsx` (not mounted to product shell) | +| 21st / jal-co | **PARTIAL** | sidebar-component, dashboard-sidebar, filesystem-item, repo-card/commit-graph anatomy in ledger | +| Hand-roll risk | **ACTIVE** | ModelView field inspector uses native ``/``/`` + label/nullability/filterable/sortable (`ModelSettingsView.tsx` ~479–528). Adapter shows canonical string only. +- **Also:** `packages/model-canvas/.../SchemaEditor.tsx` still OWOX BigQuery enum. +- **Impact:** Layer 2 types that need payload shape cannot be authored correctly in settings. +- **Fix:** payload-driven subforms from `FieldType`; keep Twenty token as presentation only. + +### F5 — SR-004 facet mapping is auto-heuristic, not operator mapping +- **Spec:** facet requirements against local fields; unmapped required flagged; property/relation maps. +- **Code:** FacetsScreen auto-matches by name then type; Apply disabled if required unmapped; no manual remap UI; no relation map (`~642–692`). +- **Impact:** wrong auto-match can apply bad CONFORMS_TO; operators cannot correct maps. +- **Fix:** per-property field picker; show relation maps; keep unmapped required flags. + +### F6 — SR-019 / SR-020 P3 Goal Stack still parallel; gallery mostly stand-in +- **Goal Stack:** `registry.tsx` still renders `GoalStackView`; `/goals` live — P3 tension unresolved. +- **Gallery:** MCP `gallery` uses empty `CommandRegistry`; client falls to `LocalDevCommandGallery`. Fork path is correct (`gallery_fork`) but publications sparse. +- **Impact:** two computation-authoring DAGs; Commands gallery teaches fixtures, not live publications. +- **Fix:** demote Goal Stack to plan lens / fold affordances into program palette; populate durable command store so gallery is substrate-backed. + +### F7 — SR-014 / SR-015 / SR-016 / SR-017 missing execution-fabric UI +- Sweep, expansion ghosts, fingerprint inspector, cache/RAM debug: **unmapped** in console. +- Compound expand ≠ D21 sweep expansion. +- **Impact:** ChainForge/ComfyUI semantics in ARD have no operator surface. +- **Fix:** blocked on substrate primitives; do not fake UI ahead of Rust sweep/fingerprint. + +### F8 — SR-022–SR-025 Part 6 remainder missing +- marimo, Blender, Dagster, Perspective/Lemma DatasetVersion views: no console surface. +- **Impact:** expected for Wave C; recorded so execute does not claim D31 “done.” + +### F9 — SR-005 action-path validation UX thin +- No console wiring to `put_item_validated` refusal codes on create forms. +- **Fix:** surface typed validation errors on Create/Update records path when backend codes exist. + +### F10 — SR-006 IndexPolicy editor incomplete vs “four flags + provenance” +- Promote list + trigger.kind shown; demotion / full IndexPolicy flag editor / observation provenance thin. +- Filterable/sortable live on Fields screen (user-mark trigger) — good partial. + +--- + +## 4. Plan to Correct + +### Pass 1 — D24 gate Run (highest user impact, low risk) +1. `runProgram` → validate first; refuse Run on failure; keep Validate button. +2. Highlight `validationNodeIds` on canvas. +3. **Gate:** vitest validate+run client; manual `/program` invalid edge → blocked. + +### Pass 2 — Field editor tagged payloads + facet manual map +1. Payload subforms for enum / vector / relation from canonical FieldType. +2. Facet property → field picker (not only auto-match). +3. **Gate:** twenty-metadata + settings component tests; `gate:twenty`. + +### Pass 3 — Live metadata oracle +1. Smoke script against harness with REST; document env. +2. Optionally fail closed for mutating routes when stand-in would answer. +3. **Gate:** env-gated integration test (ignored without URL). + +### Pass 4 — P3 Goal Stack demote + gallery substrate fill +1. Demote `/goals` to plan lens or fold palette into program. +2. Wire durable `CommandRegistry` so gallery is not empty→LocalDev. +3. **Gate:** gallery test with substrate fixture; route inventory. + +### Pass 5 — WorkOS (D31) when ready +1. Swap next-auth provider; keep session shell. +2. **Gate:** auth smoke on staging. + +### Pass 6 — Do not fake D20–D23 / D33–D36 +1. Track as substrate/Wave C blockers only. +2. Ship UI when Rust/service primitives exist. + +--- + +## 5. Validation Results + +| Check | Outcome | +|---|---| +| `vitest` console: `programClient.gallery.test.ts`, `local-dev-metadata-store.test.ts`, `view-routing-retirement.test.ts` | **10 pass** | +| `vitest` `packages/data-model-contracts` `twenty-metadata.test.ts` | **3 pass** | +| `cargo check -p rustyred-thg-mcp` (prior session) | **pass** (warnings) | +| Browser smoke `/Data-model/settings`, `/commands`, `/program` Validate | **not run** | +| Live harness `/rest/metadata` | **not run** | +| `gate:twenty` this review | **not re-run** (Wave A claimed pass) | + +--- + +## Verdict + +Wave A/B delivered a **real but partial** settings register and a **manual** validate + gallery wire. Against ARD minimum for Parts 1/4/6 UI, most rows are **partial** or **missing**; only program/model canvas roles, records-as-built, and gate fencing clearly **meet**. Highest leverage corrections: **gate Run on validate (D24)**, **tagged FieldType + manual facet maps**, then **live metadata proof** — not more Wave C greenfield. diff --git a/.full-stack-feature/ard-ui-remaining-1.0--fallback-20260808a.md b/.full-stack-feature/ard-ui-remaining-1.0--fallback-20260808a.md new file mode 100644 index 00000000..0288f8ae --- /dev/null +++ b/.full-stack-feature/ard-ui-remaining-1.0--fallback-20260808a.md @@ -0,0 +1,35 @@ +# Plan fallback: ARD-UI-REMAINING-1.0 + +%% plan:fallback:ard-ui-remaining-1.0:20260808a digest:sha256:bd022e5c0ddf98401f01d187e5eb4441a721570ce1edf8811d42d18a97bae7d1 + +**Goal:** `goal:aa1c8c6380701610` + +## Destination +Close remaining ARD-THEOREM-2026-08-04 Parts 1/4/6 UI gaps (SPEC-REVIEW SR partial/missing) except WorkOS. CommonPlace console is the product surface; Theorem supplies MCP/REST seams. + +## Out of scope +- WorkOS AuthKit / next-auth provider swap (principal handles at end) +- Vendoring AGPL twenty-front +- Inventing a second agent invoke path +- Faking Blender addon, Dagster deployment, or marimo host when no service/repo exists + +## Fog +- Whether rustyred-thg programmable graph already exposes sweep / fingerprint / cache-pressure receipts that UI can bind without new Rust primitives +- Whether CommandRegistry has a durable store seam already or must be populated from monitor templates only for gallery completeness +- Whether put_item_validated refusal codes are already returned on the console records write path today + +## Tasks +- [ ] **d24-gate-run** (build.afk) — D24 gate Run on validate +- [ ] **fieldtype-forms** (build.afk) — Tagged FieldType field editor forms +- [ ] **facet-manual-map** (build.afk) — Manual facet property mapping +- [ ] **index-policy-editor** (build.afk) — IndexPolicy four flags + provenance +- [ ] **metadata-live-smoke** (build.afk) — Env-gated live metadata REST smoke +- [ ] **records-validated-ux** (build.afk) — Records put_item_validated refusal UX +- [ ] **p3-goal-stack-demote** (build.afk) — P3 demote Goal Stack +- [ ] **gallery-substrate-fill** (build.afk) — Commands gallery substrate fill +- [ ] **layer-chrome** (build.afk) — D1 Layer 0/1/2 chrome on models settings +- [ ] **fabric-seam-inventory** (decision.afk) — Inventory D20-D23 substrate seams +- [ ] **fabric-ui-or-close** (build.afk) — Ship or close D20-D23 UI +- [ ] **wave-c-seams** (decision.afk) — Inventory D32-D36 seams (excl WorkOS) +- [ ] **wave-c-ui-or-close** (build.afk) — Ship or close D32-D36 UI (excl WorkOS) +- [ ] **settings-twenty-inputs** (build.afk) — Replace native settings inputs with twenty-ui diff --git a/.full-stack-feature/ard-ui-remaining-checklist.json b/.full-stack-feature/ard-ui-remaining-checklist.json new file mode 100644 index 00000000..73b9bce3 --- /dev/null +++ b/.full-stack-feature/ard-ui-remaining-checklist.json @@ -0,0 +1,446 @@ +{ + "fallback_id": "fallback:ard-ui-remaining-1.0:20260808a", + "canonical_plan_id": null, + "goal_id": "goal:aa1c8c6380701610", + "source_payload": ".harness/session-tools/payloads/create-plan-ard-ui-remaining.json", + "binding": "portable_fallback_not_substrate_plan", + "rendered_at": "2026-08-08", + "title": "ARD-UI-REMAINING-1.0", + "destination": "Close remaining ARD-THEOREM-2026-08-04 Parts 1/4/6 UI gaps (SPEC-REVIEW SR partial/missing) except WorkOS. CommonPlace console is the product surface; Theorem supplies MCP/REST seams.", + "acceptance_criteria": [ + "Program Run validates before execute and refuses on failure with node ids surfaced (D24).", + "Settings field editor uses tagged FieldType payloads; facets support manual property maps; IndexPolicy shows four flags plus promotion provenance.", + "Live or env-gated harness /rest/metadata smoke exists; LocalDevMetadataStore is never counted as production evidence.", + "Goal Stack is demoted or folded so Program canvas is the sole computation authoring DAG (P3).", + "Commands gallery lists substrate publications/templates without silent fixture-only UX.", + "Records Create/Update surfaces put_item_validated refusal codes (D5).", + "D20-D23 UI either ships against real substrate seams or is closed with a named substrate blocker receipt.", + "D32-D36 either ship thin clients against existing seams or are closed with named service blockers; WorkOS excluded by principal." + ], + "fog": [ + "Whether rustyred-thg programmable graph already exposes sweep / fingerprint / cache-pressure receipts that UI can bind without new Rust primitives", + "Whether CommandRegistry has a durable store seam already or must be populated from monitor templates only for gallery completeness", + "Whether put_item_validated refusal codes are already returned on the console records write path today" + ], + "out_of_scope": [ + "WorkOS AuthKit / next-auth provider swap (principal handles at end)", + "Vendoring AGPL twenty-front", + "Inventing a second agent invoke path", + "Faking Blender addon, Dagster deployment, or marimo host when no service/repo exists" + ], + "notes": [ + "Portable fallback while substrate plan create may fail store limits; goal goal:aa1c8c6380701610 exists on harness.", + "WorkOS explicitly out of scope per principal.", + "2026-08-08 execute: all fallback rows marked done or closed-with-blocker; WorkOS remains out of scope. Substrate plan create still blocked by private_store message-size limit." + ], + "items": [ + { + "id": "d24-gate-run", + "title": "D24 gate Run on validate", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "runProgram validates first; refuse Run; surface node ids.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [], + "acceptance": [ + "Run path calls validate and does not call run when validation refuses.", + "Refused node ids appear in RunRail / canvas highlight state.", + "Focused vitest covers the gated-run client behavior." + ], + "proof_command": "npm --prefix apps/console exec -- vitest run src/views/program/programClient.gallery.test.ts", + "routes": [ + "apps/console/src/views/program/ProgramView.tsx", + "apps/console/src/views/program/RunRail.tsx", + "apps/console/src/views/program/programClient.ts" + ], + "evidence": "ProgramView.runProgram validates first; shouldBlockRunAfterValidation + vitest", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-018 / ARD D24" + }, + { + "id": "fieldtype-forms", + "title": "Tagged FieldType field editor forms", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "enum/vector/relation subforms on settings Fields screen.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [], + "acceptance": [ + "Creating/editing enum, vector, and relation fields requires the tagged payload fields in the settings UI.", + "Twenty presentation tokens still round-trip through the adapter.", + "Unit tests cover adapter + at least one payload form serialization." + ], + "proof_command": "npm --prefix packages/data-model-contracts exec -- vitest run src/twenty-metadata.test.ts && npm --prefix apps/console exec -- vitest run src/lib/server/local-dev-metadata-store.test.ts", + "routes": [ + "apps/console/src/views/model/settings/ModelSettingsView.tsx", + "packages/data-model-contracts/src/twenty-metadata.ts" + ], + "evidence": "FieldSettingsPanels enum/vector/relation + twenty-metadata fieldTypeFromEditor tests", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-003 SR-007 / ARD D3 D31" + }, + { + "id": "facet-manual-map", + "title": "Manual facet property mapping", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Per-property field picker + relation map display.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [ + "fieldtype-forms" + ], + "acceptance": [ + "Operator can change the local field mapped to a facet property before apply.", + "Required unmapped properties disable apply and are visually flagged.", + "Relation map entries are visible when the facet declares them." + ], + "proof_command": "npm --prefix apps/console exec -- vitest run src/lib/server/local-dev-metadata-store.test.ts", + "routes": [ + "apps/console/src/views/model/settings/ModelSettingsView.tsx", + "apps/console/src/views/model/settings/metadataClient.ts" + ], + "evidence": "FacetsScreen property pickers + relation map display", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-004 / ARD D4" + }, + { + "id": "index-policy-editor", + "title": "IndexPolicy four flags + provenance", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Four IndexPolicy flags, promote/demote, trigger provenance.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [ + "fieldtype-forms" + ], + "acceptance": [ + "IndexPolicy four flags are editable or clearly displayed per field/index.", + "Promotion candidates still promote via REST.", + "Demotion of user-mark trigger is available or explicitly refused with reason when API lacks demote." + ], + "proof_command": "npm --prefix apps/console exec -- vitest run src/lib/server/local-dev-metadata-store.test.ts", + "routes": [ + "apps/console/src/views/model/settings/ModelSettingsView.tsx", + "apps/console/src/lib/server/local-dev-metadata-store.ts" + ], + "evidence": "IndexPolicy four toggles + demote user_marked indexes", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-006 / ARD D6" + }, + { + "id": "metadata-live-smoke", + "title": "Env-gated live metadata REST smoke", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Named live/env-gated smoke; stand-in never equals done.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": true, + "dependencies": [], + "acceptance": [ + "An env-gated test or script exists and is documented.", + "Without the env URL, the test skips/ignores rather than claiming pass on LocalDev.", + "LocalDevMetadataStore remains explicitly named as stand-in." + ], + "proof_command": "test -f apps/console/scripts/smoke-metadata-rest.mjs || test -f apps/console/src/lib/server/metadata-rest.live.test.ts", + "routes": [ + "apps/console/src/lib/server/metadata-rest.ts", + "apps/console/scripts/smoke-metadata-rest.mjs" + ], + "evidence": "scripts/smoke-metadata-rest.mjs env-gated (skipped without URL)", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-002 / ARD D2" + }, + { + "id": "records-validated-ux", + "title": "Records put_item_validated refusal UX", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Wire typed validation errors into records write UI.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [], + "acceptance": [ + "A refused validated write shows a user-visible error naming the code or field.", + "Happy-path create/update still works.", + "Focused test covers refusal rendering or client parsing." + ], + "proof_command": "npm --prefix apps/console exec -- vitest run src/views/records", + "routes": [ + "apps/console/src/views/records" + ], + "evidence": "RecordTableView surfaces refusal code + put_item_validated note", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-005 / ARD D5" + }, + { + "id": "p3-goal-stack-demote", + "title": "P3 demote Goal Stack", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Demote /goals from authoring DAG to plan lens or fold into program palette.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [ + "d24-gate-run" + ], + "acceptance": [ + "Registry/docs no longer present Goal Stack as a second programmable graph authoring surface.", + "Program canvas remains the computation authoring DAG.", + "Routing tests still pass for remaining surfaces." + ], + "proof_command": "npm --prefix apps/console exec -- vitest run src/lib/view-routing-retirement.test.ts src/lib/rail/block-surface-targets.test.ts", + "routes": [ + "apps/console/src/views/registry.tsx", + "apps/console/src/views/goal-stack", + "apps/console/src/lib/surface-routes.ts" + ], + "evidence": "registry Goal Stack renamed plan lens; kindGlyph automation", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-019 / ARD P3" + }, + { + "id": "gallery-substrate-fill", + "title": "Commands gallery substrate fill", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Populate gallery beyond empty CommandRegistry + LocalDev.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [ + "d24-gate-run" + ], + "acceptance": [ + "With harness reachable, gallery returns at least monitor templates from substrate.", + "LocalDev fallback remains labeled when used.", + "gallery_fork still works for a template id/name." + ], + "proof_command": "cd rustyredcore_THG && cargo check -p rustyred-thg-mcp --offline", + "routes": [ + "rustyredcore_THG/crates/rustyred-thg-mcp/src/programmable_graph.rs", + "apps/console/src/views/CommandsGalleryView.tsx", + "apps/console/src/views/program/programClient.ts" + ], + "evidence": "list_gallery always includes monitor templates; LocalDev named fallback", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-020" + }, + { + "id": "layer-chrome", + "title": "D1 Layer 0/1/2 chrome on models settings", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Lightweight L0/L1/L2 labeling without new canvases.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [ + "fieldtype-forms" + ], + "acceptance": [ + "Settings IA labels Layer 2 user types distinctly from kernel/system.", + "Layer 0/1 are named as non-editable or read-only where appropriate." + ], + "proof_command": "npm --prefix apps/console exec -- vitest run src/lib/view-routing-retirement.test.ts", + "routes": [ + "apps/console/src/views/model/settings/ModelSettingsView.tsx", + "apps/console/src/views/model/ModelView.tsx" + ], + "evidence": "L0/L1/L2 tags on settings header + object/field screens", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-001 / ARD D1" + }, + { + "id": "fabric-seam-inventory", + "title": "Inventory D20-D23 substrate seams", + "status": "done", + "kind": "decision.afk", + "node_type": "decision", + "controller": "agent", + "gist": "Research whether programmable_graph exposes sweep/fingerprint/cache receipts.", + "oracle_class": "contract", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [], + "acceptance": [ + "A research note lists each of D20-D23 with present/absent seam and file refs.", + "For each absent seam, a blocker receipt reason is written.", + "For each present seam, a follow-on build alias is named." + ], + "proof_command": "test -f CommonPlace/.full-stack-feature/research-d20-d23-seams.md || test -f .full-stack-feature/research-d20-d23-seams.md", + "routes": [ + "rustyredcore_THG/crates/rustyred-thg-programmable-graph", + "rustyredcore_THG/crates/rustyred-thg-mcp/src/programmable_graph.rs" + ], + "evidence": "research-d20-d23-seams.md", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-014..017 / ARD D20-D23" + }, + { + "id": "fabric-ui-or-close", + "title": "Ship or close D20-D23 UI", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Bind UI to found seams or close with blockers.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [ + "fabric-seam-inventory", + "d24-gate-run" + ], + "acceptance": [ + "Every D20-D23 item is either UI-wired with a proof or closed with a named blocker.", + "No synthetic sweep engine is invented in the console alone." + ], + "proof_command": "test -f CommonPlace/.full-stack-feature/EXECUTE-REPORT-REMAINING.md || test -f .full-stack-feature/EXECUTE-REPORT-REMAINING.md", + "routes": [ + "apps/console/src/views/program" + ], + "evidence": "closed with named blockers in research-d20-d23-seams.md — no fake UI", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-014..017" + }, + { + "id": "wave-c-seams", + "title": "Inventory D32-D36 seams (excl WorkOS)", + "status": "done", + "kind": "decision.afk", + "node_type": "decision", + "controller": "agent", + "gist": "rerun/pgwire/blender/dagster/datalab presence check.", + "oracle_class": "contract", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [], + "acceptance": [ + "A note covers D32-D36 with present/absent and next action.", + "WorkOS is explicitly excluded.", + "No fake Dagster/Blender/marimo hosts are proposed as done." + ], + "proof_command": "test -f CommonPlace/.full-stack-feature/research-d32-d36-seams.md || test -f .full-stack-feature/research-d32-d36-seams.md", + "routes": [ + "apps/console/src/views/prototype", + "rustyredcore_THG/crates/rustyred-thg-pg-server" + ], + "evidence": "research-d32-d36-seams.md (WorkOS excluded)", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-021..025" + }, + { + "id": "wave-c-ui-or-close", + "title": "Ship or close D32-D36 UI (excl WorkOS)", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Promote rerun where possible; close greenfield with blockers.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [ + "wave-c-seams" + ], + "acceptance": [ + "D32-D36 each have UI evidence or a named service blocker.", + "WorkOS is not claimed done." + ], + "proof_command": "test -f CommonPlace/.full-stack-feature/EXECUTE-REPORT-REMAINING.md || test -f .full-stack-feature/EXECUTE-REPORT-REMAINING.md", + "routes": [ + "apps/console/src/views/prototype", + "apps/console/src/views/registry.tsx" + ], + "evidence": "D32 partial kept; D33–D36 closed with service blockers; WorkOS excluded", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-021..025" + }, + { + "id": "settings-twenty-inputs", + "title": "Replace native settings inputs with twenty-ui", + "status": "done", + "kind": "build.afk", + "node_type": "build", + "controller": "agent", + "gist": "Swap native input/select for twenty-ui where available.", + "oracle_class": "unit", + "implementation_mode": "local_process", + "evidence_class": "unit", + "substitution_allowed": false, + "live_oracle_required": false, + "dependencies": [ + "facet-manual-map", + "index-policy-editor" + ], + "acceptance": [ + "Settings field controls use twenty-ui components for text/select where available.", + "npm run gate:twenty passes in apps/console." + ], + "proof_command": "npm --prefix apps/console run gate:twenty", + "routes": [ + "apps/console/src/views/model/settings/ModelSettingsView.tsx", + "apps/console/AGENTS.md" + ], + "evidence": "SearchInput + twenty-ui Toggle/Button/Tag; gate:twenty pass", + "deferral_reason": null, + "provenance": "SPEC-REVIEW SR-007 / palette rule" + } + ], + "digest": "sha256:bd022e5c0ddf98401f01d187e5eb4441a721570ce1edf8811d42d18a97bae7d1" +} diff --git a/.full-stack-feature/research-d20-d23-seams.md b/.full-stack-feature/research-d20-d23-seams.md new file mode 100644 index 00000000..8bc8923b --- /dev/null +++ b/.full-stack-feature/research-d20-d23-seams.md @@ -0,0 +1,14 @@ +# D20–D23 substrate seam inventory + +**Date:** 2026-08-08 +**Plan task:** `fabric-seam-inventory` +**Goal:** `goal:aa1c8c6380701610` + +| ARD | Seam searched | Present? | File refs | Disposition | +|---|---|---|---|---| +| D20 Sweep | `sweep` in `rustyred-thg-programmable-graph` | **absent** | no matches | **blocker:** no ChainForge sweep primitive in programmable graph | +| D21 Expansion ghosts | Compound `expand_node` only | **partial** | MCP `expand_node`; ProgramView Compound expand | Compound ≠ sweep expansion; **blocker** for sweep ghosts | +| D22 Fingerprint | affordance fingerprint fn | **absent** in program UI/MCP gallery | unrelated web/MCP fingerprints only | **blocker:** no affordance fingerprint inspector contract on program catalog | +| D23 Cache/RAM | RunRail / StatusPanel cache keys | **absent** as operator surface | server graph cache exists elsewhere | **blocker:** no RAM-pressure / dual-key debug UI API exposed to console | + +**Rule:** no fake ChainForge UI in console alone. diff --git a/.full-stack-feature/research-d32-d36-seams.md b/.full-stack-feature/research-d32-d36-seams.md new file mode 100644 index 00000000..dd72592b --- /dev/null +++ b/.full-stack-feature/research-d32-d36-seams.md @@ -0,0 +1,16 @@ +# D32–D36 seam inventory (WorkOS excluded) + +**Date:** 2026-08-08 +**Plan task:** `wave-c-seams` +**Goal:** `goal:aa1c8c6380701610` + +| ARD | Present? | Notes | Disposition | +|---|---|---|---| +| D32 rerun | **partial** | `PrototypeStageView` + `@rerun-io/web-viewer`; program handoff | Keep prototype path; fleet-default / Dagster deep-link **blocked** on D35 | +| D33 marimo/pgwire | **absent UI** | `rustyred-thg-pg-server` exists in Theorem; no console marimo host | **blocker:** no marimo thin client until pgwire auth/session productized | +| D34 blender | **absent** | no theorem-blender repo/addon in workspace | **blocker:** greenfield GPL peer | +| D35 Dagster | **absent** | no Dagster in either repo | **blocker:** service not present | +| D36 DatasetVersion / Perspective | **absent UI** | DatasetVersion concepts in ARD; no Perspective/Lemma pin surface | **blocker:** needs Flight/DatasetVersion product door | +| WorkOS | **out of scope** | principal handles separately | closed for this plan | + +**Rule:** no fake Dagster/Blender/marimo hosts claimed as done. diff --git a/.full-stack-feature/state.json b/.full-stack-feature/state.json new file mode 100644 index 00000000..419f8ee8 --- /dev/null +++ b/.full-stack-feature/state.json @@ -0,0 +1,22 @@ +{ + "feature": "ARD-THEOREM-2026-08-04 Parts 1/4/6 UI", + "status": "awaiting_workos", + "phase": "execute", + "stack": "commonplace-console + theorem-substrate", + "goal_id": "goal:aa1c8c6380701610", + "fallback_plan_id": "fallback:ard-ui-remaining-1.0:20260808a", + "current_step": "workos-deferred-to-principal", + "completed_steps": [ + "00-research-map", + "wave-a-settings-register", + "wave-a-gate-twenty", + "wave-b1-commands-gallery", + "wave-b2-program-validate", + "spec-review", + "plan-ard-ui-remaining", + "execute-ard-ui-remaining" + ], + "out_of_scope": ["WorkOS AuthKit / next-auth provider swap"], + "notes": "Plan+execute remaining ARD UI except WorkOS. Substrate plan mint blocked by store size; fallback checklist used. D20-D23 and D33-D36 closed with named blockers.", + "last_updated": "2026-08-08T19:52:00.000Z" +} diff --git a/apps/console/AGENTS.md b/apps/console/AGENTS.md index bb4d476b..8c4ce31c 100644 --- a/apps/console/AGENTS.md +++ b/apps/console/AGENTS.md @@ -104,8 +104,9 @@ asks for inspiration, reference, mood, or direction. | Approval dialog | `cmdk` Dialog | focus-managed, keyboard-selectable one-shot Plan approval and rejection | | Tabular lenses | tablecn structure on `@tanstack/react-table` plus blocks.so `table-01`..`table-05` anatomy | record.table sorting, filtering, column model | | Schema-driven record cells and chips | FieldType map in `views/records/cells.tsx` plus blocks.so badge / jalco chip anatomy | polymorphic cells, RecordChip, select hues | -| Model canvas object cards | `@xyflow/react` plus jalco `repo-card` header anatomy (structure extract) | ObjectTypeCard / GhostCard on the diagram lens | -| Model relation edges | `@xyflow/react` BaseEdge / smooth-step | field-anchored RelationEdge | +| Model canvas object cards | `@commonplace/model-canvas` OwoxStudio (OWOX/models Apache-2.0 fork) plus `@xyflow/react` MartNode / jalco `repo-card` anatomy | Models Place ERD cards, Dock, Inspector sheet, right rail; registry is semantic truth | +| Data model settings register | `twenty-ui` SegmentedControl, Toggle, Button, Tag, Pill, Card plus same-origin `/api/rest/metadata/*` (harness `/rest/metadata` or `LocalDevMetadataStore`) | D31 four screens: object types, FieldType/Twenty field editor, IndexPolicy promotion, facet conformance mapping at `/Data-model/settings` | +| Model relation edges | `@commonplace/model-canvas` RelEdge over `@xyflow/react` BaseEdge / smooth-step | field-anchored relation edges on the Models surface | | Program canvas graph, palette, and layout | `@xyflow/react`, `cmdk`, `@dagrejs/dagre`, generated `@commonplace/program-contracts` | typed program nodes/edges, searchable operation insertion, deterministic layout, and Theorem-owned wire contracts | | Record view bar and calculate footer | `cmdk` plus blocks.so command-menu / table footer patterns | ViewBar grammar and server aggregate footer | | Row virtualization | `@tanstack/react-virtual` | large record sets | diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index 000eb5f4..29f6578a 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -103,6 +103,7 @@ gap: add the row, with a named source, before writing code. | Tabular lenses | tablecn structure on `@tanstack/react-table` plus blocks.so `table-01`..`table-05` anatomy | record.table sorting, filtering, column model | | Schema-driven record cells and chips | FieldType map in `views/records/cells.tsx` plus blocks.so badge / jalco chip anatomy | polymorphic cells, RecordChip, select hues | | Model canvas object cards | `@xyflow/react` plus jalco `repo-card` header anatomy (structure extract) | ObjectTypeCard / GhostCard on the diagram lens | +| Data model settings register | `twenty-ui` SegmentedControl, Toggle, Button, Tag, Pill, Card plus `/api/rest/metadata/*` | D31 four screens at `/Data-model/settings` (types, fields, indexes, facet conformance) | | Model relation edges | `@xyflow/react` BaseEdge / smooth-step | field-anchored RelationEdge | | Record view bar and calculate footer | `cmdk` plus blocks.so command-menu / table footer patterns | ViewBar grammar and server aggregate footer | | Row virtualization | `@tanstack/react-virtual` | large record sets | diff --git a/apps/console/scripts/smoke-metadata-rest.mjs b/apps/console/scripts/smoke-metadata-rest.mjs new file mode 100644 index 00000000..273a6217 --- /dev/null +++ b/apps/console/scripts/smoke-metadata-rest.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Env-gated live smoke for harness `/rest/metadata/*`. + * + * Required: CONSOLE_METADATA_URL or CONSOLE_HARNESS_URL (mcp suffix stripped). + * Without either, exits 0 with skipped message — LocalDev is never treated as pass. + * + * Usage: + * CONSOLE_METADATA_URL=http://127.0.0.1:8080 node apps/console/scripts/smoke-metadata-rest.mjs + */ +const baseRaw = + process.env.CONSOLE_METADATA_URL?.trim() || + process.env.CONSOLE_HARNESS_URL?.trim()?.replace(/\/(?:mcp)?\/?$/, '') || + ''; + +if (!baseRaw) { + console.log( + 'smoke-metadata-rest: SKIPPED — set CONSOLE_METADATA_URL or CONSOLE_HARNESS_URL for live oracle', + ); + process.exit(0); +} + +const base = baseRaw.replace(/\/$/, ''); +const url = `${base}/rest/metadata/objects`; + +const headers = { + Accept: 'application/json', + ...(process.env.CONSOLE_HARNESS_TOKEN + ? { Authorization: `Bearer ${process.env.CONSOLE_HARNESS_TOKEN}` } + : {}), +}; + +const response = await fetch(url, { headers, cache: 'no-store' }); +const text = await response.text(); +if (!response.ok) { + console.error(`smoke-metadata-rest: FAIL ${response.status} ${url}\n${text.slice(0, 500)}`); + process.exit(1); +} +let parsed; +try { + parsed = JSON.parse(text); +} catch { + console.error('smoke-metadata-rest: FAIL non-JSON body'); + process.exit(1); +} +const edges = parsed?.objects?.edges; +if (!Array.isArray(edges)) { + console.error('smoke-metadata-rest: FAIL missing objects.edges'); + process.exit(1); +} +console.log(`smoke-metadata-rest: OK ${edges.length} object edge(s) from ${url}`); diff --git a/apps/console/src/app/Data-model/settings/page.tsx b/apps/console/src/app/Data-model/settings/page.tsx new file mode 100644 index 00000000..f7c17a3c --- /dev/null +++ b/apps/console/src/app/Data-model/settings/page.tsx @@ -0,0 +1,2 @@ +// SOURCING: none. App Router segment for Data model settings (D31 register). +export { default } from '@/lib/console-surface-page'; diff --git a/apps/console/src/app/api/rest/metadata/[[...path]]/route.ts b/apps/console/src/app/api/rest/metadata/[[...path]]/route.ts new file mode 100644 index 00000000..a74c27f5 --- /dev/null +++ b/apps/console/src/app/api/rest/metadata/[[...path]]/route.ts @@ -0,0 +1,17 @@ +// SOURCING: none. Same-origin proxy for harness `/rest/metadata/*`. + +import { forwardMetadataRest } from '@/lib/server/metadata-rest'; + +export const dynamic = 'force-dynamic'; + +type RouteContext = { params: Promise<{ path?: string[] }> }; + +async function handle(request: Request, context: RouteContext): Promise { + const { path = [] } = await context.params; + return forwardMetadataRest(request.method, path, request); +} + +export const GET = handle; +export const POST = handle; +export const PATCH = handle; +export const DELETE = handle; diff --git a/apps/console/src/app/commands/page.tsx b/apps/console/src/app/commands/page.tsx new file mode 100644 index 00000000..d31cf825 --- /dev/null +++ b/apps/console/src/app/commands/page.tsx @@ -0,0 +1,2 @@ +// SOURCING: none. App Router segment for the Commands gallery surface. +export { default } from '@/lib/console-surface-page'; diff --git a/apps/console/src/app/goals/page.tsx b/apps/console/src/app/goals/page.tsx new file mode 100644 index 00000000..b0ea93ec --- /dev/null +++ b/apps/console/src/app/goals/page.tsx @@ -0,0 +1,2 @@ +// SOURCING: none. App Router segment for the Goal Stack / Plan surface. +export { default } from '@/lib/console-surface-page'; diff --git a/apps/console/src/lib/rail/block-surface-targets.test.ts b/apps/console/src/lib/rail/block-surface-targets.test.ts new file mode 100644 index 00000000..89edeb3d --- /dev/null +++ b/apps/console/src/lib/rail/block-surface-targets.test.ts @@ -0,0 +1,36 @@ +// SOURCING: none. Palette → surface route resolution. + +import { describe, expect, it } from 'vitest'; +import { resolveBlockSurfaceTarget } from './block-surface-targets'; + +describe('resolveBlockSurfaceTarget', () => { + it('maps Records palette rows to the Records surface route', () => { + expect( + resolveBlockSurfaceTarget({ + id: 'records', + kind: 'records', + descriptorId: 'record.table', + }), + ).toEqual({ path: '/records', surfaceId: 'console-records' }); + }); + + it('maps Models palette rows to /Data-model', () => { + expect( + resolveBlockSurfaceTarget({ + id: 'model', + kind: 'model', + descriptorId: 'model.studio', + }), + ).toEqual({ path: '/Data-model', surfaceId: 'console-models' }); + }); + + it('returns null for embed-only descriptors without a surface', () => { + expect( + resolveBlockSurfaceTarget({ + id: 'unknown-widget', + kind: 'unknown-widget', + descriptorId: 'custom.orphan', + }), + ).toBeNull(); + }); +}); diff --git a/apps/console/src/lib/rail/block-surface-targets.ts b/apps/console/src/lib/rail/block-surface-targets.ts new file mode 100644 index 00000000..c6627dea --- /dev/null +++ b/apps/console/src/lib/rail/block-surface-targets.ts @@ -0,0 +1,74 @@ +// SOURCING: none. Maps Blocks palette rows to page-owned surfaces so sidebar +// clicks navigate instead of dumping view-instances into the center island. + +import { SURFACE_ROUTES } from '@/lib/surface-routes'; +import type { BlockPaletteItem } from '@/lib/rail/rail-model'; + +export type BlockSurfaceTarget = { + readonly path: string; + readonly surfaceId: string; +}; + +/** + * Descriptor id and palette kind → durable surface. Prefer explicit rows over + * guessing from kind glyphs so Blocks never reopen as editor tabs. + */ +const BY_DESCRIPTOR_ID: Readonly> = { + 'record.table': { path: '/records', surfaceId: 'console-records' }, + 'cards.grid': { path: '/cards', surfaceId: 'console-cards' }, + 'markdown.doc': { path: '/documents', surfaceId: 'console-docs' }, + 'doc.list': { path: '/documents', surfaceId: 'console-docs' }, + 'files.tree': { path: '/files', surfaceId: 'console-files' }, + 'index.rail': { path: '/filing', surfaceId: 'console-index' }, + 'index.stream': { path: '/filing', surfaceId: 'console-index' }, + 'index.rules': { path: '/filing', surfaceId: 'console-index' }, + 'automation.history': { path: '/automation', surfaceId: 'console-automation' }, + 'goal.stack': { path: '/goals', surfaceId: 'console-goals' }, + 'program.canvas': { path: '/program', surfaceId: 'console-program' }, + canvas: { path: '/canvas', surfaceId: 'console-canvas' }, + 'search.stack': { path: '/search', surfaceId: 'console-search' }, + kanban: { path: '/kanban', surfaceId: 'console-kanban' }, + 'commands.gallery': { path: '/commands', surfaceId: 'console-commands' }, + 'model.studio': { path: '/Data-model', surfaceId: 'console-models' }, + 'model.settings': { path: '/Data-model/settings', surfaceId: 'console-model-settings' }, + 'workspace.substrate': { path: '/workspace', surfaceId: 'console-workspace' }, +}; + +const BY_PALETTE_KIND: Readonly> = { + records: { path: '/records', surfaceId: 'console-records' }, + cards: { path: '/cards', surfaceId: 'console-cards' }, + doc: { path: '/documents', surfaceId: 'console-docs' }, + documents: { path: '/documents', surfaceId: 'console-docs' }, + files: { path: '/files', surfaceId: 'console-files' }, + index: { path: '/filing', surfaceId: 'console-index' }, + automation: { path: '/automation', surfaceId: 'console-automation' }, + plan: { path: '/goals', surfaceId: 'console-goals' }, + canvas: { path: '/canvas', surfaceId: 'console-canvas' }, + program: { path: '/program', surfaceId: 'console-program' }, + search: { path: '/search', surfaceId: 'console-search' }, + kanban: { path: '/kanban', surfaceId: 'console-kanban' }, + commands: { path: '/commands', surfaceId: 'console-commands' }, + model: { path: '/Data-model', surfaceId: 'console-models' }, + workspace: { path: '/workspace', surfaceId: 'console-workspace' }, +}; + +/** Resolve a Blocks palette item to a page-owned surface, or null if embed-only. */ +export function resolveBlockSurfaceTarget( + item: Pick, +): BlockSurfaceTarget | null { + const byDescriptor = BY_DESCRIPTOR_ID[item.descriptorId]; + if (byDescriptor) return byDescriptor; + const byKind = BY_PALETTE_KIND[item.kind] ?? BY_PALETTE_KIND[item.id]; + if (byKind) return byKind; + // Last resort: any SURFACE_ROUTES entry whose path basename matches the id. + const route = SURFACE_ROUTES.find( + (entry) => + entry.path === `/${item.id}` || + entry.path === `/${item.kind}` || + entry.surfaceId === `console-${item.id}` || + entry.surfaceId === `console-${item.kind}`, + ); + return route + ? { path: route.path, surfaceId: route.surfaceId } + : null; +} diff --git a/apps/console/src/lib/server/local-dev-metadata-store.test.ts b/apps/console/src/lib/server/local-dev-metadata-store.test.ts new file mode 100644 index 00000000..dbed27d5 --- /dev/null +++ b/apps/console/src/lib/server/local-dev-metadata-store.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment node +// SOURCING: none. LocalDevMetadataStore stand-in coverage. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); + +import { + handleLocalDevMetadata, + resetLocalDevMetadataStore, +} from './local-dev-metadata-store'; + +describe('LocalDevMetadataStore', () => { + beforeEach(() => { + resetLocalDevMetadataStore(); + }); + + it('lists seeded objects and promotes an index', async () => { + const listed = await handleLocalDevMetadata('GET', ['objects'], null); + expect(listed.status).toBe(200); + const body = (await listed.json()) as { + objects: { edges: Array<{ node: { nameSingular: string } }> }; + source: string; + }; + expect(body.source).toBe('LocalDevMetadataStore'); + expect(body.objects.edges.map((edge) => edge.node.nameSingular)).toEqual( + expect.arrayContaining(['customer', 'order']), + ); + + const promoted = await handleLocalDevMetadata( + 'POST', + ['indexes', 'customer', 'email'], + JSON.stringify({ kind: 'user_marked' }), + ); + expect(promoted.status).toBe(201); + + const candidates = await handleLocalDevMetadata('GET', ['promotion-candidates'], null); + const candidateBody = (await candidates.json()) as { + candidates: Array<{ fieldName: string; objectNameSingular: string }>; + }; + expect( + candidateBody.candidates.some( + (row) => row.objectNameSingular === 'customer' && row.fieldName === 'email', + ), + ).toBe(false); + }); + + it('maps and revokes facet conformance', async () => { + const created = await handleLocalDevMetadata( + 'POST', + ['objects', 'customer', 'conformances'], + JSON.stringify({ + facet: 'publishable', + propertyMap: { title: 'name' }, + }), + ); + expect(created.status).toBe(201); + + const detail = await handleLocalDevMetadata('GET', ['objects', 'customer'], null); + const object = (await detail.json()) as { + conformances: Array<{ facet: string }>; + }; + expect(object.conformances[0]?.facet).toBe('publishable'); + + const revoked = await handleLocalDevMetadata( + 'DELETE', + ['objects', 'customer', 'conformances', 'publishable'], + null, + ); + expect(revoked.status).toBe(204); + }); +}); diff --git a/apps/console/src/lib/server/local-dev-metadata-store.ts b/apps/console/src/lib/server/local-dev-metadata-store.ts new file mode 100644 index 00000000..8e61c72b --- /dev/null +++ b/apps/console/src/lib/server/local-dev-metadata-store.ts @@ -0,0 +1,489 @@ +// SOURCING: none. Named stand-in (`LocalDevMetadataStore`) for Twenty-shaped +// `/rest/metadata/*` when the harness node is unreachable or unconfigured. +// Oracle class: local process memory — not production evidence. + +import 'server-only'; + +import type { + FacetDefWire, + FieldMetadataWire, + IndexMetadataWire, + ObjectConformanceWire, + ObjectMetadataWire, + PromotionCandidateWire, + TwentyFieldTypeToken, +} from '@commonplace/data-model-contracts'; + +type StoreState = { + objects: Map; + facets: Map; +}; + +const globalKey = '__commonplace_local_dev_metadata_store__'; + +function state(): StoreState { + const g = globalThis as typeof globalThis & { + [globalKey]?: StoreState; + }; + if (!g[globalKey]) { + g[globalKey] = seed(); + } + return g[globalKey]!; +} + +function now(): number { + return Date.now(); +} + +function field( + object: string, + name: string, + label: string, + type: TwentyFieldTypeToken, + opts: Partial = {}, +): FieldMetadataWire { + const ts = now(); + return { + id: `field:${object}:${name}`, + universalIdentifier: `local.${object}.${name}`, + type, + name, + label, + isActive: true, + isSystem: opts.isSystem ?? false, + isUiEditable: opts.isUiEditable ?? true, + isNullable: opts.isNullable ?? true, + isUnique: opts.isUnique ?? false, + options: [], + settings: { + isFilterable: opts.settings?.isFilterable ?? false, + isSortable: opts.settings?.isSortable ?? false, + }, + createdAtMs: ts, + updatedAtMs: ts, + ...opts, + }; +} + +function seedObject( + singular: string, + plural: string, + label: string, + fields: FieldMetadataWire[], +): ObjectMetadataWire { + const ts = now(); + return { + id: `object:${singular}`, + universalIdentifier: `local.${singular}`, + nameSingular: singular, + namePlural: plural, + labelSingular: label, + labelPlural: `${label}s`, + isActive: true, + isSystem: false, + isUiEditable: true, + isUiCreatable: true, + isSearchable: true, + fields, + fieldsList: fields, + indexMetadataList: [], + conformances: [], + createdAtMs: ts, + updatedAtMs: ts, + }; +} + +function seed(): StoreState { + const customer = seedObject('customer', 'customers', 'Customer', [ + field('customer', 'id', 'Id', 'UUID', { isSystem: true, isNullable: false, isUnique: true }), + field('customer', 'email', 'Email', 'EMAILS', { + settings: { isFilterable: true, isSortable: true }, + }), + field('customer', 'name', 'Name', 'TEXT', { + settings: { isFilterable: true, isSortable: true }, + }), + ]); + const order = seedObject('order', 'orders', 'Order', [ + field('order', 'id', 'Id', 'UUID', { isSystem: true, isNullable: false, isUnique: true }), + field('order', 'customer_id', 'Customer', 'UUID', { + settings: { isFilterable: true, isSortable: false }, + }), + field('order', 'total', 'Total', 'CURRENCY', { + settings: { isFilterable: true, isSortable: true }, + }), + field('order', 'placed_at', 'Placed at', 'DATE_TIME', { + settings: { isFilterable: true, isSortable: true }, + }), + ]); + const publishable: FacetDefWire = { + name: 'publishable', + label: 'Publishable', + description: 'Local-dev stand-in facet requiring a title-like property.', + extends: [], + properties: [{ name: 'title', type: 'TEXT', required: true }], + relations: [], + actions: [], + isSystem: false, + }; + const temporal: FacetDefWire = { + name: 'temporal', + label: 'Temporal', + description: 'Local-dev stand-in facet for timestamp fields.', + extends: [], + properties: [{ name: 'occurred_at', type: 'DATE_TIME', required: true }], + relations: [], + actions: [], + isSystem: true, + }; + return { + objects: new Map([ + [customer.nameSingular, customer], + [order.nameSingular, order], + ]), + facets: new Map([ + [publishable.name, publishable], + [temporal.name, temporal], + ]), + }; +} + +function withFieldsList(meta: ObjectMetadataWire): ObjectMetadataWire { + return { ...meta, fieldsList: meta.fields }; +} + +function json(status: number, body: unknown): Response { + return Response.json(body, { status }); +} + +function notFound(code: string, message: string): Response { + return json(404, { error: code, message, source: 'LocalDevMetadataStore' }); +} + +function badRequest(code: string, message: string): Response { + return json(400, { error: code, message, source: 'LocalDevMetadataStore' }); +} + +export function localDevMetadataSourceBanner(): Record { + return { + source: 'LocalDevMetadataStore', + oracle_class: 'local_process_memory', + substitution_allowed: true, + message: + 'Harness /rest/metadata unavailable; serving named local stand-in. Not production evidence.', + }; +} + +/** Test-only: clear the process-global stand-in. */ +export function resetLocalDevMetadataStore(): void { + const g = globalThis as typeof globalThis & { + [globalKey]?: StoreState; + }; + delete g[globalKey]; +} + +export async function handleLocalDevMetadata( + method: string, + segments: readonly string[], + bodyText: string | null, +): Promise { + const store = state(); + const verb = method.toUpperCase(); + let body: unknown = null; + if (bodyText && bodyText.trim() !== '') { + try { + body = JSON.parse(bodyText) as unknown; + } catch { + return badRequest('invalid_json', 'Request body must be JSON.'); + } + } + + // GET /rest/metadata/objects + if (segments.length === 1 && segments[0] === 'objects' && verb === 'GET') { + const edges = [...store.objects.values()].map((node) => ({ + node: { + ...withFieldsList(node), + conformances: node.conformances ?? [], + }, + })); + return json(200, { objects: { edges }, ...localDevMetadataSourceBanner() }); + } + + // POST /rest/metadata/objects + if (segments.length === 1 && segments[0] === 'objects' && verb === 'POST') { + const incoming = body as Partial; + const singular = String(incoming.nameSingular ?? '').trim(); + if (!singular) return badRequest('name_singular_required', 'nameSingular is required'); + if (store.objects.has(singular)) { + return json(409, { error: 'commonplace_metadata_exists', message: singular }); + } + const ts = now(); + const created: ObjectMetadataWire = withFieldsList({ + id: `object:${singular}`, + universalIdentifier: `local.${singular}`, + nameSingular: singular, + namePlural: String(incoming.namePlural ?? `${singular}s`), + labelSingular: String(incoming.labelSingular ?? singular), + labelPlural: String(incoming.labelPlural ?? `${singular}s`), + description: incoming.description, + isActive: incoming.isActive ?? true, + isSystem: false, + isUiEditable: true, + isUiCreatable: true, + isSearchable: incoming.isSearchable ?? true, + fields: incoming.fields ?? [], + indexMetadataList: [], + conformances: [], + createdAtMs: ts, + updatedAtMs: ts, + }); + store.objects.set(singular, created); + return json(201, { ...created, ...localDevMetadataSourceBanner() }); + } + + // GET/PATCH/DELETE /rest/metadata/objects/:name + if (segments.length === 2 && segments[0] === 'objects') { + const name = segments[1]!; + const existing = store.objects.get(name); + if (!existing) return notFound('commonplace_metadata_missing', name); + if (verb === 'GET') { + return json(200, { + ...withFieldsList(existing), + conformances: existing.conformances ?? [], + ...localDevMetadataSourceBanner(), + }); + } + if (verb === 'DELETE') { + store.objects.delete(name); + return new Response(null, { status: 204 }); + } + if (verb === 'PATCH') { + const patch = (body ?? {}) as Partial; + const next: ObjectMetadataWire = withFieldsList({ + ...existing, + labelSingular: patch.labelSingular ?? existing.labelSingular, + labelPlural: patch.labelPlural ?? existing.labelPlural, + description: patch.description ?? existing.description, + isActive: patch.isActive ?? existing.isActive, + isSearchable: patch.isSearchable ?? existing.isSearchable, + updatedAtMs: now(), + }); + store.objects.set(name, next); + return json(200, { ...next, ...localDevMetadataSourceBanner() }); + } + } + + // Facets + if (segments.length === 1 && segments[0] === 'facets' && verb === 'GET') { + return json(200, { + facets: [...store.facets.values()], + ...localDevMetadataSourceBanner(), + }); + } + if (segments.length === 1 && segments[0] === 'facets' && verb === 'POST') { + const facet = body as FacetDefWire; + if (!facet?.name) return badRequest('facet_name_required', 'name is required'); + if (facet.isSystem) { + return json(403, { error: 'facet_system_immutable', message: facet.name }); + } + if (store.facets.has(facet.name)) { + return json(409, { error: 'commonplace_metadata_exists', message: facet.name }); + } + store.facets.set(facet.name, facet); + return json(201, { ...facet, ...localDevMetadataSourceBanner() }); + } + if (segments.length === 2 && segments[0] === 'facets') { + const name = segments[1]!; + const facet = store.facets.get(name); + if (verb === 'GET') { + if (!facet) return notFound('facet_missing', name); + return json(200, { ...facet, ...localDevMetadataSourceBanner() }); + } + if (verb === 'DELETE') { + if (!facet) return notFound('facet_missing', name); + if (facet.isSystem) { + return json(403, { error: 'facet_system_immutable', message: name }); + } + store.facets.delete(name); + return new Response(null, { status: 204 }); + } + } + + // Conformances + if (segments.length === 3 && segments[0] === 'objects' && segments[2] === 'conformances') { + const objectName = segments[1]!; + const object = store.objects.get(objectName); + if (!object) return notFound('commonplace_metadata_missing', objectName); + if (verb === 'GET') { + return json(200, { + conformances: object.conformances ?? [], + ...localDevMetadataSourceBanner(), + }); + } + if (verb === 'POST') { + const incoming = body as Partial; + const facet = String(incoming.facet ?? '').trim(); + if (!facet) return badRequest('facet_required', 'facet is required'); + if (!store.facets.has(facet)) return notFound('facet_missing', facet); + const conformance: ObjectConformanceWire = { + objectNameSingular: objectName, + facet, + propertyMap: incoming.propertyMap ?? {}, + relationMap: incoming.relationMap ?? {}, + createdAtMs: now(), + }; + const nextConformances = [ + ...(object.conformances ?? []).filter((row) => row.facet !== facet), + conformance, + ]; + store.objects.set(objectName, { ...object, conformances: nextConformances, updatedAtMs: now() }); + return json(201, { ...conformance, ...localDevMetadataSourceBanner() }); + } + } + if ( + segments.length === 4 && + segments[0] === 'objects' && + segments[2] === 'conformances' && + verb === 'DELETE' + ) { + const objectName = segments[1]!; + const facet = segments[3]!; + const object = store.objects.get(objectName); + if (!object) return notFound('commonplace_metadata_missing', objectName); + store.objects.set(objectName, { + ...object, + conformances: (object.conformances ?? []).filter((row) => row.facet !== facet), + updatedAtMs: now(), + }); + return new Response(null, { status: 204 }); + } + + // Fields + if (segments.length === 1 && segments[0] === 'fields' && verb === 'POST') { + const incoming = body as Partial & { objectNameSingular?: string }; + const objectName = String(incoming.objectNameSingular ?? '').trim(); + const object = store.objects.get(objectName); + if (!object) return notFound('commonplace_metadata_missing', objectName); + const name = String(incoming.name ?? '').trim(); + if (!name) return badRequest('field_name_required', 'name is required'); + const created = field(objectName, name, String(incoming.label ?? name), incoming.type ?? 'TEXT', { + description: incoming.description, + isNullable: incoming.isNullable, + settings: incoming.settings ?? { isFilterable: false, isSortable: false }, + }); + const fields = [...object.fields.filter((row) => row.name !== name), created]; + store.objects.set(objectName, withFieldsList({ ...object, fields, updatedAtMs: now() })); + return json(201, { ...withFieldsList(store.objects.get(objectName)!), ...localDevMetadataSourceBanner() }); + } + if (segments.length === 3 && segments[0] === 'fields') { + const objectName = segments[1]!; + const fieldName = segments[2]!; + const object = store.objects.get(objectName); + if (!object) return notFound('commonplace_metadata_missing', objectName); + const existing = object.fields.find((row) => row.name === fieldName); + if (!existing) return notFound('commonplace_metadata_missing', `${objectName}.${fieldName}`); + if (verb === 'DELETE') { + const fields = object.fields.filter((row) => row.name !== fieldName); + store.objects.set(objectName, withFieldsList({ ...object, fields, updatedAtMs: now() })); + return json(200, { ...withFieldsList(store.objects.get(objectName)!), ...localDevMetadataSourceBanner() }); + } + if (verb === 'PATCH') { + if (existing.isSystem) { + return json(403, { + error: 'commonplace_metadata_system_type', + message: `${objectName}.${fieldName}`, + }); + } + const patch = (body ?? {}) as Partial; + const nextField: FieldMetadataWire = { + ...existing, + label: patch.label ?? existing.label, + description: patch.description ?? existing.description, + type: patch.type ?? existing.type, + isNullable: patch.isNullable ?? existing.isNullable, + isUnique: patch.isUnique ?? existing.isUnique, + settings: { + isFilterable: patch.settings?.isFilterable ?? existing.settings.isFilterable, + isSortable: patch.settings?.isSortable ?? existing.settings.isSortable, + displayAs: patch.settings?.displayAs ?? existing.settings.displayAs, + raw: patch.settings?.raw ?? existing.settings.raw, + }, + updatedAtMs: now(), + }; + const fields = object.fields.map((row) => (row.name === fieldName ? nextField : row)); + store.objects.set(objectName, withFieldsList({ ...object, fields, updatedAtMs: now() })); + return json(200, { ...withFieldsList(store.objects.get(objectName)!), ...localDevMetadataSourceBanner() }); + } + } + + // Indexes / promote + if (segments.length === 3 && segments[0] === 'indexes') { + const objectName = segments[1]!; + const fieldName = segments[2]!; + const object = store.objects.get(objectName); + if (!object) return notFound('commonplace_metadata_missing', objectName); + const target = object.fields.find((row) => row.name === fieldName); + if (!target) return notFound('commonplace_metadata_missing', `${objectName}.${fieldName}`); + if (verb === 'POST') { + const index: IndexMetadataWire = { + id: `index:${objectName}:${fieldName}`, + name: `${objectName}_${fieldName}_idx`, + indexType: 'BTREE', + isUnique: false, + isCustom: true, + trigger: { kind: 'user_marked' }, + indexFieldMetadataList: [{ fieldMetadataId: target.id, order: 0 }], + createdAtMs: now(), + }; + const indexes = [ + ...object.indexMetadataList.filter((row) => row.id !== index.id), + index, + ]; + const fields = object.fields.map((row) => + row.name === fieldName + ? { + ...row, + settings: { ...row.settings, isFilterable: true, isSortable: true }, + updatedAtMs: now(), + } + : row, + ); + store.objects.set( + objectName, + withFieldsList({ ...object, fields, indexMetadataList: indexes, updatedAtMs: now() }), + ); + return json(201, { ...index, ...localDevMetadataSourceBanner() }); + } + if (verb === 'DELETE') { + const indexes = object.indexMetadataList.filter( + (row) => !row.indexFieldMetadataList.some((entry) => entry.fieldMetadataId === target.id), + ); + store.objects.set(objectName, { + ...object, + indexMetadataList: indexes, + updatedAtMs: now(), + }); + return new Response(null, { status: 204 }); + } + } + + if (segments.length === 1 && segments[0] === 'promotion-candidates' && verb === 'GET') { + const candidates: PromotionCandidateWire[] = []; + for (const object of store.objects.values()) { + for (const fieldRow of object.fields) { + const promoted = object.indexMetadataList.some((index) => + index.indexFieldMetadataList.some((entry) => entry.fieldMetadataId === fieldRow.id), + ); + if (!promoted && !fieldRow.isSystem) { + candidates.push({ + objectNameSingular: object.nameSingular, + fieldName: fieldRow.name, + trigger: { kind: 'user_marked' }, + }); + } + } + } + return json(200, { candidates, ...localDevMetadataSourceBanner() }); + } + + return notFound('metadata_route_missing', `/${segments.join('/')}`); +} diff --git a/apps/console/src/lib/server/metadata-rest.ts b/apps/console/src/lib/server/metadata-rest.ts new file mode 100644 index 00000000..fa4038c4 --- /dev/null +++ b/apps/console/src/lib/server/metadata-rest.ts @@ -0,0 +1,83 @@ +// SOURCING: none. Server adapter for Twenty-shaped `/rest/metadata/*` on the +// harness node (rustyred-thg-server). Falls back to LocalDevMetadataStore. + +import 'server-only'; + +import { + principalTenantHeaders, + resolveHarnessPrincipal, +} from '@/lib/server/harness-principal'; +import { handleLocalDevMetadata } from '@/lib/server/local-dev-metadata-store'; + +function harnessMetadataBase(): string | null { + const override = process.env.CONSOLE_METADATA_URL?.trim(); + if (override) return override.replace(/\/$/, ''); + const harness = process.env.CONSOLE_HARNESS_URL?.trim(); + if (!harness) return null; + return harness.replace(/\/(?:mcp)?\/?$/, ''); +} + +export async function forwardMetadataRest( + method: string, + segments: readonly string[], + request: Request, +): Promise { + const path = `/rest/metadata/${segments.map(encodeURIComponent).join('/')}`; + const base = harnessMetadataBase(); + const url = new URL(request.url); + const search = url.search; // includes leading ? + + if (!base) { + return handleLocalDevMetadata(method, segments, await request.clone().text().catch(() => null)); + } + + const resolution = await resolveHarnessPrincipal(); + if (!resolution.ok) return resolution.response; + + const bodyText = + method === 'GET' || method === 'HEAD' || method === 'DELETE' + ? null + : await request.text(); + + let upstream: Response; + try { + upstream = await fetch(`${base}${path}${search}`, { + method, + headers: { + Accept: 'application/json', + ...(bodyText !== null ? { 'Content-Type': 'application/json' } : {}), + ...principalTenantHeaders(resolution.principal), + ...(process.env.CONSOLE_HARNESS_TOKEN + ? { Authorization: `Bearer ${process.env.CONSOLE_HARNESS_TOKEN}` } + : {}), + }, + body: bodyText, + cache: 'no-store', + }); + } catch { + return handleLocalDevMetadata(method, segments, bodyText); + } + + // Unconfigured / missing route → local stand-in so settings UI stays usable. + if (upstream.status === 502 || upstream.status === 503) { + return handleLocalDevMetadata(method, segments, bodyText); + } + if (upstream.status === 404) { + const text = await upstream.text(); + const listingObjects = + method === 'GET' && segments.length === 1 && segments[0] === 'objects'; + if (listingObjects || text.includes('console_harness')) { + return handleLocalDevMetadata(method, segments, bodyText); + } + return new Response(text, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' }, + }); + } + + const responseBody = await upstream.text(); + return new Response(responseBody, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' }, + }); +} diff --git a/apps/console/src/lib/surface-routes.ts b/apps/console/src/lib/surface-routes.ts index c0754407..691a7d86 100644 --- a/apps/console/src/lib/surface-routes.ts +++ b/apps/console/src/lib/surface-routes.ts @@ -23,6 +23,11 @@ const AUXILIARY_ROUTES = [ { kind: 'automation', path: '/automation', surfaceId: 'console-automation', tier: 'place' as const }, { kind: 'program', path: '/program', surfaceId: 'console-program', tier: 'place' as const }, { kind: 'appearance', path: '/appearance', surfaceId: 'console-appearance', tier: 'place' as const }, + { kind: 'goals', path: '/goals', surfaceId: 'console-goals', tier: 'place' as const }, + { kind: 'search', path: '/search', surfaceId: 'console-search', tier: 'place' as const }, + { kind: 'kanban', path: '/kanban', surfaceId: 'console-kanban', tier: 'place' as const }, + { kind: 'commands', path: '/commands', surfaceId: 'console-commands', tier: 'place' as const }, + { kind: 'model-settings', path: '/Data-model/settings', surfaceId: 'console-model-settings', tier: 'place' as const }, ] as const; /** Surfaces composed by an App Router page. Blocks never own these routes. */ diff --git a/apps/console/src/lib/view-routing-retirement.test.ts b/apps/console/src/lib/view-routing-retirement.test.ts index 94a23e1f..2d51a063 100644 --- a/apps/console/src/lib/view-routing-retirement.test.ts +++ b/apps/console/src/lib/view-routing-retirement.test.ts @@ -24,10 +24,16 @@ describe('page-owned routing', () => { expect(pathForSurfaceId('console-models')).toBe('/Data-model'); expect(pathForSurfaceId('console-ide')).toBe('/IDE'); expect(pathForSurfaceId('console-program')).toBe('/program'); + expect(pathForSurfaceId('console-goals')).toBe('/goals'); + expect(pathForSurfaceId('console-search')).toBe('/search'); + expect(pathForSurfaceId('console-kanban')).toBe('/kanban'); + expect(pathForSurfaceId('console-commands')).toBe('/commands'); expect(surfaceIdForPath('/Data-model')).toBe('console-models'); expect(surfaceIdForPath('/IDE')).toBe('console-ide'); expect(surfaceIdForPath('/program')).toBe('console-program'); expect(surfaceIdForPath('/appearance')).toBe('console-appearance'); + expect(surfaceIdForPath('/goals')).toBe('console-goals'); + expect(surfaceIdForPath('/search')).toBe('console-search'); }); it('migrates known persisted view routes and refuses unknown ones', () => { diff --git a/apps/console/src/lib/workspace-seed.ts b/apps/console/src/lib/workspace-seed.ts index e830ad05..f3be0d29 100644 --- a/apps/console/src/lib/workspace-seed.ts +++ b/apps/console/src/lib/workspace-seed.ts @@ -22,6 +22,8 @@ export const SURVEY_SURFACE_ID = 'console-survey'; export const SURVEY_VIEW_INSTANCE_ID = 'survey.vi-board'; export const MODEL_SURFACE_ID = 'console-models'; export const MODEL_VIEW_INSTANCE_ID = 'models.vi-studio'; +export const MODEL_SETTINGS_SURFACE_ID = 'console-model-settings'; +export const MODEL_SETTINGS_VIEW_INSTANCE_ID = 'models.vi-settings'; export const PROGRAM_SURFACE_ID = 'console-program'; export const PROGRAM_VIEW_INSTANCE_ID = 'program.vi-canvas'; export const CONSOLE_DATA_SURFACE_ID = 'console-your-data'; @@ -208,10 +210,10 @@ export function seedLayout(): ObjectRef[] { ...companionSeeds('workspace', false, ['thread']), layoutObject('console-goals', 'surface', { - name: 'Goal Stack', kind: 'goals', role: 'surface', active: false, seed_revision: 4, + name: 'Goal Stack', kind: 'goals', role: 'place', active: false, seed_revision: 5, }, ['goals.region-editor', ...companionIds('goals')]), layoutObject('goals.region-editor', 'region', { - kind: 'editor', size: 100, active_tab: 'goals.vi-stack', seed_revision: 3, + kind: 'editor', chrome: 'bare', size: 100, active_tab: 'goals.vi-stack', seed_revision: 4, }, ['goals.vi-stack']), layoutObject('goals.vi-stack', 'view-instance', { descriptor_id: 'goal.stack', title: 'Goal Stack', @@ -290,10 +292,10 @@ export function seedLayout(): ObjectRef[] { ...companionSeeds('survey'), layoutObject(MODEL_SURFACE_ID, 'surface', { - name: 'Models', kind: 'model', role: 'place', stripe_order: 4, active: false, seed_revision: 2, + name: 'Models', kind: 'model', role: 'place', stripe_order: 4, active: false, seed_revision: 3, }, ['models.region-editor', ...companionIds('models')]), layoutObject('models.region-editor', 'region', { - kind: 'editor', chrome: 'bare', size: 100, active_tab: MODEL_VIEW_INSTANCE_ID, seed_revision: 2, + kind: 'editor', chrome: 'bare', size: 100, active_tab: MODEL_VIEW_INSTANCE_ID, seed_revision: 3, }, [MODEL_VIEW_INSTANCE_ID]), layoutObject(MODEL_VIEW_INSTANCE_ID, 'view-instance', { descriptor_id: 'model.studio', @@ -313,6 +315,18 @@ export function seedLayout(): ObjectRef[] { }), ...companionSeeds('models'), + layoutObject(MODEL_SETTINGS_SURFACE_ID, 'surface', { + name: 'Model settings', kind: 'model-settings', role: 'place', active: false, seed_revision: 1, + }, ['model-settings.region-editor', ...companionIds('model-settings')]), + layoutObject('model-settings.region-editor', 'region', { + kind: 'editor', chrome: 'bare', size: 100, active_tab: MODEL_SETTINGS_VIEW_INSTANCE_ID, seed_revision: 1, + }, [MODEL_SETTINGS_VIEW_INSTANCE_ID]), + layoutObject(MODEL_SETTINGS_VIEW_INSTANCE_ID, 'view-instance', { + descriptor_id: 'model.settings', + title: 'Model settings', + }), + ...companionSeeds('model-settings'), + layoutObject(PROGRAM_SURFACE_ID, 'surface', { name: 'Program', kind: 'program', role: 'place', active: false, seed_revision: 1, }, ['program.region-editor', ...companionIds('program')]), @@ -324,6 +338,39 @@ export function seedLayout(): ObjectRef[] { }), ...companionSeeds('program'), + layoutObject('console-search', 'surface', { + name: 'Search', kind: 'search', role: 'place', active: false, seed_revision: 1, + }, ['search.region-editor', ...companionIds('search')]), + layoutObject('search.region-editor', 'region', { + kind: 'editor', chrome: 'bare', size: 100, active_tab: 'search.vi-stack', seed_revision: 1, + }, ['search.vi-stack']), + layoutObject('search.vi-stack', 'view-instance', { + descriptor_id: 'search.stack', title: 'Search', + }), + ...companionSeeds('search'), + + layoutObject('console-kanban', 'surface', { + name: 'Kanban', kind: 'kanban', role: 'place', active: false, seed_revision: 1, + }, ['kanban.region-editor', ...companionIds('kanban')]), + layoutObject('kanban.region-editor', 'region', { + kind: 'editor', chrome: 'bare', size: 100, active_tab: 'kanban.vi-board', seed_revision: 1, + }, ['kanban.vi-board']), + layoutObject('kanban.vi-board', 'view-instance', { + descriptor_id: 'kanban', title: 'Kanban', + }), + ...companionSeeds('kanban'), + + layoutObject('console-commands', 'surface', { + name: 'Commands', kind: 'commands', role: 'place', active: false, seed_revision: 1, + }, ['commands.region-editor', ...companionIds('commands')]), + layoutObject('commands.region-editor', 'region', { + kind: 'editor', chrome: 'bare', size: 100, active_tab: 'commands.vi-gallery', seed_revision: 1, + }, ['commands.vi-gallery']), + layoutObject('commands.vi-gallery', 'view-instance', { + descriptor_id: 'commands.gallery', title: 'Commands', + }), + ...companionSeeds('commands'), + layoutObject('console-docs', 'surface', { name: 'Documents', kind: 'documents', role: 'collection', active: false, seed_revision: 3, }, ['docs.region-list', 'docs.region-editor', ...companionIds('docs')]), diff --git a/apps/console/src/views/CommandsGalleryView.tsx b/apps/console/src/views/CommandsGalleryView.tsx index c4cea916..deecbe22 100644 --- a/apps/console/src/views/CommandsGalleryView.tsx +++ b/apps/console/src/views/CommandsGalleryView.tsx @@ -1,60 +1,24 @@ 'use client'; // SOURCING: cmdk Command for searchable published-command / monitor-template -// gallery (SPEC-COMMONPLACE-COMMANDS-AND-SENTINELS-1.0 D5). Fork is the -// primary action; validation receipts are listed inline. +// gallery (SPEC-COMMONPLACE-COMMANDS-AND-SENTINELS-1.0 D5). Fork goes through +// programmable_graph `gallery` / `gallery_fork` (not orphan program.fork). -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Command } from 'cmdk'; import type { ViewRenderProps } from '@commonplace/block-view/types'; import { ViewState } from './ViewStates'; +import { + fetchCommandGallery, + forkGalleryTemplate, + type CommandGalleryEntry, +} from './program/programClient'; -export type GalleryKind = 'command' | 'monitor_template'; - -export interface GalleryEntry { - readonly kind: GalleryKind; - readonly slugOrName: string; - readonly title: string; - readonly summary: string; - readonly programId?: string; - readonly publicationRef?: string; - readonly parentProgramId?: string; - readonly validationPassed?: boolean; - readonly validationChecks?: readonly string[]; -} - -const FIXTURE_GALLERY: readonly GalleryEntry[] = [ - { - kind: 'monitor_template', - slugOrName: 'Price watch', - title: 'Price watch', - summary: 'Watch a product page price and notify when it crosses a threshold.', - programId: 'program:price-watch', - validationPassed: true, - validationChecks: ['program_identity', 'standing_budget', 'typed_stream_edges'], - }, - { - kind: 'monitor_template', - slugOrName: 'Content watch', - title: 'Content watch', - summary: 'Watch a page body and notify when the content hash changes.', - programId: 'program:content-watch', - validationPassed: true, - validationChecks: ['program_identity', 'standing_budget'], - }, - { - kind: 'monitor_template', - slugOrName: 'Release watch', - title: 'Release watch', - summary: 'Watch a releases or changelog URL and notify plus capture on change.', - programId: 'program:release-watch', - validationPassed: true, - validationChecks: ['program_identity', 'standing_budget'], - }, -]; +export type GalleryKind = CommandGalleryEntry['kind']; +export type GalleryEntry = CommandGalleryEntry; function entriesFromHost(set: ViewRenderProps['set']): GalleryEntry[] { - const fromHost = set.objects + return set.objects .filter((object) => object.properties.galleryKind || object.properties.kind === 'monitor_template') .map((object) => ({ kind: (String(object.properties.galleryKind ?? 'monitor_template') as GalleryKind), @@ -77,15 +41,74 @@ function entriesFromHost(set: ViewRenderProps['set']): GalleryEntry[] { validationChecks: Array.isArray(object.properties.validationChecks) ? object.properties.validationChecks.map(String) : [], + source: 'substrate' as const, })); - return fromHost.length > 0 ? fromHost : [...FIXTURE_GALLERY]; } -export function CommandsGalleryView({ set, host }: ViewRenderProps) { - const entries = useMemo(() => entriesFromHost(set), [set]); +export function CommandsGalleryView({ set }: ViewRenderProps) { + const hostEntries = useMemo(() => entriesFromHost(set), [set]); + const [entries, setEntries] = useState(hostEntries); + const [sourceNote, setSourceNote] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); const [forked, setForked] = useState([]); - if (entries.length === 0) { + useEffect(() => { + let cancelled = false; + void (async () => { + setBusy(true); + setError(null); + try { + if (hostEntries.length > 0) { + if (!cancelled) { + setEntries(hostEntries); + setSourceNote('Host objects'); + } + return; + } + const gallery = await fetchCommandGallery(); + if (cancelled) return; + setEntries(gallery); + const fromStandIn = gallery.some((entry) => entry.source === 'LocalDevCommandGallery'); + setSourceNote( + fromStandIn + ? 'LocalDevCommandGallery stand-in (substrate gallery empty or unreachable)' + : 'programmable_graph gallery', + ); + } catch (loadError) { + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : String(loadError)); + } + } finally { + if (!cancelled) setBusy(false); + } + })(); + return () => { + cancelled = true; + }; + }, [hostEntries]); + + async function forkEntry(entry: GalleryEntry): Promise { + if (entry.kind !== 'monitor_template') return; + const parentProgramId = entry.programId ?? entry.slugOrName; + const forkName = `Fork of ${entry.title}`; + setBusy(true); + setError(null); + try { + const program = await forkGalleryTemplate({ + parentProgramId, + name: forkName, + intent: entry.summary || `Fork of ${entry.title}`, + }); + setForked((current) => [...current, program.name || forkName]); + } catch (forkError) { + setError(forkError instanceof Error ? forkError.message : String(forkError)); + } finally { + setBusy(false); + } + } + + if (!busy && entries.length === 0) { return ( Published commands and forkable monitor templates. Validation receipts stay visible.

+ {sourceNote ?

{sourceNote}

: null} + {error ?

{error}

: null} - No published entries + {busy ? 'Loading gallery…' : 'No published entries'} {entries.map((entry) => ( { - if (entry.kind !== 'monitor_template') { - return; - } - const forkName = `Fork of ${entry.title}`; - setForked((current) => [...current, forkName]); - void host.emit({ - kind: 'invoke_tool', - tool: 'program.fork', - args: { - parentProgramId: entry.programId ?? '', - name: forkName, - }, - }); + void forkEntry(entry); }} >
@@ -147,6 +161,7 @@ export function CommandsGalleryView({ set, host }: ViewRenderProps) { {entry.publicationRef ? pub {entry.publicationRef} : null} {entry.parentProgramId ? lineage {entry.parentProgramId} : null} {entry.kind === 'monitor_template' ? Fork : null} + {entry.source === 'LocalDevCommandGallery' ? stand-in : null}
))} @@ -154,7 +169,7 @@ export function CommandsGalleryView({ set, host }: ViewRenderProps) {
{forked.length > 0 ? (

- Forked: {forked.join(', ')} + Forked via gallery_fork: {forked.join(', ')}

) : null} diff --git a/apps/console/src/views/RecordTableView.tsx b/apps/console/src/views/RecordTableView.tsx index c4e75baa..a5f8442e 100644 --- a/apps/console/src/views/RecordTableView.tsx +++ b/apps/console/src/views/RecordTableView.tsx @@ -263,9 +263,16 @@ export function RecordTableView({ set: initialSet, host, instance }: ViewRenderP return; } const note = receipt.value?.note ?? ''; - if (/reject|enforcement|refused/i.test(note)) { + const code = + typeof receipt.value?.code === 'string' + ? receipt.value.code + : typeof (receipt.value as { refusal?: { code?: string } } | undefined)?.refusal + ?.code === 'string' + ? (receipt.value as { refusal: { code: string } }).refusal.code + : null; + if (code || /reject|enforcement|refused|validated|put_item_validated/i.test(note)) { setSchemaRowProperty(rowId, fieldKey, previous); - setEditError(note); + setEditError(code ? `${code}: ${note || 'validated write refused'}` : note || 'Update refused'); setCellFocus((current) => (current ? { ...current, mode: 'soft' } : current)); return; } diff --git a/apps/console/src/views/model/ModelView.tsx b/apps/console/src/views/model/ModelView.tsx index a9957142..8ce3dd1e 100644 --- a/apps/console/src/views/model/ModelView.tsx +++ b/apps/console/src/views/model/ModelView.tsx @@ -4,6 +4,7 @@ // @xyflow/react and tablecn structure through the registered lens components. import { useCallback, useEffect, useReducer, useRef, useState, type FormEvent } from 'react'; +import Link from 'next/link'; import type { ViewRenderProps } from '@commonplace/block-view/types'; import { emptyDeclaredModel, @@ -19,7 +20,6 @@ import { type ScopeRef, } from '@commonplace/data-model-contracts'; import { DiffDialog, diffGraphs, type ModelGraph } from '@commonplace/model-canvas'; -import { BlockShell } from '@/components/block/BlockShell'; import { degradationFor, withAction } from '@/lib/degradation'; import { exportOkfModel, @@ -34,17 +34,12 @@ import { type OkfModelPreviewPayload, } from '@/lib/observed-model-client'; import { WhyTrace } from '../harness-ux/WhyTracePanel'; -import { - DiagramLens, - FieldsTableLens, -} from './ObservedDeclaredLenses'; -import { RecordsLens } from './RecordsLens'; +import { DiagramLens } from './ObservedDeclaredLenses'; import type { LayoutPositions } from './diagram/layout'; import { createModelQueryState, modelScopeFromSet, reduceModelQuery, - type ModelLens, type ModelSelection, } from './modelQuery'; import { modelCanvasId } from '@/lib/canvas/store'; @@ -53,7 +48,7 @@ import { declaredToModelGraph, parseOkfBundle, } from './okfBridge'; -import { schemaDeclareInputForField } from './schemaDeclare'; +import { schemaDeclareInputForField, schemaDeclareInputForNewObject } from './schemaDeclare'; import { UNKNOWN_REGISTRY_SIGNAL, registryMoved, @@ -406,7 +401,6 @@ function ModelInspector({ ); } -const LENSES: readonly ModelLens[] = ['diagram', 'fields', 'records']; const LAYOUT_PERSIST_MS = 400; /** Fallback heartbeat for registry changes made outside this client. */ const REGISTRY_SIGNAL_MS = 15_000; @@ -456,12 +450,22 @@ export function ModelView({ set, host }: ViewRenderProps) { }); }, [setScopeTenant, setScopeTopicId]); + const registrySignalRef = useRef(UNKNOWN_REGISTRY_SIGNAL); + const hasPaintedModelRef = useRef(false); + + useEffect(() => { + hasPaintedModelRef.current = + declared.objectTypes.length > 0 || observed.types.length > 0; + }, [declared.objectTypes.length, observed.types.length]); + useEffect(() => { if (!topicId) return; let active = true; void Promise.resolve().then(async () => { if (!active) return; - setLoading(true); + // Soft refresh: keep the current canvas mounted while re-reading. A hard + // loading flash during drag/registry polls is what made nodes "vanish". + if (!hasPaintedModelRef.current) setLoading(true); setError(null); setNotice(null); try { @@ -491,8 +495,6 @@ export function ModelView({ set, host }: ViewRenderProps) { // and PR 385's versioned projections are how we notice: re-read the head // version on the same subscription tick and rehydrate only when the anchor // actually moved, so an unchanged registry costs one comparison. - const registrySignalRef = useRef(UNKNOWN_REGISTRY_SIGNAL); - useEffect(() => { if (!declared) return; registrySignalRef.current = registrySignal(declared); @@ -570,7 +572,9 @@ export function ModelView({ set, host }: ViewRenderProps) { return () => { active = false; }; - }, [layoutHost, topicId, reloadToken]); + // Layout is topic-scoped, not registry-version-scoped. Reloading it on every + // reloadToken races drag persistence and snaps nodes away mid-gesture. + }, [layoutHost, topicId]); async function persistLayout(positions: LayoutPositions): Promise { pendingLayoutRef.current = null; @@ -668,6 +672,38 @@ export function ModelView({ set, host }: ViewRenderProps) { } } + async function spawnObject(position: { x: number; y: number }): Promise { + if (!topicId || proposalBusy) return; + setProposalBusy(true); + setError(null); + try { + const ordinal = declared.objectTypes.length + 1; + const result = await postSchemaDeclare( + topicId, + schemaDeclareInputForNewObject(ordinal), + host, + ); + setDeclared(result.declared); + const layoutKey = `declared:${result.receipt.objectTypeId}`; + scheduleLayoutPersist({ + ...layoutPositions, + [layoutKey]: position, + }); + setNotice(result.receipt.status === 'declared' + ? `${result.receipt.objectTypeId} declared on the canvas.` + : 'Object type declaration recorded.'); + setReloadToken((token) => token + 1); + dispatch({ + type: 'select', + selection: { kind: 'declared-type', key: result.receipt.objectTypeId }, + }); + } catch (spawnError) { + setError(spawnError instanceof Error ? spawnError.message : String(spawnError)); + } finally { + setProposalBusy(false); + } + } + async function applyFieldEdit( fieldId: string, replacement: FieldMetadata, @@ -830,6 +866,8 @@ export function ModelView({ set, host }: ViewRenderProps) { const leftVersion = versionById.get(leftVersionId); const rightVersion = versionById.get(rightVersionId); + const okfFileInputRef = useRef(null); + const lensProps = { observed, declared, @@ -849,6 +887,49 @@ export function ModelView({ set, host }: ViewRenderProps) { onLayoutChange: (positions: LayoutPositions) => { scheduleLayoutPersist(positions); }, + modelName: topicId ? `topic:${topicId}` : 'Data model', + onImport: () => okfFileInputRef.current?.click(), + onExport: () => { + void exportOkf(); + }, + onDeclare: () => { + const ghost = observed.types.find( + (type) => !declared.objectTypes.some( + (objectType) => objectType.provenance?.observedKey === type.observedKey, + ), + ); + if (ghost) void applyPin(ghost.observedKey, 'type'); + }, + declareDisabled: proposalBusy || observed.types.length === 0, + onSpawnObject: (position) => { + void spawnObject(position); + }, + onCompareVersion: (id: string) => { + const current = declared.versions.at(-1)?.id ?? ''; + setDiffVersionIds([id, current]); + setDiffOpen(true); + }, + onRestoreVersion: (id: string) => { + void (async () => { + if (!topicId) return; + setDiffVersionIds([diffVersionIds[0], id]); + setProposalBusy(true); + setError(null); + try { + const result = await postSchemaRestore(topicId, id, host); + setDeclared(result.declared); + setReloadToken((token) => token + 1); + const version = declared.versions.find((row) => row.id === id); + setNotice( + `Restored schema ${String(version?.version ?? id)} as a new receipted declaration batch.`, + ); + } catch (restoreError) { + setError(restoreError instanceof Error ? restoreError.message : String(restoreError)); + } finally { + setProposalBusy(false); + } + })(); + }, }; const unavailable = !topicId ? degradationFor('observed_model_scope_unavailable', 400) @@ -857,234 +938,85 @@ export function ModelView({ set, host }: ViewRenderProps) { : null; return ( -
- topic:{topicId} : 'No topic selected'} - count={`${observed.eventCount} events`} - degradation={unavailable} - controlRow={( -
-
- {LENSES.map((lens) => ( - - ))} -
- +
+
+ + Settings register + + + Object types · fields · indexes · facet conformance + +
+ void previewOkfImport(event.target.files)} + /> + {unavailable ? ( +
+ {unavailable.cause} + {unavailable.level === 'unavailable' && unavailable.action ? ( + + ) : null} +
+ ) : null} + {proposal ? ( + void acceptProposal()} + onDecline={() => setProposal(null)} + /> + ) : null} + {okfPreview ? ( +
+
+

+ OKF dry run: {okfPreview.diff.tables.added.length} tables added, + {' '}{okfPreview.diff.tables.removed.length} removed, and + {' '}{okfPreview.diff.fields.length} field groups changed. +

+ - {declared.versions.length >= 2 ? ( -
- - - - -
- ) : null} -
- )} - className="bg-transparent text-ij-ink" - > -
-
-
- {proposalComposerOpen ? ( -
void requestProposal(event)}> -