Skip to content

Local Capabilities v1: desktop control plane + gx CLI#30

Merged
jpablo merged 6 commits into
viewerfrom
local-capabilities-v2
May 18, 2026
Merged

Local Capabilities v1: desktop control plane + gx CLI#30
jpablo merged 6 commits into
viewerfrom
local-capabilities-v2

Conversation

@jpablo

@jpablo jpablo commented May 18, 2026

Copy link
Copy Markdown
Owner

Local Capabilities v1

Adds the local desktop integration surface so external tools / editors can drive Graph Explorer on the local machine.

What this adds

  • desktop/ — Tauri control plane: loopback HTTP API with bearer-token auth, runtime discovery file (~/.graph-explorer/runtime/control.json).
  • gx/ — CLI: status / watch / unwatch / get / set with revision-safe writes (stale write → exit 5 / DOCUMENT_CONFLICT).
  • local-protocol/ — versioned v1 schema for watch/document/status/events.
  • Two-way sync: filesystem watch loop (debounced/coalesced) → document.changed pushed into the webview; UI save bridge (Cmd/Ctrl+S) → revision-checked write.
  • Policy hardening: allowlist (GX_ALLOWED_ROOTS), default denylist + GX_DENY_ROOTS, request body/rate limits, metadata-only JSONL audit log.

Build / CI

  • ScalablyTyped facades generated in-build by ScalablyTypedConverterExternalNpmPlugin — no pinned content hashes, reproducible across local and CI by construction (npm install must precede sbt viewer tasks).
  • Desktop built with --features tauri/custom-protocol; CI has a guard that fails if it ever regresses to a dev build (the blank-window class of bug).
  • Cross-platform release packaging matrix (macOS/Linux/Windows) + runtime smokes.
  • LC2-T5: disk→UI latency gate — external edit reaches the webview-emit boundary in ≤300 ms median (CI: Linux ~118 ms, macOS ~248 ms).

Status

  • CI: green on this exact structure (build-frontend + Rust smoke + 3-OS packaging).
  • Go/no-go (docs/local-capabilities-v1-go-no-go.md): Go for macOS/Linux; No-go for Windows pending a known path-normalization defect (gx get → exit 4 on windows-latest) — Windows runtime smoke is continue-on-error and the defect is documented as the named residual risk.

Notes for reviewers

  • Targets mermaid-unify-sync-async (the main dev line); 6 commits, scoped to the LC delta.
  • Docs: docs/local-capabilities-v1-quickstart.md, -go-no-go.md, -implementation-plan.md.
  • Parked follow-up: Windows path-normalization bug; longer-term, native FS events instead of the polling watcher for tighter latency.

jpablo added 6 commits May 17, 2026 16:32
Replace the 7 pinned org.scalablytyped hashes with in-build facade
generation: enable ScalablyTypedConverterExternalNpmPlugin and set
stIgnore (node, dot-parser, @scala-js/vite-plugin-scalajs, mermaid,
uuid). Pinned ST hashes are not reproducible across environments
(stc backend/Scala.js differ local vs CI -> three different hashes
observed); generating in-build from node_modules + the project's
pinned sbt-converter is reproducible by construction. Scala.js stays
1.20.1 (plugin compiles facades with the project's own Scala.js).

WIP checkpoint -- frontend does NOT compile yet, two open items:
  1. base mermaid-unify-sync-async references MermaidEdgeLabelFallback
     but its defining shared/ file was never committed (commit 99c31c4);
     must be committed onto the base.
  2. desktop bridge (Viewer.scala) still uses removed ViewerState
     sourceTextWriter/sourceTextNow; needs adapting to phases.sourceText.
'viewer/update' (ST resolution) succeeds; Rust desktop+gx + smokes pass.

test(LC2-T5): disk->UI latency integration test + watcher tuning

Adds scripts/local-capabilities-disk-to-ui-smoke.sh: drives a real
external file edit and measures latency to the watcher revision
bump (same critical section as the webview document.changed emit),
over 15 samples, asserting <=300ms median and logging every sample.

The original watcher (100ms poll / 120ms debounce) only marginally
met the budget (median ~292ms, p95 354ms) -> flaky on slower CI.
Tuned spawn_watch_loop to 30ms poll / 75ms debounce via named
constants: local median drops to ~168ms (p95 175ms), comfortably
under budget while still coalescing multi-write editor saves.

Wired into CI as a blocking step on macOS/Linux (Windows excluded
while its path bug is parked). Updates implementation plan (LC2-T5
done, LC2-T2 tuning noted) and go/no-go evidence.

ci: make Windows runtime smoke non-blocking; document residual risk

Windows builds a correct production desktop, but its runtime smoke
fails on a known gx/desktop path-normalization defect (gx get -> exit
4 while watch succeeds). Mark the Windows smoke step continue-on-error
so CI is green for everything validated (frontend + 3-OS prod builds +
macOS/Linux runtime). Update the go/no-go: macOS/Linux Go, Windows
No-go pending the path fix, with the defect named as a residual risk
and the earlier (dev-build) macOS evidence corrected.

build: bump sbt-scalajs 1.19.0 -> 1.20.1 to match stc-generated IR

CI's stc generates ScalablyTyped facades with Scala.js 1.20 IR; the
project linked with 1.19 (IRVersionNotSupportedException). Align the
project's Scala.js to the toolchain CI's stc uses.

build: repin ScalablyTyped facades to stc-generated hashes

The previous org.scalablytyped pins (6.34.1-7b155b etc.) were generated
with an old toolchain and cannot be reproduced by modern stc, so no
build (CI, dev.yml, release.yml) could resolve them. Repin to the
hashes stc generates now; CI generates them in the build-frontend job.

Caveat: these are coupled to the stc/Scala.js + npm versions used to
generate them; regenerate+repin together if those change.

ci: generate ScalablyTyped facades (stc) before Scala.js compile

The org.scalablytyped:* deps in build.sbt are hash-versioned facades
generated locally by stc into ~/.ivy2/local (the converter plugin is
deliberately disabled; see scripts/build-viewer-netlify.sh). Without
stc they 'not found' on Maven. Install stc via coursier/setup-action
and run the canonical stc generation command after npm install.

ci: install sbt via sbt/setup-sbt (not preinstalled on runners)

ci: build frontend bundle and embed it in the packaging matrix

The prod desktop build embeds frontendDist (../../dist) via
generate_context!, but dist/ is gitignored and CI only set up Rust --
so the packaging job had no frontend to embed (proc-macro panic:
"frontendDist ... doesn't exist"). This was masked while the build
was a dev build.

Add a build-frontend job (Node 18 + JDK 17 + sbt viewer/fullLinkJS +
npm run build) that uploads dist/ as an artifact; release-packaging
now `needs` it and downloads dist/ before the desktop build.

ci: fix windows packaging + guard against dev-mode desktop builds

#1 Windows build failed: tauri-build requires icons/icon.ico for the
Windows resource. Add a 7-resolution icon.ico (generated from the
existing icon.png) and wire bundle.icon in tauri.conf.json.

#2 CI built the desktop without --features tauri/custom-protocol, so
even the green macOS/Linux jobs were validating a dev-mode binary that
never loads the UI. Build with the feature.

#3 Add a deterministic guard: cargo clean -p + -vv build, fail the job
if the build script emits cargo:rustc-cfg=dev. A blank-window / dev-mode
regression now fails CI instead of passing silently.

fix: build desktop with tauri/custom-protocol so it loads embedded UI

Root cause of the blank desktop window: tauri's build script sets
`dev = !custom-protocol`. Built via bare `cargo build --release`
(this project has no Tauri CLI), the binary was a *dev* build that
loaded devUrl (http://localhost:5173) instead of the embedded
frontendDist. With no vite dev server the window was a blank failed
navigation -- no index.html, no console, no stderr.

- build.sbt: add --features tauri/custom-protocol to the desktop
  cargo build in buildLocalCapabilitiesRelease.
- quickstart: same flag in the manual commands + a Troubleshooting
  entry (verify with `cargo build -v | grep -- '--cfg dev'`).

Also retains the earlier robustness fix: force the desktop
entrypoint mtime so a frontend-only dist/ change re-embeds (cargo
skips when no Rust source changed).

fix: refresh desktop bundle and auto-detect bridge format

ci: add runtime smoke steps for linux and windows

ci: add cross-platform release packaging matrix

docs: add local capabilities quickstart and go-no-go review

docs: record macOS release packaging smoke progress

feat: add request limits and structured audit logging

feat: enforce watch allowlist and default denylist

feat: add desktop save bridge and phase3 smoke flow

docs: mark completed tasks with checkmarks and green diagram nodes

feat: add revision-safe document get/set APIs and gx commands

feat: add debounced file watch loop for desktop sync

feat: add desktop text bridge and watch lifecycle api

feat: scaffold local desktop control plane and gx cli

docs: add local capabilities v1 architecture and plan
After rebasing onto the InternalPhases refactor, ViewerState no longer
exposes sourceTextWriter/sourceTextNow; rewire the desktop bridge to
the sourceText Var (set / now()).

Also stop running 'npm install' inside the externalNpm setting: every
viewer sbt task evaluates it, and when the build is driven by
'npm run build' -> @scala-js/vite-plugin-scalajs -> sbt, the nested
install rewrites node_modules out from under the running vite process
(build dies with a missing vite chunk). Dev/CI install deps before
building; ScalablyTyped just needs the node_modules path.
CI build-frontend: remove coursier/setup-action + the standalone
'Generate ScalablyTyped facades' (stc) step. ExternalNpm generates
facades in-build during viewer/fullLinkJS; keep 'npm install' before
it (the externalNpm setting no longer auto-installs).

Docs: go/no-go evidence + residual-risk now describe in-build facade
generation (no pinned hashes); quickstart manual commands add the
required 'npm install' and the note reflects ExternalNpm + the
externalNpm no-install requirement.
ScalablyTyped (ExternalNpm) reads node_modules during the fullLinkJS
that npm run build triggers, and the externalNpm setting no longer
auto-installs -- so the task needs deps installed. Prepend
npm install so the one-liner is self-sufficient on a clean checkout.
Verified end-to-end: install -> build -> desktop(prod) -> gx, success.
LC2-T5 was razor-thin on the slow CI runner (median 298ms / 300ms,
p95 454ms) because the poll loop spawned 'gx get' every iteration --
process-spawn cost is measurement overhead, not disk->UI latency.
Poll the control API directly with curl (read port/token from
control.json; endpoint nests revision under .document.revision),
tighter 5ms sleep. Local median ~166ms, low variance; CI margin
restored without weakening the <=300ms spec.

Also set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at workflow level to
clear the Node 20 deprecation warning (checkout/setup-*/artifact
actions still ship Node 20).
LC2-T5 was still thin on the slow macOS CI runner (median 278/300,
p95 476) after the curl hardening -- the dominant cost is the
watcher's fixed sleeps, not measurement. Lower poll 30->15ms and
debounce 75->50ms. Local median 166 -> 121ms (tight 80-127); the
-25ms debounce cut is a fixed win independent of runner contention.
50ms still coalesces multi-write editor saves. Doc updated.
@jpablo jpablo changed the base branch from mermaid-unify-sync-async to viewer May 18, 2026 00:59
@jpablo jpablo merged commit 98f529e into viewer May 18, 2026
10 checks passed
@jpablo jpablo deleted the local-capabilities-v2 branch May 18, 2026 01:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant